diff --git a/.claude/skills/gns3-api-test-writing/SKILL.md b/.claude/skills/gns3-api-test-writing/SKILL.md index 991df03f2..a43bab40c 100644 --- a/.claude/skills/gns3-api-test-writing/SKILL.md +++ b/.claude/skills/gns3-api-test-writing/SKILL.md @@ -33,7 +33,7 @@ from gns3server.services import auth_service from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY) -response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user +response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401 ``` diff --git a/.coveragerc b/.coveragerc index b824f504d..9f66baaf0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,9 @@ +[run] +source = gns3server +branch = True + +[report] +fail_under = 55 + [paths] source = gns3server diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index d0bfa4f1a..42916ecf5 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -21,26 +21,23 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: pip + - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[ai-features,dev] + - name: Install sharkd (marker replay engine) run: | sudo apt-get update sudo apt-get install -y wireshark-common - - name: Lint with flake8 + - name: Run all checks run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - python -m pytest -vv + just test diff --git a/.gitignore b/.gitignore index 7677a0718..b5ce8fdc4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ pip-log.txt .tox .cache nosetests.xml +coverage.xml +htmlcov/ +.mypy_cache/ +.ruff_cache/ # Translations *.mo diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index b09064e9a..46747d1dd 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -126,34 +126,37 @@ flake8 gns3server/utils/ import os # Never used # Good: Delete this line + # F841: Mark as intentionally unused # Bad def foo(): x = 1 # Never used + # Good: Use or mark as intentionally unused def foo(): x = 1 _ = x # Mark as intentionally unused + # F824: Remove unnecessary global declaration # Bad def foo(): global _fernet # Never assigned in this scope + # Good: Remove global statement (if only reading) def foo(): # Just read the global variable, no global declaration needed pass + # E501: Break long lines # Bad raise RuntimeError("Encryption not initialized. Call init_encryption() first.") # Good -raise RuntimeError( - "Encryption not initialized. Call init_encryption() first." -) +raise RuntimeError("Encryption not initialized. Call init_encryption() first.") ``` #### Ruff (Alternative) @@ -177,6 +180,7 @@ ruff format gns3server/ # Format code def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]: pass + # 2. Error handling try: result = tool.invoke(args) diff --git a/dev-requirements.txt b/dev-requirements.txt index 80d7da3b4..2f2d2c10b 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,7 +1,10 @@ pytest==9.1.1 -flake8==7.3.0 pytest-timeout==2.4.0 pytest-asyncio==1.4.0 httpx==0.28.1 httpx_ws==0.7.2 # upgrading leads to failures in tests -pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing) \ No newline at end of file +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 diff --git a/docs/bugs/telnet-server-connection-race-condition.md b/docs/bugs/telnet-server-connection-race-condition.md index e9fdf5e75..979afcb0c 100644 --- a/docs/bugs/telnet-server-connection-race-condition.md +++ b/docs/bugs/telnet-server-connection-race-condition.md @@ -279,7 +279,7 @@ Fix the WebSocket error handler: ```python @app.exception_handler(ControllerNotFoundError) async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError): - method = getattr(request, 'method', 'WebSocket') + method = getattr(request, "method", "WebSocket") log.error(f"Controller not found error in {request.url.path} ({method}): {exc}") return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index a6d5eb837..5390211a3 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -238,25 +238,32 @@ Both `device_config_send` and `device_show_run` support an optional `template` p ```python # Direct commands (single/batch) -device_config_send(project_id, device_configs=[ - {"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]}, -]) +device_config_send( + project_id, + device_configs=[ + {"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]}, + ], +) # Jinja2 template (reduces token usage for batch) -device_config_send(project_id, +device_config_send( + project_id, template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255", device_configs=[ {"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}}, {"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}}, - ]) + ], +) # Show commands with template -device_show_run(project_id, +device_show_run( + project_id, template="show ip route {{ protocol }}", device_configs=[ {"device_name": "R1", "vars": {"protocol": "ospf"}}, {"device_name": "R2", "vars": {"protocol": "bgp"}}, - ]) + ], +) ``` ### Best Practices @@ -273,9 +280,12 @@ device_show_run(project_id, ```python # Save config on device -device_show_run(project_id, device_configs=[ - {"device_name": "R1", "commands": ["write memory"]}, -]) +device_show_run( + project_id, + device_configs=[ + {"device_name": "R1", "commands": ["write memory"]}, + ], +) # Backup config = node_file_get(project_id, node_id, "startup-config.cfg") # Restore if config breaks diff --git a/docs/features/project-open-performance.md b/docs/features/project-open-performance.md index 38cd4334c..e3be54315 100644 --- a/docs/features/project-open-performance.md +++ b/docs/features/project-open-performance.md @@ -51,8 +51,8 @@ Each IOU VM creation spawned `ld-linux --verify` and `iou-image -h` subprocesses ```python # Class-level caches shared across all instances -IOUVM._loader_cache = {} # image path → loader command -IOUVM._default_values_cache = {} # image path → (ram, nvram) +IOUVM._loader_cache = {} # image path → loader command +IOUVM._default_values_cache = {} # image path → (ram, nvram) ``` Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values. diff --git a/docs/gns3-copilot/implemented/chat-api.md b/docs/gns3-copilot/implemented/chat-api.md index f11b7f98c..22cc9341b 100644 --- a/docs/gns3-copilot/implemented/chat-api.md +++ b/docs/gns3-copilot/implemented/chat-api.md @@ -798,7 +798,7 @@ OpenAI-compatible message model. content=observation, # Always JSON string format tool_call_id=tool_call["id"], name=tool_call["name"], - metadata={"created_at": datetime.utcnow().isoformat()} + metadata={"created_at": datetime.utcnow().isoformat()}, ) ``` @@ -924,11 +924,7 @@ ToolMessage(content=JSON_string) **Route Registration**: ```python -router.include_router( - chat.router, - prefix="/{project_id}/chat", - tags=["Chat"] -) +router.include_router(chat.router, prefix="/{project_id}/chat", tags=["Chat"]) ``` **Main Endpoint Implementation**: diff --git a/docs/gns3-copilot/implemented/multi-vendor-device-support.md b/docs/gns3-copilot/implemented/multi-vendor-device-support.md index eaaa63801..8dde4759b 100644 --- a/docs/gns3-copilot/implemented/multi-vendor-device-support.md +++ b/docs/gns3-copilot/implemented/multi-vendor-device-support.md @@ -79,9 +79,11 @@ PC1> ping 10.10.0.254 def check_config_mode(self) -> bool: return False # VPCS has no config mode + def config_mode(self) -> str: return "" # No config mode to enter + def exit_config_mode(self) -> str: return "" # No config mode to exit ``` @@ -354,6 +356,7 @@ The custom driver must be registered with Netmiko's global mappings: ```python def register_custom_device_type() -> None: import importlib + sd = importlib.import_module("netmiko.ssh_dispatcher") # Register in CLASS_MAPPER (for ConnectHandler) @@ -430,12 +433,12 @@ gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py **1. Preprocessing - Known Interactive Commands** ```python INTERACTIVE_PATTERNS = [ - re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF/EIGRP/BGP router-id - re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config - re.compile(r'^delete\s+', re.IGNORECASE), # delete files - re.compile(r'^format\s+', re.IGNORECASE), # format filesystem - re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot - re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image + re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF/EIGRP/BGP router-id + re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config + re.compile(r"^delete\s+", re.IGNORECASE), # delete files + re.compile(r"^format\s+", re.IGNORECASE), # format filesystem + re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot + re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image ] ``` diff --git a/docs/gns3-copilot/implemented/node-control-tools.md b/docs/gns3-copilot/implemented/node-control-tools.md index b09083f08..4937d4d2a 100644 --- a/docs/gns3-copilot/implemented/node-control-tools.md +++ b/docs/gns3-copilot/implemented/node-control-tools.md @@ -520,9 +520,14 @@ The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_hand ```python from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( - build_gns3_ctx, create_node_handler, create_link_handler, - get_nodes_handler, start_node_handler, stop_node_handler, - suspend_node_handler, update_node_handler, + build_gns3_ctx, + create_node_handler, + create_link_handler, + get_nodes_handler, + start_node_handler, + stop_node_handler, + suspend_node_handler, + update_node_handler, ) gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context @@ -650,10 +655,7 @@ Note: These special node types are filtered out by GNS3TemplateTool and won't ap from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool tool = GNS3StartNodeTool() -result = tool._run(json.dumps({ - "project_id": "abc-123-def", - "node_ids": ["node-1", "node-2", "node-3"] -})) +result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2", "node-3"]})) # Output includes progress bar and final status ``` @@ -664,10 +666,7 @@ result = tool._run(json.dumps({ from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool tool = GNS3StopNodeTool() -result = tool._run(json.dumps({ - "project_id": "abc-123-def", - "node_ids": ["node-1", "node-2"] -})) +result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2"]})) # Immediate return with stop status ``` @@ -677,19 +676,13 @@ result = tool._run(json.dumps({ ```python # Lab deployment start_tool = GNS3StartNodeTool() -start_result = start_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": all_node_ids -})) +start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids})) # ... Run tests ... # Lab shutdown stop_tool = GNS3StopNodeTool() -stop_result = stop_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": all_node_ids -})) +stop_result = stop_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids})) ``` ### Example 4: Lab Pause and Resume @@ -699,56 +692,45 @@ from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool # Start lab start_tool = GNS3StartNodeTool() -start_result = start_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": all_node_ids -})) +start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids})) # ... Configure devices ... # ... Run some tests ... # Suspend lab (preserves all state) suspend_tool = GNS3SuspendNodeTool() -suspend_result = suspend_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": all_node_ids -})) +suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids})) # Nodes suspended - state preserved in memory # ... Take a break, work on something else ... # Resume lab (quick recovery) -start_result = start_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": all_node_ids -})) +start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids})) # Back to previous state in seconds! ``` ### Example 5: Suspend While Renaming Nodes ```python -from gns3server.agent.gns3_copilot.tools_v2 import ( - GNS3SuspendNodeTool, - GNS3UpdateNodeNameTool -) +from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool, GNS3UpdateNodeNameTool # Suspend nodes first (allows renaming) suspend_tool = GNS3SuspendNodeTool() -suspend_result = suspend_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": node_ids -})) +suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": node_ids})) # Now rename nodes (possible while suspended!) rename_tool = GNS3UpdateNodeNameTool() -rename_result = rename_tool._run(json.dumps({ - "project_id": project_id, - "nodes": [ - {"node_id": "node-1", "new_name": "Router-Primary"}, - {"node_id": "node-2", "new_name": "Router-Backup"} - ] -})) +rename_result = rename_tool._run( + json.dumps( + { + "project_id": project_id, + "nodes": [ + {"node_id": "node-1", "new_name": "Router-Primary"}, + {"node_id": "node-2", "new_name": "Router-Backup"}, + ], + } + ) +) # Resume when ready # Note: Cannot rename while started, but CAN rename while suspended! @@ -777,21 +759,17 @@ result = tool._run("") from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool tool = GNS3CreateNodeTool() -result = tool._run(json.dumps({ - "project_id": "abc-123-def", - "nodes": [ +result = tool._run( + json.dumps( { - "template_id": "uuid-of-router-template", - "x": 100, - "y": -200 - }, - { - "template_id": "uuid-of-switch-template", - "x": -200, - "y": 300 + "project_id": "abc-123-def", + "nodes": [ + {"template_id": "uuid-of-router-template", "x": 100, "y": -200}, + {"template_id": "uuid-of-switch-template", "x": -200, "y": 300}, + ], } - ] -})) + ) +) # Creates two nodes with specified templates and positions ``` @@ -802,23 +780,27 @@ result = tool._run(json.dumps({ from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool tool = GNS3LinkTool() -result = tool._run(json.dumps({ - "project_id": "abc-123-def", - "links": [ +result = tool._run( + json.dumps( { - "node_id1": "uuid-of-node1", - "port1": "Ethernet0/0", - "node_id2": "uuid-of-node2", - "port2": "Ethernet0/0" - }, - { - "node_id1": "uuid-of-node1", - "port1": "Ethernet0/1", - "node_id2": "uuid-of-node3", - "port2": "Ethernet0/0" + "project_id": "abc-123-def", + "links": [ + { + "node_id1": "uuid-of-node1", + "port1": "Ethernet0/0", + "node_id2": "uuid-of-node2", + "port2": "Ethernet0/0", + }, + { + "node_id1": "uuid-of-node1", + "port1": "Ethernet0/1", + "node_id2": "uuid-of-node3", + "port2": "Ethernet0/0", + }, + ], } - ] -})) + ) +) # Creates two links connecting the nodes ``` @@ -829,15 +811,19 @@ result = tool._run(json.dumps({ from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool tool = GNS3UpdateNodeNameTool() -result = tool._run(json.dumps({ - "project_id": "abc-123-def", - "nodes": [ - {"node_id": "node-1", "new_name": "R1-Core"}, - {"node_id": "node-2", "new_name": "R2-Core"}, - {"node_id": "node-3", "new_name": "S1-Access"}, - {"node_id": "node-4", "new_name": "S2-Access"} - ] -})) +result = tool._run( + json.dumps( + { + "project_id": "abc-123-def", + "nodes": [ + {"node_id": "node-1", "new_name": "R1-Core"}, + {"node_id": "node-2", "new_name": "R2-Core"}, + {"node_id": "node-3", "new_name": "S1-Access"}, + {"node_id": "node-4", "new_name": "S2-Access"}, + ], + } + ) +) # Applies consistent naming to all nodes ``` @@ -850,7 +836,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import ( GNS3CreateNodeTool, GNS3LinkTool, GNS3UpdateNodeNameTool, - GNS3StartNodeTool + GNS3StartNodeTool, ) # Step 1: Get available templates @@ -860,44 +846,63 @@ templates = template_tool._run("") # Step 2: Create nodes create_tool = GNS3CreateNodeTool() -nodes = create_tool._run(json.dumps({ - "project_id": project_id, - "nodes": [ - {"template_id": router_template_id, "x": 0, "y": -200}, - {"template_id": router_template_id, "x": 200, "y": -200}, - {"template_id": switch_template_id, "x": 100, "y": 0} - ] -})) +nodes = create_tool._run( + json.dumps( + { + "project_id": project_id, + "nodes": [ + {"template_id": router_template_id, "x": 0, "y": -200}, + {"template_id": router_template_id, "x": 200, "y": -200}, + {"template_id": switch_template_id, "x": 100, "y": 0}, + ], + } + ) +) # Step 3: Connect nodes link_tool = GNS3LinkTool() -links = link_tool._run(json.dumps({ - "project_id": project_id, - "links": [ - {"node_id1": nodes["created_nodes"][0]["node_id"], "port1": "Ethernet0/0", - "node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/0"}, - {"node_id1": nodes["created_nodes"][1]["node_id"], "port1": "Ethernet0/0", - "node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/1"} - ] -})) +links = link_tool._run( + json.dumps( + { + "project_id": project_id, + "links": [ + { + "node_id1": nodes["created_nodes"][0]["node_id"], + "port1": "Ethernet0/0", + "node_id2": nodes["created_nodes"][2]["node_id"], + "port2": "Ethernet0/0", + }, + { + "node_id1": nodes["created_nodes"][1]["node_id"], + "port1": "Ethernet0/0", + "node_id2": nodes["created_nodes"][2]["node_id"], + "port2": "Ethernet0/1", + }, + ], + } + ) +) # Step 4: Apply naming name_tool = GNS3UpdateNodeNameTool() -names = name_tool._run(json.dumps({ - "project_id": project_id, - "nodes": [ - {"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"}, - {"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"}, - {"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"} - ] -})) +names = name_tool._run( + json.dumps( + { + "project_id": project_id, + "nodes": [ + {"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"}, + {"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"}, + {"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"}, + ], + } + ) +) # Step 5: Start nodes start_tool = GNS3StartNodeTool() -start_result = start_tool._run(json.dumps({ - "project_id": project_id, - "node_ids": [n["node_id"] for n in nodes["created_nodes"]] -})) +start_result = start_tool._run( + json.dumps({"project_id": project_id, "node_ids": [n["node_id"] for n in nodes["created_nodes"]]}) +) ``` ## Error Handling diff --git a/docs/gns3-copilot/implemented/skills-repository.md b/docs/gns3-copilot/implemented/skills-repository.md index 167941465..76f9e3c96 100644 --- a/docs/gns3-copilot/implemented/skills-repository.md +++ b/docs/gns3-copilot/implemented/skills-repository.md @@ -150,9 +150,9 @@ Git operations use per-command environment variables to prevent hanging: ```python _GIT_TIMEOUT_ENV = { - 'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s) - 'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window - 'GIT_HTTP_LOW_SPEED_LIMIT': '1000', # < 1 KB/s = slow → abort + "GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s) + "GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window + "GIT_HTTP_LOW_SPEED_LIMIT": "1000", # < 1 KB/s = slow → abort } ``` diff --git a/docs/gns3-copilot/roadmap/template-based-configuration-roadmap.md b/docs/gns3-copilot/roadmap/template-based-configuration-roadmap.md index 88cb3f542..637da5d79 100644 --- a/docs/gns3-copilot/roadmap/template-based-configuration-roadmap.md +++ b/docs/gns3-copilot/roadmap/template-based-configuration-roadmap.md @@ -389,18 +389,13 @@ router ospf {{ process_id }} @interrupt def template_review_checkpoint(state): """Pause and wait for user confirmation.""" - return { - "type": "template_review", - "data": state["generated_template"] - } + return {"type": "template_review", "data": state["generated_template"]} + @interrupt def params_review_checkpoint(state): """Pause and wait for user confirmation.""" - return { - "type": "params_review", - "data": state["generated_params"] - } + return {"type": "params_review", "data": state["generated_params"]} ``` **State Management:** @@ -939,24 +934,9 @@ For 1000+ devices, some failures are inevitable. The system provides: ```python { "total_devices": 1000, - "summary": { - "success": 987, - "failed": 13, - "skipped": 0 - }, - "failed_devices": [ - { - "device_name": "R456", - "error": "Connection timeout", - "retry_available": true - }, - ... - ], - "retry_suggestions": { - "auto_retry": True, - "retry_batch_size": 10, - "exponential_backoff": True - } + "summary": {"success": 987, "failed": 13, "skipped": 0}, + "failed_devices": [{"device_name": "R456", "error": "Connection timeout", "retry_available": true}, ...], + "retry_suggestions": {"auto_retry": True, "retry_batch_size": 10, "exponential_backoff": True}, } ``` @@ -1277,54 +1257,38 @@ All servers connect to access switches in pairs. "node_type": "cisco_iosv", "count": 4, "name_pattern": "Spine-R{{ id }}", - "properties": { - "ram": 4096, - "cpus": 2, - "adapters": 8 - }, - "position": { - "strategy": "hierarchical", - "y": 100, - "x_spacing": 600 - } + "properties": {"ram": 4096, "cpus": 2, "adapters": 8}, + "position": {"strategy": "hierarchical", "y": 100, "x_spacing": 600}, }, { "name": "leaf", "node_type": "cisco_iosv_l2", "count": 20, "name_pattern": "Leaf-SW{{ id }}", - "properties": { - "ram": 2048, - "cpus": 1, - "adapters": 16 - }, + "properties": {"ram": 2048, "cpus": 1, "adapters": 16}, "position": { "strategy": "grid", "grid_rows": 4, "grid_cols": 5, "y": 400, "x_spacing": 300, - "y_spacing": 200 - } + "y_spacing": 200, + }, }, { "name": "access", "node_type": "cisco_iosv_l2", "count": 200, "name_pattern": "Acc-SW{{ id }}", - "properties": { - "ram": 1024, - "cpus": 1, - "adapters": 4 - }, + "properties": {"ram": 1024, "cpus": 1, "adapters": 4}, "position": { "strategy": "grid", "grid_rows": 10, "grid_cols": 20, "y": 800, "x_spacing": 120, - "y_spacing": 100 - } + "y_spacing": 100, + }, }, { "name": "server", @@ -1338,36 +1302,19 @@ All servers connect to access switches in pairs. "grid_cols": 25, "y": 1200, "x_spacing": 60, - "y_spacing": 60 - } - } + "y_spacing": 60, + }, + }, ], "auto_link": { "links": [ - { - "from_group": "spine", - "to_group": "leaf", - "strategy": "mesh" - }, - { - "from_group": "leaf", - "to_group": "access", - "strategy": "paired", - "count": 10 - }, - { - "from_group": "access", - "to_group": "server", - "strategy": "paired", - "count": 2 - } + {"from_group": "spine", "to_group": "leaf", "strategy": "mesh"}, + {"from_group": "leaf", "to_group": "access", "strategy": "paired", "count": 10}, + {"from_group": "access", "to_group": "server", "strategy": "paired", "count": 2}, ] }, "layout": "auto_spine_leaf", - "resource_limits": { - "max_ram_mb": 750000, - "max_vcpus": 724 - } + "resource_limits": {"max_ram_mb": 750000, "max_vcpus": 724}, } # Execution Result @@ -1381,8 +1328,8 @@ All servers connect to access switches in pairs. {"name": "spine", "created": 4, "failed": 0}, {"name": "leaf", "created": 20, "failed": 0}, {"name": "access", "created": 200, "failed": 0}, - {"name": "server", "created": 500, "failed": 0} - ] + {"name": "server", "created": 500, "failed": 0}, + ], } ``` @@ -1522,14 +1469,14 @@ class LinkPattern(BaseModel): """Pattern for creating links between node groups.""" from_nodes: NodeSelector # Source nodes - to_nodes: NodeSelector # Destination nodes + to_nodes: NodeSelector # Destination nodes strategy: Literal[ - "one_to_one", # 1:1 pairing - "one_to_many", # Star topology - "many_to_many", # Full mesh - "sequential", # Linear chain - "ring" # Ring topology + "one_to_one", # 1:1 pairing + "one_to_many", # Star topology + "many_to_many", # Full mesh + "sequential", # Linear chain + "ring", # Ring topology ] port_allocation: PortAllocationStrategy @@ -1548,16 +1495,16 @@ class PortAllocationStrategy(BaseModel): """How to allocate ports for links.""" strategy: Literal[ - "round_robin", # Distribute evenly - "sequential", # Use in order - "optimized", # Smart allocation - "auto" # Automatic selection + "round_robin", # Distribute evenly + "sequential", # Use in order + "optimized", # Smart allocation + "auto", # Automatic selection ] on_conflict: Literal[ - "skip", # Skip if port unavailable - "use_next", # Use next available port - "fail" # Fail on conflict + "skip", # Skip if port unavailable + "use_next", # Use next available port + "fail", # Fail on conflict ] = "use_next" ``` diff --git a/docs/gns3-copilot/roadmap/user-node-limit-roadmap.md b/docs/gns3-copilot/roadmap/user-node-limit-roadmap.md index 1d59ad006..324e7b07f 100644 --- a/docs/gns3-copilot/roadmap/user-node-limit-roadmap.md +++ b/docs/gns3-copilot/roadmap/user-node-limit-roadmap.md @@ -35,7 +35,7 @@ Add `max_nodes` field to both `User` and `UserGroup` models: # In User model max_nodes = Column(Integer, nullable=True) # NULL = no limit -# In UserGroup model +# In UserGroup model max_nodes = Column(Integer, nullable=True) # NULL = no limit ``` @@ -49,9 +49,7 @@ Add node limit configuration to `ControllerSettings`: class NodeLimitSettings(BaseModel): enabled: bool = False # Feature toggle (default disabled) default_max_nodes: int = 5 # Default limit when enabled - excluded_node_types: List[str] = Field(default_factory=lambda: [ - "ethernet_switch", "ethernet_hub", "cloud", "nat" - ]) + excluded_node_types: List[str] = Field(default_factory=lambda: ["ethernet_switch", "ethernet_hub", "cloud", "nat"]) ``` #### 3. Core Service Implementation @@ -83,15 +81,13 @@ Add limit checking to node startup endpoint: async def start_node( node: Node = Depends(dep_node), current_user: User = Depends(get_current_active_user), - node_limit_service: NodeLimitService = Depends(get_node_limit_service) + node_limit_service: NodeLimitService = Depends(get_node_limit_service), ): # Node limit check - can_start, error_msg = await node_limit_service.check_user_node_limit( - current_user, node.project - ) + can_start, error_msg = await node_limit_service.check_user_node_limit(current_user, node.project) if not can_start: raise HTTPException(status_code=403, detail=error_msg) - + # Original startup logic await node.start() ``` diff --git a/gns3server/agent/__init__.py b/gns3server/agent/__init__.py index d914a912c..dee2aaed1 100644 --- a/gns3server/agent/__init__.py +++ b/gns3server/agent/__init__.py @@ -36,6 +36,7 @@ AI_COPILOT_AVAILABLE = False try: from .gns3_copilot.project_agent_manager import get_project_agent_manager from .gns3_copilot.project_agent_manager import ProjectAgentManager + AI_COPILOT_AVAILABLE = True # Start skills repository initialization in background. @@ -64,8 +65,7 @@ except ImportError as e: RuntimeError: If AI Copilot dependencies are not installed """ raise RuntimeError( - "AI Copilot is not available. " - "Install AI dependencies with: pip install gns3-server[ai-features]" + "AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]" ) class ProjectAgentManager: @@ -75,8 +75,7 @@ except ImportError as e: def __init__(self): raise RuntimeError( - "AI Copilot is not available. " - "Install AI dependencies with: pip install gns3-server[ai-features]" + "AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]" ) @@ -88,6 +87,7 @@ try: # Use importlib so the top-level SDK name "mcp" is not bound in this # namespace — it would shadow the gns3server.agent.mcp subpackage. import importlib + importlib.import_module("mcp.server.fastmcp") MCP_AVAILABLE = True except ImportError: diff --git a/gns3server/agent/gns3_copilot/agent/context_manager.py b/gns3server/agent/gns3_copilot/agent/context_manager.py index e9427c4bc..79c526dbf 100644 --- a/gns3server/agent/gns3_copilot/agent/context_manager.py +++ b/gns3server/agent/gns3_copilot/agent/context_manager.py @@ -67,9 +67,12 @@ logger = logging.getLogger(__name__) # Initialize tiktoken encoding (required dependency) import time + logger.debug("Initializing tiktoken encoding (cl100k_base)...") logger.debug(f"Cache directory: {_cache_dir}") -logger.debug("This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)") +logger.debug( + "This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)" +) start_time = time.time() _tiktoken_encoding = tiktoken.get_encoding("cl100k_base") elapsed = time.time() - start_time @@ -149,21 +152,16 @@ def estimate_tool_tokens(tools: list[Any]) -> int: if hasattr(tool, "args_schema") and tool.args_schema: try: # Try Pydantic v2 method (model_json_schema) - tool_schema["function"]["parameters"] = ( - tool.args_schema.model_json_schema() - ) + tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema() except AttributeError: # Fallback to Pydantic v1 method (schema) try: - tool_schema["function"]["parameters"] = ( - tool.args_schema.schema() - ) + tool_schema["function"]["parameters"] = tool.args_schema.schema() except Exception: # Both methods failed, use empty schema tool_name = getattr(tool, "name", "unknown") logger.debug( - "Failed to get schema for tool %s, using empty " - "parameters", + "Failed to get schema for tool %s, using empty parameters", tool_name, ) tool_schema["function"]["parameters"] = {} @@ -171,15 +169,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int: # model_json_schema() raised an exception tool_name = getattr(tool, "name", "unknown") logger.debug( - "model_json_schema() failed for tool %s: %s, " - "trying v1 fallback", + "model_json_schema() failed for tool %s: %s, trying v1 fallback", tool_name, e, ) try: - tool_schema["function"]["parameters"] = ( - tool.args_schema.schema() - ) + tool_schema["function"]["parameters"] = tool.args_schema.schema() except Exception: tool_schema["function"]["parameters"] = {} @@ -331,14 +326,11 @@ def create_pre_model_hook( if not llm_config: logger.error("LLM config not found. context_limit is required.") - raise ValueError( - "LLM config not found. context_limit is required." - ) + raise ValueError("LLM config not found. context_limit is required.") if "context_limit" not in llm_config: logger.error( - "context_limit not found in LLM config. " - "This is a required field. Please configure context_limit." + "context_limit not found in LLM config. This is a required field. Please configure context_limit." ) raise ValueError("context_limit is required in LLM config") @@ -385,9 +377,7 @@ def create_pre_model_hook( system_tokens = _count_tokens_for_message(system_message) # Calculate tokens for messages_with_system (including system) - messages_with_system_tokens = sum( - _count_tokens_for_message(m) for m in messages_with_system - ) + messages_with_system_tokens = sum(_count_tokens_for_message(m) for m in messages_with_system) # Calculate available budget model_limit_tokens = context_limit_k * TOKENS_PER_K @@ -449,14 +439,11 @@ def create_pre_model_hook( # Calculate final token counts final_total = sum(_count_tokens_for_message(m) for m in trimmed) - usage_percent = ( - (final_total + tool_tokens) / model_limit_tokens * 100 - ) + usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100 if len(trimmed) < len(messages_with_system): logger.info( - "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d " - "tools = %d / %dK (%.1f%%), strategy=%s", + "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s", len(messages_with_system), len(trimmed), final_total, @@ -468,8 +455,7 @@ def create_pre_model_hook( ) else: logger.info( - "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK " - "(%.1f%%), strategy=%s", + "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s", len(trimmed), final_total, tool_tokens, @@ -520,33 +506,23 @@ def _inject_topology_into_system( if topology_data: topology_str = str(topology_data) - formatted_prompt = system_prompt.replace( - "{{topology_info}}", f"\n\n## Current Topology\n{topology_str}" - ) + formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_str}") logger.info( "✓ Topology injected: %d chars, nodes: %s", len(topology_str), list(topology_data.get("nodes", {}).keys())[:5], ) # Show first 5 node names - logger.debug( - "Full topology data: %s", topology_str[:500] - ) # First 500 chars + logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars else: - formatted_prompt = system_prompt.replace( - "{{topology_info}}", "(No topology information available)" - ) + formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)") logger.warning("✗ Topology data is None, injecting placeholder") # Filter out existing SystemMessage instances - non_system_messages = [ - m for m in messages if not isinstance(m, SystemMessage) - ] + non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)] filtered_count = len(messages) - len(non_system_messages) if filtered_count > 0: - logger.debug( - "Filtered out %d existing SystemMessage(s)", filtered_count - ) + logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count) return [SystemMessage(content=formatted_prompt)] + non_system_messages @@ -568,8 +544,7 @@ def prepare_context_messages( **DEPRECATED**: Use create_pre_model_hook() instead. """ warnings.warn( - "prepare_context_messages() is deprecated. Use " - "create_pre_model_hook() instead.", + "prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.", DeprecationWarning, stacklevel=2, ) @@ -577,13 +552,9 @@ def prepare_context_messages( if "{{topology_info}}" not in system_prompt: formatted_prompt = system_prompt elif topology_context: - formatted_prompt = system_prompt.replace( - "{{topology_info}}", f"\n\n## Current Topology\n{topology_context}" - ) + formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_context}") else: - formatted_prompt = system_prompt.replace( - "{{topology_info}}", "(No topology information available)" - ) + formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)") return [SystemMessage(content=formatted_prompt)] + state_messages @@ -629,9 +600,7 @@ if __name__ == "__main__": # Test invocation print("\nTest 3: Invoke pre_model_hook") test_state = { - "messages": [ - HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5) - ], + "messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)], "topology_info": {"project_id": "test123", "nodes": 3}, } diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index 7478301f8..a9616eb8b 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -311,8 +311,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None): if topology and "error" not in topology: topology_info = topology logger.info( - "Successfully retrieved topology for project_id: %s, " - "name: %s", + "Successfully retrieved topology for project_id: %s, name: %s", project_id, topology.get("name"), ) @@ -320,9 +319,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None): logger.warning( "Failed to retrieve topology for project_id %s: %s", project_id, - topology.get("error", "Unknown error") - if topology - else "No result", + topology.get("error", "Unknown error") if topology else "No result", ) except Exception as e: logger.warning( @@ -338,20 +335,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None): copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower() if copilot_mode == "lab_automation_assistant": mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS - logger.info( - "Using LAB_AUTOMATION_ASSISTANT mode tools (includes " - "configuration tools)" - ) + logger.info("Using LAB_AUTOMATION_ASSISTANT mode tools (includes configuration tools)") elif copilot_mode == "troubleshooting_injection": mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS - logger.info( - "Using TROUBLESHOOTING_INJECTION mode tools (fault injection)" - ) + logger.info("Using TROUBLESHOOTING_INJECTION mode tools (fault injection)") else: # teaching_assistant mode (default) mode_tools = TEACHING_ASSISTANT_MODE_TOOLS - logger.info( - "Using TEACHING_ASSISTANT mode tools (diagnostic tools only)" - ) + logger.info("Using TEACHING_ASSISTANT mode tools (diagnostic tools only)") # Create pre_model_hook for automatic topology injection and trimming # Load system prompt based on copilot_mode configuration @@ -372,22 +362,16 @@ def llm_call(state: dict, config: RunnableConfig | None = None): copilot_mode, len(mode_tools), ) - model_with_tools = create_base_model_with_tools( - mode_tools, llm_config=llm_config - ) + model_with_tools = create_base_model_with_tools(mode_tools, llm_config=llm_config) # Call pre_hook directly to prepare messages (topology injection + # trimming) # Note: LangGraph's pre_model_hook only works with prebuilt agents, not # custom StateGraph logger.debug("Calling pre_hook to prepare %d messages", len(messages)) - prepared_state = pre_hook( - {"messages": messages, "topology_info": topology_info} - ) + prepared_state = pre_hook({"messages": messages, "topology_info": topology_info}) prepared_messages = prepared_state["messages"] - logger.info( - "Messages prepared: %d → %d", len(messages), len(prepared_messages) - ) + logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages)) # Invoke model with prepared messages response = model_with_tools.invoke(prepared_messages) @@ -421,9 +405,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None): # Define generate title node -def generate_title( - state: MessagesState, config: RunnableConfig | None = None -) -> dict: +def generate_title(state: MessagesState, config: RunnableConfig | None = None) -> dict: """ Generate a conversation title using a lightweight assistant LLM (title_model). This node is only executed when no title has been set yet @@ -469,27 +451,21 @@ def generate_title( # Validate the generated title if not new_title or len(new_title) < 3: - raise ValueError( - f"Generated title too short or empty: '{new_title}'" - ) + raise ValueError(f"Generated title too short or empty: '{new_title}'") if new_title in [ "New Conversation", "Untitled Session", "GNS3 Session", ]: - raise ValueError( - f"Generated title is a default value: '{new_title}'" - ) + raise ValueError(f"Generated title is a default value: '{new_title}'") # Safety: truncate long titles and avoid line breaks if len(new_title) > TITLE_MAX_LENGTH: new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..." # Remove unwanted characters - new_title = ( - new_title.replace("\n", " ").replace('"', "").replace("'", "") - ) + new_title = new_title.replace("\n", " ").replace('"', "").replace("'", "") logger.debug("Generated new title: %s", new_title) return {"conversation_title": new_title, "session_id": state.get("session_id")} @@ -540,9 +516,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None): result = [] for tool_call in tool_calls: tool_name = tool_call["name"] - logger.debug( - "Executing tool: %s with args: %s", tool_name, tool_call["args"] - ) + logger.debug("Executing tool: %s with args: %s", tool_name, tool_call["args"]) tool = tools_by_name[tool_name] try: observation = tool.invoke(tool_call["args"]) @@ -590,11 +564,13 @@ def abort_handler_node(state: dict) -> dict: result = [] for tool_call in last_message.tool_calls: tool_msg = ToolMessage( - content=json.dumps({ - "status": "aborted", - "message": "Tool execution was aborted by user", - "tool_call_id": tool_call["id"], - }), + content=json.dumps( + { + "status": "aborted", + "message": "Tool execution was aborted by user", + "tool_call_id": tool_call["id"], + } + ), tool_call_id=tool_call["id"], name=tool_call["name"], metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True}, diff --git a/gns3server/agent/gns3_copilot/agent/model_factory.py b/gns3server/agent/gns3_copilot/agent/model_factory.py index 86a087654..cfdc26854 100644 --- a/gns3server/agent/gns3_copilot/agent/model_factory.py +++ b/gns3server/agent/gns3_copilot/agent/model_factory.py @@ -94,8 +94,7 @@ def create_base_model( # Log the loaded configuration (mask sensitive data) logger.info( - "Creating base model: name=%s, provider=%s, base_url=%s, " - "temperature=%s", + "Creating base model: name=%s, provider=%s, base_url=%s, temperature=%s", config_vars["model_name"], config_vars["model_provider"], config_vars["base_url"] if config_vars["base_url"] else "default", @@ -163,8 +162,7 @@ def create_title_model( config_vars = _load_llm_config(llm_config) logger.info( - "Creating title model: name=%s, provider=%s, base_url=%s, " - "temperature=1.0", + "Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0", config_vars["model_name"], config_vars["model_provider"], config_vars["base_url"] if config_vars["base_url"] else "default", diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py index 627ea2f69..bf916b043 100644 --- a/gns3server/agent/gns3_copilot/agent_service.py +++ b/gns3server/agent/gns3_copilot/agent_service.py @@ -123,9 +123,7 @@ class AgentService: return self._checkpointer checkpoint_dir = self._get_checkpoint_dir() - checkpointer_path = os.path.join( - checkpoint_dir, "copilot_checkpoints.db" - ) + checkpointer_path = os.path.join(checkpoint_dir, "copilot_checkpoints.db") log.debug("Creating checkpointer at: %s", checkpointer_path) @@ -135,17 +133,13 @@ class AgentService: await self._checkpointer_conn.close() log.debug("Closed previous checkpointer connection") except Exception as e: - log.warning( - "Error closing old checkpointer connection: %s", e - ) + log.warning("Error closing old checkpointer connection: %s", e) # Create new connection conn = await aiosqlite.connect(checkpointer_path) # Enable WAL mode for better concurrent performance await conn.execute("PRAGMA journal_mode=WAL;") - self._checkpointer_conn = ( - conn # Save connection reference to prevent GC - ) + self._checkpointer_conn = conn # Save connection reference to prevent GC self._checkpointer = AsyncSqliteSaver(conn) # CRITICAL: Initialize database schema @@ -197,14 +191,8 @@ class AgentService: """) # Create indexes - await conn.execute( - "CREATE INDEX IF NOT EXISTS idx_thread_id ON " - "chat_sessions(thread_id)" - ) - await conn.execute( - "CREATE INDEX IF NOT EXISTS idx_user_project ON " - "chat_sessions(user_id, project_id)" - ) + await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)") # Check if pinned column exists, add it if not (migration for existing # databases) @@ -214,16 +202,12 @@ class AgentService: if "pinned" not in column_names: log.debug("Adding pinned column to existing chat_sessions table") - await conn.execute( - "ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT " - "FALSE" - ) + await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE") await conn.commit() # Create pinned index (after column is guaranteed to exist) await conn.execute( - "CREATE INDEX IF NOT EXISTS idx_pinned_updated ON " - "chat_sessions(pinned DESC, updated_at DESC)" + "CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)" ) await conn.commit() @@ -234,9 +218,7 @@ class AgentService: if self._graph is None: checkpointer = await self._get_checkpointer() self._graph = agent_builder.compile(checkpointer=checkpointer) - log.info( - "LangGraph agent compiled for project: %s", self.project_path - ) + log.info("LangGraph agent compiled for project: %s", self.project_path) return self._graph async def stream_chat( @@ -266,8 +248,7 @@ class AgentService: Dict containing SSE-compatible response chunks """ log.info( - "Stream chat started: project_id=%s, user_id=%s, session_id=%s, " - "mode=%s", + "Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s", project_id, user_id, session_id, @@ -293,11 +274,7 @@ class AgentService: title="New Conversation", copilot_mode=copilot_mode, ) - log.debug( - "Created new chat session: thread_id=%s, copilot_mode=%s", - session_id, - copilot_mode - ) + log.debug("Created new chat session: thread_id=%s, copilot_mode=%s", session_id, copilot_mode) # Set request-scoped context variables (memory-only, not persisted) if jwt_token: @@ -387,39 +364,25 @@ class AgentService: # Stream events try: - async for event in graph.astream_events( - inputs, config=config, version="v2" - ): + async for event in graph.astream_events(inputs, config=config, version="v2"): event_type = event.get("event", "") data = event.get("data", {}) # Track LLM calls and tokens if event_type == "on_chat_model_start": # Filter out title_generator_node from statistics - langgraph_node = event.get("metadata", {}).get( - "langgraph_node", "" - ) + langgraph_node = event.get("metadata", {}).get("langgraph_node", "") if langgraph_node != "title_generator_node": llm_calls_count += 1 - log.debug( - "LLM call started, count=%d", llm_calls_count - ) + log.debug("LLM call started, count=%d", llm_calls_count) else: - log.debug( - "Skipping LLM call count for internal node: " - "title_generator_node" - ) + log.debug("Skipping LLM call count for internal node: title_generator_node") elif event_type == "on_chat_model_end": # Filter out title_generator_node from token counting - langgraph_node = event.get("metadata", {}).get( - "langgraph_node", "" - ) + langgraph_node = event.get("metadata", {}).get("langgraph_node", "") if langgraph_node == "title_generator_node": - log.debug( - "Skipping token counting for internal node: " - "title_generator_node" - ) + log.debug("Skipping token counting for internal node: title_generator_node") else: # Extract token usage from response metadata # Try multiple possible locations where token usage @@ -441,12 +404,8 @@ class AgentService: if hasattr(output_msg, "usage_metadata"): usage = output_msg.usage_metadata if usage: - input_tokens += usage.get( - "input_tokens", 0 - ) - output_tokens += usage.get( - "output_tokens", 0 - ) + input_tokens += usage.get("input_tokens", 0) + output_tokens += usage.get("output_tokens", 0) token_info_found = True # Method 3: Check data directly for token usage fields @@ -455,10 +414,7 @@ class AgentService: input_tokens += data.get("input_tokens", 0) if "output_tokens" in data: output_tokens += data.get("output_tokens", 0) - if ( - "input_tokens" in data - or "output_tokens" in data - ): + if "input_tokens" in data or "output_tokens" in data: token_info_found = True # Count AI response as one message (only once per turn) @@ -470,9 +426,7 @@ class AgentService: elif event_type == "on_tool_end": message_count += 1 # Tool result message tool_messages_counted += 1 - log.debug( - "Tool message counted, message_count=%d", message_count - ) + log.debug("Tool message counted, message_count=%d", message_count) # Convert event to chunk for SSE streaming # Use accumulator for on_chat_model_stream events to handle @@ -480,16 +434,11 @@ class AgentService: # Filter out internal nodes (title_generator_node) from # streaming to frontend - langgraph_node = event.get("metadata", {}).get( - "langgraph_node", "" - ) + langgraph_node = event.get("metadata", {}).get("langgraph_node", "") if langgraph_node == "title_generator_node": # Skip all events from the title_generator_node (internal # use only) - log.debug( - "Skipping event from internal node: " - "title_generator_node" - ) + log.debug("Skipping event from internal node: title_generator_node") continue if event_type == "on_chat_model_stream": @@ -540,8 +489,7 @@ class AgentService: last_message_at=last_message_at, ) log.info( - "Session statistics updated: thread_id=%s, messages=%d, " - "llm_calls=%d, tokens=%d+%d=%d", + "Session statistics updated: thread_id=%s, messages=%d, llm_calls=%d, tokens=%d+%d=%d", session_id, message_count, llm_calls_count, @@ -555,13 +503,8 @@ class AgentService: if final_state and "conversation_title" in final_state.values: generated_title = final_state.values["conversation_title"] current_session = await repo.get_session_by_thread(session_id) - if ( - current_session - and current_session.title != generated_title - ): - await repo.update_session( - thread_id=session_id, title=generated_title - ) + if current_session and current_session.title != generated_title: + await repo.update_session(thread_id=session_id, title=generated_title) log.info( "Auto-generated title synced: thread_id=%s, title=%s", session_id, @@ -576,9 +519,7 @@ class AgentService: "session_id": session_id, } - def _convert_event_to_chunk( - self, event: Dict[str, Any], session_id: str - ) -> Optional[Dict[str, Any]]: + def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]: """ Convert LangGraph event to API response chunk. @@ -627,9 +568,7 @@ class AgentService: return None - async def get_history( - self, session_id: str, limit: int = 100 - ) -> Dict[str, Any]: + async def get_history(self, session_id: str, limit: int = 100) -> Dict[str, Any]: """ Get conversation history for a session. @@ -651,9 +590,7 @@ class AgentService: for msg in state.values["messages"][-limit:]: messages.append(self._convert_message_to_dict(msg)) - title = state.values.get( - "conversation_title", "New Conversation" - ) + title = state.values.get("conversation_title", "New Conversation") return { "thread_id": session_id, @@ -710,9 +647,7 @@ class AgentService: repo = ChatSessionsRepository(self._checkpointer_conn) return await repo.delete_session(session_id) - async def rename_session( - self, session_id: str, new_title: str - ) -> Optional[Dict[str, Any]]: + async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]: """ Rename a chat session. @@ -727,14 +662,10 @@ class AgentService: await self._get_checkpointer() repo = ChatSessionsRepository(self._checkpointer_conn) - session = await repo.update_session( - thread_id=session_id, title=new_title - ) + session = await repo.update_session(thread_id=session_id, title=new_title) return session.to_dict() if session else None - async def pin_session( - self, session_id: str, pinned: bool = True - ) -> Optional[Dict[str, Any]]: + async def pin_session(self, session_id: str, pinned: bool = True) -> Optional[Dict[str, Any]]: """ Pin or unpin a chat session. @@ -760,10 +691,7 @@ class AgentService: if self._checkpointer_conn: try: # Add timeout to prevent blocking on database close - await asyncio.wait_for( - self._checkpointer_conn.close(), - timeout=5.0 - ) + await asyncio.wait_for(self._checkpointer_conn.close(), timeout=5.0) log.debug( "Checkpointer connection closed for: %s", self.project_path, diff --git a/gns3server/agent/gns3_copilot/chat_sessions_repository.py b/gns3server/agent/gns3_copilot/chat_sessions_repository.py index 691971052..4d18d8852 100644 --- a/gns3server/agent/gns3_copilot/chat_sessions_repository.py +++ b/gns3server/agent/gns3_copilot/chat_sessions_repository.py @@ -156,16 +156,11 @@ class ChatSessionsRepository: await self.conn.commit() session_id = cursor.lastrowid - log.info( - "Created chat session: id=%s, thread_id=%s, copilot_mode=%s", - session_id, thread_id, copilot_mode - ) + log.info("Created chat session: id=%s, thread_id=%s, copilot_mode=%s", session_id, thread_id, copilot_mode) return await self.get_session_by_id(session_id) - async def get_session_by_id( - self, session_id: int - ) -> Optional[ChatSession]: + async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]: """ Get a session by its database ID. @@ -175,18 +170,14 @@ class ChatSessionsRepository: Returns: ChatSession or None """ - cursor = await self.conn.execute( - "SELECT * FROM chat_sessions WHERE id = ?", (session_id,) - ) + cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE id = ?", (session_id,)) row = await cursor.fetchone() if row: return self._row_to_session(row) return None - async def get_session_by_thread( - self, thread_id: str - ) -> Optional[ChatSession]: + async def get_session_by_thread(self, thread_id: str) -> Optional[ChatSession]: """ Get a session by thread_id. @@ -196,9 +187,7 @@ class ChatSessionsRepository: Returns: ChatSession or None """ - cursor = await self.conn.execute( - "SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,) - ) + cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,)) row = await cursor.fetchone() if row: @@ -318,10 +307,7 @@ class ChatSessionsRepository: params.append(now) params.append(thread_id) - query = ( - f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id " - f"= ?" - ) + query = f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id = ?" await self.conn.execute(query, params) await self.conn.commit() @@ -340,21 +326,15 @@ class ChatSessionsRepository: True if deleted, False if not found """ # First, delete the checkpoint data - await self.conn.execute( - "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) - ) + await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)) # Then delete the session - cursor = await self.conn.execute( - "DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,) - ) + cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,)) await self.conn.commit() deleted = cursor.rowcount > 0 if deleted: - log.info( - "Deleted chat session and checkpoints: thread_id=%s", thread_id - ) + log.info("Deleted chat session and checkpoints: thread_id=%s", thread_id) return deleted @@ -378,13 +358,9 @@ class ChatSessionsRepository: # Delete checkpoints and sessions for thread_id in thread_ids: - await self.conn.execute( - "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) - ) + await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)) - cursor = await self.conn.execute( - "DELETE FROM chat_sessions WHERE project_id = ?", (project_id,) - ) + cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE project_id = ?", (project_id,)) await self.conn.commit() deleted_count = cursor.rowcount @@ -397,9 +373,7 @@ class ChatSessionsRepository: return deleted_count - async def pin_session( - self, thread_id: str, pinned: bool = True - ) -> Optional[ChatSession]: + async def pin_session(self, thread_id: str, pinned: bool = True) -> Optional[ChatSession]: """ Pin or unpin a session. @@ -412,8 +386,7 @@ class ChatSessionsRepository: """ now = datetime.utcnow().isoformat() await self.conn.execute( - "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE " - "thread_id = ?", + "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE thread_id = ?", (1 if pinned else 0, now, thread_id), ) await self.conn.commit() diff --git a/gns3server/agent/gns3_copilot/configs/skills_config.py b/gns3server/agent/gns3_copilot/configs/skills_config.py index 3a10332d9..d08429042 100644 --- a/gns3server/agent/gns3_copilot/configs/skills_config.py +++ b/gns3server/agent/gns3_copilot/configs/skills_config.py @@ -35,13 +35,10 @@ from gns3server.config import Config SKILLS_CONFIG = { # Git repository URL for skills "repo_url": "https://github.com/gns3/gns3-skills.git", - # Git branch to use "branch": "main", - # Automatically pull updates on reload "auto_update": True, - # Enable external skills loading # If False, use the built-in hardcoded skills "enabled": True, diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 831dace7b..b0934c201 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB VALID_NODE_FIELDS = { # NodeBase - "compute_id", "name", "node_type", "node_id", - "console", "console_type", "console_auto_start", - "aux", "aux_type", "properties", "label", "symbol", - "x", "y", "z", "locked", - "port_name_format", "port_segment_size", "first_port_name", - "custom_adapters", "tags", + "compute_id", + "name", + "node_type", + "node_id", + "console", + "console_type", + "console_auto_start", + "aux", + "aux_type", + "properties", + "label", + "symbol", + "x", + "y", + "z", + "locked", + "port_name_format", + "port_segment_size", + "first_port_name", + "custom_adapters", + "tags", # Node - "template_id", "project_id", "node_directory", "status", - "command_line", "width", "height", "ports", "console_host", + "template_id", + "project_id", + "node_directory", + "status", + "command_line", + "width", + "height", + "ports", + "console_host", } VALID_LINK_FIELDS = { - "link_id", "project_id", "link_type", "nodes", "suspend", - "link_style", "filters", "show_filters_icon", - "capturing", "capture_file_name", "capture_file_path", - "capture_compute_id", "wireshark", + "link_id", + "project_id", + "link_type", + "nodes", + "suspend", + "link_style", + "filters", + "show_filters_icon", + "capturing", + "capture_file_name", + "capture_file_path", + "capture_compute_id", + "wireshark", } LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"] @@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"] # ── Helpers ──────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): return Gns3Connector( url=gns3_ctx["server_url"], @@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): ) -def build_gns3_ctx( - jwt_token: str | None = None, url: str | None = None -) -> dict[str, Any] | None: +def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None: """ Build a handler ``gns3_ctx`` for in-process copilot callers. @@ -183,13 +213,14 @@ def _normalize_link_nodes(nodes) -> list[dict[str, Any]]: ] raise ValueError( f"Unrecognized link nodes format. " - f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] " - f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}" + f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] ' + f'or compact ["id",0,0,"id",0,0], got: {nodes}' ) # ── Node handlers ────────────────────────────────────────────────────────── + def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: project_id = params.get("project_id") if not project_id: @@ -199,7 +230,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[ fields = params.get("fields") if fields: if not isinstance(fields, list): - return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"} + return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'} invalid = [f for f in fields if f not in VALID_NODE_FIELDS] if invalid: return { @@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s fields = params.get("fields") if fields: if not isinstance(fields, list): - return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"} + return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'} invalid = [f for f in fields if f not in VALID_NODE_FIELDS] if invalid: return { @@ -235,12 +266,14 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s def _batch_lifecycle(project_id, node_ids, action, conn, action_label): """Helper to run a lifecycle action on multiple nodes in parallel.""" + def _act(nid): try: conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}") return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"} except Exception as e: return {"node_id": nid, "status": "error", "error": str(e)} + with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool: return list(pool.map(_act, node_ids)) @@ -307,7 +340,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic fields = params.get("fields") if fields is not None and not isinstance(fields, list): - return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"} + return {"error": 'fields must be a list, e.g. ["node_id", "name"]'} nodes = params.get("nodes") # Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}] @@ -317,6 +350,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic return {"error": "nodes must be a non-empty array"} default_tid = params.get("template_id") conn = _get_connector(gns3_ctx) + def _create_one(node_data): tid = node_data.get("template_id", default_tid) if not tid: @@ -335,6 +369,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)} except Exception as e: return {"template_id": tid, "status": "error", "error": str(e)} + if any(not node.get("name") for node in nodes): # The controller assigns default names (R-1, R-2, ...) and console # ports in request arrival order, and a parallel fan-out makes the @@ -375,12 +410,14 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not isinstance(node_ids, list): return {"error": "node_ids must be a list"} conn = _get_connector(gns3_ctx) + def _del(nid): try: conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}") return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"} except Exception as e: return {"node_id": nid, "status": "error", "error": str(e)} + with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool: return list(pool.map(_del, node_ids)) node_id = params.get("node_id") @@ -423,12 +460,16 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An # the ~200-char JWT previously embedded here (dropped header segment → # "Missing 'alg' value in header" on the server). username = gns3_ctx.get("jwt_username") - ticket = access_ticket_service.mint( - username, - token_version=gns3_ctx.get("jwt_token_version", 0), - project_id=project_id, - node_id=node_id, - ) if username else None + ticket = ( + access_ticket_service.mint( + username, + token_version=gns3_ctx.get("jwt_token_version", 0), + project_id=project_id, + node_id=node_id, + ) + if username + else None + ) raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws" if ticket: raw_url += f"?token={ticket}" @@ -499,7 +540,7 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d total_lines = len(lines) # Apply offset/limit - selected = lines[offset: offset + limit] if offset < total_lines else [] + selected = lines[offset : offset + limit] if offset < total_lines else [] has_more = (offset + limit) < total_lines or truncated content = "".join(selected) @@ -581,7 +622,9 @@ def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> return {"error": "project_id and node_id are required"} conn = _get_connector(gns3_ctx) data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None} - result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json() + result = conn.http_call( + "post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data + ).json() return {"message": f"Node {node_id} duplicated", "node": result} @@ -617,6 +660,7 @@ def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> # ── Link handlers ────────────────────────────────────────────────────────── + def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: project_id = params.get("project_id") if not project_id: @@ -626,7 +670,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[ fields = params.get("fields") if fields: if not isinstance(fields, list): - return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"} + return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'} invalid = [f for f in fields if f not in VALID_LINK_FIELDS] if invalid: return { @@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic fields = params.get("fields") if fields is not None and not isinstance(fields, list): - return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"} + return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'} links = params.get("links") # Batch mode: links=[{nodes, link_type?, filters?, suspend?}] @@ -677,6 +721,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not isinstance(links, list) or not links: return {"error": "links must be a non-empty array"} conn = _get_connector(gns3_ctx) + def _create_one(link_data): raw_nodes = link_data.get("nodes") if not raw_nodes: @@ -694,6 +739,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic return {"status": "success", "link": _filter_link_response(resp, fields)} except Exception as e: return {"status": "error", "error": str(e)} + with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool: # pool.map keeps the submission order, so callers can correlate # results with the links they sent regardless of completion order @@ -725,12 +771,14 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not isinstance(link_ids, list): return {"error": "link_ids must be a list"} conn = _get_connector(gns3_ctx) + def _del(lid): try: conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}") return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"} except Exception as e: return {"link_id": lid, "status": "error", "error": str(e)} + with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: return list(pool.map(_del, link_ids)) link_id = params.get("link_id") @@ -770,6 +818,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict if not isinstance(link_ids, list): return {"error": "link_ids must be a list"} conn = _get_connector(gns3_ctx) + def _rst(lid): try: url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset" @@ -777,6 +826,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict return {"link_id": lid, "status": "reset", "link": r} except Exception as e: return {"link_id": lid, "status": "error", "error": str(e)} + with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: return list(pool.map(_rst, link_ids)) link_id = params.get("link_id") @@ -790,6 +840,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict def _batch_capture(project_id, link_ids, action, data_builder, conn): """Helper for batch capture start/stop.""" + def _act(lid): try: url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}" @@ -798,6 +849,7 @@ def _batch_capture(project_id, link_ids, action, data_builder, conn): return {"link_id": lid, "status": "success"} except Exception as e: return {"link_id": lid, "status": "error", "error": str(e)} + with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: return list(pool.map(_act, link_ids)) @@ -814,11 +866,13 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d dlt = params.get("data_link_type", "DLT_EN10MB") ws = params.get("wireshark", False) fname = params.get("capture_file_name") + def _build(lid): data = {"data_link_type": dlt, "wireshark": ws} if fname: data["capture_file_name"] = fname return {"json_data": data} + return _batch_capture(project_id, link_ids, "start", _build, conn) link_id = params.get("link_id") if not link_id: @@ -879,7 +933,11 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An if not isinstance(link_ids, list): return {"error": "link_ids must be a list"} results = [_download(lid) for lid in link_ids] - return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."} + return { + "downloads": results, + "count": len(results), + "note": "Files are in pcap format. URLs include a 10-minute ticket.", + } link_id = params.get("link_id") if not link_id: @@ -946,12 +1004,18 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic elif direction in ("tx", "rx"): body["direction"] = direction if not body: - return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"} + return { + "error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)" + } return conn.http_call("put", url, json_data=body).json() # action == "delete" conn.http_call("delete", url) - return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name} + return { + "message": f"Marker '{marker_name}' deleted from link {link_id}", + "link_id": link_id, + "marker_name": marker_name, + } def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -1002,7 +1066,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if params.get(opt) is not None: body[opt] = params[opt] if not body: - return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"} + return { + "error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)" + } return conn.http_call("put", url, json_data=body).json() # action == "delete" diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector.py b/gns3server/agent/gns3_copilot/gns3_client/connector.py index bd5fbcf9c..40ce1c5e5 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector.py @@ -139,17 +139,11 @@ class Gns3Connector: self.session.headers["Accept"] = "application/json" # pragma: no cover # Set authentication based on API version - if ( - self.auth_type == "basic" - and self.user is not None - and self.cred is not None - ): + if self.auth_type == "basic" and self.user is not None and self.cred is not None: self.session.auth = (self.user, self.cred) # pragma: no cover elif self.auth_type == "jwt" and self.access_token: - self.session.headers["Authorization"] = ( - f"Bearer {self.access_token}" - ) + self.session.headers["Authorization"] = f"Bearer {self.access_token}" def _authenticate_v3(self) -> None: """ @@ -161,15 +155,10 @@ class Gns3Connector: return if not self.user or not self.cred: - raise ValueError( - "Username and password are required for v3 authentication " - "when no JWT token is provided" - ) + raise ValueError("Username and password are required for v3 authentication when no JWT token is provided") # Construct authentication URL (v3 API uses different base URL) - auth_url = ( - f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate" - ) + auth_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate" auth_data = {"username": self.user, "password": self.cred} # Use temporary session for authentication @@ -177,21 +166,14 @@ class Gns3Connector: temp_session.headers["Content-Type"] = "application/json" try: - response = temp_session.post( - auth_url, json=auth_data, verify=self.verify, timeout=10.0 - ) + response = temp_session.post(auth_url, json=auth_data, verify=self.verify, timeout=10.0) if response.status_code == 200: auth_result = response.json() self.access_token = auth_result["access_token"] # Update session with new token - self.session.headers["Authorization"] = ( - f"Bearer {self.access_token}" - ) + self.session.headers["Authorization"] = f"Bearer {self.access_token}" else: - raise HTTPError( - f"v3 API authentication failed: {response.status_code} - " - f"{response.text}" - ) + raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}") except Exception as e: raise HTTPError(f"v3 API authentication error: {str(e)}") from e @@ -205,9 +187,7 @@ class Gns3Connector: try: # Decode token without verification to check expiry - decoded: dict[str, Any] = jwt.decode( - token, options={"verify_signature": False} - ) + decoded: dict[str, Any] = jwt.decode(token, options={"verify_signature": False}) exp = decoded.get("exp") if exp is not None: return time.time() > float(exp) @@ -237,12 +217,7 @@ class Gns3Connector: """ # Handle JWT authentication - if ( - self.auth_type == "jwt" - and not self.access_token - and self.user - and self.cred - ): + if self.auth_type == "jwt" and not self.access_token and self.user and self.cred: self._authenticate_v3() # Get request function (e.g., session.get, session.post) @@ -285,15 +260,10 @@ class Gns3Connector: try: # Only attempt parsing when Content-Type is JSON - if ( - "application/json" - in response.headers.get("Content-Type", "").lower() - ): + if "application/json" in response.headers.get("Content-Type", "").lower(): error_json = response.json() status = error_json.get("status", "Unknown Status") - message = error_json.get( - "message", "No message provided in JSON." - ) + message = error_json.get("message", "No message provided in JSON.") # Construct a more descriptive new error new_err = HTTPError( f"{status}: {message} (Original {response.status_code} Error)", diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index 724291782..c57dc8fee 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -76,10 +76,7 @@ def _get_url_from_controller() -> Optional[str]: controller = Controller.instance() local_compute = controller.get_compute("local") - url = ( - f"{local_compute.protocol}://{local_compute.host}:" - f"{local_compute.port}" - ) + url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}" logger.debug( "Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)", url, @@ -98,9 +95,7 @@ def _get_url_from_controller() -> Optional[str]: logger.debug("Local compute not found in Controller: %s", str(e)) return None except Exception as e: - logger.warning( - "Unexpected error getting URL from Controller: %s", str(e) - ) + logger.warning("Unexpected error getting URL from Controller: %s", str(e)) return None @@ -114,10 +109,7 @@ def _get_url_from_config() -> Optional[str]: from gns3server.config import Config server_config = Config.instance().settings.Server - url = ( - f"{server_config.protocol.value}://{server_config.host}:" - f"{server_config.port}" - ) + url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}" logger.debug( "Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)", url, @@ -137,9 +129,7 @@ def _get_url_from_config() -> Optional[str]: return None -def get_gns3_connector( - jwt_token: Optional[str] = None, url: Optional[str] = None -) -> Optional[Gns3Connector]: +def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]: """Create and return a Gns3Connector instance with JWT authentication. URL Resolution Strategy (in order): @@ -223,9 +213,7 @@ def get_gns3_connector( return connector except Exception as e: - logger.error( - "Failed to create Gns3Connector: %s", str(e), exc_info=True - ) + logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True) return None @@ -284,9 +272,7 @@ async def get_gns3_connector_with_llm_config( url = _detect_url_for_api() # Step 3: Get LLM config - llm_config = get_llm_config( - user_id=user_id, jwt_token=jwt_token, app=app - ) + llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app) if not llm_config: logger.warning(f"No LLM config found for user {user_id}") @@ -303,9 +289,7 @@ async def get_gns3_connector_with_llm_config( return {"connector": connector, "llm_config": llm_config} except Exception as e: - logger.error( - f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True - ) + logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True) return None @@ -375,9 +359,7 @@ def get_gns3_server_host() -> str: logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url) return host except Exception as e: - logger.warning( - "Failed to extract host from URL %s: %s, using fallback", url, e - ) + logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e) return DEFAULT_GNS3_URL.split("://")[1].split(":")[0] @@ -428,9 +410,7 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]: # We're in an async context with a running loop # This shouldn't happen since this is a sync function with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit( - asyncio.run, get_user_llm_config_with_app(user_id, app) - ) + future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app)) return future.result(timeout=10) except RuntimeError: # No running event loop - we're in a sync context @@ -449,26 +429,19 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]: return future.result(timeout=10) else: # Loop exists but not running - use it - return loop.run_until_complete( - get_user_llm_config_with_app(user_id, app) - ) + return loop.run_until_complete(get_user_llm_config_with_app(user_id, app)) except RuntimeError: # No event loop exists - create a new one loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - return loop.run_until_complete( - get_user_llm_config_with_app(user_id, app) - ) + return loop.run_until_complete(get_user_llm_config_with_app(user_id, app)) finally: loop.close() # Fallback: No app provided, try API call (will mask group config API # keys) - logger.warning( - "No app provided for get_llm_config, group config API keys may be " - "masked" - ) + logger.warning("No app provided for get_llm_config, group config API keys may be masked") return None except Exception as e: diff --git a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py index 73e275767..5070dcb9a 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py @@ -62,12 +62,8 @@ logger = logging.getLogger(__name__) # Context variables for request-scoped data # Automatically cleaned up when request context ends -_jwt_token_context: ContextVar[Optional[str]] = ContextVar( - "_jwt_token_context", default=None -) -_llm_config_context: ContextVar[Optional[dict]] = ContextVar( - "_llm_config_context", default=None -) +_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None) +_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None) def set_current_jwt_token(token: str) -> None: diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py index eedb72a00..9b699e51f 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py @@ -90,20 +90,13 @@ class GNS3ProjectInfoTool(BaseTool): """ # Log received input - logger.info( - "Received tool_input: %s, project_id: %s", tool_input, project_id - ) + logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id) try: # Validate project_id parameter if not project_id: logger.error("project_id parameter is required.") - return { - "error": ( - "project_id parameter is required. Please provide a valid " - "project UUID." - ) - } + return {"error": ("project_id parameter is required. Please provide a valid project UUID.")} # Build handler context (JWT + server URL from request context) logger.debug("Connecting to GNS3 server...") @@ -111,17 +104,10 @@ class GNS3ProjectInfoTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": ( - "Failed to connect to GNS3 server. Please check your " - "configuration." - ) - } + return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")} # Use the provided project_id directly - logger.info( - f"Retrieving project info for project_id: {project_id}" - ) + logger.info(f"Retrieving project info for project_id: {project_id}") inventory = fetch_project_inventory(gns3_ctx, project_id) # Get node and link counts diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py index 59bd33507..ce5449079 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -100,18 +100,13 @@ class GNS3TopologyTool(BaseTool): """ # Log received input - logger.info( - "Received tool_input: %s, project_id: %s", tool_input, project_id - ) + logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id) try: # Validate project_id parameter if not project_id: logger.error("project_id parameter is required.") - return { - "error": "project_id parameter is required. " - "Please provide a valid project UUID." - } + return {"error": "project_id parameter is required. Please provide a valid project UUID."} # Build handler context (JWT + server URL) # jwt_token/url can be passed explicitly (e.g. from MCP handlers) @@ -121,10 +116,7 @@ class GNS3TopologyTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. Please check " - "your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Use the provided project_id directly logger.info(f"Retrieving topology for project_id: {project_id}") @@ -135,16 +127,13 @@ class GNS3TopologyTool(BaseTool): "project_id": inventory["project_id"], "name": inventory["name"], "status": inventory["status"], - "nodes": self._clean_nodes_ports( - copy.deepcopy(inventory["nodes_inventory"]) - ), + "nodes": self._clean_nodes_ports(copy.deepcopy(inventory["nodes_inventory"])), "links": inventory["links_summary"], } # Log topology result logger.info( - "Topology retrieved: project_id=%s, name=%s, nodes=%d, " - "links=%d", + "Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d", topology.get("project_id"), topology.get("name"), len(topology.get("nodes", {})), @@ -167,10 +156,7 @@ class GNS3TopologyTool(BaseTool): """ for node in data.values(): # Iterate through R-1, R-2, R-3, R-4 if "ports" in node and isinstance(node["ports"], list): - node["ports"] = [ - {"name": port["name"], "short_name": port["short_name"]} - for port in node["ports"] - ] + node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]] return data diff --git a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py index a4b6782da..b1f958767 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py @@ -38,9 +38,7 @@ from urllib.parse import urlparse from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector -def build_nodes_inventory( - nodes: list[dict[str, Any]], server_host: str | None -) -> dict[str, Any]: +def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) -> dict[str, Any]: """ Build an inventory-style dict keyed by node name. @@ -70,9 +68,7 @@ def build_nodes_inventory( return inventory -def build_links_summary( - nodes: list[dict[str, Any]], links: list[dict[str, Any]] -) -> list[dict[str, str]]: +def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]: """ Build a human/LLM-friendly link list resolving node and port names. @@ -87,46 +83,40 @@ def build_links_summary( side_a = link["nodes"][0] side_b = link["nodes"][1] try: - node_a = next( - x for x in nodes if x.get("node_id") == side_a["node_id"] - ) + node_a = next(x for x in nodes if x.get("node_id") == side_a["node_id"]) port_a = str( next( p["name"] for p in (node_a.get("ports") or []) - if p["port_number"] == side_a["port_number"] - and p["adapter_number"] == side_a["adapter_number"] + if p["port_number"] == side_a["port_number"] and p["adapter_number"] == side_a["adapter_number"] ) ) - node_b = next( - x for x in nodes if x.get("node_id") == side_b["node_id"] - ) + node_b = next(x for x in nodes if x.get("node_id") == side_b["node_id"]) port_b = str( next( p["name"] for p in (node_b.get("ports") or []) - if p["port_number"] == side_b["port_number"] - and p["adapter_number"] == side_b["adapter_number"] + if p["port_number"] == side_b["port_number"] and p["adapter_number"] == side_b["adapter_number"] ) ) name_a = str(node_a["name"]) if node_a.get("name") else "Unknown" name_b = str(node_b["name"]) if node_b.get("name") else "Unknown" - summary.append({ - "link_id": link.get("link_id"), - "node_a": name_a, - "port_a": port_a, - "node_b": name_b, - "port_b": port_b, - }) + summary.append( + { + "link_id": link.get("link_id"), + "node_a": name_a, + "port_a": port_a, + "node_b": name_b, + "port_b": port_b, + } + ) except (StopIteration, KeyError, AttributeError): # Prevent errors when lookups can't match data continue return summary -def fetch_project_inventory( - gns3_ctx: dict[str, Any], project_id: str -) -> dict[str, Any]: +def fetch_project_inventory(gns3_ctx: dict[str, Any], project_id: str) -> dict[str, Any]: """ Fetch a project's metadata, nodes and links and return the aggregated inventory — the equivalent of the old ``Project.get()`` + diff --git a/gns3server/agent/gns3_copilot/project_agent_manager.py b/gns3server/agent/gns3_copilot/project_agent_manager.py index edc452f32..b9ade018a 100644 --- a/gns3server/agent/gns3_copilot/project_agent_manager.py +++ b/gns3server/agent/gns3_copilot/project_agent_manager.py @@ -61,9 +61,7 @@ class ProjectAgentManager: cls._instance._lock = asyncio.Lock() return cls._instance - async def get_agent( - self, project_id: str, project_path: str - ) -> AgentService: + async def get_agent(self, project_id: str, project_path: str) -> AgentService: """ Get or create an AgentService for a project. diff --git a/gns3server/agent/gns3_copilot/prompts/prompt_loader.py b/gns3server/agent/gns3_copilot/prompts/prompt_loader.py index 110f76dae..1b664b85c 100644 --- a/gns3server/agent/gns3_copilot/prompts/prompt_loader.py +++ b/gns3server/agent/gns3_copilot/prompts/prompt_loader.py @@ -70,10 +70,7 @@ def load_system_prompt(llm_config: dict | None = None) -> str: str: The system prompt string, or empty string if not found. """ if not llm_config: - logger.debug( - "No LLM config provided, using default TEACHING_ASSISTANT " - "prompt mode" - ) + logger.debug("No LLM config provided, using default TEACHING_ASSISTANT prompt mode") return _load_prompt("teaching_assistant") # llm_config is a flattened dict with copilot_mode at the top level @@ -81,15 +78,10 @@ def load_system_prompt(llm_config: dict | None = None) -> str: mode = llm_config.get("copilot_mode", "teaching_assistant").lower() if mode == "lab_automation_assistant": - logger.debug( - "Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + " - "configuration)" - ) + logger.debug("Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + configuration)") return _load_prompt("lab_automation_assistant") elif mode == "troubleshooting_injection": - logger.debug( - "Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)" - ) + logger.debug("Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)") return _load_prompt("troubleshooting_injection") else: logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)") diff --git a/gns3server/agent/gns3_copilot/skills/loader.py b/gns3server/agent/gns3_copilot/skills/loader.py index fee183ef6..655d76263 100644 --- a/gns3server/agent/gns3_copilot/skills/loader.py +++ b/gns3server/agent/gns3_copilot/skills/loader.py @@ -201,7 +201,9 @@ class SkillsLoader: topic_key = yaml_file.stem logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'") if topic_key in topics: - logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting") + logger.warning( + f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting" + ) # category/topics belong to the base skill only topic_data.pop("category", None) diff --git a/gns3server/agent/gns3_copilot/skills/manager.py b/gns3server/agent/gns3_copilot/skills/manager.py index e14b8a8dc..473fb2b09 100644 --- a/gns3server/agent/gns3_copilot/skills/manager.py +++ b/gns3server/agent/gns3_copilot/skills/manager.py @@ -49,9 +49,9 @@ logger = logging.getLogger(__name__) # Applied per-command via the `env` parameter to avoid polluting # the global process environment. _GIT_TIMEOUT_ENV = { - 'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s) - 'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window - 'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort + "GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s) + "GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window + "GIT_HTTP_LOW_SPEED_LIMIT": "10240", # < 10 KB/s = slow → abort } @@ -66,12 +66,7 @@ class SkillsManager: - Version tracking """ - def __init__( - self, - repo_url: str = None, - branch: str = "main", - auto_update: bool = False - ): + def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False): """ Initialize the skills manager. @@ -147,7 +142,7 @@ class SkillsManager: logger.warning( "Skills repository has uncommitted changes, skipping pull. " "Commit or stash changes in %s to enable automatic updates.", - self.local_path + self.local_path, ) return @@ -161,14 +156,9 @@ class SkillsManager: # Check if behind and pull try: - behind_commits = list(self._repo.iter_commits( - f'{self.branch}..origin/{self.branch}' - )) + behind_commits = list(self._repo.iter_commits(f"{self.branch}..origin/{self.branch}")) if behind_commits: - logger.info( - "Skills repository is behind by %d commit(s), pulling...", - len(behind_commits) - ) + logger.info("Skills repository is behind by %d commit(s), pulling...", len(behind_commits)) origin.pull(self.branch, env=_GIT_TIMEOUT_ENV) logger.info(f"Updated to commit {self.get_current_version()}") else: @@ -188,12 +178,7 @@ class SkillsManager: return False try: - self._repo = git.Repo.clone_from( - self.repo_url, - self.local_path, - branch=self.branch, - env=_GIT_TIMEOUT_ENV - ) + self._repo = git.Repo.clone_from(self.repo_url, self.local_path, branch=self.branch, env=_GIT_TIMEOUT_ENV) logger.info(f"Successfully cloned skills repository to {self.local_path}") return True except git.GitCommandError as e: @@ -256,7 +241,9 @@ class SkillsManager: del SKILLS_REGISTRY[k] SKILLS_REGISTRY.update(all_skills) - logger.info(f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills") + logger.info( + f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills" + ) return True except Exception as e: logger.error(f"Failed to reload skills: {e}") @@ -302,12 +289,7 @@ class SkillsManager: """ try: # Available prompt names - prompt_names = [ - "lab_automation_assistant", - "teaching_assistant", - "troubleshooting_injection", - "title" - ] + prompt_names = ["lab_automation_assistant", "teaching_assistant", "troubleshooting_injection", "title"] # Load all prompts loaded_count = 0 @@ -385,6 +367,7 @@ class SkillsManager: """ try: from .registry import INJECTION_SKILLS_REGISTRY + return len(INJECTION_SKILLS_REGISTRY) except Exception: return 0 @@ -413,7 +396,7 @@ class SkillsManager: "skill_count": self.get_skill_count(), "prompt_count": self.get_prompt_count(), "auto_update": self.auto_update, - "is_initialized": (self.local_path / ".git").exists() + "is_initialized": (self.local_path / ".git").exists(), } def rollback(self, commit_hash: str) -> bool: @@ -462,12 +445,14 @@ class SkillsManager: commits = [] for commit in self._repo.iter_commits(max_count=limit): - commits.append({ - "hash": commit.hexsha, - "message": commit.message.strip(), - "author": str(commit.author), - "date": commit.committed_datetime.isoformat() - }) + commits.append( + { + "hash": commit.hexsha, + "message": commit.message.strip(), + "author": str(commit.author), + "date": commit.committed_datetime.isoformat(), + } + ) return commits except Exception as e: logger.error(f"Failed to get commit history: {e}") diff --git a/gns3server/agent/gns3_copilot/skills/registry.py b/gns3server/agent/gns3_copilot/skills/registry.py index a003413be..d9e2b5083 100644 --- a/gns3server/agent/gns3_copilot/skills/registry.py +++ b/gns3server/agent/gns3_copilot/skills/registry.py @@ -115,7 +115,7 @@ def _ensure_skills_manager(): manager = SkillsManager( repo_url=config.get("repo_url"), branch=config.get("branch", "main"), - auto_update=config.get("auto_update", False) + auto_update=config.get("auto_update", False), ) if not manager.initialize(): @@ -224,7 +224,7 @@ def reload_injection_skills() -> dict[str, Any]: "success": False, "message": "Skills manager not initialized", "skill_count": len(INJECTION_SKILLS_REGISTRY), - "version": "" + "version": "", } try: @@ -233,7 +233,7 @@ def reload_injection_skills() -> dict[str, Any]: "success": success, "message": "Skills reloaded successfully" if success else "Failed to reload skills", "skill_count": manager.get_skill_count(), - "version": manager.get_current_version() + "version": manager.get_current_version(), } except Exception as e: logger.error(f"Error during skills reload: {e}") @@ -241,7 +241,7 @@ def reload_injection_skills() -> dict[str, Any]: "success": False, "message": f"Error: {str(e)}", "skill_count": len(INJECTION_SKILLS_REGISTRY), - "version": "" + "version": "", } @@ -263,12 +263,7 @@ def reload_prompts() -> dict[str, Any]: """ manager = get_skills_manager() if manager is None: - return { - "success": False, - "message": "Skills manager not initialized", - "prompt_count": 0, - "version": "" - } + return {"success": False, "message": "Skills manager not initialized", "prompt_count": 0, "version": ""} try: success = manager.reload_prompts() @@ -276,7 +271,7 @@ def reload_prompts() -> dict[str, Any]: "success": success, "message": "Prompts reloaded successfully" if success else "Failed to reload prompts", "prompt_count": manager.get_prompt_count(), - "version": manager.get_current_version() + "version": manager.get_current_version(), } except Exception as e: logger.error(f"Error during prompts reload: {e}") @@ -285,7 +280,7 @@ def reload_prompts() -> dict[str, Any]: "success": False, "message": f"Error: {str(e)}", "prompt_count": manager.get_prompt_count() if manager else 0, - "version": "" + "version": "", } @@ -315,16 +310,11 @@ def reload_forbidden_commands() -> dict[str, Any]: "success": True, "message": "Forbidden commands reloaded", "command_count": len(commands), - "version": manager.get_current_version() if manager else "" + "version": manager.get_current_version() if manager else "", } except Exception as e: logger.error(f"Error during forbidden commands reload: {e}") - return { - "success": False, - "message": f"Error: {str(e)}", - "command_count": 0, - "version": "" - } + return {"success": False, "message": f"Error: {str(e)}", "command_count": 0, "version": ""} def get_prompt(prompt_name: str) -> str: @@ -360,10 +350,7 @@ def get_skills_repository_info() -> dict[str, Any]: """ manager = get_skills_manager() if manager is None: - return { - "initialized": False, - "message": "Skills manager not initialized" - } + return {"initialized": False, "message": "Skills manager not initialized"} return manager.get_repository_info() @@ -411,8 +398,7 @@ def get_skill( "error": f"device_type '{device_type}' is not in category '{category}'", "device_category": skill.get("category"), "available_in_category": [ - did for did, s in SKILLS_REGISTRY.items() - if s.get("category", "").lower() == category.lower() + did for did, s in SKILLS_REGISTRY.items() if s.get("category", "").lower() == category.lower() ], } @@ -484,8 +470,7 @@ def get_skill( } if topics: result["topics"] = { - k: {"name": v.get("name", k), "description": v.get("description", "")} - for k, v in topics.items() + k: {"name": v.get("name", k), "description": v.get("description", "")} for k, v in topics.items() } return result @@ -503,19 +488,23 @@ def list_available_skills(category: str | None = None) -> list[dict[str, Any]]: for did, skill in SKILLS_REGISTRY.items(): if category: if skill.get("category", "").lower() == category.lower(): - skills.append({ + skills.append( + { + "device_type": did, + "name": skill.get("name", did), + "category": skill.get("category"), + "topic_count": len(skill.get("topics", {})), + } + ) + else: + skills.append( + { "device_type": did, "name": skill.get("name", did), "category": skill.get("category"), "topic_count": len(skill.get("topics", {})), - }) - else: - skills.append({ - "device_type": did, - "name": skill.get("name", did), - "category": skill.get("category"), - "topic_count": len(skill.get("topics", {})), - }) + } + ) return skills @@ -541,7 +530,7 @@ def get_injection_skill( return { "error": f"Unknown injection fault type: {device_type}", "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), - "hint": "Use {'action': 'list'} to see all available fault types" + "hint": "Use {'action': 'list'} to see all available fault types", } issues = skill.get("issues", {}) @@ -611,11 +600,13 @@ def list_available_injection_skills(context: list[str] | None = None) -> list[di category_lower = category.lower() if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context): continue - skills.append({ - "device_type": did, - "name": skill.get("name", did), - "category": category, - }) + skills.append( + { + "device_type": did, + "name": skill.get("name", did), + "category": category, + } + ) return skills @@ -658,11 +649,13 @@ def list_available_packet_analysis_protocols() -> list[dict[str, str]]: """ protocols = [] for key, data in PACKET_ANALYSIS_REGISTRY.items(): - protocols.append({ - "protocol": key, - "name": data.get("name", key), - "description": data.get("description", ""), - }) + protocols.append( + { + "protocol": key, + "name": data.get("name", key), + "description": data.get("description", ""), + } + ) return protocols @@ -711,10 +704,14 @@ class DeviceSkillsTool(BaseTool): try: params = json.loads(tool_input) except json.JSONDecodeError as e: - return json.dumps({ - "error": f"Invalid JSON input: {e}", - "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}' - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": f"Invalid JSON input: {e}", + "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}', + }, + ensure_ascii=False, + indent=2, + ) else: params = tool_input @@ -722,18 +719,19 @@ class DeviceSkillsTool(BaseTool): if action == "list": skills = list_available_skills() - return json.dumps({ - "count": len(skills), - "skills": skills - }, ensure_ascii=False, indent=2) + return json.dumps({"count": len(skills), "skills": skills}, ensure_ascii=False, indent=2) device_type = params.get("device_type") if not device_type: - return json.dumps({ - "error": "Missing required field: device_type", - "available_device_types": list(SKILLS_REGISTRY.keys()), - "hint": 'Use {"action": "list"} to see all available device types' - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": "Missing required field: device_type", + "available_device_types": list(SKILLS_REGISTRY.keys()), + "hint": 'Use {"action": "list"} to see all available device types', + }, + ensure_ascii=False, + indent=2, + ) category = params.get("category") detail = params.get("detail", "full") @@ -788,10 +786,14 @@ class InjectionSkillsTool(BaseTool): try: params = json.loads(tool_input) except json.JSONDecodeError as e: - return json.dumps({ - "error": f"Invalid JSON input: {e}", - "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}' - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": f"Invalid JSON input: {e}", + "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}', + }, + ensure_ascii=False, + indent=2, + ) else: params = tool_input @@ -800,33 +802,44 @@ class InjectionSkillsTool(BaseTool): if action == "list": context = params.get("context") if not context or not isinstance(context, list) or len(context) == 0: - return json.dumps({ - "error": "context parameter is required when action='list'", - "hint": "Analyze the topology and device configurations first, " - "then pass the protocols/services you found as context. " - 'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}', - "available_categories": sorted(set( - skill.get("category", "") - for skill in INJECTION_SKILLS_REGISTRY.values() - )) - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": "context parameter is required when action='list'", + "hint": "Analyze the topology and device configurations first, " + "then pass the protocols/services you found as context. " + 'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}', + "available_categories": sorted( + set(skill.get("category", "") for skill in INJECTION_SKILLS_REGISTRY.values()) + ), + }, + ensure_ascii=False, + indent=2, + ) skills = list_available_injection_skills(context=context) logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching") - return json.dumps({ - "count": len(skills), - "total_available": len(INJECTION_SKILLS_REGISTRY), - "context": context, - "fault_types": skills - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "count": len(skills), + "total_available": len(INJECTION_SKILLS_REGISTRY), + "context": context, + "fault_types": skills, + }, + ensure_ascii=False, + indent=2, + ) device_type = params.get("device_type") if not device_type: - return json.dumps({ - "error": "Missing required field: device_type", - "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), - "hint": 'Use {"action": "list"} to see all available fault types' - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": "Missing required field: device_type", + "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), + "hint": 'Use {"action": "list"} to see all available fault types', + }, + ensure_ascii=False, + indent=2, + ) detail = params.get("detail", "full") issue = params.get("issue") @@ -876,10 +889,14 @@ class PacketAnalysisSkillsTool(BaseTool): try: params = json.loads(tool_input) except json.JSONDecodeError as e: - return json.dumps({ - "error": f"Invalid JSON input: {e}", - "hint": 'Expected format: {"action": "get", "protocol": "ospf"}' - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": f"Invalid JSON input: {e}", + "hint": 'Expected format: {"action": "get", "protocol": "ospf"}', + }, + ensure_ascii=False, + indent=2, + ) else: params = tool_input @@ -887,17 +904,18 @@ class PacketAnalysisSkillsTool(BaseTool): if action == "list": protocols = list_available_packet_analysis_protocols() - return json.dumps({ - "count": len(protocols), - "protocols": protocols - }, ensure_ascii=False, indent=2) + return json.dumps({"count": len(protocols), "protocols": protocols}, ensure_ascii=False, indent=2) protocol = params.get("protocol") if not protocol: - return json.dumps({ - "error": "Missing required field: protocol", - "available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()), - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "error": "Missing required field: protocol", + "available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()), + }, + ensure_ascii=False, + indent=2, + ) result = get_packet_analysis_protocol(protocol) return json.dumps(result, ensure_ascii=False, indent=2) diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py index 285e221dc..db215c6bf 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -196,56 +196,35 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Validate input device_configs_list, project_id = self._validate_tool_input(tool_input) - if ( - isinstance(device_configs_list, list) - and len(device_configs_list) > 0 - and "error" in device_configs_list[0] - ): + if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]: return device_configs_list # Filter forbidden commands and store blocked commands info - device_configs_list, blocked_commands_map = ( - self._filter_forbidden_commands_from_device_configs( - device_configs_list - ) + device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( + device_configs_list ) # Expand multiline commands (e.g., banner commands with embedded newlines) # This converts commands like "banner motd #\nline1\nline2\n#" into # separate commands: ["banner motd #", "line1", "line2", "#"] - device_configs_list = self._expand_multiline_commands( - device_configs_list - ) + device_configs_list = self._expand_multiline_commands(device_configs_list) # Create a mapping of device names to their configuration commands device_configs_map = self._configs_map(device_configs_list) # Prepare device hosts data try: - hosts_data = self._prepare_device_hosts_data( - device_configs_list, project_id, jwt_token=jwt_token, url=url - ) + hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) return [{"status": "failed", "error": str(e)}] # Check if any devices have errors (e.g., missing device_type tag) - error_devices = { - name: data - for name, data in hosts_data.items() - if "error" in data - } + error_devices = {name: data for name, data in hosts_data.items() if "error" in data} if error_devices: - logger.error( - "Devices with configuration errors: %s", - list(error_devices.keys()) - ) + logger.error("Devices with configuration errors: %s", list(error_devices.keys())) return [ - { - "device_name": name, - "status": "failed", - "error": data["error"] - } + {"device_name": name, "status": "failed", "error": data["error"]} for name, data in error_devices.items() ] @@ -275,9 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): except Exception as e: # Overall execution failed - logger.error( - "Error executing configurations on all devices: %s", e - ) + logger.error("Error executing configurations on all devices: %s", e) return [{"status": "failed", "error": f"Execution error: {str(e)}"}] logger.info( @@ -287,22 +264,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return results - def _run_all_device_configs_with_single_retry( - self, task: Task, device_configs_map: dict[str, list[str]] - ) -> Result: + def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result: """Execute configuration commands with single retry mechanism.""" device_name = task.host.name config_commands = device_configs_map.get(device_name, []) if not config_commands: - return Result( - host=task.host, result="No configuration commands to execute" - ) + return Result(host=task.host, result="No configuration commands to execute") try: - _result = task.run( - task=netmiko_send_config, config_commands=config_commands - ) + _result = task.run(task=netmiko_send_config, config_commands=config_commands) return Result(host=task.host, result=_result.result) except ReadTimeout as e: @@ -322,9 +293,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Handle Cisco IOSv L2 where '#' prompt char may be delayed, # causing Netmiko failures. Implements retry logic. if "netmiko_send_config (failed)" in str(e): - _result = task.run( - task=netmiko_send_config, config_commands=config_commands - ) + _result = task.run(task=netmiko_send_config, config_commands=config_commands) return Result(host=task.host, result=_result.result) # Log any other exceptions with full details @@ -364,9 +333,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): parsed_input = json.loads(tool_input) logger.debug("Successfully parsed tool input from JSON string.") except json.JSONDecodeError as e: - logger.error( - "Invalid JSON string received as tool input: %s", e - ) + logger.error("Invalid JSON string received as tool input: %s", e) return ( [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], None, @@ -392,10 +359,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return ([{"status": "failed", "error": error_msg}], None) if not self._validate_project_id(project_id): - error_msg = ( - f"Invalid project_id format: {project_id}. " - "Expected UUID format." - ) + error_msg = f"Invalid project_id format: {project_id}. Expected UUID format." logger.error(error_msg) return ([{"status": "failed", "error": error_msg}], None) @@ -413,10 +377,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Handle legacy format: [...] elif isinstance(parsed_input, list): - logger.warning( - "Using legacy format without project_id. " - "Please use new format with project_id." - ) + logger.warning("Using legacy format without project_id. Please use new format with project_id.") return parsed_input, None else: @@ -438,9 +399,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): Returns: True if valid UUID format, False otherwise """ - uuid_pattern = ( - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - ) + uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) def _filter_forbidden_commands_from_device_configs( @@ -465,9 +424,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): commands = device_config["config_commands"] # Filter commands - allowed_commands, blocked_info = filter_forbidden_commands( - commands - ) + allowed_commands, blocked_info = filter_forbidden_commands(commands) # Update device config with allowed commands only filtered_config = device_config.copy() @@ -486,9 +443,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return filtered_list, blocked_commands_map - def _expand_multiline_commands( - self, device_configs_list: list[dict[str, Any]] - ) -> list[dict[str, Any]]: + def _expand_multiline_commands(self, device_configs_list: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Expand commands that contain embedded newline characters. @@ -540,9 +495,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return expanded_list - def _configs_map( - self, device_config_list: list[dict[str, Any]] - ) -> dict[str, list[str]]: + def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: """Create a mapping of device names to their configuration commands.""" device_configs_map = {} for device_config in device_config_list: @@ -561,15 +514,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ) -> dict[str, dict[str, Any]]: """Prepare device hosts data from topology information.""" # Extract device names list - device_names = [ - device_config["device_name"] - for device_config in device_config_list - ] + device_names = [device_config["device_name"] for device_config in device_config_list] # Get device port information with project_id - hosts_data = get_device_ports_from_topology( - device_names, project_id, jwt_token=jwt_token, url=url - ) + hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url) if not hosts_data: error_msg = ( @@ -589,9 +537,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return hosts_data - def _initialize_nornir( - self, hosts_data: dict[str, dict[str, Any]] - ) -> Nornir: + def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir: """ Initialize Nornir with the provided hosts data. @@ -616,8 +562,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Log device types being configured device_types = [ - host["connection_options"]["netmiko"]["extras"]["device_type"] - for host in hosts_data.values() + host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values() ] logger.info( "Initializing Nornir: host=%s, device_types=%s, hosts=%d", @@ -665,16 +610,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": ( - f"Device '{device_name}' not found in topology or " - "missing console_port" - ), + "error": (f"Device '{device_name}' not found in topology or missing console_port"), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list( - blocked_commands_info.keys() - ) + device_result["blocked_commands"] = list(blocked_commands_info.keys()) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -684,15 +624,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": ( - f"Device '{device_name}' not found in task results" - ), + "error": (f"Device '{device_name}' not found in task results"), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list( - blocked_commands_info.keys() - ) + device_result["blocked_commands"] = list(blocked_commands_info.keys()) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -704,9 +640,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): if multi_result[0].failed: # Execution failed device_result["status"] = "failed" - device_result["error"] = ( - f"Configuration execution failed: {multi_result[0].result}" - ) + device_result["error"] = f"Configuration execution failed: {multi_result[0].result}" device_result["output"] = multi_result[0].result else: # Execution successful @@ -716,9 +650,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list( - blocked_commands_info.keys() - ) + device_result["blocked_commands"] = list(blocked_commands_info.keys()) device_result["blocked_info"] = blocked_commands_info # Update status if some commands were blocked but succeeded if device_result["status"] == "success": diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py index 7c4f45622..8c4a5281d 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -199,18 +199,12 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Validate input device_configs_list, project_id = self._validate_tool_input(tool_input) - if ( - isinstance(device_configs_list, list) - and len(device_configs_list) > 0 - and "error" in device_configs_list[0] - ): + if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]: return device_configs_list # Filter forbidden commands and store blocked commands info - device_configs_list, blocked_commands_map = ( - self._filter_forbidden_commands_from_device_configs( - device_configs_list - ) + device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( + device_configs_list ) # Create a mapping of device names to their display commands @@ -218,30 +212,17 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Prepare device hosts data try: - hosts_data = self._prepare_device_hosts_data( - device_configs_list, project_id, jwt_token=jwt_token, url=url - ) + hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) return [{"status": "failed", "error": str(e)}] # Check if any devices have errors (e.g., missing device_type tag) - error_devices = { - name: data - for name, data in hosts_data.items() - if "error" in data - } + error_devices = {name: data for name, data in hosts_data.items() if "error" in data} if error_devices: - logger.error( - "Devices with configuration errors: %s", - list(error_devices.keys()) - ) + logger.error("Devices with configuration errors: %s", list(error_devices.keys())) return [ - { - "device_name": name, - "status": "failed", - "error": data["error"] - } + {"device_name": name, "status": "failed", "error": data["error"]} for name, data in error_devices.items() ] @@ -281,17 +262,13 @@ class ExecuteMultipleDeviceCommands(BaseTool): return results - def _run_all_device_configs_with_single_retry( - self, task: Task, device_configs_map: dict[str, list[str]] - ) -> Result: + def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result: """Execute READ-ONLY diagnostic commands with single retry.""" device_name = task.host.name diagnostic_commands = device_configs_map.get(device_name, []) if not diagnostic_commands: - return Result( - host=task.host, result="No diagnostic commands to execute" - ) + return Result(host=task.host, result="No diagnostic commands to execute") try: _result = task.run( @@ -366,9 +343,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): parsed_input = json.loads(tool_input) logger.info("Successfully parsed tool input from JSON string.") except json.JSONDecodeError as e: - logger.error( - "Invalid JSON string received as tool input: %s", e - ) + logger.error("Invalid JSON string received as tool input: %s", e) return ( [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], None, @@ -412,9 +387,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Handle legacy format: [...] elif isinstance(parsed_input, list): - logger.warning( - "Legacy input format without project_id. Use new format." - ) + logger.warning("Legacy input format without project_id. Use new format.") return parsed_input, None else: @@ -435,9 +408,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): Returns: True if valid UUID format, False otherwise """ - uuid_pattern = ( - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - ) + uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) def _filter_forbidden_commands_from_device_configs( @@ -462,9 +433,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): commands = device_config["commands"] # Filter commands - allowed_commands, blocked_info = filter_forbidden_commands( - commands - ) + allowed_commands, blocked_info = filter_forbidden_commands(commands) # Update device config with allowed commands only filtered_config = device_config.copy() @@ -483,9 +452,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): return filtered_list, blocked_commands_map - def _configs_map( - self, device_config_list: list[dict[str, Any]] - ) -> dict[str, list[str]]: + def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: """Create a mapping of device names to their diagnostic commands.""" device_diagnostic_map = {} for device_config in device_config_list: @@ -504,21 +471,13 @@ class ExecuteMultipleDeviceCommands(BaseTool): ) -> dict[str, dict[str, Any]]: """Prepare device hosts data from topology information.""" # Extract device names list - device_names = [ - device_config["device_name"] - for device_config in device_config_list - ] + device_names = [device_config["device_name"] for device_config in device_config_list] # Get device port information with project_id - hosts_data = get_device_ports_from_topology( - device_names, project_id, jwt_token=jwt_token, url=url - ) + hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url) if not hosts_data: - error_msg = ( - f"Failed to get device info from topology. " - f"Project: {project_id}, Devices: {device_names}" - ) + error_msg = f"Failed to get device info from topology. Project: {project_id}, Devices: {device_names}" raise ValueError(error_msg) # Check for missing devices @@ -532,9 +491,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): return hosts_data - def _initialize_nornir( - self, hosts_data: dict[str, dict[str, Any]] - ) -> Nornir: + def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir: """ Initialize Nornir with the provided hosts data. @@ -559,8 +516,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Log device types being configured device_types = [ - host["connection_options"]["netmiko"]["extras"]["device_type"] - for host in hosts_data.values() + host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values() ] logger.info( "Initializing Nornir: host=%s, device_types=%s, hosts=%d", @@ -608,16 +564,11 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": ( - f"Device '{device_name}' not found in topology " - "or missing console_port" - ), + "error": (f"Device '{device_name}' not found in topology or missing console_port"), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list( - blocked_commands_info.keys() - ) + device_result["blocked_commands"] = list(blocked_commands_info.keys()) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -627,15 +578,11 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": ( - f"Device '{device_name}' not found in task results" - ), + "error": (f"Device '{device_name}' not found in task results"), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list( - blocked_commands_info.keys() - ) + device_result["blocked_commands"] = list(blocked_commands_info.keys()) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -647,9 +594,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): if multi_result[0].failed: # Execution failed device_result["status"] = "failed" - device_result["error"] = ( - f"Diagnostic command failed: {multi_result[0].result}" - ) + device_result["error"] = f"Diagnostic command failed: {multi_result[0].result}" device_result["output"] = multi_result[0].result else: # Execution successful @@ -659,9 +604,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list( - blocked_commands_info.keys() - ) + device_result["blocked_commands"] = list(blocked_commands_info.keys()) device_result["blocked_info"] = blocked_commands_info # Update status if some commands were blocked but succeeded if device_result["status"] == "success": diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py index 86d28769f..8f926586b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py @@ -132,9 +132,7 @@ class GNS3LinkTool(BaseTool): if not isinstance(links_data, list) or len(links_data) == 0: logger.error("Invalid links data: must be a non-empty array") - return [ - {"error": "Invalid links data: must be a non-empty array"} - ] + return [{"error": "Invalid links data: must be a non-empty array"}] # Build handler context (JWT + server URL from request context) logger.info("Connecting to GNS3 server...") @@ -142,14 +140,7 @@ class GNS3LinkTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return [ - { - "error": ( - "Failed to connect to GNS3 server. " - "Please check your configuration." - ) - } - ] + return [{"error": ("Failed to connect to GNS3 server. Please check your configuration.")}] # Fetch all nodes once for port resolution listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -172,9 +163,7 @@ class GNS3LinkTool(BaseTool): # Validate link parameters if not all([node_id1, port1, node_id2, port2]): - error_msg = ( - f"Missing required fields in link definition {i}" - ) + error_msg = f"Missing required fields in link definition {i}" logger.error(error_msg) created_links.append({"error": error_msg}) continue @@ -218,29 +207,15 @@ class GNS3LinkTool(BaseTool): "nodes": [ { "node_id": node_id1, - "adapter_number": port1_info.get( - "adapter_number", 0 - ), - "port_number": port1_info.get( - "port_number", 0 - ), - "label": { - "text": port1_info.get("short_name") - or port1 - }, + "adapter_number": port1_info.get("adapter_number", 0), + "port_number": port1_info.get("port_number", 0), + "label": {"text": port1_info.get("short_name") or port1}, }, { "node_id": node_id2, - "adapter_number": port2_info.get( - "adapter_number", 0 - ), - "port_number": port2_info.get( - "port_number", 0 - ), - "label": { - "text": port2_info.get("short_name") - or port2 - }, + "adapter_number": port2_info.get("adapter_number", 0), + "port_number": port2_info.get("port_number", 0), + "label": {"text": port2_info.get("short_name") or port2}, }, ], "fields": ["link_id"], @@ -266,9 +241,7 @@ class GNS3LinkTool(BaseTool): created_links.append({"error": error_msg}) # Log final results - success_count = len( - [link for link in created_links if "error" not in link] - ) + success_count = len([link for link in created_links if "error" not in link]) logger.info( "Link creation completed: %d successful, %d failed", success_count, diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py index e03e42ec4..a511de21f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py @@ -169,9 +169,7 @@ class GNS3CreateNodeTool(BaseTool): # Validate each node in the array for i, node_data in enumerate(nodes): if not isinstance(node_data, dict): - logger.error( - "Invalid input: Node %d must be a dictionary.", i + 1 - ) + logger.error("Invalid input: Node %d must be a dictionary.", i + 1) return {"error": f"Node {i + 1} must be a dictionary."} template_id = node_data.get("template_id") @@ -187,14 +185,10 @@ class GNS3CreateNodeTool(BaseTool): ] ): logger.error( - "Invalid input: Node %d missing or invalid " - "template_id, x, or y.", + "Invalid input: Node %d missing or invalid template_id, x, or y.", i + 1, ) - return { - "error": f"Node {i + 1} missing or invalid " - f"template_id, x, or y." - } + return {"error": f"Node {i + 1} missing or invalid template_id, x, or y."} # Build handler context (JWT + server URL from request context) logger.info("Connecting to GNS3 server...") @@ -202,15 +196,10 @@ class GNS3CreateNodeTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Create nodes - logger.info( - "Creating %d nodes in project %s...", len(nodes), project_id - ) + logger.info("Creating %d nodes in project %s...", len(nodes), project_id) results: list[dict[str, Any]] = [] for i, node_data in enumerate(nodes): @@ -261,12 +250,8 @@ class GNS3CreateNodeTool(BaseTool): # Continue with next node even if one fails # Calculate summary statistics - successful_nodes = len( - [r for r in results if r.get("status") == "success"] - ) - failed_nodes = len( - [r for r in results if r.get("status") == "failed"] - ) + successful_nodes = len([r for r in results if r.get("status") == "success"]) + failed_nodes = len([r for r in results if r.get("status") == "failed"]) # Prepare final result final_result = { @@ -293,9 +278,7 @@ class GNS3CreateNodeTool(BaseTool): return {"error": f"Invalid JSON input: {e}"} except Exception as e: logger.error("Failed to process node creation request: %s", e) - return { - "error": f"Failed to process node creation request: {str(e)}" - } + return {"error": f"Failed to process node creation request: {str(e)}"} if __name__ == "__main__": diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py index e10e6390f..af685ea4b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py @@ -46,12 +46,12 @@ logger = logging.getLogger(__name__) # Built-in templates to filter out (utility templates, not actual network devices) FILTERED_TEMPLATES = { - "atm_switch", # ATM switch - "cloud", # Cloud - "ethernet_hub", # Ethernet hub - "ethernet_switch", # Ethernet switch (built-in) + "atm_switch", # ATM switch + "cloud", # Cloud + "ethernet_hub", # Ethernet hub + "ethernet_switch", # Ethernet switch (built-in) "frame_relay_switch", # Frame Relay switch - "nat", # NAT device + "nat", # NAT device } @@ -131,17 +131,10 @@ class GNS3TemplateTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return { - "error": ( - "Failed to connect to GNS3 server. " - "Please check your configuration." - ) - } + return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")} # Retrieve all available templates - templates = gns3_server.http_call( - "get", f"{gns3_server.base_url}/templates" - ).json() + templates = gns3_server.http_call("get", f"{gns3_server.base_url}/templates").json() # Filter out utility templates and extract relevant info template_info = [] @@ -154,11 +147,13 @@ class GNS3TemplateTool(BaseTool): continue # Extract name, template_id, and template_type - template_info.append({ - "name": template.get("name", "N/A"), - "template_id": template.get("template_id", "N/A"), - "template_type": template.get("template_type", "N/A"), - }) + template_info.append( + { + "name": template.get("name", "N/A"), + "template_id": template.get("template_id", "N/A"), + "template_type": template.get("template_type", "N/A"), + } + ) # Return JSON-formatted result with full logging result = {"templates": template_info} @@ -172,9 +167,7 @@ class GNS3TemplateTool(BaseTool): return result except Exception as e: - logger.error( - "Failed to connect to GNS3 server or retrieve templates: %s", e - ) + logger.error("Failed to connect to GNS3 server or retrieve templates: %s", e) return {"error": f"Failed to retrieve templates: {str(e)}"} diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py index 562a80f1b..d94a0fc1d 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py @@ -198,18 +198,14 @@ class GNS3PacketFilterTool(BaseTool): valid_actions = ["get_available", "set", "get", "clear"] if action not in valid_actions: logger.error("Invalid action: %s. Must be one of %s", action, valid_actions) - return { - "error": f"Invalid action: {action}. Must be one of {valid_actions}" - } + return {"error": f"Invalid action: {action}. Must be one of {valid_actions}"} # Validate filters for "set" action if action == "set": filters = input_data.get("filters") if not filters or not isinstance(filters, dict): logger.error("Invalid input: 'set' action requires 'filters' dict.") - return { - "error": "'set' action requires 'filters' dict with filter configuration." - } + return {"error": "'set' action requires 'filters' dict with filter configuration."} # Build handler context (JWT + server URL from request context) logger.info("Connecting to GNS3 server...") @@ -217,28 +213,19 @@ class GNS3PacketFilterTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Execute action - logger.info( - "Processing packet filter action '%s' for link %s...", action, link_id - ) + logger.info("Processing packet filter action '%s' for link %s...", action, link_id) if action == "get_available": result = self._get_available_filters(gns3_ctx, project_id, link_id) elif action == "set": filters = input_data.get("filters", {}) - result = self._set_filters( - gns3_ctx, project_id, link_id, filters, show_filters_icon - ) + result = self._set_filters(gns3_ctx, project_id, link_id, filters, show_filters_icon) elif action == "get": result = self._get_filters(gns3_ctx, project_id, link_id) elif action == "clear": - result = self._clear_filters( - gns3_ctx, project_id, link_id, show_filters_icon - ) + result = self._clear_filters(gns3_ctx, project_id, link_id, show_filters_icon) else: result = {"error": f"Unknown action: {action}"} @@ -252,18 +239,12 @@ class GNS3PacketFilterTool(BaseTool): return {"error": f"Invalid JSON input: {e}"} except Exception as e: logger.error("Failed to process packet filter request: %s", e) - return { - "error": f"Failed to process packet filter request: {str(e)}" - } + return {"error": f"Failed to process packet filter request: {str(e)}"} - def _get_available_filters( - self, gns3_ctx: dict, project_id: str, link_id: str - ) -> dict[str, Any]: + def _get_available_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]: """Get available filter types for the link.""" try: - filters = available_filters_handler( - {"project_id": project_id, "link_id": link_id}, gns3_ctx - ) + filters = available_filters_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx) if "error" in filters: raise RuntimeError(filters["error"]) logger.info("Retrieved %d available filter types.", len(filters)) @@ -307,13 +288,9 @@ class GNS3PacketFilterTool(BaseTool): if "Invalid" in result.stdout or "Invalid" in result.stderr: error_lines = [] if "Invalid" in result.stderr: - error_lines.extend( - line for line in result.stderr.split("\n") if "Invalid" in line - ) + error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line) if "Invalid" in result.stdout: - error_lines.extend( - line for line in result.stdout.split("\n") if "Invalid" in line - ) + error_lines.extend(line for line in result.stdout.split("\n") if "Invalid" in line) # Strip interface suffix (e.g., "for interface 'lo'") for cleaner error error_msg_parts = [] @@ -336,10 +313,7 @@ class GNS3PacketFilterTool(BaseTool): except FileNotFoundError: # tshark not installed - skip validation - logger.warning( - "tshark not found, skipping BPF syntax validation. " - "Install tshark to enable BPF validation." - ) + logger.warning("tshark not found, skipping BPF syntax validation. Install tshark to enable BPF validation.") return {"valid": True, "error": None} except Exception as e: @@ -414,14 +388,10 @@ class GNS3PacketFilterTool(BaseTool): "status": "failed", } - def _get_filters( - self, gns3_ctx: dict, project_id: str, link_id: str - ) -> dict[str, Any]: + def _get_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]: """Get current filters configured on the link.""" try: - link = get_link_handler( - {"project_id": project_id, "link_id": link_id}, gns3_ctx - ) + link = get_link_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx) if "error" in link: raise RuntimeError(link["error"]) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py index e7ae2dd82..8e98c7480 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -106,13 +106,8 @@ class GNS3StartNodeTool(BaseTool): # Validate input if not project_id or not node_ids: - logger.error( - "Missing required fields: project_id or node_ids." - ) - return { - "error": "Missing required fields: " - "project_id and node_ids." - } + logger.error("Missing required fields: project_id or node_ids.") + return {"error": "Missing required fields: project_id and node_ids."} if not isinstance(node_ids, list): logger.error("node_ids must be a list.") @@ -124,10 +119,7 @@ class GNS3StartNodeTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Verify nodes exist and capture pre-start info (one call) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -143,14 +135,8 @@ class GNS3StartNodeTool(BaseTool): ) results = [] known_ids = [nid for nid in node_ids if nid in nodes_by_id] - start_results = start_node_handler( - {"project_id": project_id, "node_ids": known_ids}, gns3_ctx - ) - start_errors = { - r["node_id"]: r.get("error") - for r in start_results - if r.get("status") == "error" - } + start_results = start_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx) + start_errors = {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"} # Get immediate status (likely 'starting' or 'stopped') — one call listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -160,9 +146,7 @@ class GNS3StartNodeTool(BaseTool): for node_id in node_ids: if node_id not in nodes_by_id: - logger.error( - "Node %s not found in project %s", node_id, project_id - ) + logger.error("Node %s not found in project %s", node_id, project_id) results.append( { "node_id": node_id, @@ -204,9 +188,7 @@ class GNS3StartNodeTool(BaseTool): ) # Analyze results (count based on successful command sending) - successful_nodes = [ - r for r in results if r.get("status") != "error" - ] + successful_nodes = [r for r in results if r.get("status") != "error"] failed_nodes = [r for r in results if r.get("status") == "error"] # Construct final response @@ -217,8 +199,7 @@ class GNS3StartNodeTool(BaseTool): "failed": len(failed_nodes), "nodes": results, "note": ( - "Start commands sent. Nodes are booting in background. " - "Use wait_seconds, then check node status." + "Start commands sent. Nodes are booting in background. Use wait_seconds, then check node status." ), } @@ -249,9 +230,7 @@ if __name__ == "__main__": test_input_single = json.dumps( { "project_id": "", # Replace with actual project UUID - "node_ids": [ - "fbeda109-9a74-4d8c-a749-cc3847911a90" - ], # Replace with actual node UUID + "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID } ) tool = GNS3StartNodeTool() diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py index a6cdfc2a2..3c0d8ef2c 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py @@ -94,13 +94,8 @@ class GNS3StopNodeTool(BaseTool): # Validate input if not project_id or not node_ids: - logger.error( - "Missing required fields: project_id or node_ids." - ) - return { - "error": "Missing required fields: " - "project_id and node_ids." - } + logger.error("Missing required fields: project_id or node_ids.") + return {"error": "Missing required fields: project_id and node_ids."} if not isinstance(node_ids, list): logger.error("node_ids must be a list.") @@ -112,10 +107,7 @@ class GNS3StopNodeTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Verify nodes exist and capture names (one call) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -131,14 +123,8 @@ class GNS3StopNodeTool(BaseTool): ) results = [] known_ids = [nid for nid in node_ids if nid in nodes_by_id] - stop_results = stop_node_handler( - {"project_id": project_id, "node_ids": known_ids}, gns3_ctx - ) - stop_errors = { - r["node_id"]: r.get("error") - for r in stop_results - if r.get("status") == "error" - } + stop_results = stop_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx) + stop_errors = {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"} # Get updated status — one call listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -148,9 +134,7 @@ class GNS3StopNodeTool(BaseTool): for node_id in node_ids: if node_id not in nodes_by_id: - logger.error( - "Node %s not found in project %s", node_id, project_id - ) + logger.error("Node %s not found in project %s", node_id, project_id) results.append( { "node_id": node_id, @@ -160,9 +144,7 @@ class GNS3StopNodeTool(BaseTool): } ) elif node_id in stop_errors: - logger.error( - "Failed to stop node %s: %s", node_id, stop_errors[node_id] - ) + logger.error("Failed to stop node %s: %s", node_id, stop_errors[node_id]) results.append( { "node_id": node_id, @@ -187,9 +169,7 @@ class GNS3StopNodeTool(BaseTool): ) # Analyze results - successful_nodes = [ - r for r in results if r.get("status") != "error" - ] + successful_nodes = [r for r in results if r.get("status") != "error"] failed_nodes = [r for r in results if r.get("status") == "error"] # Construct final response @@ -223,9 +203,7 @@ if __name__ == "__main__": test_input_single = json.dumps( { "project_id": "", # Replace with actual project UUID - "node_ids": [ - "fbeda109-9a74-4d8c-a749-cc3847911a90" - ], # Replace with actual node UUID + "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID } ) tool = GNS3StopNodeTool() diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py index c7d28025e..74f9ba883 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py @@ -100,13 +100,8 @@ class GNS3SuspendNodeTool(BaseTool): # Validate input if not project_id or not node_ids: - logger.error( - "Missing required fields: project_id or node_ids." - ) - return { - "error": "Missing required fields: " - "project_id and node_ids." - } + logger.error("Missing required fields: project_id or node_ids.") + return {"error": "Missing required fields: project_id and node_ids."} if not isinstance(node_ids, list): logger.error("node_ids must be a list.") @@ -118,10 +113,7 @@ class GNS3SuspendNodeTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Verify nodes exist and capture names (one call) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -137,14 +129,8 @@ class GNS3SuspendNodeTool(BaseTool): ) results = [] known_ids = [nid for nid in node_ids if nid in nodes_by_id] - suspend_results = suspend_node_handler( - {"project_id": project_id, "node_ids": known_ids}, gns3_ctx - ) - suspend_errors = { - r["node_id"]: r.get("error") - for r in suspend_results - if r.get("status") == "error" - } + suspend_results = suspend_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx) + suspend_errors = {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"} # Get updated status — one call listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -154,9 +140,7 @@ class GNS3SuspendNodeTool(BaseTool): for node_id in node_ids: if node_id not in nodes_by_id: - logger.error( - "Node %s not found in project %s", node_id, project_id - ) + logger.error("Node %s not found in project %s", node_id, project_id) results.append( { "node_id": node_id, @@ -195,9 +179,7 @@ class GNS3SuspendNodeTool(BaseTool): ) # Analyze results - successful_nodes = [ - r for r in results if r.get("status") != "error" - ] + successful_nodes = [r for r in results if r.get("status") != "error"] failed_nodes = [r for r in results if r.get("status") == "error"] # Construct final response @@ -207,10 +189,7 @@ class GNS3SuspendNodeTool(BaseTool): "successful": len(successful_nodes), "failed": len(failed_nodes), "nodes": results, - "note": ( - "Suspended nodes preserve their state in memory. " - "Use resume to continue where you left off." - ), + "note": ("Suspended nodes preserve their state in memory. Use resume to continue where you left off."), } logger.info( @@ -235,9 +214,7 @@ if __name__ == "__main__": test_input_single = json.dumps( { "project_id": "", # Replace with actual project UUID - "node_ids": [ - "fbeda109-9a74-4d8c-a749-cc3847911a90" - ], # Replace with actual node UUID + "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID } ) tool = GNS3SuspendNodeTool() diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py index f6b00171b..7756b33a2 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py @@ -125,9 +125,7 @@ class GNS3UpdateNodeNameTool(BaseTool): return {"error": f"Node {i + 1} must be a dictionary."} if "node_id" not in node_data or "new_name" not in node_data: logger.error("Node %d missing node_id or new_name.", i + 1) - return { - "error": f"Node {i + 1} missing node_id or new_name." - } + return {"error": f"Node {i + 1} missing node_id or new_name."} # Build handler context (JWT + server URL from request context) logger.info("Connecting to GNS3 server...") @@ -135,10 +133,7 @@ class GNS3UpdateNodeNameTool(BaseTool): if gns3_ctx is None: logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} # Fetch current node names in one call (old names + existence) listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) @@ -208,9 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool): "error": "Name verification failed", } results.append(error_info) - logger.error( - "Failed to update node name for %s", node_id - ) + logger.error("Failed to update node name for %s", node_id) except Exception as e: error_info = { @@ -223,9 +216,7 @@ class GNS3UpdateNodeNameTool(BaseTool): logger.error("Failed to update node %d: %s", i + 1, e) # Analyze results - successful_nodes = [ - r for r in results if r.get("status") == "success" - ] + successful_nodes = [r for r in results if r.get("status") == "success"] failed_nodes = [r for r in results if r.get("status") == "failed"] # Construct final response diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py index d31e9e6d2..15a9ed24e 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py @@ -88,9 +88,7 @@ class GNS3WaitTool(BaseTool): if not isinstance(seconds, int) or isinstance(seconds, bool): return {"error": "seconds must be an integer (1-600)."} if not 1 <= seconds <= MAX_WAIT_SECONDS: - return { - "error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}." - } + return {"error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}."} logger.info("Waiting %d seconds...", seconds) waited = 0 diff --git a/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py b/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py index 233b24e56..b9c35d141 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py +++ b/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py @@ -123,19 +123,25 @@ class PacketAnalysisTool(BaseTool): query = query.strip() if len(query.split()) > 1: - return json.dumps({ - "error": "Only one keyword allowed", - "hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words', - }) + return json.dumps( + { + "error": "Only one keyword allowed", + "hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words', + } + ) tshark = subprocess.Popen( ["tshark", "-G", "fields"], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, ) grep = subprocess.Popen( ["grep", "-iF", query, "-"], - stdin=tshark.stdout, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, text=True, + stdin=tshark.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, ) tshark.stdout.close() stdout, _ = grep.communicate(timeout=30) @@ -151,21 +157,27 @@ class PacketAnalysisTool(BaseTool): name = parts[2] if name not in seen: seen.add(name) - results.append({ - "field": name, - "type": parts[3] if len(parts) > 3 else "", - "protocol": parts[4] if len(parts) > 4 else "", - "description": parts[7] if len(parts) > 7 else "", - }) + results.append( + { + "field": name, + "type": parts[3] if len(parts) > 3 else "", + "protocol": parts[4] if len(parts) > 4 else "", + "description": parts[7] if len(parts) > 7 else "", + } + ) if len(results) >= 40: break - return json.dumps({ - "query": query, - "count": len(results), - "fields": results, - "hint": "Use the 'field' values in tshark_args with -e", - }, ensure_ascii=False, indent=2) + return json.dumps( + { + "query": query, + "count": len(results), + "fields": results, + "hint": "Use the 'field' values in tshark_args with -e", + }, + ensure_ascii=False, + indent=2, + ) @classmethod def _load_valid_tshark_fields(cls) -> set: @@ -235,11 +247,13 @@ class PacketAnalysisTool(BaseTool): i += 1 if invalid_fields: - return json.dumps({ - "error": f"Invalid tshark field names: {', '.join(invalid_fields)}", - "hint": 'Use {"action": "search_fields", "query": ""} to look up valid field names', - "invalid_fields": invalid_fields, - }) + return json.dumps( + { + "error": f"Invalid tshark field names: {', '.join(invalid_fields)}", + "hint": 'Use {"action": "search_fields", "query": ""} to look up valid field names', + "invalid_fields": invalid_fields, + } + ) return None @@ -271,8 +285,7 @@ class PacketAnalysisTool(BaseTool): return self._search_fields(query or "") logger.info( - f"PacketAnalysisTool invoked: project_id={project_id}, " - f"link_id={link_id}, tshark_args={tshark_args}" + f"PacketAnalysisTool invoked: project_id={project_id}, link_id={link_id}, tshark_args={tshark_args}" ) # Validate inputs @@ -429,6 +442,7 @@ class PacketAnalysisTool(BaseTool): """ # Build command: tshark -r import shlex + cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args) logger.info(f"Running tshark: {' '.join(cmd)}") @@ -451,10 +465,7 @@ class PacketAnalysisTool(BaseTool): f"tshark reported: {stderr_clean}", ] if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower(): - hints.append( - 'Use {"action": "search_fields", "query": ""} ' - "to find correct field names." - ) + hints.append('Use {"action": "search_fields", "query": ""} to find correct field names.') logger.warning(f"tshark stderr: {stderr}") return json.dumps({"error": "tshark argument error", "hints": hints}) diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py index 8339baefc..3a8789cda 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -179,11 +179,7 @@ class VPCSCommands(BaseTool): # Validate input device_configs_list, project_id = self._validate_tool_input(tool_input) - if ( - isinstance(device_configs_list, list) - and len(device_configs_list) > 0 - and "error" in device_configs_list[0] - ): + if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]: return device_configs_list # Create a mapping of device names to their commands @@ -191,30 +187,17 @@ class VPCSCommands(BaseTool): # Prepare device hosts data try: - hosts_data = self._prepare_device_hosts_data( - device_configs_list, project_id, jwt_token=jwt_token, url=url - ) + hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) return [{"status": "failed", "error": str(e)}] # Check if any devices have errors (e.g., missing device) - error_devices = { - name: data - for name, data in hosts_data.items() - if "error" in data - } + error_devices = {name: data for name, data in hosts_data.items() if "error" in data} if error_devices: - logger.error( - "Devices with configuration errors: %s", - list(error_devices.keys()) - ) + logger.error("Devices with configuration errors: %s", list(error_devices.keys())) return [ - { - "device_name": name, - "status": "failed", - "error": data["error"] - } + {"device_name": name, "status": "failed", "error": data["error"]} for name, data in error_devices.items() ] @@ -253,17 +236,13 @@ class VPCSCommands(BaseTool): return results - def _run_vpcs_commands( - self, task: Task, device_configs_map: dict[str, list[str]] - ) -> Result: + def _run_vpcs_commands(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result: """Execute VPCS commands with single retry.""" device_name = task.host.name commands = device_configs_map.get(device_name, []) if not commands: - return Result( - host=task.host, result="No commands to execute" - ) + return Result(host=task.host, result="No commands to execute") try: # Use netmiko_multiline for VPCS commands @@ -334,9 +313,7 @@ class VPCSCommands(BaseTool): parsed_input = json.loads(tool_input) logger.info("Successfully parsed tool input from JSON string.") except json.JSONDecodeError as e: - logger.error( - "Invalid JSON string received as tool input: %s", e - ) + logger.error("Invalid JSON string received as tool input: %s", e) return ( [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], None, @@ -377,10 +354,7 @@ class VPCSCommands(BaseTool): return device_configs, project_id else: - error_msg = ( - "Tool input must be JSON with project_id and device_configs, " - f"got {type(parsed_input).__name__}" - ) + error_msg = f"Tool input must be JSON with project_id and device_configs, got {type(parsed_input).__name__}" logger.error(error_msg) return ([{"status": "failed", "error": error_msg}], None) @@ -394,14 +368,10 @@ class VPCSCommands(BaseTool): Returns: True if valid UUID format, False otherwise """ - uuid_pattern = ( - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - ) + uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) - def _configs_map( - self, device_config_list: list[dict[str, Any]] - ) -> dict[str, list[str]]: + def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: """ Create a mapping of device names to their command lists. @@ -411,10 +381,7 @@ class VPCSCommands(BaseTool): Returns: Dictionary mapping device names to command lists """ - return { - config["device_name"]: config["commands"] - for config in device_config_list - } + return {config["device_name"]: config["commands"] for config in device_config_list} def _prepare_device_hosts_data( self, @@ -445,18 +412,14 @@ class VPCSCommands(BaseTool): device_names = [config["device_name"] for config in device_configs_list] # Get device port mappings from topology - device_ports = get_device_ports_from_topology( - device_names, project_id=project_id, jwt_token=jwt_token, url=url - ) + device_ports = get_device_ports_from_topology(device_names, project_id=project_id, jwt_token=jwt_token, url=url) # Build Nornir inventory hosts data hosts_data = {} for device_name in device_names: if device_name not in device_ports: logger.error("Device '%s' not found in topology", device_name) - hosts_data[device_name] = { - "error": f"Device '{device_name}' not found in topology" - } + hosts_data[device_name] = {"error": f"Device '{device_name}' not found in topology"} continue port = device_ports[device_name]["port"] @@ -498,9 +461,7 @@ class VPCSCommands(BaseTool): return hosts_data - def _initialize_nornir( - self, hosts_data: dict[str, dict[str, Any]] - ) -> "Nornir": + def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> "Nornir": """ Initialize Nornir with VPCS device inventory. @@ -576,12 +537,14 @@ class VPCSCommands(BaseTool): # Check if device had an error during preparation if device_name in hosts_data and "error" in hosts_data[device_name]: - results.append({ - "device_name": device_name, - "status": "failed", - "error": hosts_data[device_name]["error"], - "commands": device_config["commands"], - }) + results.append( + { + "device_name": device_name, + "status": "failed", + "error": hosts_data[device_name]["error"], + "commands": device_config["commands"], + } + ) continue # Get result from Nornir task @@ -591,28 +554,34 @@ class VPCSCommands(BaseTool): if host_result.failed: # Task failed error_msg = str(host_result.result) if host_result.result else "Unknown error" - results.append({ - "device_name": device_name, - "status": "failed", - "error": error_msg, - "commands": device_config["commands"], - }) + results.append( + { + "device_name": device_name, + "status": "failed", + "error": error_msg, + "commands": device_config["commands"], + } + ) else: # Task succeeded - results.append({ - "device_name": device_name, - "status": "success", - "output": host_result.result, - "commands": device_config["commands"], - }) + results.append( + { + "device_name": device_name, + "status": "success", + "output": host_result.result, + "commands": device_config["commands"], + } + ) else: # Device not in task result (shouldn't happen) - results.append({ - "device_name": device_name, - "status": "failed", - "error": f"Device '{device_name}' not in task results", - "commands": device_config["commands"], - }) + results.append( + { + "device_name": device_name, + "status": "failed", + "error": f"Device '{device_name}' not in task results", + "commands": device_config["commands"], + } + ) return results diff --git a/gns3server/agent/gns3_copilot/utils/command_filter.py b/gns3server/agent/gns3_copilot/utils/command_filter.py index deb5e7c5f..78ed176bd 100644 --- a/gns3server/agent/gns3_copilot/utils/command_filter.py +++ b/gns3server/agent/gns3_copilot/utils/command_filter.py @@ -73,14 +73,14 @@ def _load_forbidden_commands() -> list[str]: # Try to load from skills repository manager = get_skills_manager() if manager is not None: - commands = manager.load_forbidden_commands() - if commands: - logger.info( - "Loaded %d forbidden command patterns from skills repository", - len(commands), - ) - _forbidden_commands_cache = commands - return _forbidden_commands_cache + commands = manager.load_forbidden_commands() + if commands: + logger.info( + "Loaded %d forbidden command patterns from skills repository", + len(commands), + ) + _forbidden_commands_cache = commands + return _forbidden_commands_cache # Fallback to defaults logger.warning("Using default forbidden commands list") diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md b/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md index 2eea98e5c..f36ed12e9 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md @@ -69,11 +69,9 @@ The drivers are auto-registered when the tools are imported: from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir tool = DisplayToolNornir() -result = tool._run(json.dumps({ - "device_names": ["huawei-sw1"], - "commands": ["display version"], - "project_id": "project-uuid" -})) +result = tool._run( + json.dumps({"device_names": ["huawei-sw1"], "commands": ["display version"], "project_id": "project-uuid"}) +) ``` ## Running Tests @@ -145,11 +143,14 @@ Create `tests/test_cisco.py`: import unittest from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco + class TestCustomCiscoDriver(unittest.TestCase): def test_device_type_registered(self): from netmiko.ssh_dispatcher import CLASS_MAPPER + self.assertIn("cisco_custom", CLASS_MAPPER) + if __name__ == "__main__": unittest.main() ``` diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py index 99f2f4220..68080117e 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py @@ -209,7 +209,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase): # Ensure we're in a clean state try: - if hasattr(self, 'base_prompt') and self.base_prompt: + if hasattr(self, "base_prompt") and self.base_prompt: self._test_channel_read(pattern=self.base_prompt) else: # If base_prompt is not set yet, just read to clear buffer @@ -288,9 +288,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase): # This method keeps reading until there is no new data for # 'last_read' seconds. This is the proper Netmiko way to handle # command output - output += self.read_channel_timing( - read_timeout=read_timeout, last_read=2.0 - ) + output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0) # Exit config mode if requested if exit_config_mode: @@ -318,9 +316,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase): return output - def exit_config_mode( - self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$" - ) -> str: + def exit_config_mode(self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$") -> str: r""" Exit configuration mode for Huawei devices. @@ -377,6 +373,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase): # Register the custom device type with Netmiko _registered = False # Flag to prevent duplicate registration + def register_custom_device_type() -> None: """ Register the custom GNS3HuaweiTelnetCE device type with Netmiko. @@ -451,7 +448,4 @@ try: except Exception as e: # Log but don't fail on import logger = logging.getLogger(__name__) - logger.warning( - f"Failed to register custom device type: {e}", - exc_info=True - ) + logger.warning(f"Failed to register custom device type: {e}", exc_info=True) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py index 108eb02b9..00a70eec6 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py @@ -60,12 +60,12 @@ class RuijieTelnetEnhanced(RuijieOSBase): # Interactive command patterns that trigger [yes/no] prompts # These are commands that commonly require confirmation INTERACTIVE_PATTERNS = [ - re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id - re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config - re.compile(r'^delete\s+', re.IGNORECASE), # delete files - re.compile(r'^format\s+', re.IGNORECASE), # format filesystem - re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot - re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image + re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF router-id + re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config + re.compile(r"^delete\s+", re.IGNORECASE), # delete files + re.compile(r"^format\s+", re.IGNORECASE), # format filesystem + re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot + re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image ] def __init__( @@ -80,9 +80,7 @@ class RuijieTelnetEnhanced(RuijieOSBase): kwargs["default_enter"] = "\r\n" super().__init__(*args, **kwargs) - def _preprocess_interactive_commands( - self, config_commands: list[str] - ) -> list[str]: + def _preprocess_interactive_commands(self, config_commands: list[str]) -> list[str]: """ Preprocess commands to insert 'yes' after interactive commands. @@ -105,8 +103,7 @@ class RuijieTelnetEnhanced(RuijieOSBase): for pattern in self.INTERACTIVE_PATTERNS: if pattern.match(cmd.strip()): logging.info( - "Ruijie device: Detected interactive command '%s', " - "inserting 'yes'", + "Ruijie device: Detected interactive command '%s', inserting 'yes'", cmd.strip(), ) # Insert 'yes' after this command @@ -153,25 +150,18 @@ class RuijieTelnetEnhanced(RuijieOSBase): output += self.config_mode() # Preprocess: Insert 'yes' after known interactive commands - processed_commands = self._preprocess_interactive_commands( - config_commands - ) + processed_commands = self._preprocess_interactive_commands(config_commands) # Try batch send first (fast path) try: - output += self._send_config_batch( - processed_commands, read_timeout, delay_factor - ) + output += self._send_config_batch(processed_commands, read_timeout, delay_factor) except Exception as batch_error: logging.warning( - "Ruijie device: Batch send failed, " - "falling back to one-by-one: %s", + "Ruijie device: Batch send failed, falling back to one-by-one: %s", batch_error, ) # Fallback to one-by-one send with real-time detection - output += self._send_config_one_by_one( - config_commands, read_timeout, delay_factor - ) + output += self._send_config_one_by_one(config_commands, read_timeout, delay_factor) # Exit config mode if requested if exit_config_mode: @@ -179,9 +169,7 @@ class RuijieTelnetEnhanced(RuijieOSBase): return output - def _send_config_batch( - self, commands: list[str], read_timeout: int, delay_factor: float - ) -> str: + def _send_config_batch(self, commands: list[str], read_timeout: int, delay_factor: float) -> str: """ Send configuration commands in batch (fast). @@ -197,15 +185,11 @@ class RuijieTelnetEnhanced(RuijieOSBase): # Read all output at once # Use same default as Netmiko (2.0 seconds) to ensure complete output - output += self.read_channel_timing( - read_timeout=read_timeout, last_read=2.0 - ) + output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0) return output - def _send_config_one_by_one( - self, commands: list[str], read_timeout: int, delay_factor: float - ) -> str: + def _send_config_one_by_one(self, commands: list[str], read_timeout: int, delay_factor: float) -> str: """ Send commands one-by-one with real-time prompt detection. @@ -225,26 +209,21 @@ class RuijieTelnetEnhanced(RuijieOSBase): time.sleep(0.05) # Read output after this command - new_output = self.read_channel_timing( - read_timeout=10, last_read=0.5 - ) + new_output = self.read_channel_timing(read_timeout=10, last_read=0.5) output += new_output # Check if interactive prompt appeared after this command for pattern in interactive_patterns: if re.search(pattern, new_output, re.IGNORECASE): logging.info( - "Ruijie device: Detected interactive prompt " - "after '%s', sending 'yes'", + "Ruijie device: Detected interactive prompt after '%s', sending 'yes'", cmd, ) # Send 'yes' to confirm self.write_channel(f"yes{self.RETURN}") time.sleep(0.3) # Read the confirmation response - output += self.read_channel_timing( - read_timeout=30, last_read=0.5 - ) + output += self.read_channel_timing(read_timeout=30, last_read=0.5) break return output @@ -253,6 +232,7 @@ class RuijieTelnetEnhanced(RuijieOSBase): # Register the custom device type with Netmiko _registered = False # Flag to prevent duplicate registration + def register_custom_device_type() -> None: """ Register the custom RuijieTelnetEnhanced device type with Netmiko. @@ -295,7 +275,4 @@ try: register_custom_device_type() except Exception as e: logger = logging.getLogger(__name__) - logger.warning( - f"Failed to register Ruijie device type: {e}", - exc_info=True - ) + logger.warning(f"Failed to register Ruijie device type: {e}", exc_info=True) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py index d9edd8c34..3e951f10a 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py @@ -39,6 +39,7 @@ def get_netmiko_version() -> str: """Get Netmiko library version.""" try: import netmiko + return netmiko.__version__ except Exception: return "Unknown" @@ -68,47 +69,39 @@ def register_custom_drivers() -> set: try: # Import and register Huawei CE driver - from gns3server.agent.gns3_copilot.utils.custom_netmiko \ - import huawei_ce + from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce # Store custom device types before registration custom_devices.add("gns3_huawei_telnet_ce") # The driver auto-registers on import, but we can call it # explicitly - if hasattr(huawei_ce, 'register_custom_device_type'): + if hasattr(huawei_ce, "register_custom_device_type"): huawei_ce.register_custom_device_type() except ImportError: # Silently skip if custom drivers are not available pass except Exception as e: # Log but don't fail - print( - f"Warning: Failed to register Huawei CE driver: {e}", - file=sys.stderr - ) + print(f"Warning: Failed to register Huawei CE driver: {e}", file=sys.stderr) try: # Import and register Ruijie Telnet driver - from gns3server.agent.gns3_copilot.utils.custom_netmiko \ - import ruijie_telnet + from gns3server.agent.gns3_copilot.utils.custom_netmiko import ruijie_telnet # Store custom device types before registration custom_devices.add("gns3_ruijie_telnet") # The driver auto-registers on import, but we can call it # explicitly - if hasattr(ruijie_telnet, 'register_custom_device_type'): + if hasattr(ruijie_telnet, "register_custom_device_type"): ruijie_telnet.register_custom_device_type() except ImportError: # Silently skip if custom drivers are not available pass except Exception as e: # Log but don't fail - print( - f"Warning: Failed to register Ruijie driver: {e}", - file=sys.stderr - ) + print(f"Warning: Failed to register Ruijie driver: {e}", file=sys.stderr) return custom_devices @@ -124,32 +117,32 @@ def extract_brand_name(device_type: str) -> str: Brand name in a standardized format """ # Remove common suffixes - name = device_type.replace('_telnet', '').replace('_ssh', '') - name = name.replace('_serial', '') + name = device_type.replace("_telnet", "").replace("_ssh", "") + name = name.replace("_serial", "") # Map to standard brand names brand_mapping = { - 'cisco': 'Cisco', - 'huawei': 'Huawei', - 'juniper': 'Juniper', - 'arista': 'Arista', - 'hp': 'HP', - 'aruba': 'Aruba', - 'dell': 'Dell', - 'brocade': 'Brocade', - 'extreme': 'Extreme', - 'ruckus': 'Ruckus', - 'ruijie': 'Ruijie (锐捷)', - 'zte': 'ZTE (中兴)', - 'maipu': 'Maipu (迈普)', - 'h3c': 'H3C (华三)', - 'nokia': 'Nokia', - 'paloalto': 'Palo Alto', - 'f5': 'F5', - 'checkpoint': 'Check Point', - 'generic': 'Generic', - 'huaweiyt': 'HuaweiYT', - 'vsz': 'Ruckus', + "cisco": "Cisco", + "huawei": "Huawei", + "juniper": "Juniper", + "arista": "Arista", + "hp": "HP", + "aruba": "Aruba", + "dell": "Dell", + "brocade": "Brocade", + "extreme": "Extreme", + "ruckus": "Ruckus", + "ruijie": "Ruijie (锐捷)", + "zte": "ZTE (中兴)", + "maipu": "Maipu (迈普)", + "h3c": "H3C (华三)", + "nokia": "Nokia", + "paloalto": "Palo Alto", + "f5": "F5", + "checkpoint": "Check Point", + "generic": "Generic", + "huaweiyt": "HuaweiYT", + "vsz": "Ruckus", } # Check for exact matches first @@ -163,7 +156,7 @@ def extract_brand_name(device_type: str) -> str: return value # Return capitalized version of the first part - return name.split('_')[0].capitalize() + return name.split("_")[0].capitalize() def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]: @@ -205,8 +198,8 @@ def get_devices_by_protocol() -> Tuple[List[str], List[str]]: for device_type in CLASS_MAPPER.keys(): device_lower = device_type.lower() - has_telnet = 'telnet' in device_lower - has_ssh = 'ssh' in device_lower + has_telnet = "telnet" in device_lower + has_ssh = "ssh" in device_lower if has_telnet and has_ssh: both_protocols.append(device_type) @@ -230,17 +223,14 @@ def get_total_device_count() -> int: """Get total number of device types in Netmiko.""" try: from netmiko.ssh_dispatcher import CLASS_MAPPER + return len(CLASS_MAPPER) except ImportError: return 0 def generate_markdown( - version: str, - ssh_devices: List[str], - telnet_devices: List[str], - total_count: int, - custom_devices: set + version: str, ssh_devices: List[str], telnet_devices: List[str], total_count: int, custom_devices: set ) -> str: """ Generate Markdown documentation with table format. @@ -261,7 +251,7 @@ def generate_markdown( md_lines.append("# Netmiko Supported Devices") md_lines.append("") md_lines.append(f"**Netmiko Version:** {version}") - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") md_lines.append(f"**Generated:** {timestamp}") md_lines.append("") md_lines.append("---") @@ -313,8 +303,7 @@ def generate_markdown( # Footer md_lines.append("---") md_lines.append("") - md_lines.append("*This document was generated automatically by the " - "Netmiko device list script.*") + md_lines.append("*This document was generated automatically by the Netmiko device list script.*") return "\n".join(md_lines) @@ -332,10 +321,8 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None: # Go up from: .../gns3server/agent/gns3_copilot/utils/ # custom_netmiko/scripts to project root # That's 6 levels up - project_root = os.path.dirname(os.path.dirname( - os.path.dirname(os.path.dirname( - os.path.dirname(os.path.dirname(script_dir)))) - ) + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir))))) ) # Target directory: docs/gns3-copilot @@ -348,17 +335,15 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None: output_path = os.path.join(target_dir, filename) try: - with open(output_path, 'w', encoding='utf-8') as f: + with open(output_path, "w", encoding="utf-8") as f: f.write(content) print(f"Markdown document saved to: {output_path}") except IOError as e: - print(f"Error: Failed to save Markdown file: {e}", - file=sys.stderr) + print(f"Error: Failed to save Markdown file: {e}", file=sys.stderr) sys.exit(1) -def print_summary(version: str, ssh_count: int, telnet_count: int, - total_count: int, output_file: str) -> None: +def print_summary(version: str, ssh_count: int, telnet_count: int, total_count: int, output_file: str) -> None: """Print summary to console.""" print("=" * 80) print("Netmiko Device List Generator") @@ -402,7 +387,7 @@ def main() -> int: ssh_devices=ssh_devices, telnet_devices=telnet_devices, total_count=total_count, - custom_devices=custom_devices + custom_devices=custom_devices, ) # Save to file @@ -415,7 +400,7 @@ def main() -> int: ssh_count=len(ssh_devices), telnet_count=len(telnet_devices), total_count=total_count, - output_file=output_file + output_file=output_file, ) return 0 diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py index db18bada4..c6b6db344 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py @@ -25,9 +25,7 @@ import os # Add project root to path using relative path test_dir = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.dirname( - os.path.dirname(os.path.dirname( - os.path.dirname(os.path.dirname(test_dir))))) +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir))))) sys.path.insert(0, project_root) @@ -103,13 +101,8 @@ class TestHuaweiTelnetCEDriver(unittest.TestCase): def test_initialization_parameters(self): """Test that initialization sets correct parameters.""" # Create a mock instance (without actual connection) - with patch.object( - self.HuaweiTelnetCE, "__init__", - lambda self, *args, **kwargs: None - ): - instance = self.HuaweiTelnetCE.__new__( - self.HuaweiTelnetCE - ) + with patch.object(self.HuaweiTelnetCE, "__init__", lambda self, *args, **kwargs: None): + instance = self.HuaweiTelnetCE.__new__(self.HuaweiTelnetCE) # Mock the necessary attributes instance.protocol = "telnet" @@ -169,9 +162,7 @@ class TestHuaweiTelnetCEIntegration(unittest.TestCase): ) # Create a mock instance - instance = GNS3HuaweiTelnetCE.__new__( - GNS3HuaweiTelnetCE - ) + instance = GNS3HuaweiTelnetCE.__new__(GNS3HuaweiTelnetCE) # Mock the necessary attributes and methods instance.host = "127.0.0.1" @@ -203,12 +194,8 @@ def run_tests(): suite = unittest.TestSuite() # Add test cases - suite.addTests( - loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver) - ) - suite.addTests( - loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration) - ) + suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver)) + suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration)) # Run tests runner = unittest.TextTestRunner(verbosity=2) @@ -218,9 +205,7 @@ def run_tests(): print("\n" + "=" * 100) print("Test Summary:") print(f" Run: {result.testsRun}") - success_count = ( - result.testsRun - len(result.failures) - len(result.errors) - ) + success_count = result.testsRun - len(result.failures) - len(result.errors) print(f" Success: {success_count}") print(f" Failed: {len(result.failures)}") print(f" Errors: {len(result.errors)}") diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py index b913f83ee..dfef81823 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py @@ -25,9 +25,7 @@ import os # Add project root to path using relative path test_dir = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.dirname( - os.path.dirname(os.path.dirname( - os.path.dirname(os.path.dirname(test_dir))))) +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir))))) sys.path.insert(0, project_root) @@ -92,9 +90,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase): import re # Should have INTERACTIVE_PATTERNS as a class attribute - self.assertTrue( - hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS") - ) + self.assertTrue(hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS")) # Check it's a list patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS @@ -108,18 +104,11 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase): def test_preprocess_interactive_commands(self): """Test that interactive commands are preprocessed correctly.""" # Create a mock instance (without actual connection) - with patch.object( - self.RuijieTelnetEnhanced, "__init__", - lambda self, *args, **kwargs: None - ): - instance = self.RuijieTelnetEnhanced.__new__( - self.RuijieTelnetEnhanced - ) + with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None): + instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced) # Set INTERACTIVE_PATTERNS from class - instance.INTERACTIVE_PATTERNS = ( - self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS - ) + instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS # Test commands with router-id (should trigger 'yes') commands = [ @@ -128,34 +117,23 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase): "network 10.0.0.0 0.0.0.255 area 0", ] - processed = instance._preprocess_interactive_commands( - commands - ) + processed = instance._preprocess_interactive_commands(commands) # Should have inserted 'yes' after router-id command self.assertEqual(len(processed), 4) self.assertEqual(processed[0], "router ospf 1") self.assertEqual(processed[1], "router-id 1.1.1.1") self.assertEqual(processed[2], "yes") - self.assertEqual( - processed[3], "network 10.0.0.0 0.0.0.255 area 0" - ) + self.assertEqual(processed[3], "network 10.0.0.0 0.0.0.255 area 0") def test_preprocess_non_interactive_commands(self): """Test that non-interactive commands are not modified.""" # Create a mock instance - with patch.object( - self.RuijieTelnetEnhanced, "__init__", - lambda self, *args, **kwargs: None - ): - instance = self.RuijieTelnetEnhanced.__new__( - self.RuijieTelnetEnhanced - ) + with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None): + instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced) # Set INTERACTIVE_PATTERNS from class - instance.INTERACTIVE_PATTERNS = ( - self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS - ) + instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS # Test commands without interactive prompts commands = [ @@ -190,9 +168,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase): # Check that default_enter is handled in __init__ # (The actual logic is in the __init__ method body) - init_source = inspect.getsource( - self.RuijieTelnetEnhanced.__init__ - ) + init_source = inspect.getsource(self.RuijieTelnetEnhanced.__init__) self.assertIn("default_enter", init_source) self.assertIn("\\r\\n", init_source) @@ -207,9 +183,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase): ) # Create a mock instance - instance = RuijieTelnetEnhanced.__new__( - RuijieTelnetEnhanced - ) + instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced) # Mock the necessary attributes and methods instance.RETURN = "\r\n" @@ -224,9 +198,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase): commands = ["interface GigabitEthernet 0/1", "description Test"] # Call send_config_set - instance.send_config_set( - commands, enter_config_mode=False, exit_config_mode=False - ) + instance.send_config_set(commands, enter_config_mode=False, exit_config_mode=False) # Verify _send_config_batch was called instance._send_config_batch.assert_called_once() @@ -238,17 +210,13 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase): ) # Create a mock instance - instance = RuijieTelnetEnhanced.__new__( - RuijieTelnetEnhanced - ) - instance.INTERACTIVE_PATTERNS = ( - RuijieTelnetEnhanced.INTERACTIVE_PATTERNS - ) + instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced) + instance.INTERACTIVE_PATTERNS = RuijieTelnetEnhanced.INTERACTIVE_PATTERNS # Test commands with multiple interactive prompts commands = [ - "router-id 1.1.1.1", # Should trigger 'yes' - "erase startup-config", # Should trigger 'yes' + "router-id 1.1.1.1", # Should trigger 'yes' + "erase startup-config", # Should trigger 'yes' "interface GigabitEthernet 0/1", # Should NOT trigger ] @@ -273,12 +241,8 @@ def run_tests(): suite = unittest.TestSuite() # Add test cases - suite.addTests( - loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver) - ) - suite.addTests( - loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration) - ) + suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver)) + suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration)) # Run tests runner = unittest.TextTestRunner(verbosity=2) @@ -288,9 +252,7 @@ def run_tests(): print("\n" + "=" * 100) print("Test Summary:") print(f" Run: {result.testsRun}") - success_count = ( - result.testsRun - len(result.failures) - len(result.errors) - ) + success_count = result.testsRun - len(result.failures) - len(result.errors) print(f" Success: {success_count}") print(f" Failed: {len(result.failures)}") print(f" Errors: {len(result.errors)}") diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py index 95fa06b8c..4659465cd 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py @@ -27,9 +27,7 @@ import os # Add project root to path using relative path test_dir = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.dirname( - os.path.dirname(os.path.dirname( - os.path.dirname(os.path.dirname(test_dir))))) +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir))))) sys.path.insert(0, project_root) @@ -121,6 +119,7 @@ class TestVPCSTelnetInit(unittest.TestCase): from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 VPCSTelnet, ) + cls.VPCSTelnet = VPCSTelnet def test_default_enter_parameter(self): @@ -134,6 +133,7 @@ class TestVPCSTelnetInit(unittest.TestCase): def test_default_parameters_when_none(self): """Test default parameters when not provided.""" + # Mock the BaseConnection.__init__ to avoid actual connection # But also set device_type since VPCSTelnet.__init__ sets it def mock_init(self, *args, **kwargs): @@ -141,23 +141,20 @@ class TestVPCSTelnetInit(unittest.TestCase): kwargs.setdefault("device_type", "gns3_vpcs_telnet") self.device_type = kwargs.get("device_type") - with patch.object( - self.VPCSTelnet, "__init__", mock_init - ): + with patch.object(self.VPCSTelnet, "__init__", mock_init): instance = self.VPCSTelnet(host="127.0.0.1") self.assertEqual(instance.device_type, "gns3_vpcs_telnet") def test_device_type_set(self): """Test that device_type is set to gns3_vpcs_telnet.""" + # Mock the __init__ to set device_type def mock_init(self, *args, **kwargs): # Simulate what VPCSTelnet.__init__ does kwargs.setdefault("device_type", "gns3_vpcs_telnet") self.device_type = kwargs.get("device_type") - with patch.object( - self.VPCSTelnet, "__init__", mock_init - ): + with patch.object(self.VPCSTelnet, "__init__", mock_init): instance = self.VPCSTelnet(host="127.0.0.1") self.assertEqual(instance.device_type, "gns3_vpcs_telnet") @@ -171,6 +168,7 @@ class TestVPCSTelnetMethods(unittest.TestCase): from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 VPCSTelnet, ) + cls.VPCSTelnet = VPCSTelnet def test_check_config_mode_always_false(self): @@ -213,6 +211,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase): from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 VPCSTelnet, ) + cls.VPCSTelnet = VPCSTelnet def test_send_command_writes_bytes(self): @@ -243,7 +242,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase): # Check that expect_string defaults to r"PC\d+>" source = inspect.getsource(self.VPCSTelnet.send_command) - self.assertIn("expect_string = r\"PC\\d+>\"", source) + self.assertIn('expect_string = r"PC\\d+>"', source) def test_send_command_timing_calls_send_command(self): """Test that send_command_timing delegates to send_command.""" @@ -277,6 +276,7 @@ class TestVPCSTelnetTelnetLogin(unittest.TestCase): from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 VPCSTelnet, ) + cls.VPCSTelnet = VPCSTelnet def test_telnet_login_default_prompt_pattern(self): @@ -335,19 +335,16 @@ class TestVPCSTelnetRegistration(unittest.TestCase): from gns3server.agent.gns3_copilot.utils.custom_netmiko import ( vpcs_telnet, ) + cls.vpcs_telnet = vpcs_telnet def test_register_function_exists(self): """Test that register_custom_device_type function exists.""" - self.assertTrue( - hasattr(self.vpcs_telnet, "register_custom_device_type") - ) + self.assertTrue(hasattr(self.vpcs_telnet, "register_custom_device_type")) def test_register_function_is_callable(self): """Test that register_custom_device_type is callable.""" - self.assertTrue( - callable(self.vpcs_telnet.register_custom_device_type) - ) + self.assertTrue(callable(self.vpcs_telnet.register_custom_device_type)) class TestVPCSTelnetAnsiStripping(unittest.TestCase): @@ -359,6 +356,7 @@ class TestVPCSTelnetAnsiStripping(unittest.TestCase): from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 VPCSTelnet, ) + cls.VPCSTelnet = VPCSTelnet def test_strip_ansi_codes_method_exists(self): @@ -435,14 +433,10 @@ IP/MASK : 192.168.1.20/24""" # Mock necessary attributes instance.remote_conn = MagicMock() instance.remote_conn.write = MagicMock() - instance.read_until_pattern = MagicMock( - return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>" - ) + instance.read_until_pattern = MagicMock(return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>") # Mock _strip_ansi_codes to verify it's called - instance._strip_ansi_codes = Mock( - return_value="NAME : PC2\nPC2>" - ) + instance._strip_ansi_codes = Mock(return_value="NAME : PC2\nPC2>") # Call send_command instance.send_command("show ip") @@ -476,9 +470,7 @@ def run_tests(): print("\n" + "=" * 100) print("Test Summary:") print(f" Run: {result.testsRun}") - success_count = ( - result.testsRun - len(result.failures) - len(result.errors) - ) + success_count = result.testsRun - len(result.failures) - len(result.errors) print(f" Success: {success_count}") print(f" Failed: {len(result.failures)}") print(f" Errors: {len(result.errors)}") diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py index 348e123e1..0711f9c5d 100644 --- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py @@ -149,10 +149,7 @@ class VPCSTelnet(BaseConnection): # Step 3: Wait for VPCS prompt pattern try: # Read until we see the prompt - output = self.read_until_pattern( - pattern=pri_prompt_terminator, - read_timeout=10 - ) + output = self.read_until_pattern(pattern=pri_prompt_terminator, read_timeout=10) return_msg += output if re.search(pri_prompt_terminator, return_msg, flags=re.M): @@ -291,9 +288,7 @@ class VPCSTelnet(BaseConnection): strip_command=strip_command, ) - def check_config_mode( - self, check_string: str = "", pattern: str = "", force_regex: bool = False - ) -> bool: + def check_config_mode(self, check_string: str = "", pattern: str = "", force_regex: bool = False) -> bool: """ VPCS has no configuration mode. @@ -326,9 +321,7 @@ class VPCSTelnet(BaseConnection): """ return ANSI_ESCAPE_PATTERN.sub("", text) - def config_mode( - self, config_command: str = "", pattern: str = "", re_flags: int = 0 - ) -> str: + def config_mode(self, config_command: str = "", pattern: str = "", re_flags: int = 0) -> str: """ VPCS has no configuration mode. @@ -370,6 +363,7 @@ class VPCSTelnet(BaseConnection): # Register the custom device type with Netmiko _registered = False # Flag to prevent duplicate registration + def register_custom_device_type() -> None: """ Register the custom VPCS Telnet device type with Netmiko. @@ -431,8 +425,4 @@ try: register_custom_device_type() except Exception as e: # Log but don't fail on import - logger.warning( - "Failed to register VPCS device type: %s", - e, - exc_info=True - ) + logger.warning("Failed to register VPCS device type: %s", e, exc_info=True) diff --git a/gns3server/agent/gns3_copilot/utils/error_handler.py b/gns3server/agent/gns3_copilot/utils/error_handler.py index a919d0a41..560454eb3 100644 --- a/gns3server/agent/gns3_copilot/utils/error_handler.py +++ b/gns3server/agent/gns3_copilot/utils/error_handler.py @@ -61,10 +61,7 @@ def format_error_message(error: Exception) -> str: # Check if error message contains HTML if _contains_html(error_str): - logger.warning( - "Detected HTML in error message, likely due to incorrect API " - "configuration or base URL" - ) + logger.warning("Detected HTML in error message, likely due to incorrect API configuration or base URL") return ( "API request failed. The error response indicates an issue with " "your API configuration. Please check:\n" diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index b3127574e..6c75f5b12 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -71,9 +71,7 @@ def get_device_ports_from_topology( Devices that don't exist or missing console_port will not be included """ # Log received parameters - logger.info( - "Called with device_names=%s, project_id=%s", device_names, project_id - ) + logger.info("Called with device_names=%s, project_id=%s", device_names, project_id) try: # Lazy import to avoid circular dependency @@ -93,9 +91,7 @@ def get_device_ports_from_topology( for device_name in device_names: # Check if device exists in topology if device_name not in topology.get("nodes", {}): - logger.warning( - "Device '%s' not found in topology", device_name - ) + logger.warning("Device '%s' not found in topology", device_name) continue node_info = topology["nodes"][device_name] @@ -133,9 +129,7 @@ def get_device_ports_from_topology( f"Current tags: {tags}" ) logger.error(error_msg) - hosts_data[device_name] = { - "error": error_msg - } + hosts_data[device_name] = {"error": error_msg} continue logger.debug( @@ -170,11 +164,7 @@ def get_device_ports_from_topology( "platform": platform, "node_type": node_info.get("type"), "groups": ["network_devices"], # For inheriting hostname, timeout, etc. - "connection_options": { - "netmiko": { - "extras": {"device_type": device_type} - } - }, + "connection_options": {"netmiko": {"extras": {"device_type": device_type}}}, } # Per-node default credentials (seeded from the template appliance diff --git a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py index 8dc21a516..a28278744 100644 --- a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py +++ b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py @@ -135,14 +135,9 @@ def calculate_two_node_shape( node2_center_x = node2["x"] + (node2_width / 2) node2_center_y = node2["y"] + (node2_height / 2) - distance = math.sqrt( - (node2_center_x - node1_center_x) ** 2 - + (node2_center_y - node1_center_y) ** 2 - ) + distance = math.sqrt((node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2) - angle_rad = math.atan2( - node2_center_y - node1_center_y, node2_center_x - node1_center_x - ) + angle_rad = math.atan2(node2_center_y - node1_center_y, node2_center_x - node1_center_x) angle_deg = round(math.degrees(angle_rad)) angle_rad = math.radians(angle_deg) @@ -162,16 +157,10 @@ def calculate_two_node_shape( shape_width = rx * 2 shape_height = ry * 2 - svg_x = center_x - ( - rx * math.cos(angle_rad) - ry * math.sin(angle_rad) - ) - svg_y = center_y - ( - rx * math.sin(angle_rad) + ry * math.cos(angle_rad) - ) + svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad)) + svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad)) - shape_svg = generate_ellipse_svg( - int(rx), int(ry), color_scheme, int(shape_width), int(shape_height) - ) + shape_svg = generate_ellipse_svg(int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)) offset_distance = ry * text_offset_ratio @@ -188,22 +177,12 @@ def calculate_two_node_shape( else: # rectangle shape_width = distance - shape_height = max( - node1_width, node1_height, node2_width, node2_height - ) + shape_height = max(node1_width, node1_height, node2_width, node2_height) - svg_x = center_x - ( - (shape_width / 2) * math.cos(angle_rad) - - (shape_height / 2) * math.sin(angle_rad) - ) - svg_y = center_y - ( - (shape_width / 2) * math.sin(angle_rad) - + (shape_height / 2) * math.cos(angle_rad) - ) + svg_x = center_x - ((shape_width / 2) * math.cos(angle_rad) - (shape_height / 2) * math.sin(angle_rad)) + svg_y = center_y - ((shape_width / 2) * math.sin(angle_rad) + (shape_height / 2) * math.cos(angle_rad)) - shape_svg = generate_rectangle_svg( - int(shape_width), int(shape_height), color_scheme - ) + shape_svg = generate_rectangle_svg(int(shape_width), int(shape_height), color_scheme) offset_distance = (shape_height / 2) * text_offset_ratio @@ -354,33 +333,15 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]: return COLOR_SCHEMES["NORMAL_AREA"] # 3. Logical Isolation - if ( - "VRF" in label - or "VLAN" in label - or "MSTP" in label - or "VXLAN" in label - or "MPLS" in label - ): + if "VRF" in label or "VLAN" in label or "MSTP" in label or "VXLAN" in label or "MPLS" in label: return COLOR_SCHEMES["ISOLATION"] # 4. High Availability - if ( - "VRRP" in label - or "HSRP" in label - or "HA" in label - or "STACK" in label - or "M-LAG" in label - ): + if "VRRP" in label or "HSRP" in label or "HA" in label or "STACK" in label or "M-LAG" in label: return COLOR_SCHEMES["HIGH_AVAILABILITY"] # 5. External/Internet - if ( - "INET" in label - or "OUT" in label - or "EXTERNAL" in label - or "INTERNET" in label - or "DMZ" in label - ): + if "INET" in label or "OUT" in label or "EXTERNAL" in label or "INTERNET" in label or "DMZ" in label: return COLOR_SCHEMES["EXTERNAL"] # 6. Management @@ -392,12 +353,7 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]: return COLOR_SCHEMES["SECURITY_TRUSTED"] # 8. Cloud/Tunnel - if ( - "TUNNEL" in label - or "CLOUD" in label - or "GRE" in label - or "IPSEC" in label - ): + if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label: return COLOR_SCHEMES["CLOUD_TUNNEL"] # Legacy keyword matching for backward compatibility @@ -436,9 +392,7 @@ def calculate_two_node_ellipse( Wrapper around calculate_two_node_shape with shape_type="ellipse". """ - result = calculate_two_node_shape( - node1, node2, area_name, "ellipse", text_offset_ratio - ) + result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio) return { "ellipse": result["shape"], "text": result["text"], @@ -457,9 +411,7 @@ def calculate_two_node_rectangle( Wrapper around calculate_two_node_shape with shape_type="rectangle". """ - result = calculate_two_node_shape( - node1, node2, area_name, "rectangle", text_offset_ratio - ) + result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio) metadata = result["metadata"] return { "rectangle": result["shape"], diff --git a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py index 568a062cb..f0118c9d2 100644 --- a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py +++ b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py @@ -53,9 +53,7 @@ from fastapi import FastAPI logger = logging.getLogger(__name__) -async def get_user_llm_config_with_app( - user_id: UUID, app: FastAPI -) -> Optional[Dict[str, Any]]: +async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[Dict[str, Any]]: """ Get user's default LLM model configuration with decrypted API key. diff --git a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py index 92e60f216..4ea17912d 100644 --- a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py +++ b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py @@ -160,9 +160,7 @@ def parse_tool_content( # Empty string handling if not content.strip(): result = {} - logger.info( - "Content is empty or whitespace, returning: %s", result - ) + logger.info("Content is empty or whitespace, returning: %s", result) return result s = content.strip() @@ -170,9 +168,7 @@ def parse_tool_content( # Handle empty dictionary case if s == "{}": result = {} - logger.info( - "Content is empty dictionary, returning: %s", result - ) + logger.info("Content is empty dictionary, returning: %s", result) return result # Try to parse as Python literal @@ -190,9 +186,7 @@ def parse_tool_content( # Try to parse as JSON try: result = json.loads(s) - logger.info( - "Successfully parsed as JSON, returning: %s", result - ) + logger.info("Successfully parsed as JSON, returning: %s", result) return result except json.JSONDecodeError: pass @@ -221,8 +215,7 @@ def parse_tool_content( # Handle unsupported types error_msg = ( # type: ignore[unreachable] - "Content must be str, dict, list, int, float, bool, or None, got " - f"{type(content).__name__}" + f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}" ) logger.error(error_msg) @@ -238,9 +231,7 @@ def parse_tool_content( return result -def format_tool_response( - content: str | dict | list | int | float | bool | None, indent: int = 2 -) -> str: +def format_tool_response(content: str | dict | list | int | float | bool | None, indent: int = 2) -> str: """ Format tool response as a beautiful JSON string for UI display. @@ -259,9 +250,7 @@ def format_tool_response( logger.info("format_tool_response parameter indent: %s", indent) try: - parsed = parse_tool_content( - content, fallback_to_raw=True, strict_mode=False - ) + parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False) # Ensure the result can be serialized to JSON result = json.dumps(parsed, ensure_ascii=False, indent=indent) logger.info("format_tool_response returning: %s", result) @@ -270,9 +259,7 @@ def format_tool_response( # If the parsed result cannot be serialized, convert to string and wrap logger.error("Cannot serialize parsed result to JSON: %s", e) try: - result = json.dumps( - {"raw": str(content)}, ensure_ascii=False, indent=indent - ) + result = json.dumps({"raw": str(content)}, ensure_ascii=False, indent=indent) logger.info("format_tool_response returning fallback: %s", result) return result except Exception: @@ -286,16 +273,12 @@ def format_tool_response( return result except Exception as e: logger.error("Error formatting tool response: %s", e) - result = json.dumps( - {"error": str(e)}, ensure_ascii=False, indent=indent - ) + result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent) logger.info("format_tool_response returning error: %s", result) return result -def normalize_tool_response( - response: dict | list | str, tool_name: str = "unknown" -) -> dict: +def normalize_tool_response(response: dict | list | str, tool_name: str = "unknown") -> dict: """ Normalize tool response to standard format for consistent frontend display. @@ -334,11 +317,7 @@ def normalize_tool_response( } # Handle error responses - if ( - isinstance(response, dict) - and "error" in response - and len(response) == 1 - ): + if isinstance(response, dict) and "error" in response and len(response) == 1: return { "success": False, "total": 0, @@ -362,34 +341,21 @@ def normalize_tool_response( # Handle list responses (most tools return list of device results) if isinstance(response, list): - successful = sum( - 1 - for item in response - if isinstance(item, dict) and item.get("status") == "success" - ) + successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success") failed = len(response) - successful normalized_data = [] for item in response: if isinstance(item, dict): normalized_item = { - "id": item.get("device_id") - or item.get("node_id") - or item.get("id") - or "", + "id": item.get("device_id") or item.get("node_id") or item.get("id") or "", "name": item.get("device_name") or item.get("name") or "", "status": item.get("status", "unknown"), } if normalized_item["status"] == "success": - normalized_item["result"] = ( - item.get("output") or item.get("result") or "" - ) + normalized_item["result"] = item.get("output") or item.get("result") or "" else: - normalized_item["error"] = ( - item.get("error") - or item.get("output") - or "Unknown error" - ) + normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error" normalized_data.append(normalized_item) else: # Non-dict items in list @@ -426,14 +392,8 @@ def normalize_tool_response( } # Legacy format: extract common fields - total = ( - response.get("total_nodes") - or response.get("total") - or response.get("count", 1) - ) - successful = ( - response.get("successful_nodes") or response.get("successful") or 0 - ) + total = response.get("total_nodes") or response.get("total") or response.get("count", 1) + successful = response.get("successful_nodes") or response.get("successful") or 0 failed = response.get("failed_nodes") or response.get("failed") or 0 # Extract data from various possible locations @@ -458,13 +418,9 @@ def normalize_tool_response( if not data and "status" in response: data = [ { - "name": response.get("device_name") - or response.get("name") - or "", + "name": response.get("device_name") or response.get("name") or "", "status": response["status"], - "result": response.get("output") - or response.get("result") - or "", + "result": response.get("output") or response.get("result") or "", "error": response.get("error") or "", } ] @@ -564,9 +520,7 @@ def _test_parse_tool_content() -> None: valid = "✓" except Exception: valid = "✗" - print( - f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}" - ) + print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}") if __name__ == "__main__": diff --git a/gns3server/agent/gns3_copilot/utils/tool_call_stream.py b/gns3server/agent/gns3_copilot/utils/tool_call_stream.py index 8cd89a93e..6747db5bc 100644 --- a/gns3server/agent/gns3_copilot/utils/tool_call_stream.py +++ b/gns3server/agent/gns3_copilot/utils/tool_call_stream.py @@ -98,9 +98,7 @@ class ToolCallStreamAccumulator: "id": tc_id, "type": "function", "function": { - "name": self._current_tool_call[ - "name" - ], + "name": self._current_tool_call["name"], "arguments": "", }, }, @@ -122,9 +120,7 @@ class ToolCallStreamAccumulator: # Core: string concatenation if isinstance(args_chunk, str): - tool_data[ - "args_string" - ] += args_chunk + tool_data["args_string"] += args_chunk # Send updated tool_call event with accumulated args chunks.append( @@ -135,9 +131,7 @@ class ToolCallStreamAccumulator: "type": "function", "function": { "name": tool_data["name"], - "arguments": tool_data[ - "args_string" - ], + "arguments": tool_data["args_string"], }, }, } @@ -147,15 +141,9 @@ class ToolCallStreamAccumulator: # complete ========== # Check finish_reason == "tool_calls" or "STOP" response_metadata = getattr(chunk, "response_metadata", {}) - finish_reason = ( - response_metadata.get("finish_reason") - if isinstance(response_metadata, dict) - else None - ) + finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None - if (finish_reason == "tool_calls") or ( - finish_reason == "stop" and self._current_tool_call is not None - ): + if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None): if self._current_tool_call: tool_data = self._current_tool_call diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index d6179d24b..9b9026253 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -55,16 +55,26 @@ from gns3server.utils.request_utils import extract_client_info from gns3server.db.repositories.api_keys import ApiKeysRepository from gns3server.db.repositories.users import UsersRepository from .projects import ( - list_projects_handler, get_project_handler, create_project_handler, - delete_project_handler, open_project_handler, close_project_handler, - get_project_stats_handler, update_project_handler, duplicate_project_handler, - get_project_readme_handler, update_project_readme_handler, - lock_project_handler, unlock_project_handler, + list_projects_handler, + get_project_handler, + create_project_handler, + delete_project_handler, + open_project_handler, + close_project_handler, + get_project_stats_handler, + update_project_handler, + duplicate_project_handler, + get_project_readme_handler, + update_project_readme_handler, + lock_project_handler, + unlock_project_handler, get_locked_project_handler, ) from .server import ( - get_version_handler, get_statistics_handler, + get_version_handler, + get_statistics_handler, ) + # Symbol tools are disabled for now: they require a vision-capable model to # be genuinely useful (the tools shuttle SVG content, which a text-only LLM # cannot inspect or produce). Revisit later. @@ -74,50 +84,80 @@ from .server import ( # upload_symbol_handler, delete_symbol_handler, # ) from .appliances import ( - get_appliances_handler, get_appliance_handler, + get_appliances_handler, + get_appliance_handler, install_appliance_handler, ) from .images import ( - get_images_handler, get_image_handler, - delete_image_handler, prune_images_handler, + get_images_handler, + get_image_handler, + delete_image_handler, + prune_images_handler, install_images_handler, ) from .device_config import ( - device_config_send_handler, device_show_run_handler, + device_config_send_handler, + device_show_run_handler, vpcs_config_set_handler, ) from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( - get_nodes_handler, get_node_handler, start_node_handler, - stop_node_handler, suspend_node_handler, - create_node_handler, delete_node_handler, update_node_handler, + get_nodes_handler, + get_node_handler, + start_node_handler, + stop_node_handler, + suspend_node_handler, + create_node_handler, + delete_node_handler, + update_node_handler, get_node_console_info_handler, - list_node_files_handler, get_node_file_handler, - write_node_file_handler, delete_node_file_handler, - start_all_nodes_handler, stop_all_nodes_handler, + list_node_files_handler, + get_node_file_handler, + write_node_file_handler, + delete_node_file_handler, + start_all_nodes_handler, + stop_all_nodes_handler, suspend_all_nodes_handler, - duplicate_node_handler, isolate_node_handler, - unisolate_node_handler, get_node_links_handler, - get_links_handler, get_link_handler, available_filters_handler, + duplicate_node_handler, + isolate_node_handler, + unisolate_node_handler, + get_node_links_handler, + get_links_handler, + get_link_handler, + available_filters_handler, create_link_handler, - delete_link_handler, update_link_handler, - reset_link_handler, start_capture_handler, stop_capture_handler, + delete_link_handler, + update_link_handler, + reset_link_handler, + start_capture_handler, + stop_capture_handler, download_capture_file_handler, - link_marker_handler, marker_definition_handler, + link_marker_handler, + marker_definition_handler, ) from .templates import ( - list_templates_handler, get_template_handler, create_template_handler, - update_template_handler, delete_template_handler, + list_templates_handler, + get_template_handler, + create_template_handler, + update_template_handler, + delete_template_handler, ) from .computes import ( - list_computes_handler, get_compute_handler, get_compute_images_handler, + list_computes_handler, + get_compute_handler, + get_compute_images_handler, ) from .snapshots import ( - get_snapshots_handler, create_snapshot_handler, - delete_snapshot_handler, restore_snapshot_handler, + get_snapshots_handler, + create_snapshot_handler, + delete_snapshot_handler, + restore_snapshot_handler, ) from .drawings import ( - get_drawings_handler, create_drawing_handler, - get_drawing_handler, update_drawing_handler, delete_drawing_handler, + get_drawings_handler, + create_drawing_handler, + get_drawing_handler, + update_drawing_handler, + delete_drawing_handler, ) log = logging.getLogger(__name__) @@ -177,10 +217,7 @@ async def wait_for_mcp_ready() -> bool: log.debug("MCP server is now ready, proceeding with connection") return True except asyncio.TimeoutError: - log.warning( - "MCP server ready check timed out after 5 seconds - " - "GNS3 server initialization may have issues" - ) + log.warning("MCP server ready check timed out after 5 seconds - GNS3 server initialization may have issues") return False @@ -188,24 +225,19 @@ async def wait_for_mcp_ready() -> bool: # Set during SSE authentication, read by tool handlers running in the # same asyncio task (contextvars propagate through asyncio.to_thread). -_jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( - "mcp_jwt_token", default=None -) +_jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar("mcp_jwt_token", default=None) # Username extracted during token validation — used by handlers to generate # short-lived JWTs for download/console URLs without exposing the raw key. -_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( - "mcp_jwt_username", default=None -) +_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar("mcp_jwt_username", default=None) # token_version extracted during token validation — short-lived JWTs minted for # download/console URLs must carry the same version, or the revocation check # (token_data.token_version != user.token_version) rejects them as "revoked". -_jwt_token_version_var: contextvars.ContextVar[int] = contextvars.ContextVar( - "mcp_jwt_token_version", default=0 -) +_jwt_token_version_var: contextvars.ContextVar[int] = contextvars.ContextVar("mcp_jwt_token_version", default=0) # ── Token validation ────────────────────────────────────────────────── + async def _resolve_token(token: str) -> str | None: """Validate a token (JWT or API key) and return the effective JWT to use. @@ -245,7 +277,9 @@ async def _resolve_token(token: str) -> str | None: if user: _jwt_username_var.set(user.username) _jwt_token_version_var.set(user.token_version) - fresh_token = auth_service.create_access_token(user.username, token_version=user.token_version) + fresh_token = auth_service.create_access_token( + user.username, token_version=user.token_version + ) return fresh_token except Exception: pass @@ -255,6 +289,7 @@ async def _resolve_token(token: str) -> str | None: # ── Server URL helper ───────────────────────────────────────────────── + def _server_url() -> str: cfg = Config.instance().settings host = cfg.Server.host @@ -272,6 +307,7 @@ def _server_url() -> str: # ── FastMCP Server ──────────────────────────────────────────────────── + def _create_mcp_server() -> FastMCP: """Create MCP server with security settings from configuration.""" cfg = Config.instance().settings.Server @@ -298,6 +334,7 @@ mcp = _create_mcp_server() # ── Tool handlers ───────────────────────────────────────────────────── + def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]: """Run a synchronous Gns3Connector handler in a thread.""" ctx = { @@ -348,6 +385,7 @@ async def project_open( """Open a closed GNS3 project.""" return await asyncio.to_thread(_run_handler_sync, open_project_handler, {"project_id": project_id}) + @mcp.tool() async def project_close( project_id: Annotated[str, Field(description="UUID of the project to close")], @@ -355,6 +393,7 @@ async def project_close( """Close an open GNS3 project.""" return await asyncio.to_thread(_run_handler_sync, close_project_handler, {"project_id": project_id}) + @mcp.tool() async def project_stats( project_id: Annotated[str, Field(description="UUID of the project to get statistics for")], @@ -383,10 +422,19 @@ async def project_update( """Update a project's properties (name, auto_close, auto_open, etc.).""" params = {"project_id": project_id} local_vars = { - "name": name, "auto_close": auto_close, "auto_open": auto_open, "auto_start": auto_start, - "scene_width": scene_width, "scene_height": scene_height, "zoom": zoom, - "show_layers": show_layers, "snap_to_grid": snap_to_grid, "show_grid": show_grid, - "grid_size": grid_size, "drawing_grid_size": drawing_grid_size, "show_interface_labels": show_interface_labels, + "name": name, + "auto_close": auto_close, + "auto_open": auto_open, + "auto_start": auto_start, + "scene_width": scene_width, + "scene_height": scene_height, + "zoom": zoom, + "show_layers": show_layers, + "snap_to_grid": snap_to_grid, + "show_grid": show_grid, + "grid_size": grid_size, + "drawing_grid_size": drawing_grid_size, + "show_interface_labels": show_interface_labels, } for key, val in local_vars.items(): if val is not None: @@ -421,15 +469,23 @@ async def project_readme_update( content: Annotated[str, Field(description="Content to write to README.md (Markdown format)")], ) -> list[dict[str, Any]]: """Update or create a project's README.md file — the project documentation (Markdown format).""" - return await asyncio.to_thread(_run_handler_sync, update_project_readme_handler, {"project_id": project_id, "content": content}) + return await asyncio.to_thread( + _run_handler_sync, update_project_readme_handler, {"project_id": project_id, "content": content} + ) # ── Node tools ──────────────────────────────────────────────────────── + @mcp.tool() async def node_list( project_id: Annotated[str, Field(description="UUID of the project")], - fields: Annotated[list[str] | None, Field(description="Optional: return only these fields per node. e.g. [\"name\",\"status\"]. Available: name, status, node_type, console, console_type, console_host, node_id, project_id, compute_id, symbol, x, y, z, locked, ports, properties, command_line, node_directory, label, tags, template_id, width, height, aux, aux_type")] = None, + fields: Annotated[ + list[str] | None, + Field( + description='Optional: return only these fields per node. e.g. ["name","status"]. Available: name, status, node_type, console, console_type, console_host, node_id, project_id, compute_id, symbol, x, y, z, locked, ports, properties, command_line, node_directory, label, tags, template_id, width, height, aux, aux_type' + ), + ] = None, ) -> list[dict[str, Any]]: """List all nodes in a project. Use fields=[] to return only what you need.""" return await asyncio.to_thread(_run_handler_sync, get_nodes_handler, {"project_id": project_id, "fields": fields}) @@ -439,18 +495,32 @@ async def node_list( async def node_get( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node")], - fields: Annotated[list[str] | None, Field(description="Optional: return only these fields. e.g. [\"name\",\"status\"]. Available: name, status, node_type, console, console_type, console_host, node_id, project_id, compute_id, symbol, x, y, z, locked, ports, properties, command_line, node_directory, label, tags, template_id, width, height, aux, aux_type")] = None, + fields: Annotated[ + list[str] | None, + Field( + description='Optional: return only these fields. e.g. ["name","status"]. Available: name, status, node_type, console, console_type, console_host, node_id, project_id, compute_id, symbol, x, y, z, locked, ports, properties, command_line, node_directory, label, tags, template_id, width, height, aux, aux_type' + ), + ] = None, ) -> list[dict[str, Any]]: """Get detailed information about a specific node. Use fields=[] to return only what you need.""" - return await asyncio.to_thread(_run_handler_sync, get_node_handler, { - "project_id": project_id, "node_id": node_id, "fields": fields, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_node_handler, + { + "project_id": project_id, + "node_id": node_id, + "fields": fields, + }, + ) + @mcp.tool() async def node_start( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, - node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — start multiple nodes in parallel")] = None, + node_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — start multiple nodes in parallel') + ] = None, ) -> list[dict[str, Any]]: """Start one or more nodes. Provide node_id for single, or node_ids for batch.""" params = {"project_id": project_id} @@ -460,11 +530,14 @@ async def node_start( params["node_id"] = node_id return await asyncio.to_thread(_run_handler_sync, start_node_handler, params) + @mcp.tool() async def node_stop( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, - node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — stop multiple nodes in parallel")] = None, + node_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — stop multiple nodes in parallel') + ] = None, ) -> list[dict[str, Any]]: """Stop one or more nodes. Provide node_id for single, or node_ids for batch.""" params = {"project_id": project_id} @@ -474,11 +547,14 @@ async def node_stop( params["node_id"] = node_id return await asyncio.to_thread(_run_handler_sync, stop_node_handler, params) + @mcp.tool() async def node_suspend( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, - node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — suspend multiple nodes in parallel")] = None, + node_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — suspend multiple nodes in parallel') + ] = None, ) -> list[dict[str, Any]]: """Suspend one or more nodes. Provide node_id for single, or node_ids for batch.""" params = {"project_id": project_id} @@ -492,17 +568,29 @@ async def node_suspend( @mcp.tool() async def node_create( project_id: Annotated[str, Field(description="UUID of the project")], - template_id: Annotated[str | None, Field(description="Template UUID (required for single mode; used as default in batch mode)")] = None, + template_id: Annotated[ + str | None, Field(description="Template UUID (required for single mode; used as default in batch mode)") + ] = None, x: Annotated[int, Field(description="X coordinate (canvas center origin, right positive)")] = 0, y: Annotated[int, Field(description="Y coordinate (canvas center origin, down positive)")] = 0, compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", - nodes: Annotated[list | None, Field(description="Batch mode: [{name, template_id?, x?, y?, compute_id?}] — top-level template_id applies as default")] = None, - fields: Annotated[list[str] | None, Field(description="Response fields to include (default: [node_id, name, node_type, status, console]). " - "Available: compute_id, name, node_type, node_id, console, console_type, " - "console_auto_start, aux, aux_type, properties, label, symbol, x, y, z, " - "locked, port_name_format, port_segment_size, first_port_name, " - "custom_adapters, tags, template_id, project_id, node_directory, " - "status, command_line, width, height, ports, console_host")] = None, + nodes: Annotated[ + list | None, + Field( + description="Batch mode: [{name, template_id?, x?, y?, compute_id?}] — top-level template_id applies as default" + ), + ] = None, + fields: Annotated[ + list[str] | None, + Field( + description="Response fields to include (default: [node_id, name, node_type, status, console]). " + "Available: compute_id, name, node_type, node_id, console, console_type, " + "console_auto_start, aux, aux_type, properties, label, symbol, x, y, z, " + "locked, port_name_format, port_segment_size, first_port_name, " + "custom_adapters, tags, template_id, project_id, node_directory, " + "status, command_line, width, height, ports, console_host" + ), + ] = None, ) -> list[dict[str, Any]]: """Create one or more nodes from templates. @@ -514,21 +602,37 @@ async def node_create( port — such batches are created sequentially so those assignments follow submission order. """ if nodes is not None: - return await asyncio.to_thread(_run_handler_sync, create_node_handler, { - "project_id": project_id, "nodes": nodes, "fields": fields, + return await asyncio.to_thread( + _run_handler_sync, + create_node_handler, + { + "project_id": project_id, + "nodes": nodes, + "fields": fields, + "template_id": template_id, + }, + ) + return await asyncio.to_thread( + _run_handler_sync, + create_node_handler, + { + "project_id": project_id, "template_id": template_id, - }) - return await asyncio.to_thread(_run_handler_sync, create_node_handler, { - "project_id": project_id, "template_id": template_id, - "x": x, "y": y, "compute_id": compute_id, "fields": fields, - }) + "x": x, + "y": y, + "compute_id": compute_id, + "fields": fields, + }, + ) @mcp.tool() async def node_delete( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None, - node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — delete multiple nodes in parallel")] = None, + node_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — delete multiple nodes in parallel') + ] = None, ) -> list[dict[str, Any]]: """Delete one or more nodes from a project. Provide node_id for single, or node_ids for batch.""" params = {"project_id": project_id} @@ -576,17 +680,28 @@ async def node_console( device output is not cut off before it arrives - Set a timeout to prevent hanging connections """ - return await asyncio.to_thread(_run_handler_sync, get_node_console_info_handler, { - "project_id": project_id, "node_id": node_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_node_console_info_handler, + { + "project_id": project_id, + "node_id": node_id, + }, + ) # ── Link tools ──────────────────────────────────────────────────────── + @mcp.tool() async def link_list( project_id: Annotated[str, Field(description="UUID of the project")], - fields: Annotated[list[str] | None, Field(description="Optional: return only these fields. e.g. [\"link_id\",\"nodes\"]. Available: link_id, project_id, link_type, nodes, suspend, filters, capturing, capture_file_name, link_style")] = None, + fields: Annotated[ + list[str] | None, + Field( + description='Optional: return only these fields. e.g. ["link_id","nodes"]. Available: link_id, project_id, link_type, nodes, suspend, filters, capturing, capture_file_name, link_style' + ), + ] = None, ) -> list[dict[str, Any]]: """List all links in a project. Use fields=[] to return only what you need.""" return await asyncio.to_thread(_run_handler_sync, get_links_handler, {"project_id": project_id, "fields": fields}) @@ -604,14 +719,27 @@ async def link_get( @mcp.tool() async def link_create( project_id: Annotated[str, Field(description="UUID of the project")], - nodes: Annotated[list | None, Field(description="Single mode: [{node_id, adapter_number, port_number}] or compact [id, ad, pt, id, ad, pt]")] = None, + nodes: Annotated[ + list | None, + Field(description="Single mode: [{node_id, adapter_number, port_number}] or compact [id, ad, pt, id, ad, pt]"), + ] = None, link_type: Annotated[str, Field(description="Link type - ethernet or serial")] = "ethernet", filters: Annotated[dict | None, Field(description="Optional packet filters")] = None, - links: Annotated[list | None, Field(description="Batch mode: [{nodes, link_type?, filters?}] — nodes supports compact [id, ad, pt, id, ad, pt] format")] = None, - fields: Annotated[list[str] | None, Field(description="Response fields to include (default: [link_id, link_type, nodes]). " - "Available: link_id, project_id, link_type, nodes, suspend, " - "link_style, filters, show_filters_icon, capturing, " - "capture_file_name, capture_file_path, capture_compute_id, wireshark")] = None, + links: Annotated[ + list | None, + Field( + description="Batch mode: [{nodes, link_type?, filters?}] — nodes supports compact [id, ad, pt, id, ad, pt] format" + ), + ] = None, + fields: Annotated[ + list[str] | None, + Field( + description="Response fields to include (default: [link_id, link_type, nodes]). " + "Available: link_id, project_id, link_type, nodes, suspend, " + "link_style, filters, show_filters_icon, capturing, " + "capture_file_name, capture_file_path, capture_compute_id, wireshark" + ), + ] = None, ) -> list[dict[str, Any]]: """Create one or more links between nodes. @@ -619,9 +747,15 @@ async def link_create( Batch mode: provide links=[{nodes, link_type?, filters?}] — up to 100 in parallel """ if links: - return await asyncio.to_thread(_run_handler_sync, create_link_handler, { - "project_id": project_id, "links": links, "fields": fields, - }) + return await asyncio.to_thread( + _run_handler_sync, + create_link_handler, + { + "project_id": project_id, + "links": links, + "fields": fields, + }, + ) params = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields} if filters: params["filters"] = filters @@ -632,7 +766,9 @@ async def link_create( async def link_delete( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None, - link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — delete multiple links in parallel")] = None, + link_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — delete multiple links in parallel') + ] = None, ) -> list[dict[str, Any]]: """Delete one or more links from a project.""" params = {"project_id": project_id} @@ -685,18 +821,29 @@ async def link_available_filters( ) -> list[dict[str, Any]]: """List the packet filter types available for a link (frequency_drop, packet_loss, delay, corrupt, bpf) with their parameters. Use before setting filters with link_update.""" - return await asyncio.to_thread(_run_handler_sync, available_filters_handler, { - "project_id": project_id, "link_id": link_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + available_filters_handler, + { + "project_id": project_id, + "link_id": link_id, + }, + ) # ── Template tools ──────────────────────────────────────────── + @mcp.tool() async def template_list( - fields: Annotated[list[str] | None, Field(description="Response fields to include (default: [template_id, name, template_type, category, default_name_format]). " - "Available: template_id, name, version, category, default_name_format, symbol, " - "template_type, compute_id, usage, tags, builtin, created_at, updated_at")] = None, + fields: Annotated[ + list[str] | None, + Field( + description="Response fields to include (default: [template_id, name, template_type, category, default_name_format]). " + "Available: template_id, name, version, category, default_name_format, symbol, " + "template_type, compute_id, usage, tags, builtin, created_at, updated_at" + ), + ] = None, ) -> list[dict[str, Any]]: """List all available templates on the server.""" return await asyncio.to_thread(_run_handler_sync, list_templates_handler, {"fields": fields}) @@ -708,9 +855,14 @@ async def template_get( name: Annotated[str | None, Field(description="Template name (optional if template_id is provided)")] = None, ) -> list[dict[str, Any]]: """Get detailed information about a specific template.""" - return await asyncio.to_thread(_run_handler_sync, get_template_handler, { - "template_id": template_id, "name": name, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_template_handler, + { + "template_id": template_id, + "name": name, + }, + ) @mcp.tool() @@ -718,7 +870,9 @@ async def template_create( name: Annotated[str, Field(description="Template name")], template_type: Annotated[str, Field(description="Template type (e.g. qemu, docker, dynamips)")], compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", - image: Annotated[str | None, Field(description="Docker image name or Dynamips IOS image path (required for docker/dynamips)")] = None, + image: Annotated[ + str | None, Field(description="Docker image name or Dynamips IOS image path (required for docker/dynamips)") + ] = None, ) -> list[dict[str, Any]]: """Create a new template. @@ -751,13 +905,19 @@ async def template_delete( name: Annotated[str | None, Field(description="Template name (optional if template_id is provided)")] = None, ) -> list[dict[str, Any]]: """Delete a template.""" - return await asyncio.to_thread(_run_handler_sync, delete_template_handler, { - "template_id": template_id, "name": name, - }) + return await asyncio.to_thread( + _run_handler_sync, + delete_template_handler, + { + "template_id": template_id, + "name": name, + }, + ) # ── Compute tools ───────────────────────────────────────────────────── + @mcp.tool() async def compute_list() -> list[dict[str, Any]]: """List all remotely registered compute nodes (returns only database entries, does NOT include the built-in local compute). @@ -769,7 +929,12 @@ async def compute_list() -> list[dict[str, Any]]: @mcp.tool() async def compute_get( - compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local", + compute_id: Annotated[ + str, + Field( + description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list" + ), + ] = "local", ) -> list[dict[str, Any]]: """Get detailed information about a compute node. @@ -782,16 +947,26 @@ async def compute_get( @mcp.tool() async def compute_images( emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")], - compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local", + compute_id: Annotated[ + str, + Field( + description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list" + ), + ] = "local", ) -> list[dict[str, Any]]: """List available images for an emulator on a compute node. Accepts 'local' for the built-in local compute or a UUID from compute_list for a registered remote compute. """ - return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { - "emulator": emulator, "compute_id": compute_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_compute_images_handler, + { + "emulator": emulator, + "compute_id": compute_id, + }, + ) # ── Node file tools ──────────────────────────────────────────────────── @@ -809,9 +984,16 @@ async def node_file_list( Use this first to check file sizes before reading files with get_node_file. Large config files should be read in chunks using offset/limit. """ - return await asyncio.to_thread(_run_handler_sync, list_node_files_handler, { - "project_id": project_id, "node_id": node_id, "path": path, "recursive": recursive, - }) + return await asyncio.to_thread( + _run_handler_sync, + list_node_files_handler, + { + "project_id": project_id, + "node_id": node_id, + "path": path, + "recursive": recursive, + }, + ) @mcp.tool() @@ -831,10 +1013,17 @@ async def node_file_get( Large files (>50KB) are auto-truncated; check the metadata.truncated flag. For binary files, check the file type via list_node_files first. """ - return await asyncio.to_thread(_run_handler_sync, get_node_file_handler, { - "project_id": project_id, "node_id": node_id, "file_path": file_path, - "offset": offset, "limit": limit, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_node_file_handler, + { + "project_id": project_id, + "node_id": node_id, + "file_path": file_path, + "offset": offset, + "limit": limit, + }, + ) @mcp.tool() @@ -845,9 +1034,16 @@ async def node_file_write( content: Annotated[str, Field(description="Content to write to the file")], ) -> list[dict[str, Any]]: """Write content to a file in a node directory. Creates the file if it doesn't exist. Overwrites existing content.""" - return await asyncio.to_thread(_run_handler_sync, write_node_file_handler, { - "project_id": project_id, "node_id": node_id, "file_path": file_path, "content": content, - }) + return await asyncio.to_thread( + _run_handler_sync, + write_node_file_handler, + { + "project_id": project_id, + "node_id": node_id, + "file_path": file_path, + "content": content, + }, + ) @mcp.tool() @@ -857,9 +1053,15 @@ async def node_file_delete( file_path: Annotated[str, Field(description="Path to the file within the node directory")], ) -> list[dict[str, Any]]: """Delete a file from a node directory. Cannot be undone.""" - return await asyncio.to_thread(_run_handler_sync, delete_node_file_handler, { - "project_id": project_id, "node_id": node_id, "file_path": file_path, - }) + return await asyncio.to_thread( + _run_handler_sync, + delete_node_file_handler, + { + "project_id": project_id, + "node_id": node_id, + "file_path": file_path, + }, + ) # ── Node bulk / advanced tools ───────────────────────────────────────── @@ -870,9 +1072,13 @@ async def node_start_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Start all nodes in a project.""" - return await asyncio.to_thread(_run_handler_sync, start_all_nodes_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + start_all_nodes_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -880,9 +1086,13 @@ async def node_stop_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Stop all nodes in a project.""" - return await asyncio.to_thread(_run_handler_sync, stop_all_nodes_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + stop_all_nodes_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -890,9 +1100,13 @@ async def node_suspend_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Suspend all nodes in a project.""" - return await asyncio.to_thread(_run_handler_sync, suspend_all_nodes_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + suspend_all_nodes_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -904,9 +1118,17 @@ async def node_duplicate( z: Annotated[int, Field(description="Z layer for the new node")] = 0, ) -> list[dict[str, Any]]: """Duplicate a node in a project, creating a copy at a new position.""" - return await asyncio.to_thread(_run_handler_sync, duplicate_node_handler, { - "project_id": project_id, "node_id": node_id, "x": x, "y": y, "z": z, - }) + return await asyncio.to_thread( + _run_handler_sync, + duplicate_node_handler, + { + "project_id": project_id, + "node_id": node_id, + "x": x, + "y": y, + "z": z, + }, + ) @mcp.tool() @@ -915,9 +1137,14 @@ async def node_isolate( node_id: Annotated[str, Field(description="UUID of the node to isolate")], ) -> list[dict[str, Any]]: """Isolate a node by suspending all its attached links (network isolation).""" - return await asyncio.to_thread(_run_handler_sync, isolate_node_handler, { - "project_id": project_id, "node_id": node_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + isolate_node_handler, + { + "project_id": project_id, + "node_id": node_id, + }, + ) @mcp.tool() @@ -926,9 +1153,14 @@ async def node_unisolate( node_id: Annotated[str, Field(description="UUID of the node to unisolate")], ) -> list[dict[str, Any]]: """Un-isolate a node by resuming all its suspended links.""" - return await asyncio.to_thread(_run_handler_sync, unisolate_node_handler, { - "project_id": project_id, "node_id": node_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + unisolate_node_handler, + { + "project_id": project_id, + "node_id": node_id, + }, + ) @mcp.tool() @@ -937,9 +1169,14 @@ async def node_links( node_id: Annotated[str, Field(description="UUID of the node")], ) -> list[dict[str, Any]]: """List all links connected to a specific node.""" - return await asyncio.to_thread(_run_handler_sync, get_node_links_handler, { - "project_id": project_id, "node_id": node_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_node_links_handler, + { + "project_id": project_id, + "node_id": node_id, + }, + ) # ── Link capture / reset tools ──────────────────────────────────────── @@ -949,7 +1186,9 @@ async def node_links( async def link_reset( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None, - link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — reset multiple links in parallel")] = None, + link_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — reset multiple links in parallel') + ] = None, ) -> list[dict[str, Any]]: """Reset one or more links by tearing down and recreating the UDP connection. @@ -977,10 +1216,18 @@ async def link_capture_start( data_link_type: Annotated[str, Field(description="Data link type (default: DLT_EN10MB)")] = "DLT_EN10MB", capture_file_name: Annotated[str | None, Field(description="Capture file name (optional)")] = None, wireshark: Annotated[bool, Field(description="Open Wireshark automatically (default: false)")] = False, - link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — start capture on multiple links in parallel")] = None, + link_ids: Annotated[ + list[str] | None, + Field(description='Batch mode: ["uuid1","uuid2"] — start capture on multiple links in parallel'), + ] = None, ) -> list[dict[str, Any]]: """Start packet capture on one or more links.""" - params = {"project_id": project_id, "data_link_type": data_link_type, "capture_file_name": capture_file_name, "wireshark": wireshark} + params = { + "project_id": project_id, + "data_link_type": data_link_type, + "capture_file_name": capture_file_name, + "wireshark": wireshark, + } if link_ids: params["link_ids"] = link_ids else: @@ -992,7 +1239,10 @@ async def link_capture_start( async def link_capture_stop( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None, - link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — stop capture on multiple links in parallel")] = None, + link_ids: Annotated[ + list[str] | None, + Field(description='Batch mode: ["uuid1","uuid2"] — stop capture on multiple links in parallel'), + ] = None, ) -> list[dict[str, Any]]: """Stop packet capture on one or more links.""" params = {"project_id": project_id} @@ -1007,7 +1257,9 @@ async def link_capture_stop( async def link_capture_download( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None, - link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — get download URLs for multiple captures")] = None, + link_ids: Annotated[ + list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — get download URLs for multiple captures') + ] = None, ) -> list[dict[str, Any]]: """Get download command(s) for PCAP capture file(s). @@ -1037,16 +1289,35 @@ async def link_marker( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], action: Annotated[str, Field(description="Action: create, update, or delete")], - bpf: Annotated[str | None, Field(description="BPF expression, e.g. 'arp', 'icmp', 'tcp port 80' (required for create)")] = None, + bpf: Annotated[ + str | None, Field(description="BPF expression, e.g. 'arp', 'icmp', 'tcp port 80' (required for create)") + ] = None, marker_name: Annotated[str | None, Field(description="Marker name (required for update/delete actions)")] = None, - name: Annotated[str | None, Field(description="Custom marker name for create action (auto-generated if omitted)")] = None, + name: Annotated[ + str | None, Field(description="Custom marker name for create action (auto-generated if omitted)") + ] = None, tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None, - direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None, - capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None, + direction: Annotated[ + str | None, + Field( + description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update." + ), + ] = None, + capture_node_id: Annotated[ + str | None, + Field( + description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick." + ), + ] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, - data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (create-only): DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483, matching the encapsulation on the serial link. Omit = DLT_EN10MB (Ethernet). Ignored on update — changing it would invalidate the capture file.")] = None, + data_link_type: Annotated[ + str | None, + Field( + description="pcap link-layer type for serial links (create-only): DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483, matching the encapsulation on the serial link. Omit = DLT_EN10MB (Ethernet). Ignored on update — changing it would invalidate the capture file." + ), + ] = None, ) -> list[dict[str, Any]]: """Manage traffic-insight markers on a link. @@ -1063,7 +1334,18 @@ async def link_marker( and cannot be modified or deleted via this tool. """ params = {"project_id": project_id, "link_id": link_id, "action": action} - for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration", "data_link_type"): + for opt in ( + "bpf", + "marker_name", + "name", + "tag", + "enabled", + "direction", + "capture_node_id", + "color", + "highlight_duration", + "data_link_type", + ): val = locals().get(opt) if val is not None: params[opt] = val @@ -1074,13 +1356,22 @@ async def link_marker( async def marker_definition( project_id: Annotated[str, Field(description="UUID of the project")], action: Annotated[str, Field(description="Action: create, update, delete, or list")], - bpf: Annotated[str | None, Field(description="BPF expression, e.g. 'arp', 'ospf', 'tcp port 22' (required for create)")] = None, + bpf: Annotated[ + str | None, Field(description="BPF expression, e.g. 'arp', 'ospf', 'tcp port 22' (required for create)") + ] = None, def_name: Annotated[str | None, Field(description="Definition name (required for update/delete actions)")] = None, - name: Annotated[str | None, Field(description="Custom definition name for create action (auto-generated if omitted)")] = None, + name: Annotated[ + str | None, Field(description="Custom definition name for create action (auto-generated if omitted)") + ] = None, tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, - data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483). Omit = Ethernet-only (serial links skipped); setting it also covers serial links with that encapsulation")] = None, + data_link_type: Annotated[ + str | None, + Field( + description="pcap link-layer type for serial links (DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483). Omit = Ethernet-only (serial links skipped); setting it also covers serial links with that encapsulation" + ), + ] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1117,9 +1408,13 @@ async def snapshot_list( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """List all snapshots of a project.""" - return await asyncio.to_thread(_run_handler_sync, get_snapshots_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_snapshots_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -1133,9 +1428,14 @@ async def snapshot_create( must be stopped first. Use node_stop_all before creating a snapshot. Cloud, NAT, and switch nodes are always-running and can be ignored. """ - return await asyncio.to_thread(_run_handler_sync, create_snapshot_handler, { - "project_id": project_id, "name": name, - }) + return await asyncio.to_thread( + _run_handler_sync, + create_snapshot_handler, + { + "project_id": project_id, + "name": name, + }, + ) @mcp.tool() @@ -1144,9 +1444,14 @@ async def snapshot_delete( snapshot_id: Annotated[str, Field(description="UUID of the snapshot to delete")], ) -> list[dict[str, Any]]: """Delete a snapshot from a project. Cannot be undone.""" - return await asyncio.to_thread(_run_handler_sync, delete_snapshot_handler, { - "project_id": project_id, "snapshot_id": snapshot_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + delete_snapshot_handler, + { + "project_id": project_id, + "snapshot_id": snapshot_id, + }, + ) @mcp.tool() @@ -1155,9 +1460,14 @@ async def snapshot_restore( snapshot_id: Annotated[str, Field(description="UUID of the snapshot to restore")], ) -> list[dict[str, Any]]: """Restore a project to a previous snapshot state. The project may be closed and reopened.""" - return await asyncio.to_thread(_run_handler_sync, restore_snapshot_handler, { - "project_id": project_id, "snapshot_id": snapshot_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + restore_snapshot_handler, + { + "project_id": project_id, + "snapshot_id": snapshot_id, + }, + ) # ── Drawing tools ────────────────────────────────────────────────────── @@ -1168,9 +1478,13 @@ async def drawing_list( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """List all drawings (labels, shapes, images) on a project canvas.""" - return await asyncio.to_thread(_run_handler_sync, get_drawings_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_drawings_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -1198,10 +1512,19 @@ async def drawing_create( Line: Dashed line: """ - return await asyncio.to_thread(_run_handler_sync, create_drawing_handler, { - "project_id": project_id, "svg": svg, "x": x, "y": y, "z": z, - "locked": locked, "rotation": rotation, - }) + return await asyncio.to_thread( + _run_handler_sync, + create_drawing_handler, + { + "project_id": project_id, + "svg": svg, + "x": x, + "y": y, + "z": z, + "locked": locked, + "rotation": rotation, + }, + ) @mcp.tool() @@ -1210,9 +1533,14 @@ async def drawing_get( drawing_id: Annotated[str, Field(description="UUID of the drawing")], ) -> list[dict[str, Any]]: """Get detailed information about a specific drawing.""" - return await asyncio.to_thread(_run_handler_sync, get_drawing_handler, { - "project_id": project_id, "drawing_id": drawing_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_drawing_handler, + { + "project_id": project_id, + "drawing_id": drawing_id, + }, + ) @mcp.tool() @@ -1241,9 +1569,14 @@ async def drawing_delete( drawing_id: Annotated[str, Field(description="UUID of the drawing to delete")], ) -> list[dict[str, Any]]: """Delete a drawing from a project canvas. Cannot be undone.""" - return await asyncio.to_thread(_run_handler_sync, delete_drawing_handler, { - "project_id": project_id, "drawing_id": drawing_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + delete_drawing_handler, + { + "project_id": project_id, + "drawing_id": drawing_id, + }, + ) # ── Project lock tools ──────────────────────────────────────────────── @@ -1254,9 +1587,13 @@ async def project_lock( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Lock all drawings and nodes in a project to prevent accidental changes.""" - return await asyncio.to_thread(_run_handler_sync, lock_project_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + lock_project_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -1264,9 +1601,13 @@ async def project_unlock( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Unlock a project to allow editing of drawings and nodes.""" - return await asyncio.to_thread(_run_handler_sync, unlock_project_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + unlock_project_handler, + { + "project_id": project_id, + }, + ) @mcp.tool() @@ -1274,9 +1615,13 @@ async def project_locked( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Check whether a project is locked (preventing edits to drawings and nodes).""" - return await asyncio.to_thread(_run_handler_sync, get_locked_project_handler, { - "project_id": project_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_locked_project_handler, + { + "project_id": project_id, + }, + ) # ── Server info tools ───────────────────────────────────────────────── @@ -1369,7 +1714,12 @@ async def server_statistics() -> list[dict[str, Any]]: @mcp.tool() async def appliance_list( - fields: Annotated[list[str] | None, Field(description="Optional: return only these fields. e.g. [\"name\",\"category\"]. Available: name, category, description, vendor_name, product_name, status, availability, images, versions, tags, symbol, usage, builtin")] = None, + fields: Annotated[ + list[str] | None, + Field( + description='Optional: return only these fields. e.g. ["name","category"]. Available: name, category, description, vendor_name, product_name, status, availability, images, versions, tags, symbol, usage, builtin' + ), + ] = None, ) -> list[dict[str, Any]]: """List all available appliances (template library). Use fields=[] to return only what you need.""" return await asyncio.to_thread(_run_handler_sync, get_appliances_handler, {"fields": fields} if fields else {}) @@ -1380,15 +1730,24 @@ async def appliance_get( appliance_id: Annotated[str, Field(description="UUID of the appliance")], ) -> list[dict[str, Any]]: """Get detailed information about a specific appliance.""" - return await asyncio.to_thread(_run_handler_sync, get_appliance_handler, { - "appliance_id": appliance_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_appliance_handler, + { + "appliance_id": appliance_id, + }, + ) @mcp.tool() async def appliance_install( appliance_id: Annotated[str, Field(description="UUID of the appliance to install")], - version: Annotated[str | None, Field(description="Version to install (e.g. '2.7.0.356'). Required if the appliance has multiple versions. Use appliance_get to see available versions.")] = None, + version: Annotated[ + str | None, + Field( + description="Version to install (e.g. '2.7.0.356'). Required if the appliance has multiple versions. Use appliance_get to see available versions." + ), + ] = None, ) -> list[dict[str, Any]]: """Create a template from a GNS3 appliance definition and return the created template. @@ -1397,10 +1756,14 @@ async def appliance_install( The appliance definition is read from local .gns3a files bundled with the server. Use get_appliance first to see what images are required. """ - return await asyncio.to_thread(_run_handler_sync, install_appliance_handler, { - "appliance_id": appliance_id, - "version": version, - }) + return await asyncio.to_thread( + _run_handler_sync, + install_appliance_handler, + { + "appliance_id": appliance_id, + "version": version, + }, + ) # ── Image tools ─────────────────────────────────────────────────────── @@ -1417,9 +1780,13 @@ async def image_get( image_id: Annotated[str, Field(description="ID or filename of the image")], ) -> list[dict[str, Any]]: """Get detailed information about a specific image.""" - return await asyncio.to_thread(_run_handler_sync, get_image_handler, { - "image_id": image_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + get_image_handler, + { + "image_id": image_id, + }, + ) @mcp.tool() @@ -1427,9 +1794,13 @@ async def image_delete( image_id: Annotated[str, Field(description="ID or filename of the image to delete")], ) -> list[dict[str, Any]]: """Delete an image from the server. Cannot be undone.""" - return await asyncio.to_thread(_run_handler_sync, delete_image_handler, { - "image_id": image_id, - }) + return await asyncio.to_thread( + _run_handler_sync, + delete_image_handler, + { + "image_id": image_id, + }, + ) @mcp.tool() @@ -1470,10 +1841,18 @@ async def image_install() -> list[dict[str, Any]]: @mcp.tool() async def device_config_send( project_id: Annotated[str, Field(description="UUID of the project")], - device_configs: Annotated[list, Field( - description="List of device configs. Each entry: {\"device_name\": \"R1\", \"config_commands\": [\"int lo0\", \"ip add 1.1.1.1 255.255.255.255\"]}" - )], - template: Annotated[str | None, Field(description="Optional Jinja2 template. Use with vars in each device to reduce token usage for batch config. Example: \"interface lo{{ n }}\\nip address {{ ip }} 255.255.255.255\"")] = None, + device_configs: Annotated[ + list, + Field( + description='List of device configs. Each entry: {"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]}' + ), + ], + template: Annotated[ + str | None, + Field( + description='Optional Jinja2 template. Use with vars in each device to reduce token usage for batch config. Example: "interface lo{{ n }}\\nip address {{ ip }} 255.255.255.255"' + ), + ] = None, ) -> list[dict[str, Any]]: """Send configuration commands to network devices via console (telnet/SSH). @@ -1499,10 +1878,18 @@ async def device_config_send( @mcp.tool() async def device_show_run( project_id: Annotated[str, Field(description="UUID of the project")], - device_configs: Annotated[list, Field( - description="List of device commands. Each entry: {\"device_name\": \"R1\", \"commands\": [\"show ip int brief\", \"show running-config\"]}" - )], - template: Annotated[str | None, Field(description="Optional Jinja2 template. Use with vars per device. Example: \"show ip route {{ protocol }}\"")] = None, + device_configs: Annotated[ + list, + Field( + description='List of device commands. Each entry: {"device_name": "R1", "commands": ["show ip int brief", "show running-config"]}' + ), + ], + template: Annotated[ + str | None, + Field( + description='Optional Jinja2 template. Use with vars per device. Example: "show ip route {{ protocol }}"' + ), + ] = None, ) -> list[dict[str, Any]]: """Run read-only diagnostic (show) commands on network devices via console. @@ -1533,9 +1920,12 @@ async def device_show_run( @mcp.tool() async def vpcs_config_set( project_id: Annotated[str, Field(description="UUID of the project")], - device_configs: Annotated[list, Field( - description="List of VPCS configs. Each entry: {\"device_name\": \"PC1\", \"commands\": [\"ip 10.0.0.1/24 10.0.0.254\", \"save\"]}" - )], + device_configs: Annotated[ + list, + Field( + description='List of VPCS configs. Each entry: {"device_name": "PC1", "commands": ["ip 10.0.0.1/24 10.0.0.254", "save"]}' + ), + ], ) -> list[dict[str, Any]]: """Configure VPCS devices (set IP addresses, gateway, etc.). @@ -1549,13 +1939,19 @@ async def vpcs_config_set( - save Save config to startup.vpc - ping Test connectivity """ - return await asyncio.to_thread(_run_handler_sync, vpcs_config_set_handler, { - "project_id": project_id, "device_configs": device_configs, - }) + return await asyncio.to_thread( + _run_handler_sync, + vpcs_config_set_handler, + { + "project_id": project_id, + "device_configs": device_configs, + }, + ) # ── Auth‑wrapped SSE app ────────────────────────────────────────────── + def _make_auth_wrapper(inner_app): """Wrap the SSE app with JWT validation. @@ -1576,10 +1972,7 @@ def _make_auth_wrapper(inner_app): f"Rejecting MCP connection - GNS3 server initialization not complete. " f"Client: {client_info['host']}:{client_info['port']} ({client_info['user_info']}, Path: {client_info['path']})" ) - response = Response( - "GNS3 server initialization not complete - please retry later", - status_code=503 - ) + response = Response("GNS3 server initialization not complete - please retry later", status_code=503) await response(scope, receive, send) return diff --git a/gns3server/agent/mcp/appliances.py b/gns3server/agent/mcp/appliances.py index ab867fe57..45a9b472a 100644 --- a/gns3server/agent/mcp/appliances.py +++ b/gns3server/agent/mcp/appliances.py @@ -28,8 +28,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -41,12 +43,32 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── VALID_APPLIANCE_FIELDS = { - "appliance_id", "name", "category", "description", "vendor_name", - "vendor_url", "product_name", "product_url", "documentation_url", - "status", "availability", "maintainer", "usage", "symbol", - "images", "versions", "tags", "builtin", - "first_port_name", "port_name_format", "port_segment_size", - "linked_clone", "docker", "iou", "dynamips", "qemu", + "appliance_id", + "name", + "category", + "description", + "vendor_name", + "vendor_url", + "product_name", + "product_url", + "documentation_url", + "status", + "availability", + "maintainer", + "usage", + "symbol", + "images", + "versions", + "tags", + "builtin", + "first_port_name", + "port_name_format", + "port_segment_size", + "linked_clone", + "docker", + "iou", + "dynamips", + "qemu", } @@ -56,7 +78,7 @@ def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> fields = params.get("fields") if fields: if not isinstance(fields, list): - return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"} + return {"error": 'fields must be a list, e.g. ["name", "category"]'} invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS] if invalid: return { diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py index 254dc53f1..1f744066c 100644 --- a/gns3server/agent/mcp/computes.py +++ b/gns3server/agent/mcp/computes.py @@ -27,6 +27,7 @@ log = logging.getLogger(__name__) def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], diff --git a/gns3server/agent/mcp/device_config.py b/gns3server/agent/mcp/device_config.py index 990f4b097..278295071 100644 --- a/gns3server/agent/mcp/device_config.py +++ b/gns3server/agent/mcp/device_config.py @@ -81,6 +81,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field: # ── Tool handlers ────────────────────────────────────────────────────────── + def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: """Send configuration commands to network devices via console.""" project_id = params.get("project_id") @@ -97,10 +98,12 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands tool = ExecuteMultipleDeviceConfigCommands() - input_data = json.dumps({ - "project_id": project_id, - "device_configs": device_configs, - }) + input_data = json.dumps( + { + "project_id": project_id, + "device_configs": device_configs, + } + ) return tool._run( input_data, jwt_token=gns3_ctx["jwt_token"], @@ -114,7 +117,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> device_configs = params.get("device_configs") template = params.get("template") if not project_id or not device_configs: - return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}] + return [ + { + "status": "failed", + "error": "project_id and device_configs (list of {device_name, commands}) are required", + } + ] if template: device_configs = _render_template(template, device_configs, commands_field="commands") @@ -124,10 +132,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands tool = ExecuteMultipleDeviceCommands() - input_data = json.dumps({ - "project_id": project_id, - "device_configs": device_configs, - }) + input_data = json.dumps( + { + "project_id": project_id, + "device_configs": device_configs, + } + ) return tool._run( input_data, jwt_token=gns3_ctx["jwt_token"], @@ -145,10 +155,12 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands tool = VPCSCommands() - input_data = json.dumps({ - "project_id": project_id, - "device_configs": device_configs, - }) + input_data = json.dumps( + { + "project_id": project_id, + "device_configs": device_configs, + } + ) return tool._run( input_data, jwt_token=gns3_ctx["jwt_token"], diff --git a/gns3server/agent/mcp/drawings.py b/gns3server/agent/mcp/drawings.py index e9135b0f9..a3ca146f6 100644 --- a/gns3server/agent/mcp/drawings.py +++ b/gns3server/agent/mcp/drawings.py @@ -28,8 +28,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── + def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: project_id = params.get("project_id") if not project_id: diff --git a/gns3server/agent/mcp/images.py b/gns3server/agent/mcp/images.py index 3fcbfd17d..d2b7ed958 100644 --- a/gns3server/agent/mcp/images.py +++ b/gns3server/agent/mcp/images.py @@ -28,8 +28,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── + def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) images = conn.http_call("get", f"{conn.base_url}/images").json() diff --git a/gns3server/agent/mcp/projects.py b/gns3server/agent/mcp/projects.py index e095354a1..ff6b5125c 100644 --- a/gns3server/agent/mcp/projects.py +++ b/gns3server/agent/mcp/projects.py @@ -31,9 +31,11 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): """Create a Gns3Connector from the GNS3 context dict.""" from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -44,6 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── + def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) projects = conn.http_call("get", f"{conn.base_url}/projects").json() @@ -141,7 +144,12 @@ def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) return {"project_id": project_id, "file": "README.txt", "content": content} except Exception as e: if "404" in str(e): - return {"project_id": project_id, "file": "README.txt", "content": None, "message": "README.txt does not exist yet"} + return { + "project_id": project_id, + "file": "README.txt", + "content": None, + "message": "README.txt does not exist yet", + } raise @@ -286,7 +294,10 @@ PROJECT_TOOLS = [ "show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"}, "grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"}, "drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"}, - "show_interface_labels": {"type": "boolean", "description": "Show interface labels on the drawing area"}, + "show_interface_labels": { + "type": "boolean", + "description": "Show interface labels on the drawing area", + }, }, "required": ["project_id"], }, diff --git a/gns3server/agent/mcp/server.py b/gns3server/agent/mcp/server.py index 0f8ac4abb..fe5c21dc3 100644 --- a/gns3server/agent/mcp/server.py +++ b/gns3server/agent/mcp/server.py @@ -28,8 +28,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── + def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) return conn.http_call("get", f"{conn.base_url}/version").json() diff --git a/gns3server/agent/mcp/snapshots.py b/gns3server/agent/mcp/snapshots.py index f428292b9..336c7a3e0 100644 --- a/gns3server/agent/mcp/snapshots.py +++ b/gns3server/agent/mcp/snapshots.py @@ -28,8 +28,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── + def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: project_id = params.get("project_id") if not project_id: diff --git a/gns3server/agent/mcp/symbols.py b/gns3server/agent/mcp/symbols.py index 96f2841d6..d363e042c 100644 --- a/gns3server/agent/mcp/symbols.py +++ b/gns3server/agent/mcp/symbols.py @@ -30,8 +30,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -42,6 +44,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── + def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) symbols = conn.http_call("get", f"{conn.base_url}/symbols").json() @@ -57,9 +60,11 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict username = gns3_ctx.get("jwt_username") # short-lived ticket bound to this exact path — LLM clients retyping curl # commands corrupted the long Bearer JWT this used to embed - ticket = access_ticket_service.mint( - username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path - ) if username else None + ticket = ( + access_ticket_service.mint(username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path) + if username + else None + ) if ticket: download_url += f"?token={ticket}" result = { @@ -68,7 +73,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict "note": "Symbol files are SVG images.", } if ticket: - safe_name = symbol_id.replace(':', '').replace('/', '_') + safe_name = symbol_id.replace(":", "").replace("/", "_") result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'" result["note"] += " The download URL includes a 10-minute ticket." return result diff --git a/gns3server/agent/mcp/templates.py b/gns3server/agent/mcp/templates.py index 7c0f678bb..a80f08ff9 100644 --- a/gns3server/agent/mcp/templates.py +++ b/gns3server/agent/mcp/templates.py @@ -31,8 +31,10 @@ log = logging.getLogger(__name__) # ── Helper ───────────────────────────────────────────────────────────────── + def _get_connector(gns3_ctx: dict[str, Any]): from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector + return Gns3Connector( url=gns3_ctx["server_url"], jwt_token=gns3_ctx["jwt_token"], @@ -44,9 +46,19 @@ def _get_connector(gns3_ctx: dict[str, Any]): # ── Tool handlers ────────────────────────────────────────────────────────── VALID_TEMPLATE_FIELDS = { - "template_id", "name", "version", "category", "default_name_format", - "symbol", "template_type", "compute_id", "usage", "tags", "builtin", - "created_at", "updated_at", + "template_id", + "name", + "version", + "category", + "default_name_format", + "symbol", + "template_type", + "compute_id", + "usage", + "tags", + "builtin", + "created_at", + "updated_at", } TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"] @@ -67,7 +79,7 @@ def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> fields = params.get("fields") if fields: if not isinstance(fields, list): - return {"error": "fields must be a list, e.g. [\"template_id\", \"name\"]"} + return {"error": 'fields must be a list, e.g. ["template_id", "name"]'} invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS] if invalid: return { diff --git a/gns3server/agent/web_wireshark/__init__.py b/gns3server/agent/web_wireshark/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gns3server/agent/web_wireshark/docker_client.py b/gns3server/agent/web_wireshark/docker_client.py index ca9e86360..d27873fa5 100644 --- a/gns3server/agent/web_wireshark/docker_client.py +++ b/gns3server/agent/web_wireshark/docker_client.py @@ -83,7 +83,7 @@ class DockerHTTPClient: self._connected = True # Parse API version - api_version = parse_version(docker_info['ApiVersion']) + api_version = parse_version(docker_info["ApiVersion"]) docker_version = docker_info["Version"] logger.info(f"Connected to Docker {docker_version}, API {api_version}") @@ -102,7 +102,7 @@ class DockerHTTPClient: logger.info(f"Using Docker API version {self._api_version}") else: # Use Docker daemon's actual API version - self._api_version = docker_info['ApiVersion'] + self._api_version = docker_info["ApiVersion"] logger.info(f"Using Docker API version {self._api_version} (daemon native)") except (aiohttp.ClientError, FileNotFoundError) as e: @@ -124,7 +124,7 @@ class DockerHTTPClient: Response JSON data """ # Check connection and version on first request - check_connection = kwargs.pop('check_connection', True) + check_connection = kwargs.pop("check_connection", True) if check_connection and not self._connected: await self._check_connection() @@ -147,22 +147,21 @@ class DockerHTTPClient: except RuntimeError as e: # Retry with lower API version if Docker daemon doesn't support current version error_msg = str(e) - if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION: - logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}") + if ( + "400" in error_msg or "not found" in error_msg.lower() + ) and self._api_version == DOCKER_PREFERRED_API_VERSION: + logger.warning( + f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}" + ) self._api_version = DOCKER_MINIMUM_API_VERSION return await self._request(method, endpoint, check_connection=False, **kwargs) raise async def create_network(self, name: str, driver: str = "bridge", subnet: str = None): """Create Docker network.""" - data = { - "Name": name, - "Driver": driver - } + data = {"Name": name, "Driver": driver} if subnet: - data["IPAM"] = { - "Config": [{"Subnet": subnet}] - } + data["IPAM"] = {"Config": [{"Subnet": subnet}]} await self._request("POST", "networks/create", json=data) async def get_network(self, name: str): @@ -176,19 +175,12 @@ class DockerHTTPClient: async def create_container(self, name: str, image: str, **kwargs): """Create container.""" - data = { - "Image": image, - "name": name, - "HostConfig": {}, - "NetworkingConfig": {} - } + data = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}} # Handle network config if "network" in kwargs: network_name = kwargs.pop("network") - data["NetworkingConfig"]["EndpointsConfig"] = { - network_name: {} - } + data["NetworkingConfig"]["EndpointsConfig"] = {network_name: {}} # Handle environment variables if "environment" in kwargs: diff --git a/gns3server/agent/web_wireshark/manage_wireshark.py b/gns3server/agent/web_wireshark/manage_wireshark.py index 905e1c2da..559eaadd9 100755 --- a/gns3server/agent/web_wireshark/manage_wireshark.py +++ b/gns3server/agent/web_wireshark/manage_wireshark.py @@ -92,10 +92,7 @@ logger = logging.getLogger(__name__) def setup_logging(verbose: bool = False): """Setup logging configuration.""" level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) + logging.basicConfig(level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") async def cmd_start(args) -> int: @@ -119,7 +116,7 @@ async def cmd_start(args) -> int: image=args.image, memory=args.memory, cpus=args.cpus, - pids_limit=args.pids_limit + pids_limit=args.pids_limit, ) print(json.dumps(result, indent=2)) @@ -143,10 +140,7 @@ async def cmd_stop(args) -> int: """ manager = WebWiresharkManager() try: - await manager.stop_wireshark_session( - project_id=args.project_id, - link_id=args.link_id - ) + await manager.stop_wireshark_session(project_id=args.project_id, link_id=args.link_id) print(json.dumps({"status": "stopped"})) return 0 except Exception as e: @@ -172,7 +166,7 @@ async def cmd_restart(args) -> int: project_id=args.project_id, link_id=args.link_id, jwt_token=args.jwt_token, - capture_stream_url=args.capture_url + capture_stream_url=args.capture_url, ) print(json.dumps(result, indent=2)) return 0 @@ -274,14 +268,8 @@ async def cmd_delete(args) -> int: def create_parser() -> argparse.ArgumentParser: """Create command line argument parser.""" - parser = argparse.ArgumentParser( - description="Web Wireshark container management" - ) - parser.add_argument( - "--verbose", "-v", - action="store_true", - help="Enable verbose logging" - ) + parser = argparse.ArgumentParser(description="Web Wireshark container management") + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") subparsers = parser.add_subparsers(dest="command", help="Commands") @@ -290,32 +278,13 @@ def create_parser() -> argparse.ArgumentParser: start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID") start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID") start_parser.add_argument("--jwt-token", required=True, help="JWT token") + start_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)") start_parser.add_argument( - "--capture-url", - help="Capture stream URL (auto-detected if not provided)" - ) - start_parser.add_argument( - "--image", - default="gns3/web-wireshark:latest", - help="Docker image (default: gns3/web-wireshark:latest)" - ) - start_parser.add_argument( - "--memory", - default="2g", - help="Memory limit (default: 2g)" - ) - start_parser.add_argument( - "--cpus", - type=float, - default=1.0, - help="CPU cores (default: 1.0)" - ) - start_parser.add_argument( - "--pids-limit", - type=int, - default=1000, - help="Process limit (default: 1000)" + "--image", default="gns3/web-wireshark:latest", help="Docker image (default: gns3/web-wireshark:latest)" ) + start_parser.add_argument("--memory", default="2g", help="Memory limit (default: 2g)") + start_parser.add_argument("--cpus", type=float, default=1.0, help="CPU cores (default: 1.0)") + start_parser.add_argument("--pids-limit", type=int, default=1000, help="Process limit (default: 1000)") # Stop command stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session") @@ -327,10 +296,7 @@ def create_parser() -> argparse.ArgumentParser: restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID") restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID") restart_parser.add_argument("--jwt-token", required=True, help="JWT token") - restart_parser.add_argument( - "--capture-url", - help="Capture stream URL (auto-detected if not provided)" - ) + restart_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)") # Stop all command stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions") diff --git a/gns3server/agent/web_wireshark/manager.py b/gns3server/agent/web_wireshark/manager.py index 19d23a5ab..e17cf65c0 100644 --- a/gns3server/agent/web_wireshark/manager.py +++ b/gns3server/agent/web_wireshark/manager.py @@ -63,15 +63,16 @@ class WebWiresharkManager: """ try: proc = await asyncio.create_subprocess_exec( - "docker", "exec", container_id, - "bash", "-c", "echo 'ping'", + "docker", + "exec", + container_id, + "bash", + "-c", + "echo 'ping'", stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, _ = await asyncio.wait_for( - proc.communicate(), - timeout=self.CONTAINER_EXEC_TIMEOUT + stderr=asyncio.subprocess.PIPE, ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.CONTAINER_EXEC_TIMEOUT) return proc.returncode == 0 and b"ping" in stdout except asyncio.TimeoutError: logger.warning(f"Container {container_id[:12]} health check timeout") @@ -99,16 +100,17 @@ class WebWiresharkManager: try: proc = await asyncio.create_subprocess_exec( - "docker", "exec", container_id, - "bash", "-c", command, + "docker", + "exec", + container_id, + "bash", + "-c", + command, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) try: - stdout, stderr = await asyncio.wait_for( - proc.communicate(), - timeout=timeout - ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) return (proc.returncode, stdout.decode().strip(), stderr.decode().strip()) except asyncio.TimeoutError: logger.error(f"Command timeout after {timeout}s: {command}") @@ -142,7 +144,7 @@ class WebWiresharkManager: logger.debug(f"No processes found matching pattern '{pattern}'") return - pids = pids_str.replace('\n', ' ') + pids = pids_str.replace("\n", " ") logger.info(f"Found matching processes for '{pattern}': PIDs={pids}") # Kill the processes using their container-local PIDs @@ -170,10 +172,13 @@ class WebWiresharkManager: try: # Get container init PID from host perspective proc = await asyncio.create_subprocess_exec( - "docker", "inspect", container_id, - "--format", "{{.State.Pid}}", + "docker", + "inspect", + container_id, + "--format", + "{{.State.Pid}}", stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) stdout, _ = await proc.communicate() container_init_pid = stdout.decode().strip() @@ -187,15 +192,13 @@ class WebWiresharkManager: # Check for residual processes from host perspective proc = await asyncio.create_subprocess_exec( - "ps", "-eo", "pid,ppid,args", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + "ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, _ = await proc.communicate() # Build parent->children mapping children_map = {} - for line in stdout.decode().strip().split('\n'): + for line in stdout.decode().strip().split("\n"): parts = line.split(None, 2) if len(parts) < 3: continue @@ -218,14 +221,15 @@ class WebWiresharkManager: # Check if any descendant processes match display patterns import re + patterns = [ - f'xpra.*:{display}', - f'Xvfb.*:{display}', - f'wireshark.*:{display}', - f'pulseaudio.*display=:{display}' + f"xpra.*:{display}", + f"Xvfb.*:{display}", + f"wireshark.*:{display}", + f"pulseaudio.*display=:{display}", ] - for line in stdout.decode().strip().split('\n'): + for line in stdout.decode().strip().split("\n"): parts = line.split(None, 2) if len(parts) < 3: continue @@ -248,13 +252,11 @@ class WebWiresharkManager: # /proc//root points to container filesystem lock_paths = [ f"/proc/{container_init_pid}/root/tmp/.X{display}-lock", - f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}" + f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}", ] for lock_path in lock_paths: proc = await asyncio.create_subprocess_exec( - "test", "-e", lock_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + "test", "-e", lock_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) await proc.communicate() if proc.returncode == 0: @@ -264,9 +266,7 @@ class WebWiresharkManager: # Check for xpra socket files socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket" proc = await asyncio.create_subprocess_exec( - "test", "-e", socket_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + "test", "-e", socket_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) await proc.communicate() if proc.returncode == 0: @@ -298,10 +298,13 @@ class WebWiresharkManager: try: # Get container init PID from host perspective proc = await asyncio.create_subprocess_exec( - "docker", "inspect", container_id, - "--format", "{{.State.Pid}}", + "docker", + "inspect", + container_id, + "--format", + "{{.State.Pid}}", stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) stdout, _ = await proc.communicate() container_init_pid = stdout.decode().strip() @@ -314,9 +317,7 @@ class WebWiresharkManager: # List all processes from host perspective with PID, PPID, and command # This is faster than docker exec and allows us to walk the process tree proc = await asyncio.create_subprocess_exec( - "ps", "-eo", "pid,ppid,args", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + "ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, _ = await proc.communicate() @@ -328,7 +329,7 @@ class WebWiresharkManager: children_map = {} # ppid -> [pid] process_info = {} # pid -> (ppid, command) - for line in stdout.decode().strip().split('\n'): + for line in stdout.decode().strip().split("\n"): parts = line.split(None, 2) if len(parts) < 3: continue @@ -371,9 +372,7 @@ class WebWiresharkManager: logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}") # Kill all matching PIDs from host perspective proc = await asyncio.create_subprocess_exec( - "kill", "-9", *matching_pids, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + "kill", "-9", *matching_pids, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) await proc.communicate() logger.debug("Kill completed from host perspective") @@ -396,16 +395,12 @@ class WebWiresharkManager: """ try: # Combine all patterns into a single regex using OR operator - combined_pattern = '|'.join(f'({pattern})' for pattern in patterns) + combined_pattern = "|".join(f"({pattern})" for pattern in patterns) # Single pgrep to find all matching processes pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi' - returncode, stdout, stderr = await self._exec_in_container( - container_id, - pgrep_cmd, - timeout=5 - ) + returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd, timeout=5) if "Found processes:" in stdout: logger.info(stdout.strip()) @@ -422,10 +417,12 @@ class WebWiresharkManager: """ # Clean up X lock files and xpra socket files in a single docker exec # This reduces docker exec calls from 2 to 1, improving stop performance - cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} " - f"/run/user/1000/xpra/{display}/socket " - f"/run/user/1000/xpra/*-{display} " - f"/home/gns3/.xpra/*-{display} 2>/dev/null || true") + cmd = ( + f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} " + f"/run/user/1000/xpra/{display}/socket " + f"/run/user/1000/xpra/*-{display} " + f"/home/gns3/.xpra/*-{display} 2>/dev/null || true" + ) returncode, stdout, stderr = await self._exec_in_container(container_id, cmd) logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}") @@ -436,9 +433,11 @@ class WebWiresharkManager: container_id: Container ID display: Display number (e.g., 10210) """ - cmd = (f"rm -f /run/user/1000/xpra/{display}/socket " - f"/run/user/1000/xpra/*-{display} " - f"/home/gns3/.xpra/*-{display} 2>/dev/null || true") + cmd = ( + f"rm -f /run/user/1000/xpra/{display}/socket " + f"/run/user/1000/xpra/*-{display} " + f"/home/gns3/.xpra/*-{display} 2>/dev/null || true" + ) returncode, stdout, stderr = await self._exec_in_container(container_id, cmd) logger.debug(f"Cleanup socket files: returncode={returncode}") @@ -457,20 +456,15 @@ class WebWiresharkManager: return 0 # Extract number and unit - match = re.match(r'(\d+(?:\.\d+)?)\s*([kmg]b?)?', memory_str) + match = re.match(r"(\d+(?:\.\d+)?)\s*([kmg]b?)?", memory_str) if not match: raise ValueError(f"Invalid memory format: {memory_str}") value = float(match.group(1)) - unit = match.group(2) or 'b' + unit = match.group(2) or "b" # Convert to bytes - multipliers = { - 'b': 1, - 'k': 1024, - 'm': 1024 * 1024, - 'g': 1024 * 1024 * 1024 - } + multipliers = {"b": 1, "k": 1024, "m": 1024 * 1024, "g": 1024 * 1024 * 1024} unit = unit[0] # Take first character return int(value * multipliers.get(unit, 1)) @@ -511,8 +505,7 @@ class WebWiresharkManager: if container_id: try: returncode, stdout, stderr = await self._exec_in_container( - container_id, - "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1" + container_id, "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1" ) logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'") if returncode == 0 and stdout.strip(): @@ -557,8 +550,7 @@ class WebWiresharkManager: # Use 'hostname -I' to get all IP addresses and take the first one # This works on most Linux systems and is more portable than 'ip' command returncode, stdout, stderr = await self._exec_in_container( - container_id, - "hostname -I 2>/dev/null | awk '{print $1}'" + container_id, "hostname -I 2>/dev/null | awk '{print $1}'" ) if returncode == 0 and stdout.strip(): container_ip = stdout.strip() @@ -585,7 +577,7 @@ class WebWiresharkManager: """ try: parsed = urlparse(url) - if parsed.hostname in ('localhost', '127.0.0.1', '0.0.0.0'): + if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0"): gateway = await self._get_container_gateway_ip(container_id) if gateway: fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}" @@ -627,11 +619,7 @@ class WebWiresharkManager: config = Config.instance() subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22") logger.info(f"Creating network {self.network_name} with subnet {subnet}") - await self.docker.create_network( - self.network_name, - driver="bridge", - subnet=subnet - ) + await self.docker.create_network(self.network_name, driver="bridge", subnet=subnet) async def get_or_create_container( self, @@ -640,7 +628,7 @@ class WebWiresharkManager: memory: str = "2g", memory_swap: str = None, cpus: float = 1.0, - pids_limit: int = 1000 + pids_limit: int = 1000, ) -> str: """Get or create project's Web Wireshark container. @@ -716,21 +704,15 @@ class WebWiresharkManager: "PidsLimit": pids_limit, "RestartPolicy": {"Name": "unless-stopped"}, "Init": True, # Use init system (tini) as PID 1 to reap zombie processes - "LogConfig": { - "Type": "json-file", - "Config": { - "max-size": "10m", - "max-file": "3" - } - } + "LogConfig": {"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}}, } # Health check configuration health_config = { "Test": ["CMD-SHELL", "xpra list"], "Interval": 30000000000, # 30 seconds (nanoseconds) - "Timeout": 10000000000, # 10 seconds - "Retries": 3 + "Timeout": 10000000000, # 10 seconds + "Retries": 3, } try: @@ -740,11 +722,7 @@ class WebWiresharkManager: network=self.network_name, host_config=host_config, health_config=health_config, - environment={ - "XDG_RUNTIME_DIR": "/run/user/1000", - "LANG": "C.UTF-8", - "LC_ALL": "C.UTF-8" - } + environment={"XDG_RUNTIME_DIR": "/run/user/1000", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}, ) except RuntimeError as e: error_msg = str(e) @@ -773,7 +751,7 @@ class WebWiresharkManager: memory: str = "2g", memory_swap: Optional[str] = None, cpus: float = 1.0, - pids_limit: int = 1000 + pids_limit: int = 1000, ) -> dict: """Start Web Wireshark session. @@ -799,20 +777,10 @@ class WebWiresharkManager: # Auto-detect capture_stream_url if not provided if not capture_stream_url: gns3_url = self.detect_gns3_url() - capture_stream_url = ( - f"{gns3_url}/v3/projects/{project_id}/links/" - f"{link_id}/capture/stream" - ) + capture_stream_url = f"{gns3_url}/v3/projects/{project_id}/links/{link_id}/capture/stream" logger.info(f"Auto-detected capture stream URL: {capture_stream_url}") - container_id = await self.get_or_create_container( - project_id, - image, - memory, - memory_swap, - cpus, - pids_limit - ) + container_id = await self.get_or_create_container(project_id, image, memory, memory_swap, cpus, pids_limit) container_name = f"gns3-wireshark-{project_id}" # Fix localhost URL after we have container_id @@ -828,13 +796,15 @@ class WebWiresharkManager: has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display) if has_process_residuals or has_socket_residuals: - logger.info(f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up...") + logger.info( + f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up..." + ) if has_process_residuals: patterns = [ - f'xpra.*:{display}', - f'Xvfb.*:{display}', - f'wireshark.*:{display}', - f'pulseaudio.*display=:{display}' + f"xpra.*:{display}", + f"Xvfb.*:{display}", + f"wireshark.*:{display}", + f"pulseaudio.*display=:{display}", ] await self._kill_process_tree_batch(container_id, patterns) if has_socket_residuals: @@ -848,29 +818,24 @@ class WebWiresharkManager: xpra_cmd = [ "XPRA_CLIENT_CAN_SHUTDOWN=false", - "xpra", "start", f":{display}", + "xpra", + "start", + f":{display}", '--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"', "--html=off", f"--bind-ws=0.0.0.0:{port}", f"--session-name={session_name}", "--daemon=yes", "--dbus-launch=no", - "--resize-display=yes" + "--resize-display=yes", ] # Parallel execution: get container info + start xpra - container_info_task = asyncio.create_task( - self.docker.get_container(container_name) - ) - xpra_start_task = asyncio.create_task( - self._exec_in_container(container_id, " ".join(xpra_cmd)) - ) + container_info_task = asyncio.create_task(self.docker.get_container(container_name)) + xpra_start_task = asyncio.create_task(self._exec_in_container(container_id, " ".join(xpra_cmd))) # Wait for both tasks to complete - container, (returncode, stdout, stderr) = await asyncio.gather( - container_info_task, - xpra_start_task - ) + container, (returncode, stdout, stderr) = await asyncio.gather(container_info_task, xpra_start_task) # Check xpra start result if returncode != 0: @@ -901,10 +866,14 @@ class WebWiresharkManager: # Execute Wireshark command without waiting for completion await asyncio.create_subprocess_exec( - "docker", "exec", container_id, - "bash", "-c", wireshark_cmd, + "docker", + "exec", + container_id, + "bash", + "-c", + wireshark_cmd, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) # Don't wait - Wireshark runs in background @@ -918,7 +887,7 @@ class WebWiresharkManager: "container_name": container_name, "container_id": container_id, "session_name": session_name, - "capture_stream_url": capture_stream_url + "capture_stream_url": capture_stream_url, } logger.info(f"Web Wireshark session started successfully: {result['ws_url']}") @@ -947,11 +916,11 @@ class WebWiresharkManager: # This ensures child processes are properly terminated, not left as zombies logger.info(f"Stopping all processes on display :{display}") patterns = [ - f'xpra.*:{display}', - f'Xvfb.*:{display}', - f'Xvfb-for-Xpra-{display}', - f'wireshark.*:{display}', - f'pulseaudio.*display=:{display}' + f"xpra.*:{display}", + f"Xvfb.*:{display}", + f"Xvfb-for-Xpra-{display}", + f"wireshark.*:{display}", + f"pulseaudio.*display=:{display}", ] await self._kill_process_tree_batch(container["Id"], patterns) await self._cleanup_x_lock(container["Id"], display) @@ -971,7 +940,7 @@ class WebWiresharkManager: memory: str = "2g", memory_swap: str = None, cpus: float = 1.0, - pids_limit: int = 1000 + pids_limit: int = 1000, ): """Restart Web Wireshark session. @@ -1001,7 +970,7 @@ class WebWiresharkManager: memory=memory, memory_swap=memory_swap, cpus=cpus, - pids_limit=pids_limit + pids_limit=pids_limit, ) async def stop_all_sessions(self, project_id: str): @@ -1021,12 +990,7 @@ class WebWiresharkManager: # Kill all wireshark, xpra and Xvfb processes for link sessions # This ensures clean removal of all session processes - patterns = [ - "xpra.*--session-name=link-", - "Xvfb-for-Xpra-", - "wireshark.*display :", - "pulseaudio.*display :" - ] + patterns = ["xpra.*--session-name=link-", "Xvfb-for-Xpra-", "wireshark.*display :", "pulseaudio.*display :"] await self._kill_process_tree_batch(container["Id"], patterns) logger.info(f"All Web Wireshark sessions stopped for project {project_id}") diff --git a/gns3server/agent/web_wireshark/setup_wireshark_image.py b/gns3server/agent/web_wireshark/setup_wireshark_image.py index 65afc892d..92949a923 100644 --- a/gns3server/agent/web_wireshark/setup_wireshark_image.py +++ b/gns3server/agent/web_wireshark/setup_wireshark_image.py @@ -62,11 +62,7 @@ def check_docker(): # Check if Docker daemon is running try: - result = subprocess.run( - ["docker", "info"], - capture_output=True, - text=True - ) + result = subprocess.run(["docker", "info"], capture_output=True, text=True) if result.returncode != 0: print("Error: Docker daemon is not running", file=sys.stderr) print("Please start Docker and try again", file=sys.stderr) @@ -82,11 +78,7 @@ def image_exists(): """Check if the Docker image already exists.""" try: - result = subprocess.run( - ["docker", "image", "inspect", DOCKER_IMAGE], - capture_output=True, - text=True - ) + result = subprocess.run(["docker", "image", "inspect", DOCKER_IMAGE], capture_output=True, text=True) return result.returncode == 0 except Exception: return False @@ -115,11 +107,7 @@ def pull_image(): print(f"Pulling Docker image: {DOCKER_IMAGE}") print("-" * 60) - result = subprocess.run( - ["docker", "pull", DOCKER_IMAGE], - capture_output=True, - text=True - ) + result = subprocess.run(["docker", "pull", DOCKER_IMAGE], capture_output=True, text=True) # Store output for network error detection pull_output = result.stdout + result.stderr @@ -140,33 +128,17 @@ def build_image(dockerfile_path): print("-" * 60) result = subprocess.run( - ["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], - cwd=dockerfile_dir, - pass_fds=(1, 2) + ["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], cwd=dockerfile_dir, pass_fds=(1, 2) ) return result.returncode == 0 def main(): - parser = argparse.ArgumentParser( - description="Setup Web Wireshark Docker image for GNS3" - ) - parser.add_argument( - "--force", - action="store_true", - help="Force rebuild even if image exists" - ) - parser.add_argument( - "--build-only", - action="store_true", - help="Only build locally, skip pull" - ) - parser.add_argument( - "--pull-only", - action="store_true", - help="Only pull from registry, skip build" - ) + parser = argparse.ArgumentParser(description="Setup Web Wireshark Docker image for GNS3") + parser.add_argument("--force", action="store_true", help="Force rebuild even if image exists") + parser.add_argument("--build-only", action="store_true", help="Only build locally, skip pull") + parser.add_argument("--pull-only", action="store_true", help="Only pull from registry, skip build") args = parser.parse_args() @@ -183,7 +155,7 @@ def main(): if image_exists() and not args.force: print(f"Image {DOCKER_IMAGE} already exists.") response = input("Do you want to rebuild it? [y/N]: ").strip().lower() - if response != 'y': + if response != "y": print("Setup cancelled.") sys.exit(0) print() diff --git a/gns3server/agent/web_wireshark/stats.py b/gns3server/agent/web_wireshark/stats.py index e5611a997..78dc7b0c0 100644 --- a/gns3server/agent/web_wireshark/stats.py +++ b/gns3server/agent/web_wireshark/stats.py @@ -30,12 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict: """ from .manager import WebWiresharkManager - stats = { - "total_containers": 0, - "running_containers": 0, - "active_sessions": 0, - "containers": [] - } + stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []} # Create a single manager instance and reuse it manager = WebWiresharkManager() @@ -70,15 +65,14 @@ async def collect_webwireshark_stats(projects: List) -> Dict: pids_limit = host_config.get("PidsLimit", 0) # Count active capture sessions - active_sessions = sum( - 1 for link in project.links.values() - if getattr(link, "capturing", False) - ) + active_sessions = sum(1 for link in project.links.values() if getattr(link, "capturing", False)) stats["active_sessions"] += active_sessions container_info["active_sessions"] = active_sessions # Add resource limits - container_info["memory_limit"] = f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited" + container_info["memory_limit"] = ( + f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited" + ) container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited" container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited" @@ -111,21 +105,15 @@ async def _get_container_resource_stats(container_id: str) -> Optional[Dict]: """ try: result = subprocess.run( - ["docker", "stats", "--no-stream", "--format", - "{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}", - container_id], + ["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}", container_id], capture_output=True, text=True, - timeout=2 + timeout=2, ) if result.returncode == 0: parts = result.stdout.strip().split("\t") if len(parts) >= 3: - return { - "memory": parts[0], - "cpu": parts[1], - "pids": int(parts[2]) - } + return {"memory": parts[0], "cpu": parts[1], "pids": int(parts[2])} except subprocess.TimeoutExpired: logger.debug(f"Docker stats timeout for container {container_id[:12]}") except Exception as e: diff --git a/gns3server/api/routes/compute/__init__.py b/gns3server/api/routes/compute/__init__.py index 017853b85..7606e6ddf 100644 --- a/gns3server/api/routes/compute/__init__.py +++ b/gns3server/api/routes/compute/__init__.py @@ -155,113 +155,71 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException): ) -compute_api.include_router( - capabilities.router, - dependencies=[Depends(compute_authentication)], - tags=["Capabilities"] -) +compute_api.include_router(capabilities.router, dependencies=[Depends(compute_authentication)], tags=["Capabilities"]) -compute_api.include_router( - compute.router, - dependencies=[Depends(compute_authentication)], - tags=["Compute"] -) +compute_api.include_router(compute.router, dependencies=[Depends(compute_authentication)], tags=["Compute"]) -compute_api.include_router( - notifications.router, - tags=["Notifications"] -) +compute_api.include_router(notifications.router, tags=["Notifications"]) -compute_api.include_router( - projects.router, - dependencies=[Depends(compute_authentication)], - tags=["Projects"] -) +compute_api.include_router(projects.router, dependencies=[Depends(compute_authentication)], tags=["Projects"]) -compute_api.include_router( - images.router, - dependencies=[Depends(compute_authentication)], - tags=["Images"] -) +compute_api.include_router(images.router, dependencies=[Depends(compute_authentication)], tags=["Images"]) compute_api.include_router( atm_switch_nodes.router, dependencies=[Depends(compute_authentication)], prefix="/projects/{project_id}/atm_switch/nodes", - tags=["ATM switch"] + tags=["ATM switch"], ) compute_api.include_router( cloud_nodes.router, dependencies=[Depends(compute_authentication)], prefix="/projects/{project_id}/cloud/nodes", - tags=["Cloud nodes"] + tags=["Cloud nodes"], ) -compute_api.include_router( - docker_nodes.router, - prefix="/projects/{project_id}/docker/nodes", - tags=["Docker nodes"] -) +compute_api.include_router(docker_nodes.router, prefix="/projects/{project_id}/docker/nodes", tags=["Docker nodes"]) compute_api.include_router( - dynamips_nodes.router, - prefix="/projects/{project_id}/dynamips/nodes", - tags=["Dynamips nodes"] + dynamips_nodes.router, prefix="/projects/{project_id}/dynamips/nodes", tags=["Dynamips nodes"] ) compute_api.include_router( ethernet_hub_nodes.router, dependencies=[Depends(compute_authentication)], prefix="/projects/{project_id}/ethernet_hub/nodes", - tags=["Ethernet hub nodes"] + tags=["Ethernet hub nodes"], ) compute_api.include_router( ethernet_switch_nodes.router, dependencies=[Depends(compute_authentication)], prefix="/projects/{project_id}/ethernet_switch/nodes", - tags=["Ethernet switch nodes"] + tags=["Ethernet switch nodes"], ) compute_api.include_router( frame_relay_switch_nodes.router, dependencies=[Depends(compute_authentication)], prefix="/projects/{project_id}/frame_relay_switch/nodes", - tags=["Frame Relay switch nodes"] + tags=["Frame Relay switch nodes"], ) -compute_api.include_router( - iou_nodes.router, - prefix="/projects/{project_id}/iou/nodes", - tags=["IOU nodes"]) +compute_api.include_router(iou_nodes.router, prefix="/projects/{project_id}/iou/nodes", tags=["IOU nodes"]) compute_api.include_router( nat_nodes.router, dependencies=[Depends(compute_authentication)], prefix="/projects/{project_id}/nat/nodes", - tags=["NAT nodes"] + tags=["NAT nodes"], ) -compute_api.include_router( - qemu_nodes.router, - prefix="/projects/{project_id}/qemu/nodes", - tags=["Qemu nodes"] -) +compute_api.include_router(qemu_nodes.router, prefix="/projects/{project_id}/qemu/nodes", tags=["Qemu nodes"]) compute_api.include_router( - virtualbox_nodes.router, - prefix="/projects/{project_id}/virtualbox/nodes", - tags=["VirtualBox nodes"] + virtualbox_nodes.router, prefix="/projects/{project_id}/virtualbox/nodes", tags=["VirtualBox nodes"] ) -compute_api.include_router( - vmware_nodes.router, - prefix="/projects/{project_id}/vmware/nodes", - tags=["VMware nodes"] -) +compute_api.include_router(vmware_nodes.router, prefix="/projects/{project_id}/vmware/nodes", tags=["VMware nodes"]) -compute_api.include_router( - vpcs_nodes.router, - prefix="/projects/{project_id}/vpcs/nodes", - tags=["VPCS nodes"] -) +compute_api.include_router(vpcs_nodes.router, prefix="/projects/{project_id}/vpcs/nodes", tags=["VPCS nodes"]) diff --git a/gns3server/api/routes/compute/atm_switch_nodes.py b/gns3server/api/routes/compute/atm_switch_nodes.py index 2dbe10a1d..65bc1157d 100644 --- a/gns3server/api/routes/compute/atm_switch_nodes.py +++ b/gns3server/api/routes/compute/atm_switch_nodes.py @@ -79,8 +79,7 @@ def get_atm_switch(node: ATMSwitch = Depends(dep_node)) -> schemas.ATMSwitch: @router.post("/{node_id}/duplicate", response_model=schemas.ATMSwitch, status_code=status.HTTP_201_CREATED) async def duplicate_atm_switch( - destination_node_id: UUID = Body(..., embed=True), - node: ATMSwitch = Depends(dep_node) + destination_node_id: UUID = Body(..., embed=True), node: ATMSwitch = Depends(dep_node) ) -> schemas.ATMSwitch: """ Duplicate an ATM switch node. @@ -92,8 +91,7 @@ async def duplicate_atm_switch( @router.put("/{node_id}", response_model=schemas.ATMSwitch) async def update_atm_switch( - node_data: schemas.ATMSwitchUpdate, - node: ATMSwitch = Depends(dep_node) + node_data: schemas.ATMSwitchUpdate, node: ATMSwitch = Depends(dep_node) ) -> schemas.ATMSwitch: """ Update an ATM switch node. @@ -124,8 +122,7 @@ def start_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Start is not supported for ATM switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for ATM switches" ) @@ -135,10 +132,7 @@ def stop_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: Stop an ATM switch node. """ - raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Stop is not supported for ATM switches" - ) + raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for ATM switches") @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @@ -149,8 +143,7 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for ATM switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for ATM switches" ) @@ -160,11 +153,11 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: response_model=schemas.UDPNIO, ) async def create_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: ATMSwitch = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: ATMSwitch = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -189,11 +182,11 @@ async def delete_nio(adapter_number: int, port_number: int, node: ATMSwitch = De @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") async def start_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: ATMSwitch = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: ATMSwitch = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -210,10 +203,7 @@ async def start_capture( status_code=status.HTTP_204_NO_CONTENT, ) async def stop_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: ATMSwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -225,10 +215,7 @@ async def stop_capture( @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: ATMSwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index a5f2bb6a9..7baf8ca17 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -122,10 +122,7 @@ async def stop_cloud(node: Cloud = Depends(dep_node)) -> None: Stop a cloud node. """ - raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Stop is not supported for cloud nodes" - ) + raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for cloud nodes") @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @@ -136,8 +133,7 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for cloud nodes" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for cloud nodes" ) @@ -147,11 +143,11 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None: response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], ) async def create_cloud_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], - node: Cloud = Depends(dep_node), + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], + node: Cloud = Depends(dep_node), ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: """ Add a NIO (Network Input/Output) to the node. @@ -169,11 +165,11 @@ async def create_cloud_nio( response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], ) async def update_cloud_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], - node: Cloud = Depends(dep_node), + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], + node: Cloud = Depends(dep_node), ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: """ Update a NIO (Network Input/Output) to the node. @@ -192,10 +188,7 @@ async def update_cloud_nio( @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_cloud_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: Cloud = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node) ) -> None: """ Remove a NIO (Network Input/Output) from the node. @@ -207,11 +200,11 @@ async def delete_cloud_nio( @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") async def start_cloud_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: Cloud = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: Cloud = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -227,10 +220,7 @@ async def start_cloud_capture( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT ) async def stop_cloud_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: Cloud = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -242,10 +232,7 @@ async def stop_cloud_capture( @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: Cloud = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -257,13 +244,9 @@ async def stream_pcap_file( return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") -@router.put( - "/{node_id}/markers/{marker_name}" -) +@router.put("/{node_id}/markers/{marker_name}") async def toggle_cloud_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: Cloud = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: Cloud = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -278,19 +261,13 @@ async def toggle_cloud_marker( return {"marker_name": marker_name, "enabled": toggle_data.enabled} -@router.post( - "/{node_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT -) +@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT) async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None: await node._ubridge_marker_pause() -@router.post( - "/{node_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT -) +@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT) async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None: await node._ubridge_marker_resume() @@ -298,7 +275,7 @@ async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None: @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", - status_code=status.HTTP_204_NO_CONTENT + status_code=status.HTTP_204_NO_CONTENT, ) async def delete_cloud_marker_capture( *, @@ -306,7 +283,7 @@ async def delete_cloud_marker_capture( adapter_number: int = Path(..., ge=0, le=0), port_number: int, link_id: str = "", - node: Cloud = Depends(dep_node) + node: Cloud = Depends(dep_node), ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -321,9 +298,7 @@ async def delete_cloud_marker_capture( @router.put("/{node_id}/markers/{marker_name}/rebuild") async def rebuild_cloud_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: Cloud = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Cloud = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -331,7 +306,11 @@ async def rebuild_cloud_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/compute/dependencies/authentication.py b/gns3server/api/routes/compute/dependencies/authentication.py index a5ad44b10..14ba07f91 100644 --- a/gns3server/api/routes/compute/dependencies/authentication.py +++ b/gns3server/api/routes/compute/dependencies/authentication.py @@ -32,7 +32,7 @@ security = HTTPBasic(auto_error=False) def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None: """ Authenticate compute requests. - + Returns None if authentication is disabled or if authentication succeeds Raises HTTPException if authentication is required but credentials are invalid """ @@ -58,9 +58,9 @@ def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends headers={"WWW-Authenticate": "Basic"}, ) + async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]: - """ - """ + """ """ server_settings = Config.instance().settings.Server diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index a5e48c57d..6091cf37b 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> DockerVM: response_model=schemas.Docker, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker: """ @@ -87,10 +87,23 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) # fallback loop below only applies truly extra keys and does not # re-trigger console/aux port setter logging. for key in ( - "console", "console_type", "console_resolution", "console_http_port", - "console_http_path", "aux", "aux_type", "start_command", "environment", - "adapters", "mac_address", "extra_hosts", "extra_volumes", "extra_configs", - "memory", "cpus", "image_digest", + "console", + "console_type", + "console_resolution", + "console_http_port", + "console_http_path", + "aux", + "aux_type", + "start_command", + "environment", + "adapters", + "mac_address", + "extra_hosts", + "extra_volumes", + "extra_configs", + "memory", + "cpus", + "image_digest", ): node_data.pop(key, None) for name, value in node_data.items(): @@ -101,11 +114,7 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) return container.asdict() -@router.get( - "/{node_id}", - response_model=schemas.Docker, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)]) def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker: """ Return a Docker node. @@ -114,11 +123,7 @@ def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker: return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.Docker, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)]) async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker: """ Update a Docker node. @@ -160,11 +165,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D return node.asdict() -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ Start a Docker node. @@ -173,11 +174,7 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None: await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ Stop a Docker node. This is the explicit user stop — the only path that @@ -189,9 +186,7 @@ async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ @@ -202,9 +197,7 @@ async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ @@ -214,11 +207,7 @@ async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None: await node.restart() -@router.post( - "/{node_id}/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ Pause a Docker node. @@ -228,9 +217,7 @@ async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/unpause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/unpause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ @@ -240,11 +227,7 @@ async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None: await node.unpause() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None: """ Delete a Docker node. @@ -258,11 +241,10 @@ async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None: "/{node_id}/duplicate", response_model=schemas.Docker, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def duplicate_docker_node( - destination_node_id: UUID = Body(..., embed=True), - node: DockerVM = Depends(dep_node) + destination_node_id: UUID = Body(..., embed=True), node: DockerVM = Depends(dep_node) ) -> schemas.Docker: """ Duplicate a Docker node. @@ -276,7 +258,7 @@ async def duplicate_docker_node( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_docker_node_nio( adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node) @@ -295,7 +277,7 @@ async def create_docker_node_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_docker_node_nio( adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node) @@ -318,13 +300,9 @@ async def update_docker_node_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) -async def delete_docker_node_nio( - adapter_number: int, - port_number: int, - node: DockerVM = Depends(dep_node) -) -> None: +async def delete_docker_node_nio(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None: """ Delete a NIO (Network Input/Output) from the node. The port number on the Docker node is always 0. @@ -335,13 +313,10 @@ async def delete_docker_node_nio( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_docker_node_capture( - adapter_number: int, - port_number: int, - node_capture_data: schemas.NodeCapture, - node: DockerVM = Depends(dep_node) + adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: DockerVM = Depends(dep_node) ) -> dict: """ Start a packet capture on the node. @@ -356,13 +331,9 @@ async def start_docker_node_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) -async def stop_docker_node_capture( - adapter_number: int, - port_number: int, - node: DockerVM = Depends(dep_node) -) -> None: +async def stop_docker_node_capture(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None: """ Stop a packet capture on the node. The port number on the Docker node is always 0. @@ -373,12 +344,10 @@ async def stop_docker_node_capture( @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stream_pcap_file( - adapter_number: int, - port_number: int, - node: DockerVM = Depends(dep_node) + adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -392,8 +361,7 @@ async def stream_pcap_file( @router.websocket("/{node_id}/console/ws") async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: DockerVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node) ) -> None: """ Console WebSocket. @@ -403,12 +371,9 @@ async def console_ws( await node.start_websocket_console(websocket) -@router.websocket( - "/{node_id}/console/vnc" -) +@router.websocket("/{node_id}/console/vnc") async def vnc_console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: DockerVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node) ) -> None: """ VNC Console WebSocket. @@ -419,23 +384,16 @@ async def vnc_console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: DockerVM = Depends(dep_node)) -> None: await node.reset_console() -@router.put( - "/{node_id}/markers/{marker_name}", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)]) async def toggle_docker_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: DockerVM = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: DockerVM = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -451,9 +409,7 @@ async def toggle_docker_marker( @router.post( - "/{node_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None: @@ -461,9 +417,7 @@ async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: @@ -473,14 +427,10 @@ async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_docker_marker_capture( - marker_name: str, - adapter_number: int, - port_number: int, - link_id: str = "", - node: DockerVM = Depends(dep_node) + marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: DockerVM = Depends(dep_node) ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -493,14 +443,9 @@ async def delete_docker_marker_capture( await node.delete_marker_capture(marker_name, link_id, nio) -@router.put( - "/{node_id}/markers/{marker_name}/rebuild", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)]) async def rebuild_docker_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: DockerVM = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: DockerVM = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -508,7 +453,11 @@ async def rebuild_docker_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 41314f8a1..ce1c1b42b 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -55,7 +55,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> Router: response_model=schemas.Dynamips, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Dynamips node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> schemas.Dynamips: """ @@ -86,11 +86,7 @@ async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> return vm.asdict() -@router.get( - "/{node_id}", - response_model=schemas.Dynamips, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)]) def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips: """ Return Dynamips router. @@ -99,11 +95,7 @@ def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips: return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.Dynamips, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)]) async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depends(dep_node)) -> schemas.Dynamips: """ Update a Dynamips router. @@ -114,11 +106,7 @@ async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depend return node.asdict() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_router(node: Router = Depends(dep_node)) -> None: """ Delete a Dynamips router. @@ -127,11 +115,7 @@ async def delete_router(node: Router = Depends(dep_node)) -> None: await Dynamips.instance().delete_node(node.id) -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_router(node: Router = Depends(dep_node)) -> None: """ Start a Dynamips router. @@ -144,11 +128,7 @@ async def start_router(node: Router = Depends(dep_node)) -> None: await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_router(node: Router = Depends(dep_node)) -> None: """ Stop a Dynamips router. @@ -158,9 +138,7 @@ async def stop_router(node: Router = Depends(dep_node)) -> None: @router.post( - "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def suspend_router(node: Router = Depends(dep_node)) -> None: @@ -168,9 +146,7 @@ async def suspend_router(node: Router = Depends(dep_node)) -> None: @router.post( - "/{node_id}/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_router(node: Router = Depends(dep_node)) -> None: """ @@ -181,9 +157,7 @@ async def resume_router(node: Router = Depends(dep_node)) -> None: @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_router(node: Router = Depends(dep_node)) -> None: """ @@ -197,13 +171,10 @@ async def reload_router(node: Router = Depends(dep_node)) -> None: "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_nio( - adapter_number: int, - port_number: int, - nio_data: schemas.UDPNIO, - node: Router = Depends(dep_node) + adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node) ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -218,13 +189,10 @@ async def create_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_nio( - adapter_number: int, - port_number: int, - nio_data: schemas.UDPNIO, - node: Router = Depends(dep_node) + adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node) ) -> schemas.UDPNIO: """ Update a NIO (Network Input/Output) on the node. @@ -242,7 +210,7 @@ async def update_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_nio(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None: """ @@ -255,13 +223,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_capture( - adapter_number: int, - port_number: int, - node_capture_data: schemas.NodeCapture, - node: Router = Depends(dep_node) + adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: Router = Depends(dep_node) ) -> dict: """ Start a packet capture on the node. @@ -275,7 +240,7 @@ async def start_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stop_capture(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None: """ @@ -287,12 +252,10 @@ async def stop_capture(adapter_number: int, port_number: int, node: Router = Dep @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stream_pcap_file( - adapter_number: int, - port_number: int, - node: Router = Depends(dep_node) + adapter_number: int, port_number: int, node: Router = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -303,10 +266,7 @@ async def stream_pcap_file( return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") -@router.get( - "/{node_id}/idlepc_proposals", - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}/idlepc_proposals", dependencies=[Depends(compute_authentication)]) async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]: """ Retrieve Dynamips idle-pc proposals @@ -316,10 +276,7 @@ async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]: return await node.get_idle_pc_prop() -@router.get( - "/{node_id}/auto_idlepc", - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}/auto_idlepc", dependencies=[Depends(compute_authentication)]) async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict: """ Get an automatically guessed best idle-pc value. @@ -333,9 +290,11 @@ async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict: "/{node_id}/duplicate", response_model=schemas.Dynamips, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) -async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)) -> schemas.Dynamips: +async def duplicate_router( + destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node) +) -> schemas.Dynamips: """ Duplicate a router. """ @@ -346,9 +305,7 @@ async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), no @router.websocket("/{node_id}/console/ws") async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: Router = Depends(dep_node) - + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: Router = Depends(dep_node) ) -> None: """ Console WebSocket. @@ -359,23 +316,16 @@ async def console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: Router = Depends(dep_node)) -> None: await node.reset_console() -@router.put( - "/{node_id}/markers/{marker_name}", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)]) async def toggle_dynamips_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: Router = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: Router = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -391,9 +341,7 @@ async def toggle_dynamips_marker( @router.post( - "/{node_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None: @@ -401,9 +349,7 @@ async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None: @router.post( - "/{node_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: @@ -413,14 +359,10 @@ async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_dynamips_marker_capture( - marker_name: str, - adapter_number: int, - port_number: int, - link_id: str = "", - node: Router = Depends(dep_node) + marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: Router = Depends(dep_node) ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -433,14 +375,9 @@ async def delete_dynamips_marker_capture( await node.delete_marker_capture(marker_name, link_id, nio) -@router.put( - "/{node_id}/markers/{marker_name}/rebuild", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)]) async def rebuild_dynamips_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: Router = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Router = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -448,7 +385,11 @@ async def rebuild_dynamips_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/compute/ethernet_hub_nodes.py b/gns3server/api/routes/compute/ethernet_hub_nodes.py index 22f7a3b6d..350105789 100644 --- a/gns3server/api/routes/compute/ethernet_hub_nodes.py +++ b/gns3server/api/routes/compute/ethernet_hub_nodes.py @@ -91,8 +91,7 @@ async def duplicate_ethernet_hub( @router.put("/{node_id}", response_model=schemas.EthernetHub) async def update_ethernet_hub( - node_data: schemas.EthernetHubUpdate, - node: EthernetHub = Depends(dep_node) + node_data: schemas.EthernetHubUpdate, node: EthernetHub = Depends(dep_node) ) -> schemas.EthernetHub: """ Update an Ethernet hub. @@ -124,8 +123,7 @@ def start_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Start is not supported for Ethernet hubs" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet hubs" ) @@ -136,8 +134,7 @@ def stop_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Stop is not supported for Ethernet hubs" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet hubs" ) @@ -148,8 +145,7 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for Ethernet hubs" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet hubs" ) @@ -159,11 +155,11 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: response_model=schemas.UDPNIO, ) async def create_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: EthernetHub = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: EthernetHub = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -177,10 +173,7 @@ async def create_nio( @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: EthernetHub = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node) ) -> None: """ Delete a NIO (Network Input/Output) from the node. @@ -193,11 +186,11 @@ async def delete_nio( @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") async def start_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: EthernetHub = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: EthernetHub = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -213,10 +206,7 @@ async def start_capture( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT ) async def stop_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: EthernetHub = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -228,10 +218,7 @@ async def stop_capture( @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: EthernetHub = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py index d6d253ca9..bedfac0b6 100644 --- a/gns3server/api/routes/compute/ethernet_switch_nodes.py +++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py @@ -82,8 +82,7 @@ def get_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> schemas.Eth @router.post("/{node_id}/duplicate", response_model=schemas.EthernetSwitch, status_code=status.HTTP_201_CREATED) async def duplicate_ethernet_switch( - destination_node_id: UUID = Body(..., embed=True), - node: EthernetSwitch = Depends(dep_node) + destination_node_id: UUID = Body(..., embed=True), node: EthernetSwitch = Depends(dep_node) ) -> schemas.EthernetSwitch: """ Duplicate an Ethernet switch. @@ -95,8 +94,7 @@ async def duplicate_ethernet_switch( @router.put("/{node_id}", response_model=schemas.EthernetSwitch) async def update_ethernet_switch( - node_data: schemas.EthernetSwitchUpdate, - node: EthernetSwitch = Depends(dep_node) + node_data: schemas.EthernetSwitchUpdate, node: EthernetSwitch = Depends(dep_node) ) -> schemas.EthernetSwitch: """ Update an Ethernet switch. @@ -132,8 +130,7 @@ def start_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Start is not supported for Ethernet switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet switches" ) @@ -144,8 +141,7 @@ def stop_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Stop is not supported for Ethernet switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet switches" ) @@ -156,8 +152,7 @@ def suspend_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for Ethernet switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet switches" ) @@ -169,8 +164,7 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Reload is not supported for Ethernet switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Reload is not supported for Ethernet switches" ) @@ -180,11 +174,11 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: response_model=schemas.UDPNIO, ) async def create_ethernet_switch_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: EthernetSwitch = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: EthernetSwitch = Depends(dep_node), ) -> schemas.UDPNIO: nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True)) @@ -198,11 +192,11 @@ async def create_ethernet_switch_nio( response_model=schemas.UDPNIO, ) async def update_ethernet_switch_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: EthernetSwitch = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: EthernetSwitch = Depends(dep_node), ) -> schemas.UDPNIO: """ Update a NIO (Network Input/Output) on the node: re-apply the packet @@ -221,10 +215,7 @@ async def update_ethernet_switch_nio( @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_ethernet_switch_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: EthernetSwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node) ) -> None: """ Delete a NIO (Network Input/Output) from the node. @@ -236,11 +227,11 @@ async def delete_ethernet_switch_nio( @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") async def start_ethernet_switch_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: EthernetSwitch = Depends(dep_node), + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: EthernetSwitch = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -256,10 +247,7 @@ async def start_ethernet_switch_capture( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT ) async def stop_ethernet_switch_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: EthernetSwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -271,10 +259,7 @@ async def stop_ethernet_switch_capture( @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: EthernetSwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -288,9 +273,7 @@ async def stream_pcap_file( @router.put("/{node_id}/markers/{marker_name}") async def toggle_ethernet_switch_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: EthernetSwitch = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: EthernetSwitch = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -322,12 +305,12 @@ async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node status_code=status.HTTP_204_NO_CONTENT, ) async def delete_ethernet_switch_marker_capture( - *, - marker_name: str, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - link_id: str = "", - node: EthernetSwitch = Depends(dep_node) + *, + marker_name: str, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + link_id: str = "", + node: EthernetSwitch = Depends(dep_node), ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -342,9 +325,7 @@ async def delete_ethernet_switch_marker_capture( @router.put("/{node_id}/markers/{marker_name}/rebuild") async def rebuild_ethernet_switch_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: EthernetSwitch = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: EthernetSwitch = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -352,7 +333,11 @@ async def rebuild_ethernet_switch_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/compute/frame_relay_switch_nodes.py b/gns3server/api/routes/compute/frame_relay_switch_nodes.py index aa81896e3..f425b3200 100644 --- a/gns3server/api/routes/compute/frame_relay_switch_nodes.py +++ b/gns3server/api/routes/compute/frame_relay_switch_nodes.py @@ -51,8 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> FrameRelaySwitch: responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Frame Relay switch node"}}, ) async def create_frame_relay_switch( - project_id: UUID, - node_data: schemas.FrameRelaySwitchCreate + project_id: UUID, node_data: schemas.FrameRelaySwitchCreate ) -> schemas.FrameRelaySwitch: """ Create a new Frame Relay switch node. @@ -82,8 +81,7 @@ def get_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> schema @router.post("/{node_id}/duplicate", response_model=schemas.FrameRelaySwitch, status_code=status.HTTP_201_CREATED) async def duplicate_frame_relay_switch( - destination_node_id: UUID = Body(..., embed=True), - node: FrameRelaySwitch = Depends(dep_node) + destination_node_id: UUID = Body(..., embed=True), node: FrameRelaySwitch = Depends(dep_node) ) -> schemas.FrameRelaySwitch: """ Duplicate a Frame Relay switch node. @@ -95,8 +93,7 @@ async def duplicate_frame_relay_switch( @router.put("/{node_id}", response_model=schemas.FrameRelaySwitch) async def update_frame_relay_switch( - node_data: schemas.FrameRelaySwitchUpdate, - node: FrameRelaySwitch = Depends(dep_node) + node_data: schemas.FrameRelaySwitchUpdate, node: FrameRelaySwitch = Depends(dep_node) ) -> schemas.FrameRelaySwitch: """ Update an Frame Relay switch node. @@ -127,8 +124,7 @@ def start_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Start is not supported for Frame Relay switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Frame Relay switches" ) @@ -139,8 +135,7 @@ def stop_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Stop is not supported for Frame Relay switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Frame Relay switches" ) @@ -151,8 +146,7 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for Frame Relay switches" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Frame Relay switches" ) @@ -162,11 +156,11 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No response_model=schemas.UDPNIO, ) async def create_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: FrameRelaySwitch = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: FrameRelaySwitch = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -180,10 +174,7 @@ async def create_nio( @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: FrameRelaySwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node) ) -> None: """ Remove a NIO (Network Input/Output) from the node. @@ -196,11 +187,11 @@ async def delete_nio( @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") async def start_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: FrameRelaySwitch = Depends(dep_node), + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: FrameRelaySwitch = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -216,10 +207,7 @@ async def start_capture( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT ) async def stop_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: FrameRelaySwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -231,10 +219,7 @@ async def stop_capture( @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: FrameRelaySwitch = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index b826b21d9..0282d0f0f 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> IOUVM: response_model=schemas.IOU, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create IOU node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> schemas.IOU: """ @@ -85,11 +85,7 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch return vm.asdict() -@router.get( - "/{node_id}", - response_model=schemas.IOU, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)]) def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU: """ Return an IOU node. @@ -98,11 +94,7 @@ def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU: return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.IOU, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)]) async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(dep_node)) -> schemas.IOU: """ Update an IOU node. @@ -123,11 +115,7 @@ async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(de return node.asdict() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None: """ Delete an IOU node. @@ -140,11 +128,10 @@ async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None: "/{node_id}/duplicate", response_model=schemas.IOU, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def duplicate_iou_node( - destination_node_id: UUID = Body(..., embed=True), - node: IOUVM = Depends(dep_node) + destination_node_id: UUID = Body(..., embed=True), node: IOUVM = Depends(dep_node) ) -> schemas.IOU: """ Duplicate an IOU node. @@ -154,11 +141,7 @@ async def duplicate_iou_node( return new_node.asdict() -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep_node)) -> None: """ Start an IOU node. @@ -172,11 +155,7 @@ async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None: """ Stop an IOU node. @@ -185,25 +164,17 @@ async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None: await node.stop() -@router.post( - "/{node_id}/suspend", - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/suspend", dependencies=[Depends(compute_authentication)]) def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None: """ Suspend an IOU node. """ - raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for IOU nodes" - ) + raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for IOU nodes") @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None: """ @@ -217,7 +188,7 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None: "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_iou_node_nio( adapter_number: int, @@ -238,7 +209,7 @@ async def create_iou_node_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_iou_node_nio( adapter_number: int, @@ -263,7 +234,7 @@ async def update_iou_node_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None: """ @@ -275,13 +246,10 @@ async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_iou_node_capture( - adapter_number: int, - port_number: int, - node_capture_data: schemas.NodeCapture, - node: IOUVM = Depends(dep_node) + adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: IOUVM = Depends(dep_node) ) -> dict: """ Start a packet capture on the node. @@ -295,7 +263,7 @@ async def start_iou_node_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None: """ @@ -307,13 +275,9 @@ async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOU @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) -async def stream_pcap_file( - adapter_number: int, - port_number: int, - node: IOUVM = Depends(dep_node) -) -> StreamingResponse: +async def stream_pcap_file(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> StreamingResponse: """ Stream the pcap capture file. """ @@ -327,8 +291,7 @@ async def stream_pcap_file( "/{node_id}/console/ws", ) async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: IOUVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: IOUVM = Depends(dep_node) ) -> None: """ Console WebSocket. @@ -339,23 +302,16 @@ async def console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: IOUVM = Depends(dep_node)) -> None: await node.reset_console() -@router.put( - "/{node_id}/markers/{marker_name}", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)]) async def toggle_iou_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: IOUVM = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: IOUVM = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -371,9 +327,7 @@ async def toggle_iou_marker( @router.post( - "/{node_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None: @@ -381,9 +335,7 @@ async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: @@ -393,14 +345,10 @@ async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_iou_marker_capture( - marker_name: str, - adapter_number: int, - port_number: int, - link_id: str = "", - node: IOUVM = Depends(dep_node) + marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: IOUVM = Depends(dep_node) ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -413,14 +361,9 @@ async def delete_iou_marker_capture( await node.delete_marker_capture(marker_name, link_id, nio) -@router.put( - "/{node_id}/markers/{marker_name}/rebuild", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)]) async def rebuild_iou_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: IOUVM = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: IOUVM = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -428,7 +371,11 @@ async def rebuild_iou_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/compute/nat_nodes.py b/gns3server/api/routes/compute/nat_nodes.py index d11b7975d..60654b603 100644 --- a/gns3server/api/routes/compute/nat_nodes.py +++ b/gns3server/api/routes/compute/nat_nodes.py @@ -117,10 +117,7 @@ async def stop_nat_node(node: Nat = Depends(dep_node)) -> None: Stop a NAT node. """ - raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Stop is not supported for NAT nodes" - ) + raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for NAT nodes") @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @@ -129,10 +126,7 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None: Suspend a NAT node. """ - raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for NAT nodes" - ) + raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for NAT nodes") @router.post( @@ -141,11 +135,11 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None: response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], ) async def create_nat_node_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], - node: Nat = Depends(dep_node), + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], + node: Nat = Depends(dep_node), ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: """ Add a NIO (Network Input/Output) to the node. @@ -163,11 +157,11 @@ async def create_nat_node_nio( response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], ) async def update_nat_node_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], - node: Nat = Depends(dep_node), + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], + node: Nat = Depends(dep_node), ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: """ Update a NIO (Network Input/Output) to the node. @@ -184,10 +178,7 @@ async def update_nat_node_nio( @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_nat_node_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: Nat = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node) ) -> None: """ Remove a NIO (Network Input/Output) from the node. @@ -199,11 +190,11 @@ async def delete_nat_node_nio( @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") async def start_nat_node_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: Nat = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: Nat = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -219,10 +210,7 @@ async def start_nat_node_capture( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT ) async def stop_nat_node_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: Nat = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -234,10 +222,7 @@ async def stop_nat_node_capture( @router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream") async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: Nat = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. diff --git a/gns3server/api/routes/compute/notifications.py b/gns3server/api/routes/compute/notifications.py index f784e3c34..2c6ca4ad5 100644 --- a/gns3server/api/routes/compute/notifications.py +++ b/gns3server/api/routes/compute/notifications.py @@ -18,7 +18,6 @@ API routes for compute notifications. """ - from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from typing import Union from websockets.exceptions import ConnectionClosed, WebSocketException @@ -53,7 +52,6 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w if __name__ == "__main__": - import uvicorn from fastapi import FastAPI from starlette.responses import HTMLResponse diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py index 54d7e460e..7c1248cc5 100644 --- a/gns3server/api/routes/compute/projects.py +++ b/gns3server/api/routes/compute/projects.py @@ -86,8 +86,7 @@ def create_compute_project(project_data: schemas.ProjectCreate) -> schemas.Proje @router.put("/projects/{project_id}", response_model=schemas.Project) async def update_compute_project( - project_data: schemas.ProjectUpdate, - project: Project = Depends(dep_project) + project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project) ) -> schemas.Project: """ Update project on the compute. @@ -195,6 +194,7 @@ def _get_existing_nio(node, adapter_number, port_number): # via get_nio(port). if hasattr(node, "get_nio"): import inspect as _inspect + if len(_inspect.signature(node.get_nio).parameters) >= 2: return node.get_nio(adapter_number, port_number) return node.get_nio(port_number) @@ -242,9 +242,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio): status_code=status.HTTP_201_CREATED, ) async def create_batch_nios( - project_id: UUID, - batch: schemas.BatchNIOCreate, - project: Project = Depends(dep_project), + project_id: UUID, + batch: schemas.BatchNIOCreate, + project: Project = Depends(dep_project), ) -> dict: """ Create many NIO bindings across nodes in a single request. @@ -281,9 +281,7 @@ async def create_batch_nios( nio = node.manager.create_nio(nio_settings) await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio) - await asyncio.gather( - *[_create_one_node(nid, ents) for nid, ents in per_node.items()] - ) + await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()]) return {"added": len(batch.nios)} @@ -292,9 +290,9 @@ async def create_batch_nios( status_code=status.HTTP_200_OK, ) async def update_batch_nios( - project_id: UUID, - batch: schemas.BatchNIOCreate, - project: Project = Depends(dep_project), + project_id: UUID, + batch: schemas.BatchNIOCreate, + project: Project = Depends(dep_project), ) -> dict: """ Update many NIO bindings (filters + markers) across nodes in a single @@ -322,9 +320,7 @@ async def update_batch_nios( nio.markers = e.nio.markers or {} await _update_nio_binding(node, e.adapter_number, e.port_number, nio) - await asyncio.gather( - *[_update_one_node(nid, ents) for nid, ents in per_node.items()] - ) + await asyncio.gather(*[_update_one_node(nid, ents) for nid, ents in per_node.items()]) return {"updated": len(batch.nios)} @@ -343,7 +339,7 @@ async def get_compute_node_files( node_id: str, project: Project = Depends(dep_project), path: str = Query("", description="Subdirectory path within node directory"), - recursive: bool = Query(False, description="Recursively list all files") + recursive: bool = Query(False, description="Recursively list all files"), ) -> List[schemas.NodeFile]: """ Return files belonging to a specific node with detailed metadata. @@ -374,11 +370,7 @@ async def get_compute_project_file(file_path: str, project: Project = Depends(de @router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT) -async def write_compute_project_file( - file_path: str, - request: Request, - project: Project = Depends(dep_project) -) -> None: +async def write_compute_project_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None: file_path = urllib.parse.unquote(file_path) path = os.path.normpath(file_path) @@ -405,10 +397,7 @@ async def write_compute_project_file( @router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_compute_project_file( - file_path: str, - project: Project = Depends(dep_project) -) -> None: +async def delete_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> None: file_path = urllib.parse.unquote(file_path) path = os.path.normpath(file_path) diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index ed9345308..4f8498847 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -33,6 +33,7 @@ from gns3server.compute.qemu.qemu_vm import QemuVM from .dependencies.authentication import compute_authentication, ws_compute_authentication import logging + log = logging.getLogger(__name__) responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}} @@ -55,7 +56,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> QemuVM: response_model=schemas.Qemu, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Qemu node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> schemas.Qemu: """ @@ -117,11 +118,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s return vm.asdict() -@router.get( - "/{node_id}", - response_model=schemas.Qemu, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)]) def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu: """ Return a Qemu node. @@ -130,11 +127,7 @@ def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu: return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.Qemu, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)]) async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends(dep_node)) -> schemas.Qemu: """ Update a Qemu node. @@ -150,11 +143,7 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends return node.asdict() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None: """ Delete a Qemu node. @@ -167,11 +156,10 @@ async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None: "/{node_id}/duplicate", response_model=schemas.Qemu, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def duplicate_qemu_node( - destination_node_id: UUID = Body(..., embed=True), - node: QemuVM = Depends(dep_node) + destination_node_id: UUID = Body(..., embed=True), node: QemuVM = Depends(dep_node) ) -> schemas.Qemu: """ Duplicate a Qemu node. @@ -184,12 +172,10 @@ async def duplicate_qemu_node( @router.post( "/{node_id}/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_qemu_disk_image( - disk_name: str, - disk_data: schemas.QemuDiskImageCreate, - node: QemuVM = Depends(dep_node) + disk_name: str, disk_data: schemas.QemuDiskImageCreate, node: QemuVM = Depends(dep_node) ) -> None: """ Create a Qemu disk image. @@ -202,12 +188,10 @@ async def create_qemu_disk_image( @router.put( "/{node_id}/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_qemu_disk_image( - disk_name: str, - disk_data: schemas.QemuDiskImageUpdate, - node: QemuVM = Depends(dep_node) + disk_name: str, disk_data: schemas.QemuDiskImageUpdate, node: QemuVM = Depends(dep_node) ) -> None: """ Update a Qemu disk image. @@ -220,12 +204,9 @@ async def update_qemu_disk_image( @router.delete( "/{node_id}/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) -async def delete_qemu_disk_image( - disk_name: str, - node: QemuVM = Depends(dep_node) -) -> None: +async def delete_qemu_disk_image(disk_name: str, node: QemuVM = Depends(dep_node)) -> None: """ Delete a Qemu disk image. """ @@ -233,11 +214,7 @@ async def delete_qemu_disk_image( node.delete_disk_image(disk_name) -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None: """ Start a Qemu node. @@ -246,11 +223,7 @@ async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None: await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None: """ Stop a Qemu node. @@ -260,9 +233,7 @@ async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None: """ @@ -273,9 +244,7 @@ async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None: """ @@ -286,9 +255,7 @@ async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None: """ @@ -302,14 +269,14 @@ async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None: "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_qemu_node_nio( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - nio_data: schemas.UDPNIO, - node: QemuVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + nio_data: schemas.UDPNIO, + node: QemuVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -325,14 +292,14 @@ async def create_qemu_node_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_qemu_node_nio( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - nio_data: schemas.UDPNIO, - node: QemuVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + nio_data: schemas.UDPNIO, + node: QemuVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Update a NIO (Network Input/Output) on the node. @@ -352,12 +319,10 @@ async def update_qemu_node_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_qemu_node_nio( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: QemuVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node) ) -> None: """ Delete a NIO (Network Input/Output) from the node. @@ -369,14 +334,14 @@ async def delete_qemu_node_nio( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_qemu_node_capture( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node_capture_data: schemas.NodeCapture, - node: QemuVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + node_capture_data: schemas.NodeCapture, + node: QemuVM = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -391,12 +356,10 @@ async def start_qemu_node_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stop_qemu_node_capture( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: QemuVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -408,12 +371,11 @@ async def stop_qemu_node_capture( @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stream_pcap_file( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: QemuVM = Depends(dep_node)) -> StreamingResponse: + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node) +) -> StreamingResponse: """ Stream the pcap capture file. The port number on the Qemu node is always 0. @@ -424,12 +386,9 @@ async def stream_pcap_file( return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") -@router.websocket( - "/{node_id}/console/ws" -) +@router.websocket("/{node_id}/console/ws") async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: QemuVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node) ) -> None: """ Console WebSocket. @@ -439,12 +398,9 @@ async def console_ws( await node.start_websocket_console(websocket) -@router.websocket( - "/{node_id}/console/vnc" -) +@router.websocket("/{node_id}/console/vnc") async def vnc_console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: QemuVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node) ) -> None: """ VNC Console WebSocket. @@ -455,23 +411,16 @@ async def vnc_console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: QemuVM = Depends(dep_node)) -> None: await node.reset_console() -@router.put( - "/{node_id}/markers/{marker_name}", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)]) async def toggle_qemu_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: QemuVM = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: QemuVM = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -487,9 +436,7 @@ async def toggle_qemu_marker( @router.post( - "/{node_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: @@ -497,9 +444,7 @@ async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: @@ -509,14 +454,14 @@ async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_qemu_marker_capture( marker_name: str, adapter_number: int, port_number: int = Path(..., ge=0, le=0), link_id: str = "", - node: QemuVM = Depends(dep_node) + node: QemuVM = Depends(dep_node), ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -529,14 +474,9 @@ async def delete_qemu_marker_capture( await node.delete_marker_capture(marker_name, link_id, nio) -@router.put( - "/{node_id}/markers/{marker_name}/rebuild", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)]) async def rebuild_qemu_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: QemuVM = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: QemuVM = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -544,7 +484,11 @@ async def rebuild_qemu_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/compute/virtualbox_nodes.py b/gns3server/api/routes/compute/virtualbox_nodes.py index 957f1635f..ec040d3e4 100644 --- a/gns3server/api/routes/compute/virtualbox_nodes.py +++ b/gns3server/api/routes/compute/virtualbox_nodes.py @@ -53,7 +53,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VirtualBoxVM: response_model=schemas.VirtualBox, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VirtualBox node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBoxCreate) -> schemas.VirtualBox: """ @@ -86,11 +86,7 @@ async def create_virtualbox_node(project_id: UUID, node_data: schemas.VirtualBox return vm.asdict() -@router.get( - "/{node_id}", - response_model=schemas.VirtualBox, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)]) def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.VirtualBox: """ Return a VirtualBox node. @@ -99,14 +95,9 @@ def get_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> schemas.Virtu return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.VirtualBox, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.VirtualBox, dependencies=[Depends(compute_authentication)]) async def update_virtualbox_node( - node_data: schemas.VirtualBoxUpdate, - node: VirtualBoxVM = Depends(dep_node) + node_data: schemas.VirtualBoxUpdate, node: VirtualBoxVM = Depends(dep_node) ) -> schemas.VirtualBox: """ Update a VirtualBox node. @@ -148,11 +139,7 @@ async def update_virtualbox_node( return node.asdict() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: """ Delete a VirtualBox node. @@ -161,11 +148,7 @@ async def delete_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None await VirtualBox.instance().delete_node(node.id) -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: """ Start a VirtualBox node. @@ -174,11 +157,7 @@ async def start_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: """ Stop a VirtualBox node. @@ -188,9 +167,7 @@ async def stop_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: """ @@ -201,9 +178,7 @@ async def suspend_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> Non @router.post( - "/{node_id}/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: """ @@ -214,9 +189,7 @@ async def resume_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None: """ @@ -230,14 +203,14 @@ async def reload_virtualbox_node(node: VirtualBoxVM = Depends(dep_node)) -> None "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_virtualbox_node_nio( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - nio_data: schemas.UDPNIO, - node: VirtualBoxVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + nio_data: schemas.UDPNIO, + node: VirtualBoxVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -253,14 +226,14 @@ async def create_virtualbox_node_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_virtualbox_node_nio( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - nio_data: schemas.UDPNIO, - node: VirtualBoxVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + nio_data: schemas.UDPNIO, + node: VirtualBoxVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Update a NIO (Network Input/Output) on the node. @@ -279,12 +252,10 @@ async def update_virtualbox_node_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_virtualbox_node_nio( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: VirtualBoxVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node) ) -> None: """ Delete a NIO (Network Input/Output) from the node. @@ -296,14 +267,14 @@ async def delete_virtualbox_node_nio( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_virtualbox_node_capture( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node_capture_data: schemas.NodeCapture, - node: VirtualBoxVM = Depends(dep_node), + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + node_capture_data: schemas.NodeCapture, + node: VirtualBoxVM = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -318,12 +289,10 @@ async def start_virtualbox_node_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stop_virtualbox_node_capture( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: VirtualBoxVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -335,12 +304,10 @@ async def stop_virtualbox_node_capture( @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stream_pcap_file( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: VirtualBoxVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VirtualBoxVM = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -352,12 +319,9 @@ async def stream_pcap_file( return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") -@router.websocket( - "/{node_id}/console/ws" -) +@router.websocket("/{node_id}/console/ws") async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: VirtualBoxVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VirtualBoxVM = Depends(dep_node) ) -> None: """ Console WebSocket. @@ -367,11 +331,8 @@ async def console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: VirtualBoxVM = Depends(dep_node)) -> None: await node.reset_console() - diff --git a/gns3server/api/routes/compute/vmware_nodes.py b/gns3server/api/routes/compute/vmware_nodes.py index aecce0289..0f339883e 100644 --- a/gns3server/api/routes/compute/vmware_nodes.py +++ b/gns3server/api/routes/compute/vmware_nodes.py @@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VMwareVM: response_model=schemas.VMware, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate) -> schemas.VMware: """ @@ -79,11 +79,7 @@ async def create_vmware_node(project_id: UUID, node_data: schemas.VMwareCreate) return vm.asdict() -@router.get( - "/{node_id}", - response_model=schemas.VMware, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)]) def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware: """ Return a VMware node. @@ -92,11 +88,7 @@ def get_vmware_node(node: VMwareVM = Depends(dep_node)) -> schemas.VMware: return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.VMware, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.VMware, dependencies=[Depends(compute_authentication)]) async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = Depends(dep_node)) -> schemas.VMware: """ Update a VMware node. @@ -113,11 +105,7 @@ async def update_vmware_node(node_data: schemas.VMwareUpdate, node: VMwareVM = D return node.asdict() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: """ Delete a VMware node. @@ -126,11 +114,7 @@ async def delete_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: await VMware.instance().delete_node(node.id) -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: """ Start a VMware node. @@ -139,11 +123,7 @@ async def start_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: """ Stop a VMware node. @@ -153,9 +133,7 @@ async def stop_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: """ @@ -166,9 +144,7 @@ async def suspend_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: """ @@ -179,9 +155,7 @@ async def resume_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: """ @@ -195,14 +169,14 @@ async def reload_vmware_node(node: VMwareVM = Depends(dep_node)) -> None: "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_vmware_node_nio( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - nio_data: schemas.UDPNIO, - node: VMwareVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + nio_data: schemas.UDPNIO, + node: VMwareVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -218,14 +192,14 @@ async def create_vmware_node_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_vmware_node_nio( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - nio_data: schemas.UDPNIO, - node: VMwareVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + nio_data: schemas.UDPNIO, + node: VMwareVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Update a NIO (Network Input/Output) on the node. @@ -243,12 +217,10 @@ async def update_vmware_node_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_vmware_node_nio( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: VMwareVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node) ) -> None: """ Delete a NIO (Network Input/Output) from the node. @@ -260,14 +232,14 @@ async def delete_vmware_node_nio( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_vmware_node_capture( - *, - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node_capture_data: schemas.NodeCapture, - node: VMwareVM = Depends(dep_node) + *, + adapter_number: int, + port_number: int = Path(..., ge=0, le=0), + node_capture_data: schemas.NodeCapture, + node: VMwareVM = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -282,12 +254,10 @@ async def start_vmware_node_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stop_vmware_node_capture( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: VMwareVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -299,12 +269,10 @@ async def stop_vmware_node_capture( @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stream_pcap_file( - adapter_number: int, - port_number: int = Path(..., ge=0, le=0), - node: VMwareVM = Depends(dep_node) + adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: VMwareVM = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -317,9 +285,7 @@ async def stream_pcap_file( @router.post( - "/{node_id}/interfaces/vmnet", - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(compute_authentication)] + "/{node_id}/interfaces/vmnet", status_code=status.HTTP_201_CREATED, dependencies=[Depends(compute_authentication)] ) def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict: """ @@ -335,8 +301,7 @@ def allocate_vmnet(node: VMwareVM = Depends(dep_node)) -> dict: @router.websocket("/{node_id}/console/ws") async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: VMwareVM = Depends(dep_node) + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VMwareVM = Depends(dep_node) ) -> None: """ Console WebSocket. @@ -347,9 +312,7 @@ async def console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: VMwareVM = Depends(dep_node)) -> None: diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 546fabdb0..826571dec 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> VPCSVM: response_model=schemas.VPCS, status_code=status.HTTP_201_CREATED, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create VMware node"}}, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> schemas.VPCS: """ @@ -72,11 +72,7 @@ async def create_vpcs_node(project_id: UUID, node_data: schemas.VPCSCreate) -> s return vm.asdict() -@router.get( - "/{node_id}", - response_model=schemas.VPCS, - dependencies=[Depends(compute_authentication)] -) +@router.get("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)]) def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS: """ Return a VPCS node. @@ -85,11 +81,7 @@ def get_vpcs_node(node: VPCSVM = Depends(dep_node)) -> schemas.VPCS: return node.asdict() -@router.put( - "/{node_id}", - response_model=schemas.VPCS, - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}", response_model=schemas.VPCS, dependencies=[Depends(compute_authentication)]) async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends(dep_node)) -> schemas.VPCS: """ Update a VPCS node. @@ -109,11 +101,7 @@ async def update_vpcs_node(node_data: schemas.VPCSUpdate, node: VPCSVM = Depends return node.asdict() -@router.delete( - "/{node_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ Delete a VPCS node. @@ -126,11 +114,11 @@ async def delete_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: "/{node_id}/duplicate", response_model=schemas.VPCS, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def duplicate_vpcs_node( - destination_node_id: UUID = Body(..., embed=True), - node: VPCSVM = Depends(dep_node)) -> None: + destination_node_id: UUID = Body(..., embed=True), node: VPCSVM = Depends(dep_node) +) -> None: """ Duplicate a VPCS node. """ @@ -139,11 +127,7 @@ async def duplicate_vpcs_node( return new_node.asdict() -@router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ Start a VPCS node. @@ -152,11 +136,7 @@ async def start_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: await node.start() -@router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) +@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]) async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ Stop a VPCS node. @@ -166,9 +146,7 @@ async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ @@ -176,15 +154,12 @@ async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ raise HTTPException( - status_code=status.HTTP_405_METHOD_NOT_ALLOWED, - detail="Suspend is not supported for VPCS nodes" + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for VPCS nodes" ) @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ @@ -198,14 +173,14 @@ async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def create_vpcs_node_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: VPCSVM = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: VPCSVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Add a NIO (Network Input/Output) to the node. @@ -221,14 +196,14 @@ async def create_vpcs_node_nio( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, response_model=schemas.UDPNIO, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def update_vpcs_node_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - nio_data: schemas.UDPNIO, - node: VPCSVM = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: VPCSVM = Depends(dep_node), ) -> schemas.UDPNIO: """ Update a NIO (Network Input/Output) on the node. @@ -247,13 +222,10 @@ async def update_vpcs_node_nio( @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_vpcs_node_nio( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: VPCSVM = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node) ) -> None: """ Delete a NIO (Network Input/Output) from the node. @@ -265,14 +237,14 @@ async def delete_vpcs_node_nio( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def start_vpcs_node_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node_capture_data: schemas.NodeCapture, - node: VPCSVM = Depends(dep_node) + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + node_capture_data: schemas.NodeCapture, + node: VPCSVM = Depends(dep_node), ) -> dict: """ Start a packet capture on the node. @@ -287,13 +259,10 @@ async def start_vpcs_node_capture( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stop_vpcs_node_capture( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: VPCSVM = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node) ) -> None: """ Stop a packet capture on the node. @@ -303,16 +272,12 @@ async def stop_vpcs_node_capture( await node.stop_capture(port_number) - @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def stream_pcap_file( - *, - adapter_number: int = Path(..., ge=0, le=0), - port_number: int, - node: VPCSVM = Depends(dep_node) + *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: VPCSVM = Depends(dep_node) ) -> StreamingResponse: """ Stream the pcap capture file. @@ -324,12 +289,10 @@ async def stream_pcap_file( return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") -@router.websocket( - "/{node_id}/console/ws" -) +@router.websocket("/{node_id}/console/ws") async def console_ws( - websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), - node: VPCSVM = Depends(dep_node)) -> None: + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: VPCSVM = Depends(dep_node) +) -> None: """ Console WebSocket. """ @@ -338,23 +301,16 @@ async def console_ws( @router.post( - "/{node_id}/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def reset_console(node: VPCSVM = Depends(dep_node)) -> None: await node.reset_console() -@router.put( - "/{node_id}/markers/{marker_name}", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)]) async def toggle_vpcs_marker( - marker_name: str, - toggle_data: schemas.MarkerToggle, - node: VPCSVM = Depends(dep_node) + marker_name: str, toggle_data: schemas.MarkerToggle, node: VPCSVM = Depends(dep_node) ) -> dict: """ Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). @@ -370,9 +326,7 @@ async def toggle_vpcs_marker( @router.post( - "/{node_id}/markers/pause", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: @@ -380,9 +334,7 @@ async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/markers/resume", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + "/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)] ) async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: @@ -392,7 +344,7 @@ async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: @router.delete( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] + dependencies=[Depends(compute_authentication)], ) async def delete_vpcs_marker_capture( *, @@ -400,7 +352,7 @@ async def delete_vpcs_marker_capture( adapter_number: int = Path(..., ge=0, le=0), port_number: int, link_id: str = "", - node: VPCSVM = Depends(dep_node) + node: VPCSVM = Depends(dep_node), ) -> None: """ Delete a marker's capture pcap (called by the controller when the marker is @@ -413,14 +365,9 @@ async def delete_vpcs_marker_capture( await node.delete_marker_capture(marker_name, link_id, nio) -@router.put( - "/{node_id}/markers/{marker_name}/rebuild", - dependencies=[Depends(compute_authentication)] -) +@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)]) async def rebuild_vpcs_marker( - marker_name: str, - rebuild_data: schemas.MarkerRebuild, - node: VPCSVM = Depends(dep_node) + marker_name: str, rebuild_data: schemas.MarkerRebuild, node: VPCSVM = Depends(dep_node) ) -> dict: """ Re-install a single marker filter with new BPF/tag/direction (delete + add, @@ -428,7 +375,11 @@ async def rebuild_vpcs_marker( """ await node.rebuild_marker_filter( - marker_name, rebuild_data.link_id, rebuild_data.bpf, - rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + marker_name, + rebuild_data.link_id, + rebuild_data.bpf, + rebuild_data.tag, + rebuild_data.direction, + rebuild_data.enabled, ) return {"marker_name": marker_name} diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index 25334f81b..9236e17c0 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -24,6 +24,7 @@ if AI_COPILOT_AVAILABLE: from . import chat from . import copilot from . import llm_model_configs + _chat_router = chat.router _copilot_router = copilot.router _llm_router = llm_model_configs.router @@ -39,9 +40,10 @@ else: async def ai_not_available(path: str = ""): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]" + detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]", ) + from . import controller from . import appliances from . import computes @@ -68,148 +70,65 @@ from .dependencies.authentication import get_current_active_user router = APIRouter() +router.include_router(controller.router, tags=["Controller"]) + +router.include_router(settings.router, prefix="/settings", tags=["Server settings"]) + +router.include_router(users.router, prefix="/access/users", tags=["Users"]) + +router.include_router(groups.router, prefix="/access/groups", tags=["Users groups"]) + +router.include_router(roles.router, prefix="/access/roles", tags=["Roles"]) + router.include_router( - controller.router, - tags=["Controller"] + privileges.router, dependencies=[Depends(get_current_active_user)], prefix="/access/privileges", tags=["Privileges"] +) + +router.include_router(acl.router, prefix="/access/acl", tags=["ACL"]) + +router.include_router(images.router, prefix="/images", tags=["Images"]) + +router.include_router(templates.router, prefix="/templates", tags=["Templates"]) + +router.include_router(projects.router, prefix="/projects", tags=["Projects"]) + +router.include_router(nodes.router, prefix="/projects/{project_id}/nodes", tags=["Nodes"]) + +router.include_router(links.router, prefix="/projects/{project_id}/links", tags=["Links"]) + +router.include_router(drawings.router, prefix="/projects/{project_id}/drawings", tags=["Drawings"]) + +router.include_router(symbols.router, prefix="/symbols", tags=["Symbols"]) + +router.include_router(snapshots.router, prefix="/projects/{project_id}/snapshots", tags=["Snapshots"]) + +router.include_router( + computes.router, dependencies=[Depends(get_current_active_user)], prefix="/computes", tags=["Computes"] +) + +router.include_router(appliances.router, prefix="/appliances", tags=["Appliances"]) + +router.include_router(netmiko.router, prefix="/netmiko", tags=["Netmiko"]) + +router.include_router(pools.router, prefix="/pools", tags=["Resource pools"]) + +router.include_router( + gns3vm.router, dependencies=[Depends(get_current_active_user)], deprecated=True, prefix="/gns3vm", tags=["GNS3 VM"] ) router.include_router( - settings.router, - prefix="/settings", - tags=["Server settings"] + _llm_router, prefix="/access", dependencies=[Depends(get_current_active_user)], tags=["LLM Model Configurations"] ) router.include_router( - users.router, - prefix="/access/users", - tags=["Users"] -) - -router.include_router( - groups.router, - prefix="/access/groups", - tags=["Users groups"] -) - -router.include_router( - roles.router, - prefix="/access/roles", - tags=["Roles"] -) - -router.include_router( - privileges.router, - dependencies=[Depends(get_current_active_user)], - prefix="/access/privileges", - tags=["Privileges"] -) - -router.include_router( - acl.router, - prefix="/access/acl", - tags=["ACL"] -) - -router.include_router( - images.router, - prefix="/images", - tags=["Images"] -) - -router.include_router( - templates.router, - prefix="/templates", - tags=["Templates"] -) - -router.include_router( - projects.router, - prefix="/projects", - tags=["Projects"]) - -router.include_router( - nodes.router, - prefix="/projects/{project_id}/nodes", - tags=["Nodes"] -) - -router.include_router( - links.router, - prefix="/projects/{project_id}/links", - tags=["Links"] -) - -router.include_router( - drawings.router, - prefix="/projects/{project_id}/drawings", - tags=["Drawings"]) - -router.include_router( - symbols.router, - prefix="/symbols", tags=["Symbols"] -) - -router.include_router( - snapshots.router, - prefix="/projects/{project_id}/snapshots", - tags=["Snapshots"]) - -router.include_router( - computes.router, - dependencies=[Depends(get_current_active_user)], - prefix="/computes", - tags=["Computes"] -) - -router.include_router( - appliances.router, - prefix="/appliances", - tags=["Appliances"] -) - -router.include_router( - netmiko.router, - prefix="/netmiko", - tags=["Netmiko"] -) - -router.include_router( - pools.router, - prefix="/pools", - tags=["Resource pools"] -) - -router.include_router( - gns3vm.router, - dependencies=[Depends(get_current_active_user)], - deprecated=True, - prefix="/gns3vm", - tags=["GNS3 VM"] -) - -router.include_router( - _llm_router, - prefix="/access", - dependencies=[Depends(get_current_active_user)], - tags=["LLM Model Configurations"] -) - -router.include_router( - _copilot_router, - prefix="/copilot", - dependencies=[Depends(get_current_active_user)], - tags=["GNS3 Copilot"] + _copilot_router, prefix="/copilot", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"] ) router.include_router( _chat_router, prefix="/copilot/projects/{project_id}/chat", dependencies=[Depends(get_current_active_user)], - tags=["GNS3 Copilot"] + tags=["GNS3 Copilot"], ) -router.include_router( - api_keys.router, - dependencies=[Depends(get_current_active_user)], - tags=["API Keys"] -) +router.include_router(api_keys.router, dependencies=[Depends(get_current_active_user)], tags=["API Keys"]) diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py index 3ec65e874..beb7bea07 100644 --- a/gns3server/api/routes/controller/acl.py +++ b/gns3server/api/routes/controller/acl.py @@ -29,10 +29,7 @@ from typing import Iterator, List, Sequence from gns3server import schemas -from gns3server.controller.controller_error import ( - ControllerBadRequestError, - ControllerNotFoundError -) +from gns3server.controller.controller_error import ControllerBadRequestError, ControllerNotFoundError from gns3server.controller import Controller from gns3server.db.repositories.users import UsersRepository @@ -69,7 +66,9 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou for route in routes: if isinstance(route, _IncludedRouter): include_prefix = route.include_context.prefix or "" - yield from _iter_route_paths(route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes) + yield from _iter_route_paths( + route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes + ) continue if isinstance(route, APIRoute): @@ -82,16 +81,12 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes) -@router.get( - "/endpoints", - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("ACE.Audit"))] -) +@router.get("/endpoints", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("ACE.Audit"))]) async def endpoints( - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), ) -> List[dict]: """ List all endpoints to be used in ACL entries. @@ -123,7 +118,7 @@ async def endpoints( add_to_endpoints( f"/projects/{project.id}/nodes/{node['node_id']}", f'Node "{node["name"]}" in project "{project.name}"', - endpoint_type="node" + endpoint_type="node", ) # links @@ -140,7 +135,7 @@ async def endpoints( add_to_endpoints( f"/projects/{project.id}/links/{link['link_id']}", f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"', - endpoint_type="link" + endpoint_type="link", ) # users @@ -179,14 +174,8 @@ async def endpoints( return endpoints -@router.get( - "", - response_model=List[schemas.ACE], - dependencies=[Depends(has_privilege("ACE.Audit"))] -) -async def get_aces( - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) -) -> List[schemas.ACE]: +@router.get("", response_model=List[schemas.ACE], dependencies=[Depends(has_privilege("ACE.Audit"))]) +async def get_aces(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[schemas.ACE]: """ Get all ACL entries. @@ -200,12 +189,10 @@ async def get_aces( "", response_model=schemas.ACE, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("ACE.Allocate"))] + dependencies=[Depends(has_privilege("ACE.Allocate"))], ) async def create_ace( - request: Request, - ace_create: schemas.ACECreate, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + request: Request, ace_create: schemas.ACECreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> schemas.ACE: """ Create a new ACL entry. @@ -217,11 +204,12 @@ async def create_ace( print(route_path) for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True): - # remove the prefix (e.g. "/v3") from the route path normalized_path = re.sub(r"^/v[0-9]", "", route_path) # replace route path ID parameters by a UUID regex - normalized_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path) + normalized_path = re.sub( + r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path + ) # replace remaining route path parameters by a word matching regex normalized_path = re.sub(r"/{[\w:]+}", r"/\\w+", normalized_path) @@ -232,14 +220,10 @@ async def create_ace( raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint") -@router.get( - "/{ace_id}", - response_model=schemas.ACE, - dependencies=[Depends(has_privilege("ACE.Audit"))] -) +@router.get("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Audit"))]) async def get_ace( - ace_id: UUID, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + ace_id: UUID, + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> schemas.ACE: """ Get an ACL entry. @@ -253,15 +237,9 @@ async def get_ace( return ace -@router.put( - "/{ace_id}", - response_model=schemas.ACE, - dependencies=[Depends(has_privilege("ACE.Modify"))] -) +@router.put("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Modify"))]) async def update_ace( - ace_id: UUID, - ace_update: schemas.ACEUpdate, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + ace_id: UUID, ace_update: schemas.ACEUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> schemas.ACE: """ Update an ACL entry. @@ -277,9 +255,7 @@ async def update_ace( @router.delete( - "/{ace_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("ACE.Allocate"))] + "/{ace_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("ACE.Allocate"))] ) async def delete_ace( ace_id: UUID, diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index 142818f66..46c187940 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -26,11 +26,7 @@ from uuid import UUID from gns3server import schemas from gns3server.controller import Controller -from gns3server.controller.controller_error import ( - ControllerError, - ControllerBadRequestError, - ControllerNotFoundError -) +from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError, ControllerNotFoundError from gns3server.db.repositories.images import ImagesRepository from gns3server.db.repositories.templates import TemplatesRepository @@ -50,12 +46,9 @@ router = APIRouter() "", response_model=List[schemas.Appliance], response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Appliance.Audit"))] + dependencies=[Depends(has_privilege("Appliance.Audit"))], ) -async def get_appliances( - update: Optional[bool] = False, - symbol_theme: Optional[str] = None -) -> List[schemas.Appliance]: +async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[str] = None) -> List[schemas.Appliance]: """ Return all appliances known by the controller. @@ -73,7 +66,7 @@ async def get_appliances( "/{appliance_id}", response_model=schemas.Appliance, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Appliance.Audit"))] + dependencies=[Depends(has_privilege("Appliance.Audit"))], ) def get_appliance(appliance_id: UUID) -> schemas.Appliance: """ @@ -92,9 +85,11 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance: @router.post( "/{appliance_id}/version", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Appliance.Allocate"))] + dependencies=[Depends(has_privilege("Appliance.Allocate"))], ) -def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]) -> dict: +def add_appliance_version( + appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8] +) -> dict: """ Add a version to an appliance. @@ -124,15 +119,15 @@ def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.A "/{appliance_id}/install", response_model=schemas.Template, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Appliance.Allocate"))] + dependencies=[Depends(has_privilege("Appliance.Allocate"))], ) async def install_appliance( - appliance_id: UUID, - version: Optional[str] = None, - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), - current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + appliance_id: UUID, + version: Optional[str] = None, + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), + current_user: schemas.User = Depends(get_current_active_user), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> schemas.Template: """ Install an appliance and return the created template. @@ -142,10 +137,5 @@ async def install_appliance( controller = Controller.instance() return await controller.appliance_manager.install_appliance( - appliance_id, - version, - images_repo, - templates_repo, - rbac_repo, - current_user + appliance_id, version, images_repo, templates_repo, rbac_repo, current_user ) diff --git a/gns3server/api/routes/controller/chat.py b/gns3server/api/routes/controller/chat.py index 61f56e0d7..cc796fe26 100644 --- a/gns3server/api/routes/controller/chat.py +++ b/gns3server/api/routes/controller/chat.py @@ -69,7 +69,7 @@ def dep_project(project_id: UUID) -> Project: "/stream", response_model=None, summary="Stream chat responses from GNS3 Copilot", - description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE)." + description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE).", ) async def stream_chat( request: schemas.ChatRequest, @@ -96,11 +96,11 @@ async def stream_chat( "Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s", user_id, project.id, - project.status + project.status, ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to use chat. Current status: {project.status}" + detail=f"Project must be opened to use chat. Current status: {project.status}", ) log.info( @@ -124,7 +124,7 @@ async def stream_chat( log.warning("LLM config not found for user: %s", user_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="LLM configuration not found. Please configure your LLM settings first." + detail="LLM configuration not found. Please configure your LLM settings first.", ) log.debug( @@ -161,7 +161,7 @@ async def stream_chat( user_id=user_id, jwt_token=jwt_token, mode=request.mode, - llm_config=llm_config + llm_config=llm_config, ): try: # Validate and serialize chunk @@ -187,7 +187,7 @@ async def stream_chat( "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive", - } + }, ) @@ -195,7 +195,7 @@ async def stream_chat( "/sessions", response_model=List[schemas.ChatSession], summary="List chat sessions", - description="List all chat sessions for a project, optionally filtered by copilot_mode." + description="List all chat sessions for a project, optionally filtered by copilot_mode.", ) async def list_sessions( project: Project = Depends(dep_project), @@ -213,7 +213,7 @@ async def list_sessions( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to access chat sessions. Current status: {project.status}" + detail=f"Project must be opened to access chat sessions. Current status: {project.status}", ) # Get AgentService for this project @@ -221,10 +221,7 @@ async def list_sessions( agent_service = await agent_manager.get_agent(str(project.id), project.path) # List sessions with optional copilot_mode filter - sessions = await agent_service.list_sessions( - user_id=str(current_user.user_id), - copilot_mode=copilot_mode - ) + sessions = await agent_service.list_sessions(user_id=str(current_user.user_id), copilot_mode=copilot_mode) # Convert to schemas return [schemas.ChatSession(**s) for s in sessions] @@ -234,7 +231,7 @@ async def list_sessions( "/sessions/{session_id}/history", response_model=schemas.ConversationHistory, summary="Get conversation history", - description="Retrieve the conversation history for a specific session/thread." + description="Retrieve the conversation history for a specific session/thread.", ) async def get_history( session_id: str, @@ -250,7 +247,7 @@ async def get_history( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to access chat history. Current status: {project.status}" + detail=f"Project must be opened to access chat history. Current status: {project.status}", ) # Get AgentService for this project @@ -267,7 +264,7 @@ async def get_history( "/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a chat session", - description="Delete a specific chat session and its checkpoints." + description="Delete a specific chat session and its checkpoints.", ) async def delete_session( session_id: str, @@ -282,7 +279,7 @@ async def delete_session( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to delete chat sessions. Current status: {project.status}" + detail=f"Project must be opened to delete chat sessions. Current status: {project.status}", ) # Get AgentService for this project @@ -293,17 +290,14 @@ async def delete_session( deleted = await agent_service.delete_session(session_id) if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session '{session_id}' not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found") @router.post( "/sessions/{session_id}/abort", status_code=status.HTTP_200_OK, summary="Abort a streaming session", - description="Abort an ongoing streaming session for a specific session." + description="Abort an ongoing streaming session for a specific session.", ) async def abort_session( session_id: str, @@ -321,7 +315,7 @@ async def abort_session( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to abort chat session. Current status: {project.status}" + detail=f"Project must be opened to abort chat session. Current status: {project.status}", ) # Get AgentService for this project @@ -338,7 +332,7 @@ async def abort_session( "/sessions/{session_id}", response_model=schemas.ChatSession, summary="Rename a chat session", - description="Rename a specific chat session." + description="Rename a specific chat session.", ) async def rename_session( session_id: str, @@ -354,7 +348,7 @@ async def rename_session( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to rename chat sessions. Current status: {project.status}" + detail=f"Project must be opened to rename chat sessions. Current status: {project.status}", ) # Get AgentService for this project @@ -365,10 +359,7 @@ async def rename_session( session = await agent_service.rename_session(session_id, request.title) if not session: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session '{session_id}' not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found") return schemas.ChatSession(**session) @@ -377,7 +368,7 @@ async def rename_session( "/sessions/{session_id}/pin", response_model=schemas.ChatSession, summary="Pin a chat session", - description="Pin a chat session to the top of the list." + description="Pin a chat session to the top of the list.", ) async def pin_session( session_id: str, @@ -392,7 +383,7 @@ async def pin_session( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to pin chat sessions. Current status: {project.status}" + detail=f"Project must be opened to pin chat sessions. Current status: {project.status}", ) # Get AgentService for this project @@ -403,10 +394,7 @@ async def pin_session( session = await agent_service.pin_session(session_id, pinned=True) if not session: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session '{session_id}' not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found") return schemas.ChatSession(**session) @@ -415,7 +403,7 @@ async def pin_session( "/sessions/{session_id}/pin", response_model=schemas.ChatSession, summary="Unpin a chat session", - description="Unpin a chat session from the top of the list." + description="Unpin a chat session from the top of the list.", ) async def unpin_session( session_id: str, @@ -430,7 +418,7 @@ async def unpin_session( if project.status != "opened": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}" + detail=f"Project must be opened to unpin chat sessions. Current status: {project.status}", ) # Get AgentService for this project @@ -441,10 +429,7 @@ async def unpin_session( session = await agent_service.pin_session(session_id, pinned=False) if not session: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session '{session_id}' not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Session '{session_id}' not found") return schemas.ChatSession(**session) @@ -453,7 +438,7 @@ async def unpin_session( "/inject", response_model=None, summary="Inject a network fault for troubleshooting practice", - description="Inject a realistic network fault into the GNS3 lab for troubleshooting training." + description="Inject a realistic network fault into the GNS3 lab for troubleshooting training.", ) async def inject_issue( request: schemas.ChatRequest, @@ -482,11 +467,11 @@ async def inject_issue( "Fault injection rejected: project not opened. user_id=%s, project_id=%s, status=%s", user_id, project.id, - project.status + project.status, ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Project must be opened to use fault injection. Current status: {project.status}" + detail=f"Project must be opened to use fault injection. Current status: {project.status}", ) log.info( @@ -510,7 +495,7 @@ async def inject_issue( log.warning("LLM config not found for user: %s", user_id) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="LLM configuration not found. Please configure your LLM settings first." + detail="LLM configuration not found. Please configure your LLM settings first.", ) # Override copilot_mode to troubleshooting_injection @@ -546,7 +531,7 @@ async def inject_issue( user_id=user_id, jwt_token=jwt_token, mode=request.mode, - llm_config=llm_config + llm_config=llm_config, ): try: # Validate and serialize chunk @@ -572,5 +557,5 @@ async def inject_issue( "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive", - } + }, ) diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py index 59c347ef7..b1173eba7 100644 --- a/gns3server/api/routes/controller/computes.py +++ b/gns3server/api/routes/controller/computes.py @@ -45,12 +45,12 @@ router = APIRouter(responses=responses) 409: {"model": schemas.ErrorMessage, "description": "Could not create compute"}, 401: {"model": schemas.ErrorMessage, "description": "Invalid authentication for compute"}, }, - dependencies=[Depends(has_privilege("Compute.Allocate"))] + dependencies=[Depends(has_privilege("Compute.Allocate"))], ) async def create_compute( compute_create: schemas.ComputeCreate, computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)), - connect: Optional[bool] = False + connect: Optional[bool] = False, ) -> schemas.Compute: """ Create a new compute on the controller. @@ -64,7 +64,7 @@ async def create_compute( @router.post( "/{compute_id}/connect", status_code=status.HTTP_204_NO_CONTENT, - #dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui + # dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui ) async def connect_compute(compute_id: Union[str, UUID]) -> None: """ @@ -82,7 +82,7 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None: "/{compute_id}", response_model=schemas.Compute, response_model_exclude_unset=True, - #dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui + # dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui ) async def get_compute( compute_id: Union[str, UUID], computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)) @@ -100,7 +100,7 @@ async def get_compute( "", response_model=List[schemas.Compute], response_model_exclude_unset=True, - #dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui + # dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui ) async def get_computes( computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)), @@ -118,7 +118,7 @@ async def get_computes( "/{compute_id}", response_model=schemas.Compute, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Compute.Modify"))] + dependencies=[Depends(has_privilege("Compute.Modify"))], ) async def update_compute( compute_id: Union[str, UUID], @@ -135,14 +135,12 @@ async def update_compute( @router.delete( - "/{compute_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Compute.Allocate"))] + "/{compute_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Compute.Allocate"))] ) async def delete_compute( - compute_id: Union[str, UUID], - computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + compute_id: Union[str, UUID], + computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a compute from the controller. @@ -168,11 +166,10 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput @router.post( "/{compute_id}/docker/images/pull", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Compute.Modify"))] + dependencies=[Depends(has_privilege("Compute.Modify"))], ) async def docker_pull_image( - compute_id: Union[str, UUID], - image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$") + compute_id: Union[str, UUID], image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$") ) -> None: """ Pull or update a Docker image on a compute. diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index da00957a4..843cad739 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -68,11 +68,7 @@ def get_version(request: Request) -> dict: controller_host = route.app.state.controller_host local_server = Config.instance().settings.Server.local - return { - "controller_host": controller_host, - "version": __version__, - "local": local_server - } + return {"controller_host": controller_host, "version": __version__, "local": local_server} @router.post( @@ -140,11 +136,7 @@ async def shutdown() -> None: os.kill(os.getpid(), signal.SIGTERM) -@router.get( - "/iou_license", - dependencies=[Depends(get_current_active_user)], - response_model=schemas.IOULicense -) +@router.get("/iou_license", dependencies=[Depends(get_current_active_user)], response_model=schemas.IOULicense) def get_iou_license() -> schemas.IOULicense: """ Return the IOU license settings @@ -157,7 +149,7 @@ def get_iou_license() -> schemas.IOULicense: "/iou_license", dependencies=[Depends(get_current_active_user)], status_code=status.HTTP_201_CREATED, - response_model=schemas.IOULicense + response_model=schemas.IOULicense, ) async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULicense: """ @@ -271,8 +263,10 @@ async def controller_http_notifications(request: Request) -> StreamingResponse: """ from gns3server.api.server import app - log.info(f"New client {request.client.host}:{request.client.port} has connected to controller HTTP " - f"notification stream") + + log.info( + f"New client {request.client.host}:{request.client.port} has connected to controller HTTP notification stream" + ) async def event_stream(): try: @@ -281,15 +275,17 @@ async def controller_http_notifications(request: Request) -> StreamingResponse: msg = await queue.get_json(5) yield f"{msg}\n".encode("utf-8") finally: - log.info(f"Client {request.client.host}:{request.client.port} has disconnected from controller HTTP " - f"notification stream") + log.info( + f"Client {request.client.host}:{request.client.port} has disconnected from controller HTTP " + f"notification stream" + ) + return StreamingResponse(event_stream(), media_type="application/json") @router.websocket("/notifications/ws") async def controller_ws_notifications( - websocket: WebSocket, - current_user: schemas.User = Depends(get_current_active_user_from_websocket) + websocket: WebSocket, current_user: schemas.User = Depends(get_current_active_user_from_websocket) ) -> None: """ Receive project notifications about the controller from WebSocket. diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index d363c996e..1043db717 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -50,14 +50,13 @@ def _reject_refresh_token(token_data) -> None: async def get_user_from_token( - request: Request, - bearer_token: str = Depends(oauth2_scheme), - user_repo: UsersRepository = Depends(get_repository(UsersRepository)), - api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), - token: Optional[str] = Query(None, include_in_schema=False) + request: Request, + bearer_token: str = Depends(oauth2_scheme), + user_repo: UsersRepository = Depends(get_repository(UsersRepository)), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), + token: Optional[str] = Query(None, include_in_schema=False), ) -> schemas.User: - if bearer_token: # bearer token is used first, then any token passed as a URL parameter token = bearer_token @@ -147,9 +146,9 @@ async def get_user_from_token( async def get_current_active_user( - request: Request, - current_user: schemas.User = Depends(get_user_from_token), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + request: Request, + current_user: schemas.User = Depends(get_user_from_token), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> schemas.User: # Super admin is always authorized @@ -167,9 +166,9 @@ async def get_current_active_user( async def get_current_active_user_from_websocket( - websocket: WebSocket, - token: str = Query(...), - user_repo: UsersRepository = Depends(get_repository(UsersRepository)), + websocket: WebSocket, + token: str = Query(...), + user_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> Optional[schemas.User]: # Extract requested subprotocols from headers for proper WebSocket negotiation @@ -209,12 +208,11 @@ async def get_current_active_user_from_websocket( if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"Could not validate credentials for '{token_data.username}'" + detail=f"Could not validate credentials for '{token_data.username}'", ) if token_data.token_version != user.token_version: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"Token has been revoked for '{token_data.username}'" + status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Token has been revoked for '{token_data.username}'" ) # Super admin is always authorized @@ -223,8 +221,7 @@ async def get_current_active_user_from_websocket( if not user.is_active: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"'{token_data.username}' is not an active user" + status_code=status.HTTP_401_UNAUTHORIZED, detail=f"'{token_data.username}' is not an active user" ) return user @@ -242,4 +239,3 @@ async def get_current_active_user_from_websocket( await websocket.send_json(websocket_error) log.error(err_msg) return await websocket.close(code=1008) - diff --git a/gns3server/api/routes/controller/dependencies/database.py b/gns3server/api/routes/controller/dependencies/database.py index e5338ad75..2e859106b 100644 --- a/gns3server/api/routes/controller/dependencies/database.py +++ b/gns3server/api/routes/controller/dependencies/database.py @@ -22,8 +22,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from gns3server.db.repositories.base import BaseRepository - - async def get_db_session(request: HTTPConnection) -> AsyncSession: async with AsyncSession(request.app.state._db_engine, expire_on_commit=False) as session: diff --git a/gns3server/api/routes/controller/dependencies/rbac.py b/gns3server/api/routes/controller/dependencies/rbac.py index 41f4cbd0d..995055e1d 100644 --- a/gns3server/api/routes/controller/dependencies/rbac.py +++ b/gns3server/api/routes/controller/dependencies/rbac.py @@ -27,30 +27,29 @@ import logging log = logging.getLogger() -def has_privilege( - privilege_name: str -): +def has_privilege(privilege_name: str): async def get_user_and_check_privilege( - request: Request, - current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + request: Request, + current_user: schemas.User = Depends(get_current_active_user), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ): if not current_user.is_superadmin: path = re.sub(r"^/v[0-9]", "", request.url.path) # remove the prefix (e.g. "/v3") from URL path log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'") if not await rbac_repo.check_user_has_privilege(current_user.user_id, path, privilege_name): - raise HTTPException(status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)") + raise HTTPException( + status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)" + ) return current_user + return get_user_and_check_privilege -def has_privilege_on_websocket( - privilege_name: str -): +def has_privilege_on_websocket(privilege_name: str): async def get_user_and_check_privilege( - websocket: WebSocket, - current_user: schemas.User = Depends(get_current_active_user_from_websocket), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + websocket: WebSocket, + current_user: schemas.User = Depends(get_current_active_user_from_websocket), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ): # Authentication may have failed and closed the socket inside the auth # dependency, returning None — bail out before touching the user object. @@ -60,10 +59,14 @@ def has_privilege_on_websocket( path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'") if not await rbac_repo.check_user_has_privilege(current_user.user_id, path, privilege_name): - raise HTTPException(status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)") + raise HTTPException( + status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)" + ) return current_user + return get_user_and_check_privilege + # class PrivilegeChecker: # # def __init__(self, required_privilege: str) -> None: diff --git a/gns3server/api/routes/controller/drawings.py b/gns3server/api/routes/controller/drawings.py index 1d8c47f17..42e989f0f 100644 --- a/gns3server/api/routes/controller/drawings.py +++ b/gns3server/api/routes/controller/drawings.py @@ -39,7 +39,7 @@ router = APIRouter(responses=responses) "", response_model=List[schemas.Drawing], response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Drawing.Audit"))] + dependencies=[Depends(has_privilege("Drawing.Audit"))], ) async def get_drawings(project_id: UUID) -> List[schemas.Drawing]: """ @@ -59,7 +59,7 @@ async def get_drawings(project_id: UUID) -> List[schemas.Drawing]: "", status_code=status.HTTP_201_CREATED, response_model=schemas.Drawing, - dependencies=[Depends(has_privilege("Drawing.Allocate"))] + dependencies=[Depends(has_privilege("Drawing.Allocate"))], ) async def create_drawing(project_id: UUID, drawing_data: schemas.Drawing) -> schemas.Drawing: """ @@ -77,7 +77,7 @@ async def create_drawing(project_id: UUID, drawing_data: schemas.Drawing) -> sch "/{drawing_id}", response_model=schemas.Drawing, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Drawing.Audit"))] + dependencies=[Depends(has_privilege("Drawing.Audit"))], ) async def get_drawing(project_id: UUID, drawing_id: UUID) -> schemas.Drawing: """ @@ -95,7 +95,7 @@ async def get_drawing(project_id: UUID, drawing_id: UUID) -> schemas.Drawing: "/{drawing_id}", response_model=schemas.Drawing, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Drawing.Modify"))] + dependencies=[Depends(has_privilege("Drawing.Modify"))], ) async def update_drawing(project_id: UUID, drawing_id: UUID, drawing_data: schemas.Drawing) -> schemas.Drawing: """ @@ -111,14 +111,10 @@ async def update_drawing(project_id: UUID, drawing_id: UUID, drawing_data: schem @router.delete( - "/{drawing_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Drawing.Allocate"))] + "/{drawing_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Drawing.Allocate"))] ) async def delete_drawing( - project_id: UUID, - drawing_id: UUID, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + project_id: UUID, drawing_id: UUID, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> None: """ Delete a drawing. diff --git a/gns3server/api/routes/controller/groups.py b/gns3server/api/routes/controller/groups.py index c74f2b8c1..606835419 100644 --- a/gns3server/api/routes/controller/groups.py +++ b/gns3server/api/routes/controller/groups.py @@ -44,13 +44,9 @@ log = logging.getLogger(__name__) router = APIRouter() -@router.get( - "", - response_model=List[schemas.UserGroup], - dependencies=[Depends(has_privilege("Group.Audit"))] -) +@router.get("", response_model=List[schemas.UserGroup], dependencies=[Depends(has_privilege("Group.Audit"))]) async def get_user_groups( - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> List[schemas.UserGroup]: """ Get all user groups. @@ -65,11 +61,10 @@ async def get_user_groups( "", response_model=schemas.UserGroup, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Group.Allocate"))] + dependencies=[Depends(has_privilege("Group.Allocate"))], ) async def create_user_group( - user_group_create: schemas.UserGroupCreate, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_group_create: schemas.UserGroupCreate, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) ) -> schemas.UserGroup: """ Create a new user group. @@ -83,14 +78,10 @@ async def create_user_group( return await users_repo.create_user_group(user_group_create) -@router.get( - "/{user_group_id}", - response_model=schemas.UserGroup, - dependencies=[Depends(has_privilege("Group.Audit"))] -) +@router.get("/{user_group_id}", response_model=schemas.UserGroup, dependencies=[Depends(has_privilege("Group.Audit"))]) async def get_user_group( - user_group_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + user_group_id: UUID, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> schemas.UserGroup: """ Get a user group. @@ -104,15 +95,11 @@ async def get_user_group( return user_group -@router.put( - "/{user_group_id}", - response_model=schemas.UserGroup, - dependencies=[Depends(has_privilege("Group.Modify"))] -) +@router.put("/{user_group_id}", response_model=schemas.UserGroup, dependencies=[Depends(has_privilege("Group.Modify"))]) async def update_user_group( - user_group_id: UUID, - user_group_update: schemas.UserGroupUpdate, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_group_id: UUID, + user_group_update: schemas.UserGroupUpdate, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> schemas.UserGroup: """ Update a user group. @@ -130,14 +117,12 @@ async def update_user_group( @router.delete( - "/{user_group_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Group.Allocate"))] + "/{user_group_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Group.Allocate"))] ) async def delete_user_group( - user_group_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + user_group_id: UUID, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a user group. @@ -159,13 +144,10 @@ async def delete_user_group( @router.get( - "/{user_group_id}/members", - response_model=List[schemas.User], - dependencies=[Depends(has_privilege("Group.Audit"))] + "/{user_group_id}/members", response_model=List[schemas.User], dependencies=[Depends(has_privilege("Group.Audit"))] ) async def get_user_group_members( - user_group_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_group_id: UUID, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) ) -> List[schemas.User]: """ Get all user group members. @@ -179,12 +161,10 @@ async def get_user_group_members( @router.put( "/{user_group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Group.Modify"))] + dependencies=[Depends(has_privilege("Group.Modify"))], ) async def add_member_to_group( - user_group_id: UUID, - user_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_group_id: UUID, user_id: UUID, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) ) -> None: """ Add member to a user group. @@ -209,7 +189,7 @@ async def add_member_to_group( @router.delete( "/{user_group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Group.Modify"))] + dependencies=[Depends(has_privilege("Group.Modify"))], ) async def remove_member_from_group( user_group_id: UUID, diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index fe27c20d8..a67c68351 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -31,7 +31,13 @@ from typing import List, Optional from gns3server import schemas from gns3server.config import Config from gns3server.compute.qemu import Qemu -from gns3server.utils.images import InvalidImageError, write_image, read_image_info, default_images_directory, get_builtin_disks +from gns3server.utils.images import ( + InvalidImageError, + write_image, + read_image_info, + default_images_directory, + get_builtin_disks, +) from gns3server.db.repositories.images import ImagesRepository from gns3server.db.repositories.templates import TemplatesRepository from gns3server.db.repositories.rbac import RbacRepository @@ -40,7 +46,7 @@ from gns3server.controller.controller_error import ( ControllerError, ControllerNotFoundError, ControllerForbiddenError, - ControllerBadRequestError + ControllerBadRequestError, ) from .dependencies.authentication import get_current_active_user @@ -51,17 +57,17 @@ log = logging.getLogger(__name__) router = APIRouter() + @router.post( "/qemu/{image_path:path}", response_model=schemas.Image, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Image.Allocate"))] + dependencies=[Depends(has_privilege("Image.Allocate"))], ) async def create_qemu_image( - image_path: str, - image_data: schemas.QemuDiskImageCreate, - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), - + image_path: str, + image_data: schemas.QemuDiskImageCreate, + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), ) -> schemas.Image: """ Create a new blank Qemu image. @@ -104,14 +110,11 @@ async def create_qemu_image( else: return await images_repo.add_image(**image_info) -@router.get( - "", - response_model=List[schemas.Image], - dependencies=[Depends(has_privilege("Image.Audit"))] -) + +@router.get("", response_model=List[schemas.Image], dependencies=[Depends(has_privilege("Image.Audit"))]) async def get_images( - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), - image_type: Optional[schemas.ImageType] = None + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + image_type: Optional[schemas.ImageType] = None, ) -> List[schemas.Image]: """ Return all images. @@ -126,16 +129,16 @@ async def get_images( "/upload/{image_path:path}", response_model=schemas.Image, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Image.Allocate"))] + dependencies=[Depends(has_privilege("Image.Allocate"))], ) async def upload_image( - image_path: str, - request: Request, - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), - current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), - install_appliances: Optional[bool] = False, + image_path: str, + request: Request, + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), + current_user: schemas.User = Depends(get_current_active_user), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + install_appliances: Optional[bool] = False, ) -> schemas.Image: """ Upload an image. @@ -161,9 +164,7 @@ async def upload_image( duplicate = await images_repo.get_image_by_checksum(checksum_header, check_dir) if duplicate: location = f" in '{check_dir}'" if check_dir else "" - raise ControllerError( - f"Image '{duplicate.filename}' with the same checksum already exists{location}" - ) + raise ControllerError(f"Image '{duplicate.filename}' with the same checksum already exists{location}") try: allow_raw_image = Config.instance().settings.Server.allow_raw_images @@ -180,19 +181,17 @@ async def upload_image( templates_repo, rbac_repo, current_user, - os.path.dirname(image.path) + os.path.dirname(image.path), ) return image @router.delete( - "/prune", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Image.Allocate"))] + "/prune", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Image.Allocate"))] ) async def prune_images( - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), ) -> None: """ Prune images not attached to any template. @@ -209,14 +208,10 @@ async def prune_images( await images_repo.prune_images(list(skip_images) + list(referenced_filenames)) -@router.post( - "/install", - status_code=status.HTTP_200_OK, - dependencies=[Depends(has_privilege("Image.Allocate"))] -) +@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))]) async def install_images( - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), ) -> dict: """ Attempt to automatically create templates based on image checksums. @@ -239,19 +234,15 @@ async def install_images( if templates: # the image is already used by a template log.warning(f"Image '{image.path}' is used by one or more templates") - skipped.append({ - "name": image.filename, - "reason": "image is already used by one or more templates", - }) + skipped.append( + { + "name": image.filename, + "reason": "image is already used by one or more templates", + } + ) continue results = await Controller.instance().appliance_manager.install_appliances_from_image( - image.path, - image.checksum, - images_repo, - templates_repo, - None, - None, - os.path.dirname(image.path) + image.path, image.checksum, images_repo, templates_repo, None, None, os.path.dirname(image.path) ) for result in results: if result.get("status") == "created": @@ -261,14 +252,10 @@ async def install_images( return {"created": created, "skipped": skipped} -@router.get( - "/{image_path:path}", - response_model=schemas.Image, - dependencies=[Depends(has_privilege("Image.Audit"))] -) +@router.get("/{image_path:path}", response_model=schemas.Image, dependencies=[Depends(has_privilege("Image.Audit"))]) async def get_image( - image_path: str, - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + image_path: str, + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), ) -> schemas.Image: """ Return an image. @@ -286,11 +273,11 @@ async def get_image( @router.delete( "/{image_path:path}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Image.Allocate"))] + dependencies=[Depends(has_privilege("Image.Allocate"))], ) async def delete_image( - image_path: str, - images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + image_path: str, + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), ) -> None: """ Delete an image. @@ -303,8 +290,9 @@ async def delete_image( try: image = await images_repo.get_image(image_path) except MultipleResultsFound: - raise ControllerBadRequestError(f"Image '{image_path}' matches multiple images. " - f"Please include the absolute path of the image") + raise ControllerBadRequestError( + f"Image '{image_path}' matches multiple images. Please include the absolute path of the image" + ) if not image: raise ControllerNotFoundError(f"Image '{image_path}' not found") diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index bf6c5467f..1ee60a891 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -65,7 +65,7 @@ async def dep_link(project_id: UUID, link_id: UUID) -> Link: "", response_model=List[schemas.Link], response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Link.Audit"))] + dependencies=[Depends(has_privilege("Link.Audit"))], ) async def get_links(project_id: UUID) -> List[schemas.Link]: """ @@ -89,7 +89,7 @@ async def get_links(project_id: UUID) -> List[schemas.Link]: 404: {"model": schemas.ErrorMessage, "description": "Could not find project"}, 409: {"model": schemas.ErrorMessage, "description": "Could not create link"}, }, - dependencies=[Depends(has_privilege("Link.Allocate"))] + dependencies=[Depends(has_privilege("Link.Allocate"))], ) async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schemas.Link: """ @@ -123,10 +123,7 @@ async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schema return link.asdict() -@router.get( - "/{link_id}/available_filters", - dependencies=[Depends(has_privilege("Link.Audit"))] -) +@router.get("/{link_id}/available_filters", dependencies=[Depends(has_privilege("Link.Audit"))]) async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: """ Return all filters available for a given link. @@ -141,7 +138,7 @@ async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: "/{link_id}", response_model=schemas.Link, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Link.Audit"))] + dependencies=[Depends(has_privilege("Link.Audit"))], ) async def get_link(link: Link = Depends(dep_link)) -> schemas.Link: """ @@ -157,7 +154,7 @@ async def get_link(link: Link = Depends(dep_link)) -> schemas.Link: "/{link_id}", response_model=schemas.Link, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Link.Modify"))] + dependencies=[Depends(has_privilege("Link.Modify"))], ) async def update_link(link_data: schemas.LinkUpdate, link: Link = Depends(dep_link)) -> schemas.Link: """ @@ -181,14 +178,12 @@ async def update_link(link_data: schemas.LinkUpdate, link: Link = Depends(dep_li @router.delete( - "/{link_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Link.Allocate"))] + "/{link_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Link.Allocate"))] ) async def delete_link( - project_id: UUID, - link: Link = Depends(dep_link), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + project_id: UUID, + link: Link = Depends(dep_link), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a link. @@ -201,11 +196,7 @@ async def delete_link( await rbac_repo.delete_all_ace_starting_with_path(f"/links/{link.id}") -@router.post( - "/{link_id}/reset", - response_model=schemas.Link, - dependencies=[Depends(has_privilege("Link.Modify"))] -) +@router.post("/{link_id}/reset", response_model=schemas.Link, dependencies=[Depends(has_privilege("Link.Modify"))]) async def reset_link(link: Link = Depends(dep_link)) -> schemas.Link: """ Reset a link. @@ -221,12 +212,10 @@ async def reset_link(link: Link = Depends(dep_link)) -> schemas.Link: "/{link_id}/capture/start", status_code=status.HTTP_201_CREATED, response_model=schemas.Link, - dependencies=[Depends(has_privilege("Link.Capture"))] + dependencies=[Depends(has_privilege("Link.Capture"))], ) async def start_capture( - capture_data: schemas.LinkCapture, - http_request: Request, - link: Link = Depends(dep_link) + capture_data: schemas.LinkCapture, http_request: Request, link: Link = Depends(dep_link) ) -> schemas.Link: """ Start packet capture on the link. @@ -242,7 +231,7 @@ async def start_capture( data_link_type=capture_data.data_link_type, capture_file_name=capture_data.capture_file_name, wireshark=capture_data.wireshark, - jwt_token=jwt_token + jwt_token=jwt_token, ) return link.asdict() @@ -250,7 +239,7 @@ async def start_capture( @router.post( "/{link_id}/capture/stop", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Link.Capture"))] + dependencies=[Depends(has_privilege("Link.Capture"))], ) async def stop_capture(link: Link = Depends(dep_link)) -> None: """ @@ -265,12 +254,9 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None: @router.post( "/{link_id}/capture/wireshark/restart", status_code=status.HTTP_200_OK, - dependencies=[Depends(has_privilege("Link.Capture"))] + dependencies=[Depends(has_privilege("Link.Capture"))], ) -async def restart_wireshark( - http_request: Request, - link: Link = Depends(dep_link) -) -> dict: +async def restart_wireshark(http_request: Request, link: Link = Depends(dep_link)) -> dict: """ Restart Wireshark window without stopping the capture. @@ -289,10 +275,7 @@ async def restart_wireshark( return {"status": "restarted"} -@router.get( - "/{link_id}/capture/stream", - dependencies=[Depends(has_privilege("Link.Capture"))] -) +@router.get("/{link_id}/capture/stream", dependencies=[Depends(has_privilege("Link.Capture"))]) async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> StreamingResponse: """ Stream the PCAP capture file from compute. @@ -318,13 +301,13 @@ async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> Strea try: ssl_context = Controller.instance().ssl_context() async with HTTPClient.request( - request.method, - pcap_streaming_url, - user=compute.user, - password=compute.password, - ssl_context=ssl_context, - timeout=None, - data=body + request.method, + pcap_streaming_url, + user=compute.user, + password=compute.password, + ssl_context=ssl_context, + timeout=None, + data=body, ) as response: async for data in response.content.iter_any(): if not data: @@ -337,9 +320,7 @@ async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> Strea @router.get( - "/{link_id}/capture/file", - dependencies=[Depends(has_privilege("Link.Capture"))], - response_class=FileResponse + "/{link_id}/capture/file", dependencies=[Depends(has_privilege("Link.Capture"))], response_class=FileResponse ) async def download_capture_file(link: Link = Depends(dep_link)): """ @@ -359,7 +340,7 @@ async def download_capture_file(link: Link = Depends(dep_link)): return FileResponse( path=link.capture_file_path, filename=os.path.basename(link.capture_file_path), - media_type="application/vnd.tcpdump.pcap" + media_type="application/vnd.tcpdump.pcap", ) @@ -368,7 +349,7 @@ async def web_wireshark_websocket( websocket: WebSocket, link_id: str, project_id: str, - current_user: schemas.User = Depends(has_privilege_on_websocket("Link.Capture")) + current_user: schemas.User = Depends(has_privilege_on_websocket("Link.Capture")), ): """ WebSocket proxy endpoint for xpra container (Web Wireshark). @@ -424,10 +405,7 @@ async def web_wireshark_websocket( pass -@router.get( - "/{link_id}/markers", - dependencies=[Depends(has_privilege("Link.Audit"))] -) +@router.get("/{link_id}/markers", dependencies=[Depends(has_privilege("Link.Audit"))]) async def get_markers(link: Link = Depends(dep_link)) -> dict: """ Return all traffic-insight markers configured on this link. @@ -439,14 +417,9 @@ async def get_markers(link: Link = Depends(dep_link)) -> dict: @router.post( - "/{link_id}/markers", - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Link.Modify"))] + "/{link_id}/markers", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Link.Modify"))] ) -async def create_marker( - marker_data: schemas.MarkerCreate, - link: Link = Depends(dep_link) -) -> dict: +async def create_marker(marker_data: schemas.MarkerCreate, link: Link = Depends(dep_link)) -> dict: """ Attach a traffic-insight marker to the link. On BPF match uBridge emits MARK signals and appends packets to a pcap. @@ -477,12 +450,9 @@ async def create_marker( @router.delete( "/{link_id}/markers/{marker_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Link.Modify"))] + dependencies=[Depends(has_privilege("Link.Modify"))], ) -async def delete_marker( - marker_name: str, - link: Link = Depends(dep_link) -) -> None: +async def delete_marker(marker_name: str, link: Link = Depends(dep_link)) -> None: """ Remove a traffic-insight marker from the link. @@ -492,15 +462,8 @@ async def delete_marker( await link.stop_marker(marker_name) -@router.put( - "/{link_id}/markers/{marker_name}", - dependencies=[Depends(has_privilege("Link.Modify"))] -) -async def update_marker( - marker_name: str, - marker_data: schemas.MarkerUpdate, - link: Link = Depends(dep_link) -) -> dict: +@router.put("/{link_id}/markers/{marker_name}", dependencies=[Depends(has_privilege("Link.Modify"))]) +async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, link: Link = Depends(dep_link)) -> dict: """ Update a traffic-insight marker (change BPF, tag, or enabled). @@ -522,7 +485,7 @@ async def update_marker( @router.get( "/{link_id}/iface", response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo], - dependencies=[Depends(has_privilege("Link.Audit"))] + dependencies=[Depends(has_privilege("Link.Audit"))], ) async def get_iface(link: Link = Depends(dep_link)) -> Union[schemas.UDPPortInfo, schemas.EthernetPortInfo]: """ @@ -544,7 +507,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> Union[schemas.UDPPortInfo if "ports_mapping" not in response.json: continue ports_mapping = response.json["ports_mapping"] - + for port in ports_mapping: port_num = port.get("port_number") @@ -556,7 +519,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> Union[schemas.UDPPortInfo "type": f"{port_type}", "lport": port["lport"], "rhost": port["rhost"], - "rport": port["rport"] + "rport": port["rport"], } else: ifaces_info = { @@ -564,7 +527,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> Union[schemas.UDPPortInfo "type": f"{port_type}", "interface": port["interface"], } - + if not ifaces_info: raise ControllerError("Link not connected to Cloud/NAT") return ifaces_info diff --git a/gns3server/api/routes/controller/llm_model_configs.py b/gns3server/api/routes/controller/llm_model_configs.py index e6c5b4338..620f75c26 100644 --- a/gns3server/api/routes/controller/llm_model_configs.py +++ b/gns3server/api/routes/controller/llm_model_configs.py @@ -60,14 +60,12 @@ def _filter_api_key_from_config(config: dict) -> dict: # User LLM Model Configuration Endpoints # ============================================================================ -@router.get( - "/users/{user_id}/llm-model-configs", - response_model=schemas.LLMModelConfigInheritedResponse -) + +@router.get("/users/{user_id}/llm-model-configs", response_model=schemas.LLMModelConfigInheritedResponse) async def get_user_llm_model_configs( - user_id: UUID, - current_user: schemas.User = Depends(has_privilege("LLMConfig.Audit")), - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, + current_user: schemas.User = Depends(has_privilege("LLMConfig.Audit")), + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigInheritedResponse: """ Get user's effective LLM model configurations (own + inherited from groups). @@ -77,31 +75,25 @@ async def get_user_llm_model_configs( try: result = await llm_repo.get_user_effective_configs( - user_id, - current_user_id=current_user.user_id, - current_user_is_superadmin=current_user.is_superadmin + user_id, current_user_id=current_user.user_id, current_user_is_superadmin=current_user.is_superadmin ) return schemas.LLMModelConfigInheritedResponse( - configs=result["configs"], - default_config=result.get("default_config"), - total=len(result["configs"]) + configs=result["configs"], default_config=result.get("default_config"), total=len(result["configs"]) ) except Exception as e: log.error(f"Failed to retrieve user LLM model configs: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve LLM model configurations" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve LLM model configurations" ) @router.get( "/users/{user_id}/llm-model-configs/own", response_model=List[schemas.LLMModelConfigResponse], - dependencies=[Depends(has_privilege("LLMConfig.Audit"))] + dependencies=[Depends(has_privilege("LLMConfig.Audit"))], ) async def get_user_own_llm_model_configs( - user_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) ) -> List[schemas.LLMModelConfigResponse]: """ Get user's own LLM model configurations (excluding inherited ones). @@ -122,26 +114,24 @@ async def get_user_own_llm_model_configs( is_default=config.is_default, version=config.version, created_at=config.created_at, - updated_at=config.updated_at + updated_at=config.updated_at, ) for config in configs ] except Exception as e: log.error(f"Failed to retrieve user's own LLM model configs: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve LLM model configurations" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve LLM model configurations" ) @router.get( "/users/{user_id}/llm-model-configs/default", response_model=schemas.LLMModelConfigResponse, - dependencies=[Depends(has_privilege("LLMConfig.Audit"))] + dependencies=[Depends(has_privilege("LLMConfig.Audit"))], ) async def get_user_default_llm_model_config( - user_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) ) -> schemas.LLMModelConfigResponse: """ Get user's default LLM model configuration. @@ -154,7 +144,7 @@ async def get_user_default_llm_model_config( if not config: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"No default LLM model configuration found for user '{user_id}'" + detail=f"No default LLM model configuration found for user '{user_id}'", ) return schemas.LLMModelConfigResponse( @@ -167,15 +157,14 @@ async def get_user_default_llm_model_config( is_default=config.is_default, version=config.version, created_at=config.created_at, - updated_at=config.updated_at + updated_at=config.updated_at, ) except HTTPException: raise except Exception as e: log.error(f"Failed to retrieve user's default LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve LLM model configuration" ) @@ -183,13 +172,13 @@ async def get_user_default_llm_model_config( "/users/{user_id}/llm-model-configs", response_model=schemas.LLMModelConfigResponse, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("LLMConfig.Modify"))] + dependencies=[Depends(has_privilege("LLMConfig.Modify"))], ) async def create_user_llm_model_config( - user_id: UUID, - config_create: schemas.LLMModelConfigCreate, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, + config_create: schemas.LLMModelConfigCreate, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigResponse: """ Create a new LLM model configuration for a user. @@ -206,7 +195,7 @@ async def create_user_llm_model_config( raise ControllerNotFoundError(f"User '{user_id}' not found") # Validate context_limit is provided - if not hasattr(config_create, 'context_limit') or config_create.context_limit is None: + if not hasattr(config_create, "context_limit") or config_create.context_limit is None: raise ControllerBadRequestError( "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). " "Please check your model provider's documentation for the current context window size " @@ -217,11 +206,7 @@ async def create_user_llm_model_config( # Extract config fields (excluding table-level fields) config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"}) new_config = await llm_repo.create_user_config( - user_id, - config_create.name, - config_create.model_type, - config_fields, - is_default=config_create.is_default + user_id, config_create.name, config_create.model_type, config_fields, is_default=config_create.is_default ) return schemas.LLMModelConfigResponse( @@ -234,28 +219,27 @@ async def create_user_llm_model_config( is_default=new_config.is_default, version=new_config.version, created_at=new_config.created_at, - updated_at=new_config.updated_at + updated_at=new_config.updated_at, ) except ValueError as e: raise ControllerBadRequestError(str(e)) except Exception as e: log.error(f"Failed to create LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create LLM model configuration: {e}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create LLM model configuration: {e}" ) @router.put( "/users/{user_id}/llm-model-configs/{config_id}", response_model=schemas.LLMModelConfigResponse, - dependencies=[Depends(has_privilege("LLMConfig.Modify"))] + dependencies=[Depends(has_privilege("LLMConfig.Modify"))], ) async def update_user_llm_model_config( - user_id: UUID, - config_id: UUID, - config_update: schemas.LLMModelConfigUpdate, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, + config_id: UUID, + config_update: schemas.LLMModelConfigUpdate, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigResponse: """ Update a user's LLM model configuration. @@ -272,16 +256,12 @@ async def update_user_llm_model_config( expected_version = updates.pop("expected_version", None) updated_config = await llm_repo.update_user_config( - config_id, - user_id, - updates, - expected_version=expected_version + config_id, user_id, updates, expected_version=expected_version ) if not updated_config: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"LLM model configuration '{config_id}' not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found" ) return schemas.LLMModelConfigResponse( @@ -294,35 +274,31 @@ async def update_user_llm_model_config( is_default=updated_config.is_default, version=updated_config.version, created_at=updated_config.created_at, - updated_at=updated_config.updated_at + updated_at=updated_config.updated_at, ) except HTTPException: raise except ValueError as e: # Handle optimistic lock errors if "Concurrent modification" in str(e): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) raise ControllerBadRequestError(str(e)) except Exception as e: log.error(f"Failed to update LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update LLM model configuration" ) @router.delete( "/users/{user_id}/llm-model-configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("LLMConfig.Modify"))] + dependencies=[Depends(has_privilege("LLMConfig.Modify"))], ) async def delete_user_llm_model_config( - user_id: UUID, - config_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> None: """ Delete a user's LLM model configuration. @@ -334,28 +310,26 @@ async def delete_user_llm_model_config( success = await llm_repo.delete_user_config(config_id, user_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"LLM model configuration '{config_id}' not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found" ) except HTTPException: raise except Exception as e: log.error(f"Failed to delete LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete LLM model configuration" ) @router.put( "/users/{user_id}/llm-model-configs/default/{config_id}", response_model=schemas.LLMModelConfigResponse, - dependencies=[Depends(has_privilege("LLMConfig.Modify"))] + dependencies=[Depends(has_privilege("LLMConfig.Modify"))], ) async def set_user_default_llm_model_config( - user_id: UUID, - config_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + user_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigResponse: """ Set a user's default LLM model configuration. @@ -367,8 +341,7 @@ async def set_user_default_llm_model_config( success = await llm_repo.set_user_default_config(user_id, config_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"LLM model configuration '{config_id}' not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found" ) # Get the updated config @@ -383,15 +356,14 @@ async def set_user_default_llm_model_config( is_default=config.is_default, version=config.version, created_at=config.created_at, - updated_at=config.updated_at + updated_at=config.updated_at, ) except HTTPException: raise except Exception as e: log.error(f"Failed to set default LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to set default LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to set default LLM model configuration" ) @@ -399,14 +371,14 @@ async def set_user_default_llm_model_config( # Group LLM Model Configuration Endpoints # ============================================================================ + @router.get( "/groups/{group_id}/llm-model-configs", response_model=schemas.LLMModelConfigListResponse, - dependencies=[Depends(has_privilege("Group.Audit"))] + dependencies=[Depends(has_privilege("Group.Audit"))], ) async def get_group_llm_model_configs( - group_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + group_id: UUID, llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) ) -> schemas.LLMModelConfigListResponse: """ Get all LLM model configurations for a user group. @@ -427,7 +399,7 @@ async def get_group_llm_model_configs( is_default=config.is_default, version=config.version, created_at=config.created_at, - updated_at=config.updated_at + updated_at=config.updated_at, ) for config in configs ] @@ -444,26 +416,22 @@ async def get_group_llm_model_configs( default_config = config_responses[0] return schemas.LLMModelConfigListResponse( - configs=config_responses, - default_config=default_config, - total=len(config_responses) + configs=config_responses, default_config=default_config, total=len(config_responses) ) except Exception as e: log.error(f"Failed to retrieve group LLM model configs: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve LLM model configurations" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve LLM model configurations" ) @router.get( "/groups/{group_id}/llm-model-configs/default", response_model=schemas.LLMModelConfigResponse, - dependencies=[Depends(has_privilege("Group.Audit"))] + dependencies=[Depends(has_privilege("Group.Audit"))], ) async def get_group_default_llm_model_config( - group_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + group_id: UUID, llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) ) -> schemas.LLMModelConfigResponse: """ Get group's default LLM model configuration. @@ -476,7 +444,7 @@ async def get_group_default_llm_model_config( if not config: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"No default LLM model configuration found for group '{group_id}'" + detail=f"No default LLM model configuration found for group '{group_id}'", ) return schemas.LLMModelConfigResponse( @@ -489,15 +457,14 @@ async def get_group_default_llm_model_config( is_default=config.is_default, version=config.version, created_at=config.created_at, - updated_at=config.updated_at + updated_at=config.updated_at, ) except HTTPException: raise except Exception as e: log.error(f"Failed to retrieve group's default LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to retrieve LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve LLM model configuration" ) @@ -505,13 +472,13 @@ async def get_group_default_llm_model_config( "/groups/{group_id}/llm-model-configs", response_model=schemas.LLMModelConfigResponse, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Group.Modify"))] + dependencies=[Depends(has_privilege("Group.Modify"))], ) async def create_group_llm_model_config( - group_id: UUID, - config_create: schemas.LLMModelConfigCreate, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + group_id: UUID, + config_create: schemas.LLMModelConfigCreate, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigResponse: """ Create a new LLM model configuration for a user group. @@ -528,7 +495,7 @@ async def create_group_llm_model_config( raise ControllerNotFoundError(f"User group '{group_id}' not found") # Validate context_limit is provided - if not hasattr(config_create, 'context_limit') or config_create.context_limit is None: + if not hasattr(config_create, "context_limit") or config_create.context_limit is None: raise ControllerBadRequestError( "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). " "Please check your model provider's documentation for the current context window size " @@ -539,11 +506,7 @@ async def create_group_llm_model_config( # Extract config fields (excluding table-level fields) config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"}) new_config = await llm_repo.create_group_config( - group_id, - config_create.name, - config_create.model_type, - config_fields, - is_default=config_create.is_default + group_id, config_create.name, config_create.model_type, config_fields, is_default=config_create.is_default ) return schemas.LLMModelConfigResponse( @@ -556,28 +519,27 @@ async def create_group_llm_model_config( is_default=new_config.is_default, version=new_config.version, created_at=new_config.created_at, - updated_at=new_config.updated_at + updated_at=new_config.updated_at, ) except ValueError as e: raise ControllerBadRequestError(str(e)) except Exception as e: log.error(f"Failed to create LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create LLM model configuration: {e}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create LLM model configuration: {e}" ) @router.put( "/groups/{group_id}/llm-model-configs/{config_id}", response_model=schemas.LLMModelConfigResponse, - dependencies=[Depends(has_privilege("Group.Modify"))] + dependencies=[Depends(has_privilege("Group.Modify"))], ) async def update_group_llm_model_config( - group_id: UUID, - config_id: UUID, - config_update: schemas.LLMModelConfigUpdate, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + group_id: UUID, + config_id: UUID, + config_update: schemas.LLMModelConfigUpdate, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigResponse: """ Update a group's LLM model configuration. @@ -594,16 +556,12 @@ async def update_group_llm_model_config( expected_version = updates.pop("expected_version", None) updated_config = await llm_repo.update_group_config( - config_id, - group_id, - updates, - expected_version=expected_version + config_id, group_id, updates, expected_version=expected_version ) if not updated_config: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"LLM model configuration '{config_id}' not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found" ) return schemas.LLMModelConfigResponse( @@ -616,35 +574,31 @@ async def update_group_llm_model_config( is_default=updated_config.is_default, version=updated_config.version, created_at=updated_config.created_at, - updated_at=updated_config.updated_at + updated_at=updated_config.updated_at, ) except HTTPException: raise except ValueError as e: # Handle optimistic lock errors if "Concurrent modification" in str(e): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) raise ControllerBadRequestError(str(e)) except Exception as e: log.error(f"Failed to update LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update LLM model configuration" ) @router.delete( "/groups/{group_id}/llm-model-configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Group.Modify"))] + dependencies=[Depends(has_privilege("Group.Modify"))], ) async def delete_group_llm_model_config( - group_id: UUID, - config_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + group_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> None: """ Delete a group's LLM model configuration. @@ -656,28 +610,26 @@ async def delete_group_llm_model_config( success = await llm_repo.delete_group_config(config_id, group_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"LLM model configuration '{config_id}' not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found" ) except HTTPException: raise except Exception as e: log.error(f"Failed to delete LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete LLM model configuration" ) @router.put( "/groups/{group_id}/llm-model-configs/default/{config_id}", response_model=schemas.LLMModelConfigResponse, - dependencies=[Depends(has_privilege("Group.Modify"))] + dependencies=[Depends(has_privilege("Group.Modify"))], ) async def set_group_default_llm_model_config( - group_id: UUID, - config_id: UUID, - llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) + group_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)), ) -> schemas.LLMModelConfigResponse: """ Set a group's default LLM model configuration. @@ -689,8 +641,7 @@ async def set_group_default_llm_model_config( success = await llm_repo.set_group_default_config(group_id, config_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"LLM model configuration '{config_id}' not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found" ) # Get the updated config @@ -705,13 +656,12 @@ async def set_group_default_llm_model_config( is_default=config.is_default, version=config.version, created_at=config.created_at, - updated_at=config.updated_at + updated_at=config.updated_at, ) except HTTPException: raise except Exception as e: log.error(f"Failed to set default LLM model config: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to set default LLM model configuration" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to set default LLM model configuration" ) diff --git a/gns3server/api/routes/controller/netmiko.py b/gns3server/api/routes/controller/netmiko.py index 1fd2b907d..827ac751c 100644 --- a/gns3server/api/routes/controller/netmiko.py +++ b/gns3server/api/routes/controller/netmiko.py @@ -55,6 +55,7 @@ def _load_netmiko_device_types() -> schemas.NetmikoDeviceTypeList: import importlib import netmiko + # "from netmiko import ssh_dispatcher" is shadowed by a function of the same # name in netmiko's __init__, so import the module through importlib sd = importlib.import_module("netmiko.ssh_dispatcher") @@ -79,9 +80,7 @@ def _load_netmiko_device_types() -> schemas.NetmikoDeviceTypeList: @router.get( - "/device_types", - response_model=schemas.NetmikoDeviceTypeList, - dependencies=[Depends(get_current_active_user)] + "/device_types", response_model=schemas.NetmikoDeviceTypeList, dependencies=[Depends(get_current_active_user)] ) def get_netmiko_device_types() -> schemas.NetmikoDeviceTypeList: """ @@ -97,6 +96,6 @@ def get_netmiko_device_types() -> schemas.NetmikoDeviceTypeList: except ImportError: raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="Netmiko is not available. Install AI dependencies with: pip install gns3-server[ai-features]" + detail="Netmiko is not available. Install AI dependencies with: pip install gns3-server[ai-features]", ) return _device_types_cache diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index 482c56146..0938ab087 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -125,7 +125,7 @@ def _check_node_type(node: Node, *required_types: str) -> None: 404: {"model": schemas.ErrorMessage, "description": "Could not find project"}, 409: {"model": schemas.ErrorMessage, "description": "Could not create node"}, }, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node: """ @@ -145,11 +145,11 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends( "", response_model=List[schemas.Node], response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Node.Audit"))] + dependencies=[Depends(has_privilege("Node.Audit"))], ) def get_nodes( project: Project = Depends(dep_project), - tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"), ) -> List[schemas.Node]: """ Return all nodes belonging to a given project. @@ -213,7 +213,9 @@ async def stop_all_nodes(project: Project = Depends(dep_project)) -> None: raise -@router.post("/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) +@router.post( + "/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] +) async def suspend_all_nodes(project: Project = Depends(dep_project)) -> None: """ Suspend all nodes belonging to a given project. @@ -270,7 +272,7 @@ async def get_node(node: Node = Depends(dep_node)) -> schemas.Node: "/{node_id}", response_model=schemas.Node, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Node.Modify"))] + dependencies=[Depends(has_privilege("Node.Modify"))], ) async def update_node(node_data: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node: """ @@ -294,11 +296,12 @@ async def update_node(node_data: schemas.NodeUpdate, node: Node = Depends(dep_no "/{node_id}", status_code=status.HTTP_204_NO_CONTENT, responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Cannot delete node"}}, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) async def delete_node( - node_id: UUID, project: Project = Depends(dep_project), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + node_id: UUID, + project: Project = Depends(dep_project), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a node from a project. @@ -314,7 +317,7 @@ async def delete_node( "/{node_id}/duplicate", response_model=schemas.Node, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Depends(dep_node)) -> schemas.Node: """ @@ -328,9 +331,7 @@ async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Dep @router.post( - "/{node_id}/start", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + "/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] ) async def start_node(start_data: Optional[dict] = None, node: Node = Depends(dep_node)) -> None: """ @@ -345,10 +346,9 @@ async def start_node(start_data: Optional[dict] = None, node: Node = Depends(dep if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: raise + @router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + "/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] ) async def stop_node(node: Node = Depends(dep_node)) -> None: """ @@ -367,7 +367,7 @@ async def stop_node(node: Node = Depends(dep_node)) -> None: @router.post( "/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + dependencies=[Depends(has_privilege("Node.PowerMgmt"))], ) async def suspend_node(node: Node = Depends(dep_node)) -> None: """ @@ -383,9 +383,7 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None: @router.post( - "/{node_id}/reload", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] ) async def reload_node(node: Node = Depends(dep_node)) -> None: """ @@ -400,10 +398,9 @@ async def reload_node(node: Node = Depends(dep_node)) -> None: if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: raise + @router.post( - "/{node_id}/isolate", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Link.Modify"))] + "/{node_id}/isolate", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Link.Modify"))] ) async def isolate_node(node: Node = Depends(dep_node)) -> None: """ @@ -417,9 +414,7 @@ async def isolate_node(node: Node = Depends(dep_node)) -> None: @router.post( - "/{node_id}/unisolate", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Link.Modify"))] + "/{node_id}/unisolate", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Link.Modify"))] ) async def unisolate_node(node: Node = Depends(dep_node)) -> None: """ @@ -436,7 +431,7 @@ async def unisolate_node(node: Node = Depends(dep_node)) -> None: "/{node_id}/links", response_model=List[schemas.Link], response_model_exclude_unset=True, - dependencies = [Depends(has_privilege("Link.Audit"))] + dependencies=[Depends(has_privilege("Link.Audit"))], ) async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]: """ @@ -478,12 +473,10 @@ async def idlepc_proposals(node: Node = Depends(dep_node)) -> List[str]: @router.post( "/{node_id}/qemu/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) async def create_disk_image( - disk_name: str, - disk_data: schemas.QemuDiskImageCreate, - node: Node = Depends(dep_node) + disk_name: str, disk_data: schemas.QemuDiskImageCreate, node: Node = Depends(dep_node) ) -> None: """ Create a Qemu disk image. @@ -498,12 +491,10 @@ async def create_disk_image( @router.put( "/{node_id}/qemu/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) async def update_disk_image( - disk_name: str, - disk_data: schemas.QemuDiskImageUpdate, - node: Node = Depends(dep_node) + disk_name: str, disk_data: schemas.QemuDiskImageUpdate, node: Node = Depends(dep_node) ) -> None: """ Update a Qemu disk image. @@ -518,12 +509,9 @@ async def update_disk_image( @router.delete( "/{node_id}/qemu/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) -async def delete_disk_image( - disk_name: str, - node: Node = Depends(dep_node) -) -> None: +async def delete_disk_image(disk_name: str, node: Node = Depends(dep_node)) -> None: """ Delete a Qemu disk image. @@ -534,11 +522,13 @@ async def delete_disk_image( await node.delete(f"/disk_image/{disk_name}") -@router.get("/{node_id}/files", response_model=List[schemas.NodeFile], dependencies=[Depends(has_privilege("Node.Audit"))]) +@router.get( + "/{node_id}/files", response_model=List[schemas.NodeFile], dependencies=[Depends(has_privilege("Node.Audit"))] +) async def list_node_files( node: Node = Depends(dep_node), 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 files in a node directory with detailed metadata. @@ -556,11 +546,7 @@ async def list_node_files( params["path"] = path if recursive: params["recursive"] = "true" - res = await node.compute.http_query( - "GET", url, - params=params if params else None, - timeout=None - ) + res = await node.compute.http_query("GET", url, params=params if params else None, timeout=None) return res.json @@ -582,8 +568,7 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response: path = f"/project-files/{node_type}/{node.id}/{path}" compute_resp = await node.compute.http_query( - "GET", f"/projects/{node.project.id}/files{path}", - timeout=None, stream=True + "GET", f"/projects/{node.project.id}/files{path}", timeout=None, stream=True ) async def streamer(): @@ -606,7 +591,7 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response: @router.post( "/{node_id}/files/{file_path:path}", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Node.Modify"))] + dependencies=[Depends(has_privilege("Node.Modify"))], ) async def post_file(file_path: str, request: Request, node: Node = Depends(dep_node)): """ @@ -626,15 +611,14 @@ async def post_file(file_path: str, request: Request, node: Node = Depends(dep_n # Stream request body directly to compute node await node.compute.http_query( - "POST", f"/projects/{node.project.id}/files{path}", - data=request.stream(), timeout=None + "POST", f"/projects/{node.project.id}/files{path}", data=request.stream(), timeout=None ) @router.delete( "/{node_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.Modify"))] + dependencies=[Depends(has_privilege("Node.Modify"))], ) async def delete_node_file(file_path: str, node: Node = Depends(dep_node)) -> None: """ @@ -651,17 +635,14 @@ async def delete_node_file(file_path: str, node: Node = Depends(dep_node)) -> No node_type = node.node_type path = f"/project-files/{node_type}/{node.id}/{path}" - await node.compute.http_query( - "DELETE", f"/projects/{node.project.id}/files{path}", - timeout=None - ) + await node.compute.http_query("DELETE", f"/projects/{node.project.id}/files{path}", timeout=None) @router.websocket("/{node_id}/console/ws") async def ws_console( - websocket: WebSocket, - current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")), - node: Node = Depends(dep_node) + websocket: WebSocket, + current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")), + node: Node = Depends(dep_node), ) -> None: """ WebSocket console. @@ -682,7 +663,7 @@ async def ws_console( # handle IPv6 address ip = ipaddress.ip_address(compute_host) if isinstance(ip, ipaddress.IPv6Address): - compute_host = '[' + compute_host + ']' + compute_host = "[" + compute_host + "]" except ValueError: pass @@ -710,8 +691,7 @@ async def ws_console( except WebSocketDisconnect: pass log.info( - f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller" - f" console WebSocket" + f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller console WebSocket" ) async def ws_send(ws_console_compute): @@ -766,11 +746,12 @@ async def ws_console( except aiohttp.ClientError as e: log.error(f"Client error received when forwarding to compute console WebSocket: {e}") + @router.websocket("/{node_id}/console/vnc") async def vnc_console( - websocket: WebSocket, - current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")), - node: Node = Depends(dep_node) + websocket: WebSocket, + current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")), + node: Node = Depends(dep_node), ) -> None: """ VNC WebSocket console. @@ -791,7 +772,7 @@ async def vnc_console( # handle IPv6 address ip = ipaddress.ip_address(compute_host) if isinstance(ip, ipaddress.IPv6Address): - compute_host = '[' + compute_host + ']' + compute_host = "[" + compute_host + "]" except ValueError: pass @@ -852,7 +833,9 @@ async def vnc_console( else: auth = aiohttp.BasicAuth(user, "") ssl_context = Controller.instance().ssl_context() - async with HTTPClient.get_client().ws_connect(vnc_console_compute_url, auth=auth, ssl_context=ssl_context) as ws: + async with HTTPClient.get_client().ws_connect( + vnc_console_compute_url, auth=auth, ssl_context=ssl_context + ) as ws: tasks = [ asyncio.ensure_future(vnc_receive(ws)), asyncio.ensure_future(vnc_send(ws)), @@ -870,10 +853,9 @@ async def vnc_console( except aiohttp.ClientError as e: log.error(f"Client error received when forwarding to compute VNC console WebSocket: {e}") + @router.post( - "/console/reset", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.Console"))] + "/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.Console"))] ) async def reset_console_all_nodes(project: Project = Depends(dep_project)) -> None: """ @@ -888,7 +870,7 @@ async def reset_console_all_nodes(project: Project = Depends(dep_project)) -> No @router.post( "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Node.Console"))] + dependencies=[Depends(has_privilege("Node.Console"))], ) async def console_reset(node: Node = Depends(dep_node)) -> None: """ diff --git a/gns3server/api/routes/controller/pools.py b/gns3server/api/routes/controller/pools.py index 0d79e39bc..a5ef8e8b5 100644 --- a/gns3server/api/routes/controller/pools.py +++ b/gns3server/api/routes/controller/pools.py @@ -24,11 +24,7 @@ from uuid import UUID from typing import List from gns3server import schemas -from gns3server.controller.controller_error import ( - ControllerError, - ControllerBadRequestError, - ControllerNotFoundError -) +from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError, ControllerNotFoundError from gns3server.controller import Controller from gns3server.db.repositories.rbac import RbacRepository @@ -44,13 +40,9 @@ log = logging.getLogger(__name__) router = APIRouter() -@router.get( - "", - response_model=List[schemas.ResourcePool], - dependencies=[Depends(has_privilege("Pool.Audit"))] -) +@router.get("", response_model=List[schemas.ResourcePool], dependencies=[Depends(has_privilege("Pool.Audit"))]) async def get_resource_pools( - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)) + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), ) -> List[schemas.ResourcePool]: """ Get all resource pools. @@ -65,11 +57,11 @@ async def get_resource_pools( "", response_model=schemas.ResourcePool, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Pool.Allocate"))] + dependencies=[Depends(has_privilege("Pool.Allocate"))], ) async def create_resource_pool( - resource_pool_create: schemas.ResourcePoolCreate, - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)) + resource_pool_create: schemas.ResourcePoolCreate, + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), ) -> schemas.ResourcePool: """ Create a new resource pool @@ -78,19 +70,16 @@ async def create_resource_pool( """ if await pools_repo.get_resource_pool_by_name(resource_pool_create.name): - raise ControllerBadRequestError(f"Resource pool '{resource_pool_create.name}' already exists") + raise ControllerBadRequestError(f"Resource pool '{resource_pool_create.name}' already exists") return await pools_repo.create_resource_pool(resource_pool_create) @router.get( - "/{resource_pool_id}", - response_model=schemas.ResourcePool, - dependencies=[Depends(has_privilege("Pool.Audit"))] + "/{resource_pool_id}", response_model=schemas.ResourcePool, dependencies=[Depends(has_privilege("Pool.Audit"))] ) async def get_resource_pool( - resource_pool_id: UUID, - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)) + resource_pool_id: UUID, pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)) ) -> schemas.ResourcePool: """ Get a resource pool. @@ -105,14 +94,12 @@ async def get_resource_pool( @router.put( - "/{resource_pool_id}", - response_model=schemas.ResourcePool, - dependencies=[Depends(has_privilege("Pool.Modify"))] + "/{resource_pool_id}", response_model=schemas.ResourcePool, dependencies=[Depends(has_privilege("Pool.Modify"))] ) async def update_resource_pool( - resource_pool_id: UUID, - resource_pool_update: schemas.ResourcePoolUpdate, - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)) + resource_pool_id: UUID, + resource_pool_update: schemas.ResourcePoolUpdate, + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), ) -> schemas.ResourcePool: """ Update a resource pool. @@ -130,12 +117,12 @@ async def update_resource_pool( @router.delete( "/{resource_pool_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Pool.Allocate"))] + dependencies=[Depends(has_privilege("Pool.Allocate"))], ) async def delete_resource_pool( - resource_pool_id: UUID, - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + resource_pool_id: UUID, + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a resource pool. @@ -184,11 +171,11 @@ async def delete_resource_pool( @router.get( "/{resource_pool_id}/resources", response_model=List[schemas.Resource], - dependencies=[Depends(has_privilege("Pool.Audit"))] + dependencies=[Depends(has_privilege("Pool.Audit"))], ) async def get_pool_resources( - resource_pool_id: UUID, - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), + resource_pool_id: UUID, + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), ) -> List[schemas.Resource]: """ Get all resource in a pool. @@ -202,12 +189,12 @@ async def get_pool_resources( @router.put( "/{resource_pool_id}/resources/{resource_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Pool.Modify"))] + dependencies=[Depends(has_privilege("Pool.Modify"))], ) async def add_resource_to_pool( - resource_pool_id: UUID, - resource_id: UUID, - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), + resource_pool_id: UUID, + resource_id: UUID, + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), ) -> None: """ Add resource to a resource pool. @@ -240,7 +227,7 @@ async def add_resource_to_pool( @router.delete( "/{resource_pool_id}/resources/{resource_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Pool.Modify"))] + dependencies=[Depends(has_privilege("Pool.Modify"))], ) async def remove_resource_from_pool( resource_pool_id: UUID, diff --git a/gns3server/api/routes/controller/privileges.py b/gns3server/api/routes/controller/privileges.py index 45d250e60..a070a6ee6 100644 --- a/gns3server/api/routes/controller/privileges.py +++ b/gns3server/api/routes/controller/privileges.py @@ -32,7 +32,7 @@ router = APIRouter() response_model=List[schemas.Privilege], ) async def get_privileges( - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> List[schemas.Privilege]: """ Get all privileges. diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 26fb7d042..19c29eead 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -71,14 +71,10 @@ def dep_project(project_id: UUID) -> Project: return project -@router.get( - "", - response_model=List[schemas.Project], - response_model_exclude_unset=True -) +@router.get("", response_model=List[schemas.Project], response_model_exclude_unset=True) async def get_projects( - current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + current_user: schemas.User = Depends(get_current_active_user), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> List[schemas.Project]: """ Return all projects. @@ -124,11 +120,11 @@ async def get_projects( response_model=schemas.Project, response_model_exclude_unset=True, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}}, - dependencies=[Depends(has_privilege("Project.Allocate"))] + dependencies=[Depends(has_privilege("Project.Allocate"))], ) async def create_project( - project_data: schemas.ProjectCreate, - current_user: schemas.User = Depends(get_current_active_user), + project_data: schemas.ProjectCreate, + current_user: schemas.User = Depends(get_current_active_user), ) -> schemas.Project: """ Create a new project. @@ -158,11 +154,10 @@ def get_project(project: Project = Depends(dep_project)) -> schemas.Project: "/{project_id}", response_model=schemas.Project, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) async def update_project( - project_data: schemas.ProjectUpdate, - project: Project = Depends(dep_project) + project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project) ) -> schemas.Project: """ Update a project. @@ -175,13 +170,11 @@ async def update_project( @router.delete( - "/{project_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Allocate"))] + "/{project_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Project.Allocate"))] ) async def delete_project( - project: Project = Depends(dep_project), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + project: Project = Depends(dep_project), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a project. @@ -272,9 +265,7 @@ async def replay_tag_range( Required privilege: Project.Audit """ - return await _replay_response( - marker_replay.build_timeline(project, tag, filter_expr=filter, link_id=link) - ) + return await _replay_response(marker_replay.build_timeline(project, tag, filter_expr=filter, link_id=link)) @router.get( @@ -306,8 +297,13 @@ async def replay_tag_frames( return await _replay_response( marker_replay.query_frames( - project, tag, ts, window_ms=window_ms, limit=limit, - filter_expr=filter, link_id=link, + project, + tag, + ts, + window_ms=window_ms, + limit=limit, + filter_expr=filter, + link_id=link, ) ) @@ -343,9 +339,7 @@ async def replay_tag_frame_detail( """ return await _replay_response( - marker_replay.decode_frame( - project, tag, ts, node_id, link_id, marker, frame_number=frame_number - ) + marker_replay.decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=frame_number) ) @@ -353,10 +347,8 @@ async def replay_tag_frame_detail( # Project-level marker definitions (global rules inherited by every link) # --------------------------------------------------------------------------- -@router.get( - "/{project_id}/marker-definitions", - dependencies=[Depends(has_privilege("Project.Audit"))] -) + +@router.get("/{project_id}/marker-definitions", dependencies=[Depends(has_privilege("Project.Audit"))]) def get_marker_definitions(project: Project = Depends(dep_project)) -> dict: """ Return all project-level marker definitions with their bound link IDs. @@ -368,9 +360,9 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict: for name, d in project.marker_definitions.items(): # Collect which links currently carry an inherited copy. bound = [ - lid for lid, link in project.links.items() - if f"global-{name}" in link.markers - and link.markers[f"global-{name}"].get("inherited_from") == name + lid + for lid, link in project.links.items() + if f"global-{name}" in link.markers and link.markers[f"global-{name}"].get("inherited_from") == name ] result[name] = {**d, "link_ids": bound} return result @@ -379,11 +371,10 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict: @router.post( "/{project_id}/marker-definitions", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) async def create_marker_definition( - def_data: schemas.MarkerDefinitionCreate, - project: Project = Depends(dep_project) + def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project) ) -> dict: """ Create a project-level marker definition and fan out to every link. @@ -406,14 +397,9 @@ async def create_marker_definition( return project.marker_definitions.get(name, {}) -@router.put( - "/{project_id}/marker-definitions/{def_name}", - dependencies=[Depends(has_privilege("Project.Modify"))] -) +@router.put("/{project_id}/marker-definitions/{def_name}", dependencies=[Depends(has_privilege("Project.Modify"))]) async def update_marker_definition( - def_name: str, - def_data: schemas.MarkerDefinitionCreate, - project: Project = Depends(dep_project) + def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project) ) -> dict: """ Update a marker definition and sync all inherited copies on every link. @@ -436,12 +422,9 @@ async def update_marker_definition( @router.post( "/{project_id}/marker-definitions/{def_name}/pause", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) -async def pause_marker_definition( - def_name: str, - project: Project = Depends(dep_project) -) -> None: +async def pause_marker_definition(def_name: str, project: Project = Depends(dep_project)) -> None: """ Pause a definition: toggle off every inherited ``global-{def_name}`` copy on every link (uBridge ``enable_packet_filter off``, instant — no NIO @@ -457,12 +440,9 @@ async def pause_marker_definition( @router.post( "/{project_id}/marker-definitions/{def_name}/resume", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) -async def resume_marker_definition( - def_name: str, - project: Project = Depends(dep_project) -) -> None: +async def resume_marker_definition(def_name: str, project: Project = Depends(dep_project)) -> None: """Resume a paused definition (toggle on every inherited copy). Required privilege: Project.Modify @@ -474,12 +454,9 @@ async def resume_marker_definition( @router.delete( "/{project_id}/marker-definitions/{def_name}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) -async def delete_marker_definition( - def_name: str, - project: Project = Depends(dep_project) -) -> None: +async def delete_marker_definition(def_name: str, project: Project = Depends(dep_project)) -> None: """ Delete a marker definition and remove all inherited copies from every link. @@ -493,7 +470,7 @@ async def delete_marker_definition( "/{project_id}/close", status_code=status.HTTP_204_NO_CONTENT, responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not close project"}}, - dependencies=[Depends(has_privilege("Project.Allocate"))] + dependencies=[Depends(has_privilege("Project.Allocate"))], ) async def close_project(project: Project = Depends(dep_project)) -> None: """ @@ -510,7 +487,7 @@ async def close_project(project: Project = Depends(dep_project)) -> None: status_code=status.HTTP_201_CREATED, response_model=schemas.Project, responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open project"}}, - dependencies=[Depends(has_privilege("Project.Allocate"))] + dependencies=[Depends(has_privilege("Project.Allocate"))], ) async def open_project(project: Project = Depends(dep_project)) -> schemas.Project: """ @@ -528,7 +505,7 @@ async def open_project(project: Project = Depends(dep_project)) -> schemas.Proje status_code=status.HTTP_201_CREATED, response_model=schemas.Project, responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not load project"}}, - dependencies=[Depends(has_privilege("Project.Allocate"))] + dependencies=[Depends(has_privilege("Project.Allocate"))], ) async def load_project(path: str = Body(..., embed=True)) -> schemas.Project: """ @@ -552,6 +529,7 @@ async def project_http_notifications(project_id: UUID) -> StreamingResponse: """ from gns3server.api.server import app + controller = Controller.instance() project = controller.get_project(str(project_id)) @@ -579,9 +557,9 @@ async def project_http_notifications(project_id: UUID) -> StreamingResponse: @router.websocket("/{project_id}/notifications/ws") async def project_ws_notifications( - project_id: UUID, - websocket: WebSocket, - current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit")) + project_id: UUID, + websocket: WebSocket, + current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit")), ) -> None: """ Receive project notifications about the controller from WebSocket. @@ -617,9 +595,9 @@ async def project_ws_notifications( @router.websocket("/{project_id}/notifications/markers/ws") async def project_marker_ws_notifications( - project_id: UUID, - websocket: WebSocket, - current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit")) + project_id: UUID, + websocket: WebSocket, + current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit")), ) -> None: """ Receive marker notifications (e.g. marker.match) for a project on a @@ -635,14 +613,18 @@ async def project_marker_ws_notifications( controller = Controller.instance() project = controller.get_project(str(project_id)) - log.info(f"New client has connected to the marker notification stream for project ID '{project.id}' (WebSocket method)") + log.info( + f"New client has connected to the marker notification stream for project ID '{project.id}' (WebSocket method)" + ) try: with controller.notification.project_marker_queue(project.id) as queue: while True: notification = await queue.get_json(5) await websocket.send_text(notification) except (ConnectionClosed, WebSocketDisconnect): - log.info(f"Client has disconnected from the marker notification stream for project ID '{project.id}' (WebSocket method)") + log.info( + f"Client has disconnected from the marker notification stream for project ID '{project.id}' (WebSocket method)" + ) except WebSocketException as e: log.warning(f"Error while sending marker event to WebSocket client: {e}") @@ -685,7 +667,9 @@ async def export_project( raise ControllerBadRequestError("Compression level must be between 1 and 22 for Zstandard compression") if compression_level is not None and compression_query in ("none", "lzma"): - raise ControllerBadRequestError(f"Compression level is not supported for '{compression_query}' compression method") + raise ControllerBadRequestError( + f"Compression level is not supported for '{compression_query}' compression method" + ) try: begin = time.time() @@ -693,8 +677,9 @@ async def export_project( working_dir = os.path.abspath(os.path.join(project.path, os.pardir)) async def streamer(): - log.info(f"Exporting project '{project.name}' with '{compression_query}' compression " - f"(level {compression_level})") + log.info( + f"Exporting project '{project.name}' with '{compression_query}' compression (level {compression_level})" + ) with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir: with aiozipstream.ZipFile(compression=compression, compresslevel=compression_level) as zstream: await export_controller_project( @@ -720,7 +705,7 @@ async def export_project( encoded = urllib.parse.quote(project.name, safe="") headers = { "Content-Disposition": ( - f'attachment; filename="{fallback}.gns3project"; filename*=UTF-8\'\'{encoded}.gns3project' + f"attachment; filename=\"{fallback}.gns3project\"; filename*=UTF-8''{encoded}.gns3project" ) } return StreamingResponse(streamer(), media_type="application/gns3project", headers=headers) @@ -730,13 +715,9 @@ async def export_project( "/{project_id}/import", status_code=status.HTTP_201_CREATED, response_model=schemas.Project, - dependencies=[Depends(has_privilege("Project.Allocate"))] + dependencies=[Depends(has_privilege("Project.Allocate"))], ) -async def import_project( - project_id: UUID, - request: Request, - name: Optional[str] = None -) -> schemas.Project: +async def import_project(project_id: UUID, request: Request, name: Optional[str] = None) -> schemas.Project: """ Import a project from a portable archive. @@ -768,14 +749,14 @@ async def import_project( status_code=status.HTTP_201_CREATED, response_model=schemas.Project, responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not duplicate project"}}, - dependencies=[Depends(has_privilege("Project.Audit"))] + dependencies=[Depends(has_privilege("Project.Audit"))], ) async def duplicate_project( - project_data: schemas.ProjectDuplicate, - project: Project = Depends(dep_project), - current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), - pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)) + project_data: schemas.ProjectDuplicate, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)), ) -> schemas.Project: """ Duplicate a project. @@ -790,22 +771,26 @@ async def duplicate_project( can_be_duplicated_somewhere = False if pool_memberships: for pool in pool_memberships: - if await rbac_repo.check_user_has_privilege(current_user.user_id, f"/pools/{pool.resource_pool_id}", "Project.Allocate"): + if await rbac_repo.check_user_has_privilege( + current_user.user_id, f"/pools/{pool.resource_pool_id}", "Project.Allocate" + ): can_be_duplicated_somewhere = True break - if not can_be_duplicated_somewhere and not await rbac_repo.check_user_has_privilege(current_user.user_id, "/projects", "Project.Allocate"): + if not can_be_duplicated_somewhere and not await rbac_repo.check_user_has_privilege( + current_user.user_id, "/projects", "Project.Allocate" + ): log.warning(f"Project {project.name} cannot be duplicated anywhere") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) reset_mac_addresses = project_data.reset_mac_addresses - new_project = await project.duplicate( - name=project_data.name, reset_mac_addresses=reset_mac_addresses - ) + new_project = await project.duplicate(name=project_data.name, reset_mac_addresses=reset_mac_addresses) # Add the new project in the same resource pools if the duplicated project belongs to any if pool_memberships: - resource_create = schemas.ResourceCreate(resource_id=new_project.id, resource_type="project", name=new_project.name) + resource_create = schemas.ResourceCreate( + resource_id=new_project.id, resource_type="project", name=new_project.name + ) resource = await pools_repo.create_resource(resource_create) for pool in pool_memberships: await pools_repo.add_resource_to_pool(pool.resource_pool_id, resource) @@ -827,7 +812,7 @@ async def locked_project(project: Project = Depends(dep_project)) -> bool: @router.post( "/{project_id}/lock", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) async def lock_project(project: Project = Depends(dep_project)) -> None: """ @@ -842,7 +827,7 @@ async def lock_project(project: Project = Depends(dep_project)) -> None: @router.post( "/{project_id}/unlock", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) async def unlock_project(project: Project = Depends(dep_project)) -> None: """ @@ -894,7 +879,7 @@ async def get_project_gns3_file(project: Project = Depends(dep_project)) -> File @router.post( "/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Project.Modify"))] + dependencies=[Depends(has_privilege("Project.Modify"))], ) async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None: """ @@ -929,7 +914,7 @@ async def write_file(file_path: str, request: Request, project: Project = Depend response_model=schemas.Node, status_code=status.HTTP_201_CREATED, responses={404: {"model": schemas.ErrorMessage, "description": "Could not find project or template"}}, - dependencies=[Depends(has_privilege("Node.Allocate"))] + dependencies=[Depends(has_privilege("Node.Allocate"))], ) async def create_node_from_template( project_id: UUID, diff --git a/gns3server/api/routes/controller/roles.py b/gns3server/api/routes/controller/roles.py index f1a5434f1..aab6e5a00 100644 --- a/gns3server/api/routes/controller/roles.py +++ b/gns3server/api/routes/controller/roles.py @@ -42,14 +42,8 @@ log = logging.getLogger(__name__) router = APIRouter() -@router.get( - "", - response_model=List[schemas.Role], - dependencies=[Depends(has_privilege("Role.Audit"))] -) -async def get_roles( - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) -) -> List[schemas.Role]: +@router.get("", response_model=List[schemas.Role], dependencies=[Depends(has_privilege("Role.Audit"))]) +async def get_roles(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[schemas.Role]: """ Get all roles. @@ -63,11 +57,10 @@ async def get_roles( "", response_model=schemas.Role, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Role.Allocate"))] + dependencies=[Depends(has_privilege("Role.Allocate"))], ) async def create_role( - role_create: schemas.RoleCreate, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + role_create: schemas.RoleCreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> schemas.Role: """ Create a new role. @@ -81,14 +74,10 @@ async def create_role( return await rbac_repo.create_role(role_create) -@router.get( - "/{role_id}", - response_model=schemas.Role, - dependencies=[Depends(has_privilege("Role.Audit"))] -) +@router.get("/{role_id}", response_model=schemas.Role, dependencies=[Depends(has_privilege("Role.Audit"))]) async def get_role( - role_id: UUID, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + role_id: UUID, + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> schemas.Role: """ Get a role. @@ -102,15 +91,9 @@ async def get_role( return role -@router.put( - "/{role_id}", - response_model=schemas.Role, - dependencies=[Depends(has_privilege("Role.Modify"))] -) +@router.put("/{role_id}", response_model=schemas.Role, dependencies=[Depends(has_privilege("Role.Modify"))]) async def update_role( - role_id: UUID, - role_update: schemas.RoleUpdate, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + role_id: UUID, role_update: schemas.RoleUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> schemas.Role: """ Update a role. @@ -129,9 +112,7 @@ async def update_role( @router.delete( - "/{role_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Role.Allocate"))] + "/{role_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Role.Allocate"))] ) async def delete_role( role_id: UUID, @@ -157,13 +138,10 @@ async def delete_role( @router.get( - "/{role_id}/privileges", - response_model=List[schemas.Privilege], - dependencies=[Depends(has_privilege("Role.Audit"))] + "/{role_id}/privileges", response_model=List[schemas.Privilege], dependencies=[Depends(has_privilege("Role.Audit"))] ) async def get_role_privileges( - role_id: UUID, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + role_id: UUID, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> List[schemas.Privilege]: """ Get all role privileges. @@ -177,12 +155,10 @@ async def get_role_privileges( @router.put( "/{role_id}/privileges/{privilege_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Role.Modify"))] + dependencies=[Depends(has_privilege("Role.Modify"))], ) async def add_privilege_to_role( - role_id: UUID, - privilege_id: UUID, - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + role_id: UUID, privilege_id: UUID, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ) -> None: """ Add a privilege to a role. @@ -202,7 +178,7 @@ async def add_privilege_to_role( @router.delete( "/{role_id}/privileges/{privilege_id}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Role.Modify"))] + dependencies=[Depends(has_privilege("Role.Modify"))], ) async def remove_privilege_from_role( role_id: UUID, diff --git a/gns3server/api/routes/controller/settings.py b/gns3server/api/routes/controller/settings.py index 9924435ac..466edc726 100644 --- a/gns3server/api/routes/controller/settings.py +++ b/gns3server/api/routes/controller/settings.py @@ -44,35 +44,37 @@ router = APIRouter() # - port ranges are read once by the PortManager singleton # - default admin credentials are only used to seed the users database # - builtin templates/appliances and the skills repository are installed at startup -RESTART_REQUIRED = frozenset({ - "Server.host", - "Server.port", - "Server.protocol", - "Server.enable_ssl", - "Server.certfile", - "Server.certkey", - "Server.secrets_dir", - "Server.images_path", - "Server.projects_path", - "Server.appliances_path", - "Server.symbols_path", - "Server.configs_path", - "Server.resources_path", - "Server.console_start_port_range", - "Server.console_end_port_range", - "Server.vnc_console_start_port_range", - "Server.vnc_console_end_port_range", - "Server.udp_start_port_range", - "Server.udp_end_port_range", - "Server.enable_builtin_templates", - "Server.install_builtin_appliances", - "Server.skills_repo_url", - "Server.skills_repo_branch", - "Server.skills_auto_update", - "Server.ubridge_path", - "Controller.default_admin_username", - "Controller.default_admin_password", -}) +RESTART_REQUIRED = frozenset( + { + "Server.host", + "Server.port", + "Server.protocol", + "Server.enable_ssl", + "Server.certfile", + "Server.certkey", + "Server.secrets_dir", + "Server.images_path", + "Server.projects_path", + "Server.appliances_path", + "Server.symbols_path", + "Server.configs_path", + "Server.resources_path", + "Server.console_start_port_range", + "Server.console_end_port_range", + "Server.vnc_console_start_port_range", + "Server.vnc_console_end_port_range", + "Server.udp_start_port_range", + "Server.udp_end_port_range", + "Server.enable_builtin_templates", + "Server.install_builtin_appliances", + "Server.skills_repo_url", + "Server.skills_repo_branch", + "Server.skills_auto_update", + "Server.ubridge_path", + "Controller.default_admin_username", + "Controller.default_admin_password", + } +) # never expose these sections (deprecated) nor the secret managed outside # the configuration file; must match the response model in schemas.controller.settings @@ -89,9 +91,12 @@ def _current_settings_response() -> dict: return settings.model_dump(mode="json", exclude=_DUMP_EXCLUDE) -@router.get("", response_model=schemas.SettingsResponse, - dependencies=[Depends(has_privilege("Server.Audit"))], - responses={401: {"model": schemas.ErrorMessage}, 403: {"model": schemas.ErrorMessage}}) +@router.get( + "", + response_model=schemas.SettingsResponse, + dependencies=[Depends(has_privilege("Server.Audit"))], + responses={401: {"model": schemas.ErrorMessage}, 403: {"model": schemas.ErrorMessage}}, +) async def get_server_settings() -> schemas.SettingsResponse: """ Return the server settings. @@ -103,15 +108,18 @@ async def get_server_settings() -> schemas.SettingsResponse: return schemas.SettingsResponse.model_validate(_current_settings_response()) -@router.put("", response_model=schemas.SettingsUpdateResponse, - dependencies=[Depends(has_privilege("Server.Modify"))], - responses={ - 400: {"model": schemas.ErrorMessage}, - 401: {"model": schemas.ErrorMessage}, - 403: {"model": schemas.ErrorMessage}, - 409: {"model": schemas.ErrorMessage}, - 422: {"model": schemas.ErrorMessage}, - }) +@router.put( + "", + response_model=schemas.SettingsUpdateResponse, + dependencies=[Depends(has_privilege("Server.Modify"))], + responses={ + 400: {"model": schemas.ErrorMessage}, + 401: {"model": schemas.ErrorMessage}, + 403: {"model": schemas.ErrorMessage}, + 409: {"model": schemas.ErrorMessage}, + 422: {"model": schemas.ErrorMessage}, + }, +) async def update_server_settings(settings_update: schemas.SettingsUpdate) -> schemas.SettingsUpdateResponse: """ Update the server settings and persist them to the configuration file. @@ -122,9 +130,7 @@ async def update_server_settings(settings_update: schemas.SettingsUpdate) -> sch """ changes = { - section: options - for section, options in settings_update.model_dump(exclude_unset=True).items() - if options + section: options for section, options in settings_update.model_dump(exclude_unset=True).items() if options } # masked or empty secrets mean "unchanged": never write them back @@ -153,8 +159,7 @@ async def update_server_settings(settings_update: schemas.SettingsUpdate) -> sch controller = Controller.instance() if controller is not None: controller.notification.controller_emit( - "settings.updated", - {"changed": changed, "restart_required": restart_required} + "settings.updated", {"changed": changed, "restart_required": restart_required} ) data = _current_settings_response() diff --git a/gns3server/api/routes/controller/snapshots.py b/gns3server/api/routes/controller/snapshots.py index 37d9f7771..f85b5d9ef 100644 --- a/gns3server/api/routes/controller/snapshots.py +++ b/gns3server/api/routes/controller/snapshots.py @@ -53,11 +53,10 @@ def dep_project(project_id: UUID) -> Project: "", status_code=status.HTTP_201_CREATED, response_model=schemas.Snapshot, - dependencies=[Depends(has_privilege("Snapshot.Allocate"))] + dependencies=[Depends(has_privilege("Snapshot.Allocate"))], ) async def create_snapshot( - snapshot_data: schemas.SnapshotCreate, - project: Project = Depends(dep_project) + snapshot_data: schemas.SnapshotCreate, project: Project = Depends(dep_project) ) -> schemas.Snapshot: """ Create a new snapshot of a project. @@ -73,7 +72,7 @@ async def create_snapshot( "", response_model=List[schemas.Snapshot], response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Snapshot.Audit"))] + dependencies=[Depends(has_privilege("Snapshot.Audit"))], ) def get_snapshots(project: Project = Depends(dep_project)) -> List[schemas.Snapshot]: """ @@ -87,14 +86,10 @@ def get_snapshots(project: Project = Depends(dep_project)) -> List[schemas.Snaps @router.delete( - "/{snapshot_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Snapshot.Allocate"))] + "/{snapshot_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Snapshot.Allocate"))] ) async def delete_snapshot( - snapshot_id: UUID, - project: Project = Depends(dep_project), - rbac_repo=Depends(get_repository(RbacRepository)) + snapshot_id: UUID, project: Project = Depends(dep_project), rbac_repo=Depends(get_repository(RbacRepository)) ) -> None: """ Delete a snapshot. @@ -110,7 +105,7 @@ async def delete_snapshot( "/{snapshot_id}/restore", status_code=status.HTTP_201_CREATED, response_model=schemas.Project, - dependencies=[Depends(has_privilege("Snapshot.Restore"))] + dependencies=[Depends(has_privilege("Snapshot.Restore"))], ) async def restore_snapshot(snapshot_id: UUID, project: Project = Depends(dep_project)) -> schemas.Project: """ diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py index 12c36d00c..5f004ffa6 100644 --- a/gns3server/api/routes/controller/symbols.py +++ b/gns3server/api/routes/controller/symbols.py @@ -40,10 +40,7 @@ log = logging.getLogger(__name__) router = APIRouter() -@router.get( - "", - dependencies=[Depends(has_privilege("Symbol.Audit"))] -) +@router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))]) def get_symbols() -> List[dict]: """ Return all symbols. @@ -58,7 +55,7 @@ def get_symbols() -> List[dict]: @router.get( "/{symbol_id:path}/raw", responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}}, - dependencies=[Depends(has_privilege("Symbol.Audit"))] + dependencies=[Depends(has_privilege("Symbol.Audit"))], ) async def get_symbol(symbol_id: str, request: Request) -> Response: """ @@ -74,7 +71,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response: symbol, headers={ "Access-Control-Allow-Origin": request.headers.get("origin", "*"), - } + }, ) except (KeyError, OSError) as e: raise ControllerNotFoundError(f"Could not get symbol file: {e}") @@ -83,7 +80,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response: @router.get( "/{symbol_id:path}/dimensions", responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}}, - dependencies=[Depends(has_privilege("Symbol.Audit"))] + dependencies=[Depends(has_privilege("Symbol.Audit"))], ) async def get_symbol_dimensions(symbol_id: str) -> dict: """ @@ -116,7 +113,7 @@ def get_default_symbols() -> dict: @router.post( "/{symbol_id:path}/raw", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Symbol.Allocate"))] + dependencies=[Depends(has_privilege("Symbol.Allocate"))], ) async def upload_symbol(symbol_id: str, request: Request) -> None: """ @@ -141,7 +138,7 @@ async def upload_symbol(symbol_id: str, request: Request) -> None: @router.delete( "/{symbol_id:path}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Symbol.Allocate"))] + dependencies=[Depends(has_privilege("Symbol.Allocate"))], ) async def delete_symbol(symbol_id: str) -> None: """ diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index 6d59cd913..84d55d693 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -52,11 +52,11 @@ router = APIRouter(responses=responses) "", response_model=schemas.Template, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Template.Allocate"))] + dependencies=[Depends(has_privilege("Template.Allocate"))], ) async def create_template( template_create: schemas.TemplateCreate, - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), ) -> schemas.Template: """ Create a new template. @@ -73,7 +73,7 @@ async def create_template( response_model=schemas.Template, response_model_exclude_unset=True, dependencies=[Depends(get_current_active_user)], - #dependencies=[Depends(has_privilege("Template.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui + # dependencies=[Depends(has_privilege("Template.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui ) async def get_template( template_id: UUID, @@ -102,7 +102,7 @@ async def get_template( "/{template_id}", response_model=schemas.Template, response_model_exclude_unset=True, - dependencies=[Depends(has_privilege("Template.Modify"))] + dependencies=[Depends(has_privilege("Template.Modify"))], ) async def update_template( template_id: UUID, @@ -119,16 +119,14 @@ async def update_template( @router.delete( - "/{template_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("Template.Allocate"))] + "/{template_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Template.Allocate"))] ) async def delete_template( - template_id: UUID, - prune_images: Optional[bool] = False, - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), - images_repo: RbacRepository = Depends(get_repository(ImagesRepository)), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + template_id: UUID, + prune_images: Optional[bool] = False, + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), + images_repo: RbacRepository = Depends(get_repository(ImagesRepository)), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a template. @@ -152,7 +150,8 @@ async def delete_template( # the template being deleted is still in the database at this # point, exclude it from the other-templates check other_templates = [ - template for template in await images_repo.get_image_templates(image.image_id) + template + for template in await images_repo.get_image_templates(image.image_id) if str(template.template_id) != str(template_id) ] if other_templates: @@ -163,7 +162,9 @@ async def delete_template( referenced_filenames = controller.collect_referenced_image_filenames() if image.filename in referenced_filenames: project_names = controller.find_projects_using_image(image.filename) - raise ControllerError(f"Image '{image.path}' is used by one or more projects: {', '.join(project_names)}") + raise ControllerError( + f"Image '{image.path}' is used by one or more projects: {', '.join(project_names)}" + ) images_to_prune.append(image) await TemplatesService(templates_repo).delete_template(template_id) @@ -186,12 +187,12 @@ async def delete_template( response_model=List[schemas.Template], response_model_exclude_unset=True, dependencies=[Depends(get_current_active_user)], - #dependencies=[Depends(has_privilege("Template.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui + # dependencies=[Depends(has_privilege("Template.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui ) async def get_templates( - templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), - current_user: schemas.User = Depends(get_current_active_user), - tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") + templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), + current_user: schemas.User = Depends(get_current_active_user), + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"), ) -> List[schemas.Template]: """ Return all templates. @@ -239,10 +240,10 @@ async def get_templates( "/{template_id}/duplicate", response_model=schemas.Template, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("Template.Allocate"))] + dependencies=[Depends(has_privilege("Template.Allocate"))], ) async def duplicate_template( - template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) + template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) ) -> schemas.Template: """ Duplicate a template. @@ -253,10 +254,8 @@ async def duplicate_template( template = await TemplatesService(templates_repo).duplicate_template(template_id) return template -@router.get( - "/{template_id}/base-config/{filename}", - dependencies=[Depends(has_privilege("Template.Audit"))] -) + +@router.get("/{template_id}/base-config/{filename}", dependencies=[Depends(has_privilege("Template.Audit"))]) async def get_base_config( template_id: UUID, filename: str, @@ -267,17 +266,10 @@ async def get_base_config( await service.get_template(template_id) content = service.get_file(str(template_id), filename) - return { - "template_id": str(template_id), - "filename": os.path.basename(filename), - "content": content - } + return {"template_id": str(template_id), "filename": os.path.basename(filename), "content": content} -@router.put( - "/{template_id}/base-config/{filename}", - dependencies=[Depends(has_privilege("Template.Modify"))] -) +@router.put("/{template_id}/base-config/{filename}", dependencies=[Depends(has_privilege("Template.Modify"))]) async def update_base_config( template_id: UUID, filename: str, @@ -291,17 +283,10 @@ async def update_base_config( await service.get_template(template_id) service.update_file(str(template_id), filename, body["content"]) - return { - "template_id": str(template_id), - "filename": os.path.basename(filename), - "content": body["content"] - } + return {"template_id": str(template_id), "filename": os.path.basename(filename), "content": body["content"]} -@router.get( - "/{template_id}/base-configs", - dependencies=[Depends(has_privilege("Template.Audit"))] -) +@router.get("/{template_id}/base-configs", dependencies=[Depends(has_privilege("Template.Audit"))]) async def list_base_configs( template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py index 97fedd47f..1f834d712 100644 --- a/gns3server/api/routes/controller/users.py +++ b/gns3server/api/routes/controller/users.py @@ -144,8 +144,8 @@ async def refresh_access_token( @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout( - current_user: schemas.User = Depends(get_current_active_user), - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + current_user: schemas.User = Depends(get_current_active_user), + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> None: """ Logout the current user by revoking all existing tokens. @@ -165,9 +165,9 @@ async def get_logged_in_user(current_user: schemas.User = Depends(get_current_ac @router.put("/me", response_model=schemas.User) async def update_logged_in_user( - user_update: schemas.LoggedInUserUpdate, - current_user: schemas.User = Depends(get_current_active_user), - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_update: schemas.LoggedInUserUpdate, + current_user: schemas.User = Depends(get_current_active_user), + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> schemas.User: """ Update the current active user. @@ -179,14 +179,8 @@ async def update_logged_in_user( return await users_repo.update_user(current_user.user_id, user_update) -@router.get( - "", - response_model=List[schemas.User], - dependencies=[Depends(has_privilege("User.Audit"))] -) -async def get_users( - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) -) -> List[schemas.User]: +@router.get("", response_model=List[schemas.User], dependencies=[Depends(has_privilege("User.Audit"))]) +async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRepository))) -> List[schemas.User]: """ Get all users. @@ -200,11 +194,10 @@ async def get_users( "", response_model=schemas.User, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(has_privilege("User.Allocate"))] + dependencies=[Depends(has_privilege("User.Allocate"))], ) async def create_user( - user_create: schemas.UserCreate, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_create: schemas.UserCreate, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) ) -> schemas.User: """ Create a new user. @@ -221,14 +214,10 @@ async def create_user( return await users_repo.create_user(user_create) -@router.get( - "/{user_id}", - response_model=schemas.User, - dependencies=[Depends(has_privilege("User.Audit"))] -) +@router.get("/{user_id}", response_model=schemas.User, dependencies=[Depends(has_privilege("User.Audit"))]) async def get_user( - user_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + user_id: UUID, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> schemas.User: """ Get a user. @@ -242,15 +231,11 @@ async def get_user( return user -@router.put( - "/{user_id}", - response_model=schemas.User, - dependencies=[Depends(has_privilege("User.Modify"))] -) +@router.put("/{user_id}", response_model=schemas.User, dependencies=[Depends(has_privilege("User.Modify"))]) async def update_user( - user_id: UUID, - user_update: schemas.UserUpdate, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_id: UUID, + user_update: schemas.UserUpdate, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> schemas.User: """ Update a user. @@ -271,14 +256,12 @@ async def update_user( @router.delete( - "/{user_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(has_privilege("User.Allocate"))] + "/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("User.Allocate"))] ) async def delete_user( - user_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + user_id: UUID, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), ) -> None: """ Delete a user. @@ -300,13 +283,10 @@ async def delete_user( @router.get( - "/{user_id}/groups", - response_model=List[schemas.UserGroup], - dependencies=[Depends(has_privilege("Group.Audit"))] + "/{user_id}/groups", response_model=List[schemas.UserGroup], dependencies=[Depends(has_privilege("Group.Audit"))] ) async def get_user_memberships( - user_id: UUID, - users_repo: UsersRepository = Depends(get_repository(UsersRepository)) + user_id: UUID, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) ) -> List[schemas.UserGroup]: """ Get user memberships. diff --git a/gns3server/api/server.py b/gns3server/api/server.py index 3b3218380..3eaade6e8 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -40,7 +40,7 @@ from gns3server.controller.controller_error import ( ControllerTimeoutError, ControllerForbiddenError, ControllerUnauthorizedError, - ComputeConflictError + ComputeConflictError, ) from gns3server.api.routes import controller, index @@ -52,6 +52,7 @@ from gns3server.agent import MCP_AVAILABLE if MCP_AVAILABLE: from gns3server.agent import mcp + _mcp_router = mcp.router else: from fastapi import APIRouter @@ -62,9 +63,10 @@ else: async def mcp_not_available(path: str = ""): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="MCP is not available. Install AI dependencies with: pip install gns3-server[ai-features]" + detail="MCP is not available. Install AI dependencies with: pip install gns3-server[ai-features]", ) + import logging log = logging.getLogger(__name__) @@ -78,7 +80,7 @@ def get_application() -> FastAPI: description="This page describes the public controller API for GNS3", version="3.0.0", docs_url=None, - redoc_url=None + redoc_url=None, ) application.add_middleware( @@ -91,7 +93,7 @@ def get_application() -> FastAPI: application.include_router(index.router, tags=["Index"]) application.include_router(controller.router, prefix="/v3") - application.mount("/static", StaticFiles(packages=[('gns3server', 'static')], html=True), name="static") + application.mount("/static", StaticFiles(packages=[("gns3server", "static")], html=True), name="static") application.mount("/v3/compute", compute_api, name="compute") # Register MCP routes (stub returns 501 if MCP dependencies are not installed) @@ -118,6 +120,7 @@ def handle_exit(*args, **kwargs): UvicornServer.handle_exit = handle_exit + # Configure self-hosting JavaScript and CSS for docs @app.get("/docs", include_in_schema=False) async def custom_swagger_ui_html(): @@ -127,9 +130,10 @@ async def custom_swagger_ui_html(): oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, swagger_js_url="/static/swagger-ui-bundle.js", swagger_css_url="/static/swagger-ui.css", - swagger_favicon_url="/static/favicon.ico" + swagger_favicon_url="/static/favicon.ico", ) + @app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False) async def swagger_ui_redirect(): return get_swagger_ui_oauth2_redirect_html() @@ -141,9 +145,10 @@ async def redoc_html(): openapi_url=app.openapi_url, title=app.title + " - ReDoc", redoc_js_url="/static/redoc.standalone.js", - redoc_favicon_url="/static/favicon.ico" + redoc_favicon_url="/static/favicon.ico", ) + @app.exception_handler(ControllerError) async def controller_error_handler(request: Request, exc: ControllerError): log.error(f"Controller error in {request.url.path} ({request.method}): {exc}") @@ -210,11 +215,7 @@ async def compute_conflict_error_handler(request: Request, exc: ComputeConflictE # make sure the content key is "message", not "detail" per default @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - headers=exc.headers - ) + return JSONResponse(status_code=exc.status_code, content={"message": exc.detail}, headers=exc.headers) @app.exception_handler(SQLAlchemyError) @@ -229,7 +230,4 @@ async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): log.error(f"Request validation error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - content={"message": str(exc)} - ) + return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"message": str(exc)}) diff --git a/gns3server/compute/__init__.py b/gns3server/compute/__init__.py index 30cf3c493..9b7a6cc18 100644 --- a/gns3server/compute/__init__.py +++ b/gns3server/compute/__init__.py @@ -34,5 +34,6 @@ if ( # IOU & Docker only runs on Linux but test suite works on UNIX platform from .docker import Docker from .iou import IOU + MODULES.append(Docker) MODULES.append(IOU) diff --git a/gns3server/compute/adapters/adapter.py b/gns3server/compute/adapters/adapter.py index 8294a036d..6b34d352f 100644 --- a/gns3server/compute/adapters/adapter.py +++ b/gns3server/compute/adapters/adapter.py @@ -16,7 +16,6 @@ class Adapter: - """ Base class for adapters. diff --git a/gns3server/compute/adapters/ethernet_adapter.py b/gns3server/compute/adapters/ethernet_adapter.py index 1a09c25c1..d0116e9fd 100644 --- a/gns3server/compute/adapters/ethernet_adapter.py +++ b/gns3server/compute/adapters/ethernet_adapter.py @@ -18,7 +18,6 @@ from .adapter import Adapter class EthernetAdapter(Adapter): - """ Ethernet adapter. """ diff --git a/gns3server/compute/adapters/serial_adapter.py b/gns3server/compute/adapters/serial_adapter.py index 989d5be46..265398eee 100644 --- a/gns3server/compute/adapters/serial_adapter.py +++ b/gns3server/compute/adapters/serial_adapter.py @@ -18,7 +18,6 @@ from .adapter import Adapter class SerialAdapter(Adapter): - """ Serial adapter. """ diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 8ef27265c..6ee63f427 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -50,7 +50,6 @@ CHUNK_SIZE = 1024 * 8 # 8KB class BaseManager: - """ Base class for all Manager classes. Responsible of management of a node pool of the same type. @@ -222,7 +221,7 @@ class BaseManager: if hasattr(source_node, "status") and source_node.status != "stopped": raise ComputeError("Cannot duplicate node data while the node is running") - + destination_dir = destination_node.working_dir try: shutil.rmtree(destination_dir) @@ -431,7 +430,6 @@ class BaseManager: ) if not os.path.isabs(orig_path): - for directory in valid_directory_prefices: log.debug(f"Searching for image '{orig_path}' in '{directory}'") path = self._recursive_search_file_in_directory(directory, orig_path) @@ -466,7 +464,7 @@ class BaseManager: for root, dirs, files in os.walk(directory): for file in files: - if s[1] == file and (s[0] == '' or root == os.path.join(directory, s[0])): + if s[1] == file and (s[0] == "" or root == os.path.join(directory, s[0])): path = os.path.normpath(os.path.join(root, s[1])) if os.path.exists(path): return path diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 30fa856c5..0f56bdd04 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -46,7 +46,6 @@ log = logging.getLogger(__name__) class BaseNode: - """ Base node implementation. @@ -460,8 +459,7 @@ class BaseNode: while True: try: (self._wrap_console_reader, self._wrap_console_writer) = await asyncio.open_connection( - host="127.0.0.1", - port=internal_port + host="127.0.0.1", port=internal_port ) break except (OSError, ConnectionRefusedError) as e: @@ -472,10 +470,7 @@ class BaseNode: if console_type == "telnet": await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True) server = AsyncioTelnetServer( - reader=self._wrap_console_reader, - writer=self._wrap_console_writer, - binary=True, - echo=True + reader=self._wrap_console_reader, writer=self._wrap_console_writer, binary=True, echo=True ) elif console_type == "ssh": server = AsyncioSSHServer(reader=self._wrap_console_reader, writer=self._wrap_console_writer) @@ -543,8 +538,7 @@ class BaseNode: """ log.info( - f"New client {websocket.client.host}:{websocket.client.port} has connected to compute" - f" console WebSocket" + f"New client {websocket.client.host}:{websocket.client.port} has connected to compute console WebSocket" ) if self.status != "started": @@ -555,8 +549,7 @@ class BaseNode: if self._console_type not in ("telnet", "ssh", "docker_exec"): await websocket.close(code=1000) log.warning( - f"Cannot open console WebSocket: node {self.name} console type '{self._console_type}' " - f"is not supported" + f"Cannot open console WebSocket: node {self.name} console type '{self._console_type}' is not supported" ) return @@ -609,8 +602,10 @@ class BaseNode: return None cols, rows = message.get("cols"), message.get("rows") if ( - isinstance(cols, int) and not isinstance(cols, bool) - and isinstance(rows, int) and not isinstance(rows, bool) + isinstance(cols, int) + and not isinstance(cols, bool) + and isinstance(rows, int) + and not isinstance(rows, bool) and 2 <= cols <= 5000 and 2 <= rows <= 100000 ): @@ -720,8 +715,7 @@ class BaseNode: """ log.info( - f"New client {websocket.client.host}:{websocket.client.port} has connected to compute " - f"VNC console WebSocket" + f"New client {websocket.client.host}:{websocket.client.port} has connected to compute VNC console WebSocket" ) if self.status != "started": @@ -730,15 +724,13 @@ class BaseNode: return if self._console_type != "vnc": await websocket.close(code=1000) - log.warning( - f"Cannot open VNC WebSocket: node {self.name} console type '{self._console_type}' is not vnc" - ) + log.warning(f"Cannot open VNC WebSocket: node {self.name} console type '{self._console_type}' is not vnc") return try: vnc_reader, vnc_writer = await asyncio.open_connection( self._manager.port_manager.console_host, - self.console # VNC port + self.console, # VNC port ) log.info(f"Connected to VNC server {self._manager.port_manager.console_host}:{self.console}") except ConnectionError as e: @@ -909,9 +901,7 @@ class BaseNode: elif console_type == "vnc": vnc_console_start_port_range, vnc_console_end_port_range = self._get_vnc_console_port_range() self._console = self._manager.port_manager.get_free_tcp_port( - self._project, - vnc_console_start_port_range, - vnc_console_end_port_range + self._project, vnc_console_start_port_range, vnc_console_end_port_range ) else: self._console = self._manager.port_manager.get_free_tcp_port(self._project) @@ -1044,9 +1034,7 @@ class BaseNode: log.debug(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.start() if self._ubridge_hypervisor: - log.info( - f"Hypervisor at {self._ubridge_hypervisor.endpoint} has successfully started" - ) + log.info(f"Hypervisor at {self._ubridge_hypervisor.endpoint} has successfully started") await self._ubridge_hypervisor.connect() # Tell this uBridge where to send MARK signals and which node id to # tag them with. Marker is opt-in and inert until a `mark` filter is @@ -1181,7 +1169,7 @@ class BaseNode: """ i = 0 - for (filter_type, values) in filters.items(): + for filter_type, values in filters.items(): if isinstance(values[0], str): for line in values[0].split("\n"): line = line.strip() @@ -1217,7 +1205,9 @@ class BaseNode: dlt = dlt[4:] return None if dlt == "EN10MB" else dlt - async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None): + async def _ubridge_add_marker_filter( + self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None + ): """ Attach a `mark` packet filter to a uBridge bridge for traffic insight. @@ -1246,9 +1236,7 @@ class BaseNode: # so allow up to 48 here. if not _MARKER_NAME_RE.match(name) or len(name) > 48: raise UbridgeError(f"Invalid marker name: {name!r}") - cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format( - bridge=bridge_name, name=name, bpf=bpf - ) + cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(bridge=bridge_name, name=name, bpf=bpf) if tag is not None: cmd += f" tag {tag}" # Per-link attribution (contract §3.2): when one ubridge bridge serves @@ -1353,7 +1341,7 @@ class BaseNode: """ from gns3server.compute.marker.marker_manager import MarkerManager - markers = nio.markers if hasattr(nio, 'markers') else {} + markers = nio.markers if hasattr(nio, "markers") else {} manager = MarkerManager.instance() markers_dir = self.project.markers_working_directory() desired = {(name, spec.get("link_id", "")): spec for name, spec in markers.items()} @@ -1404,9 +1392,16 @@ class BaseNode: continue # unchanged pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap") try: - await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, - direction=spec.get("direction"), - data_link_type=spec.get("data_link_type")) + await self._ubridge_add_marker_filter( + bridge_name, + name, + bpf, + pcap_path, + tag, + link_id, + direction=spec.get("direction"), + data_link_type=spec.get("data_link_type"), + ) except UbridgeError as e: # Swallow BPF compile errors (warn + skip) so a single bad # expression can't break link creation / node restart — mirrors diff --git a/gns3server/compute/builtin/__init__.py b/gns3server/compute/builtin/__init__.py index 84e093951..94b20748b 100644 --- a/gns3server/compute/builtin/__init__.py +++ b/gns3server/compute/builtin/__init__.py @@ -18,7 +18,6 @@ Builtin nodes server module. """ - from ..base_manager import BaseManager from .builtin_node_factory import BuiltinNodeFactory, BUILTIN_NODES @@ -28,7 +27,6 @@ log = logging.getLogger(__name__) class Builtin(BaseManager): - _NODE_CLASS = BuiltinNodeFactory def __init__(self): diff --git a/gns3server/compute/builtin/builtin_node_factory.py b/gns3server/compute/builtin/builtin_node_factory.py index a078274c2..eafa1d544 100644 --- a/gns3server/compute/builtin/builtin_node_factory.py +++ b/gns3server/compute/builtin/builtin_node_factory.py @@ -29,7 +29,6 @@ BUILTIN_NODES = {"cloud": Cloud, "nat": Nat, "ethernet_hub": EthernetHub, "ether class BuiltinNodeFactory: - """ Factory to create an builtin object based on the node type. """ diff --git a/gns3server/compute/builtin/nodes/cloud.py b/gns3server/compute/builtin/nodes/cloud.py index b01f2b03d..173604582 100644 --- a/gns3server/compute/builtin/nodes/cloud.py +++ b/gns3server/compute/builtin/nodes/cloud.py @@ -31,7 +31,6 @@ log = logging.getLogger(__name__) class Cloud(BaseNode): - """ Cloud. @@ -317,7 +316,6 @@ class Cloud(BaseNode): await self._ubridge_apply_filters(bridge_name, nio.filters) await self._ubridge_apply_markers(bridge_name, nio) if port_info["type"] in ("ethernet", "tap"): - if not self.manager.has_privileged_access(self.ubridge_path): raise NodeError( "uBridge requires root access or the capability to interact with Ethernet and TAP adapters" @@ -339,9 +337,7 @@ class Cloud(BaseNode): elif port_info["type"] == "tap": await self._ubridge_send( - 'bridge add_nio_tap {name} "{interface}"'.format( - name=bridge_name, interface=port_info["interface"] - ) + 'bridge add_nio_tap {name} "{interface}"'.format(name=bridge_name, interface=port_info["interface"]) ) elif port_info["type"] == "udp": @@ -367,7 +363,6 @@ class Cloud(BaseNode): interface = port_info["interface"] if gns3server.utils.interfaces.is_interface_bridge(interface): - network_interfaces = [interface["name"] for interface in self._interfaces()] i = 0 while True: diff --git a/gns3server/compute/builtin/nodes/ethernet_hub.py b/gns3server/compute/builtin/nodes/ethernet_hub.py index 4ef0e27e9..0edc82ae5 100644 --- a/gns3server/compute/builtin/nodes/ethernet_hub.py +++ b/gns3server/compute/builtin/nodes/ethernet_hub.py @@ -24,7 +24,6 @@ log = logging.getLogger(__name__) class EthernetHub(BaseNode): - """ Ethernet hub @@ -40,12 +39,7 @@ class EthernetHub(BaseNode): def asdict(self): - return { - "name": self.name, - "usage": self.usage, - "node_id": self.id, - "project_id": self.project.id - } + return {"name": self.name, "usage": self.usage, "node_id": self.id, "project_id": self.project.id} async def create(self): """ diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 5c66cbad5..153cb0af6 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -55,7 +55,6 @@ _QINQ_ETHERTYPE = "0x88a8" class EthernetSwitch(BaseNode): - """ Ethernet switch. @@ -74,8 +73,8 @@ class EthernetSwitch(BaseNode): self._ubridge_require_privileged_access = True self._nios = {} - self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge - self._bridge_name = None # kernel bridge interface name (allocated on start) + self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge + self._bridge_name = None # kernel bridge interface name (allocated on start) self._bridge_created = False self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied # Idempotency flag for start(). Decoupled from ``status`` so the node can diff --git a/gns3server/compute/builtin/nodes/nat.py b/gns3server/compute/builtin/nodes/nat.py index a3b9b69d4..4a75d9a48 100644 --- a/gns3server/compute/builtin/nodes/nat.py +++ b/gns3server/compute/builtin/nodes/nat.py @@ -38,14 +38,16 @@ class Nat(Cloud): allowed_interfaces = Config.instance().settings.Server.allowed_interfaces if allowed_interfaces and isinstance(allowed_interfaces, str): - allowed_interfaces = allowed_interfaces.split(',') + allowed_interfaces = allowed_interfaces.split(",") if sys.platform.startswith("linux"): nat_interface = Config.instance().settings.Server.default_nat_interface if not nat_interface: nat_interface = "virbr0" if allowed_interfaces and nat_interface not in allowed_interfaces: - raise NodeError("NAT interface {} is not allowed be used on this server. " - "Please check the server configuration file.".format(nat_interface)) + raise NodeError( + "NAT interface {} is not allowed be used on this server. " + "Please check the server configuration file.".format(nat_interface) + ) if nat_interface not in [interface["name"] for interface in gns3server.utils.interfaces.interfaces()]: raise NodeError(f"NAT interface {nat_interface} is missing, please install libvirt") interface = nat_interface @@ -54,8 +56,10 @@ class Nat(Cloud): if not nat_interface: nat_interface = "vmnet8" if allowed_interfaces and nat_interface not in allowed_interfaces: - raise NodeError("NAT interface {} is not allowed be used on this server. " - "Please check the server configuration file.".format(nat_interface)) + raise NodeError( + "NAT interface {} is not allowed be used on this server. " + "Please check the server configuration file.".format(nat_interface) + ) interfaces = list( filter( lambda x: nat_interface in x.lower(), diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index 39ca9f20a..a6b1d4dae 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -34,7 +34,12 @@ from gns3server.compute.base_manager import BaseManager from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM from gns3server.compute.docker.iol_docker_vm import IOLDockerVM -from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error +from gns3server.compute.docker.docker_error import ( + DockerError, + DockerHttp304Error, + DockerHttp404Error, + DockerHttp409Error, +) log = logging.getLogger(__name__) @@ -47,7 +52,6 @@ CHUNK_SIZE = 1024 * 8 # 8KB class Docker(BaseManager): - _NODE_CLASS = DockerVM def __init__(self): @@ -97,10 +101,7 @@ class Docker(BaseManager): # check that busybox is statically linked # (dynamically linked busybox will fail to run in a container) proc = await asyncio.create_subprocess_exec( - "ldd", - busybox_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL + "ldd", busybox_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL ) stdout, _ = await proc.communicate() if proc.returncode == 1 or "static" in busybox_exec: @@ -111,11 +112,15 @@ class Docker(BaseManager): shutil.copy2(busybox_path, dst_busybox, follow_symlinks=True) return else: - log.warning(f"Busybox '{busybox_path}' is dynamically linked\n" - f"{stdout.decode('utf-8', errors='ignore').strip()}") + log.warning( + f"Busybox '{busybox_path}' is dynamically linked\n" + f"{stdout.decode('utf-8', errors='ignore').strip()}" + ) except OSError as e: raise DockerError(f"Could not install busybox: {e}") - raise DockerError("No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH") + raise DockerError( + "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH" + ) @staticmethod def resources_path(): @@ -142,6 +147,7 @@ class Docker(BaseManager): dst_path = self.resources_path() log.info(f"Installing Docker resources in '{dst_path}'") from gns3server.controller import Controller + await Controller.instance().install_resource_files(dst_path, "compute/docker/resources") await self.install_busybox(dst_path) except OSError as e: @@ -157,12 +163,12 @@ class Docker(BaseManager): self._connected = False raise DockerError("Can't connect to Docker daemon") - api_version = parse_version(docker_info['ApiVersion']) + api_version = parse_version(docker_info["ApiVersion"]) version = docker_info["Version"] if api_version < parse_version(DOCKER_MINIMUM_API_VERSION): - raise DockerError(f"Docker version is {version}. " - f"GNS3 requires a minimum version of {DOCKER_MINIMUM_VERSION}" + raise DockerError( + f"Docker version is {version}. GNS3 requires a minimum version of {DOCKER_MINIMUM_VERSION}" ) preferred_api_version = parse_version(DOCKER_PREFERRED_API_VERSION) @@ -170,7 +176,7 @@ class Docker(BaseManager): self._api_version = DOCKER_PREFERRED_API_VERSION else: # use the Min API version supported by the daemon - self._api_version = docker_info['MinAPIVersion'] + self._api_version = docker_info["MinAPIVersion"] log.warning("Using Docker client with the minimum API version {}".format(self._api_version)) log.info("Connected to Docker daemon version {} using API version {}".format(version, self._api_version)) @@ -264,7 +270,7 @@ class Docker(BaseManager): response = await self.http_query(method, path, data=data, params=params) body = await response.read() response.close() - if response.headers.get('CONTENT-TYPE') == 'application/json': + if response.headers.get("CONTENT-TYPE") == "application/json": body = json.loads(body.decode("utf-8", errors="ignore")) else: body = body.decode("utf-8", errors="ignore") @@ -292,7 +298,7 @@ class Docker(BaseManager): if timeout is None: timeout = 60 * 60 * 24 * 31 # One month timeout - if path == 'version': + if path == "version": url = "http://docker/" + path else: await self._check_connection() # version is use by check connection @@ -380,9 +386,7 @@ class Docker(BaseManager): f"Disconnected while pulling Docker image '{image}' from Docker repository" ) from e except asyncio.TimeoutError as e: - raise DockerError( - f"Timeout while pulling Docker image '{image}' from Docker repository" - ) from e + raise DockerError(f"Timeout while pulling Docker image '{image}' from Docker repository") from e if not chunk: break content += chunk.decode("utf-8") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index bfcc53a7e..7d557dff2 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -236,7 +236,9 @@ class DockerVM(BaseNode): """ if not is_rfc1123_hostname_valid(new_name): - raise DockerError(f"'{new_name}' is an invalid name to rename Docker container '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen.") + raise DockerError( + f"'{new_name}' is an invalid name to rename Docker container '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen." + ) super(DockerVM, DockerVM).name.__set__(self, new_name) @property @@ -275,10 +277,10 @@ class DockerVM(BaseNode): else: self._mac_address = mac_address - log.debug('Docker container "{name}" [{id}]: MAC address changed to {mac_addr}'.format( - name=self._name, - id=self._id, - mac_addr=self._mac_address) + log.debug( + 'Docker container "{name}" [{id}]: MAC address changed to {mac_addr}'.format( + name=self._name, id=self._id, mac_addr=self._mac_address + ) ) @property @@ -453,12 +455,7 @@ class DockerVM(BaseNode): raise DockerError(f"Cannot access resources: {e}") log.debug(f'Mount resources from "{resources_path}"') - binds = [{ - "Type": "bind", - "Source": resources_path, - "Target": "/gns3", - "ReadOnly": True - }] + binds = [{"Type": "bind", "Source": resources_path, "Target": "/gns3", "ReadOnly": True}] self._image_id = image_info.get("Id") or self._image_id # We mount our own etc/network @@ -471,11 +468,7 @@ class DockerVM(BaseNode): for volume in self._volumes: source = os.path.join(self.working_dir, os.path.relpath(volume, "/")) os.makedirs(source, exist_ok=True) - binds.append({ - "Type": "bind", - "Source": source, - "Target": "/gns3volumes{}".format(volume) - }) + binds.append({"Type": "bind", "Source": source, "Target": "/gns3volumes{}".format(volume)}) # Inject extra config files: write each to the node working directory and # bind-mount it read-only at its target path. Single-file binds are applied @@ -485,9 +478,7 @@ class DockerVM(BaseNode): target = cfg["target"] if isinstance(cfg, dict) else cfg.target content = cfg["content"] if isinstance(cfg, dict) else cfg.content if not target.startswith("/") or target.endswith("/") or ".." in target.split("/"): - raise DockerError( - f"Extra config target '{target}' must be an absolute file path and not contain '..'." - ) + raise DockerError(f"Extra config target '{target}' must be an absolute file path and not contain '..'.") for volume in self._volumes: # A single-file bind gets covered by the volume's bind mount at # start (init.sh or the vendor volume bridge), so the injected @@ -497,18 +488,22 @@ class DockerVM(BaseNode): log.warning( "Extra config target '%s' on container '%s' is shadowed by persisted volume '%s' " "and will not take effect; pick a target outside persisted volumes.", - target, self._name, volume, + target, + self._name, + volume, ) host_path = os.path.join(self.working_dir, "configs", target.lstrip("/")) os.makedirs(os.path.dirname(host_path), exist_ok=True) with open(host_path, "w") as f: f.write(content) - binds.append({ - "Type": "bind", - "Source": host_path, - "Target": target, - "ReadOnly": True, - }) + binds.append( + { + "Type": "bind", + "Source": host_path, + "Target": target, + "ReadOnly": True, + } + ) return binds @@ -554,7 +549,8 @@ class DockerVM(BaseNode): #auto eth{adapter} #iface eth{adapter} inet dhcp #\thostname {hostname} -""".format(adapter=adapter, hostname=self._name)) +""".format(adapter=adapter, hostname=self._name) + ) return path def _prepare_init_and_interface_env(self, params): @@ -573,9 +569,11 @@ class DockerVM(BaseNode): """ if ":" in os.path.splitdrive(self.working_dir)[1]: - raise DockerError("Cannot create a Docker container with a project directory containing a colon character (':')") + raise DockerError( + "Cannot create a Docker container with a project directory containing a colon character (':')" + ) - #await self.manager.install_resources() + # await self.manager.install_resources() try: image_infos = await self._get_image_information() @@ -601,8 +599,7 @@ class DockerVM(BaseNode): available_cpus = psutil.cpu_count(logical=True) if self._cpus > available_cpus: raise DockerError( - f"You have allocated too many CPUs for the Docker container " - f"(max available is {available_cpus} CPUs)" + f"You have allocated too many CPUs for the Docker container (max available is {available_cpus} CPUs)" ) # Prepare persistent volume content before the container and its @@ -622,7 +619,7 @@ class DockerVM(BaseNode): "Mounts": self._mount_binds(image_infos), "Memory": self._memory * (1024 * 1024), # convert memory to bytes "NanoCpus": int(self._cpus * 1e9), # convert cpus to nano cpus - "UsernsMode": "host" + "UsernsMode": "host", }, "Volumes": {}, "Env": ["container=docker"], # Systemd compliant: https://github.com/GNS3/gns3-server/issues/573 @@ -651,8 +648,11 @@ class DockerVM(BaseNode): devices = self._format_devices(line.split("=", 1)[1]) if devices: params["HostConfig"]["Devices"] = devices - elif line.startswith("GNS3_MASK_UDEV=") and \ - line.split("=", 1)[1].strip().lower() in ("1", "true", "yes"): + elif line.startswith("GNS3_MASK_UDEV=") and line.split("=", 1)[1].strip().lower() in ( + "1", + "true", + "yes", + ): # A privileged systemd-based NOS container (e.g. Cisco XRd) # runs systemd-udevd, which coldplugs every device it can see # -- and in privileged mode that includes the HOST's USB/input/ @@ -660,24 +660,28 @@ class DockerVM(BaseNode): # XRd doesn't need udev (interfaces are pre-created by GNS3), so # bind /dev/null over the udev units to keep it from running. for target in [f"/etc/systemd/system/{u}" for u in self._UDEV_UNITS] + list(self._UDEVADM_PATHS): - params["HostConfig"]["Mounts"].append({ - "Type": "bind", - "Source": "/dev/null", - "Target": target, - "ReadOnly": True, - }) + params["HostConfig"]["Mounts"].append( + { + "Type": "bind", + "Source": "/dev/null", + "Target": target, + "ReadOnly": True, + } + ) elif line.startswith("GNS3_MASK_SYSTEMD="): # Generic form: comma/semicolon-separated unit names to mask # the same way (bind /dev/null over /etc/systemd/system/). for unit in line.split("=", 1)[1].replace(";", ",").split(","): unit = unit.strip() if unit and "/" not in unit and ".." not in unit: - params["HostConfig"]["Mounts"].append({ - "Type": "bind", - "Source": "/dev/null", - "Target": f"/etc/systemd/system/{unit}", - "ReadOnly": True, - }) + params["HostConfig"]["Mounts"].append( + { + "Type": "bind", + "Source": "/dev/null", + "Target": f"/etc/systemd/system/{unit}", + "ReadOnly": True, + } + ) # Overlapping bind targets (GNS3_MASK_UDEV together with a # GNS3_MASK_SYSTEMD entry for the same unit, an extra_configs target @@ -754,12 +758,14 @@ class DockerVM(BaseNode): "QT_GRAPHICSSYSTEM=native" ) # To fix a Qt issue: https://github.com/GNS3/gns3-server/issues/556 params["Env"].append(f"DISPLAY=:{self._display}") - params["HostConfig"]["Mounts"].append({ - "Type": "bind", - "Source": f"/tmp/.X11-unix/X{self._display}", - "Target": f"/tmp/.X11-unix/X{self._display}", - "ReadOnly": True - }) + params["HostConfig"]["Mounts"].append( + { + "Type": "bind", + "Source": f"/tmp/.X11-unix/X{self._display}", + "Target": f"/tmp/.X11-unix/X{self._display}", + "ReadOnly": True, + } + ) if self._extra_hosts: extra_hosts = self._format_extra_hosts(self._extra_hosts) @@ -772,7 +778,9 @@ class DockerVM(BaseNode): except DockerHttp409Error: # Container name already exists. This can happen when the server crashes # and leaves containers behind. Try to remove the conflicting container. - log.warning(f"Container name '{self.docker_name}' is already in use, attempting to clean up the stale container...") + log.warning( + f"Container name '{self.docker_name}' is already in use, attempting to clean up the stale container..." + ) try: # Try to get and remove the conflicting container try: @@ -850,11 +858,13 @@ class DockerVM(BaseNode): on_host, in_container, permissions = parts else: continue - formatted.append({ - "PathOnHost": on_host, - "PathInContainer": in_container, - "CgroupPermissions": permissions, - }) + formatted.append( + { + "PathOnHost": on_host, + "PathInContainer": in_container, + "CgroupPermissions": permissions, + } + ) return formatted async def update(self): @@ -897,7 +907,6 @@ class DockerVM(BaseNode): await self._start_interface_monitor() return else: - if self._console_type == "vnc" and not self._vnc_process: # restart the vnc process in case it had previously crashed await self._start_vnc_process(restart=True) @@ -1042,7 +1051,9 @@ class DockerVM(BaseNode): stderr = (await process.stderr.read()).decode(errors="replace").strip() log.error( "Failed to fix permissions on '%s' for container '%s': %s", - volume, self._name, stderr or f"exit code {process.returncode}" + volume, + self._name, + stderr or f"exit code {process.returncode}", ) else: self._permissions_fixed = True @@ -1102,7 +1113,9 @@ class DockerVM(BaseNode): log.warning(f"Docker container '{self._name}': cannot access resources to reclaim '{directory}': {e}") return False - log.info(f"Docker container '{self._name}': reclaiming root-owned files under '{directory}' via a one-shot container") + log.info( + f"Docker container '{self._name}': reclaiming root-owned files under '{directory}' via a one-shot container" + ) # Prefer the image's own chown over the static busybox one: busybox's # chown dlopens NSS modules from the image, which mismatch the static # glibc and abort on NOS images whose glibc differs (same reasoning @@ -1116,12 +1129,24 @@ class DockerVM(BaseNode): image_ref = self._image_id or self._image try: process = await asyncio.subprocess.create_subprocess_exec( - "docker", "run", "--rm", "--network", "none", "--pull", "never", "--user", "0:0", - "--entrypoint", "/gns3/bin/busybox", - "-v", f"{resources_path}:/gns3:ro", - "-v", f"{directory}:/target", + "docker", + "run", + "--rm", + "--network", + "none", + "--pull", + "never", + "--user", + "0:0", + "--entrypoint", + "/gns3/bin/busybox", + "-v", + f"{resources_path}:/gns3:ro", + "-v", + f"{directory}:/target", image_ref, - "sh", "-c", + "sh", + "-c", "/gns3/bin/busybox chmod -R u+rwX /target" f" && ( command -v chown >/dev/null 2>&1 && chown {uid}:{gid} -R /target" f" || /gns3/bin/busybox chown {uid}:{gid} -R /target )", @@ -1159,17 +1184,27 @@ class DockerVM(BaseNode): if tigervnc_path: with open(os.path.join(self.working_dir, "vnc.log"), "w") as fd: - self._vnc_process = await asyncio.create_subprocess_exec(tigervnc_path, - "-extension", "MIT-SHM", - "-geometry", self._console_resolution, - "-depth", "16", - "-interface", self._manager.port_manager.console_host, - "-rfbport", str(self.console), - "-AlwaysShared", - "-SecurityTypes", "None", - "-desktop", self.name, - ":{}".format(self._display), - stdout=fd, stderr=subprocess.STDOUT) + self._vnc_process = await asyncio.create_subprocess_exec( + tigervnc_path, + "-extension", + "MIT-SHM", + "-geometry", + self._console_resolution, + "-depth", + "16", + "-interface", + self._manager.port_manager.console_host, + "-rfbport", + str(self.console), + "-AlwaysShared", + "-SecurityTypes", + "None", + "-desktop", + self.name, + ":{}".format(self._display), + stdout=fd, + stderr=subprocess.STDOUT, + ) async def _start_vnc(self): """ @@ -1576,8 +1611,8 @@ class DockerVM(BaseNode): script = ( "while :; do " "for ifname do " - "flags=$(/gns3/bin/busybox cat \"/sys/class/net/$ifname/flags\" 2>/dev/null) || continue; " - "/gns3/bin/busybox printf '%s=%s\\n' \"$ifname\" \"$flags\"; " + 'flags=$(/gns3/bin/busybox cat "/sys/class/net/$ifname/flags" 2>/dev/null) || continue; ' + '/gns3/bin/busybox printf \'%s=%s\\n\' "$ifname" "$flags"; ' "done; " "/gns3/bin/busybox sleep 1; " "done" @@ -1621,7 +1656,7 @@ class DockerVM(BaseNode): asyncio.IncompleteReadError, asyncio.TimeoutError, ) as e: - if 'writer' in locals(): + if "writer" in locals(): await self._close_interface_monitor_writer(writer) log.warning("Could not monitor interfaces for Docker container '%s': %s", self.name, e) return @@ -1658,8 +1693,7 @@ class DockerVM(BaseNode): """Read ``ifname=flags`` records and emit changed adapter states.""" interfaces = { - self._get_container_ifname(adapter_number): adapter_number - for adapter_number in range(self.adapters) + self._get_container_ifname(adapter_number): adapter_number for adapter_number in range(self.adapters) } try: while True: @@ -1747,9 +1781,7 @@ class DockerVM(BaseNode): await self._ubridge_send(f"bridge create {bridge_name}") self._bridges.add(bridge_name) await self._ubridge_send( - "bridge add_nio_tap {bridge_name} {hostif} off".format( - bridge_name=bridge_name, hostif=adapter.host_ifc - ) + "bridge add_nio_tap {bridge_name} {hostif} off".format(bridge_name=bridge_name, hostif=adapter.host_ifc) ) mac_address = int_to_macaddress(macaddress_to_int(self._mac_address) + adapter_number) @@ -1759,17 +1791,14 @@ class DockerVM(BaseNode): mac_address = custom_mac_address try: - await self._ubridge_send('docker set_mac_addr {ifc} {mac}'.format(ifc=adapter.host_ifc, mac=mac_address)) + await self._ubridge_send("docker set_mac_addr {ifc} {mac}".format(ifc=adapter.host_ifc, mac=mac_address)) except UbridgeError: log.warning(f"Could not set MAC address {mac_address} on interface {adapter.host_ifc}") - ifname = self._get_container_ifname(adapter_number) log.debug(f"Move container {self.name} adapter {adapter.host_ifc} -> {ifname} in ns {self._namespace}") try: - await self._ubridge_send( - f"docker move_to_ns {adapter.host_ifc} {self._namespace} {ifname}" - ) + await self._ubridge_send(f"docker move_to_ns {adapter.host_ifc} {self._namespace} {ifname}") except UbridgeError as e: raise UbridgeNamespaceError(e) else: @@ -2061,6 +2090,6 @@ class DockerVM(BaseNode): raise ComputeError( f"Could not delete the node directory '{self.working_dir}': files left owned by " f"another user could not be reclaimed ({e}). Reclaim them manually with: " - f"docker run --rm --user 0:0 -v \"{self.working_dir}\":/target --entrypoint /bin/sh " + f'docker run --rm --user 0:0 -v "{self.working_dir}":/target --entrypoint /bin/sh ' f"{self._image} -c 'chown -R {os.getuid()}:{os.getgid()} /target'" ) diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 38042de6d..ff0ab2b00 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -247,7 +247,8 @@ class IOLDockerVM(VendorDockerVM): log.debug( "IOL container '%s': number of 4-port Ethernet adapters set to %d", - self._name, adapters, + self._name, + adapters, ) def _persistent_volume_list(self, image_info, include_network_config=True): diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index b156209ae..1adb5a951 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -113,9 +113,7 @@ class VendorDockerVM(DockerVM): if _line.startswith("GNS3_SKIP_INIT="): self._gns3_init = _line.split("=", 1)[1].strip().lower() not in ("1", "true", "yes") elif _line.startswith("GNS3_INTERFACE_NAMES="): - self._interface_names = [ - n.strip() for n in _line.split("=", 1)[1].split(",") if n.strip() - ] + self._interface_names = [n.strip() for n in _line.split("=", 1)[1].split(",") if n.strip()] elif _line.startswith("GNS3_CONSOLE_CMD="): self._console_cmd = _line.split("=", 1)[1].strip() elif _line.startswith("GNS3_CONSOLE_RESIZE="): @@ -172,19 +170,20 @@ class VendorDockerVM(DockerVM): if self._unix_socket_nio: socket_dir = self._unix_socket_dir.rstrip("/") if not any( - v.rstrip("/") == socket_dir or socket_dir.startswith(v.rstrip("/") + "/") - for v in self._volumes + v.rstrip("/") == socket_dir or socket_dir.startswith(v.rstrip("/") + "/") for v in self._volumes ): # The image's network agent drops privileges before using the # socket directory, so it must be writable by the server user: # bind a per-node directory from the runtime directory (see # _unix_socket_host_dir). - binds.append({ - "Type": "bind", - "Source": self._unix_socket_host_dir(), - "Target": socket_dir, - "BindOptions": {"Propagation": "rprivate"}, - }) + binds.append( + { + "Type": "bind", + "Source": self._unix_socket_host_dir(), + "Target": socket_dir, + "BindOptions": {"Propagation": "rprivate"}, + } + ) if self._gns3_init: return binds binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] @@ -198,7 +197,7 @@ class VendorDockerVM(DockerVM): for bind in binds: target = bind.get("Target", "") if target.startswith("/gns3volumes"): - volume = target[len("/gns3volumes"):] + volume = target[len("/gns3volumes") :] if volume in self._volumes: bind = {**bind, "Target": volume} retargeted.append(bind) @@ -249,8 +248,11 @@ class VendorDockerVM(DockerVM): try: process = await asyncio.subprocess.create_subprocess_exec( - "docker", "create", self._image, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + "docker", + "create", + self._image, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) except OSError as e: raise DockerError(f"Could not seed persistent volumes for '{self._name}': {e}") @@ -271,8 +273,13 @@ class VendorDockerVM(DockerVM): try: process = await asyncio.subprocess.create_subprocess_exec( - "docker", "cp", "-a", f"{seed_cid}:{volume}/.", host_dir + "/", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + "docker", + "cp", + "-a", + f"{seed_cid}:{volume}/.", + host_dir + "/", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) except OSError as e: raise DockerError(f"Could not seed persistent volume '{volume}' for '{self._name}': {e}") @@ -283,7 +290,10 @@ class VendorDockerVM(DockerVM): # init.sh's first copy (cp -a ... 2>/dev/null). log.info( "Persistent volume '%s' on '%s' not seedable from image '%s' (%s); starting empty", - volume, self._name, self._image, stderr.decode(errors="replace").strip(), + volume, + self._name, + self._image, + stderr.decode(errors="replace").strip(), ) return log.info("Seeded persistent volume '%s' for '%s' from image '%s'", volume, self._name, self._image) @@ -295,8 +305,12 @@ class VendorDockerVM(DockerVM): try: process = await asyncio.subprocess.create_subprocess_exec( - "docker", "rm", "-f", seed_cid, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + "docker", + "rm", + "-f", + seed_cid, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) except OSError: return @@ -348,9 +362,7 @@ class VendorDockerVM(DockerVM): try: os.makedirs(host_dir, mode=0o700, exist_ok=True) except OSError as e: - raise DockerError( - f"Could not create unix-socket directory '{host_dir}' for container '{self._name}': {e}" - ) + raise DockerError(f"Could not create unix-socket directory '{host_dir}' for container '{self._name}': {e}") return host_dir def _remove_unix_socket_host_dir(self): @@ -448,7 +460,11 @@ class VendorDockerVM(DockerVM): adapter.host_ifc = local_sock # bookkeeping / removal logging only log.debug( "Adapter %d port %d of container '%s' wired via unix sockets %s <-> %s", - adapter_number, port_number, self._name, local_sock, remote_sock, + adapter_number, + port_number, + self._name, + local_sock, + remote_sock, ) if nio: @@ -564,7 +580,8 @@ class VendorDockerVM(DockerVM): if state == "stopped" or state == "exited": log.info( "Container '%s' is %s, skipping permission fix (next start will fix)", - self._name, state, + self._name, + state, ) return @@ -601,7 +618,9 @@ class VendorDockerVM(DockerVM): stderr = (await process.stderr.read()).decode(errors="replace").strip() log.error( "Failed to fix permissions on '%s' for container '%s': %s", - volume, self._name, stderr or f"exit code {process.returncode}", + volume, + self._name, + stderr or f"exit code {process.returncode}", ) else: self._permissions_fixed = True @@ -640,9 +659,7 @@ class VendorDockerVM(DockerVM): allow_resize=self._console_resize, ) try: - self._telnet_servers.append( - await telnet.start(self._manager.port_manager.console_host, self.console) - ) + self._telnet_servers.append(await telnet.start(self._manager.port_manager.console_host, self.console)) except OSError as e: raise DockerError( f"Could not start console server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" diff --git a/gns3server/compute/dynamips/__init__.py b/gns3server/compute/dynamips/__init__.py index 134ce83e0..cbedaa6ad 100644 --- a/gns3server/compute/dynamips/__init__.py +++ b/gns3server/compute/dynamips/__init__.py @@ -110,7 +110,6 @@ PLATFORMS_DEFAULT_RAM = { class Dynamips(BaseManager): - _NODE_CLASS = DynamipsFactory _NODE_TYPE = "dynamips" _ghost_ios_lock = None @@ -253,7 +252,9 @@ class Dynamips(BaseManager): dynamips_path = self.config.settings.Dynamips.dynamips_path if not os.path.isabs(dynamips_path): if sys.platform.startswith("win") and hasattr(sys, "frozen"): - dynamips_dir = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(sys.executable)), "dynamips")) + dynamips_dir = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(sys.executable)), "dynamips") + ) os.environ["PATH"] = os.pathsep.join(dynamips_dir) + os.pathsep + os.environ.get("PATH", "") dynamips_path = shutil.which(dynamips_path) @@ -305,14 +306,14 @@ class Dynamips(BaseManager): bind_console_host = False dynamips_version = await self.dynamips_version(self.dynamips_path) - if parse_version(dynamips_version) < parse_version('0.2.11'): + if parse_version(dynamips_version) < parse_version("0.2.11"): raise DynamipsError("Dynamips version must be >= 0.2.11, detected version is {}".format(dynamips_version)) if not sys.platform.startswith("win"): # Hypervisor should always listen to 127.0.0.1 # See https://github.com/GNS3/dynamips/issues/62 # This was fixed in Dynamips v0.2.23 which hasn't been built for Windows - if parse_version(dynamips_version) >= parse_version('0.2.23'): + if parse_version(dynamips_version) >= parse_version("0.2.23"): server_host = "127.0.0.1" bind_console_host = True @@ -331,7 +332,9 @@ class Dynamips(BaseManager): raise DynamipsError(f"Could not find free port for the Dynamips hypervisor: {e}") port_manager = PortManager.instance() - hypervisor = Hypervisor(self._dynamips_path, working_dir, server_host, port, port_manager.console_host, bind_console_host) + hypervisor = Hypervisor( + self._dynamips_path, working_dir, server_host, port, port_manager.console_host, bind_console_host + ) log.debug(f"Creating new hypervisor {hypervisor.host}:{hypervisor.port} with working directory {working_dir}") await hypervisor.start() @@ -482,7 +485,9 @@ class Dynamips(BaseManager): if vm.slots[slot_id].removable(): await vm.slot_remove_binding(slot_id) else: - log.warning(f"Slot {slot_id} on router '{vm.name}' has a non-removable adapter, skipping replacement") + log.warning( + f"Slot {slot_id} on router '{vm.name}' has a non-removable adapter, skipping replacement" + ) continue if not isinstance(vm.slots[slot_id], type(adapter)): await vm.slot_add_binding(slot_id, adapter) @@ -500,7 +505,11 @@ class Dynamips(BaseManager): wic_name = value wic = WIC_MATRIX[wic_name]() try: - if vm.slots[0] and vm.slots[0].wics[wic_slot_id] and not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)): + if ( + vm.slots[0] + and vm.slots[0].wics[wic_slot_id] + and not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)) + ): await vm.uninstall_wic(wic_slot_id) if vm.slots[0] and not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)): await vm.install_wic(wic_slot_id, wic) diff --git a/gns3server/compute/dynamips/adapters/adapter.py b/gns3server/compute/dynamips/adapters/adapter.py index 5126f0bb0..afa2a3e52 100644 --- a/gns3server/compute/dynamips/adapters/adapter.py +++ b/gns3server/compute/dynamips/adapters/adapter.py @@ -16,7 +16,6 @@ class Adapter: - """ Base class for adapters. diff --git a/gns3server/compute/dynamips/adapters/c1700_mb_1fe.py b/gns3server/compute/dynamips/adapters/c1700_mb_1fe.py index ef7d88c48..14048fe02 100644 --- a/gns3server/compute/dynamips/adapters/c1700_mb_1fe.py +++ b/gns3server/compute/dynamips/adapters/c1700_mb_1fe.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C1700_MB_1FE(Adapter): - """ Integrated 1 port FastEthernet adapter for c1700 platform. """ diff --git a/gns3server/compute/dynamips/adapters/c1700_mb_wic1.py b/gns3server/compute/dynamips/adapters/c1700_mb_wic1.py index 170a44898..d5bd6443f 100644 --- a/gns3server/compute/dynamips/adapters/c1700_mb_wic1.py +++ b/gns3server/compute/dynamips/adapters/c1700_mb_wic1.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C1700_MB_WIC1(Adapter): - """ Fake module to provide a placeholder for slot 1 interfaces when WICs are inserted into WIC slot 1. diff --git a/gns3server/compute/dynamips/adapters/c2600_mb_1e.py b/gns3server/compute/dynamips/adapters/c2600_mb_1e.py index be92cf3f5..25f6b690e 100644 --- a/gns3server/compute/dynamips/adapters/c2600_mb_1e.py +++ b/gns3server/compute/dynamips/adapters/c2600_mb_1e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C2600_MB_1E(Adapter): - """ Integrated 1 port Ethernet adapter for the c2600 platform. """ diff --git a/gns3server/compute/dynamips/adapters/c2600_mb_1fe.py b/gns3server/compute/dynamips/adapters/c2600_mb_1fe.py index 2c06dbdde..2974d6c0f 100644 --- a/gns3server/compute/dynamips/adapters/c2600_mb_1fe.py +++ b/gns3server/compute/dynamips/adapters/c2600_mb_1fe.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C2600_MB_1FE(Adapter): - """ Integrated 1 port FastEthernet adapter for the c2600 platform. """ diff --git a/gns3server/compute/dynamips/adapters/c2600_mb_2e.py b/gns3server/compute/dynamips/adapters/c2600_mb_2e.py index 7798b07a8..858f952c1 100644 --- a/gns3server/compute/dynamips/adapters/c2600_mb_2e.py +++ b/gns3server/compute/dynamips/adapters/c2600_mb_2e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C2600_MB_2E(Adapter): - """ Integrated 2 port Ethernet adapter for the c2600 platform. """ diff --git a/gns3server/compute/dynamips/adapters/c2600_mb_2fe.py b/gns3server/compute/dynamips/adapters/c2600_mb_2fe.py index a3016f01d..724c2ff2f 100644 --- a/gns3server/compute/dynamips/adapters/c2600_mb_2fe.py +++ b/gns3server/compute/dynamips/adapters/c2600_mb_2fe.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C2600_MB_2FE(Adapter): - """ Integrated 2 port FastEthernet adapter for the c2600 platform. """ diff --git a/gns3server/compute/dynamips/adapters/c7200_io_2fe.py b/gns3server/compute/dynamips/adapters/c7200_io_2fe.py index addde38cd..2715c1c2b 100644 --- a/gns3server/compute/dynamips/adapters/c7200_io_2fe.py +++ b/gns3server/compute/dynamips/adapters/c7200_io_2fe.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C7200_IO_2FE(Adapter): - """ C7200-IO-2FE FastEthernet Input/Ouput controller. """ diff --git a/gns3server/compute/dynamips/adapters/c7200_io_fe.py b/gns3server/compute/dynamips/adapters/c7200_io_fe.py index 4d2c1b1ba..a570ab7e5 100644 --- a/gns3server/compute/dynamips/adapters/c7200_io_fe.py +++ b/gns3server/compute/dynamips/adapters/c7200_io_fe.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C7200_IO_FE(Adapter): - """ C7200-IO-FE FastEthernet Input/Ouput controller. """ diff --git a/gns3server/compute/dynamips/adapters/c7200_io_ge_e.py b/gns3server/compute/dynamips/adapters/c7200_io_ge_e.py index 09f982ed6..50913f1c7 100644 --- a/gns3server/compute/dynamips/adapters/c7200_io_ge_e.py +++ b/gns3server/compute/dynamips/adapters/c7200_io_ge_e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class C7200_IO_GE_E(Adapter): - """ C7200-IO-GE-E GigabitEthernet Input/Ouput controller. """ diff --git a/gns3server/compute/dynamips/adapters/leopard_2fe.py b/gns3server/compute/dynamips/adapters/leopard_2fe.py index 55d75c19f..83752b14e 100644 --- a/gns3server/compute/dynamips/adapters/leopard_2fe.py +++ b/gns3server/compute/dynamips/adapters/leopard_2fe.py @@ -18,7 +18,6 @@ from .adapter import Adapter class Leopard_2FE(Adapter): - """ Integrated 2 port FastEthernet adapter for c3660 router. """ diff --git a/gns3server/compute/dynamips/adapters/nm_16esw.py b/gns3server/compute/dynamips/adapters/nm_16esw.py index f346da8bd..d95b9a339 100644 --- a/gns3server/compute/dynamips/adapters/nm_16esw.py +++ b/gns3server/compute/dynamips/adapters/nm_16esw.py @@ -18,7 +18,6 @@ from .adapter import Adapter class NM_16ESW(Adapter): - """ NM-16ESW FastEthernet network module. """ diff --git a/gns3server/compute/dynamips/adapters/nm_1e.py b/gns3server/compute/dynamips/adapters/nm_1e.py index 919324743..f0b27c5a4 100644 --- a/gns3server/compute/dynamips/adapters/nm_1e.py +++ b/gns3server/compute/dynamips/adapters/nm_1e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class NM_1E(Adapter): - """ NM-1E Ethernet network module. """ diff --git a/gns3server/compute/dynamips/adapters/nm_1fe_tx.py b/gns3server/compute/dynamips/adapters/nm_1fe_tx.py index bb03d3f30..f693b019f 100644 --- a/gns3server/compute/dynamips/adapters/nm_1fe_tx.py +++ b/gns3server/compute/dynamips/adapters/nm_1fe_tx.py @@ -18,7 +18,6 @@ from .adapter import Adapter class NM_1FE_TX(Adapter): - """ NM-1FE-TX FastEthernet network module. """ diff --git a/gns3server/compute/dynamips/adapters/nm_4e.py b/gns3server/compute/dynamips/adapters/nm_4e.py index 6a4db9f7c..2db29130b 100644 --- a/gns3server/compute/dynamips/adapters/nm_4e.py +++ b/gns3server/compute/dynamips/adapters/nm_4e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class NM_4E(Adapter): - """ NM-4E Ethernet network module. """ diff --git a/gns3server/compute/dynamips/adapters/nm_4t.py b/gns3server/compute/dynamips/adapters/nm_4t.py index fa527c2ff..ddda0659d 100644 --- a/gns3server/compute/dynamips/adapters/nm_4t.py +++ b/gns3server/compute/dynamips/adapters/nm_4t.py @@ -18,7 +18,6 @@ from .adapter import Adapter class NM_4T(Adapter): - """ NM-4T Serial network module. """ diff --git a/gns3server/compute/dynamips/adapters/pa_2fe_tx.py b/gns3server/compute/dynamips/adapters/pa_2fe_tx.py index 361199996..c191172ac 100644 --- a/gns3server/compute/dynamips/adapters/pa_2fe_tx.py +++ b/gns3server/compute/dynamips/adapters/pa_2fe_tx.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_2FE_TX(Adapter): - """ PA-2FE-TX FastEthernet port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_4e.py b/gns3server/compute/dynamips/adapters/pa_4e.py index 5f3288b76..551e4177e 100644 --- a/gns3server/compute/dynamips/adapters/pa_4e.py +++ b/gns3server/compute/dynamips/adapters/pa_4e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_4E(Adapter): - """ PA-4E Ethernet port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_4t.py b/gns3server/compute/dynamips/adapters/pa_4t.py index beae79654..68aee25a0 100644 --- a/gns3server/compute/dynamips/adapters/pa_4t.py +++ b/gns3server/compute/dynamips/adapters/pa_4t.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_4T(Adapter): - """ PA-4T+ Serial port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_8e.py b/gns3server/compute/dynamips/adapters/pa_8e.py index b36173a23..f0db7c00b 100644 --- a/gns3server/compute/dynamips/adapters/pa_8e.py +++ b/gns3server/compute/dynamips/adapters/pa_8e.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_8E(Adapter): - """ PA-8E Ethernet port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_8t.py b/gns3server/compute/dynamips/adapters/pa_8t.py index 81d307c87..1b4dddfd8 100644 --- a/gns3server/compute/dynamips/adapters/pa_8t.py +++ b/gns3server/compute/dynamips/adapters/pa_8t.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_8T(Adapter): - """ PA-8T Serial port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_a1.py b/gns3server/compute/dynamips/adapters/pa_a1.py index b20efb700..9dcbf47a4 100644 --- a/gns3server/compute/dynamips/adapters/pa_a1.py +++ b/gns3server/compute/dynamips/adapters/pa_a1.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_A1(Adapter): - """ PA-A1 ATM port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_fe_tx.py b/gns3server/compute/dynamips/adapters/pa_fe_tx.py index 3a5f3fdb6..0ce6d94cb 100644 --- a/gns3server/compute/dynamips/adapters/pa_fe_tx.py +++ b/gns3server/compute/dynamips/adapters/pa_fe_tx.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_FE_TX(Adapter): - """ PA-FE-TX FastEthernet port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_ge.py b/gns3server/compute/dynamips/adapters/pa_ge.py index dde8d7ed0..e64007b83 100644 --- a/gns3server/compute/dynamips/adapters/pa_ge.py +++ b/gns3server/compute/dynamips/adapters/pa_ge.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_GE(Adapter): - """ PA-GE GigabitEthernet port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/pa_pos_oc3.py b/gns3server/compute/dynamips/adapters/pa_pos_oc3.py index d6b8487ca..9646d9250 100644 --- a/gns3server/compute/dynamips/adapters/pa_pos_oc3.py +++ b/gns3server/compute/dynamips/adapters/pa_pos_oc3.py @@ -18,7 +18,6 @@ from .adapter import Adapter class PA_POS_OC3(Adapter): - """ PA-POS-OC3 port adapter. """ diff --git a/gns3server/compute/dynamips/adapters/wic_1enet.py b/gns3server/compute/dynamips/adapters/wic_1enet.py index 1c8c98055..df099f674 100644 --- a/gns3server/compute/dynamips/adapters/wic_1enet.py +++ b/gns3server/compute/dynamips/adapters/wic_1enet.py @@ -16,7 +16,6 @@ class WIC_1ENET: - """ WIC-1ENET Ethernet """ diff --git a/gns3server/compute/dynamips/adapters/wic_1t.py b/gns3server/compute/dynamips/adapters/wic_1t.py index 95bc57d4b..9738525c9 100644 --- a/gns3server/compute/dynamips/adapters/wic_1t.py +++ b/gns3server/compute/dynamips/adapters/wic_1t.py @@ -16,7 +16,6 @@ class WIC_1T: - """ WIC-1T Serial """ diff --git a/gns3server/compute/dynamips/adapters/wic_2t.py b/gns3server/compute/dynamips/adapters/wic_2t.py index 2f32db654..cb88bc1f0 100644 --- a/gns3server/compute/dynamips/adapters/wic_2t.py +++ b/gns3server/compute/dynamips/adapters/wic_2t.py @@ -16,7 +16,6 @@ class WIC_2T: - """ WIC-2T Serial """ diff --git a/gns3server/compute/dynamips/dynamips_error.py b/gns3server/compute/dynamips/dynamips_error.py index 8f9140e09..cf7f82f2d 100644 --- a/gns3server/compute/dynamips/dynamips_error.py +++ b/gns3server/compute/dynamips/dynamips_error.py @@ -22,5 +22,4 @@ from ..error import NodeError class DynamipsError(NodeError): - pass diff --git a/gns3server/compute/dynamips/dynamips_factory.py b/gns3server/compute/dynamips/dynamips_factory.py index 35dbd2e86..cbafc4934 100644 --- a/gns3server/compute/dynamips/dynamips_factory.py +++ b/gns3server/compute/dynamips/dynamips_factory.py @@ -52,7 +52,6 @@ DEVICES = { class DynamipsFactory: - """ Factory to create an Router object based on the correct platform. """ diff --git a/gns3server/compute/dynamips/dynamips_hypervisor.py b/gns3server/compute/dynamips/dynamips_hypervisor.py index 997cfce60..514e75360 100644 --- a/gns3server/compute/dynamips/dynamips_hypervisor.py +++ b/gns3server/compute/dynamips/dynamips_hypervisor.py @@ -30,7 +30,6 @@ log = logging.getLogger(__name__) class DynamipsHypervisor: - """ Creates a new connection to a Dynamips server (also called hypervisor) @@ -90,7 +89,9 @@ class DynamipsHypervisor: if not connection_success: raise DynamipsError(f"Couldn't connect to hypervisor on {host}:{self._port} :{last_exception}") else: - log.debug(f"Connected to Dynamips hypervisor on {host}:{self._port} after {time.time() - begin:.4f} seconds") + log.debug( + f"Connected to Dynamips hypervisor on {host}:{self._port} after {time.time() - begin:.4f} seconds" + ) try: version = await self.send("hypervisor version") diff --git a/gns3server/compute/dynamips/hypervisor.py b/gns3server/compute/dynamips/hypervisor.py index 30d9a268b..403c38c7e 100644 --- a/gns3server/compute/dynamips/hypervisor.py +++ b/gns3server/compute/dynamips/hypervisor.py @@ -33,7 +33,6 @@ log = logging.getLogger(__name__) class Hypervisor(DynamipsHypervisor): - """ Hypervisor. diff --git a/gns3server/compute/dynamips/nios/nio.py b/gns3server/compute/dynamips/nios/nio.py index 5c5c9ec6d..512a10699 100644 --- a/gns3server/compute/dynamips/nios/nio.py +++ b/gns3server/compute/dynamips/nios/nio.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIO: - """ Base NIO class diff --git a/gns3server/compute/dynamips/nios/nio_generic_ethernet.py b/gns3server/compute/dynamips/nios/nio_generic_ethernet.py index cf2cc7509..1407c6694 100644 --- a/gns3server/compute/dynamips/nios/nio_generic_ethernet.py +++ b/gns3server/compute/dynamips/nios/nio_generic_ethernet.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIOGenericEthernet(NIO): - """ Dynamips generic Ethernet NIO. @@ -67,7 +66,4 @@ class NIOGenericEthernet(NIO): def asdict(self): - return { - "type": "nio_generic_ethernet", - "ethernet_device": self._ethernet_device - } + return {"type": "nio_generic_ethernet", "ethernet_device": self._ethernet_device} diff --git a/gns3server/compute/dynamips/nios/nio_linux_ethernet.py b/gns3server/compute/dynamips/nios/nio_linux_ethernet.py index 64f7e9385..c11039a5c 100644 --- a/gns3server/compute/dynamips/nios/nio_linux_ethernet.py +++ b/gns3server/compute/dynamips/nios/nio_linux_ethernet.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIOLinuxEthernet(NIO): - """ Dynamips Linux Ethernet NIO. @@ -66,7 +65,4 @@ class NIOLinuxEthernet(NIO): def asdict(self): - return { - "type": "nio_linux_ethernet", - "ethernet_device": self._ethernet_device - } + return {"type": "nio_linux_ethernet", "ethernet_device": self._ethernet_device} diff --git a/gns3server/compute/dynamips/nios/nio_null.py b/gns3server/compute/dynamips/nios/nio_null.py index 08144646c..1f56a12e8 100644 --- a/gns3server/compute/dynamips/nios/nio_null.py +++ b/gns3server/compute/dynamips/nios/nio_null.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIONull(NIO): - """ Dynamips NULL NIO. diff --git a/gns3server/compute/dynamips/nios/nio_tap.py b/gns3server/compute/dynamips/nios/nio_tap.py index 50efd5534..acd3a864e 100644 --- a/gns3server/compute/dynamips/nios/nio_tap.py +++ b/gns3server/compute/dynamips/nios/nio_tap.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIOTAP(NIO): - """ Dynamips TAP NIO. @@ -60,7 +59,4 @@ class NIOTAP(NIO): def asdict(self): - return { - "type": "nio_tap", - "tap_device": self._tap_device - } + return {"type": "nio_tap", "tap_device": self._tap_device} diff --git a/gns3server/compute/dynamips/nios/nio_udp.py b/gns3server/compute/dynamips/nios/nio_udp.py index 6a7590cbd..c811858d9 100644 --- a/gns3server/compute/dynamips/nios/nio_udp.py +++ b/gns3server/compute/dynamips/nios/nio_udp.py @@ -31,7 +31,6 @@ log = logging.getLogger(__name__) class NIOUDP(NIO): - """ Dynamips UDP NIO. @@ -130,9 +129,4 @@ class NIOUDP(NIO): def asdict(self): - return { - "type": "nio_udp", - "lport": self._lport, - "rport": self._rport, - "rhost": self._rhost - } + return {"type": "nio_udp", "lport": self._lport, "rport": self._rport, "rhost": self._rhost} diff --git a/gns3server/compute/dynamips/nios/nio_unix.py b/gns3server/compute/dynamips/nios/nio_unix.py index e6edaa4ba..bab9ee06e 100644 --- a/gns3server/compute/dynamips/nios/nio_unix.py +++ b/gns3server/compute/dynamips/nios/nio_unix.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIOUNIX(NIO): - """ Dynamips UNIX NIO. @@ -81,8 +80,4 @@ class NIOUNIX(NIO): def asdict(self): - return { - "type": "nio_unix", - "local_file": self._local_file, - "remote_file": self._remote_file - } + return {"type": "nio_unix", "local_file": self._local_file, "remote_file": self._remote_file} diff --git a/gns3server/compute/dynamips/nios/nio_vde.py b/gns3server/compute/dynamips/nios/nio_vde.py index d65687739..7218179ce 100644 --- a/gns3server/compute/dynamips/nios/nio_vde.py +++ b/gns3server/compute/dynamips/nios/nio_vde.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class NIOVDE(NIO): - """ Dynamips VDE NIO. @@ -81,8 +80,4 @@ class NIOVDE(NIO): def asdict(self): - return { - "type": "nio_vde", - "local_file": self._local_file, - "control_file": self._control_file - } + return {"type": "nio_vde", "local_file": self._local_file, "control_file": self._control_file} diff --git a/gns3server/compute/dynamips/nodes/atm_switch.py b/gns3server/compute/dynamips/nodes/atm_switch.py index c97b3ad35..38e9d4ecb 100644 --- a/gns3server/compute/dynamips/nodes/atm_switch.py +++ b/gns3server/compute/dynamips/nodes/atm_switch.py @@ -32,7 +32,6 @@ log = logging.getLogger(__name__) class ATMSwitch(Device): - """ Dynamips ATM switch. diff --git a/gns3server/compute/dynamips/nodes/bridge.py b/gns3server/compute/dynamips/nodes/bridge.py index 4b8bfcaf4..04c1651f8 100644 --- a/gns3server/compute/dynamips/nodes/bridge.py +++ b/gns3server/compute/dynamips/nodes/bridge.py @@ -24,7 +24,6 @@ from .device import Device class Bridge(Device): - """ Dynamips bridge. diff --git a/gns3server/compute/dynamips/nodes/c1700.py b/gns3server/compute/dynamips/nodes/c1700.py index 55b8dc4dc..b2d24b25b 100644 --- a/gns3server/compute/dynamips/nodes/c1700.py +++ b/gns3server/compute/dynamips/nodes/c1700.py @@ -30,7 +30,6 @@ log = logging.getLogger(__name__) class C1700(Router): - """ Dynamips c1700 router. diff --git a/gns3server/compute/dynamips/nodes/c2600.py b/gns3server/compute/dynamips/nodes/c2600.py index 8c39f43b2..774d67e54 100644 --- a/gns3server/compute/dynamips/nodes/c2600.py +++ b/gns3server/compute/dynamips/nodes/c2600.py @@ -32,7 +32,6 @@ log = logging.getLogger(__name__) class C2600(Router): - """ Dynamips c2600 router. diff --git a/gns3server/compute/dynamips/nodes/c2691.py b/gns3server/compute/dynamips/nodes/c2691.py index 3ef3f73ff..c28d1c161 100644 --- a/gns3server/compute/dynamips/nodes/c2691.py +++ b/gns3server/compute/dynamips/nodes/c2691.py @@ -30,7 +30,6 @@ log = logging.getLogger(__name__) class C2691(Router): - """ Dynamips c2691 router. diff --git a/gns3server/compute/dynamips/nodes/c3600.py b/gns3server/compute/dynamips/nodes/c3600.py index fc10a1629..d4d5f7235 100644 --- a/gns3server/compute/dynamips/nodes/c3600.py +++ b/gns3server/compute/dynamips/nodes/c3600.py @@ -29,7 +29,6 @@ log = logging.getLogger(__name__) class C3600(Router): - """ Dynamips c3600 router. diff --git a/gns3server/compute/dynamips/nodes/c3725.py b/gns3server/compute/dynamips/nodes/c3725.py index c20cdbecf..3f9c22e1d 100644 --- a/gns3server/compute/dynamips/nodes/c3725.py +++ b/gns3server/compute/dynamips/nodes/c3725.py @@ -30,7 +30,6 @@ log = logging.getLogger(__name__) class C3725(Router): - """ Dynamips c3725 router. diff --git a/gns3server/compute/dynamips/nodes/c3745.py b/gns3server/compute/dynamips/nodes/c3745.py index 9da5de8d5..36eac087d 100644 --- a/gns3server/compute/dynamips/nodes/c3745.py +++ b/gns3server/compute/dynamips/nodes/c3745.py @@ -30,7 +30,6 @@ log = logging.getLogger(__name__) class C3745(Router): - """ Dynamips c3745 router. diff --git a/gns3server/compute/dynamips/nodes/c7200.py b/gns3server/compute/dynamips/nodes/c7200.py index fe2f2b039..98541b87f 100644 --- a/gns3server/compute/dynamips/nodes/c7200.py +++ b/gns3server/compute/dynamips/nodes/c7200.py @@ -32,7 +32,6 @@ log = logging.getLogger(__name__) class C7200(Router): - """ Dynamips c7200 router (model is 7206). diff --git a/gns3server/compute/dynamips/nodes/device.py b/gns3server/compute/dynamips/nodes/device.py index 898f0ef9b..ed44e04d5 100644 --- a/gns3server/compute/dynamips/nodes/device.py +++ b/gns3server/compute/dynamips/nodes/device.py @@ -16,7 +16,6 @@ class Device: - """ Base device for switches and hubs diff --git a/gns3server/compute/dynamips/nodes/ethernet_hub.py b/gns3server/compute/dynamips/nodes/ethernet_hub.py index 46f8cc5a4..e8ea75062 100644 --- a/gns3server/compute/dynamips/nodes/ethernet_hub.py +++ b/gns3server/compute/dynamips/nodes/ethernet_hub.py @@ -29,7 +29,6 @@ log = logging.getLogger(__name__) class EthernetHub(Bridge): - """ Dynamips Ethernet hub (based on Bridge) diff --git a/gns3server/compute/dynamips/nodes/ethernet_switch.py b/gns3server/compute/dynamips/nodes/ethernet_switch.py index 016322141..df3adf349 100644 --- a/gns3server/compute/dynamips/nodes/ethernet_switch.py +++ b/gns3server/compute/dynamips/nodes/ethernet_switch.py @@ -68,7 +68,6 @@ log = logging.getLogger(__name__) class EthernetSwitch(Device): - """ Dynamips Ethernet switch. diff --git a/gns3server/compute/dynamips/nodes/frame_relay_switch.py b/gns3server/compute/dynamips/nodes/frame_relay_switch.py index 32c920898..5839e1cdf 100644 --- a/gns3server/compute/dynamips/nodes/frame_relay_switch.py +++ b/gns3server/compute/dynamips/nodes/frame_relay_switch.py @@ -31,7 +31,6 @@ log = logging.getLogger(__name__) class FrameRelaySwitch(Device): - """ Dynamips Frame Relay switch. diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 6a09cae5f..4bc1f77b5 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -42,7 +42,6 @@ from gns3server.utils.images import md5sum class Router(BaseNode): - """ Dynamips router implementation. @@ -80,7 +79,14 @@ class Router(BaseNode): raise DynamipsError(f"{name} is an invalid name to create a Dynamips node") super().__init__( - name, node_id, project, manager, console=console, console_type=console_type, aux=aux, aux_type=aux_type, + name, + node_id, + project, + manager, + console=console, + console_type=console_type, + aux=aux, + aux_type=aux_type, wrap_console=(console_type == "ssh"), wrap_aux=(aux_type == "ssh"), ) @@ -242,7 +248,6 @@ class Router(BaseNode): ) if not self._ghost_flag: - log.debug( 'Router {platform} "{name}" [{id}] has been created'.format( name=self._name, platform=self._platform, id=self._id @@ -252,7 +257,9 @@ class Router(BaseNode): if self._console is not None: # For SSH console, tell Dynamips to listen on the internal port so that # the AsyncioSSHServer proxy can wrap it on the external console port. - con_port = self._internal_console_port if self._wrap_console and self._internal_console_port else self._console + con_port = ( + self._internal_console_port if self._wrap_console and self._internal_console_port else self._console + ) await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {con_port}') if self.aux is not None: @@ -287,7 +294,6 @@ class Router(BaseNode): if status == "suspended": await self.resume() elif status == "inactive": - if not os.path.isfile(self._image) or not os.path.exists(self._image): if os.path.islink(self._image): raise DynamipsError( @@ -1026,8 +1032,11 @@ class Router(BaseNode): if self.console_type != console_type: status = await self.get_status() if status == "running": - raise DynamipsError('"{name}" must be stopped to change the console type to {console_type}'.format(name=self._name, - console_type=console_type)) + raise DynamipsError( + '"{name}" must be stopped to change the console type to {console_type}'.format( + name=self._name, console_type=console_type + ) + ) self.console_type = console_type @@ -1068,9 +1077,10 @@ class Router(BaseNode): if self.aux_type != aux_type: status = await self.get_status() if status == "running": - raise DynamipsError('"{name}" must be stopped to change the auxiliary console type to {aux_type}'.format( - name=self._name, - aux_type=aux_type) + raise DynamipsError( + '"{name}" must be stopped to change the auxiliary console type to {aux_type}'.format( + name=self._name, aux_type=aux_type + ) ) self.aux_type = aux_type @@ -1228,7 +1238,6 @@ class Router(BaseNode): # Generate an OIR event if the router is running if is_running: - await self._hypervisor.send( 'vm slot_oir_start "{name}" {slot_number} 0'.format(name=self._name, slot_number=slot_number) ) @@ -1274,7 +1283,6 @@ class Router(BaseNode): # Generate an OIR event if the router is running if is_running: - await self._hypervisor.send( 'vm slot_oir_stop "{name}" {slot_number} 0'.format(name=self._name, slot_number=slot_number) ) @@ -1722,7 +1730,9 @@ class Router(BaseNode): """ if not is_ios_hostname_valid(new_name): - raise DynamipsError(f"{new_name} is an invalid name to rename router '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name must start with a letter, end with a letter or digit, and be 63 characters or fewer.") + raise DynamipsError( + f"{new_name} is an invalid name to rename router '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name must start with a letter, end with a letter or digit, and be 63 characters or fewer." + ) await self._hypervisor.send(f'vm rename "{self._name}" "{new_name}"') diff --git a/gns3server/compute/iou/__init__.py b/gns3server/compute/iou/__init__.py index 31e7878db..afe2766a2 100644 --- a/gns3server/compute/iou/__init__.py +++ b/gns3server/compute/iou/__init__.py @@ -31,7 +31,6 @@ log = logging.getLogger(__name__) class IOU(BaseManager): - _NODE_CLASS = IOUVM _NODE_TYPE = "iou" diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index ff46bb4fb..4e329aad5 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -338,13 +338,11 @@ class IOUVM(BaseNode): if elf_header_start[4] == 1: # 32-bit loader loader = os.path.join(self._lib_base, "lib", "ld-linux.so.2") - lib_path = (os.path.join(self._lib_base, "lib"), - os.path.join(self._lib_base, "lib", "i386-linux-gnu")) + lib_path = (os.path.join(self._lib_base, "lib"), os.path.join(self._lib_base, "lib", "i386-linux-gnu")) else: # 64-bit loader loader = os.path.join(self._lib_base, "lib64", "ld-linux-x86-64.so.2") - lib_path = (os.path.join(self._lib_base, "lib64"), - os.path.join(self._lib_base, "lib", "x86_64-linux-gnu")) + lib_path = (os.path.join(self._lib_base, "lib64"), os.path.join(self._lib_base, "lib", "x86_64-linux-gnu")) self._loader = [] if os.path.isfile(loader): try: @@ -475,7 +473,9 @@ class IOUVM(BaseNode): """ if not is_ios_hostname_valid(new_name): - raise IOUError(f"'{new_name}' is an invalid name to rename IOU node '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name must start with a letter, end with a letter or digit, and be 63 characters or fewer.") + raise IOUError( + f"'{new_name}' is an invalid name to rename IOU node '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name must start with a letter, end with a letter or digit, and be 63 characters or fewer." + ) if self.startup_config_file: content = self.startup_config_content content = re.sub(r"hostname .+$", "hostname " + new_name, content, flags=re.MULTILINE) @@ -613,7 +613,7 @@ class IOUVM(BaseNode): raise IOUError(f"Invalid hostid detected: {hostid}") for x in hostname: ioukey += ord(x) - pad1 = b"\x4B\x58\x21\x81\x56\x7B\x0D\xF3\x21\x43\x9B\x7E\xAC\x1D\xE6\x8A" + pad1 = b"\x4b\x58\x21\x81\x56\x7b\x0d\xf3\x21\x43\x9b\x7e\xac\x1d\xe6\x8a" pad2 = b"\x80" + 39 * b"\0" ioukey = hashlib.md5(pad1 + pad2 + struct.pack("!I", ioukey) + pad1).hexdigest()[:16] if ioukey != user_ioukey: @@ -668,7 +668,6 @@ class IOUVM(BaseNode): await self._check_requirements() if not self.is_running(): - await self._library_check() try: @@ -709,7 +708,7 @@ class IOUVM(BaseNode): if len(iou_image_path) > 63: # IOU file basename length must be <= 63 chars iou_file_name, iou_file_ext = os.path.splitext(iou_image_path) - iou_image_path = iou_file_name[:63 - len(iou_file_ext)] + iou_file_ext + iou_image_path = iou_file_name[: 63 - len(iou_file_ext)] + iou_file_ext symlink = os.path.join(self.working_dir, iou_image_path) if os.path.islink(symlink): os.unlink(symlink) @@ -729,7 +728,8 @@ class IOUVM(BaseNode): log.debug(f"Starting IOU: {command}") self.command_line = " ".join(command) self._iou_process = await asyncio.create_subprocess_exec( - *self._loader, *command, + *self._loader, + *command, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE, stderr=subprocess.STDOUT, @@ -743,7 +743,9 @@ class IOUVM(BaseNode): gns3server.utils.asyncio.monitor_process(self._iou_process, callback) except FileNotFoundError as e: self._stop_l1_keepalive_responder() - raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead") + raise IOUError( + f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead" + ) except (OSError, subprocess.SubprocessError) as e: self._stop_l1_keepalive_responder() iou_stdout = self.read_iou_stdout() @@ -775,8 +777,7 @@ class IOUVM(BaseNode): await self.stop() raise IOUError( "Could not start {} server on socket {}:{}: {}".format( - error_prefix, - self._manager.port_manager.console_host, self.console, e + error_prefix, self._manager.port_manager.console_host, self.console, e ) ) @@ -1281,13 +1282,11 @@ class IOUVM(BaseNode): """ from gns3server.compute.marker.marker_manager import MarkerManager - markers = nio.markers if hasattr(nio, 'markers') else {} + markers = nio.markers if hasattr(nio, "markers") else {} manager = MarkerManager.instance() markers_dir = self.project.markers_working_directory() bridge_name = f"IOL-BRIDGE-{self.application_id + 512}" - location = "{bridge_name} {bay} {unit}".format( - bridge_name=bridge_name, bay=adapter_number, unit=port_number - ) + location = "{bridge_name} {bay} {unit}".format(bridge_name=bridge_name, bay=adapter_number, unit=port_number) desired = {(name, spec.get("link_id", "")): spec for name, spec in markers.items()} # 1. Remove installed markers that are no longer desired. @@ -1331,9 +1330,7 @@ class IOUVM(BaseNode): continue pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap") # iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}" - cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format( - loc=location, name=name, bpf=bpf - ) + cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(loc=location, name=name, bpf=bpf) if tag is not None: cmd += f" tag {tag}" if link_id: diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py index a30769c1e..4dff2ab18 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -103,9 +103,7 @@ class MarkerListener(asyncio.DatagramProtocol): project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name) if project_id is None: - log.warning( - "MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name - ) + log.warning("MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name) return # `link=` is the authoritative per-link id (opaque, set by gns3server at diff --git a/gns3server/compute/marker/marker_manager.py b/gns3server/compute/marker/marker_manager.py index 132a19812..7c5fe8b36 100644 --- a/gns3server/compute/marker/marker_manager.py +++ b/gns3server/compute/marker/marker_manager.py @@ -86,24 +86,18 @@ class MarkerManager: sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024) try: - self._transport, _ = await loop.create_datagram_endpoint( - lambda: self._listener, local_addr=(host, port) - ) + self._transport, _ = await loop.create_datagram_endpoint(lambda: self._listener, local_addr=(host, port)) _configure_transport(self._transport) except OSError: if port != 0: - log.warning( - "Marker listener: port %s unavailable, falling back to OS-assigned port", port - ) + log.warning("Marker listener: port %s unavailable, falling back to OS-assigned port", port) try: self._transport, _ = await loop.create_datagram_endpoint( lambda: self._listener, local_addr=(host, 0) ) _configure_transport(self._transport) except OSError as e: - log.error( - "Marker listener startup failed: %s. Traffic insight signals are unavailable.", e - ) + log.error("Marker listener startup failed: %s. Traffic insight signals are unavailable.", e) self._listener = None return else: @@ -132,7 +126,9 @@ class MarkerManager: if received: log.info( "marker sink: %d matches (%.0f/s), %d errors in last 10s", - received, received / 10.0, errors, + received, + received / 10.0, + errors, ) async def stop(self): diff --git a/gns3server/compute/nios/nio.py b/gns3server/compute/nios/nio.py index 6fe57a130..0de5e4cfc 100644 --- a/gns3server/compute/nios/nio.py +++ b/gns3server/compute/nios/nio.py @@ -20,7 +20,6 @@ Base interface for NIOs. class NIO: - """ IOU NIO. """ diff --git a/gns3server/compute/nios/nio_ethernet.py b/gns3server/compute/nios/nio_ethernet.py index 0f7b48d31..0dc153c54 100644 --- a/gns3server/compute/nios/nio_ethernet.py +++ b/gns3server/compute/nios/nio_ethernet.py @@ -22,7 +22,6 @@ from .nio import NIO class NIOEthernet(NIO): - """ Generic Ethernet NIO. @@ -50,7 +49,4 @@ class NIOEthernet(NIO): def asdict(self): - return { - "type": "nio_ethernet", - "ethernet_device": self._ethernet_device - } + return {"type": "nio_ethernet", "ethernet_device": self._ethernet_device} diff --git a/gns3server/compute/nios/nio_tap.py b/gns3server/compute/nios/nio_tap.py index a81b0906f..ac26bb662 100644 --- a/gns3server/compute/nios/nio_tap.py +++ b/gns3server/compute/nios/nio_tap.py @@ -22,7 +22,6 @@ from .nio import NIO class NIOTAP(NIO): - """ TAP NIO. @@ -50,7 +49,4 @@ class NIOTAP(NIO): def asdict(self): - return { - "type": "nio_tap", - "tap_device": self._tap_device - } + return {"type": "nio_tap", "tap_device": self._tap_device} diff --git a/gns3server/compute/nios/nio_udp.py b/gns3server/compute/nios/nio_udp.py index e6f1bd8bc..0003e0744 100644 --- a/gns3server/compute/nios/nio_udp.py +++ b/gns3server/compute/nios/nio_udp.py @@ -22,7 +22,6 @@ from .nio import NIO class NIOUDP(NIO): - """ UDP NIO. @@ -81,5 +80,5 @@ class NIOUDP(NIO): "rhost": self._rhost, "suspend": self._suspended, "filters": self._filters, - "markers": self._markers + "markers": self._markers, } diff --git a/gns3server/compute/port_manager.py b/gns3server/compute/port_manager.py index dbb18fc0e..ece70c3ab 100644 --- a/gns3server/compute/port_manager.py +++ b/gns3server/compute/port_manager.py @@ -95,7 +95,6 @@ BANNED_PORTS = { class PortManager: - """ :param host: IP address to bind for console connections """ @@ -318,7 +317,9 @@ class PortManager: if port in self._used_tcp_ports: old_port = port port = self.get_free_tcp_port(project, port_range_start=port_range_start, port_range_end=port_range_end) - msg = f"TCP port {old_port} already in use on host {self._console_host}. Port has been replaced by {port}" + msg = ( + f"TCP port {old_port} already in use on host {self._console_host}. Port has been replaced by {port}" + ) log.debug(msg) return port if port < port_range_start or port > port_range_end: @@ -335,7 +336,9 @@ class PortManager: except OSError: old_port = port port = self.get_free_tcp_port(project, port_range_start=port_range_start, port_range_end=port_range_end) - msg = f"TCP port {old_port} already in use on host {self._console_host}. Port has been replaced by {port}" + msg = ( + f"TCP port {old_port} already in use on host {self._console_host}. Port has been replaced by {port}" + ) log.debug(msg) return port @@ -395,8 +398,7 @@ class PortManager: if port < self._udp_port_range[0] or port > self._udp_port_range[1]: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"UDP port {port} is outside the range " - f"{self._udp_port_range[0]}-{self._udp_port_range[1]}", + detail=f"UDP port {port} is outside the range {self._udp_port_range[0]}-{self._udp_port_range[1]}", ) self._used_udp_ports.add(port) project.record_udp_port(port) diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index ae3b5c8d1..6f683d5bc 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -37,7 +37,6 @@ log = logging.getLogger(__name__) class Project: - """ A project contains a list of nodes. In theory nodes are isolated project/project. @@ -82,11 +81,7 @@ class Project: def asdict(self): - return { - "name": self._name, - "project_id": self._id, - "variables": self._variables - } + return {"name": self._name, "project_id": self._id, "variables": self._variables} @property def id(self): @@ -461,15 +456,14 @@ class Project: # Security check: ensure the path is within the node directory if not os.path.commonpath([target_path, node_full_path]) == node_full_path: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail="Path is outside the node directory") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Path is outside the node directory") if not os.path.exists(target_path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="Path not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Path not found") if recursive: - return await self._list_node_files_recursive(node_full_path, target_path, - node_full_path if subpath else None) + return await self._list_node_files_recursive( + node_full_path, target_path, node_full_path if subpath else None + ) # Non-recursive: list only the current directory level files = [] @@ -492,20 +486,20 @@ class Project: modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): created_at = modified_at = "" - files.append({ - "path": rel_path, - "size": stat_info.st_size, - "created_at": created_at, - "modified_at": modified_at, - "file_type": "directory" - }) + files.append( + { + "path": rel_path, + "size": stat_info.st_size, + "created_at": created_at, + "modified_at": modified_at, + "file_type": "directory", + } + ) else: if name.endswith(".ghost"): continue try: - file_type = await wait_run_in_executor( - lambda e=entry: magic.from_file(e.path, mime=False) - ) + file_type = await wait_run_in_executor(lambda e=entry: magic.from_file(e.path, mime=False)) except Exception as e: log.warning(f"Error getting file type for '{rel_path}': {e}") file_type = "" @@ -514,13 +508,15 @@ class Project: modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): created_at = modified_at = "" - files.append({ - "path": rel_path, - "size": stat_info.st_size, - "created_at": created_at, - "modified_at": modified_at, - "file_type": file_type - }) + files.append( + { + "path": rel_path, + "size": stat_info.st_size, + "created_at": created_at, + "modified_at": modified_at, + "file_type": file_type, + } + ) except OSError: continue return files @@ -543,13 +539,15 @@ class Project: modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): created_at = modified_at = "" - files.append({ - "path": os.path.relpath(dir_full_path, base_path), - "size": stat_info.st_size, - "created_at": created_at, - "modified_at": modified_at, - "file_type": "directory" - }) + files.append( + { + "path": os.path.relpath(dir_full_path, base_path), + "size": stat_info.st_size, + "created_at": created_at, + "modified_at": modified_at, + "file_type": "directory", + } + ) except OSError: continue @@ -561,9 +559,7 @@ class Project: try: stat_info = await wait_run_in_executor(os.stat, file_path) try: - file_type = await wait_run_in_executor( - lambda fp=file_path: magic.from_file(fp, mime=False) - ) + file_type = await wait_run_in_executor(lambda fp=file_path: magic.from_file(fp, mime=False)) except Exception as e: log.warning(f"Error getting file type for '{rel_path}': {e}") file_type = "" @@ -573,13 +569,15 @@ class Project: except (OSError, OverflowError, ValueError) as e: log.warning(f"Invalid timestamp for '{rel_path}': {e}") created_at = modified_at = "" - files.append({ - "path": rel_path, - "size": stat_info.st_size, - "created_at": created_at, - "modified_at": modified_at, - "file_type": file_type - }) + files.append( + { + "path": rel_path, + "size": stat_info.st_size, + "created_at": created_at, + "modified_at": modified_at, + "file_type": file_type, + } + ) except OSError as e: log.warning(f"Error getting metadata for file '{rel_path}': {e}") continue diff --git a/gns3server/compute/project_manager.py b/gns3server/compute/project_manager.py index a2ab578d2..91f4757b4 100644 --- a/gns3server/compute/project_manager.py +++ b/gns3server/compute/project_manager.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class ProjectManager: - """ This singleton keeps track of available projects. """ @@ -106,7 +105,7 @@ class ProjectManager: if project_id is not None and project_id in self._projects: return self._projects[project_id] project = Project(name=name, project_id=project_id, path=path, variables=variables) - #self._check_available_disk_space(project) # FIXME: disabled for now + # self._check_available_disk_space(project) # FIXME: disabled for now self._projects[project.id] = project return project diff --git a/gns3server/compute/qemu/__init__.py b/gns3server/compute/qemu/__init__.py index 1bb0f06eb..f01fdb68e 100644 --- a/gns3server/compute/qemu/__init__.py +++ b/gns3server/compute/qemu/__init__.py @@ -42,7 +42,6 @@ log = logging.getLogger(__name__) class Qemu(BaseManager): - _NODE_CLASS = QemuVM _NODE_TYPE = "qemu" @@ -252,17 +251,21 @@ class Qemu(BaseManager): :returns: HAXM version number. Returns None if HAXM is not installed. """ - assert(sys.platform.startswith("win")) + assert sys.platform.startswith("win") import winreg - hkey = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products") + hkey = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products" + ) version = None for index in range(winreg.QueryInfoKey(hkey)[0]): product_id = winreg.EnumKey(hkey, index) try: product_key = winreg.OpenKey(hkey, r"{}\InstallProperties".format(product_id)) try: - if winreg.QueryValueEx(product_key, "DisplayName")[0].endswith("Hardware Accelerated Execution Manager"): + if winreg.QueryValueEx(product_key, "DisplayName")[0].endswith( + "Hardware Accelerated Execution Manager" + ): version = winreg.QueryValueEx(product_key, "DisplayVersion")[0] break finally: diff --git a/gns3server/compute/qemu/qemu_error.py b/gns3server/compute/qemu/qemu_error.py index 90659215d..2d4a9df7b 100644 --- a/gns3server/compute/qemu/qemu_error.py +++ b/gns3server/compute/qemu/qemu_error.py @@ -22,5 +22,4 @@ from ..error import NodeError class QemuError(NodeError): - pass diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index d6b0318b5..25721b309 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -57,10 +57,8 @@ import logging log = logging.getLogger(__name__) # forbidden additional options -FORBIDDEN_OPTIONS = {"-blockdev", "-drive", "-hda", "-hdb", "-hdc", "-hdd", - "-fsdev", "-virtfs", "-nic", "-netdev"} -FORBIDDEN_OPTIONS |= {"-" + opt for opt in FORBIDDEN_OPTIONS - if opt.startswith("-") and not opt.startswith("--")} +FORBIDDEN_OPTIONS = {"-blockdev", "-drive", "-hda", "-hdb", "-hdc", "-hdd", "-fsdev", "-virtfs", "-nic", "-netdev"} +FORBIDDEN_OPTIONS |= {"-" + opt for opt in FORBIDDEN_OPTIONS if opt.startswith("-") and not opt.startswith("--")} class QemuVM(BaseNode): @@ -132,8 +130,10 @@ class QemuVM(BaseNode): except QemuError: # If the binary is not found for topologies 1.4 and later # search via the platform otherwise use the binary name - log.warning(f"Could not find the QEMU binary {qemu_path} on this system, " - f"trying to find one using platform {platform}") + log.warning( + f"Could not find the QEMU binary {qemu_path} on this system, " + f"trying to find one using platform {platform}" + ) if platform: self.platform = platform else: @@ -198,7 +198,9 @@ class QemuVM(BaseNode): """ if not is_rfc1123_hostname_valid(new_name): - raise QemuError(f"'{new_name}' is an invalid name to rename Qemu node '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen.") + raise QemuError( + f"'{new_name}' is an invalid name to rename Qemu node '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen." + ) super(QemuVM, QemuVM).name.__set__(self, new_name) @property @@ -267,7 +269,7 @@ class QemuVM(BaseNode): if qemu_bin == "qemu": self._platform = "i386" else: - self._platform = re.sub(r'^qemu-system-(\w+).*$', r'\1', qemu_bin, flags=re.IGNORECASE) + self._platform = re.sub(r"^qemu-system-(\w+).*$", r"\1", qemu_bin, flags=re.IGNORECASE) if self._platform.split(".")[0] not in list(QemuPlatform): raise QemuError(f"Platform {self._platform} is unknown") log.debug(f'QEMU VM "{self._name}" [{self._name}] has set the QEMU path to {qemu_path}') @@ -1082,9 +1084,7 @@ class QemuVM(BaseNode): else: priority = 0 try: - process = await asyncio.create_subprocess_exec( - "renice", "-n", str(priority), "-p", str(self._process.pid) - ) + process = await asyncio.create_subprocess_exec("renice", "-n", str(priority), "-p", str(self._process.pid)) await process.wait() except (OSError, subprocess.SubprocessError) as e: log.error(f'Could not change process priority for QEMU VM "{self._name}": {e}') @@ -1108,11 +1108,18 @@ class QemuVM(BaseNode): try: if sys.platform.startswith("win") and hasattr(sys, "frozen"): - cpulimit_exec = os.path.join(os.path.dirname(os.path.abspath(sys.executable)), "cpulimit", "cpulimit.exe") + cpulimit_exec = os.path.join( + os.path.dirname(os.path.abspath(sys.executable)), "cpulimit", "cpulimit.exe" + ) else: cpulimit_exec = "cpulimit" - command = [cpulimit_exec, "--lazy", "--pid={}".format(self._process.pid), "--limit={}".format(self._cpu_throttling)] + command = [ + cpulimit_exec, + "--lazy", + "--pid={}".format(self._process.pid), + "--limit={}".format(self._cpu_throttling), + ] self._cpulimit_process = subprocess.Popen(command, cwd=self.working_dir) log.debug(f"CPU throttled to {self._cpu_throttling}%") except FileNotFoundError: @@ -1251,10 +1258,12 @@ class QemuVM(BaseNode): if "TPM result for CMD_INIT" in qemu_stdout: partition = self._find_partition_for_path(self.project.path) if partition and partition.mountpoint != "/": - qemu_stdout += "\nTPM error: the project directory is not on the same partition as the root directory which can be a problem when using AppArmor.\n" \ - "Please try to execute the following commands on the server:\n\n" \ - "echo 'owner {}/** rwk,' | sudo tee /etc/apparmor.d/local/usr.bin.swtpm > /dev/null\n" \ - "sudo service apparmor restart".format(os.path.dirname(self.project.path)) + qemu_stdout += ( + "\nTPM error: the project directory is not on the same partition as the root directory which can be a problem when using AppArmor.\n" + "Please try to execute the following commands on the server:\n\n" + "echo 'owner {}/** rwk,' | sudo tee /etc/apparmor.d/local/usr.bin.swtpm > /dev/null\n" + "sudo service apparmor restart".format(os.path.dirname(self.project.path)) + ) self.project.emit( "log.error", {"message": f"QEMU process has stopped, return code: {returncode}\n{qemu_stdout}"}, @@ -1272,7 +1281,6 @@ class QemuVM(BaseNode): if self.is_running(): log.debug(f'Stopping QEMU VM "{self._name}" PID={self._process.pid}') try: - if self.on_close == "save_vm_state": await self._control_vm("stop") await self._control_vm("savevm GNS3_SAVED_STATE") @@ -1399,7 +1407,6 @@ class QemuVM(BaseNode): """ if self.is_running() and self._monitor: - reader, writer = await self._open_qemu_monitor_connection_vm() if reader is None and writer is None: return @@ -1729,7 +1736,9 @@ class QemuVM(BaseNode): retcode = await self._qemu_img_exec(command) if retcode: stdout = self.read_qemu_img_stdout() - raise QemuError(f"Could not create '{disk_name}' disk image: qemu-img returned with {retcode}\n{stdout}") + raise QemuError( + f"Could not create '{disk_name}' disk image: qemu-img returned with {retcode}\n{stdout}" + ) else: log.debug(f"QEMU VM '{self.name}' [{self.id}]: Qemu disk image'{disk_name}' created") except (OSError, subprocess.SubprocessError) as e: @@ -1757,7 +1766,9 @@ class QemuVM(BaseNode): retcode = await self._qemu_img_exec(command) if retcode: stdout = self.read_qemu_img_stdout() - raise QemuError(f"Could not update '{disk_name}' disk image: qemu-img returned with {retcode}\n{stdout}") + raise QemuError( + f"Could not update '{disk_name}' disk image: qemu-img returned with {retcode}\n{stdout}" + ) else: log.debug(f"QEMU VM '{self.name}' [{self.id}]: Qemu disk image '{disk_name}' extended by {extend} MB") except (OSError, subprocess.SubprocessError) as e: @@ -1894,9 +1905,7 @@ class QemuVM(BaseNode): console_host = "::" except OSError as e: raise QemuError("Could not check if IPv6 is enabled: {}".format(e)) - return ["-spice", - f"addr={console_host},port={port},disable-ticketing", - "-vga", "qxl"] + return ["-spice", f"addr={console_host},port={port},disable-ticketing", "-vga", "qxl"] else: return [] @@ -2015,11 +2024,31 @@ class QemuVM(BaseNode): if base_qcow2 and base_qcow2.crypt_method: # Workaround for https://gitlab.com/qemu-project/qemu/-/issues/441 # (we have to pass -u and the size). Also embed secret name. - command = [qemu_img_path, "create", "-b", backing_options, - "-F", backing_file_format, "-f", "qcow2", "-u", disk, str(base_qcow2.size)] + command = [ + qemu_img_path, + "create", + "-b", + backing_options, + "-F", + backing_file_format, + "-f", + "qcow2", + "-u", + disk, + str(base_qcow2.size), + ] else: - command = [qemu_img_path, "create", "-o", "backing_file={}".format(disk_image), - "-F", backing_file_format, "-f", "qcow2", disk] + command = [ + qemu_img_path, + "create", + "-o", + "backing_file={}".format(disk_image), + "-F", + backing_file_format, + "-f", + "qcow2", + disk, + ] retcode = await self._qemu_img_exec(command) if retcode: @@ -2186,8 +2215,7 @@ class QemuVM(BaseNode): if not os.path.isfile(disk_image) or not os.path.exists(disk_image): if os.path.islink(disk_image): raise QemuError( - f"'{disk_name}' disk image linked to " - f"'{os.path.realpath(disk_image)}' is not accessible" + f"'{disk_name}' disk image linked to '{os.path.realpath(disk_image)}' is not accessible" ) else: raise QemuError(f"'{disk_image}' is not accessible") @@ -2203,7 +2231,7 @@ class QemuVM(BaseNode): if await self._qemu_img_exec([qemu_img_path, "check", "-r", "leaks", f"{disk_image}"]) == 3: self.project.emit( "log.warning", - {"message": f"Disk image '{disk_image}' has leaked clusters and could not be fixed"} + {"message": f"Disk image '{disk_image}' has leaked clusters and could not be fixed"}, ) elif retcode == 2: # image is corrupted, let's try to fix it @@ -2211,15 +2239,14 @@ class QemuVM(BaseNode): if await self._qemu_img_exec([qemu_img_path, "check", "-r", "all", f"{disk_image}"]) == 2: self.project.emit( "log.warning", - {"message": f"Disk image '{disk_image}' is corrupted and could not be fixed"} + {"message": f"Disk image '{disk_image}' is corrupted and could not be fixed"}, ) except (OSError, subprocess.SubprocessError) as e: stdout = self.read_qemu_img_stdout() raise QemuError(f"Could not check '{disk_name}' disk image: {e}\n{stdout}") if self.linked_clone and os.path.dirname(disk_image) != self.working_dir: - - #cloned_disk_image = os.path.splitext(os.path.basename(disk_image)) + # cloned_disk_image = os.path.splitext(os.path.basename(disk_image)) disk = os.path.join(self.working_dir, f"{disk_name}_disk.qcow2") if not os.path.exists(disk): # create the disk @@ -2305,8 +2332,9 @@ class QemuVM(BaseNode): else: # Use a manual case-insensitive search instead try: - system_ovmf_firmware_path = next((f for f in system_ovmf_firmware_dir.glob("*.fd") - if f.name.lower() == "ovmf_code_4m.fd"), None) + system_ovmf_firmware_path = next( + (f for f in system_ovmf_firmware_dir.glob("*.fd") if f.name.lower() == "ovmf_code_4m.fd"), None + ) except (FileNotFoundError, StopIteration): system_ovmf_firmware_path = None @@ -2321,8 +2349,9 @@ class QemuVM(BaseNode): # try to use the UEFI variables store from the system first try: - system_ovmf_vars_path = next((f for f in system_ovmf_firmware_dir.glob("*.fd") - if f.name.lower() == "ovmf_vars_4m.fd"), None) + system_ovmf_vars_path = next( + (f for f in system_ovmf_firmware_dir.glob("*.fd") if f.name.lower() == "ovmf_vars_4m.fd"), None + ) except (FileNotFoundError, StopIteration): system_ovmf_vars_path = None if system_ovmf_vars_path: @@ -2389,15 +2418,18 @@ class QemuVM(BaseNode): swtpm_version = await self.manager.get_swtpm_version(swtpm) if swtpm_version and parse_version(swtpm_version) < parse_version("0.8.0"): # swtpm >= version 0.8.0 is required - raise QemuError("swtpm version 0.8.0 or above must be installed (detected version is {})".format(swtpm_version)) + raise QemuError( + "swtpm version 0.8.0 or above must be installed (detected version is {})".format(swtpm_version) + ) try: command = [ swtpm, "socket", "--tpm2", - '--tpmstate', "dir={}".format(tpm_dir), + "--tpmstate", + "dir={}".format(tpm_dir), "--ctrl", - "type=unixio,path={},terminate".format(tpm_sock) + "type=unixio,path={},terminate".format(tpm_sock), ] command_string = " ".join(shlex.quote(s) for s in command) log.debug("Starting swtpm (TPM emulator) with: {}".format(command_string)) @@ -2429,7 +2461,7 @@ class QemuVM(BaseNode): "-tpmdev", "emulator,id=tpm0,chardev=chrtpm", "-device", - "tpm-tis,tpmdev=tpm0" + "tpm-tis,tpmdev=tpm0", ] return options @@ -2506,7 +2538,7 @@ class QemuVM(BaseNode): Disable graphics depending of the QEMU version """ - if any(opt in self._options for opt in ["-display", "-nographic", "-curses", "-sdl" "-spice", "-vnc"]): + if any(opt in self._options for opt in ["-display", "-nographic", "-curses", "-sdl-spice", "-vnc"]): return [] if self._qemu_version and parse_version(self._qemu_version) >= parse_version("3.0"): return ["-display", "none"] @@ -2663,7 +2695,11 @@ class QemuVM(BaseNode): command.extend(["-enable-kvm"]) # Issue on some combo Intel CPU + KVM + Qemu 2.4.0 # https://github.com/GNS3/gns3-server/issues/685 - if self._qemu_version and parse_version(self._qemu_version) >= parse_version("2.4.0") and self.platform == "x86_64": + if ( + self._qemu_version + and parse_version(self._qemu_version) >= parse_version("2.4.0") + and self.platform == "x86_64" + ): command.extend(["-machine", "smm=off"]) elif sys.platform.startswith("darwin"): command.extend(["-enable-hax"]) @@ -2701,7 +2737,7 @@ class QemuVM(BaseNode): # avoiding mouse offset (see https://github.com/GNS3/gns3-server/issues/2335) if self._console_type == "vnc": - command.extend(['-machine', 'usb=on', '-device', 'usb-tablet']) + command.extend(["-machine", "usb=on", "-device", "usb-tablet"]) return command def asdict(self): diff --git a/gns3server/compute/qemu/utils/qcow2.py b/gns3server/compute/qemu/utils/qcow2.py index 60fdc9724..9ea5fb40a 100644 --- a/gns3server/compute/qemu/utils/qcow2.py +++ b/gns3server/compute/qemu/utils/qcow2.py @@ -60,11 +60,18 @@ class Qcow2: # } QCowHeader; struct_format = ">IIQiiQi" - with open(self._path, 'rb') as f: + with open(self._path, "rb") as f: content = f.read(struct.calcsize(struct_format)) try: - (self.magic, self.version, self.backing_file_offset, self.backing_file_size, - self.cluster_bits, self.size, self.crypt_method) = struct.unpack_from(struct_format, content) + ( + self.magic, + self.version, + self.backing_file_offset, + self.backing_file_size, + self.cluster_bits, + self.size, + self.crypt_method, + ) = struct.unpack_from(struct_format, content) except struct.error: raise Qcow2Error(f"Invalid file header for {self._path}") @@ -112,7 +119,7 @@ class Qcow2: "filename": base_image, }, } - return ("json:"+json.dumps(options, separators=(',', ':')), base_qcow2) + return ("json:" + json.dumps(options, separators=(",", ":")), base_qcow2) else: return (base_image, base_qcow2) except Qcow2Error: diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index faefcb872..addc5cc9f 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -37,7 +37,6 @@ log = logging.getLogger(__name__) class Hypervisor(UBridgeHypervisor): - """ Hypervisor. diff --git a/gns3server/compute/ubridge/ubridge_hypervisor.py b/gns3server/compute/ubridge/ubridge_hypervisor.py index c010e1e2d..b03752b69 100644 --- a/gns3server/compute/ubridge/ubridge_hypervisor.py +++ b/gns3server/compute/ubridge/ubridge_hypervisor.py @@ -26,7 +26,6 @@ log = logging.getLogger(__name__) class UBridgeHypervisor: - """ Creates a new connection to a uBridge hypervisor control channel. diff --git a/gns3server/compute/virtualbox/__init__.py b/gns3server/compute/virtualbox/__init__.py index ccc5a6952..3f3e92634 100644 --- a/gns3server/compute/virtualbox/__init__.py +++ b/gns3server/compute/virtualbox/__init__.py @@ -34,7 +34,6 @@ from .virtualbox_error import VirtualBoxError class VirtualBox(BaseManager): - _NODE_CLASS = VirtualBoxVM def __init__(self): @@ -106,10 +105,7 @@ class VirtualBox(BaseManager): env["LANG"] = "en" # force english output because we rely on it to parse the output try: process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env + *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env ) except (OSError, subprocess.SubprocessError) as e: raise VirtualBoxError(f"Could not execute VBoxManage: {e}") diff --git a/gns3server/compute/virtualbox/virtualbox_error.py b/gns3server/compute/virtualbox/virtualbox_error.py index 91f87e382..84597f68e 100644 --- a/gns3server/compute/virtualbox/virtualbox_error.py +++ b/gns3server/compute/virtualbox/virtualbox_error.py @@ -22,5 +22,4 @@ from ..error import NodeError class VirtualBoxError(NodeError): - pass diff --git a/gns3server/compute/virtualbox/virtualbox_vm.py b/gns3server/compute/virtualbox/virtualbox_vm.py index bcaa8b683..6c9fb6154 100644 --- a/gns3server/compute/virtualbox/virtualbox_vm.py +++ b/gns3server/compute/virtualbox/virtualbox_vm.py @@ -45,7 +45,6 @@ log = logging.getLogger(__name__) class VirtualBoxVM(BaseNode): - """ VirtualBox VM implementation. """ @@ -242,23 +241,23 @@ class VirtualBoxVM(BaseNode): try: tree = ET.parse(linked_vbox_file) except ET.ParseError: - raise VirtualBoxError(f"Cannot modify VirtualBox linked node file. " - "File {linked_vbox_file} is corrupted.") + raise VirtualBoxError(f"Cannot modify VirtualBox linked node file. File {{linked_vbox_file}} is corrupted.") except OSError as e: raise VirtualBoxError(f"Cannot modify VirtualBox linked nodes file '{self._linked_vbox_file()}': {e}") machine = tree.getroot().find("{http://www.virtualbox.org/}Machine") if machine is not None and machine.get("uuid") != "{" + self.id + "}": - for image in tree.getroot().findall("{http://www.virtualbox.org/}Image"): currentSnapshot = machine.get("currentSnapshot") if currentSnapshot: newSnapshot = re.sub(r"\{.*\}", "{" + str(uuid.uuid4()) + "}", currentSnapshot) shutil.move( os.path.join(self.working_dir, self._vmname, "Snapshots", currentSnapshot) + ".vdi", - os.path.join(self.working_dir, self._vmname, "Snapshots", newSnapshot) + ".vdi" + os.path.join(self.working_dir, self._vmname, "Snapshots", newSnapshot) + ".vdi", + ) + log.info( + f"VirtualBox VM '{self.name}' [{self.id}] snapshot file moved from '{currentSnapshot}' to '{newSnapshot}'" ) - log.info(f"VirtualBox VM '{self.name}' [{self.id}] snapshot file moved from '{currentSnapshot}' to '{newSnapshot}'") image.set("uuid", newSnapshot) log.info(f"VirtualBox VM '{self.name}' [{self.id}] '{linked_vbox_file}' has been patched") @@ -346,7 +345,6 @@ class VirtualBoxVM(BaseNode): vm_state = await self._get_vm_state() log.info(f"Stopping VirtualBox VM '{self.name}' [{self.id}] (current state is {vm_state})") if vm_state in ("running", "paused"): - if self.on_close == "save_vm_state": # add a guest property to know the VM has been saved await self.manager.execute("guestproperty", ["set", self._uuid, "SavedByGNS3", "yes"]) diff --git a/gns3server/compute/vmware/__init__.py b/gns3server/compute/vmware/__init__.py index f19e36613..03119d214 100644 --- a/gns3server/compute/vmware/__init__.py +++ b/gns3server/compute/vmware/__init__.py @@ -42,7 +42,6 @@ from gns3server.compute.vmware.vmware_error import VMwareError class VMware(BaseManager): - _NODE_CLASS = VMwareVM def __init__(self): diff --git a/gns3server/compute/vmware/vmware_error.py b/gns3server/compute/vmware/vmware_error.py index 1a4ec0cf3..88a3f4a94 100644 --- a/gns3server/compute/vmware/vmware_error.py +++ b/gns3server/compute/vmware/vmware_error.py @@ -22,5 +22,4 @@ from ..error import NodeError class VMwareError(NodeError): - pass diff --git a/gns3server/compute/vmware/vmware_vm.py b/gns3server/compute/vmware/vmware_vm.py index 1e0fa3b69..d36074f0b 100644 --- a/gns3server/compute/vmware/vmware_vm.py +++ b/gns3server/compute/vmware/vmware_vm.py @@ -42,7 +42,6 @@ log = logging.getLogger(__name__) class VMwareVM(BaseNode): - """ VMware VM implementation. """ @@ -257,7 +256,6 @@ class VMwareVM(BaseNode): # first some sanity checks for adapter_number in range(0, self._adapters): - # we want the vmnet interface to be connected when starting the VM connected = f"ethernet{adapter_number}.startConnected" if self._get_vmx_setting(connected): @@ -271,7 +269,6 @@ class VMwareVM(BaseNode): self.manager.refresh_vmnet_list(ubridge=use_ubridge) # then configure VMware network adapters for adapter_number in range(0, self._adapters): - custom_adapter = self._get_custom_adapter_settings(adapter_number) adapter_type = custom_adapter.get("adapter_type", self._adapter_type) @@ -365,7 +362,9 @@ class VMwareVM(BaseNode): await self._add_ubridge_ethernet_connection(vnet, vmnet_interface, block_host_traffic) else: # special case on macOS, we cannot bind VMnet interfaces using the libpcap - await self._ubridge_send('bridge add_nio_fusion_vmnet {name} "{interface}"'.format(name=vnet, interface=vmnet_interface)) + await self._ubridge_send( + 'bridge add_nio_fusion_vmnet {name} "{interface}"'.format(name=vnet, interface=vmnet_interface) + ) else: await self._add_ubridge_ethernet_connection(vnet, vmnet_interface, block_host_traffic) diff --git a/gns3server/compute/vpcs/__init__.py b/gns3server/compute/vpcs/__init__.py index 90953dd0a..d9073a193 100644 --- a/gns3server/compute/vpcs/__init__.py +++ b/gns3server/compute/vpcs/__init__.py @@ -27,7 +27,6 @@ from .vpcs_vm import VPCSVM class VPCS(BaseManager): - _NODE_CLASS = VPCSVM def __init__(self): diff --git a/gns3server/compute/vpcs/vpcs_error.py b/gns3server/compute/vpcs/vpcs_error.py index 0fb6f5a91..bde63c3ad 100644 --- a/gns3server/compute/vpcs/vpcs_error.py +++ b/gns3server/compute/vpcs/vpcs_error.py @@ -22,5 +22,4 @@ from ..error import NodeError class VPCSError(NodeError): - pass diff --git a/gns3server/config.py b/gns3server/config.py index 278432358..0baf2fe2f 100644 --- a/gns3server/config.py +++ b/gns3server/config.py @@ -110,20 +110,30 @@ class Config: self._files = [] if self._main_config_file is None: - if not os.path.exists(versioned_user_dir): # Try to migrate the configuration files and database from the previous version if it exists previous_version = f"{__version_info__[0]}.{int(__version_info__[1]) - 1}" if self._profile: - previous_versioned_user_dir = os.path.join(home, ".config", appname, previous_version, "profiles", self._profile) + previous_versioned_user_dir = os.path.join( + home, ".config", appname, previous_version, "profiles", self._profile + ) else: previous_versioned_user_dir = os.path.join(home, ".config", appname, previous_version) if os.path.exists(previous_versioned_user_dir): try: - shutil.copytree(previous_versioned_user_dir, versioned_user_dir, symlinks=True, ignore_dangling_symlinks=True) - log.info(f"Migrated configuration files and database from '{previous_versioned_user_dir}' to '{versioned_user_dir}'") + shutil.copytree( + previous_versioned_user_dir, + versioned_user_dir, + symlinks=True, + ignore_dangling_symlinks=True, + ) + log.info( + f"Migrated configuration files and database from '{previous_versioned_user_dir}' to '{versioned_user_dir}'" + ) except OSError as e: - log.error(f"Cannot migrate old config files and database from '{previous_versioned_user_dir}: {e}") + log.error( + f"Cannot migrate old config files and database from '{previous_versioned_user_dir}: {e}" + ) os.makedirs(versioned_user_dir, exist_ok=True) try: diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index b1e16c87b..debc6960a 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -202,12 +202,12 @@ class Controller: if computes: for c in computes: try: - #FIXME: Task exception was never retrieved + # FIXME: Task exception was never retrieved await self.add_compute( compute_id=str(c.compute_id), connect=False, **c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}), - ssl_context=self._ssl_context + ssl_context=self._ssl_context, ) except (ControllerError, KeyError): pass # Skip not available servers at loading @@ -222,11 +222,9 @@ class Controller: # start to auto open projects (if configured) 5 seconds after the controller has started self._project_auto_open_task_handle = asyncio.get_event_loop().call_later( - 5, - lambda: asyncio.create_task(self._project_auto_open()) + 5, lambda: asyncio.create_task(self._project_auto_open()) ) - def _create_ssl_context(self, server_config): import ssl @@ -303,11 +301,10 @@ class Controller: controller_vars = { "appliances_etag": self._appliance_manager.appliances_etag, "iou_license_check": self._iou_license_settings["license_check"], - "version": __version__ + "version": __version__, } if self._iou_license_settings["iourc_content"]: - server_config = Config.instance().settings.Server os.makedirs(server_config.secrets_dir, exist_ok=True) iourc_path = os.path.join(server_config.secrets_dir, "iou_license") @@ -321,7 +318,7 @@ class Controller: try: os.makedirs(os.path.dirname(self._vars_file), exist_ok=True) - with open(self._vars_file, 'w+') as f: + with open(self._vars_file, "w+") as f: json.dump(controller_vars, f, indent=4, sort_keys=True) except OSError as e: log.error(f"Cannot write controller vars file '{self._vars_file}': {e}") @@ -357,15 +354,20 @@ class Controller: # IOU license check is disabled by default self._iou_license_settings["license_check"] = controller_vars.get("iou_license_check", False) - log.info("IOU license check is {} on the controller".format("enabled" if self._iou_license_settings["license_check"] else "disabled")) + log.info( + "IOU license check is {} on the controller".format( + "enabled" if self._iou_license_settings["license_check"] else "disabled" + ) + ) # install the built-in appliances if needed if Config.instance().settings.Server.install_builtin_appliances: previous_version = controller_vars.get("version") log.info("Comparing controller version {} with config version {}".format(__version__, previous_version)) builtin_appliances_path = self._appliance_manager.builtin_appliances_path() - if not previous_version or \ - parse_version(__version__.split("+")[0]) > parse_version(previous_version.split("+")[0]): + if not previous_version or parse_version(__version__.split("+")[0]) > parse_version( + previous_version.split("+")[0] + ): await self._appliance_manager.install_builtin_appliances() elif not os.listdir(builtin_appliances_path): await self._appliance_manager.install_builtin_appliances() @@ -467,7 +469,9 @@ class Controller: self._projects_scan_handle.cancel() loop = self._projects_monitor_loop or asyncio.get_running_loop() - self._projects_scan_handle = loop.call_later(delay, lambda: asyncio.create_task(self._scan_projects_directory())) + self._projects_scan_handle = loop.call_later( + delay, lambda: asyncio.create_task(self._scan_projects_directory()) + ) async def _scan_projects_directory(self): """ @@ -488,7 +492,6 @@ class Controller: except Exception as e: log.warning(f"Projects directory rescan failed: {e}") - @staticmethod async def install_resource_files(dst_path, resource_name, upgrade_resources=True): """ @@ -496,6 +499,7 @@ class Controller: """ installed_resources = [] + async def should_copy(src, dst, upgrade_resources): if not os.path.exists(dst): return True @@ -512,7 +516,7 @@ class Controller: if not os.path.exists(os.path.join(dst_path, filename)): shutil.copy(os.path.join(resource_path, filename), os.path.join(dst_path, filename)) else: - for entry in importlib_resources.files('gns3server').joinpath(resource_name).iterdir(): + for entry in importlib_resources.files("gns3server").joinpath(resource_name).iterdir(): full_path = os.path.join(dst_path, entry.name) if entry.is_file() and await should_copy(str(entry), full_path, upgrade_resources): log.debug(f'Installing {resource_name} resource file "{entry.name}" to "{full_path}"') @@ -604,7 +608,6 @@ class Controller: """ if compute_id not in self._computes: - # We disallow to create from the outside the local and VM server if (compute_id == "local" or compute_id == "vm") and not force: return None @@ -799,10 +802,7 @@ class Controller: project = self._projects[topo_data["project_id"]] else: project = await self.add_project( - path=os.path.dirname(path), - status="closed", - filename=os.path.basename(path), - **topo_data + path=os.path.dirname(path), status="closed", filename=os.path.basename(path), **topo_data ) if load or project.auto_open: await project.open() diff --git a/gns3server/controller/appliance.py b/gns3server/controller/appliance.py index fa964abfa..10d958983 100644 --- a/gns3server/controller/appliance.py +++ b/gns3server/controller/appliance.py @@ -22,7 +22,6 @@ log = logging.getLogger(__name__) class Appliance: - def __init__(self, path, data, builtin=True): self._data = data.copy() diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index e411b9852..3bf0a302c 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -116,6 +116,7 @@ class ApplianceManager: dst_path = self.builtin_appliances_path() log.info(f"Installing built-in appliances in '{dst_path}'") from . import Controller + try: await Controller.instance().install_resource_files(dst_path, "appliances") except OSError as e: @@ -135,12 +136,7 @@ class ApplianceManager: return appliances async def _download_image( - self, - image_dir: str, - image_name: str, - image_type: str, - image_url: str, - images_repo: ImagesRepository + self, image_dir: str, image_name: str, image_type: str, image_url: str, images_repo: ImagesRepository ) -> None: """ Download an image. @@ -152,7 +148,9 @@ class ApplianceManager: async with HTTPClient.get(image_url) as response: if response.status != 200: raise ControllerError(f"Could not download '{image_name}' due to HTTP error code {response.status}") - await write_image(image_name, image_path, response.content.iter_any(), images_repo, allow_raw_image=True) + await write_image( + image_name, image_path, response.content.iter_any(), images_repo, allow_raw_image=True + ) except (OSError, InvalidImageError) as e: raise ControllerError(f"Could not save {image_type} image '{image_path}': {e}") except ClientError as e: @@ -161,11 +159,7 @@ class ApplianceManager: raise ControllerError(f"Timeout while downloading '{image_name}' from '{image_url}'") async def _find_appliance_version_images( - self, - appliance: Appliance, - version: dict, - images_repo: ImagesRepository, - image_dir: str + self, appliance: Appliance, version: dict, images_repo: ImagesRepository, image_dir: str ) -> None: """ Find all the images belonging to a specific appliance version. @@ -196,11 +190,8 @@ class ApplianceManager: direct_download_url = image.get("direct_download_url") if direct_download_url: await self._download_image( - image_dir, - appliance_file, - appliance.type, - direct_download_url, - images_repo) + image_dir, appliance_file, appliance.type, direct_download_url, images_repo + ) else: raise ControllerError(f"Could not find '{appliance_file}'") @@ -214,8 +205,8 @@ class ApplianceManager: except ValidationError as e: raise ControllerError(message=f"Could not validate template data: {e}") template = await TemplatesService(templates_repo).create_template(template_create) - #template_id = template.get("template_id") - #await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*") + # template_id = template.get("template_id") + # await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*") log.info(f"Template '{template.get('name')}' has been created") return template @@ -237,14 +228,14 @@ class ApplianceManager: return template_data async def install_appliances_from_image( - self, - image_path: str, - image_checksum: str, - images_repo: ImagesRepository, - templates_repo: TemplatesRepository, - rbac_repo: RbacRepository, - current_user: schemas.User, - image_dir: str + self, + image_path: str, + image_checksum: str, + images_repo: ImagesRepository, + templates_repo: TemplatesRepository, + rbac_repo: RbacRepository, + current_user: schemas.User, + image_dir: str, ) -> List[dict]: """ Install appliances using an image checksum. @@ -262,11 +253,13 @@ class ApplianceManager: ApplianceModel.model_validate(appliance.asdict()) except ValidationError as e: log.warning(f"Could not validate appliance '{appliance.id}': {e}") - results.append({ - "status": "skipped", - "name": appliance.name, - "reason": f"could not validate appliance '{appliance.id}': {e}", - }) + results.append( + { + "status": "skipped", + "name": appliance.name, + "reason": f"could not validate appliance '{appliance.id}': {e}", + } + ) continue if appliance.versions: for version in appliance.versions: @@ -282,37 +275,45 @@ class ApplianceManager: # duplicates when the appliance version differs, but two # templates sharing a name is never what the user asked for here log.warning(f"Template '{name}' already exists, skipping automatic template creation") - results.append({ - "status": "skipped", - "name": name, - "reason": f"a template named '{name}' already exists", - }) + results.append( + { + "status": "skipped", + "name": name, + "reason": f"a template named '{name}' already exists", + } + ) continue - template = await self._create_template(template_data, templates_repo, rbac_repo, current_user) - results.append({ - "status": "created", - "template_id": str(template.get("template_id")), - "name": template.get("name"), - "version": template.get("version"), - "template_type": template.get("template_type"), - }) + template = await self._create_template( + template_data, templates_repo, rbac_repo, current_user + ) + results.append( + { + "status": "created", + "template_id": str(template.get("template_id")), + "name": template.get("name"), + "version": template.get("version"), + "template_type": template.get("template_type"), + } + ) except (ControllerError, InvalidImageError) as e: log.warning(f"Could not automatically create template using image '{image_path}': {e}") - results.append({ - "status": "skipped", - "name": appliance.name, - "reason": str(e), - }) + results.append( + { + "status": "skipped", + "name": appliance.name, + "reason": str(e), + } + ) return results async def install_appliance( - self, - appliance_id: UUID, - version: str, - images_repo: ImagesRepository, - templates_repo: TemplatesRepository, - rbac_repo: RbacRepository, - current_user: schemas.User + self, + appliance_id: UUID, + version: str, + images_repo: ImagesRepository, + templates_repo: TemplatesRepository, + rbac_repo: RbacRepository, + current_user: schemas.User, ) -> None: """ Install a new appliance @@ -335,11 +336,15 @@ class ApplianceManager: for appliance_version_info in appliance.versions: if appliance_version_info.get("name") == version: try: - template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info) + template_type = ApplianceToTemplate().get_template_type( + appliance.asdict(), appliance_version_info + ) if template_type != "docker": # docker appliances have no image files to find or download image_dir = default_images_directory(template_type) - await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir) + await self._find_appliance_version_images( + appliance, appliance_version_info, images_repo, image_dir + ) except InvalidImageError as e: raise ControllerError(message=f"Image error: {e}") template_data = await self._appliance_to_template(appliance, appliance_version_info) @@ -350,8 +355,9 @@ class ApplianceManager: else: if appliance.versions: # TODO: install appliance versions based on available images - raise ControllerBadRequestError(message=f"Selecting a version is required to install " - f"appliance '{appliance_id}'") + raise ControllerBadRequestError( + message=f"Selecting a version is required to install appliance '{appliance_id}'" + ) template_data = await self._appliance_to_template(appliance) return await self._create_template(template_data, templates_repo, rbac_repo, current_user) @@ -383,7 +389,9 @@ class ApplianceManager: json_data = appliance.asdict() # Check if loaded without error if appliance.status != "broken": # Validate using discriminated union - automatically routes to correct version - log.debug(f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}") + log.debug( + f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}" + ) ApplianceModel.model_validate(json_data) self._appliances[appliance.id] = appliance if not appliance.symbol or appliance.symbol.startswith(":/symbols/"): diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index 3602a7a7e..b848e56d8 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -315,8 +315,7 @@ class ApplianceToTemplate: return settings_list[0] raise ControllerError( - f"Appliance '{appliance_config['name']}' has multiple settings " - f"but none is marked as default" + f"Appliance '{appliance_config['name']}' has multiple settings but none is marked as default" ) def _merge_v8_properties(self, settings, appliance_config): diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index a418f92a6..6c22d16b2 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -40,7 +40,7 @@ from ..controller.controller_error import ( ControllerTimeoutError, ControllerUnauthorizedError, ComputeError, - ComputeConflictError + ComputeConflictError, ) from ..version import __version__, __version_info__ @@ -483,10 +483,7 @@ class Compute: self._controller.notification.controller_emit("compute.updated", self.asdict()) else: await self._controller.notification.dispatch( - action, - event, - project_id=project_id, - compute_id=self.id + action, event, project_id=project_id, compute_id=self.id ) else: if response.type == aiohttp.WSMsgType.CLOSE: @@ -514,6 +511,7 @@ class Compute: self._controller.notification.controller_emit("compute.updated", self.asdict()) # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb) from gns3server.api.server import app + if not app.state.exiting and not hasattr(sys, "_called_from_test"): log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect())) @@ -534,7 +532,7 @@ class Compute: return f"{self._protocol}://{host}:{self._port}/v3/compute{path}" def get_url(self, path): - """ Returns URL for specific path at Compute""" + """Returns URL for specific path at Compute""" return self._getUrl(path) async def _run_http_query(self, method, path, data=None, timeout=120, raw=False, stream=False, params=None): @@ -567,7 +565,14 @@ class Compute: try: log.debug(f"Attempting request to compute: {method} {url} {headers}") response = await self._session().request( - method, url, headers=headers, data=data, auth=self._auth, params=params, chunked=chunked, timeout=timeout + method, + url, + headers=headers, + data=data, + auth=self._auth, + params=params, + chunked=chunked, + timeout=timeout, ) except asyncio.TimeoutError: raise ComputeError(f"Timeout error for {method} call to {url} after {timeout}s") @@ -627,7 +632,7 @@ class Compute: raise HTTPException( status_code=response.status, detail=f"HTTP error {response.status} received from compute " - f"'{self.name}' for request {method} {path}: {msg}" + f"'{self.name}' for request {method} {path}: {msg}", ) if body and len(body): diff --git a/gns3server/controller/export_project.py b/gns3server/controller/export_project.py index 89b5a8140..080d3675c 100644 --- a/gns3server/controller/export_project.py +++ b/gns3server/controller/export_project.py @@ -81,7 +81,7 @@ async def export_project( keep_compute_ids, allow_all_nodes, temporary_dir, - reset_mac_addresses + reset_mac_addresses, ) # Export the local files @@ -198,14 +198,7 @@ def _is_exportable(path, include_snapshots=False): async def _patch_project_file( - project, - path, - zstream, - include_images, - keep_compute_ids, - allow_all_nodes, - temporary_dir, - reset_mac_addresses + project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses ): """ Patch a project file (.gns3) to export a project. @@ -242,7 +235,6 @@ async def _patch_project_file( if "properties" in node: for prop, value in node["properties"].items(): - # reset the MAC address if reset_mac_addresses and prop in ("mac_addr", "mac_address"): node["properties"][prop] = None diff --git a/gns3server/controller/gns3vm/hyperv_gns3_vm.py b/gns3server/controller/gns3vm/hyperv_gns3_vm.py index 50b053f64..7ec969223 100644 --- a/gns3server/controller/gns3vm/hyperv_gns3_vm.py +++ b/gns3server/controller/gns3vm/hyperv_gns3_vm.py @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) class HyperVGNS3VM(BaseGNS3VM): - _HYPERV_VM_STATE_ENABLED = 2 _HYPERV_VM_STATE_DISABLED = 3 _HYPERV_VM_STATE_SHUTDOWN = 4 diff --git a/gns3server/controller/gns3vm/virtualbox_gns3_vm.py b/gns3server/controller/gns3vm/virtualbox_gns3_vm.py index 97f309a15..f3a78f03d 100644 --- a/gns3server/controller/gns3vm/virtualbox_gns3_vm.py +++ b/gns3server/controller/gns3vm/virtualbox_gns3_vm.py @@ -135,8 +135,8 @@ class VirtualBoxGNS3VM(BaseGNS3VM): result = await self._execute("showvminfo", [self._vmname, "--machinereadable"]) for info in result.splitlines(): - if '=' in info: - name, value = info.split('=', 1) + if "=" in info: + name, value = info.split("=", 1) if name == "{}{}".format(backend_type, interface_number): return value.strip('"') return None @@ -188,7 +188,9 @@ class VirtualBoxGNS3VM(BaseGNS3VM): lower_ip = str(interface.ip + 2) upper_ip = str(subnet.network_address + subnet.num_addresses - 2) except ValueError: - raise GNS3VMError("Invalid IP address and netmask for vboxnet {}: {}/{}".format(vboxnet, ip_address, netmask)) + raise GNS3VMError( + "Invalid IP address and netmask for vboxnet {}: {}/{}".format(vboxnet, ip_address, netmask) + ) dhcp_server_args = [ "add", @@ -197,7 +199,7 @@ class VirtualBoxGNS3VM(BaseGNS3VM): "--netmask={}".format(netmask), "--lower-ip={}".format(lower_ip), "--upper-ip={}".format(upper_ip), - "--enable" + "--enable", ] await self._execute("dhcpserver", dhcp_server_args) @@ -249,7 +251,6 @@ class VirtualBoxGNS3VM(BaseGNS3VM): return True return False - async def list(self): """ List all VirtualBox VMs @@ -271,16 +272,24 @@ class VirtualBoxGNS3VM(BaseGNS3VM): # get a NAT interface number nat_interface_number = await self._look_for_interface("nat") if nat_interface_number < 0 and await self._look_for_interface("natnetwork") < 0: - raise GNS3VMError(f'VM "{self.vmname}" must have a NAT interface or NAT Network configured in order to start') + raise GNS3VMError( + f'VM "{self.vmname}" must have a NAT interface or NAT Network configured in order to start' + ) - if sys.platform.startswith("darwin") and parse_version(self._system_properties["API version"]) >= parse_version("7_0"): + if sys.platform.startswith("darwin") and parse_version(self._system_properties["API version"]) >= parse_version( + "7_0" + ): # VirtualBox 7.0+ on macOS requires a host-only network interface backend_type = "hostonly-network" backend_description = "host-only network" vboxnet_type = "hostonlynets" interface_number = await self._look_for_interface("hostonlynetwork") if interface_number < 0: - raise GNS3VMError('VM "{}" must have a network adapter attached to a host-only network in order to start'.format(self.vmname)) + raise GNS3VMError( + 'VM "{}" must have a network adapter attached to a host-only network in order to start'.format( + self.vmname + ) + ) else: backend_type = "hostonlyadapter" backend_description = "host-only adapter" @@ -288,11 +297,19 @@ class VirtualBoxGNS3VM(BaseGNS3VM): interface_number = await self._look_for_interface("hostonly") if interface_number < 0: - raise GNS3VMError('VM "{}" must have a network adapter attached to a {} in order to start'.format(self.vmname, backend_description)) + raise GNS3VMError( + 'VM "{}" must have a network adapter attached to a {} in order to start'.format( + self.vmname, backend_description + ) + ) vboxnet = await self._look_for_vboxnet(backend_type, interface_number) if vboxnet is None: - raise GNS3VMError('A VirtualBox host-only network could not be found on network adapter {} for "{}"'.format(interface_number, self._vmname)) + raise GNS3VMError( + 'A VirtualBox host-only network could not be found on network adapter {} for "{}"'.format( + interface_number, self._vmname + ) + ) if not (await self._check_vboxnet_exists(vboxnet, vboxnet_type)): if sys.platform.startswith("win") and vboxnet == "vboxnet0": @@ -300,24 +317,30 @@ class VirtualBoxGNS3VM(BaseGNS3VM): # on Windows. Try to patch this with the first available vboxnet we find. first_available_vboxnet = await self._find_first_available_vboxnet() if first_available_vboxnet is None: - raise GNS3VMError('Please add a VirtualBox host-only network with DHCP enabled and attached it to network adapter {} for "{}"'.format(interface_number, self._vmname)) + raise GNS3VMError( + 'Please add a VirtualBox host-only network with DHCP enabled and attached it to network adapter {} for "{}"'.format( + interface_number, self._vmname + ) + ) await self.set_hostonly_network(interface_number, first_available_vboxnet) vboxnet = first_available_vboxnet else: try: await self._execute("hostonlyif", ["create"]) except GNS3VMError: - raise GNS3VMError('VirtualBox host-only network "{}" does not exist and could not be automatically created, please make the sure the network adapter {} configuration is valid for "{}"'.format( - vboxnet, - interface_number, - self._vmname - )) + raise GNS3VMError( + 'VirtualBox host-only network "{}" does not exist and could not be automatically created, please make the sure the network adapter {} configuration is valid for "{}"'.format( + vboxnet, interface_number, self._vmname + ) + ) if backend_type == "hostonlyadapter" and not (await self._check_dhcp_server(vboxnet)): try: await self._add_dhcp_server(vboxnet) except GNS3VMError as e: - raise GNS3VMError("Could not add DHCP server for vboxnet {}: {}, please configure manually".format(vboxnet, e)) + raise GNS3VMError( + "Could not add DHCP server for vboxnet {}: {}, please configure manually".format(vboxnet, e) + ) vm_state = await self._get_state() log.info(f'"{self._vmname}" state is {vm_state}') @@ -363,12 +386,24 @@ class VirtualBoxGNS3VM(BaseGNS3VM): if await self._check_vbox_port_forwarding(): # delete the GNS3VM NAT port forwarding rule if it exists log.info("Removing GNS3VM NAT port forwarding rule from interface {}".format(nat_interface_number)) - await self._execute("controlvm", [self._vmname, "natpf{}".format(nat_interface_number), "delete", "GNS3VM"]) + await self._execute( + "controlvm", [self._vmname, "natpf{}".format(nat_interface_number), "delete", "GNS3VM"] + ) # add a GNS3VM NAT port forwarding rule to redirect 127.0.0.1 with random port to the port in the VM - log.info("Adding GNS3VM NAT port forwarding rule with port {} to interface {}".format(api_port, nat_interface_number)) - await self._execute("controlvm", [self._vmname, "natpf{}".format(nat_interface_number), - "GNS3VM,tcp,{},{},,{}".format(ip_address, api_port, self.port)]) + log.info( + "Adding GNS3VM NAT port forwarding rule with port {} to interface {}".format( + api_port, nat_interface_number + ) + ) + await self._execute( + "controlvm", + [ + self._vmname, + "natpf{}".format(nat_interface_number), + "GNS3VM,tcp,{},{},,{}".format(ip_address, api_port, self.port), + ], + ) self.ip_address = await self._get_ip_from_server(interface_number, api_port) @@ -384,8 +419,8 @@ class VirtualBoxGNS3VM(BaseGNS3VM): while remaining_try > 0: result = await self._execute("guestproperty", ["get", self._vmname, "/VirtualBox/GuestInfo/Net/0/V4/IP"]) for info in result.splitlines(): - if ':' in info: - name, value = info.split(':', 1) + if ":" in info: + name, value = info.split(":", 1) if name == "Value": return value.strip() remaining_try -= 1 diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index e5da5460c..f26c747e2 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -41,16 +41,16 @@ Handle the import of project from a .gns3project async def import_project( - controller, - project_id, - stream, - location=None, - name=None, - reset_mac_addresses=False, - keep_compute_ids=False, - auto_start=False, - auto_open=False, - auto_close=True, + controller, + project_id, + stream, + location=None, + name=None, + reset_mac_addresses=False, + keep_compute_ids=False, + auto_start=False, + auto_open=False, + auto_close=True, ): """ Import a project contain in a zip file @@ -135,7 +135,9 @@ async def import_project( else: # Round-robin through available compute resources. # Only use computes that are connected to avoid import failures - available_computes = {compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected} + available_computes = { + compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected + } if available_computes: compute_nodes = itertools.cycle(available_computes) for node in topology["topology"]["nodes"]: @@ -258,6 +260,7 @@ def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=Fals for drawing in topology["topology"]["drawings"]: drawing["drawing_id"] = str(uuid.uuid4()) + def _move_node_file(path, old_id, new_id): """ Move a file from a node when changing its id @@ -284,7 +287,7 @@ async def _move_files_to_compute(compute, project_id, directory, files_path): location = os.path.join(directory, files_path) if os.path.exists(location): - for (dirpath, dirnames, filenames) in os.walk(location, followlinks=False): + for dirpath, dirnames, filenames in os.walk(location, followlinks=False): for filename in filenames: path = os.path.join(dirpath, filename) if os.path.islink(path): @@ -314,7 +317,7 @@ async def _import_images(controller, images_path): image_dir = controller.images_path() root = images_path - for (dirpath, dirnames, filenames) in os.walk(root, followlinks=False): + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): for filename in filenames: path = os.path.join(dirpath, filename) dst = os.path.join(image_dir, os.path.relpath(path, root)) @@ -326,11 +329,12 @@ async def _import_images(controller, images_path): # read the first 7 bytes of the file. elf_header_start = f.read(7) # IOU images must start with the ELF magic number, be 32-bit or 64-bit, little endian and have an ELF version of 1 - if elf_header_start == b'\x7fELF\x01\x01\x01' or elf_header_start == b'\x7fELF\x02\x01\x01': + if elf_header_start == b"\x7fELF\x01\x01\x01" or elf_header_start == b"\x7fELF\x02\x01\x01": os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) except OSError as e: continue + async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True): """ Load the snapshots and update their project name and project ID to be the same as the main project. @@ -342,7 +346,6 @@ async def update_snapshots(snapshots_dir, project_path, project_name, project_id continue snapshot_path = os.path.join(snapshots_dir, snapshot) with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir: - # extract everything to a temporary directory try: with open(snapshot_path, "rb") as f: diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 5f68e484b..72c6552d5 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -23,7 +23,11 @@ import html from .controller_error import ControllerError, ControllerNotFoundError from gns3server.agent.web_wireshark.manager import WebWiresharkManager from gns3server.config import Config -from gns3server.utils.packet_filter_validation import validate_all_filters, filter_inactive_filters, FilterValidationError +from gns3server.utils.packet_filter_validation import ( + validate_all_filters, + filter_inactive_filters, + FilterValidationError, +) import logging @@ -164,7 +168,7 @@ class Link: """ Get whether to show filters icon in Web UI """ - return getattr(self, '_show_filters_icon', True) + return getattr(self, "_show_filters_icon", True) @property def project(self): @@ -438,14 +442,13 @@ class Link: jwt_token=jwt_token, memory=webwireshark_config.memory, cpus=webwireshark_config.cpus, - pids_limit=webwireshark_config.pids_limit + pids_limit=webwireshark_config.pids_limit, ) # Send notification - self._project.emit_notification("link.web_wireshark_started", { - "link_id": self.id, - "ws_url": result.get("ws_url", result.get("url")) - }) + self._project.emit_notification( + "link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))} + ) log.info(f"Web Wireshark started for link {self.id}: {result.get('ws_url')}") self._wireshark = True @@ -466,10 +469,7 @@ class Link: try: log.info(f"Stopping Web Wireshark for link {self.id}") - await manager.stop_wireshark_session( - project_id=self._project.id, - link_id=self.id - ) + await manager.stop_wireshark_session(project_id=self._project.id, link_id=self.id) log.info(f"Web Wireshark stopped for link {self.id}") self._wireshark = False @@ -494,15 +494,12 @@ class Link: log.info(f"Restarting Web Wireshark for link {self.id}") result = await manager.restart_wireshark_session( - project_id=self._project.id, - link_id=self.id, - jwt_token=jwt_token + project_id=self._project.id, link_id=self.id, jwt_token=jwt_token ) - self._project.emit_notification("link.web_wireshark_started", { - "link_id": self.id, - "ws_url": result.get("ws_url", result.get("url")) - }) + self._project.emit_notification( + "link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))} + ) log.info(f"Web Wireshark restarted for link {self.id}: {result.get('ws_url')}") @@ -660,7 +657,7 @@ class Link: "markers": self._persist_markers(), "link_style": self._link_style, "suspend": self._suspended, - "show_filters_icon": getattr(self, '_show_filters_icon', True), + "show_filters_icon": getattr(self, "_show_filters_icon", True), } result = { "nodes": res, @@ -676,6 +673,6 @@ class Link: "suspend": self._suspended, "link_style": self._link_style, "wireshark": self._wireshark, - "show_filters_icon": getattr(self, '_show_filters_icon', True), + "show_filters_icon": getattr(self, "_show_filters_icon", True), } return result diff --git a/gns3server/controller/marker_replay.py b/gns3server/controller/marker_replay.py index a3b9366e3..f5996c159 100644 --- a/gns3server/controller/marker_replay.py +++ b/gns3server/controller/marker_replay.py @@ -124,10 +124,10 @@ class _SharkdRpcError(Exception): # magic → (byte order, timestamp unit). Both pcap families uBridge can write # (libpcap default µs; ns variant accepted defensively) and both endiannesses. _PCAP_MAGICS = { - 0xA1B2C3D4: ("<", 1), # little-endian, microseconds - 0xD4C3B2A1: (">", 1), # big-endian, microseconds - 0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds - 0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds + 0xA1B2C3D4: ("<", 1), # little-endian, microseconds + 0xD4C3B2A1: (">", 1), # big-endian, microseconds + 0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds + 0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds } @@ -174,9 +174,7 @@ def scan_pcap_frames(path): pos = 24 while pos + 16 <= len(data): - ts_sec, ts_frac, incl_len, _orig_len = struct.unpack( - byteorder + "IIII", data[pos:pos + 16] - ) + ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(byteorder + "IIII", data[pos : pos + 16]) if incl_len > 0xFFFF or pos + 16 + incl_len > len(data): break # truncated tail (snapshot mid-write / torn final record) # Normalize ns pcaps to µs by truncation — uBridge writes µs anyway. @@ -197,7 +195,7 @@ def read_frame_bytes(path, frame_number): return None # Offset arithmetic mirrors the header scan: global header + every full # record before the target + the target's own record header. - offset = 24 + sum(16 + incl for _s, _u, incl in frames[:frame_number - 1]) + 16 + offset = 24 + sum(16 + incl for _s, _u, incl in frames[: frame_number - 1]) + 16 incl_len = frames[frame_number - 1][2] with open(path, "rb") as f: f.seek(offset) @@ -213,10 +211,7 @@ def read_frame_bytes(path, frame_number): # (/etc/wireshark &c.), so the column indexes in _columns_for are a contract # we own rather than an environment default. Exactly the four columns the # frame entries consume — nothing else rides along in every page. -_PINNED_COLUMNS = ( - 'gui.column.format: "Source", "%s", "Destination", "%d", ' - '"Protocol", "%p", "Info", "%i"\n' -) +_PINNED_COLUMNS = 'gui.column.format: "Source", "%s", "Destination", "%d", "Protocol", "%p", "Info", "%i"\n' async def _prepare_scratch(pcap): @@ -262,13 +257,13 @@ def _engine_env(scratch_dir): # semantics live in VALUES (field names, labels, filter expressions), which # are never touched. _KEY_RENAME = { - "t": "element", # node type ("proto", …) - "l": "label", # display text - "fn": "name", # field name (e.g. "ip.ttl") - "f": "filter_expr", # ready-made display filter with the value baked in - "s": "expert", # expert severity name ("Chat", "Warn", …) - "g": "generated", # generated-by-wireshark flag - "n": "children", # nested fields + "t": "element", # node type ("proto", …) + "l": "label", # display text + "fn": "name", # field name (e.g. "ip.ttl") + "f": "filter_expr", # ready-made display filter with the value baked in + "s": "expert", # expert severity name ("Chat", "Warn", …) + "g": "generated", # generated-by-wireshark flag + "n": "children", # nested fields } # "h" → pos + size (byte range for hex highlighting) — handled specially. # "e" is sharkd's internal header-field registry id — unstable across @@ -308,6 +303,7 @@ def _count_tree_nodes(value): # sharkd sessions (one resident process per source pcap) # --------------------------------------------------------------------------- + class _SharkdSession: """A resident `sharkd -` process with one pcap loaded, addressed through line-oriented JSON-RPC. Serialized by an asyncio lock (sharkd serves one @@ -425,8 +421,7 @@ class _SharkdManager: async def _acquire(self, pcap): if shutil.which("sharkd") is None: raise SharkdMissingError( - "sharkd is not available on this server — marker replay requires sharkd " - "(part of the Wireshark package)" + "sharkd is not available on this server — marker replay requires sharkd (part of the Wireshark package)" ) try: stat = os.stat(pcap) @@ -492,9 +487,12 @@ class _SharkdManager: scratch_dir, scratch = await _prepare_scratch(pcap) try: proc = await asyncio.create_subprocess_exec( - "sharkd", "-", - stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, env=_engine_env(scratch_dir), + "sharkd", + "-", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + env=_engine_env(scratch_dir), limit=_STREAM_LIMIT_BYTES, ) except OSError as e: @@ -541,6 +539,7 @@ async def close_sessions(): # Columns + display filter (sharkd `frames` RPC) # --------------------------------------------------------------------------- + async def _columns_for(pcap, filter_expr): """ One resident `frames` pass over the source pcap. Returns @@ -596,6 +595,7 @@ async def _columns_for(pcap, filter_expr): # Tag gate + timeline assembly # --------------------------------------------------------------------------- + def _tag_markers(project, tag): """ Every marker entry in the project carrying ``tag`` (flat @@ -606,13 +606,15 @@ def _tag_markers(project, tag): for key, info in project.markers.items(): if info.get("tag") == tag: link_id, _, name = key.partition("/") - entries.append({ - "node_id": info["node_id"], - "link_id": link_id, - "marker": name, - "enabled": info.get("enabled", True), - "data_link_type": info.get("data_link_type", "DLT_EN10MB"), - }) + entries.append( + { + "node_id": info["node_id"], + "link_id": link_id, + "marker": name, + "enabled": info.get("enabled", True), + "data_link_type": info.get("data_link_type", "DLT_EN10MB"), + } + ) return entries @@ -659,13 +661,12 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None): merged = [] sources = [] for entry in entries: - pcap = os.path.join( - markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap" - ) + pcap = os.path.join(markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap") frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else [] # Inventory first: every source, engine-free totals. - sources.append({**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")}, - "count": len(frames)}) + sources.append( + {**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")}, "count": len(frames)} + ) if link_id and entry["link_id"] != link_id: continue # link narrows the stream before any engine work if frames: @@ -677,22 +678,24 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None): if filter_expr is not None and frame_number not in columns: continue cols = columns.get(frame_number, {}) - merged.append({ - "ts": _format_ts(sec, usec), - "ts_us": sec * 1_000_000 + usec, - "_source": source_key, - "len": incl_len, - "node_id": entry["node_id"], - "link_id": entry["link_id"], - "marker": entry["marker"], - "frame_number": frame_number, - "src": cols.get("src"), - "dst": cols.get("dst"), - "proto": cols.get("proto"), - "info": cols.get("info"), - "bg": cols.get("bg"), - "fg": cols.get("fg"), - }) + merged.append( + { + "ts": _format_ts(sec, usec), + "ts_us": sec * 1_000_000 + usec, + "_source": source_key, + "len": incl_len, + "node_id": entry["node_id"], + "link_id": entry["link_id"], + "marker": entry["marker"], + "frame_number": frame_number, + "src": cols.get("src"), + "dst": cols.get("dst"), + "proto": cols.get("proto"), + "info": cols.get("info"), + "bg": cols.get("bg"), + "fg": cols.get("fg"), + } + ) merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"])) for frame in merged: del frame["ts_us"] @@ -702,9 +705,7 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None): def _validate_filter(filter_expr): if filter_expr is not None and len(filter_expr) > FILTER_MAX_LENGTH: - raise ControllerBadRequestError( - f"Display filter too long (max {FILTER_MAX_LENGTH} characters)" - ) + raise ControllerBadRequestError(f"Display filter too long (max {FILTER_MAX_LENGTH} characters)") async def build_timeline(project, tag, filter_expr=None, link_id=None): @@ -746,13 +747,14 @@ async def query_frames(project, tag, ts, window_ms=100, limit=1000, filter_expr= start_us = _parse_ts(ts) end_us = start_us + max(window_ms, 0) * 1000 hits = [f for f in frames if start_us <= _parse_ts(f["ts"]) <= end_us] - return {"frames": hits[:max(limit, 0)]} + return {"frames": hits[: max(limit, 0)]} # --------------------------------------------------------------------------- # Frame detail (lazy — one frame per call, via the resident session) # --------------------------------------------------------------------------- + async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=None): """ Decode exactly one frame: locate its pcap by source identity, verify the @@ -770,29 +772,23 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number= entries = gate_tag(project, tag) entry = next( - (e for e in entries - if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker), + (e for e in entries if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker), None, ) if entry is None: - raise ControllerNotFoundError( - f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}" - ) + raise ControllerNotFoundError(f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}") pcap = os.path.join(project.markers_directory, f"{node_id}_{link_id}_{marker}.pcap") if not os.path.exists(pcap): raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)") frames = scan_pcap_frames(pcap) - rebuilt_message = ( - f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)" - ) + rebuilt_message = f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)" if frame_number is None: # The ts must be the exact string the timeline returned; find the frame # it identifies rather than trusting any position hint from the client. frame_number = next( - (i for i, (sec, usec, _len) in enumerate(frames, start=1) - if _format_ts(sec, usec) == ts), + (i for i, (sec, usec, _len) in enumerate(frames, start=1) if _format_ts(sec, usec) == ts), None, ) if frame_number is None: @@ -820,8 +816,7 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number= tree = _rename_value(result.get("tree", [])) return { "ts": ts, - "source": {"node_id": node_id, "link_id": link_id, "marker": marker, - "frame_number": frame_number}, + "source": {"node_id": node_id, "link_id": link_id, "marker": marker, "frame_number": frame_number}, "field_count": _count_tree_nodes(tree), "hex": raw_hex, "tree": tree, diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 9cbfd9292..dac44f60d 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -21,12 +21,7 @@ import copy import uuid import os -from .controller_error import ( - ControllerError, - ControllerTimeoutError, - ComputeError, - ComputeConflictError -) +from .controller_error import ControllerError, ControllerTimeoutError, ComputeError, ComputeConflictError from .node_types import BUILTIN_NODE_TYPES from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory from ..utils.images import images_directories @@ -540,7 +535,10 @@ class Node: raise log.warning( "Could not provide missing image '%s' for node '%s' [%s]: %s", - last_missing_image, self._name, self._id, upload_error + last_missing_image, + self._name, + self._id, + upload_error, ) if not res: if allow_missing_image: @@ -554,7 +552,9 @@ class Node: self._missing_images = missing_images log.warning( "Node '%s' [%s] is kept in degraded state, missing image(s): %s", - self._name, self._id, ", ".join(m["image"] for m in self._missing_images) + self._name, + self._id, + ", ".join(m["image"] for m in self._missing_images), ) return False raise e @@ -571,7 +571,9 @@ class Node: self._missing_images = self._compute_missing_images(last_missing_image) log.warning( "Node '%s' [%s] could not be created, missing image(s): %s", - self._name, self._id, ", ".join(m["image"] for m in self._missing_images) + self._name, + self._id, + ", ".join(m["image"] for m in self._missing_images), ) elif last_missing_error is not None: # Uploading appeared to succeed, but the compute rejected every @@ -655,18 +657,13 @@ class Node: if prop is None: fallback_basename = os.path.basename(fallback_image) prop = next( - ( - p - for p in mapping - if properties.get(p) and os.path.basename(properties[p]) == fallback_basename - ), + (p for p in mapping if properties.get(p) and os.path.basename(properties[p]) == fallback_basename), None, ) if prop is None and mapping: prop = next(iter(mapping)) already_reported = any( - m["image"] == fallback_image or (prop is not None and m["property"] == prop) - for m in missing + m["image"] == fallback_image or (prop is not None and m["property"] == prop) for m in missing ) if not already_reported: image_type = mapping.get(prop, self._node_type) @@ -722,11 +719,7 @@ class Node: if prop == "properties": compute_properties = copy.deepcopy(kwargs[prop]) else: - if ( - prop == "name" - and self.status == "started" - and self._node_type not in BUILTIN_NODE_TYPES - ): + if prop == "name" and self.status == "started" and self._node_type not in BUILTIN_NODE_TYPES: raise ControllerError("Sorry, it is not possible to rename a node that is already powered on") setattr(self, prop, kwargs[prop]) @@ -808,7 +801,7 @@ class Node: "startup_config_content", "private_config_content", "startup_script", - "custom_adapters" + "custom_adapters", ]: if key in self._properties: del self._properties[key] @@ -866,10 +859,17 @@ class Node: # Image path fields for various node types image_path_fields = { # Common fields (IOU, Docker, etc.) - "path", "image", + "path", + "image", # QEMU-specific fields - "hda_disk_image", "hdb_disk_image", "hdc_disk_image", "hdd_disk_image", - "cdrom_image", "bios_image", "initrd", "kernel_image", + "hda_disk_image", + "hdb_disk_image", + "hdc_disk_image", + "hdd_disk_image", + "cdrom_image", + "bios_image", + "initrd", + "kernel_image", # VMware-specific fields "vmx_path", } @@ -931,7 +931,9 @@ class Node: if self.node_type == "iou": license_check = self._project.controller.iou_license.get("license_check", True) iourc_content = self._project.controller.iou_license.get("iourc_content", None) - await self.post("/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}) + await self.post( + "/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content} + ) else: await self.post("/start", data=data, timeout=240) except asyncio.TimeoutError: @@ -1046,9 +1048,7 @@ class Node: self.project.emit_notification("log.info", {"message": f"Uploading missing image {img}"}) try: with open(image, "rb") as f: - await self._compute.post( - f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None - ) + await self._compute.post(f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None) except OSError as e: raise ControllerError(f"Can't upload {image}: {str(e)}") self.project.emit_notification("log.info", {"message": f"Upload finished for {img}"}) @@ -1094,23 +1094,23 @@ class Node: # compute to pull it from the Docker repository as a fallback self.project.emit_notification( "log.info", - {"message": f"Docker image '{image}' is not on the controller host, " - f"asking compute '{self._compute.name}' to pull it"} + { + "message": f"Docker image '{image}' is not on the controller host, " + f"asking compute '{self._compute.name}' to pull it" + }, ) await self._compute.post("/docker/images/pull", data={"image": image}, timeout=None) return True self.project.emit_notification( - "log.info", - {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"} + "log.info", {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"} ) try: await self._compute.post("/docker/images/load", data=response.content, timeout=None) finally: response.close() self.project.emit_notification( - "log.info", - {"message": f"Docker image '{image}' has been synced to compute '{self._compute.name}'"} + "log.info", {"message": f"Docker image '{image}' has been synced to compute '{self._compute.name}'"} ) return True @@ -1201,7 +1201,9 @@ class Node: port_name = custom_adapter_settings.get("port_name", port_name) mac_address = custom_adapter_settings.get("mac_address") if not mac_address and "mac_address" in self._properties: - mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number) + mac_address = int_to_macaddress( + macaddress_to_int(self._properties["mac_address"]) + adapter_number + ) port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name) port.mac_address = mac_address @@ -1245,33 +1247,33 @@ class Node: """ topology = { - "compute_id": str(self._compute.id), - "node_id": self._id, - "node_type": self._node_type, - "template_id": self._template_id, - "name": self._name, - "console": self._console, - "console_type": self._console_type, - "console_auto_start": self._console_auto_start, - "netmiko_device_type": self._netmiko_device_type, - "default_username": self._default_username, - "default_password": self._default_password, - "aux": self._aux, - "aux_type": self._aux_type, - "properties": self._properties, - "label": self._label, - "x": self._x, - "y": self._y, - "z": self._z, - "locked": self._locked, - "width": self._width, - "height": self._height, - "symbol": self._symbol, - "port_name_format": self._port_name_format, - "port_segment_size": self._port_segment_size, - "first_port_name": self._first_port_name, - "custom_adapters": self._custom_adapters, - "tags": self._tags, + "compute_id": str(self._compute.id), + "node_id": self._id, + "node_type": self._node_type, + "template_id": self._template_id, + "name": self._name, + "console": self._console, + "console_type": self._console_type, + "console_auto_start": self._console_auto_start, + "netmiko_device_type": self._netmiko_device_type, + "default_username": self._default_username, + "default_password": self._default_password, + "aux": self._aux, + "aux_type": self._aux_type, + "properties": self._properties, + "label": self._label, + "x": self._x, + "y": self._y, + "z": self._z, + "locked": self._locked, + "width": self._width, + "height": self._height, + "symbol": self._symbol, + "port_name_format": self._port_name_format, + "port_segment_size": self._port_segment_size, + "first_port_name": self._first_port_name, + "custom_adapters": self._custom_adapters, + "tags": self._tags, } if topology_dump: diff --git a/gns3server/controller/node_types.py b/gns3server/controller/node_types.py index 4c4683c62..98263ee54 100644 --- a/gns3server/controller/node_types.py +++ b/gns3server/controller/node_types.py @@ -21,11 +21,13 @@ Shared node type constants used across the controller. # Node types that are always running (builtin/virtual switch nodes). # These nodes do not require explicit start/stop and have limited feature support. -BUILTIN_NODE_TYPES = frozenset({ - "cloud", - "nat", - "ethernet_switch", - "ethernet_hub", - "frame_relay_switch", - "atm_switch", -}) \ No newline at end of file +BUILTIN_NODE_TYPES = frozenset( + { + "cloud", + "nat", + "ethernet_switch", + "ethernet_hub", + "frame_relay_switch", + "atm_switch", + } +) diff --git a/gns3server/controller/ports/ethernet_port.py b/gns3server/controller/ports/ethernet_port.py index 3ae256e23..6158685af 100644 --- a/gns3server/controller/ports/ethernet_port.py +++ b/gns3server/controller/ports/ethernet_port.py @@ -18,12 +18,10 @@ Ethernet port for Ethernet link end points. """ - from .port import Port class EthernetPort(Port): - """ Ethernet port. """ diff --git a/gns3server/controller/ports/port.py b/gns3server/controller/ports/port.py index 52e5fe4c0..9d45cbda1 100644 --- a/gns3server/controller/ports/port.py +++ b/gns3server/controller/ports/port.py @@ -83,7 +83,7 @@ class Port: # If port name format has changed we use the port name as the short name (1.X behavior) if self._short_name: return self._short_name - elif self._name and '/' in self._name: + elif self._name and "/" in self._name: return self._name.replace(self.long_name_type(), self.short_name_type()) elif self._name and self._name.startswith(f"{self.long_name_type()}{self._interface_number}"): return self.short_name_type() + f"{self._interface_number}" diff --git a/gns3server/controller/ports/port_factory.py b/gns3server/controller/ports/port_factory.py index 9aa0f98e1..c17434578 100644 --- a/gns3server/controller/ports/port_factory.py +++ b/gns3server/controller/ports/port_factory.py @@ -66,7 +66,6 @@ class StandardPortFactory: ethernet_adapters = properties.get("adapters", 1) for adapter_number in range(adapter_number, ethernet_adapters + adapter_number): - custom_adapter_settings = {} if custom_adapters: for custom_adapter in custom_adapters: diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 24511d40b..00906bc42 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -657,9 +657,7 @@ class Project: return node @open_required - async def add_node( - self, compute, name, node_id, dump=True, node_type=None, allow_missing_image=False, **kwargs - ): + async def add_node(self, compute, name, node_id, dump=True, node_type=None, allow_missing_image=False, **kwargs): """ Create a node or return an existing node @@ -846,10 +844,7 @@ class Project: try: await link.update_filters(link_data["filters"]) except ControllerError as e: - log.warning( - "Dropping invalid filters on link %s: %s", - link_data.get("link_id"), e - ) + log.warning("Dropping invalid filters on link %s: %s", link_data.get("link_id"), e) # Restore traffic-insight markers directly into link state (mirrors how # filters are restored via update_filters). The capture_node_id persisted # last time is reused for NIO routing; no side resolution is possible here @@ -865,7 +860,9 @@ class Project: if not result.get("valid"): log.warning( "Dropping marker %s on link %s: invalid BPF (%s)", - name, link_data.get("link_id"), result.get("error") + name, + link_data.get("link_id"), + result.get("error"), ) continue link._markers[name] = { @@ -935,7 +932,9 @@ class Project: if not result.get("valid"): log.warning( "Dropping marker %s on link %s: invalid BPF (%s)", - name, link_data.get("link_id"), result.get("error") + name, + link_data.get("link_id"), + result.get("error"), ) continue link._markers[name] = { @@ -996,7 +995,9 @@ class Project: link._deferred = True log.info( "Project '%s' [%s]: deferring link %s until missing image(s) are resolved", - self._name, self._id, link.id, + self._name, + self._id, + link.id, ) return None # Apply project-level marker definitions onto the link's memory @@ -1151,7 +1152,8 @@ class Project: self._marker_definitions[name]["paused"] = True marker_name = f"global-{name}" affected = [ - link for link in self._links.values() + link + for link in self._links.values() if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name ] await self._marker_apply_concurrently( @@ -1170,7 +1172,8 @@ class Project: self._marker_definitions[name]["paused"] = False marker_name = f"global-{name}" affected = [ - link for link in self._links.values() + link + for link in self._links.values() if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name ] await self._marker_apply_concurrently( @@ -1199,9 +1202,7 @@ class Project: """ result = validate_bpf_syntax(bpf) if not result.get("valid"): - raise ControllerError( - f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}" - ) + raise ControllerError(f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}") def _validate_marker_definition_direction(self, name, direction): """ @@ -1223,7 +1224,9 @@ class Project: "For a capture-node-relative direction on a single link, use a per-link marker." ) - async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"): + async def create_marker_definition( + self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB" + ): """ Create a project-level marker definition and fan out to every existing link that has a capable node. Links without a capable node are silently @@ -1231,26 +1234,32 @@ class Project: """ if name in self._marker_definitions: - raise ControllerError( - f"Marker definition '{name}' already exists in this project" - ) + raise ControllerError(f"Marker definition '{name}' already exists in this project") self._validate_marker_definition_bpf(name, bpf) self._validate_marker_definition_direction(name, direction) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "data_link_type": data_link_type, "paused": False} + self._marker_definitions[name] = { + "bpf": bpf, + "tag": tag, + "direction": direction, + "color": color, + "highlight_duration": highlight_duration, + "data_link_type": data_link_type, + "paused": False, + } await self._apply_def_to_all_links(name) self.dump() self.emit_notification("project.updated", self.asdict()) - async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET): + async def update_marker_definition( + self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET + ): """ Update a marker definition and sync every inherited copy on every link. """ if name not in self._marker_definitions: - raise ControllerNotFoundError( - f"Marker definition '{name}' not found in this project" - ) + raise ControllerNotFoundError(f"Marker definition '{name}' not found in this project") d = self._marker_definitions[name] if bpf is not None: @@ -1270,9 +1279,9 @@ class Project: # Links that currently carry an inherited copy of this definition. affected = [ - link for link in self._links.values() - if f"global-{name}" in link.markers - and link.markers[f"global-{name}"].get("inherited_from") == name + link + for link in self._links.values() + if f"global-{name}" in link.markers and link.markers[f"global-{name}"].get("inherited_from") == name ] if data_link_type is not _UNSET: @@ -1291,9 +1300,15 @@ class Project: for link in affected: try: await link.update_marker( - f"global-{name}", bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), - color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True, - dump=False, memory_only=True + f"global-{name}", + bpf=d["bpf"], + tag=d.get("tag"), + direction=d.get("direction"), + color=d.get("color"), + highlight_duration=d.get("highlight_duration"), + inherited=True, + dump=False, + memory_only=True, ) except ControllerError as e: log.warning("Failed to sync marker global-%s on link %s: %s", name, link.id, e) @@ -1307,16 +1322,14 @@ class Project: """ if name not in self._marker_definitions: - raise ControllerNotFoundError( - f"Marker definition '{name}' not found in this project" - ) + raise ControllerNotFoundError(f"Marker definition '{name}' not found in this project") del self._marker_definitions[name] affected = [ - link for link in self._links.values() - if f"global-{name}" in link.markers - and link.markers[f"global-{name}"].get("inherited_from") == name + link + for link in self._links.values() + if f"global-{name}" in link.markers and link.markers[f"global-{name}"].get("inherited_from") == name ] for link in affected: try: @@ -1382,9 +1395,7 @@ class Project: ) if per_compute: - await asyncio.gather( - *[_dispatch(c, n) for c, n in per_compute.items()] - ) + await asyncio.gather(*[_dispatch(c, n) for c, n in per_compute.items()]) async def apply_defs_to_new_link(self, link): """ @@ -1403,10 +1414,7 @@ class Project: # after; per-def dumps here would be N full topology writes. await link.inherit_marker(def_name, d, dump=False) except ControllerError as e: - log.warning( - "Marker definition '%s' could not be applied to new link %s: %s", - def_name, link.id, e - ) + log.warning("Marker definition '%s' could not be applied to new link %s: %s", def_name, link.id, e) async def _marker_apply_concurrently(self, links, operation, fail_msg): """ @@ -1428,10 +1436,7 @@ class Project: if not links: return _t0 = time.time() - log.info( - "Project '%s' [%s]: fanning out marker operation to %d links...", - self._name, self._id, len(links) - ) + log.info("Project '%s' [%s]: fanning out marker operation to %d links...", self._name, self._id, len(links)) sem = asyncio.Semaphore(32) async def guarded(link): @@ -1442,10 +1447,7 @@ class Project: log.warning(fail_msg(link, e)) await asyncio.gather(*(guarded(link) for link in links)) - log.info( - "Project '%s' [%s]: marker fan-out done in %.2fs", - self._name, self._id, time.time() - _t0 - ) + log.info("Project '%s' [%s]: marker fan-out done in %.2fs", self._name, self._id, time.time() - _t0) @property def snapshots(self): @@ -1512,7 +1514,7 @@ class Project: for snapshot in self._snapshots.values(): self._snapshot_conf.append(snapshot.asdict()) try: - with open(self._snapshot_conf_path, 'w+') as f: + with open(self._snapshot_conf_path, "w+") as f: json.dump(self._snapshot_conf, f, indent=4) except OSError as e: log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e)) @@ -1874,8 +1876,7 @@ class Project: result = validate_bpf_syntax(bpf) if not result.get("valid"): log.warning( - "Dropping marker definition '%s' on load: invalid BPF (%s)", - def_name, result.get("error") + "Dropping marker definition '%s' on load: invalid BPF (%s)", def_name, result.get("error") ) continue clean_defs[def_name] = d @@ -1986,9 +1987,7 @@ class Project: ) if per_compute: - await asyncio.gather( - *[_dispatch_batch(c, n) for c, n in per_compute.items()] - ) + await asyncio.gather(*[_dispatch_batch(c, n) for c, n in per_compute.items()]) # Finalise every link: wire node/port back-references, mark created, # notify clients, and apply project-level marker definitions. @@ -2096,12 +2095,7 @@ class Project: # Do not compress the exported project when duplicating with aiozipstream.ZipFile(compression=zipfile.ZIP_STORED) as zstream: await export_project( - zstream, - self, - tmpdir, - keep_compute_ids=True, - include_snapshots=True, - allow_all_nodes=True + zstream, self, tmpdir, keep_compute_ids=True, include_snapshots=True, allow_all_nodes=True ) # export the project to a temporary location @@ -2120,7 +2114,7 @@ class Project: f, name=name, reset_mac_addresses=reset_mac_addresses, - keep_compute_ids=True + keep_compute_ids=True, ) log.info(f"Project '{project.name}' duplicated in {time.time() - begin:.4f} seconds") @@ -2161,8 +2155,12 @@ class Project: else: new_project_path = p_work.joinpath(new_project_id) # copy dir - await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True) - log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0)) + await wait_run_in_executor( + shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True + ) + log.info( + "Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0) + ) # Read the topology file using the actual filename (self._filename), not self.name # This handles the case where a project has been renamed but we need to read the actual file @@ -2181,7 +2179,7 @@ class Project: regenerate_topology_ids(topology, new_project_path, reset_mac_addresses) # dump the updated .gns3 project file - dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name)) + dot_gns3_path = new_project_path.joinpath("{}.gns3".format(project_name)) topology["project_id"] = new_project_id with open(dot_gns3_path, "w+") as f: json.dump(topology, f, indent=4, sort_keys=True) @@ -2359,13 +2357,7 @@ class Project: data["z"] = z data["locked"] = False # duplicated node must not be locked new_node_uuid = str(uuid.uuid4()) - new_node = await self.add_node( - node.compute, - node.name, - new_node_uuid, - node_type=node_type, - **data - ) + new_node = await self.add_node(node.compute, node.name, new_node_uuid, node_type=node_type, **data) try: await node.post("/duplicate", timeout=None, data={"destination_node_id": new_node_uuid}) except ControllerNotFoundError: diff --git a/gns3server/controller/snapshot.py b/gns3server/controller/snapshot.py index ce98dcbe0..d203830f7 100644 --- a/gns3server/controller/snapshot.py +++ b/gns3server/controller/snapshot.py @@ -42,6 +42,7 @@ FILENAME_DATETIME_FORMAT = "%d%m%y_%H%M%S" # Used to create a description of the snapshot with a human-readable date and time DESCRIPTION_DATETIME_FORMAT = "%Y-%m-%d at %H:%M:%S" + class Snapshot: """ A snapshot object @@ -69,10 +70,16 @@ class Snapshot: else: self._name = filename.rsplit("_", 2)[0] datestring = filename.replace(self._name + "_", "").split(".")[0] - self._created_at = int(datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()) + self._created_at = int( + datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp() + ) if not description: - date = datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(DESCRIPTION_DATETIME_FORMAT) + date = ( + datetime.fromtimestamp(self._created_at, tz=timezone.utc) + .replace(tzinfo=None) + .strftime(DESCRIPTION_DATETIME_FORMAT) + ) description = "Snapshot '{}' taken on {}".format(self._name, date) self._description = description @@ -149,7 +156,7 @@ class Snapshot: location=self._project.path, auto_start=self._project.auto_start, auto_open=self._project.auto_open, - auto_close=self._project.auto_close + auto_close=self._project.auto_close, ) log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin)) except (OSError, PermissionError) as e: @@ -165,5 +172,5 @@ class Snapshot: "created_at": self._created_at, "description": self._description, "filename": self._filename, - "project_id": self._project.id + "project_id": self._project.id, } diff --git a/gns3server/controller/topology.py b/gns3server/controller/topology.py index a71b88dd6..5048be4ce 100644 --- a/gns3server/controller/topology.py +++ b/gns3server/controller/topology.py @@ -35,7 +35,12 @@ from .drawing import Drawing from .node import Node from .link import Link -from gns3server.utils.hostname import is_ios_hostname_valid, is_rfc1123_hostname_valid, to_rfc1123_hostname, to_ios_hostname +from gns3server.utils.hostname import ( + is_ios_hostname_valid, + is_rfc1123_hostname_valid, + to_rfc1123_hostname, + to_ios_hostname, +) from gns3server.schemas.controller.topology import Topology from gns3server.schemas.compute.dynamips_nodes import DynamipsCreate @@ -227,7 +232,7 @@ def _convert_2_2_0(topo, topo_path): new_name = to_rfc1123_hostname(node["name"]) log.info(f"Convert node name {node['name']} to {new_name} (RFC1123)") node["name"] = new_name - if node["node_type"] in ("dynamips", "iou") and not is_ios_hostname_valid(node["name"] ): + if node["node_type"] in ("dynamips", "iou") and not is_ios_hostname_valid(node["name"]): new_name = to_ios_hostname(node["name"]) log.info(f"Convert node name {node['name']} to {new_name} (IOS)") node["name"] = new_name diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 766958bc4..f5fdcbb9c 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -29,9 +29,17 @@ from gns3server.utils.packet_filter_validation import validate_bpf_syntax, Filte # (which has no uBridge) and "ethernet_hub" (still Dynamips-hosted, no # uBridge of its own). "ethernet_switch" hosts markers on the per-port # uBridge relays of its brctl kernel-bridge backend. -_MARKER_CAPABLE_TYPES = frozenset({ - "vpcs", "qemu", "docker", "iou", "dynamips", "cloud", "ethernet_switch", -}) +_MARKER_CAPABLE_TYPES = frozenset( + { + "vpcs", + "qemu", + "docker", + "iou", + "dynamips", + "cloud", + "ethernet_switch", + } +) log = logging.getLogger(__name__) @@ -58,7 +66,7 @@ class UDPLink(Link): def deferred(self): """Whether NIO creation is waiting for missing node images.""" return self._deferred - + def _get_node_filters(self, node1, node2): """ Determine which node gets the active filters applied. @@ -81,10 +89,14 @@ class UDPLink(Link): without an NIO rebuild. """ return { - name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, - "direction": m.get("direction"), - "data_link_type": m.get("data_link_type", "DLT_EN10MB"), - "enabled": m.get("enabled", True)} + name: { + "bpf": m["bpf"], + "tag": m.get("tag"), + "link_id": self._id, + "direction": m.get("direction"), + "data_link_type": m.get("data_link_type", "DLT_EN10MB"), + "enabled": m.get("enabled", True), + } for name, m in self._markers.items() if m.get("capture_node_id") == node.id } @@ -183,33 +195,24 @@ class UDPLink(Link): tuples returned by :meth:`_prepare`. """ - (node1, adapter_number1, port_number1, nio_data1), \ - (node2, adapter_number2, port_number2, nio_data2) = entries + (node1, adapter_number1, port_number1, nio_data1), (node2, adapter_number2, port_number2, nio_data2) = entries # The two ends are independent once the ports and peer addresses are # known — each node talks to its own compute/uBridge with no shared # lock between them — so the two POSTs overlap. If either fails, roll # back whichever side succeeded before re-raising the first error. results = await asyncio.gather( - node1.post( - f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120 - ), - node2.post( - f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120 - ), + node1.post(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120), + node2.post(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120), return_exceptions=True, ) errors = [result for result in results if isinstance(result, Exception)] if errors: cleanup = [] if not isinstance(results[0], Exception): - cleanup.append( - node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120) - ) + cleanup.append(node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)) if not isinstance(results[1], Exception): - cleanup.append( - node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120) - ) + cleanup.append(node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120)) if cleanup: await asyncio.gather(*cleanup, return_exceptions=True) raise errors[0] @@ -320,7 +323,9 @@ class UDPLink(Link): ), data=data, ) - await super().start_capture(data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token) + await super().start_capture( + data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token + ) async def stop_capture(self): """ @@ -378,10 +383,7 @@ class UDPLink(Link): # Prefer started. for node in self._nodes: - if ( - node["node"].node_type in _MARKER_CAPABLE_TYPES - and node["node"].status == "started" - ): + if node["node"].node_type in _MARKER_CAPABLE_TYPES and node["node"].status == "started": return node # Accept stopped but capable (marker rides NIO, applied at start). @@ -389,10 +391,7 @@ class UDPLink(Link): if node["node"].node_type in _MARKER_CAPABLE_TYPES: return node - raise ControllerError( - "Cannot add marker because no device on this link supports " - "traffic insight" - ) + raise ControllerError("Cannot add marker because no device on this link supports traffic insight") def _node_by_id(self, node_id): """ @@ -415,9 +414,7 @@ class UDPLink(Link): f"marker — no uBridge bridge to attach the filter to" ) return node - raise ControllerNotFoundError( - f"Node {node_id} is not an endpoint of link {self._id}" - ) + raise ControllerNotFoundError(f"Node {node_id} is not an endpoint of link {self._id}") async def node_updated(self, node): """ @@ -431,7 +428,21 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None, dump=True, memory_only=False): + async def start_marker( + self, + name, + bpf, + tag=None, + direction=None, + data_link_type="DLT_EN10MB", + capture_node_id=None, + color=None, + highlight_duration=None, + enabled=True, + inherited_from=None, + dump=True, + memory_only=False, + ): """ Attach a traffic-insight marker to this link. @@ -571,7 +582,19 @@ class UDPLink(Link): if dump: self._project.dump() - async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False, dump=True, memory_only=False): + async def update_marker( + self, + name, + bpf=None, + tag=None, + enabled=None, + direction=_UNSET, + color=None, + highlight_duration=None, + inherited=False, + dump=True, + memory_only=False, + ): """ Update an existing marker's fields and push to uBridge fine-grained — no full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction diff --git a/gns3server/core/tasks.py b/gns3server/core/tasks.py index 0b745a067..427fc2dd8 100644 --- a/gns3server/core/tasks.py +++ b/gns3server/core/tasks.py @@ -76,8 +76,7 @@ async def startup(app: FastAPI) -> None: # to give it a chance to process API requests global auto_discover_images_task_handle auto_discover_images_task_handle = asyncio.get_event_loop().call_later( - 5, - lambda: asyncio.create_task(discover_images_on_filesystem(app)) + 5, lambda: asyncio.create_task(discover_images_on_filesystem(app)) ) for module in MODULES: @@ -98,6 +97,7 @@ async def startup(app: FastAPI) -> None: if MCP_AVAILABLE: from gns3server.agent.mcp import set_mcp_server_ready + set_mcp_server_ready(True) log.info("GNS3 server startup completed") diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py index 66ceea1f8..34fa73b6c 100644 --- a/gns3server/crash_report.py +++ b/gns3server/crash_report.py @@ -53,7 +53,6 @@ if __version_info__[3] != 0: class CrashReport: - """ Report crash to a third party service """ @@ -74,10 +73,9 @@ class CrashReport: # Don't send log records as events. sentry_logging = LoggingIntegration(level=logging.INFO, event_level=None) try: - sentry_sdk.init(dsn=CrashReport.DSN, - release=__version__, - default_integrations=False, - integrations=[sentry_logging]) + sentry_sdk.init( + dsn=CrashReport.DSN, release=__version__, default_integrations=False, integrations=[sentry_logging] + ) except Exception as e: log.error("Crash report could not be sent: {}".format(e)) return @@ -88,7 +86,6 @@ class CrashReport: "os:win_32": " ".join(platform.win32_ver()), "os:mac": "{} {}".format(platform.mac_ver()[0], platform.mac_ver()[2]), "os:linux": distro.name(pretty=True), - } with sentry_sdk.configure_scope() as scope: @@ -137,7 +134,6 @@ class CrashReport: return if Config.instance().settings.Server.report_errors: - if not SENTRY_SDK_AVAILABLE: log.warning("Cannot capture exception: Sentry SDK is not available") return diff --git a/gns3server/db/models/acl.py b/gns3server/db/models/acl.py index 20df831d7..32f7f7248 100644 --- a/gns3server/db/models/acl.py +++ b/gns3server/db/models/acl.py @@ -26,7 +26,6 @@ log = logging.getLogger(__name__) class ACE(BaseTable): - __tablename__ = "acl" ace_id = Column(GUID, primary_key=True, default=generate_uuid) @@ -34,11 +33,11 @@ class ACE(BaseTable): path = Column(String) propagate = Column(Boolean, default=True) allowed = Column(Boolean, default=True) - user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE")) + user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE")) user = relationship("User", back_populates="acl_entries") - group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE")) + group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE")) group = relationship("UserGroup", back_populates="acl_entries") - role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE")) + role_id = Column(GUID, ForeignKey("roles.role_id", ondelete="CASCADE")) role = relationship("Role", back_populates="acl_entries") __table_args__ = ( diff --git a/gns3server/db/models/api_keys.py b/gns3server/db/models/api_keys.py index b43ecf5f2..5cc40eb0b 100644 --- a/gns3server/db/models/api_keys.py +++ b/gns3server/db/models/api_keys.py @@ -20,7 +20,6 @@ from .base import BaseTable, GUID class ApiKey(BaseTable): - __tablename__ = "api_keys" api_key_id = Column(GUID, primary_key=True) diff --git a/gns3server/db/models/base.py b/gns3server/db/models/base.py index 97bd2fcba..cf726ec79 100644 --- a/gns3server/db/models/base.py +++ b/gns3server/db/models/base.py @@ -84,7 +84,7 @@ class ListType(TypeDecorator): impl = VARCHAR cache_ok = True - def __init__(self, separator=',', *args, **kwargs): + def __init__(self, separator=",", *args, **kwargs): self._separator = separator super().__init__(*args, **kwargs) @@ -92,8 +92,7 @@ class ListType(TypeDecorator): def process_bind_param(self, value, dialect): if value is not None: if any(self._separator in str(item) for item in value): - raise ListException(f"List values cannot contain '{self._separator}'" - f"Please use a different separator.") + raise ListException(f"List values cannot contain '{self._separator}'Please use a different separator.") return self._separator.join(map(str, value)) def process_result_value(self, value, dialect): @@ -104,7 +103,6 @@ class ListType(TypeDecorator): class BaseTable(Base): - __abstract__ = True created_at = Column(DateTime, server_default=func.current_timestamp()) diff --git a/gns3server/db/models/computes.py b/gns3server/db/models/computes.py index 71043d885..dff176cda 100644 --- a/gns3server/db/models/computes.py +++ b/gns3server/db/models/computes.py @@ -21,7 +21,6 @@ from .base import BaseTable, GUID class Compute(BaseTable): - __tablename__ = "computes" compute_id = Column(GUID, primary_key=True) diff --git a/gns3server/db/models/images.py b/gns3server/db/models/images.py index 529afe4bf..9755d8470 100644 --- a/gns3server/db/models/images.py +++ b/gns3server/db/models/images.py @@ -25,12 +25,11 @@ image_template_map = Table( "image_template_map", Base.metadata, Column("image_id", Integer, ForeignKey("images.image_id", ondelete="CASCADE")), - Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE")) + Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE")), ) class Image(BaseTable): - __tablename__ = "images" image_id = Column(Integer, primary_key=True, autoincrement=True) diff --git a/gns3server/db/models/llm_model_configs.py b/gns3server/db/models/llm_model_configs.py index 77d65cbb7..778d16bbc 100644 --- a/gns3server/db/models/llm_model_configs.py +++ b/gns3server/db/models/llm_model_configs.py @@ -50,21 +50,28 @@ class LLMModelConfig(BaseTable): __table_args__ = ( # Ensure a config belongs to either a user or a group, not both CheckConstraint( - "(user_id IS NOT NULL AND group_id IS NULL) OR " - "(user_id IS NULL AND group_id IS NOT NULL)", - name="single_owner_check" + "(user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)", + name="single_owner_check", ), # Validate model_type values CheckConstraint( "model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')", - name="valid_model_type_check" + name="valid_model_type_check", ), # Each user can have at most one default config (partial unique index, PostgreSQL only) - Index("unique_user_default", "user_id", unique=True, - postgresql_where=text("is_default = TRUE AND user_id IS NOT NULL")), + Index( + "unique_user_default", + "user_id", + unique=True, + postgresql_where=text("is_default = TRUE AND user_id IS NOT NULL"), + ), # Each group can have at most one default config (partial unique index, PostgreSQL only) - Index("unique_group_default", "group_id", unique=True, - postgresql_where=text("is_default = TRUE AND group_id IS NOT NULL")), + Index( + "unique_group_default", + "group_id", + unique=True, + postgresql_where=text("is_default = TRUE AND group_id IS NOT NULL"), + ), # Indexes for efficient queries Index("idx_llm_model_configs_user_id", "user_id"), Index("idx_llm_model_configs_group_id", "group_id"), diff --git a/gns3server/db/models/pools.py b/gns3server/db/models/pools.py index dce9bf5b2..d8c011cbc 100644 --- a/gns3server/db/models/pools.py +++ b/gns3server/db/models/pools.py @@ -29,12 +29,11 @@ resource_pool_map = Table( "resource_pool_map", Base.metadata, Column("resource_id", GUID, ForeignKey("resources.resource_id", ondelete="CASCADE")), - Column("resource_pool_id", GUID, ForeignKey("resource_pools.resource_pool_id", ondelete="CASCADE")) + Column("resource_pool_id", GUID, ForeignKey("resource_pools.resource_pool_id", ondelete="CASCADE")), ) class Resource(BaseTable): - __tablename__ = "resources" resource_id = Column(GUID, primary_key=True) @@ -44,7 +43,6 @@ class Resource(BaseTable): class ResourcePool(BaseTable): - __tablename__ = "resource_pools" resource_pool_id = Column(GUID, primary_key=True, default=generate_uuid) diff --git a/gns3server/db/models/privileges.py b/gns3server/db/models/privileges.py index c446b17ee..49a904ed8 100644 --- a/gns3server/db/models/privileges.py +++ b/gns3server/db/models/privileges.py @@ -29,12 +29,11 @@ privilege_role_map = Table( "privilege_role_map", Base.metadata, Column("privilege_id", GUID, ForeignKey("privileges.privilege_id", ondelete="CASCADE")), - Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE")) + Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE")), ) class Privilege(BaseTable): - __tablename__ = "privileges" privilege_id = Column(GUID, primary_key=True, default=generate_uuid) @@ -43,210 +42,60 @@ class Privilege(BaseTable): roles = relationship("Role", secondary=privilege_role_map, back_populates="privileges") -@event.listens_for(Privilege.__table__, 'after_create') +@event.listens_for(Privilege.__table__, "after_create") def create_default_roles(target, connection, **kw): default_privileges = [ - { - "description": "Create or delete a user", - "name": "User.Allocate" - }, - { - "description": "View a user", - "name": "User.Audit" - }, - { - "description": "Update a user", - "name": "User.Modify" - }, - { - "description": "Create or delete a group", - "name": "Group.Allocate" - }, - { - "description": "View a group", - "name": "Group.Audit" - }, - { - "description": "Update a group", - "name": "Group.Modify" - }, - { - "description": "Create or delete a role", - "name": "Role.Allocate" - }, - { - "description": "View a role", - "name": "Role.Audit" - }, - { - "description": "Update a role", - "name": "Role.Modify" - }, - { - "description": "Create or delete an ACE", - "name": "ACE.Allocate" - }, - { - "description": "View an ACE", - "name": "ACE.Audit" - }, - { - "description": "Update an ACE", - "name": "ACE.Modify" - }, - { - "description": "Create or delete a resource pool", - "name": "Pool.Allocate" - }, - { - "description": "View a resource pool", - "name": "Pool.Audit" - }, - { - "description": "Update a resource pool", - "name": "Pool.Modify" - }, - { - "description": "Create or delete a template", - "name": "Template.Allocate" - }, - { - "description": "View a template", - "name": "Template.Audit" - }, - { - "description": "Update a template", - "name": "Template.Modify" - }, - { - "description": "Create or delete a project", - "name": "Project.Allocate" - }, - { - "description": "View a project", - "name": "Project.Audit" - }, - { - "description": "Update a project", - "name": "Project.Modify" - }, - { - "description": "Create or delete project snapshots", - "name": "Snapshot.Allocate" - }, - { - "description": "Restore a snapshot", - "name": "Snapshot.Restore" - }, - { - "description": "View a snapshot", - "name": "Snapshot.Audit" - }, - { - "description": "Create or delete a node", - "name": "Node.Allocate" - }, - { - "description": "View a node", - "name": "Node.Audit" - }, - { - "description": "Update a node", - "name": "Node.Modify" - }, - { - "description": "Console access to a node", - "name": "Node.Console" - }, - { - "description": "Power management for a node", - "name": "Node.PowerMgmt" - }, - { - "description": "Create or delete a link", - "name": "Link.Allocate" - }, - { - "description": "View a link", - "name": "Link.Audit" - }, - { - "description": "Update a link", - "name": "Link.Modify" - }, - { - "description": "Capture packets on a link", - "name": "Link.Capture" - }, - { - "description": "Create or delete a drawing", - "name": "Drawing.Allocate" - }, - { - "description": "View a drawing", - "name": "Drawing.Audit" - }, - { - "description": "Update a drawing", - "name": "Drawing.Modify" - }, - { - "description": "Create or delete a symbol", - "name": "Symbol.Allocate" - }, - { - "description": "View a symbol", - "name": "Symbol.Audit" - }, - { - "description": "Create or delete an image", - "name": "Image.Allocate" - }, - { - "description": "View an image", - "name": "Image.Audit" - }, - { - "description": "Create or delete a compute", - "name": "Compute.Allocate" - }, - { - "description": "Update a compute", - "name": "Compute.Modify" - }, - { - "description": "View a compute", - "name": "Compute.Audit" - }, - { - "description": "Install an appliance", - "name": "Appliance.Allocate" - }, - { - "description": "View an appliance", - "name": "Appliance.Audit" - }, - { - "description": "Create or delete an LLM model configuration", - "name": "LLMConfig.Allocate" - }, - { - "description": "View an LLM model configuration", - "name": "LLMConfig.Audit" - }, - { - "description": "Update an LLM model configuration", - "name": "LLMConfig.Modify" - }, - { - "description": "View server settings", - "name": "Server.Audit" - }, - { - "description": "Update server settings", - "name": "Server.Modify" - } + {"description": "Create or delete a user", "name": "User.Allocate"}, + {"description": "View a user", "name": "User.Audit"}, + {"description": "Update a user", "name": "User.Modify"}, + {"description": "Create or delete a group", "name": "Group.Allocate"}, + {"description": "View a group", "name": "Group.Audit"}, + {"description": "Update a group", "name": "Group.Modify"}, + {"description": "Create or delete a role", "name": "Role.Allocate"}, + {"description": "View a role", "name": "Role.Audit"}, + {"description": "Update a role", "name": "Role.Modify"}, + {"description": "Create or delete an ACE", "name": "ACE.Allocate"}, + {"description": "View an ACE", "name": "ACE.Audit"}, + {"description": "Update an ACE", "name": "ACE.Modify"}, + {"description": "Create or delete a resource pool", "name": "Pool.Allocate"}, + {"description": "View a resource pool", "name": "Pool.Audit"}, + {"description": "Update a resource pool", "name": "Pool.Modify"}, + {"description": "Create or delete a template", "name": "Template.Allocate"}, + {"description": "View a template", "name": "Template.Audit"}, + {"description": "Update a template", "name": "Template.Modify"}, + {"description": "Create or delete a project", "name": "Project.Allocate"}, + {"description": "View a project", "name": "Project.Audit"}, + {"description": "Update a project", "name": "Project.Modify"}, + {"description": "Create or delete project snapshots", "name": "Snapshot.Allocate"}, + {"description": "Restore a snapshot", "name": "Snapshot.Restore"}, + {"description": "View a snapshot", "name": "Snapshot.Audit"}, + {"description": "Create or delete a node", "name": "Node.Allocate"}, + {"description": "View a node", "name": "Node.Audit"}, + {"description": "Update a node", "name": "Node.Modify"}, + {"description": "Console access to a node", "name": "Node.Console"}, + {"description": "Power management for a node", "name": "Node.PowerMgmt"}, + {"description": "Create or delete a link", "name": "Link.Allocate"}, + {"description": "View a link", "name": "Link.Audit"}, + {"description": "Update a link", "name": "Link.Modify"}, + {"description": "Capture packets on a link", "name": "Link.Capture"}, + {"description": "Create or delete a drawing", "name": "Drawing.Allocate"}, + {"description": "View a drawing", "name": "Drawing.Audit"}, + {"description": "Update a drawing", "name": "Drawing.Modify"}, + {"description": "Create or delete a symbol", "name": "Symbol.Allocate"}, + {"description": "View a symbol", "name": "Symbol.Audit"}, + {"description": "Create or delete an image", "name": "Image.Allocate"}, + {"description": "View an image", "name": "Image.Audit"}, + {"description": "Create or delete a compute", "name": "Compute.Allocate"}, + {"description": "Update a compute", "name": "Compute.Modify"}, + {"description": "View a compute", "name": "Compute.Audit"}, + {"description": "Install an appliance", "name": "Appliance.Allocate"}, + {"description": "View an appliance", "name": "Appliance.Audit"}, + {"description": "Create or delete an LLM model configuration", "name": "LLMConfig.Allocate"}, + {"description": "View an LLM model configuration", "name": "LLMConfig.Audit"}, + {"description": "Update an LLM model configuration", "name": "LLMConfig.Modify"}, + {"description": "View server settings", "name": "Server.Audit"}, + {"description": "Update server settings", "name": "Server.Modify"}, ] stmt = target.insert().values(default_privileges) @@ -258,6 +107,7 @@ def create_default_roles(target, connection, **kw): def add_privileges_to_role(target, connection, role, privileges): from .roles import Role + roles_table = Role.__table__ privileges_table = Privilege.__table__ @@ -272,10 +122,11 @@ def add_privileges_to_role(target, connection, role, privileges): connection.execute(stmt) -@event.listens_for(privilege_role_map, 'after_create') +@event.listens_for(privilege_role_map, "after_create") def add_privileges_to_default_roles(target, connection, **kw): from .roles import Role + roles_table = Role.__table__ stmt = roles_table.select().where(roles_table.c.name == "Administrator") result = connection.execute(stmt) @@ -317,7 +168,7 @@ def add_privileges_to_default_roles(target, connection, **kw): "Appliance.Allocate", "Appliance.Audit", "LLMConfig.Audit", - "LLMConfig.Modify" + "LLMConfig.Modify", ) add_privileges_to_role(target, connection, "User", user_privileges) @@ -333,7 +184,7 @@ def add_privileges_to_default_roles(target, connection, **kw): "Symbol.Audit", "Image.Audit", "Compute.Audit", - "Appliance.Audit" + "Appliance.Audit", ) add_privileges_to_role(target, connection, "Auditor", auditor_privileges) @@ -348,7 +199,7 @@ def add_privileges_to_default_roles(target, connection, **kw): "Image.Allocate", "Image.Audit", "Appliance.Allocate", - "Appliance.Audit" + "Appliance.Audit", ) add_privileges_to_role(target, connection, "Template manager", template_manager_privileges) @@ -360,20 +211,13 @@ def add_privileges_to_default_roles(target, connection, **kw): "User.Modify", "Group.Allocate", "Group.Audit", - "Group.Modify" + "Group.Modify", ) add_privileges_to_role(target, connection, "User manager", user_manager_privileges) # add required privileges to the "ACL manager" role - acl_manager_privileges = ( - "Role.Allocate", - "Role.Audit", - "Role.Modify", - "ACE.Allocate", - "ACE.Audit", - "ACE.Modify" - ) + acl_manager_privileges = ("Role.Allocate", "Role.Audit", "Role.Modify", "ACE.Allocate", "ACE.Audit", "ACE.Modify") add_privileges_to_role(target, connection, "ACL manager", acl_manager_privileges) diff --git a/gns3server/db/models/roles.py b/gns3server/db/models/roles.py index ea02365fe..73c29dacb 100644 --- a/gns3server/db/models/roles.py +++ b/gns3server/db/models/roles.py @@ -27,7 +27,6 @@ log = logging.getLogger(__name__) class Role(BaseTable): - __tablename__ = "roles" role_id = Column(GUID, primary_key=True, default=generate_uuid) @@ -38,7 +37,7 @@ class Role(BaseTable): acl_entries = relationship("ACE") -@event.listens_for(Role.__table__, 'after_create') +@event.listens_for(Role.__table__, "after_create") def create_default_roles(target, connection, **kw): default_roles = [ @@ -48,7 +47,7 @@ def create_default_roles(target, connection, **kw): {"name": "Template manager", "description": "Role to manage templates", "is_builtin": True}, {"name": "User manager", "description": "Role to manage users and groups", "is_builtin": True}, {"name": "ACL manager", "description": "Role to manage other roles and the ACL", "is_builtin": True}, - {"name": "No Access", "description": "Role with no privileges (used to forbid access)", "is_builtin": True} + {"name": "No Access", "description": "Role with no privileges (used to forbid access)", "is_builtin": True}, ] stmt = target.insert().values(default_roles) diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index 759cc0fc8..62ea74a9e 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -24,7 +24,6 @@ from .images import image_template_map class Template(BaseTable): - __tablename__ = "templates" template_id = Column(GUID, primary_key=True, default=generate_uuid) @@ -49,7 +48,6 @@ class Template(BaseTable): class CloudTemplate(Template): - __tablename__ = "cloud_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -63,7 +61,6 @@ class CloudTemplate(Template): class DockerTemplate(Template): - __tablename__ = "docker_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -89,7 +86,6 @@ class DockerTemplate(Template): class DynamipsTemplate(Template): - __tablename__ = "dynamips_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -132,7 +128,6 @@ class DynamipsTemplate(Template): class EthernetHubTemplate(Template): - __tablename__ = "ethernet_hub_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -142,7 +137,6 @@ class EthernetHubTemplate(Template): class EthernetSwitchTemplate(Template): - __tablename__ = "ethernet_switch_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -153,7 +147,6 @@ class EthernetSwitchTemplate(Template): class IOUTemplate(Template): - __tablename__ = "iou_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -173,7 +166,6 @@ class IOUTemplate(Template): class QemuTemplate(Template): - __tablename__ = "qemu_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -220,7 +212,6 @@ class QemuTemplate(Template): class VirtualBoxTemplate(Template): - __tablename__ = "virtualbox_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -243,7 +234,6 @@ class VirtualBoxTemplate(Template): class VMwareTemplate(Template): - __tablename__ = "vmware_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) @@ -265,7 +255,6 @@ class VMwareTemplate(Template): class VPCSTemplate(Template): - __tablename__ = "vpcs_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) diff --git a/gns3server/db/models/users.py b/gns3server/db/models/users.py index 5c14c3c15..d9fc9aa29 100644 --- a/gns3server/db/models/users.py +++ b/gns3server/db/models/users.py @@ -31,12 +31,11 @@ user_group_map = Table( "user_group_map", Base.metadata, Column("user_id", GUID, ForeignKey("users.user_id", ondelete="CASCADE")), - Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE")) + Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE")), ) class User(BaseTable): - __tablename__ = "users" user_id = Column(GUID, primary_key=True, default=generate_uuid) @@ -52,7 +51,7 @@ class User(BaseTable): acl_entries = relationship("ACE") -@event.listens_for(User.__table__, 'after_create') +@event.listens_for(User.__table__, "after_create") def create_default_super_admin(target, connection, **kw): config = Config.instance().settings @@ -63,7 +62,7 @@ def create_default_super_admin(target, connection, **kw): username=default_admin_username, full_name="Super Administrator", hashed_password=hashed_password, - is_superadmin=True + is_superadmin=True, ) connection.execute(stmt) connection.commit() @@ -71,7 +70,6 @@ def create_default_super_admin(target, connection, **kw): class UserGroup(BaseTable): - __tablename__ = "user_groups" user_group_id = Column(GUID, primary_key=True, default=generate_uuid) @@ -81,13 +79,10 @@ class UserGroup(BaseTable): acl_entries = relationship("ACE") -@event.listens_for(UserGroup.__table__, 'after_create') +@event.listens_for(UserGroup.__table__, "after_create") def create_default_user_groups(target, connection, **kw): - default_groups = [ - {"name": "Administrators", "is_builtin": True}, - {"name": "Users", "is_builtin": True} - ] + default_groups = [{"name": "Administrators", "is_builtin": True}, {"name": "Users", "is_builtin": True}] stmt = target.insert().values(default_groups) connection.execute(stmt) diff --git a/gns3server/db/repositories/api_keys.py b/gns3server/db/repositories/api_keys.py index ae56dd37e..d43d44dd1 100644 --- a/gns3server/db/repositories/api_keys.py +++ b/gns3server/db/repositories/api_keys.py @@ -30,7 +30,6 @@ log = logging.getLogger(__name__) class ApiKeysRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -55,11 +54,7 @@ class ApiKeysRepository(BaseRepository): return result.scalars().first() async def get_api_keys_by_user(self, user_id: UUID) -> List[models.ApiKey]: - query = ( - select(models.ApiKey) - .where(models.ApiKey.user_id == user_id) - .order_by(models.ApiKey.created_at.desc()) - ) + query = select(models.ApiKey).where(models.ApiKey.user_id == user_id).order_by(models.ApiKey.created_at.desc()) result = await self._db_session.execute(query) return list(result.scalars().all()) @@ -69,31 +64,19 @@ class ApiKeysRepository(BaseRepository): return result.scalars().first() async def revoke_api_key(self, api_key_id: UUID) -> bool: - query = ( - update(models.ApiKey) - .where(models.ApiKey.api_key_id == api_key_id) - .values(revoked=True) - ) + query = update(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id).values(revoked=True) result = await self._db_session.execute(query) await self._db_session.commit() return result.rowcount > 0 async def restore_api_key(self, api_key_id: UUID) -> bool: - query = ( - update(models.ApiKey) - .where(models.ApiKey.api_key_id == api_key_id) - .values(revoked=False) - ) + query = update(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id).values(revoked=False) result = await self._db_session.execute(query) await self._db_session.commit() return result.rowcount > 0 async def update_last_used(self, api_key_id: UUID) -> None: - query = ( - update(models.ApiKey) - .where(models.ApiKey.api_key_id == api_key_id) - .values(last_used_at=func.now()) - ) + query = update(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id).values(last_used_at=func.now()) await self._db_session.execute(query) await self._db_session.commit() diff --git a/gns3server/db/repositories/computes.py b/gns3server/db/repositories/computes.py index 181b92aa6..4f3aac7f4 100644 --- a/gns3server/db/repositories/computes.py +++ b/gns3server/db/repositories/computes.py @@ -27,7 +27,6 @@ from gns3server import schemas class ComputesRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -72,9 +71,7 @@ class ComputesRepository(BaseRepository): if compute_update.password is not None: update_values["password"] = compute_update.password.get_secret_value() - query = update(models.Compute).\ - where(models.Compute.compute_id == compute_id).\ - values(update_values) + query = update(models.Compute).where(models.Compute.compute_id == compute_id).values(update_values) await self._db_session.execute(query) await self._db_session.commit() diff --git a/gns3server/db/repositories/images.py b/gns3server/db/repositories/images.py index 8f6c64dc9..c709c9820 100644 --- a/gns3server/db/repositories/images.py +++ b/gns3server/db/repositories/images.py @@ -31,7 +31,6 @@ log = logging.getLogger(__name__) class ImagesRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -43,8 +42,9 @@ class ImagesRepository(BaseRepository): image_dir, image_name = os.path.split(image_path) if image_dir: - query = select(models.Image).\ - where(models.Image.filename == image_name, models.Image.path.endswith(image_path)) + query = select(models.Image).where( + models.Image.filename == image_name, models.Image.path.endswith(image_path) + ) else: query = select(models.Image).where(models.Image.filename == image_name) result = await self._db_session.execute(query) @@ -56,8 +56,9 @@ class ImagesRepository(BaseRepository): """ if image_dir: - query = select(models.Image).\ - where(models.Image.checksum == checksum, models.Image.path.startswith(image_dir)) + query = select(models.Image).where( + models.Image.checksum == checksum, models.Image.path.startswith(image_dir) + ) result = await self._db_session.execute(query) return result.scalars().one_or_none() else: @@ -82,9 +83,7 @@ class ImagesRepository(BaseRepository): Get all templates that an image belongs to. """ - query = select(models.Template).\ - join(models.Template.images).\ - filter(models.Image.image_id == image_id) + query = select(models.Template).join(models.Template.images).filter(models.Image.image_id == image_id) result = await self._db_session.execute(query) return result.scalars().all() @@ -101,7 +100,7 @@ class ImagesRepository(BaseRepository): image_size=image_size, path=path, checksum=checksum, - checksum_algorithm=checksum_algorithm + checksum_algorithm=checksum_algorithm, ) self._db_session.add(db_image) @@ -114,9 +113,11 @@ class ImagesRepository(BaseRepository): Update an image. """ - query = update(models.Image).\ - where(models.Image.path == image_path).\ - values(checksum=checksum, checksum_algorithm=checksum_algorithm) + query = ( + update(models.Image) + .where(models.Image.path == image_path) + .values(checksum=checksum, checksum_algorithm=checksum_algorithm) + ) await self._db_session.execute(query) await self._db_session.commit() @@ -132,9 +133,11 @@ class ImagesRepository(BaseRepository): image_dir, image_name = os.path.split(image_path) if image_dir: - query = delete(models.Image).\ - where(models.Image.filename == image_name, models.Image.path.endswith(image_path)).\ - execution_options(synchronize_session=False) + query = ( + delete(models.Image) + .where(models.Image.filename == image_name, models.Image.path.endswith(image_path)) + .execution_options(synchronize_session=False) + ) else: query = delete(models.Image).where(models.Image.filename == image_name) result = await self._db_session.execute(query) @@ -146,8 +149,7 @@ class ImagesRepository(BaseRepository): Prune images not attached to any template. """ - query = select(models.Image).\ - filter(~models.Image.templates.any()) + query = select(models.Image).filter(~models.Image.templates.any()) result = await self._db_session.execute(query) images = result.scalars().all() images_deleted = 0 diff --git a/gns3server/db/repositories/llm_model_configs.py b/gns3server/db/repositories/llm_model_configs.py index f5ed28b46..f65ca8cac 100644 --- a/gns3server/db/repositories/llm_model_configs.py +++ b/gns3server/db/repositories/llm_model_configs.py @@ -50,44 +50,36 @@ class LLMModelConfigsRepository(BaseRepository): async def get_user_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]: """Get a user's LLM model configuration by ID.""" query = select(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.user_id.isnot(None) - ) + and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id.isnot(None)) ) result = await self._db_session.execute(query) return result.scalars().first() async def get_user_configs(self, user_id: UUID) -> List[models.LLMModelConfig]: """Get all LLM model configurations for a user.""" - query = select(models.LLMModelConfig).where( - models.LLMModelConfig.user_id == user_id - ).order_by(models.LLMModelConfig.created_at) + query = ( + select(models.LLMModelConfig) + .where(models.LLMModelConfig.user_id == user_id) + .order_by(models.LLMModelConfig.created_at) + ) result = await self._db_session.execute(query) return result.scalars().all() async def get_user_default_config(self, user_id: UUID) -> Optional[models.LLMModelConfig]: """Get a user's default LLM model configuration.""" query = select(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.user_id == user_id, - models.LLMModelConfig.is_default - ) + and_(models.LLMModelConfig.user_id == user_id, models.LLMModelConfig.is_default) ) result = await self._db_session.execute(query) return result.scalars().first() async def create_user_config( - self, - user_id: UUID, - name: str, - model_type: str, - config_data: Dict[str, Any], - is_default: bool = False + self, user_id: UUID, name: str, model_type: str, config_data: Dict[str, Any], is_default: bool = False ) -> models.LLMModelConfig: """Create a new LLM model configuration for a user.""" # Encrypt API key if present from gns3server.utils.encryption import encrypt + config_to_store = config_data.copy() if "api_key" in config_to_store and config_to_store["api_key"]: try: @@ -105,7 +97,7 @@ class LLMModelConfigsRepository(BaseRepository): user_id=user_id, is_default=is_default, created_at=now, - updated_at=now + updated_at=now, ) self._db_session.add(db_config) await self._db_session.commit() @@ -113,11 +105,7 @@ class LLMModelConfigsRepository(BaseRepository): return db_config async def update_user_config( - self, - config_id: UUID, - user_id: UUID, - updates: Dict[str, Any], - expected_version: Optional[int] = None + self, config_id: UUID, user_id: UUID, updates: Dict[str, Any], expected_version: Optional[int] = None ) -> Optional[models.LLMModelConfig]: """ Update a user's LLM model configuration. @@ -130,10 +118,7 @@ class LLMModelConfigsRepository(BaseRepository): :raises ValueError: If version mismatch (concurrent modification) """ query = select(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.user_id == user_id - ) + and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id == user_id) ) result = await self._db_session.execute(query) db_config = result.scalars().first() @@ -193,10 +178,7 @@ class LLMModelConfigsRepository(BaseRepository): async def delete_user_config(self, config_id: UUID, user_id: UUID) -> bool: """Delete a user's LLM model configuration.""" query = delete(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.user_id == user_id - ) + and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id == user_id) ) result = await self._db_session.execute(query) await self._db_session.commit() @@ -208,22 +190,16 @@ class LLMModelConfigsRepository(BaseRepository): # First, unset current default await self._db_session.execute( update(models.LLMModelConfig) - .where( - and_( - models.LLMModelConfig.user_id == user_id, - models.LLMModelConfig.is_default - ) - ) + .where(and_(models.LLMModelConfig.user_id == user_id, models.LLMModelConfig.is_default)) .values(is_default=False, updated_at=now) ) # Set new default - query = update(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.user_id == user_id - ) - ).values(is_default=True, updated_at=now) + query = ( + update(models.LLMModelConfig) + .where(and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.user_id == user_id)) + .values(is_default=True, updated_at=now) + ) result = await self._db_session.execute(query) await self._db_session.commit() return result.rowcount > 0 @@ -233,44 +209,36 @@ class LLMModelConfigsRepository(BaseRepository): async def get_group_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]: """Get a group's LLM model configuration by ID.""" query = select(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.group_id.isnot(None) - ) + and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id.isnot(None)) ) result = await self._db_session.execute(query) return result.scalars().first() async def get_group_configs(self, group_id: UUID) -> List[models.LLMModelConfig]: """Get all LLM model configurations for a group.""" - query = select(models.LLMModelConfig).where( - models.LLMModelConfig.group_id == group_id - ).order_by(models.LLMModelConfig.created_at) + query = ( + select(models.LLMModelConfig) + .where(models.LLMModelConfig.group_id == group_id) + .order_by(models.LLMModelConfig.created_at) + ) result = await self._db_session.execute(query) return result.scalars().all() async def get_group_default_config(self, group_id: UUID) -> Optional[models.LLMModelConfig]: """Get a group's default LLM model configuration.""" query = select(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.group_id == group_id, - models.LLMModelConfig.is_default - ) + and_(models.LLMModelConfig.group_id == group_id, models.LLMModelConfig.is_default) ) result = await self._db_session.execute(query) return result.scalars().first() async def create_group_config( - self, - group_id: UUID, - name: str, - model_type: str, - config_data: Dict[str, Any], - is_default: bool = False + self, group_id: UUID, name: str, model_type: str, config_data: Dict[str, Any], is_default: bool = False ) -> models.LLMModelConfig: """Create a new LLM model configuration for a group.""" # Encrypt API key if present from gns3server.utils.encryption import encrypt + config_to_store = config_data.copy() if "api_key" in config_to_store and config_to_store["api_key"]: try: @@ -288,7 +256,7 @@ class LLMModelConfigsRepository(BaseRepository): group_id=group_id, is_default=is_default, created_at=now, - updated_at=now + updated_at=now, ) self._db_session.add(db_config) await self._db_session.commit() @@ -296,11 +264,7 @@ class LLMModelConfigsRepository(BaseRepository): return db_config async def update_group_config( - self, - config_id: UUID, - group_id: UUID, - updates: Dict[str, Any], - expected_version: Optional[int] = None + self, config_id: UUID, group_id: UUID, updates: Dict[str, Any], expected_version: Optional[int] = None ) -> Optional[models.LLMModelConfig]: """ Update a group's LLM model configuration. @@ -313,10 +277,7 @@ class LLMModelConfigsRepository(BaseRepository): :raises ValueError: If version mismatch (concurrent modification) """ query = select(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.group_id == group_id - ) + and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id == group_id) ) result = await self._db_session.execute(query) db_config = result.scalars().first() @@ -376,10 +337,7 @@ class LLMModelConfigsRepository(BaseRepository): async def delete_group_config(self, config_id: UUID, group_id: UUID) -> bool: """Delete a group's LLM model configuration.""" query = delete(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.group_id == group_id - ) + and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id == group_id) ) result = await self._db_session.execute(query) await self._db_session.commit() @@ -391,22 +349,16 @@ class LLMModelConfigsRepository(BaseRepository): # First, unset current default await self._db_session.execute( update(models.LLMModelConfig) - .where( - and_( - models.LLMModelConfig.group_id == group_id, - models.LLMModelConfig.is_default - ) - ) + .where(and_(models.LLMModelConfig.group_id == group_id, models.LLMModelConfig.is_default)) .values(is_default=False, updated_at=now) ) # Set new default - query = update(models.LLMModelConfig).where( - and_( - models.LLMModelConfig.config_id == config_id, - models.LLMModelConfig.group_id == group_id - ) - ).values(is_default=True, updated_at=now) + query = ( + update(models.LLMModelConfig) + .where(and_(models.LLMModelConfig.config_id == config_id, models.LLMModelConfig.group_id == group_id)) + .values(is_default=True, updated_at=now) + ) result = await self._db_session.execute(query) await self._db_session.commit() return result.rowcount > 0 @@ -414,10 +366,7 @@ class LLMModelConfigsRepository(BaseRepository): # Inheritance methods async def get_user_effective_configs( - self, - user_id: UUID, - current_user_id: Optional[UUID] = None, - current_user_is_superadmin: bool = False + self, user_id: UUID, current_user_id: Optional[UUID] = None, current_user_is_superadmin: bool = False ) -> Dict[str, Any]: """ Get user's effective configurations (own + inherited from groups). @@ -429,9 +378,7 @@ class LLMModelConfigsRepository(BaseRepository): user_configs = await self.get_user_configs(user_id) # Get user's groups - query = select(models.UserGroup).\ - join(models.UserGroup.users).\ - filter(models.User.user_id == user_id) + query = select(models.UserGroup).join(models.UserGroup.users).filter(models.User.user_id == user_id) result = await self._db_session.execute(query) user_groups = result.scalars().all() @@ -453,28 +400,8 @@ class LLMModelConfigsRepository(BaseRepository): # Always hide API key for security config_dict = self._hide_api_key(config.config) - configs_with_source.append({ - "config_id": config.config_id, - "name": config.name, - "model_type": config.model_type, - "config": config_dict, - "user_id": config.user_id, - "group_id": config.group_id, - "is_default": config.is_default, - "version": config.version, - "created_at": config.created_at, - "updated_at": config.updated_at, - "source": "user", - "group_name": None - }) - - # Add inherited group configs (always shown, regardless of user configs) - for group_id, configs in group_configs_map.items(): - for config in configs: - # Always hide API key for security - config_dict = self._hide_api_key(config.config) - - configs_with_source.append({ + configs_with_source.append( + { "config_id": config.config_id, "name": config.name, "model_type": config.model_type, @@ -485,9 +412,33 @@ class LLMModelConfigsRepository(BaseRepository): "version": config.version, "created_at": config.created_at, "updated_at": config.updated_at, - "source": "group", - "group_name": group_names_map[group_id] - }) + "source": "user", + "group_name": None, + } + ) + + # Add inherited group configs (always shown, regardless of user configs) + for group_id, configs in group_configs_map.items(): + for config in configs: + # Always hide API key for security + config_dict = self._hide_api_key(config.config) + + configs_with_source.append( + { + "config_id": config.config_id, + "name": config.name, + "model_type": config.model_type, + "config": config_dict, + "user_id": config.user_id, + "group_id": config.group_id, + "is_default": config.is_default, + "version": config.version, + "created_at": config.created_at, + "updated_at": config.updated_at, + "source": "group", + "group_name": group_names_map[group_id], + } + ) # Select default_config with proper priority: # 1. User's config marked with is_default: true @@ -508,7 +459,4 @@ class LLMModelConfigsRepository(BaseRepository): if default_config is None and configs_with_source: default_config = configs_with_source[0] - return { - "configs": configs_with_source, - "default_config": default_config - } + return {"configs": configs_with_source, "default_config": default_config} diff --git a/gns3server/db/repositories/pools.py b/gns3server/db/repositories/pools.py index 336c29c8d..90615ee68 100644 --- a/gns3server/db/repositories/pools.py +++ b/gns3server/db/repositories/pools.py @@ -32,7 +32,6 @@ log = logging.getLogger(__name__) class ResourcePoolsRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -61,9 +60,7 @@ class ResourcePoolsRepository(BaseRepository): """ db_resource = models.Resource( - resource_id=resource.resource_id, - resource_type=resource.resource_type, - name=resource.name + resource_id=resource.resource_id, resource_type=resource.resource_type, name=resource.name ) self._db_session.add(db_resource) await self._db_session.commit() @@ -85,9 +82,11 @@ class ResourcePoolsRepository(BaseRepository): Get all resource memberships in resource pools. """ - query = select(models.ResourcePool).\ - join(models.ResourcePool.resources).\ - filter(models.Resource.resource_id == resource_id) + query = ( + select(models.ResourcePool) + .join(models.ResourcePool.resources) + .filter(models.Resource.resource_id == resource_id) + ) result = await self._db_session.execute(query) return result.scalars().all() @@ -131,18 +130,18 @@ class ResourcePoolsRepository(BaseRepository): return db_resource_pool async def update_resource_pool( - self, - resource_pool_id: UUID, - resource_pool_update: schemas.ResourcePoolUpdate + self, resource_pool_id: UUID, resource_pool_update: schemas.ResourcePoolUpdate ) -> Optional[models.ResourcePool]: """ Update a resource pool. """ update_values = resource_pool_update.model_dump(exclude_unset=True) - query = update(models.ResourcePool).\ - where(models.ResourcePool.resource_pool_id == resource_pool_id).\ - values(update_values) + query = ( + update(models.ResourcePool) + .where(models.ResourcePool.resource_pool_id == resource_pool_id) + .values(update_values) + ) await self._db_session.execute(query) await self._db_session.commit() @@ -170,17 +169,17 @@ class ResourcePoolsRepository(BaseRepository): return result.rowcount > 0 async def add_resource_to_pool( - self, - resource_pool_id: UUID, - resource: models.Resource + self, resource_pool_id: UUID, resource: models.Resource ) -> Union[None, models.ResourcePool]: """ Add a resource to a resource pool. """ - query = select(models.ResourcePool).\ - options(selectinload(models.ResourcePool.resources)).\ - where(models.ResourcePool.resource_pool_id == resource_pool_id) + query = ( + select(models.ResourcePool) + .options(selectinload(models.ResourcePool.resources)) + .where(models.ResourcePool.resource_pool_id == resource_pool_id) + ) result = await self._db_session.execute(query) resource_pool_db = result.scalars().first() if not resource_pool_db: @@ -192,17 +191,17 @@ class ResourcePoolsRepository(BaseRepository): return resource_pool_db async def remove_resource_from_pool( - self, - resource_pool_id: UUID, - resource: models.Resource + self, resource_pool_id: UUID, resource: models.Resource ) -> Union[None, models.ResourcePool]: """ Remove a resource from a resource pool. """ - query = select(models.ResourcePool).\ - options(selectinload(models.ResourcePool.resources)).\ - where(models.ResourcePool.resource_pool_id == resource_pool_id) + query = ( + select(models.ResourcePool) + .options(selectinload(models.ResourcePool.resources)) + .where(models.ResourcePool.resource_pool_id == resource_pool_id) + ) result = await self._db_session.execute(query) resource_pool_db = result.scalars().first() if not resource_pool_db: @@ -219,9 +218,11 @@ class ResourcePoolsRepository(BaseRepository): Get all resources from a resource pool. """ - query = select(models.Resource).\ - join(models.Resource.resource_pools).\ - filter(models.ResourcePool.resource_pool_id == resource_pool_id) + query = ( + select(models.Resource) + .join(models.Resource.resource_pools) + .filter(models.ResourcePool.resource_pool_id == resource_pool_id) + ) result = await self._db_session.execute(query) return result.scalars().all() diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py index bd8f1ad9a..13bfd067c 100644 --- a/gns3server/db/repositories/rbac.py +++ b/gns3server/db/repositories/rbac.py @@ -33,7 +33,6 @@ log = logging.getLogger(__name__) class RbacRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -43,9 +42,7 @@ class RbacRepository(BaseRepository): Get a role by its ID. """ - query = select(models.Role).\ - options(selectinload(models.Role.privileges)).\ - where(models.Role.role_id == role_id) + query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.role_id == role_id) result = await self._db_session.execute(query) return result.scalars().first() @@ -54,9 +51,7 @@ class RbacRepository(BaseRepository): Get a role by its name. """ - query = select(models.Role).\ - options(selectinload(models.Role.privileges)).\ - where(models.Role.name == name) + query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.name == name) result = await self._db_session.execute(query) return result.scalars().first() @@ -82,19 +77,13 @@ class RbacRepository(BaseRepository): await self._db_session.commit() return await self.get_role(db_role.role_id) - async def update_role( - self, - role_id: UUID, - role_update: schemas.RoleUpdate - ) -> Optional[models.Role]: + async def update_role(self, role_id: UUID, role_update: schemas.RoleUpdate) -> Optional[models.Role]: """ Update a role. """ update_values = role_update.model_dump(exclude_unset=True) - query = update(models.Role).\ - where(models.Role.role_id == role_id).\ - values(update_values) + query = update(models.Role).where(models.Role.role_id == role_id).values(update_values) await self._db_session.execute(query) await self._db_session.commit() @@ -113,18 +102,12 @@ class RbacRepository(BaseRepository): await self._db_session.commit() return result.rowcount > 0 - async def add_privilege_to_role( - self, - role_id: UUID, - privilege: models.Privilege - ) -> Union[None, models.Role]: + async def add_privilege_to_role(self, role_id: UUID, privilege: models.Privilege) -> Union[None, models.Role]: """ Add a privilege to a role. """ - query = select(models.Role).\ - options(selectinload(models.Role.privileges)).\ - where(models.Role.role_id == role_id) + query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.role_id == role_id) result = await self._db_session.execute(query) role_db = result.scalars().first() if not role_db: @@ -142,18 +125,12 @@ class RbacRepository(BaseRepository): await self._db_session.refresh(role_db) return role_db - async def remove_privilege_from_role( - self, - role_id: UUID, - privilege: models.Privilege - ) -> Union[None, models.Role]: + async def remove_privilege_from_role(self, role_id: UUID, privilege: models.Privilege) -> Union[None, models.Role]: """ Remove a privilege from a role. """ - query = select(models.Role).\ - options(selectinload(models.Role.privileges)).\ - where(models.Role.role_id == role_id) + query = select(models.Role).options(selectinload(models.Role.privileges)).where(models.Role.role_id == role_id) result = await self._db_session.execute(query) role_db = result.scalars().first() if not role_db: @@ -169,9 +146,7 @@ class RbacRepository(BaseRepository): Get all the role privileges. """ - query = select(models.Privilege).\ - join(models.Privilege.roles).\ - filter(models.Role.role_id == role_id) + query = select(models.Privilege).join(models.Privilege.roles).filter(models.Role.role_id == role_id) result = await self._db_session.execute(query) return result.scalars().all() @@ -237,15 +212,11 @@ class RbacRepository(BaseRepository): This method includes related user, group, and role information. """ - query = select(models.ACE).\ - where( - (models.ACE.path == path) | (models.ACE.path.startswith(path + "/")) - ).\ - options( - selectinload(models.ACE.user), - selectinload(models.ACE.group), - selectinload(models.ACE.role) - ) + query = ( + select(models.ACE) + .where((models.ACE.path == path) | (models.ACE.path.startswith(path + "/"))) + .options(selectinload(models.ACE.user), selectinload(models.ACE.group), selectinload(models.ACE.role)) + ) result = await self._db_session.execute(query) return result.scalars().all() @@ -254,8 +225,7 @@ class RbacRepository(BaseRepository): Check if an ACE exists. """ - query = select(models.ACE).\ - where(models.ACE.path == path) + query = select(models.ACE).where(models.ACE.path == path) result = await self._db_session.execute(query) return result.scalars().first() is not None @@ -271,19 +241,13 @@ class RbacRepository(BaseRepository): await self._db_session.refresh(db_ace) return db_ace - async def update_ace( - self, - ace_id: UUID, - ace_update: schemas.ACEUpdate - ) -> Optional[models.ACE]: + async def update_ace(self, ace_id: UUID, ace_update: schemas.ACEUpdate) -> Optional[models.ACE]: """ Update an ACE """ update_values = ace_update.model_dump(exclude_unset=True) - query = update(models.ACE).\ - where(models.ACE.ace_id == ace_id).\ - values(update_values) + query = update(models.ACE).where(models.ACE.ace_id == ace_id).values(update_values) await self._db_session.execute(query) await self._db_session.commit() @@ -307,9 +271,7 @@ class RbacRepository(BaseRepository): Delete all ACEs starting with path. """ - query = delete(models.ACE).\ - where(models.ACE.path.startswith(path)).\ - execution_options(synchronize_session=False) + query = delete(models.ACE).where(models.ACE.path.startswith(path)).execution_options(synchronize_session=False) result = await self._db_session.execute(query) log.debug(f"{result.rowcount} ACE(s) have been deleted") @@ -344,9 +306,11 @@ class RbacRepository(BaseRepository): for ace_path, ace_propagate, ace_allowed, ace_privilege in aces: if ace_path.startswith("/pool"): resource_pool_id = ace_path.split("/")[2] - query = select(models.Resource). \ - join(models.Resource.resource_pools). \ - filter(models.ResourcePool.resource_pool_id == resource_pool_id) + query = ( + select(models.Resource) + .join(models.Resource.resource_pools) + .filter(models.ResourcePool.resource_pool_id == resource_pool_id) + ) result = await self._db_session.execute(query) resources = result.scalars().all() @@ -366,13 +330,15 @@ class RbacRepository(BaseRepository): Retrieve all user ACEs matching the user_id and privilege name. """ - query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name).\ - join(models.Privilege.roles).\ - join(models.Role.acl_entries).\ - join(models.ACE.user). \ - filter(models.User.user_id == user_id).\ - filter(models.Privilege.name == privilege_name).\ - order_by(models.ACE.path.desc()) + query = ( + select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name) + .join(models.Privilege.roles) + .join(models.Role.acl_entries) + .join(models.ACE.user) + .filter(models.User.user_id == user_id) + .filter(models.Privilege.name == privilege_name) + .order_by(models.ACE.path.desc()) + ) result = await self._db_session.execute(query) return result.all() @@ -382,13 +348,15 @@ class RbacRepository(BaseRepository): Retrieve all group ACEs matching the user_id and privilege name. """ - query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name). \ - join(models.Privilege.roles). \ - join(models.Role.acl_entries). \ - join(models.ACE.group). \ - join(models.UserGroup.users).\ - filter(models.User.user_id == user_id). \ - filter(models.Privilege.name == privilege_name) + query = ( + select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name) + .join(models.Privilege.roles) + .join(models.Role.acl_entries) + .join(models.ACE.group) + .join(models.UserGroup.users) + .filter(models.User.user_id == user_id) + .filter(models.Privilege.name == privilege_name) + ) result = await self._db_session.execute(query) return result.all() @@ -404,12 +372,7 @@ class RbacRepository(BaseRepository): pool_resources.extend(await self._get_resources_in_pools(group_aces)) return list(set(pool_resources)) - async def get_accessible_project_ids( - self, - user_id: UUID, - privilege_name: str, - all_project_ids: List[str] - ): + async def get_accessible_project_ids(self, user_id: UUID, privilege_name: str, all_project_ids: List[str]): """ Batch check which projects a user can access via direct ACE or resource pools. Performs 3 fixed DB queries regardless of project count. @@ -448,8 +411,11 @@ class RbacRepository(BaseRepository): user_denied.add(pid) # --- User ACE: pool check --- - user_pool_ids = {ace_path.split("/")[2] for ace_path, _, ace_allowed, _ in user_aces - if ace_path.startswith("/pools/") and ace_allowed} + user_pool_ids = { + ace_path.split("/")[2] + for ace_path, _, ace_allowed, _ in user_aces + if ace_path.startswith("/pools/") and ace_allowed + } pool_accessible_ids = set() for pool_id, project_ids in pool_to_projects.items(): @@ -470,8 +436,11 @@ class RbacRepository(BaseRepository): pass # --- Group ACE: pool check --- - group_pool_ids = {ace_path.split("/")[2] for ace_path, _, ace_allowed, _ in group_aces - if ace_path.startswith("/pools/") and ace_allowed} + group_pool_ids = { + ace_path.split("/")[2] + for ace_path, _, ace_allowed, _ in group_aces + if ace_path.startswith("/pools/") and ace_allowed + } for pool_id, project_ids in pool_to_projects.items(): if pool_id in group_pool_ids: diff --git a/gns3server/db/repositories/templates.py b/gns3server/db/repositories/templates.py index a08b58053..582404301 100644 --- a/gns3server/db/repositories/templates.py +++ b/gns3server/db/repositories/templates.py @@ -47,7 +47,6 @@ TEMPLATE_TYPE_TO_MODEL = { class TemplatesRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -57,17 +56,21 @@ class TemplatesRepository(BaseRepository): async def get_template(self, template_id: UUID) -> Union[None, models.Template]: - query = select(models.Template).\ - options(selectinload(models.Template.images)).\ - where(models.Template.template_id == template_id) + query = ( + select(models.Template) + .options(selectinload(models.Template.images)) + .where(models.Template.template_id == template_id) + ) result = await self._db_session.execute(query) return result.scalars().first() async def get_template_by_name_and_version(self, name: str, version: str) -> Union[None, models.Template]: - query = select(models.Template).\ - options(selectinload(models.Template.images)).\ - where(models.Template.name == name, models.Template.version == version) + query = ( + select(models.Template) + .options(selectinload(models.Template.images)) + .where(models.Template.name == name, models.Template.version == version) + ) result = await self._db_session.execute(query) return result.scalars().first() @@ -76,9 +79,9 @@ class TemplatesRepository(BaseRepository): Return the first template with this name, regardless of version. """ - query = select(models.Template).\ - options(selectinload(models.Template.images)).\ - where(models.Template.name == name) + query = ( + select(models.Template).options(selectinload(models.Template.images)).where(models.Template.name == name) + ) result = await self._db_session.execute(query) return result.scalars().first() @@ -115,9 +118,11 @@ class TemplatesRepository(BaseRepository): async def duplicate_template(self, template_id: UUID) -> Optional[schemas.Template]: - query = select(models.Template).\ - options(selectinload(models.Template.images)).\ - where(models.Template.template_id == template_id) + query = ( + select(models.Template) + .options(selectinload(models.Template.images)) + .where(models.Template.template_id == template_id) + ) db_template = (await self._db_session.execute(query)).scalars().first() if db_template: # duplicate db object with new primary key (template_id) @@ -136,8 +141,9 @@ class TemplatesRepository(BaseRepository): image_dir, image_name = os.path.split(image_path) if image_dir: - query = select(models.Image).\ - where(models.Image.filename == image_name, models.Image.path.endswith(image_path)) + query = select(models.Image).where( + models.Image.filename == image_name, models.Image.path.endswith(image_path) + ) else: query = select(models.Image).where(models.Image.filename == image_name) query = query.order_by(models.Image.image_id) @@ -151,18 +157,16 @@ class TemplatesRepository(BaseRepository): ) return images[0] if images else None - async def add_image_to_template( - self, - template_id: UUID, - image: models.Image - ) -> Union[None, models.Template]: + async def add_image_to_template(self, template_id: UUID, image: models.Image) -> Union[None, models.Template]: """ Add an image to template. """ - query = select(models.Template).\ - options(selectinload(models.Template.images)).\ - where(models.Template.template_id == template_id) + query = ( + select(models.Template) + .options(selectinload(models.Template.images)) + .where(models.Template.template_id == template_id) + ) result = await self._db_session.execute(query) template_in_db = result.scalars().first() if not template_in_db: @@ -173,18 +177,16 @@ class TemplatesRepository(BaseRepository): await self._db_session.refresh(template_in_db) return template_in_db - async def remove_image_from_template( - self, - template_id: UUID, - image: models.Image - ) -> Union[None, models.Template]: + async def remove_image_from_template(self, template_id: UUID, image: models.Image) -> Union[None, models.Template]: """ Remove an image from a template. """ - query = select(models.Template).\ - options(selectinload(models.Template.images)).\ - where(models.Template.template_id == template_id) + query = ( + select(models.Template) + .options(selectinload(models.Template.images)) + .where(models.Template.template_id == template_id) + ) result = await self._db_session.execute(query) template_in_db = result.scalars().first() if not template_in_db: @@ -201,8 +203,6 @@ class TemplatesRepository(BaseRepository): Return all images attached to a template. """ - query = select(models.Image).\ - join(models.Image.templates).\ - filter(models.Template.template_id == template_id) + query = select(models.Image).join(models.Image.templates).filter(models.Template.template_id == template_id) result = await self._db_session.execute(query) return result.scalars().all() diff --git a/gns3server/db/repositories/users.py b/gns3server/db/repositories/users.py index ee005b2bf..f7e098dc4 100644 --- a/gns3server/db/repositories/users.py +++ b/gns3server/db/repositories/users.py @@ -33,7 +33,6 @@ log = logging.getLogger(__name__) class UsersRepository(BaseRepository): - def __init__(self, db_session: AsyncSession) -> None: super().__init__(db_session) @@ -82,10 +81,7 @@ class UsersRepository(BaseRepository): hashed_password = self._auth_service.hash_password(user.password.get_secret_value()) db_user = models.User( - username=user.username, - email=user.email, - full_name=user.full_name, - hashed_password=hashed_password + username=user.username, email=user.email, full_name=user.full_name, hashed_password=hashed_password ) self._db_session.add(db_user) await self._db_session.commit() @@ -102,9 +98,7 @@ class UsersRepository(BaseRepository): if password: update_values["hashed_password"] = self._auth_service.hash_password(password=password.get_secret_value()) - query = update(models.User).\ - where(models.User.user_id == user_id).\ - values(update_values) + query = update(models.User).where(models.User.user_id == user_id).values(update_values) await self._db_session.execute(query) await self._db_session.commit() @@ -118,9 +112,11 @@ class UsersRepository(BaseRepository): Increment token_version to invalidate all existing tokens for the user. """ - query = update(models.User).\ - where(models.User.user_id == user_id).\ - values(token_version=models.User.token_version + 1) + query = ( + update(models.User) + .where(models.User.user_id == user_id) + .values(token_version=models.User.token_version + 1) + ) await self._db_session.execute(query) await self._db_session.commit() @@ -147,8 +143,9 @@ class UsersRepository(BaseRepository): # this is useful for manual password recovery like: # sqlite3 gns3_controller.db "UPDATE users SET hashed_password = null WHERE username = 'admin';" if user.hashed_password is None: - log.warning(f"User '{username}' has been authenticated without a password " - f"configured. Please set a new password.") + log.warning( + f"User '{username}' has been authenticated without a password configured. Please set a new password." + ) return user if not self._auth_service.verify_password(password, user.hashed_password): return None @@ -168,9 +165,7 @@ class UsersRepository(BaseRepository): Get all user memberships (user groups). """ - query = select(models.UserGroup).\ - join(models.UserGroup.users).\ - filter(models.User.user_id == user_id) + query = select(models.UserGroup).join(models.UserGroup.users).filter(models.User.user_id == user_id) result = await self._db_session.execute(query) return result.scalars().all() @@ -214,18 +209,14 @@ class UsersRepository(BaseRepository): return db_user_group async def update_user_group( - self, - user_group_id: UUID, - user_group_update: schemas.UserGroupUpdate + self, user_group_id: UUID, user_group_update: schemas.UserGroupUpdate ) -> Optional[models.UserGroup]: """ Update a user group. """ update_values = user_group_update.model_dump(exclude_unset=True) - query = update(models.UserGroup).\ - where(models.UserGroup.user_group_id == user_group_id).\ - values(update_values) + query = update(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id).values(update_values) await self._db_session.execute(query) await self._db_session.commit() @@ -244,18 +235,16 @@ class UsersRepository(BaseRepository): await self._db_session.commit() return result.rowcount > 0 - async def add_member_to_user_group( - self, - user_group_id: UUID, - user: models.User - ) -> Union[None, models.UserGroup]: + async def add_member_to_user_group(self, user_group_id: UUID, user: models.User) -> Union[None, models.UserGroup]: """ Add a member to a user group. """ - query = select(models.UserGroup).\ - options(selectinload(models.UserGroup.users)).\ - where(models.UserGroup.user_group_id == user_group_id) + query = ( + select(models.UserGroup) + .options(selectinload(models.UserGroup.users)) + .where(models.UserGroup.user_group_id == user_group_id) + ) result = await self._db_session.execute(query) user_group_db = result.scalars().first() if not user_group_db: @@ -267,17 +256,17 @@ class UsersRepository(BaseRepository): return user_group_db async def remove_member_from_user_group( - self, - user_group_id: UUID, - user: models.User + self, user_group_id: UUID, user: models.User ) -> Union[None, models.UserGroup]: """ Remove a member from a user group. """ - query = select(models.UserGroup).\ - options(selectinload(models.UserGroup.users)).\ - where(models.UserGroup.user_group_id == user_group_id) + query = ( + select(models.UserGroup) + .options(selectinload(models.UserGroup.users)) + .where(models.UserGroup.user_group_id == user_group_id) + ) result = await self._db_session.execute(query) user_group_db = result.scalars().first() if not user_group_db: @@ -293,9 +282,7 @@ class UsersRepository(BaseRepository): Get all members from a user group. """ - query = select(models.User).\ - join(models.User.groups).\ - filter(models.UserGroup.user_group_id == user_group_id) + query = select(models.User).join(models.User.groups).filter(models.UserGroup.user_group_id == user_group_id) result = await self._db_session.execute(query) return result.scalars().all() diff --git a/gns3server/db/tasks.py b/gns3server/db/tasks.py index 285d95b54..8b3395156 100644 --- a/gns3server/db/tasks.py +++ b/gns3server/db/tasks.py @@ -79,7 +79,9 @@ async def connect_to_db(app: FastAPI) -> None: db_path = os.path.join(Config.instance().config_dir, "gns3_controller.db") db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite+aiosqlite:///{db_path}") - engine = create_async_engine(db_url, connect_args={"check_same_thread": False, "timeout": 20}, future=True, pool_size=512, max_overflow=1024) + engine = create_async_engine( + db_url, connect_args={"check_same_thread": False, "timeout": 20}, future=True, pool_size=512, max_overflow=1024 + ) # Register PRAGMA on the sync engine to ensure it fires for async connections @event.listens_for(engine.sync_engine, "connect") @@ -91,19 +93,21 @@ async def connect_to_db(app: FastAPI) -> None: # Verify WAL mode is active async with engine.connect() as _verify_conn: + def _check_wal(conn): cursor = conn.connection.cursor() cursor.execute("PRAGMA journal_mode") row = cursor.fetchone() cursor.close() return row[0] if row else "unknown" + wal_mode = await _verify_conn.run_sync(_check_wal) log.info(f"SQLite journal mode: {wal_mode}") if wal_mode and wal_mode.upper() != "WAL": log.warning("WAL mode not active - concurrent writes may cause 'database is locked' errors") alembic_cfg = config.Config() alembic_cfg.set_main_option("script_location", "gns3server:db_migrations") - #alembic_cfg.set_main_option('sqlalchemy.url', db_url) + # alembic_cfg.set_main_option('sqlalchemy.url', db_url) try: async with engine.connect() as conn: current_rev, head_rev = await conn.run_sync(check_revision, alembic_cfg) @@ -116,27 +120,27 @@ async def connect_to_db(app: FastAPI) -> None: inspector = sa.inspect(connection) tables = inspector.get_table_names() - if 'users' not in tables: - return 'new' # Truly new database + if "users" not in tables: + return "new" # Truly new database # Check for new feature columns that indicate this is already migrated - columns = [col['name'] for col in inspector.get_columns('users')] - if 'llm_model_configs' in tables: + columns = [col["name"] for col in inspector.get_columns("users")] + if "llm_model_configs" in tables: # The llm_model_configs table already exists (created from code) - return 'new_with_llm_configs' + return "new_with_llm_configs" else: # Old database without llm_model_configs table, needs migration - return 'old_needs_migration' + return "old_needs_migration" db_state = await conn.run_sync(check_db_state) - if db_state == 'new': + if db_state == "new": # Truly new database: create all tables and stamp await conn.run_sync(Base.metadata.create_all) await conn.run_sync(run_stamp, alembic_cfg) await conn.commit() log.info("Created new database and stamped to head revision") - elif db_state == 'new_with_llm_configs': + elif db_state == "new_with_llm_configs": # Database already has llm_model_configs table (from Base.metadata.create_all) # Just stamp the version await conn.run_sync(run_stamp, alembic_cfg) @@ -216,6 +220,7 @@ async def update_disk_checksums(updated_disks: List[str]) -> None: """ from gns3server.api.server import app + async with AsyncSession(app.state._db_engine) as db_session: images_repository = ImagesRepository(db_session) for path in updated_disks: @@ -226,6 +231,7 @@ async def update_disk_checksums(updated_disks: List[str]) -> None: if image.checksum != checksum: await images_repository.update_image(path, checksum, "md5") + class EventHandler(PatternMatchingEventHandler): """ Watchdog event handler. @@ -237,7 +243,7 @@ class EventHandler(PatternMatchingEventHandler): self._queue = queue # ignore temporary files, md5sum files, hidden files and directories - super().__init__(ignore_patterns=["*.tmp", "*.md5sum", ".*"], ignore_directories = True, **kwargs) + super().__init__(ignore_patterns=["*.tmp", "*.md5sum", ".*"], ignore_directories=True, **kwargs) def on_closed(self, event: FileSystemEvent) -> None: # monitor for closed files (e.g. when a file has finished to be copied) @@ -245,6 +251,7 @@ class EventHandler(PatternMatchingEventHandler): return # ignore custom IOU libraries self._loop.call_soon_threadsafe(self._queue.put_nowait, event) + class EventIterator(object): """ Watchdog Event iterator. @@ -263,13 +270,11 @@ class EventIterator(object): raise StopAsyncIteration return item + async def monitor_images_on_filesystem(app: FastAPI): def watchdog( - path: str, - queue: asyncio.Queue, - loop: asyncio.BaseEventLoop, - app: FastAPI, recursive: bool = False + path: str, queue: asyncio.Queue, loop: asyncio.BaseEventLoop, app: FastAPI, recursive: bool = False ) -> None: """ Thread to monitor a directory for new images. @@ -294,7 +299,7 @@ async def monitor_images_on_filesystem(app: FastAPI): loop = asyncio.get_event_loop() server_config = Config.instance().settings.Server image_dir = os.path.expanduser(server_config.images_path) - asyncio.get_event_loop().run_in_executor(None, watchdog,image_dir, queue, loop, app, True) + asyncio.get_event_loop().run_in_executor(None, watchdog, image_dir, queue, loop, app, True) async for filesystem_event in EventIterator(queue): # read the file system event from the queue @@ -352,7 +357,7 @@ async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict] result = await repo.get_user_effective_configs( user_uuid, current_user_id=user_uuid, # Viewing own config - current_user_is_superadmin=False + current_user_is_superadmin=False, ) if not result or not result.get("default_config"): @@ -393,7 +398,7 @@ async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict] "group_name": default_config.get("group_name"), "user_id": str(full_config.user_id) if full_config.user_id else None, "group_id": str(full_config.group_id) if full_config.group_id else None, - **config_data # provider, api_key, model, temperature, etc. + **config_data, # provider, api_key, model, temperature, etc. } # Validate required fields @@ -415,4 +420,3 @@ async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict] except Exception as e: log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True) return None - diff --git a/gns3server/db_migrations/env.py b/gns3server/db_migrations/env.py index 043c832bb..f60fef2f6 100644 --- a/gns3server/db_migrations/env.py +++ b/gns3server/db_migrations/env.py @@ -21,6 +21,7 @@ if config.config_file_name is not None: # from myapp import mymodel # target_metadata = mymodel.Base.metadata from gns3server.db.models.base import Base + target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, @@ -63,9 +64,10 @@ def do_run_migrations(connection): async def run_async_migrations(): from gns3server.config import Config as GNS3Config + db_path = os.path.join(GNS3Config.instance().config_dir, "gns3_controller.db") db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite+aiosqlite:///{db_path}") - config.set_main_option('sqlalchemy.url', db_url) + config.set_main_option("sqlalchemy.url", db_url) connectable = async_engine_from_config( config.get_section(config.config_ini_section), diff --git a/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py b/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py index fa7b99c6d..27bdd04ea 100644 --- a/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py +++ b/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py @@ -5,13 +5,14 @@ Revises: 98083573d011 Create Date: 2026-03-03 """ + from alembic import op import sqlalchemy as sa from sqlalchemy import inspect # revision identifiers, used by Alembic. -revision = '20260303_create_llm_model_configs' -down_revision = '98083573d011' +revision = "20260303_create_llm_model_configs" +down_revision = "98083573d011" branch_labels = None depends_on = None @@ -25,43 +26,44 @@ def upgrade() -> None: # Check if table already exists (idempotent for databases created from code) tables = inspector.get_table_names() - if 'llm_model_configs' in tables: + if "llm_model_configs" in tables: # Table already exists from Base.metadata.create_all, skip creation return # Create llm_model_configs table with cross-database compatible types op.create_table( - 'llm_model_configs', - sa.Column('config_id', sa.String(32), primary_key=True), - sa.Column('name', sa.String(100), nullable=False), - sa.Column('model_type', sa.String(50), nullable=False), - sa.Column('config', sa.JSON(), nullable=False), - sa.Column('user_id', sa.String(32), sa.ForeignKey('users.user_id', ondelete='CASCADE'), nullable=True), - sa.Column('group_id', sa.String(32), sa.ForeignKey('user_groups.user_group_id', ondelete='CASCADE'), nullable=True), - sa.Column('is_default', sa.Boolean(), default=False, nullable=False), - sa.Column('version', sa.Integer(), nullable=False, server_default='0'), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), + "llm_model_configs", + sa.Column("config_id", sa.String(32), primary_key=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("model_type", sa.String(50), nullable=False), + sa.Column("config", sa.JSON(), nullable=False), + sa.Column("user_id", sa.String(32), sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True), + sa.Column( + "group_id", sa.String(32), sa.ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True + ), + sa.Column("is_default", sa.Boolean(), default=False, nullable=False), + sa.Column("version", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), sa.CheckConstraint( - "(user_id IS NOT NULL AND group_id IS NULL) OR " - "(user_id IS NULL AND group_id IS NOT NULL)", - name='single_owner_check' + "(user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)", + name="single_owner_check", ), sa.CheckConstraint( "model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')", - name='valid_model_type_check' + name="valid_model_type_check", ), ) # Create indexes for efficient queries - op.create_index('idx_llm_model_configs_user_id', 'llm_model_configs', ['user_id']) - op.create_index('idx_llm_model_configs_group_id', 'llm_model_configs', ['group_id']) - op.create_index('idx_llm_model_configs_model_type', 'llm_model_configs', ['model_type']) + op.create_index("idx_llm_model_configs_user_id", "llm_model_configs", ["user_id"]) + op.create_index("idx_llm_model_configs_group_id", "llm_model_configs", ["group_id"]) + op.create_index("idx_llm_model_configs_model_type", "llm_model_configs", ["model_type"]) # PostgreSQL-specific indexes - if dialect_name == 'postgresql': + if dialect_name == "postgresql": # GIN index for JSONB config column - op.create_index('idx_llm_model_configs_config', 'llm_model_configs', ['config'], postgresql_using='gin') + op.create_index("idx_llm_model_configs_config", "llm_model_configs", ["config"], postgresql_using="gin") # Partial unique indexes for default configs op.execute(""" @@ -82,14 +84,14 @@ def downgrade() -> None: dialect_name = conn.dialect.name # Drop indexes - op.drop_index('idx_llm_model_configs_model_type', table_name='llm_model_configs') - op.drop_index('idx_llm_model_configs_group_id', table_name='llm_model_configs') - op.drop_index('idx_llm_model_configs_user_id', table_name='llm_model_configs') + op.drop_index("idx_llm_model_configs_model_type", table_name="llm_model_configs") + op.drop_index("idx_llm_model_configs_group_id", table_name="llm_model_configs") + op.drop_index("idx_llm_model_configs_user_id", table_name="llm_model_configs") - if dialect_name == 'postgresql': - op.drop_index('idx_llm_model_configs_config', table_name='llm_model_configs') - op.drop_index('unique_user_default', table_name='llm_model_configs') - op.drop_index('unique_group_default', table_name='llm_model_configs') + if dialect_name == "postgresql": + op.drop_index("idx_llm_model_configs_config", table_name="llm_model_configs") + op.drop_index("unique_user_default", table_name="llm_model_configs") + op.drop_index("unique_group_default", table_name="llm_model_configs") # Drop table - op.drop_table('llm_model_configs') + op.drop_table("llm_model_configs") diff --git a/gns3server/db_migrations/versions/7ceeddd9c9a8_init.py b/gns3server/db_migrations/versions/7ceeddd9c9a8_init.py index ca9a5ff2c..4c218cf5e 100644 --- a/gns3server/db_migrations/versions/7ceeddd9c9a8_init.py +++ b/gns3server/db_migrations/versions/7ceeddd9c9a8_init.py @@ -1,16 +1,17 @@ """init Revision ID: 7ceeddd9c9a8 -Revises: +Revises: Create Date: 2023-01-11 08:55:25.116126 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '7ceeddd9c9a8' +revision = "7ceeddd9c9a8" down_revision = None branch_labels = None depends_on = None diff --git a/gns3server/db_migrations/versions/8f2a1c4e9d3b_add_extra_configs_to_docker_templates.py b/gns3server/db_migrations/versions/8f2a1c4e9d3b_add_extra_configs_to_docker_templates.py index 2be50f287..64d1489b4 100644 --- a/gns3server/db_migrations/versions/8f2a1c4e9d3b_add_extra_configs_to_docker_templates.py +++ b/gns3server/db_migrations/versions/8f2a1c4e9d3b_add_extra_configs_to_docker_templates.py @@ -5,22 +5,23 @@ Revises: f0b0de2a9 Create Date: 2026-08-14 10:00:00.000000 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '8f2a1c4e9d3b' -down_revision = 'f0b0de2a9' +revision = "8f2a1c4e9d3b" +down_revision = "f0b0de2a9" branch_labels = None depends_on = None def upgrade() -> None: - op.add_column('docker_templates', sa.Column('extra_configs', sa.JSON())) + op.add_column("docker_templates", sa.Column("extra_configs", sa.JSON())) def downgrade() -> None: - op.drop_column('docker_templates', 'extra_configs') + op.drop_column("docker_templates", "extra_configs") diff --git a/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py b/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py index eeafe3afd..567aa3107 100644 --- a/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py +++ b/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py @@ -5,22 +5,23 @@ Revises: 9a5292aa4389 Create Date: 2026-02-23 18:23:59.857607 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '98083573d011' -down_revision = '9a5292aa4389' +revision = "98083573d011" +down_revision = "9a5292aa4389" branch_labels = None depends_on = None def upgrade() -> None: - op.add_column('templates', sa.Column('tags', sa.String())) + op.add_column("templates", sa.Column("tags", sa.String())) def downgrade() -> None: - op.drop_column('templates', 'tags') + op.drop_column("templates", "tags") diff --git a/gns3server/db_migrations/versions/9a5292aa4389_add_mac_address_field_in_docker_.py b/gns3server/db_migrations/versions/9a5292aa4389_add_mac_address_field_in_docker_.py index 5fb1597a5..e85dc00f7 100644 --- a/gns3server/db_migrations/versions/9a5292aa4389_add_mac_address_field_in_docker_.py +++ b/gns3server/db_migrations/versions/9a5292aa4389_add_mac_address_field_in_docker_.py @@ -5,23 +5,23 @@ Revises: 7ceeddd9c9a8 Create Date: 2024-09-18 17:52:53.429522 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '9a5292aa4389' -down_revision = '7ceeddd9c9a8' +revision = "9a5292aa4389" +down_revision = "7ceeddd9c9a8" branch_labels = None depends_on = None def upgrade() -> None: - op.add_column('docker_templates', sa.Column('mac_address', sa.String())) + op.add_column("docker_templates", sa.Column("mac_address", sa.String())) def downgrade() -> None: - op.drop_column('docker_templates', 'mac_address') - + op.drop_column("docker_templates", "mac_address") diff --git a/gns3server/db_migrations/versions/a8829e6c069b_add_llm_config_privileges.py b/gns3server/db_migrations/versions/a8829e6c069b_add_llm_config_privileges.py index cb87fd15b..fb146edd7 100644 --- a/gns3server/db_migrations/versions/a8829e6c069b_add_llm_config_privileges.py +++ b/gns3server/db_migrations/versions/a8829e6c069b_add_llm_config_privileges.py @@ -5,33 +5,34 @@ Revises: aff810fc119a Create Date: 2026-05-26 """ + from alembic import op import sqlalchemy as sa from uuid import uuid4 # revision identifiers, used by Alembic. -revision = 'a8829e6c069b' -down_revision = 'aff810fc119a' +revision = "a8829e6c069b" +down_revision = "aff810fc119a" branch_labels = None depends_on = None privileges_table = sa.table( - 'privileges', - sa.column('privilege_id', sa.String), - sa.column('name', sa.String), - sa.column('description', sa.String), + "privileges", + sa.column("privilege_id", sa.String), + sa.column("name", sa.String), + sa.column("description", sa.String), ) roles_table = sa.table( - 'roles', - sa.column('role_id', sa.String), - sa.column('name', sa.String), + "roles", + sa.column("role_id", sa.String), + sa.column("name", sa.String), ) privilege_role_map = sa.table( - 'privilege_role_map', - sa.column('privilege_id', sa.String), - sa.column('role_id', sa.String), + "privilege_role_map", + sa.column("privilege_id", sa.String), + sa.column("role_id", sa.String), ) @@ -48,9 +49,7 @@ def upgrade() -> None: privilege_ids = {} for priv in new_privileges: result = conn.execute( - sa.select(privileges_table.c.privilege_id).where( - privileges_table.c.name == priv["name"] - ) + sa.select(privileges_table.c.privilege_id).where(privileges_table.c.name == priv["name"]) ).fetchone() if result: @@ -67,9 +66,7 @@ def upgrade() -> None: privilege_ids[priv["name"]] = priv_id # Add LLMConfig.Audit and LLMConfig.Modify to the User role - user_role = conn.execute( - sa.select(roles_table.c.role_id).where(roles_table.c.name == "User") - ).fetchone() + user_role = conn.execute(sa.select(roles_table.c.role_id).where(roles_table.c.name == "User")).fetchone() if user_role: user_role_id = user_role[0] @@ -85,17 +82,13 @@ def upgrade() -> None: def downgrade() -> None: conn = op.get_bind() - user_role = conn.execute( - sa.select(roles_table.c.role_id).where(roles_table.c.name == "User") - ).fetchone() + user_role = conn.execute(sa.select(roles_table.c.role_id).where(roles_table.c.name == "User")).fetchone() if user_role: user_role_id = user_role[0] for priv_name in ("LLMConfig.Audit", "LLMConfig.Modify"): priv = conn.execute( - sa.select(privileges_table.c.privilege_id).where( - privileges_table.c.name == priv_name - ) + sa.select(privileges_table.c.privilege_id).where(privileges_table.c.name == priv_name) ).fetchone() if priv: conn.execute( @@ -106,8 +99,4 @@ def downgrade() -> None: ) for priv_name in ("LLMConfig.Allocate", "LLMConfig.Audit", "LLMConfig.Modify"): - conn.execute( - privileges_table.delete().where( - privileges_table.c.name == priv_name - ) - ) + conn.execute(privileges_table.delete().where(privileges_table.c.name == priv_name)) diff --git a/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py b/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py index f9f82cc43..8de44a283 100644 --- a/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py +++ b/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py @@ -5,20 +5,22 @@ Revises: ec4b7b198555 Create Date: 2026-04-06 19:49:12.155446 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'aff810fc119a' -down_revision = 'ec4b7b198555' +revision = "aff810fc119a" +down_revision = "ec4b7b198555" branch_labels = None depends_on = None def upgrade() -> None: - op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0')) + op.add_column("users", sa.Column("token_version", sa.Integer(), nullable=False, server_default="0")) + def downgrade() -> None: - op.drop_column('users', 'token_version') + op.drop_column("users", "token_version") diff --git a/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py b/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py index bc0241c12..661b33d69 100644 --- a/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py +++ b/gns3server/db_migrations/versions/b3c7e2a91d4f_add_netmiko_device_type_to_templates.py @@ -5,22 +5,23 @@ Revises: 8f2a1c4e9d3b Create Date: 2026-08-16 10:00:00.000000 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'b3c7e2a91d4f' -down_revision = '8f2a1c4e9d3b' +revision = "b3c7e2a91d4f" +down_revision = "8f2a1c4e9d3b" branch_labels = None depends_on = None def upgrade() -> None: - op.add_column('templates', sa.Column('netmiko_device_type', sa.String())) + op.add_column("templates", sa.Column("netmiko_device_type", sa.String())) def downgrade() -> None: - op.drop_column('templates', 'netmiko_device_type') + op.drop_column("templates", "netmiko_device_type") diff --git a/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py b/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py index 7c7b18fbb..e0725102f 100644 --- a/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py +++ b/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py @@ -5,22 +5,23 @@ Revises: b3c7e2a91d4f Create Date: 2026-08-16 12:00:00.000000 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'c7e4a9f1d2b6' -down_revision = 'b3c7e2a91d4f' +revision = "c7e4a9f1d2b6" +down_revision = "b3c7e2a91d4f" branch_labels = None depends_on = None def upgrade() -> None: - op.add_column('templates', sa.Column('appliance_metadata', sa.JSON())) + op.add_column("templates", sa.Column("appliance_metadata", sa.JSON())) def downgrade() -> None: - op.drop_column('templates', 'appliance_metadata') + op.drop_column("templates", "appliance_metadata") diff --git a/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py b/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py index 6b393940e..d25782c13 100644 --- a/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py +++ b/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py @@ -5,12 +5,13 @@ Revises: 20260303_create_llm_model_configs Create Date: 2026-04-03 19:51:06.173013 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'ec4b7b198555' -down_revision = '20260303_create_llm_model_configs' +revision = "ec4b7b198555" +down_revision = "20260303_create_llm_model_configs" branch_labels = None depends_on = None @@ -33,13 +34,13 @@ def convert_pickle_to_json(conn, table_name: str, column_name: str) -> None: json_data = json.dumps(data) conn.execute( sa.text(f"UPDATE {table_name} SET {column_name} = :data WHERE template_id = :template_id"), - {"data": json_data, "template_id": row.template_id} + {"data": json_data, "template_id": row.template_id}, ) else: # Set NULL if there is no data to be converted conn.execute( sa.text(f"UPDATE {table_name} SET {column_name} = NULL WHERE template_id = :template_id"), - {"template_id": row.template_id} + {"template_id": row.template_id}, ) @@ -61,13 +62,13 @@ def convert_json_to_pickle(conn, table_name: str, column_name: str) -> None: pickle_data = pickle.dumps(data) conn.execute( sa.text(f"UPDATE {table_name} SET {column_name} = :data WHERE template_id = :template_id"), - {"data": pickle_data, "template_id": row.template_id} + {"data": pickle_data, "template_id": row.template_id}, ) else: # Set NULL if there is no data to be converted conn.execute( sa.text(f"UPDATE {table_name} SET {column_name} = NULL WHERE template_id = :template_id"), - {"template_id": row.template_id} + {"template_id": row.template_id}, ) @@ -86,21 +87,22 @@ def upgrade() -> None: convert_pickle_to_json(conn, "virtualbox_templates", "custom_adapters") convert_pickle_to_json(conn, "vmware_templates", "custom_adapters") - with op.batch_alter_table('cloud_templates') as batch_op: - batch_op.alter_column('ports_mapping', type_=sa.JSON()) - with op.batch_alter_table('docker_templates') as batch_op: - batch_op.alter_column('extra_volumes', type_=sa.JSON()) - batch_op.alter_column('custom_adapters', type_=sa.JSON()) - with op.batch_alter_table('ethernet_hub_templates') as batch_op: - batch_op.alter_column('ports_mapping', type_=sa.JSON()) - with op.batch_alter_table('ethernet_switch_templates') as batch_op: - batch_op.alter_column('ports_mapping', type_=sa.JSON()) - with op.batch_alter_table('qemu_templates') as batch_op: - batch_op.alter_column('custom_adapters', type_=sa.JSON()) - with op.batch_alter_table('virtualbox_templates') as batch_op: - batch_op.alter_column('custom_adapters', type_=sa.JSON()) - with op.batch_alter_table('vmware_templates') as batch_op: - batch_op.alter_column('custom_adapters', type_=sa.JSON()) + with op.batch_alter_table("cloud_templates") as batch_op: + batch_op.alter_column("ports_mapping", type_=sa.JSON()) + with op.batch_alter_table("docker_templates") as batch_op: + batch_op.alter_column("extra_volumes", type_=sa.JSON()) + batch_op.alter_column("custom_adapters", type_=sa.JSON()) + with op.batch_alter_table("ethernet_hub_templates") as batch_op: + batch_op.alter_column("ports_mapping", type_=sa.JSON()) + with op.batch_alter_table("ethernet_switch_templates") as batch_op: + batch_op.alter_column("ports_mapping", type_=sa.JSON()) + with op.batch_alter_table("qemu_templates") as batch_op: + batch_op.alter_column("custom_adapters", type_=sa.JSON()) + with op.batch_alter_table("virtualbox_templates") as batch_op: + batch_op.alter_column("custom_adapters", type_=sa.JSON()) + with op.batch_alter_table("vmware_templates") as batch_op: + batch_op.alter_column("custom_adapters", type_=sa.JSON()) + def downgrade() -> None: @@ -117,18 +119,18 @@ def downgrade() -> None: convert_json_to_pickle(conn, "virtualbox_templates", "custom_adapters") convert_json_to_pickle(conn, "vmware_templates", "custom_adapters") - with op.batch_alter_table('cloud_templates') as batch_op: - batch_op.alter_column('ports_mapping', type_=sa.PickleType()) - with op.batch_alter_table('docker_templates') as batch_op: - batch_op.alter_column('extra_volumes', type_=sa.PickleType()) - batch_op.alter_column('custom_adapters', type_=sa.PickleType()) - with op.batch_alter_table('ethernet_hub_templates') as batch_op: - batch_op.alter_column('ports_mapping', type_=sa.PickleType()) - with op.batch_alter_table('ethernet_switch_templates') as batch_op: - batch_op.alter_column('ports_mapping', type_=sa.PickleType()) - with op.batch_alter_table('qemu_templates') as batch_op: - batch_op.alter_column('custom_adapters', type_=sa.PickleType()) - with op.batch_alter_table('virtualbox_templates') as batch_op: - batch_op.alter_column('custom_adapters', type_=sa.PickleType()) - with op.batch_alter_table('vmware_templates') as batch_op: - batch_op.alter_column('custom_adapters', type_=sa.PickleType()) + with op.batch_alter_table("cloud_templates") as batch_op: + batch_op.alter_column("ports_mapping", type_=sa.PickleType()) + with op.batch_alter_table("docker_templates") as batch_op: + batch_op.alter_column("extra_volumes", type_=sa.PickleType()) + batch_op.alter_column("custom_adapters", type_=sa.PickleType()) + with op.batch_alter_table("ethernet_hub_templates") as batch_op: + batch_op.alter_column("ports_mapping", type_=sa.PickleType()) + with op.batch_alter_table("ethernet_switch_templates") as batch_op: + batch_op.alter_column("ports_mapping", type_=sa.PickleType()) + with op.batch_alter_table("qemu_templates") as batch_op: + batch_op.alter_column("custom_adapters", type_=sa.PickleType()) + with op.batch_alter_table("virtualbox_templates") as batch_op: + batch_op.alter_column("custom_adapters", type_=sa.PickleType()) + with op.batch_alter_table("vmware_templates") as batch_op: + batch_op.alter_column("custom_adapters", type_=sa.PickleType()) diff --git a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py index 9c8b4fdb6..a52dd05d6 100644 --- a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py +++ b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py @@ -5,13 +5,14 @@ Revises: a8829e6c069b Create Date: 2026-06-11 10:00:00.000000 """ + from alembic import op import sqlalchemy as sa import gns3server.db.models.base as models # revision identifiers, used by Alembic. -revision = 'f0b0de2a9' -down_revision = 'a8829e6c069b' +revision = "f0b0de2a9" +down_revision = "a8829e6c069b" branch_labels = None depends_on = None @@ -19,25 +20,25 @@ depends_on = None def upgrade() -> None: op.create_table( - 'api_keys', - sa.Column('api_key_id', models.GUID(), nullable=False), - sa.Column('user_id', models.GUID(), nullable=False), - sa.Column('name', sa.String(128), nullable=False), - sa.Column('key_hash', sa.String(128), nullable=False), - sa.Column('key_prefix', sa.String(8), nullable=False), - sa.Column('last_used_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), - sa.Column('revoked', sa.Boolean(), default=False, nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('api_key_id'), + "api_keys", + sa.Column("api_key_id", models.GUID(), nullable=False), + sa.Column("user_id", models.GUID(), nullable=False), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("key_hash", sa.String(128), nullable=False), + sa.Column("key_prefix", sa.String(8), nullable=False), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("revoked", sa.Boolean(), default=False, nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("api_key_id"), ) - op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id']) - op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash']) + op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) + op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) def downgrade() -> None: - op.drop_index('ix_api_keys_key_hash', table_name='api_keys') - op.drop_index('ix_api_keys_user_id', table_name='api_keys') - op.drop_table('api_keys') + op.drop_index("ix_api_keys_key_hash", table_name="api_keys") + op.drop_index("ix_api_keys_user_id", table_name="api_keys") + op.drop_table("api_keys") diff --git a/gns3server/logger.py b/gns3server/logger.py index 2baba0fd3..e2f184bca 100644 --- a/gns3server/logger.py +++ b/gns3server/logger.py @@ -17,7 +17,6 @@ """Provide a pretty logging on console""" - import logging import sys import os @@ -28,10 +27,10 @@ from logging.handlers import RotatingFileHandler class ColouredFormatter(logging.Formatter): - RESET = "\x1B[0m" - RED = "\x1B[31m" - YELLOW = "\x1B[33m" - GREEN = "\x1B[32m" + RESET = "\x1b[0m" + RED = "\x1b[31m" + YELLOW = "\x1b[33m" + GREEN = "\x1b[32m" PINK = "\x1b[35m" def format(self, record, colour=False): diff --git a/gns3server/main.py b/gns3server/main.py index 4069f5e3a..150987d41 100644 --- a/gns3server/main.py +++ b/gns3server/main.py @@ -62,6 +62,7 @@ def daemonize(): print("Second fork failed: %d (%s)\n" % (e.errno, e.strerror), file=sys.stderr) sys.exit(1) + def parse_arguments(argv): """ Parse command line arguments @@ -69,6 +70,7 @@ def parse_arguments(argv): :param argv: Array of command line arguments """ from gns3server.version import __version__ + parser = argparse.ArgumentParser(description=f"GNS3 server version {__version__}") parser.add_argument("-v", "--version", help="show the version", action="version", version=__version__) parser.add_argument("--host", help="run on the given host/IP address") @@ -78,16 +80,12 @@ def parse_arguments(argv): parser.add_argument("--certfile", help="SSL cert file") parser.add_argument("--certkey", help="SSL key file") parser.add_argument("-L", "--local", action="store_true", help="local mode (allows some insecure operations)") - parser.add_argument( - "-A", "--allow", action="store_true", help="allow remote connections to local console ports" - ) + parser.add_argument("-A", "--allow", action="store_true", help="allow remote connections to local console ports") parser.add_argument("-q", "--quiet", default=False, action="store_true", help="do not show logs on stdout") parser.add_argument("-d", "--debug", default=False, action="store_true", help="show debug logs") parser.add_argument("--logfile", "--log", help="send output to logfile instead of console") parser.add_argument("--logmaxsize", default=10000000, help="maximum logfile size in bytes (default is 10MB)") - parser.add_argument( - "--logbackupcount", default=10, help="number of historical log files to keep (default is 10)" - ) + parser.add_argument("--logbackupcount", default=10, help="number of historical log files to keep (default is 10)") parser.add_argument( "--logcompression", default=False, action="store_true", help="compress inactive (historical) logs" ) @@ -118,9 +116,7 @@ def _raise_open_files_limit(target=65535): new_soft = min(target, hard) resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard)) if new_soft < target: - log.warning( - f"Open-files limit raised to {new_soft} (hard limit), below the requested {target}" - ) + log.warning(f"Open-files limit raised to {new_soft} (hard limit), below the requested {target}") else: log.info(f"Open-files limit raised from {soft} to {new_soft}") except (OSError, ValueError) as e: @@ -141,6 +137,7 @@ def main(): try: parser, args = parse_arguments(sys.argv[1:]) from gns3server.server import Server + asyncio.run(Server().run(parser, args)) except KeyboardInterrupt: pass diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index bc8cca5b9..23056d58c 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -20,16 +20,51 @@ from .common import ErrorMessage from .version import Version # Controller schemas -from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerUpdate, MarkerDefinitionCreate -from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute +from .controller.links import ( + LinkCreate, + LinkUpdate, + Link, + UDPPortInfo, + EthernetPortInfo, + LinkCapture, + MarkerCreate, + MarkerUpdate, + MarkerDefinitionCreate, +) +from .controller.computes import ( + ComputeCreate, + ComputeUpdate, + ComputeVirtualBoxVM, + ComputeVMwareVM, + ComputeDockerImage, + AutoIdlePC, + Compute, +) from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template from .controller.images import Image, ImageType from .controller.appliances import ApplianceVersion, ApplianceVersionV8, Appliance from .controller.drawings import Drawing from .controller.gns3vm import GNS3VM from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node -from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression, NodeFile -from .controller.users import UserCreate, UserUpdate, LoggedInUserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup +from .controller.projects import ( + ProjectCreate, + ProjectUpdate, + ProjectDuplicate, + Project, + ProjectFile, + ProjectCompression, + NodeFile, +) +from .controller.users import ( + UserCreate, + UserUpdate, + LoggedInUserUpdate, + User, + Credentials, + UserGroupCreate, + UserGroupUpdate, + UserGroup, +) # Conditionally import AI-related schemas try: @@ -40,7 +75,7 @@ try: LLMModelConfigResponse, LLMModelConfigWithSource, LLMModelConfigInheritedResponse, - LLMModelConfigListResponse + LLMModelConfigListResponse, ) from .controller.chat import ( OpenAIToolCall, @@ -49,7 +84,7 @@ try: OpenAIMessage, ConversationHistory, ChatSession, - RenameSession + RenameSession, ) except ImportError: # AI schemas are not available (should not happen as they don't depend on external libs) @@ -89,7 +124,7 @@ from .controller.templates.dynamips_templates import ( C3745DynamipsTemplate, C3745DynamipsTemplateUpdate, C7200DynamipsTemplate, - C7200DynamipsTemplateUpdate + C7200DynamipsTemplateUpdate, ) # Compute schemas diff --git a/gns3server/schemas/compute/atm_switch_nodes.py b/gns3server/schemas/compute/atm_switch_nodes.py index 836fbfa37..9a9db0845 100644 --- a/gns3server/schemas/compute/atm_switch_nodes.py +++ b/gns3server/schemas/compute/atm_switch_nodes.py @@ -50,6 +50,5 @@ class ATMSwitchUpdate(ATMSwitchBase): class ATMSwitch(ATMSwitchBase): - project_id: UUID status: Optional[NodeStatus] = None diff --git a/gns3server/schemas/compute/cloud_nodes.py b/gns3server/schemas/compute/cloud_nodes.py index 28e01c72d..4cda4ce23 100644 --- a/gns3server/schemas/compute/cloud_nodes.py +++ b/gns3server/schemas/compute/cloud_nodes.py @@ -23,19 +23,16 @@ from ..common import NodeStatus class HostInterfaceType(str, Enum): - ethernet = "ethernet" tap = "tap" class IPAddressFamily(str, Enum): - ipv4 = "ipv4" ipv6 = "ipv6" class InterfaceStatus(str, Enum): - up = "up" down = "down" @@ -115,7 +112,6 @@ class UDPPort(BaseModel): class CloudConsoleType(str, Enum): - telnet = "telnet" ssh = "ssh" vnc = "vnc" @@ -160,7 +156,6 @@ class CloudUpdate(CloudBase): class Cloud(CloudBase): - project_id: UUID node_id: UUID ports_mapping: List[Union[EthernetPort, TAPPort, UDPPort]] diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index d752b7586..4359df723 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -54,11 +54,15 @@ class DockerBase(BaseModel): usage: Optional[str] = Field(None, description="How to use the Docker container") start_command: Optional[str] = Field(None, description="Docker CMD entry") adapters: Optional[int] = Field(None, ge=0, le=99, description="Number of adapters") - mac_address: Optional[str] = Field(None, description="Base MAC address", pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$") + mac_address: Optional[str] = Field( + None, description="Base MAC address", pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$" + ) environment: Optional[str] = Field(None, description="Docker environment variables") extra_hosts: Optional[str] = Field(None, description="Docker extra hosts (added to /etc/hosts)") extra_volumes: Optional[List[str]] = Field(None, description="Additional directories to make persistent") - extra_configs: Optional[List[ExtraConfig]] = Field(None, description="Configuration files injected into the container (bind-mounted read-only)") + extra_configs: Optional[List[ExtraConfig]] = Field( + None, description="Configuration files injected into the container (bind-mounted read-only)" + ) startup_config_content: Optional[str] = Field( None, description="Startup-config content (IOL runner images: materialized into the node's NVRAM at start)" ) @@ -76,10 +80,11 @@ class DockerCreate(DockerBase): None, ge=1, le=1022, description="IOL application ID for iol-runner images (allocated by the controller)" ) image_digest: Optional[str] = Field( - None, pattern=r"^sha256:[a-f0-9]{64}$", + None, + pattern=r"^sha256:[a-f0-9]{64}$", description="Image id the controller expects for 'image' (sha256:, resolved from the Docker " - "daemon on the controller host). When set and the compute holds a different image " - "under the same tag, the image is reported as missing so the controller re-syncs it" + "daemon on the controller host). When set and the compute holds a different image " + "under the same tag, the image is reported as missing so the controller re-syncs it", ) @@ -93,7 +98,6 @@ class DockerUpdate(DockerBase): class Docker(DockerBase): - container_id: str = Field( ..., min_length=12, max_length=64, pattern="^[a-f0-9]+$", description="Docker container ID (read only)" ) diff --git a/gns3server/schemas/compute/dynamips_nodes.py b/gns3server/schemas/compute/dynamips_nodes.py index 1ab8e649b..d01020a28 100644 --- a/gns3server/schemas/compute/dynamips_nodes.py +++ b/gns3server/schemas/compute/dynamips_nodes.py @@ -189,7 +189,6 @@ class DynamipsUpdate(DynamipsBase): class Dynamips(DynamipsBase): - name: str node_id: UUID project_id: UUID diff --git a/gns3server/schemas/compute/ethernet_hub_nodes.py b/gns3server/schemas/compute/ethernet_hub_nodes.py index 803be8f3b..866e57a38 100644 --- a/gns3server/schemas/compute/ethernet_hub_nodes.py +++ b/gns3server/schemas/compute/ethernet_hub_nodes.py @@ -22,7 +22,6 @@ from ..common import NodeStatus class EthernetHubPort(BaseModel): - name: str port_number: int @@ -55,7 +54,6 @@ class EthernetHubUpdate(EthernetHubBase): class EthernetHub(EthernetHubBase): - name: str node_id: UUID project_id: UUID diff --git a/gns3server/schemas/compute/ethernet_switch_nodes.py b/gns3server/schemas/compute/ethernet_switch_nodes.py index ddb4b32cc..16bd63b3e 100644 --- a/gns3server/schemas/compute/ethernet_switch_nodes.py +++ b/gns3server/schemas/compute/ethernet_switch_nodes.py @@ -23,14 +23,12 @@ from ..common import NodeStatus class EthernetSwitchPortType(str, Enum): - access = "access" dot1q = "dot1q" qinq = "qinq" class EthernetSwitchEtherType(str, Enum): - ethertype_8021q = "0x8100" ethertype_qinq = "0x88A8" ethertype_8021q9100 = "0x9100" @@ -38,12 +36,13 @@ class EthernetSwitchEtherType(str, Enum): class EthernetSwitchPort(BaseModel): - name: str port_number: int type: EthernetSwitchPortType = Field(..., description="Port type") vlan: int = Field(..., ge=1, le=4094, description="VLAN number") - ethertype: Optional[EthernetSwitchEtherType] = Field(EthernetSwitchEtherType.ethertype_8021q, description="QinQ Ethertype") + ethertype: Optional[EthernetSwitchEtherType] = Field( + EthernetSwitchEtherType.ethertype_8021q, description="QinQ Ethertype" + ) @model_validator(mode="after") def check_ethertype(self) -> "EthernetSwitchPort": @@ -92,7 +91,6 @@ class EthernetSwitchUpdate(EthernetSwitchBase): class EthernetSwitch(EthernetSwitchBase): - name: str node_id: UUID project_id: UUID diff --git a/gns3server/schemas/compute/frame_relay_switch_nodes.py b/gns3server/schemas/compute/frame_relay_switch_nodes.py index 6b759eb43..67db0bf3e 100644 --- a/gns3server/schemas/compute/frame_relay_switch_nodes.py +++ b/gns3server/schemas/compute/frame_relay_switch_nodes.py @@ -50,6 +50,5 @@ class FrameRelaySwitchUpdate(FrameRelaySwitchBase): class FrameRelaySwitch(FrameRelaySwitchBase): - project_id: UUID status: Optional[NodeStatus] = None diff --git a/gns3server/schemas/compute/iou_nodes.py b/gns3server/schemas/compute/iou_nodes.py index c04b6f0ef..f4dd42ce5 100644 --- a/gns3server/schemas/compute/iou_nodes.py +++ b/gns3server/schemas/compute/iou_nodes.py @@ -66,7 +66,6 @@ class IOUUpdate(IOUBase): class IOU(IOUBase): - project_id: UUID = Field(..., description="Project ID") node_directory: str = Field(..., description="Path to the node working directory (read only)") command_line: str = Field(..., description="Last command line used to start IOU (read only)") @@ -74,6 +73,5 @@ class IOU(IOUBase): class IOUStart(BaseModel): - iourc_content: Optional[str] = Field(None, description="Content of the iourc file") license_check: Optional[bool] = Field(None, description="Whether the IOU license should be checked") diff --git a/gns3server/schemas/compute/nat_nodes.py b/gns3server/schemas/compute/nat_nodes.py index ef91c8dd6..95a703320 100644 --- a/gns3server/schemas/compute/nat_nodes.py +++ b/gns3server/schemas/compute/nat_nodes.py @@ -23,7 +23,6 @@ from ..common import NodeStatus class HostInterfaceType(str, Enum): - ethernet = "ethernet" tap = "tap" @@ -115,7 +114,6 @@ class NATUpdate(NATBase): class NAT(NATBase): - project_id: UUID node_id: UUID ports_mapping: List[Union[EthernetPort, TAPPort, UDPPort]] diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index a1d8641b3..f05d82737 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -21,7 +21,6 @@ from enum import Enum class UDPNIOType(str, Enum): - udp = "nio_udp" @@ -40,7 +39,6 @@ class UDPNIO(BaseModel): class EthernetNIOType(str, Enum): - ethernet = "nio_ethernet" @@ -54,7 +52,6 @@ class EthernetNIO(BaseModel): class TAPNIOType(str, Enum): - tap = "nio_tap" diff --git a/gns3server/schemas/compute/qemu_nodes.py b/gns3server/schemas/compute/qemu_nodes.py index 658fb26d5..4596677c8 100644 --- a/gns3server/schemas/compute/qemu_nodes.py +++ b/gns3server/schemas/compute/qemu_nodes.py @@ -23,7 +23,6 @@ from ..common import NodeStatus, CustomAdapter class QemuPlatform(str, Enum): - aarch64 = "aarch64" alpha = "alpha" arm = "arm" @@ -94,7 +93,6 @@ class QemuOnCloseAction(str, Enum): class QemuProcessPriority(str, Enum): - realtime = "realtime" very_high = "very high" high = "high" @@ -236,7 +234,6 @@ class QemuUpdate(QemuBase): class Qemu(QemuBase): - project_id: UUID = Field(..., description="Project ID") node_directory: str = Field(..., description="Path to the node working directory (read only)") command_line: str = Field(..., description="Last command line used to start IOU (read only)") @@ -244,6 +241,5 @@ class Qemu(QemuBase): class QemuBinaryPath(BaseModel): - path: str version: str diff --git a/gns3server/schemas/compute/virtualbox_nodes.py b/gns3server/schemas/compute/virtualbox_nodes.py index faa6ed7ee..06ad9f831 100644 --- a/gns3server/schemas/compute/virtualbox_nodes.py +++ b/gns3server/schemas/compute/virtualbox_nodes.py @@ -43,7 +43,6 @@ class VirtualBoxOnCloseAction(str, Enum): class VirtualBoxAdapterType(str, Enum): - pcnet_pci_ii = ("PCnet-PCI II (Am79C970A)",) pcnet_fast_iii = ("PCNet-FAST III (Am79C973)",) intel_pro_1000_mt_desktop = ("Intel PRO/1000 MT Desktop (82540EM)",) @@ -92,7 +91,6 @@ class VirtualBoxUpdate(VirtualBoxBase): class VirtualBox(VirtualBoxBase): - project_id: UUID = Field(..., description="Project ID") node_directory: Optional[str] = Field(None, description="Path to the node working directory (read only)") status: NodeStatus = Field(..., description="Container status (read only)") diff --git a/gns3server/schemas/compute/vmware_nodes.py b/gns3server/schemas/compute/vmware_nodes.py index 770f6f7c6..ee670a2bc 100644 --- a/gns3server/schemas/compute/vmware_nodes.py +++ b/gns3server/schemas/compute/vmware_nodes.py @@ -97,7 +97,6 @@ class VMwareUpdate(VMwareBase): class VMware(VMwareBase): - project_id: UUID = Field(..., description="Project ID") node_directory: Optional[str] = Field(None, description="Path to the node working directory (read only)") status: NodeStatus = Field(..., description="Container status (read only)") diff --git a/gns3server/schemas/compute/vpcs_nodes.py b/gns3server/schemas/compute/vpcs_nodes.py index 1b2278536..30cd40960 100644 --- a/gns3server/schemas/compute/vpcs_nodes.py +++ b/gns3server/schemas/compute/vpcs_nodes.py @@ -62,7 +62,6 @@ class VPCSUpdate(VPCSBase): class VPCS(VPCSBase): - project_id: UUID = Field(..., description="Project ID") node_directory: str = Field(..., description="Path to the node working directory (read only)") status: NodeStatus = Field(..., description="Container status (read only)") diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 227129b28..7b1eb19fb 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -17,96 +17,86 @@ import socket from enum import Enum -from pydantic import ( - ConfigDict, - BaseModel, - Field, - SecretStr, - FilePath, - DirectoryPath, - field_validator, - model_validator -) +from pydantic import ConfigDict, BaseModel, Field, SecretStr, FilePath, DirectoryPath, field_validator, model_validator from typing import List, Optional class ControllerSettings(BaseModel): - jwt_secret_key: Optional[str] = Field( None, description="Secret key used to sign the JWT authentication tokens " - "(normally managed via the secrets directory, not the configuration file)") + "(normally managed via the secrets directory, not the configuration file)", + ) jwt_algorithm: str = Field("HS256", description="Algorithm used to sign the JWT tokens") jwt_access_token_expire_minutes: int = Field( - 1440, description="Lifetime of the JWT access tokens in minutes (24 hours by default)") + 1440, description="Lifetime of the JWT access tokens in minutes (24 hours by default)" + ) jwt_refresh_token_expire_minutes: int = Field( - 43200, description="Lifetime of the JWT refresh tokens in minutes (30 days by default)") + 43200, description="Lifetime of the JWT refresh tokens in minutes (30 days by default)" + ) default_admin_username: str = Field( "admin", description="Username of the super admin account seeded when the controller database is created; " - "changing it has no effect until the database is re-created (which resets the account)") + "changing it has no effect until the database is re-created (which resets the account)", + ) default_admin_password: SecretStr = Field( SecretStr("admin"), description="Password of the super admin account seeded when the controller database is created; " - "changing it has no effect until the database is re-created (which resets the account)") + "changing it has no effect until the database is re-created (which resets the account)", + ) model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class VPCSSettings(BaseModel): - vpcs_path: str = Field("vpcs", description="VPCS executable location, default: search in PATH") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class DynamipsSettings(BaseModel): - - allocate_aux_console_ports: bool = Field( - False, description="Allocate auxiliary console ports on IOS routers") + allocate_aux_console_ports: bool = Field(False, description="Allocate auxiliary console ports on IOS routers") mmap_support: bool = Field( - True, description="Use memory-mapped flash files (mmap) to lower the memory usage of routers") + True, description="Use memory-mapped flash files (mmap) to lower the memory usage of routers" + ) dynamips_path: str = Field("dynamips", description="Dynamips executable location, default: search in PATH") sparse_memory_support: bool = Field( - True, description="Use sparse memory allocation to lower the memory usage of routers") + True, description="Use sparse memory allocation to lower the memory usage of routers" + ) ghost_ios_support: bool = Field( - True, description="Enable Ghost IOS support to share memory between identical IOS images") + True, description="Enable Ghost IOS support to share memory between identical IOS images" + ) model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class IOUSettings(BaseModel): - iourc_path: Optional[str] = Field( - None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided") + None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided" + ) license_check: bool = Field( True, description="Validate the iourc license file (if disabled, IOU will not start and no errors " - "will be shown when the license is invalid)") + "will be shown when the license is invalid)", + ) model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class QemuSettings(BaseModel): - - enable_monitor: bool = Field( - True, description="Use the Qemu monitor feature to communicate with Qemu VMs") + enable_monitor: bool = Field(True, description="Use the Qemu monitor feature to communicate with Qemu VMs") monitor_host: str = Field("127.0.0.1", description="IP used to listen for the monitor") - enable_hardware_acceleration: bool = Field( - True, description="Enable hardware acceleration (KVM)") + enable_hardware_acceleration: bool = Field(True, description="Enable hardware acceleration (KVM)") require_hardware_acceleration: bool = Field( - False, description="Require hardware acceleration in order to start VMs") - allow_unsafe_options: bool = Field( - False, description="Allow unsafe additional command line options") - ovmf_firmware_dir: str = Field( - "/usr/share/OVMF", description="Path to the OVMF firmware directory") + False, description="Require hardware acceleration in order to start VMs" + ) + allow_unsafe_options: bool = Field(False, description="Allow unsafe additional command line options") + ovmf_firmware_dir: str = Field("/usr/share/OVMF", description="Path to the OVMF firmware directory") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class VirtualBoxSettings(BaseModel): - vboxmanage_path: Optional[str] = None model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) class VMwareSettings(BaseModel): - vmrun_path: Optional[str] = None vmnet_start_range: int = Field(2, ge=1, le=255) vmnet_end_range: int = Field(255, ge=1, le=255) # should be limited to 19 on Windows @@ -121,15 +111,15 @@ class VMwareSettings(BaseModel): class WebWiresharkSettings(BaseModel): - enabled: bool = Field( - True, description="Enable the Web Wireshark feature (container-based Wireshark in the browser)") - image: str = Field( - "gns3/web-wireshark:latest", description="Docker image for the Web Wireshark containers") + True, description="Enable the Web Wireshark feature (container-based Wireshark in the browser)" + ) + image: str = Field("gns3/web-wireshark:latest", description="Docker image for the Web Wireshark containers") network_subnet: str = Field( "172.31.0.0/22", description="Docker network subnet for the Web Wireshark containers (change it if it conflicts " - "with your existing network)") + "with your existing network)", + ) memory: str = Field("2g", description='Memory limit per container (e.g. "512m", "2g")') cpus: float = Field(1.0, description="CPU cores per container (e.g. 1.0, 2.0)") pids_limit: int = Field(1000, description="Process limit per container") @@ -137,13 +127,11 @@ class WebWiresharkSettings(BaseModel): class ServerProtocol(str, Enum): - http = "http" https = "https" class UbridgeControlTransport(str, Enum): - # TCP control channel: -H host:port. ubridge now binds loopback by default, # so this is reachable only locally. Retained for backward compatibility. tcp = "tcp" @@ -153,7 +141,6 @@ class UbridgeControlTransport(str, Enum): class BuiltinSymbolTheme(str, Enum): - classic = "Classic" affinity_square_blue = "Affinity-square-blue" affinity_square_red = "Affinity-square-red" @@ -164,20 +151,20 @@ class BuiltinSymbolTheme(str, Enum): class ServerSettings(BaseModel): - local: bool = Field( - False, - description="Local server mode, set by the --local command line argument (not meant to be set by hand)") + False, description="Local server mode, set by the --local command line argument (not meant to be set by hand)" + ) enable_http_auth: bool = Field(True, description="Enable compute HTTP authentication") name: str = Field( f"{socket.gethostname()} (controller)", - description="Server name, default is what is returned by socket.gethostname()") - protocol: ServerProtocol = Field( - ServerProtocol.http, description="Protocol used by the server: http or https") + description="Server name, default is what is returned by socket.gethostname()", + ) + protocol: ServerProtocol = Field(ServerProtocol.http, description="Protocol used by the server: http or https") host: str = Field("0.0.0.0", description="IP address where the server listens for connections") port: int = Field(3080, gt=0, le=65535, description="HTTP port used to control the server") secrets_dir: Optional[DirectoryPath] = Field( - None, description="Directory where secrets are stored (e.g. the JWT secret key)") + None, description="Directory where secrets are stored (e.g. the JWT secret key)" + ) certfile: Optional[FilePath] = Field(None, description="SSL certificate file, requires enable_ssl") certkey: Optional[FilePath] = Field(None, description="SSL key file, requires enable_ssl") enable_ssl: bool = Field(False, description="Enable SSL encryption") @@ -189,80 +176,98 @@ class ServerSettings(BaseModel): resources_path: Optional[str] = Field( None, description="Path where files like built-in appliances and Docker resources are stored " - "(defaults to the local user data directory)") + "(defaults to the local user data directory)", + ) default_symbol_theme: BuiltinSymbolTheme = Field( BuiltinSymbolTheme.affinity_square_blue, - description='Default symbol theme, e.g. "Classic" or "Affinity-square-blue"') - allow_raw_images: bool = Field( - True, description="Allow raw images to be uploaded to the server") - auto_discover_images: bool = Field( - True, description="Automatically discover images in the images directory") - report_errors: bool = Field( - True, description="Automatically send crash reports to the GNS3 team") + description='Default symbol theme, e.g. "Classic" or "Affinity-square-blue"', + ) + allow_raw_images: bool = Field(True, description="Allow raw images to be uploaded to the server") + auto_discover_images: bool = Field(True, description="Automatically discover images in the images directory") + report_errors: bool = Field(True, description="Automatically send crash reports to the GNS3 team") additional_images_paths: List[str] = Field( default_factory=list, - description="Additional paths to look for images (semicolon-separated in the configuration file)") + description="Additional paths to look for images (semicolon-separated in the configuration file)", + ) console_start_port_range: int = Field( - 5000, gt=0, le=65535, description="First console port of the range allocated to devices") + 5000, gt=0, le=65535, description="First console port of the range allocated to devices" + ) console_end_port_range: int = Field( - 10000, gt=0, le=65535, description="Last console port of the range allocated to devices") + 10000, gt=0, le=65535, description="Last console port of the range allocated to devices" + ) vnc_console_start_port_range: int = Field( - 5900, ge=5900, le=65535, description="First VNC console port of the range allocated to devices") + 5900, ge=5900, le=65535, description="First VNC console port of the range allocated to devices" + ) vnc_console_end_port_range: int = Field( - 10000, ge=5900, le=65535, description="Last VNC console port of the range allocated to devices") + 10000, ge=5900, le=65535, description="Last VNC console port of the range allocated to devices" + ) udp_start_port_range: int = Field( - 10000, gt=0, le=65535, - description="First UDP port of the range allocated for inter-device communication (two ports per link)") + 10000, + gt=0, + le=65535, + description="First UDP port of the range allocated for inter-device communication (two ports per link)", + ) udp_end_port_range: int = Field( - 30000, gt=0, le=65535, - description="Last UDP port of the range allocated for inter-device communication (two ports per link)") + 30000, + gt=0, + le=65535, + description="Last UDP port of the range allocated for inter-device communication (two ports per link)", + ) ubridge_path: str = Field("ubridge", description="uBridge executable location, default: search in PATH") ubridge_control_transport: UbridgeControlTransport = Field( UbridgeControlTransport.unix, description='uBridge control channel transport: "unix" (AF_UNIX + SO_PEERCRED, recommended ' - 'on Linux) or "tcp" (loopback, kept for backward compatibility)') + 'on Linux) or "tcp" (loopback, kept for backward compatibility)', + ) marker_listen_host: str = Field( "127.0.0.1", description="Marker (traffic-insight) UDP sink listen host: one listener per compute process " - "receives uBridge MARK signals from every uBridge on this host") + "receives uBridge MARK signals from every uBridge on this host", + ) marker_listen_port: int = Field( - 3070, ge=0, le=65535, - description="Marker UDP sink listen port (0 lets the operating system choose a free port)") - compute_username: str = Field( - "gns3", description='Username for compute HTTP authentication, "gns3" is the default') + 3070, ge=0, le=65535, description="Marker UDP sink listen port (0 lets the operating system choose a free port)" + ) + compute_username: str = Field("gns3", description='Username for compute HTTP authentication, "gns3" is the default') compute_password: SecretStr = Field( SecretStr(""), - description="Password for compute HTTP authentication, a randomly generated password is used if not set") + description="Password for compute HTTP authentication, a randomly generated password is used if not set", + ) allowed_interfaces: List[str] = Field( default_factory=list, description="Only allow these interfaces to be used by GNS3, for the Cloud node for example " - "(comma-separated; do not forget virbr0 for the NAT node to work)") + "(comma-separated; do not forget virbr0 for the NAT node to work)", + ) default_nat_interface: Optional[str] = Field( - None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)") + None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)" + ) allow_remote_console: bool = Field( False, description="Allow console connections from remote machines " - "(console ports only accept local connections by default)") + "(console ports only accept local connections by default)", + ) enable_builtin_templates: bool = Field(True, description="Enable the built-in templates") install_builtin_appliances: bool = Field(True, description="Install the built-in appliances") skills_repo_url: str = Field( "https://github.com/gns3/gns3-skills.git", description="Git repository URL for the external GNS3 Copilot skills " - "(injection skills, prompts and device skills)") + "(injection skills, prompts and device skills)", + ) skills_repo_branch: str = Field("main", description="Git branch of the skills repository") skills_auto_update: bool = Field( - True, description="Automatically pull updates from the skills repository when reloading") + True, description="Automatically pull updates from the skills repository when reloading" + ) mcp_enable_dns_rebinding_protection: bool = Field( False, - description="Enable MCP transport DNS rebinding protection " - "(allowed hosts and origins must be configured)") + description="Enable MCP transport DNS rebinding protection (allowed hosts and origins must be configured)", + ) mcp_allowed_hosts: list[str] = Field( default_factory=list, description='Allowed hosts for MCP connections, only "host:*" port wildcards are supported ' - '(e.g. "127.0.0.1:*")') + '(e.g. "127.0.0.1:*")', + ) mcp_allowed_origins: list[str] = Field( - default_factory=list, - description='Allowed origins for MCP connections (e.g. "http://localhost:*")') + default_factory=list, description='Allowed origins for MCP connections (e.g. "http://localhost:*")' + ) model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) @@ -325,7 +330,6 @@ class ServerSettings(BaseModel): class ServerConfig(BaseModel): - Server: ServerSettings = ServerSettings() Controller: ControllerSettings = ControllerSettings() VPCS: VPCSSettings = VPCSSettings() diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 9fb818b8a..a4aecaa94 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -27,330 +27,346 @@ from ..common import ExtraConfig # Shared Enums # ============================================================================ + class Category(str, Enum): """Appliance category enum""" - router = 'router' - multilayer_switch = 'multilayer_switch' - switch = 'switch' - firewall = 'firewall' - guest = 'guest' + router = "router" + multilayer_switch = "multilayer_switch" + switch = "switch" + firewall = "firewall" + guest = "guest" class Status(str, Enum): """Appliance status enum""" - stable = 'stable' - experimental = 'experimental' - broken = 'broken' + stable = "stable" + experimental = "experimental" + broken = "broken" class Availability(str, Enum): """Image availability enum""" - free = 'free' - with_registration = 'with-registration' - free_to_try = 'free-to-try' - service_contract = 'service-contract' + free = "free" + with_registration = "with-registration" + free_to_try = "free-to-try" + service_contract = "service-contract" class Compression(str, Enum): """Compression type enum""" - bzip2 = 'bzip2' - gzip = 'gzip' - lzma = 'lzma' - xz = 'xz' - rar = 'rar' - zip = 'zip' - field_7z = '7z' + bzip2 = "bzip2" + gzip = "gzip" + lzma = "lzma" + xz = "xz" + rar = "rar" + zip = "zip" + field_7z = "7z" class DynamipsPlatform(str, Enum): """Dynamips platform enum""" - c1700 = 'c1700' - c2600 = 'c2600' - c2691 = 'c2691' - c3725 = 'c3725' - c3745 = 'c3745' - c3600 = 'c3600' - c7200 = 'c7200' + c1700 = "c1700" + c2600 = "c2600" + c2691 = "c2691" + c3725 = "c3725" + c3745 = "c3745" + c3600 = "c3600" + c7200 = "c7200" class DynamipsChassis(str, Enum): """Dynamips chassis enum""" - chassis_1720 = '1720' - chassis_1721 = '1721' - chassis_1750 = '1750' - chassis_1751 = '1751' - chassis_1760 = '1760' - chassis_2610 = '2610' - chassis_2620 = '2620' - chassis_2610XM = '2610XM' - chassis_2620XM = '2620XM' - chassis_2650XM = '2650XM' - chassis_2621 = '2621' - chassis_2611XM = '2611XM' - chassis_2621XM = '2621XM' - chassis_2651XM = '2651XM' - chassis_3620 = '3620' - chassis_3640 = '3640' - chassis_3660 = '3660' - _ = '' + chassis_1720 = "1720" + chassis_1721 = "1721" + chassis_1750 = "1750" + chassis_1751 = "1751" + chassis_1760 = "1760" + chassis_2610 = "2610" + chassis_2620 = "2620" + chassis_2610XM = "2610XM" + chassis_2620XM = "2620XM" + chassis_2650XM = "2650XM" + chassis_2621 = "2621" + chassis_2611XM = "2611XM" + chassis_2621XM = "2621XM" + chassis_2651XM = "2651XM" + chassis_3620 = "3620" + chassis_3640 = "3640" + chassis_3660 = "3660" + _ = "" class DynamipsSlot(str, Enum): """Dynamips slot enum""" - C7200_IO_2FE = 'C7200-IO-2FE' - C7200_IO_FE = 'C7200-IO-FE' - C7200_IO_GE_E = 'C7200-IO-GE-E' - NM_16ESW = 'NM-16ESW' - NM_1E = 'NM-1E' - NM_1FE_TX = 'NM-1FE-TX' - NM_4E = 'NM-4E' - NM_4T = 'NM-4T' - PA_2FE_TX = 'PA-2FE-TX' - PA_4E = 'PA-4E' - PA_4T_ = 'PA-4T+' - PA_8E = 'PA-8E' - PA_8T = 'PA-8T' - PA_A1 = 'PA-A1' - PA_FE_TX = 'PA-FE-TX' - PA_GE = 'PA-GE' - PA_POS_OC3 = 'PA-POS-OC3' - C2600_MB_2FE = 'C2600-MB-2FE' - C2600_MB_1E = 'C2600-MB-1E' - C1700_MB_1FE = 'C1700-MB-1FE' - C2600_MB_2E = 'C2600-MB-2E' - C2600_MB_1FE = 'C2600-MB-1FE' - C1700_MB_WIC1 = 'C1700-MB-WIC1' - GT96100_FE = 'GT96100-FE' - Leopard_2FE = 'Leopard-2FE' - _ = '' + C7200_IO_2FE = "C7200-IO-2FE" + C7200_IO_FE = "C7200-IO-FE" + C7200_IO_GE_E = "C7200-IO-GE-E" + NM_16ESW = "NM-16ESW" + NM_1E = "NM-1E" + NM_1FE_TX = "NM-1FE-TX" + NM_4E = "NM-4E" + NM_4T = "NM-4T" + PA_2FE_TX = "PA-2FE-TX" + PA_4E = "PA-4E" + PA_4T_ = "PA-4T+" + PA_8E = "PA-8E" + PA_8T = "PA-8T" + PA_A1 = "PA-A1" + PA_FE_TX = "PA-FE-TX" + PA_GE = "PA-GE" + PA_POS_OC3 = "PA-POS-OC3" + C2600_MB_2FE = "C2600-MB-2FE" + C2600_MB_1E = "C2600-MB-1E" + C1700_MB_1FE = "C1700-MB-1FE" + C2600_MB_2E = "C2600-MB-2E" + C2600_MB_1FE = "C2600-MB-1FE" + C1700_MB_WIC1 = "C1700-MB-WIC1" + GT96100_FE = "GT96100-FE" + Leopard_2FE = "Leopard-2FE" + _ = "" + class DynamipsWic(str, Enum): """Dynamips WIC enum""" - WIC_1ENET = 'WIC-1ENET' - WIC_1T = 'WIC-1T' - WIC_2T = 'WIC-2T' - _ = '' + WIC_1ENET = "WIC-1ENET" + WIC_1T = "WIC-1T" + WIC_2T = "WIC-2T" + _ = "" + class DynamipsMidplane(str, Enum): """Dynamips midplane enum""" - std = 'std' - vxr = 'vxr' + std = "std" + vxr = "vxr" class DynamipsNpe(str, Enum): """Dynamips NPE enum""" - npe_100 = 'npe-100' - npe_150 = 'npe-150' - npe_175 = 'npe-175' - npe_200 = 'npe-200' - npe_225 = 'npe-225' - npe_300 = 'npe-300' - npe_400 = 'npe-400' - npe_g2 = 'npe-g2' + npe_100 = "npe-100" + npe_150 = "npe-150" + npe_175 = "npe-175" + npe_200 = "npe-200" + npe_225 = "npe-225" + npe_300 = "npe-300" + npe_400 = "npe-400" + npe_g2 = "npe-g2" class QemuConsoleType(str, Enum): """Qemu console type enum""" - telnet = 'telnet' - ssh = 'ssh' - vnc = 'vnc' - spice = 'spice' - spice_agent = 'spice+agent' - none = 'none' + telnet = "telnet" + ssh = "ssh" + vnc = "vnc" + spice = "spice" + spice_agent = "spice+agent" + none = "none" class QemuAdapterType(str, Enum): """Qemu adapter type enum""" - e1000 = 'e1000' - i82550 = 'i82550' - i82551 = 'i82551' - i82557a = 'i82557a' - i82557b = 'i82557b' - i82557c = 'i82557c' - i82558a = 'i82558a' - i82558b = 'i82558b' - i82559a = 'i82559a' - i82559b = 'i82559b' - i82559c = 'i82559c' - i82559er = 'i82559er' - i82562 = 'i82562' - i82801 = 'i82801' - igb = 'igb' - ne2k_pci = 'ne2k_pci' - pcnet = 'pcnet' - rtl8139 = 'rtl8139' - virtio = 'virtio' - virtio_net_pci = 'virtio-net-pci' - vmxnet3 = 'vmxnet3' + e1000 = "e1000" + i82550 = "i82550" + i82551 = "i82551" + i82557a = "i82557a" + i82557b = "i82557b" + i82557c = "i82557c" + i82558a = "i82558a" + i82558b = "i82558b" + i82559a = "i82559a" + i82559b = "i82559b" + i82559c = "i82559c" + i82559er = "i82559er" + i82562 = "i82562" + i82801 = "i82801" + igb = "igb" + ne2k_pci = "ne2k_pci" + pcnet = "pcnet" + rtl8139 = "rtl8139" + virtio = "virtio" + virtio_net_pci = "virtio-net-pci" + vmxnet3 = "vmxnet3" class QemuPlatform(str, Enum): """Qemu platform enum""" - aarch64 = 'aarch64' - alpha = 'alpha' - arm = 'arm' - cris = 'cris' - i386 = 'i386' - lm32 = 'lm32' - m68k = 'm68k' - microblaze = 'microblaze' - microblazeel = 'microblazeel' - mips = 'mips' - mips64 = 'mips64' - mips64el = 'mips64el' - mipsel = 'mipsel' - moxie = 'moxie' - or32 = 'or32' - ppc = 'ppc' - ppc64 = 'ppc64' - ppcemb = 'ppcemb' - s390x = 's390x' - sh4 = 'sh4' - sh4eb = 'sh4eb' - sparc = 'sparc' - sparc64 = 'sparc64' - tricore = 'tricore' - unicore32 = 'unicore32' - x86_64 = 'x86_64' - xtensa = 'xtensa' - xtensaeb = 'xtensaeb' + aarch64 = "aarch64" + alpha = "alpha" + arm = "arm" + cris = "cris" + i386 = "i386" + lm32 = "lm32" + m68k = "m68k" + microblaze = "microblaze" + microblazeel = "microblazeel" + mips = "mips" + mips64 = "mips64" + mips64el = "mips64el" + mipsel = "mipsel" + moxie = "moxie" + or32 = "or32" + ppc = "ppc" + ppc64 = "ppc64" + ppcemb = "ppcemb" + s390x = "s390x" + sh4 = "sh4" + sh4eb = "sh4eb" + sparc = "sparc" + sparc64 = "sparc64" + tricore = "tricore" + unicore32 = "unicore32" + x86_64 = "x86_64" + xtensa = "xtensa" + xtensaeb = "xtensaeb" class QemuDiskInterface(str, Enum): """Disk interface enum""" - ide = 'ide' - sata = 'sata' - nvme = 'nvme' - scsi = 'scsi' - sd = 'sd' - mtd = 'mtd' - floppy = 'floppy' - pflash = 'pflash' - virtio = 'virtio' - none = 'none' + ide = "ide" + sata = "sata" + nvme = "nvme" + scsi = "scsi" + sd = "sd" + mtd = "mtd" + floppy = "floppy" + pflash = "pflash" + virtio = "virtio" + none = "none" class QemuBootPriority(str, Enum): """Boot priority enum""" - c = 'c' - d = 'd' - n = 'n' - cn = 'cn' - cd = 'cd' - dn = 'dn' - dc = 'dc' - nc = 'nc' - nd = 'nd' + c = "c" + d = "d" + n = "n" + cn = "cn" + cd = "cd" + dn = "dn" + dc = "dc" + nc = "nc" + nd = "nd" class QemuOnClose(str, Enum): """Qemu on_close action enum""" - power_off = 'power_off' - shutdown_signal = 'shutdown_signal' - save_vm_state = 'save_vm_state' + power_off = "power_off" + shutdown_signal = "shutdown_signal" + save_vm_state = "save_vm_state" class QemuProcessPriority(str, Enum): """Qemu process priority enum""" - realtime = 'realtime' - very_high = 'very high' - high = 'high' - normal = 'normal' - low = 'low' - very_low = 'very low' + realtime = "realtime" + very_high = "very high" + high = "high" + normal = "normal" + low = "low" + very_low = "very low" class DockerConsoleType(str, Enum): """Docker console type enum""" - telnet = 'telnet' - ssh = 'ssh' - vnc = 'vnc' - http = 'http' - https = 'https' - none = 'none' - docker_exec = 'docker_exec' + telnet = "telnet" + ssh = "ssh" + vnc = "vnc" + http = "http" + https = "https" + none = "none" + docker_exec = "docker_exec" class ChecksumType(str, Enum): """Checksum type enum""" - md5 = 'md5' + md5 = "md5" class TemplateType(str, Enum): """Template type enum""" - docker = 'docker' - iou = 'iou' - dynamips = 'dynamips' - qemu = 'qemu' + docker = "docker" + iou = "iou" + dynamips = "dynamips" + qemu = "qemu" + # ============================================================================ # Version 1-6 Specific Enums # ============================================================================ + class Kvm(str, Enum): """KVM requirements enum""" - require = 'require' - allow = 'allow' - disable = 'disable' + require = "require" + allow = "allow" + disable = "disable" + # ============================================================================ # Version 1-6 Models # ============================================================================ + class Docker(BaseModel): """Docker configuration for v1-6""" - adapters: int = Field(..., title='Number of Ethernet adapters') - image: str = Field(..., title='Docker image in the Docker Hub') - start_command: Optional[str] = Field(None, title='Command executed when the container start. Empty will use the default') - environment: Optional[str] = Field(None, title='One KEY=VAR environment by line') - console_type: Optional[DockerConsoleType] = Field(None, title='Type of console connection for the administration of the appliance') - console_http_port: Optional[int] = Field(None, description='Internal port in the container of the HTTP server') - console_http_path: Optional[str] = Field(None, description='Path of the web interface') - extra_hosts: Optional[str] = Field(None, description='Hosts which will be written to /etc/hosts into container') - extra_volumes: Optional[List[str]] = Field(None, description='Additional directories to make persistent that are not included in the images VOLUME directive') - extra_configs: Optional[List[ExtraConfig]] = Field(None, description='Configuration files injected into the container (bind-mounted read-only)') + adapters: int = Field(..., title="Number of Ethernet adapters") + image: str = Field(..., title="Docker image in the Docker Hub") + start_command: Optional[str] = Field( + None, title="Command executed when the container start. Empty will use the default" + ) + environment: Optional[str] = Field(None, title="One KEY=VAR environment by line") + console_type: Optional[DockerConsoleType] = Field( + None, title="Type of console connection for the administration of the appliance" + ) + console_http_port: Optional[int] = Field(None, description="Internal port in the container of the HTTP server") + console_http_path: Optional[str] = Field(None, description="Path of the web interface") + extra_hosts: Optional[str] = Field(None, description="Hosts which will be written to /etc/hosts into container") + extra_volumes: Optional[List[str]] = Field( + None, + description="Additional directories to make persistent that are not included in the images VOLUME directive", + ) + extra_configs: Optional[List[ExtraConfig]] = Field( + None, description="Configuration files injected into the container (bind-mounted read-only)" + ) class Iou(BaseModel): """IOU configuration for v1-6""" - ethernet_adapters: int = Field(..., title='Number of Ethernet adapters') - serial_adapters: int = Field(..., title='Number of serial adapters') - nvram: int = Field(..., title='Host NVRAM') - ram: int = Field(..., title='Host RAM') - startup_config: str = Field(..., title='Config loaded at startup') + ethernet_adapters: int = Field(..., title="Number of Ethernet adapters") + serial_adapters: int = Field(..., title="Number of serial adapters") + nvram: int = Field(..., title="Host NVRAM") + ram: int = Field(..., title="Host RAM") + startup_config: str = Field(..., title="Config loaded at startup") class Dynamips(BaseModel): """Dynamips configuration for v1-6""" - chassis: Optional[DynamipsChassis] = Field(None, title='Chassis type') - platform: DynamipsPlatform = Field(..., title='Platform type') - ram: Annotated[int, Field(ge=1)] = Field(..., title='Amount of ram') - nvram: Annotated[int, Field(ge=1)] = Field(..., title='Amount of nvram') - startup_config: Optional[str] = Field(None, title='Config loaded at startup') + chassis: Optional[DynamipsChassis] = Field(None, title="Chassis type") + platform: DynamipsPlatform = Field(..., title="Platform type") + ram: Annotated[int, Field(ge=1)] = Field(..., title="Amount of ram") + nvram: Annotated[int, Field(ge=1)] = Field(..., title="Amount of nvram") + startup_config: Optional[str] = Field(None, title="Config loaded at startup") wic0: Optional[DynamipsWic] = None wic1: Optional[DynamipsWic] = None wic2: Optional[DynamipsWic] = None @@ -368,69 +384,76 @@ class Dynamips(BaseModel): class Qemu(BaseModel): """QEMU configuration for v1-6""" - adapter_type: QemuAdapterType = Field(..., title='Type of network adapter') - adapters: int = Field(..., title='Number of adapters') - ram: int = Field(..., title='RAM allocated to the appliance (MB)') - cpus: Optional[int] = Field(None, title='Number of Virtual CPU') - hda_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hda_disk_image') - hdb_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdb_disk_image') - hdc_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdc_disk_image') - hdd_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdd_disk_image') - arch: QemuPlatform = Field(..., title='Architecture emulated') - console_type: QemuConsoleType = Field(..., title='Type of console connection for the administration of the appliance') - boot_priority: Optional[QemuBootPriority] = Field(None, title='Disk boot priority') - kernel_command_line: Optional[str] = Field(None, title='Command line parameters sent to the kernel') - kvm: Kvm = Field(..., title='KVM requirements') - options: Optional[str] = Field(None, title='Optional additional qemu command line options') - cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU') - on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed') - process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU') + adapter_type: QemuAdapterType = Field(..., title="Type of network adapter") + adapters: int = Field(..., title="Number of adapters") + ram: int = Field(..., title="RAM allocated to the appliance (MB)") + cpus: Optional[int] = Field(None, title="Number of Virtual CPU") + hda_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hda_disk_image" + ) + hdb_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hdb_disk_image" + ) + hdc_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hdc_disk_image" + ) + hdd_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hdd_disk_image" + ) + arch: QemuPlatform = Field(..., title="Architecture emulated") + console_type: QemuConsoleType = Field( + ..., title="Type of console connection for the administration of the appliance" + ) + boot_priority: Optional[QemuBootPriority] = Field(None, title="Disk boot priority") + kernel_command_line: Optional[str] = Field(None, title="Command line parameters sent to the kernel") + kvm: Kvm = Field(..., title="KVM requirements") + options: Optional[str] = Field(None, title="Optional additional qemu command line options") + cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title="Throttle the CPU") + on_close: Optional[QemuOnClose] = Field(None, title="Action to execute on the VM is closed") + process_priority: Optional[QemuProcessPriority] = Field(None, title="Process priority for QEMU") class ApplianceVersionImages(BaseModel): """Appliance version images configuration for v1-6""" - kernel_image: Optional[str] = Field(None, title='Kernel image') - initrd: Optional[str] = Field(None, title='Initrd disk image') - image: Optional[str] = Field(None, title='OS image') - bios_image: Optional[str] = Field(None, title='Bios image') - hda_disk_image: Optional[str] = Field(None, title='Hda disk image') - hdb_disk_image: Optional[str] = Field(None, title='Hdc disk image') - hdc_disk_image: Optional[str] = Field(None, title='Hdd disk image') - hdd_disk_image: Optional[str] = Field(None, title='Hdd diskimage') - cdrom_image: Optional[str] = Field(None, title='cdrom image') + kernel_image: Optional[str] = Field(None, title="Kernel image") + initrd: Optional[str] = Field(None, title="Initrd disk image") + image: Optional[str] = Field(None, title="OS image") + bios_image: Optional[str] = Field(None, title="Bios image") + hda_disk_image: Optional[str] = Field(None, title="Hda disk image") + hdb_disk_image: Optional[str] = Field(None, title="Hdc disk image") + hdc_disk_image: Optional[str] = Field(None, title="Hdd disk image") + hdd_disk_image: Optional[str] = Field(None, title="Hdd diskimage") + cdrom_image: Optional[str] = Field(None, title="cdrom image") class ApplianceVersion(BaseModel): """Appliance version definition for v1-6""" - name: str = Field(..., title='Name of the version') - idlepc: Optional[str] = Field(None, pattern='^0x[0-9a-f]{8}') - images: Optional[ApplianceVersionImages] = Field(None, title='Images used for this version') + name: str = Field(..., title="Name of the version") + idlepc: Optional[str] = Field(None, pattern="^0x[0-9a-f]{8}") + images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version") class ApplianceImage(BaseModel): """Appliance image definition - compatible with both versions""" - filename: str = Field(..., title='Filename') - version: str = Field(..., title='Version of the file') - md5sum: Optional[str] = Field(None, title='md5sum of the file', pattern='^[a-f0-9]{32}$') - filesize: int = Field(..., title='File size in bytes') + filename: str = Field(..., title="Filename") + version: str = Field(..., title="Version of the file") + md5sum: Optional[str] = Field(None, title="md5sum of the file", pattern="^[a-f0-9]{32}$") + filesize: int = Field(..., title="File size in bytes") download_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( - None, - title='Download url where you can download the appliance from a browser' + None, title="Download url where you can download the appliance from a browser" ) direct_download_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( None, - title='Optional. Non authenticated url to the image file where you can download the image.', + title="Optional. Non authenticated url to the image file where you can download the image.", ) - compression: Optional[Compression] = Field( - None, title='Optional, compression type of direct download url image.' - ) - checksum: Optional[str] = Field(None, title='checksum of the image file') - checksum_type: Optional[ChecksumType] = Field(None, title='checksum type of the image file') + compression: Optional[Compression] = Field(None, title="Optional, compression type of direct download url image.") + checksum: Optional[str] = Field(None, title="checksum of the image file") + checksum_type: Optional[ChecksumType] = Field(None, title="checksum type of the image file") compression_target: Optional[str] = Field( - None, title='Optional, file name of the image file inside the compressed file.' + None, title="Optional, file name of the image file inside the compressed file." ) @@ -438,84 +461,79 @@ class ApplianceImage(BaseModel): # Version 8 Models # ============================================================================ + class CustomAdapterItem(BaseModel): """Custom adapter configuration (v8)""" - adapter_number: int = Field(..., title='Adapter number') - port_name: Optional[str] = Field(None, title='Custom port name') - adapter_type: Optional[QemuAdapterType] = Field(None, title='Custom adapter type') + adapter_number: int = Field(..., title="Adapter number") + port_name: Optional[str] = Field(None, title="Custom port name") + adapter_type: Optional[QemuAdapterType] = Field(None, title="Custom adapter type") mac_address: Optional[str] = Field( None, - title='Custom MAC address', - pattern=r'^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$', + title="Custom MAC address", + pattern=r"^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$", ) class DockerPropertiesV8(BaseModel): """Docker template properties (v8)""" - name: Optional[str] = Field(None, title='Name of the template') - category: Optional[Category] = Field(None, title='Category of the template') - default_name_format: Optional[str] = Field(None, title='Default name format') - usage: Optional[str] = Field(None, title='How to use the template') - symbol: Optional[str] = Field(None, title='Symbol of the template') - image: str = Field(..., title='Docker image') - adapters: Optional[int] = Field(None, title='Number of ethernet adapters') + name: Optional[str] = Field(None, title="Name of the template") + category: Optional[Category] = Field(None, title="Category of the template") + default_name_format: Optional[str] = Field(None, title="Default name format") + usage: Optional[str] = Field(None, title="How to use the template") + symbol: Optional[str] = Field(None, title="Symbol of the template") + image: str = Field(..., title="Docker image") + adapters: Optional[int] = Field(None, title="Number of ethernet adapters") start_command: Optional[str] = Field( - None, title='Command executed when the container start. Empty will use the default' + None, title="Command executed when the container start. Empty will use the default" ) - environment: Optional[str] = Field(None, title='One KEY=VAR environment by line') - console_type: Optional[DockerConsoleType] = Field( - None, title='Type of console' - ) - console_http_port: Optional[int] = Field( - None, title='Internal port in the container of the HTTP server' - ) - console_http_path: Optional[str] = Field(None, title='Path of the web interface') + environment: Optional[str] = Field(None, title="One KEY=VAR environment by line") + console_type: Optional[DockerConsoleType] = Field(None, title="Type of console") + console_http_port: Optional[int] = Field(None, title="Internal port in the container of the HTTP server") + console_http_path: Optional[str] = Field(None, title="Path of the web interface") console_resolution: Optional[str] = Field( None, - title='Console resolution for VNC, for example 1024x768', - pattern=r'^[0-9]+x[0-9]+$', + title="Console resolution for VNC, for example 1024x768", + pattern=r"^[0-9]+x[0-9]+$", ) - extra_hosts: Optional[str] = Field(None, title='Docker extra hosts (added to /etc/hosts)') - extra_volumes: Optional[List[str]] = Field( - None, title='Additional directories to make persistent' - ) - custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters') + extra_hosts: Optional[str] = Field(None, title="Docker extra hosts (added to /etc/hosts)") + extra_volumes: Optional[List[str]] = Field(None, title="Additional directories to make persistent") + custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title="Custom adapters") extra_configs: Optional[List[ExtraConfig]] = Field( - None, title='Configuration files injected into the container (bind-mounted read-only)' + None, title="Configuration files injected into the container (bind-mounted read-only)" ) class IouPropertiesV8(BaseModel): """IOU template properties (v8)""" - name: Optional[str] = Field(None, title='Name of the template') - category: Optional[Category] = Field(None, title='Category of the template') - default_name_format: Optional[str] = Field(None, title='Default name format') - usage: Optional[str] = Field(None, title='How to use the template') - symbol: Optional[str] = Field(None, title='Symbol of the template') - ethernet_adapters: Optional[int] = Field(None, title='Number of ethernet adapters') - serial_adapters: Optional[int] = Field(None, title='Number of serial adapters') - ram: Optional[int] = Field(None, title='Host RAM') - nvram: Optional[int] = Field(None, title='Host NVRAM') - startup_config: Optional[str] = Field(None, title='Config loaded at startup') + name: Optional[str] = Field(None, title="Name of the template") + category: Optional[Category] = Field(None, title="Category of the template") + default_name_format: Optional[str] = Field(None, title="Default name format") + usage: Optional[str] = Field(None, title="How to use the template") + symbol: Optional[str] = Field(None, title="Symbol of the template") + ethernet_adapters: Optional[int] = Field(None, title="Number of ethernet adapters") + serial_adapters: Optional[int] = Field(None, title="Number of serial adapters") + ram: Optional[int] = Field(None, title="Host RAM") + nvram: Optional[int] = Field(None, title="Host NVRAM") + startup_config: Optional[str] = Field(None, title="Config loaded at startup") class DynamipsPropertiesV8(BaseModel): """Dynamips template properties (v8)""" - name: Optional[str] = Field(None, title='Name of the template') - category: Optional[Category] = Field(None, title='Category of the template') - default_name_format: Optional[str] = Field(None, title='Default name format') - usage: Optional[str] = Field(None, title='How to use the template') - symbol: Optional[str] = Field(None, title='Symbol of the template') - chassis: Optional[DynamipsChassis] = Field(None, title='Chassis type') - platform: Optional[DynamipsPlatform] = Field(None, title='Platform type') - ram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title='Amount of ram') - nvram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title='Amount of nvram') - idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}') - startup_config: Optional[str] = Field(None, title='Config loaded at startup') + name: Optional[str] = Field(None, title="Name of the template") + category: Optional[Category] = Field(None, title="Category of the template") + default_name_format: Optional[str] = Field(None, title="Default name format") + usage: Optional[str] = Field(None, title="How to use the template") + symbol: Optional[str] = Field(None, title="Symbol of the template") + chassis: Optional[DynamipsChassis] = Field(None, title="Chassis type") + platform: Optional[DynamipsPlatform] = Field(None, title="Platform type") + ram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title="Amount of ram") + nvram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title="Amount of nvram") + idlepc: Optional[str] = Field(None, pattern=r"^0x[0-9a-f]{8}") + startup_config: Optional[str] = Field(None, title="Config loaded at startup") wic0: Optional[str] = Field(None) wic1: Optional[str] = Field(None) wic2: Optional[str] = Field(None) @@ -533,47 +551,51 @@ class DynamipsPropertiesV8(BaseModel): class QemuPropertiesV8(BaseModel): """Qemu template properties (v8)""" - name: Optional[str] = Field(None, title='Name of the template') - category: Optional[Category] = Field(None, title='Category of the template') - default_name_format: Optional[str] = Field(None, title='Default name format') - usage: Optional[str] = Field(None, title='How to use the template') - symbol: Optional[str] = Field(None, title='Symbol of the template') - adapter_type: Optional[QemuAdapterType] = Field(None, title='Type of network adapter') - adapters: Optional[int] = Field(None, title='Number of adapters') - custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters') - first_port_name: Optional[str] = Field( - None, title='Optional name of the first networking port example: eth0' - ) - port_name_format: Optional[str] = Field( - None, title='Optional formating of the networking port example: eth{0}' - ) + name: Optional[str] = Field(None, title="Name of the template") + category: Optional[Category] = Field(None, title="Category of the template") + default_name_format: Optional[str] = Field(None, title="Default name format") + usage: Optional[str] = Field(None, title="How to use the template") + symbol: Optional[str] = Field(None, title="Symbol of the template") + adapter_type: Optional[QemuAdapterType] = Field(None, title="Type of network adapter") + adapters: Optional[int] = Field(None, title="Number of adapters") + custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title="Custom adapters") + first_port_name: Optional[str] = Field(None, title="Optional name of the first networking port example: eth0") + port_name_format: Optional[str] = Field(None, title="Optional formating of the networking port example: eth{0}") port_segment_size: Optional[int] = Field( None, - title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2', + title="Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2", ) linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes") - ram: Optional[int] = Field(None, title='Ram allocated to the appliance (MB)') - cpus: Optional[int] = Field(None, title='Number of Virtual CPU') - hda_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hda_disk_image') - hdb_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdb_disk_image') - hdc_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdc_disk_image') - hdd_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdd_disk_image') - platform: Optional[QemuPlatform] = Field(None, title='Platform to emulate') + ram: Optional[int] = Field(None, title="Ram allocated to the appliance (MB)") + cpus: Optional[int] = Field(None, title="Number of Virtual CPU") + hda_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hda_disk_image" + ) + hdb_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hdb_disk_image" + ) + hdc_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hdc_disk_image" + ) + hdd_disk_interface: Optional[QemuDiskInterface] = Field( + None, title="Disk interface for the installed hdd_disk_image" + ) + platform: Optional[QemuPlatform] = Field(None, title="Platform to emulate") console_type: Optional[QemuConsoleType] = Field( - None, title='Type of console connection for the administration of the appliance' + None, title="Type of console connection for the administration of the appliance" ) boot_priority: Optional[QemuBootPriority] = Field( None, - title='Optional define the disk boot priory. Refer to -boot option in qemu manual for more details.', + title="Optional define the disk boot priory. Refer to -boot option in qemu manual for more details.", ) - kernel_command_line: Optional[str] = Field(None, title='Command line parameters send to the kernel') - kvm: Optional[Kvm] = Field(None, title='KVM requirements') - options: Optional[str] = Field(None, title='Optional additional qemu command line options') - cpu_throttling: Optional[Annotated[int, Field(ge=0, le=800)]] = Field(None, title='Throttle the CPU') - tpm: Optional[bool] = Field(None, title='Enable the Trusted Platform Module (TPM)') - uefi: Optional[bool] = Field(None, title='Enable the UEFI boot mode') - on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed') - process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU') + kernel_command_line: Optional[str] = Field(None, title="Command line parameters send to the kernel") + kvm: Optional[Kvm] = Field(None, title="KVM requirements") + options: Optional[str] = Field(None, title="Optional additional qemu command line options") + cpu_throttling: Optional[Annotated[int, Field(ge=0, le=800)]] = Field(None, title="Throttle the CPU") + tpm: Optional[bool] = Field(None, title="Enable the Trusted Platform Module (TPM)") + uefi: Optional[bool] = Field(None, title="Enable the UEFI boot mode") + on_close: Optional[QemuOnClose] = Field(None, title="Action to execute on the VM is closed") + process_priority: Optional[QemuProcessPriority] = Field(None, title="Process priority for QEMU") _V8_PROPERTIES_MODELS = { @@ -587,16 +609,15 @@ _V8_PROPERTIES_MODELS = { class TemplateSetting(BaseModel): """Emulator settings configuration (v8)""" - name: Optional[str] = Field(None, title='Name of the settings set') - default: Optional[bool] = Field(None, title='Whether these are the default settings') - inherit_default_properties: Optional[bool] = Field(True, title='Whether the default properties should be used') - template_type: TemplateType = Field(..., title='Type of emulator properties') + name: Optional[str] = Field(None, title="Name of the settings set") + default: Optional[bool] = Field(None, title="Whether these are the default settings") + inherit_default_properties: Optional[bool] = Field(True, title="Whether the default properties should be used") + template_type: TemplateType = Field(..., title="Type of emulator properties") template_properties: Union[QemuPropertiesV8, DynamipsPropertiesV8, IouPropertiesV8, DockerPropertiesV8] = Field( - ..., - title='Properties for the template' + ..., title="Properties for the template" ) - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def _validate_template_properties(cls, data): """ @@ -621,68 +642,76 @@ class TemplateSetting(BaseModel): class ApplianceVersionV8(BaseModel): """Appliance version definition (v8)""" - name: str = Field(..., title='Name of the version') - settings: Optional[str] = Field(None, title='Template settings to use to run the version') - idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}') - category: Optional[Category] = Field(None, title='Category of the version') - installation_instructions: Optional[str] = Field(None, title='Optional installation instructions for the version') - usage: Optional[str] = Field(None, title='Optional instructions about using the version') - default_username: Optional[str] = Field(None, title='Default username for the version') - default_password: Optional[str] = Field(None, title='Default password for the version') - symbol: Optional[str] = Field(None, title='An optional symbol for the version') - images: Optional[ApplianceVersionImages] = Field(None, title='Images used for this version') + name: str = Field(..., title="Name of the version") + settings: Optional[str] = Field(None, title="Template settings to use to run the version") + idlepc: Optional[str] = Field(None, pattern=r"^0x[0-9a-f]{8}") + category: Optional[Category] = Field(None, title="Category of the version") + installation_instructions: Optional[str] = Field(None, title="Optional installation instructions for the version") + usage: Optional[str] = Field(None, title="Optional instructions about using the version") + default_username: Optional[str] = Field(None, title="Default username for the version") + default_password: Optional[str] = Field(None, title="Default password for the version") + symbol: Optional[str] = Field(None, title="An optional symbol for the version") + images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version") # ============================================================================ # Child Models with Discriminated Union # ============================================================================ + class ApplianceV1_6(BaseModel): """GNS3 Appliance model for registry versions 1-6""" - registry_version: Literal[1, 2, 3, 4, 5, 6] = Field(..., title='Version of the registry compatible with this appliance') - appliance_id: UUID = Field(..., title='Appliance ID') - name: str = Field(..., title='Appliance name') - builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not') - category: Category = Field(..., title='Category of the appliance') - description: str = Field(..., title='Description of the appliance. Could be a marketing description') - vendor_name: str = Field(..., title='Name of the vendor') - vendor_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(None, title='Website of the vendor') - documentation_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( - None, - title='An optional documentation for using the appliance on vendor website' + registry_version: Literal[1, 2, 3, 4, 5, 6] = Field( + ..., title="Version of the registry compatible with this appliance" ) - product_name: str = Field(..., title='Product name') - product_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(None, title='An optional product url on vendor website') - status: Status = Field(..., title='Document if the appliance is working or not') + appliance_id: UUID = Field(..., title="Appliance ID") + name: str = Field(..., title="Appliance name") + builtin: Optional[bool] = Field(None, title="Whether the appliance is builtin or not") + category: Category = Field(..., title="Category of the appliance") + description: str = Field(..., title="Description of the appliance. Could be a marketing description") + vendor_name: str = Field(..., title="Name of the vendor") + vendor_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( + None, title="Website of the vendor" + ) + documentation_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( + None, title="An optional documentation for using the appliance on vendor website" + ) + product_name: str = Field(..., title="Product name") + product_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( + None, title="An optional product url on vendor website" + ) + status: Status = Field(..., title="Document if the appliance is working or not") availability: Optional[Availability] = Field( None, - title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly', + title="About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly", ) - maintainer: str = Field(..., title='Maintainer name') - maintainer_email: Optional[Union[EmailStr, Annotated[str, Field(max_length=0)]]] = Field(None, title='Maintainer email') - usage: Optional[str] = Field(None, title='How to use the appliance') - symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') + maintainer: str = Field(..., title="Maintainer name") + maintainer_email: Optional[Union[EmailStr, Annotated[str, Field(max_length=0)]]] = Field( + None, title="Maintainer email" + ) + usage: Optional[str] = Field(None, title="How to use the appliance") + symbol: Optional[str] = Field(None, title="An optional symbol for the appliance") netmiko_device_type: Optional[str] = Field( - None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$' + None, title="Device type for Netmiko-based automation tools", pattern=r"^[a-z0-9_]+$|^$" ) - first_port_name: Optional[str] = Field(None, title='Optional name of the first networking port example: eth0') - port_name_format: Optional[str] = Field(None, title='Optional formating of the networking port example: eth{0}') + first_port_name: Optional[str] = Field(None, title="Optional name of the first networking port example: eth0") + port_name_format: Optional[str] = Field(None, title="Optional formating of the networking port example: eth{0}") port_segment_size: Optional[int] = Field( None, - title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2', + title="Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2", ) custom_adapters: Optional[List[CustomAdapterItem]] = Field( - None, title='Optional per-adapter overrides (port name, adapter type, MAC address)' + None, title="Optional per-adapter overrides (port name, adapter type, MAC address)" ) linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes") - docker: Optional[Docker] = Field(None, title='Docker specific options') - iou: Optional[Iou] = Field(None, title='IOU specific options') - dynamips: Optional[Dynamips] = Field(None, title='Dynamips specific options') - qemu: Optional[Qemu] = Field(None, title='Qemu specific options') - tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance') - images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance') - versions: Optional[List[ApplianceVersion]] = Field(None, title='Versions of the appliance') + docker: Optional[Docker] = Field(None, title="Docker specific options") + iou: Optional[Iou] = Field(None, title="IOU specific options") + dynamips: Optional[Dynamips] = Field(None, title="Dynamips specific options") + qemu: Optional[Qemu] = Field(None, title="Qemu specific options") + tags: Optional[List[str]] = Field(None, title="User-defined metadata tags for the appliance") + images: Optional[List[ApplianceImage]] = Field(None, title="Images for this appliance") + versions: Optional[List[ApplianceVersion]] = Field(None, title="Versions of the appliance") class ApplianceV8(BaseModel): @@ -690,42 +719,44 @@ class ApplianceV8(BaseModel): registry_version: Literal[8] = Field( ..., - title='Version of the registry compatible with this appliance (version >=8 introduced breaking changes)', + title="Version of the registry compatible with this appliance (version >=8 introduced breaking changes)", ) appliance_id: str = Field( ..., - title='Appliance ID', - pattern=r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$', + title="Appliance ID", + pattern=r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", ) - name: str = Field(..., title='Appliance name') - builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not') - category: Category = Field(..., title='Category of the appliance') - description: str = Field(..., title='Description of the appliance. Could be a marketing description') - vendor_name: str = Field(..., title='Name of the vendor') - vendor_url: AnyUrl = Field(..., title='Website of the vendor') - vendor_logo_url: Optional[AnyUrl] = Field(None, title='Link to the vendor logo (used by the GNS3 marketplace)') - documentation_url: Optional[AnyUrl] = Field(None, title='An optional documentation for using the appliance on vendor website') - product_name: str = Field(..., title='Product name') - product_url: Optional[AnyUrl] = Field(None, title='An optional product url on vendor website') - status: Status = Field(..., title='Document if the appliance is working or not') + name: str = Field(..., title="Appliance name") + builtin: Optional[bool] = Field(None, title="Whether the appliance is builtin or not") + category: Category = Field(..., title="Category of the appliance") + description: str = Field(..., title="Description of the appliance. Could be a marketing description") + vendor_name: str = Field(..., title="Name of the vendor") + vendor_url: AnyUrl = Field(..., title="Website of the vendor") + vendor_logo_url: Optional[AnyUrl] = Field(None, title="Link to the vendor logo (used by the GNS3 marketplace)") + documentation_url: Optional[AnyUrl] = Field( + None, title="An optional documentation for using the appliance on vendor website" + ) + product_name: str = Field(..., title="Product name") + product_url: Optional[AnyUrl] = Field(None, title="An optional product url on vendor website") + status: Status = Field(..., title="Document if the appliance is working or not") availability: Optional[Availability] = Field( None, - title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly', + title="About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly", ) - maintainer: str = Field(..., title='Maintainer name') - maintainer_email: EmailStr = Field(..., title='Maintainer email') - installation_instructions: Optional[str] = Field(None, title='Optional installation instructions') - usage: Optional[str] = Field(None, title='How to use the appliance') - default_username: Optional[str] = Field(None, title='Default username for the appliance') - default_password: Optional[str] = Field(None, title='Default password for the appliance') - symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') + maintainer: str = Field(..., title="Maintainer name") + maintainer_email: EmailStr = Field(..., title="Maintainer email") + installation_instructions: Optional[str] = Field(None, title="Optional installation instructions") + usage: Optional[str] = Field(None, title="How to use the appliance") + default_username: Optional[str] = Field(None, title="Default username for the appliance") + default_password: Optional[str] = Field(None, title="Default password for the appliance") + symbol: Optional[str] = Field(None, title="An optional symbol for the appliance") netmiko_device_type: Optional[str] = Field( - None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$' + None, title="Device type for Netmiko-based automation tools", pattern=r"^[a-z0-9_]+$|^$" ) - tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance') - settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance') - images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance') - versions: Optional[List[ApplianceVersionV8]] = Field(None, title='Versions of the appliance') + tags: Optional[List[str]] = Field(None, title="User-defined metadata tags for the appliance") + settings: List[TemplateSetting] = Field(..., title="Settings for running the appliance") + images: Optional[List[ApplianceImage]] = Field(None, title="Images for this appliance") + versions: Optional[List[ApplianceVersionV8]] = Field(None, title="Versions of the appliance") # ============================================================================ @@ -735,10 +766,10 @@ class ApplianceV8(BaseModel): # Define the discriminated union type ApplianceUnion = Annotated[ Union[ - Annotated[ApplianceV1_6, Tag('v1_6')], - Annotated[ApplianceV8, Tag('v8')], + Annotated[ApplianceV1_6, Tag("v1_6")], + Annotated[ApplianceV8, Tag("v8")], ], - Discriminator('registry_version'), + Discriminator("registry_version"), ] """ Discriminated union type supporting both registry versions 1-6 and 8. diff --git a/gns3server/schemas/controller/base.py b/gns3server/schemas/controller/base.py index 08f446998..e413dd479 100644 --- a/gns3server/schemas/controller/base.py +++ b/gns3server/schemas/controller/base.py @@ -20,6 +20,5 @@ from pydantic import BaseModel class DateTimeModelMixin(BaseModel): - created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/gns3server/schemas/controller/chat.py b/gns3server/schemas/controller/chat.py index 36f58036e..9289baf0b 100644 --- a/gns3server/schemas/controller/chat.py +++ b/gns3server/schemas/controller/chat.py @@ -40,7 +40,7 @@ class ChatRequest(BaseModel): None, description="LLM temperature parameter (NOTE: currently not used. " "Temperature is loaded from user's LLM config in database. " - "Reserved for future runtime override support.)" + "Reserved for future runtime override support.)", ) mode: Literal["text"] = Field(default="text", description="Interaction mode") @@ -49,14 +49,14 @@ class ChatResponse(BaseModel): """Chat streaming response model.""" type: Literal[ - "content", # AI text content - "tool_call", # Tool call request - "tool_start", # Tool execution started - "tool_end", # Tool execution completed - "error", # Error message - "done", # Stream ended - "heartbeat", # Keep-alive signal - "abort" # Stream aborted + "content", # AI text content + "tool_call", # Tool call request + "tool_start", # Tool execution started + "tool_end", # Tool execution completed + "error", # Error message + "done", # Stream ended + "heartbeat", # Keep-alive signal + "abort", # Stream aborted ] = Field(..., description="Response message type") content: Optional[str] = Field(None, description="Text content (for type=content)") message_id: Optional[str] = Field(None, description="Message ID") diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py index ecb4e3339..1c87d95a0 100644 --- a/gns3server/schemas/controller/computes.py +++ b/gns3server/schemas/controller/computes.py @@ -16,14 +16,7 @@ import uuid -from pydantic import ( - ConfigDict, - BaseModel, - Field, - SecretStr, - field_validator, - model_validator -) +from pydantic import ConfigDict, BaseModel, Field, SecretStr, field_validator, model_validator from typing import List, Optional, Union, Any from enum import Enum @@ -60,27 +53,22 @@ class ComputeCreate(ComputeBase): """ compute_id: Union[str, uuid.UUID] = None - model_config = ConfigDict(json_schema_extra={ - "example": { - "name": "My compute", - "host": "127.0.0.1", - "port": 3080, - "user": "user", - "password": "password" + model_config = ConfigDict( + json_schema_extra={ + "example": {"name": "My compute", "host": "127.0.0.1", "port": 3080, "user": "user", "password": "password"} } - }) + ) - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def set_default_compute_id_and_name(cls, data: Any) -> Any: if "compute_id" not in data: - data['compute_id'] = uuid.uuid5( - uuid.NAMESPACE_URL, - f"{data.get('protocol')}://{data.get('host')}:{data.get('port')}" + data["compute_id"] = uuid.uuid5( + uuid.NAMESPACE_URL, f"{data.get('protocol')}://{data.get('host')}:{data.get('port')}" ) if "name" not in data: - data['name'] = f"{data.get('protocol')}://{data.get('user', '')}@{data.get('host')}:{data.get('port')}" + data["name"] = f"{data.get('protocol')}://{data.get('user', '')}@{data.get('host')}:{data.get('port')}" return data @@ -94,12 +82,14 @@ class ComputeUpdate(ComputeBase): port: Optional[int] = Field(None, gt=0, le=65535) user: Optional[str] = None password: Optional[SecretStr] = None - model_config = ConfigDict(json_schema_extra={ - "example": { - "host": "10.0.0.1", - "port": 8080, + model_config = ConfigDict( + json_schema_extra={ + "example": { + "host": "10.0.0.1", + "port": 8080, + } } - }) + ) class Capabilities(BaseModel): @@ -165,4 +155,6 @@ class AutoIdlePC(BaseModel): platform: str = Field(..., description="Cisco platform") image: str = Field(..., description="Image path") ram: int = Field(..., description="Amount of RAM in MB") - model_config = ConfigDict(json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}}) + model_config = ConfigDict( + json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}} + ) diff --git a/gns3server/schemas/controller/images.py b/gns3server/schemas/controller/images.py index 417bc086f..ae98bcb6b 100644 --- a/gns3server/schemas/controller/images.py +++ b/gns3server/schemas/controller/images.py @@ -21,7 +21,6 @@ from .base import DateTimeModelMixin class ImageType(str, Enum): - qemu = "qemu" ios = "ios" iou = "iou" diff --git a/gns3server/schemas/controller/iou_license.py b/gns3server/schemas/controller/iou_license.py index 72c80100b..fcbfc488a 100644 --- a/gns3server/schemas/controller/iou_license.py +++ b/gns3server/schemas/controller/iou_license.py @@ -18,6 +18,5 @@ from pydantic import BaseModel, Field class IOULicense(BaseModel): - iourc_content: str = Field(..., description="Content of iourc file") license_check: bool = Field(..., description="Whether the license must be checked or not") diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index fef63f8b1..24c9f00e9 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -43,7 +43,6 @@ class LinkType(str, Enum): class LinkStyle(BaseModel): - color: Optional[str] = None width: Optional[int] = None type: Optional[int] = None @@ -63,50 +62,36 @@ class LinkBase(BaseModel): link_style: Optional[LinkStyle] = None filters: Optional[dict] = None markers: Optional[dict] = Field( - None, - description="Traffic-insight markers on this link: name → {bpf, tag, enabled}" - ) - show_filters_icon: Optional[bool] = Field( - True, - description="Show filters icon in Web UI" + None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}" ) + show_filters_icon: Optional[bool] = Field(True, description="Show filters icon in Web UI") class LinkCreate(LinkBase): - link_id: UUID = Field(default_factory=uuid4) nodes: List[LinkNode] = Field(..., min_length=2, max_length=2) class LinkUpdate(LinkBase): - pass class Link(LinkBase): - link_id: UUID project_id: Optional[UUID] = None link_type: Optional[LinkType] = None - capturing: Optional[bool] = Field( - None, - description="Read only property. True if a capture running on the link" - ) + capturing: Optional[bool] = Field(None, description="Read only property. True if a capture running on the link") capture_file_name: Optional[str] = Field( - None, - description="Read only property. The name of the capture file if a capture is running" + None, description="Read only property. The name of the capture file if a capture is running" ) capture_file_path: Optional[str] = Field( - None, - description="Read only property. The full path of the capture file if a capture is running" + None, description="Read only property. The full path of the capture file if a capture is running" ) capture_compute_id: Optional[str] = Field( - None, - description="Read only property. The compute identifier where a capture is running" + None, description="Read only property. The compute identifier where a capture is running" ) wireshark: Optional[bool] = Field( - False, - description="Read only property. True if a Web Wireshark session is active on the link" + False, description="Read only property. True if a Web Wireshark session is active on the link" ) @@ -121,6 +106,7 @@ class UDPPortInfo(BaseModel): rport: int type: str + class EthernetPortInfo(BaseModel): """ Ethernet port information. @@ -153,7 +139,7 @@ class MarkerCreate(BaseModel): None, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", max_length=32, - description='Unique marker name on the link. Auto-generated when absent.', + description="Unique marker name on the link. Auto-generated when absent.", ) bpf: str tag: Optional[int] = None @@ -226,9 +212,7 @@ class MarkerUpdate(BaseModel): description="Direction filter; 'both' or an explicit null clears it to both. Omit to keep.", ) color: Optional[str] = Field(None, description="Hex color render hint, e.g. '#ff5722'") - highlight_duration: Optional[int] = Field( - None, ge=1, description="UI highlight duration in ms; null = UI default" - ) + highlight_duration: Optional[int] = Field(None, ge=1, description="UI highlight duration in ms; null = UI default") enabled: Optional[bool] = Field(None, description="Toggle the marker on/off (instant).") @field_validator("direction", mode="before") @@ -288,5 +272,3 @@ class MarkerDefinitionCreate(BaseModel): @classmethod def _both_to_none(cls, v): return None if v == "both" else v - - diff --git a/gns3server/schemas/controller/llm_model_configs.py b/gns3server/schemas/controller/llm_model_configs.py index bcac68c1c..d5de5ee0b 100644 --- a/gns3server/schemas/controller/llm_model_configs.py +++ b/gns3server/schemas/controller/llm_model_configs.py @@ -22,7 +22,7 @@ from .base import DateTimeModelMixin # Valid model types -ModelType = Literal['text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other'] +ModelType = Literal["text", "vision", "stt", "tts", "multimodal", "embedding", "reranking", "other"] # Core model config schema (stored in config JSONB field) @@ -51,8 +51,7 @@ class LLMModelConfigData(BaseModel): "balanced", description="Context trimming strategy: conservative (60%), balanced (75%), aggressive (85%)" ) copilot_mode: Optional[str] = Field( - None, - description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + None, description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" ) # Allow extra fields for extensibility @@ -81,8 +80,7 @@ class LLMModelConfigCreate(BaseModel): "balanced", description="Context trimming strategy" ) copilot_mode: Optional[str] = Field( - None, - description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + None, description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" ) # Allow extra config fields @@ -112,14 +110,13 @@ class LLMModelConfigUpdate(BaseModel): None, description="Context trimming strategy" ) copilot_mode: Optional[str] = Field( - None, - description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + None, description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" ) # Allow extra config fields model_config = ConfigDict(extra="allow") - @field_validator('max_tokens', mode='before') + @field_validator("max_tokens", mode="before") @classmethod def validate_max_tokens(cls, v): """Handle string 'null' values for max_tokens.""" @@ -150,8 +147,7 @@ class LLMModelConfigDataWithoutSecret(BaseModel): "balanced", description="Context trimming strategy: conservative (60%), balanced (75%), aggressive (85%)" ) copilot_mode: Optional[str] = Field( - None, - description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + None, description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" ) # Allow extra fields for extensibility diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index 048ff1364..43d8ca5b4 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -157,13 +157,11 @@ class NodeBase(BaseModel): first_port_name: Optional[str] = Field(None, description="Name of the first port") custom_adapters: Optional[List[CustomAdapter]] = None tags: Optional[List[str]] = Field( - default_factory=list, - description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" + default_factory=list, description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" ) class NodeCreate(NodeBase): - node_id: UUID = Field(default_factory=uuid4) @@ -178,9 +176,9 @@ class NodeUpdate(NodeBase): class Node(NodeBase): - - template_id: Optional[UUID] = Field(None, - description="Template UUID from which the node has been created. Read only") + template_id: Optional[UUID] = Field( + None, description="Template UUID from which the node has been created. Read only" + ) project_id: Optional[UUID] = None node_directory: Optional[str] = Field(None, description="Working directory of the node. Read only") status: Optional[NodeStatus] = Field(None, description="Node status. Read only") diff --git a/gns3server/schemas/controller/pools.py b/gns3server/schemas/controller/pools.py index 0235cc1c2..d0d4f7957 100644 --- a/gns3server/schemas/controller/pools.py +++ b/gns3server/schemas/controller/pools.py @@ -23,7 +23,6 @@ from .base import DateTimeModelMixin class ResourceType(str, Enum): - project = "project" @@ -47,7 +46,6 @@ class ResourceCreate(ResourceBase): class Resource(DateTimeModelMixin, ResourceBase): - model_config = ConfigDict(from_attributes=True) @@ -76,6 +74,5 @@ class ResourcePoolUpdate(ResourcePoolBase): class ResourcePool(DateTimeModelMixin, ResourcePoolBase): - resource_pool_id: UUID model_config = ConfigDict(from_attributes=True) diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py index ab79a344a..ddfbde02e 100644 --- a/gns3server/schemas/controller/projects.py +++ b/gns3server/schemas/controller/projects.py @@ -31,13 +31,11 @@ class ProjectStatus(str, Enum): class Supplier(BaseModel): - logo: str = Field(..., description="Path to the project supplier logo") url: Optional[HttpUrl] = Field(None, description="URL to the project supplier site") class Variable(BaseModel): - name: str = Field(..., description="Variable name") value: Optional[str] = Field(None, description="Variable value") @@ -91,7 +89,6 @@ class ProjectUpdate(ProjectBase): class Project(ProjectBase): - project_id: UUID name: Optional[str] = None status: Optional[ProjectStatus] = None @@ -100,7 +97,6 @@ class Project(ProjectBase): class ProjectFile(BaseModel): - path: str = Field(..., description="File path") md5sum: str = Field(..., description="File checksum") diff --git a/gns3server/schemas/controller/rbac.py b/gns3server/schemas/controller/rbac.py index 9d44e5c45..11e0c1d6e 100644 --- a/gns3server/schemas/controller/rbac.py +++ b/gns3server/schemas/controller/rbac.py @@ -32,13 +32,11 @@ class PrivilegeBase(BaseModel): class Privilege(DateTimeModelMixin, PrivilegeBase): - privilege_id: UUID model_config = ConfigDict(from_attributes=True) class ACEType(str, Enum): - user = "user" group = "group" @@ -75,7 +73,6 @@ class ACEUpdate(ACEBase): class ACE(DateTimeModelMixin, ACEBase): - ace_id: UUID model_config = ConfigDict(from_attributes=True) @@ -106,7 +103,6 @@ class RoleUpdate(RoleBase): class Role(DateTimeModelMixin, RoleBase): - role_id: UUID is_builtin: bool privileges: List[Privilege] diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py index 73bace5bf..d64d029e1 100644 --- a/gns3server/schemas/controller/settings.py +++ b/gns3server/schemas/controller/settings.py @@ -47,11 +47,9 @@ SECRET_MASK = "**********" class ServerSettingsResponse(ServerSettings): - # plain strings instead of FilePath/DirectoryPath: paths are validated when # the settings are loaded or updated, not when echoed back to the client - secrets_dir: Optional[str] = Field( - None, description="Directory where secrets are stored (e.g. the JWT secret key)") + secrets_dir: Optional[str] = Field(None, description="Directory where secrets are stored (e.g. the JWT secret key)") certfile: Optional[str] = Field(None, description="SSL certificate file, requires enable_ssl") certkey: Optional[str] = Field(None, description="SSL key file, requires enable_ssl") # Optional overrides: typed as plain "str = None" in the config schema, @@ -59,28 +57,30 @@ class ServerSettingsResponse(ServerSettings): resources_path: Optional[str] = Field( None, description="Path where files like built-in appliances and Docker resources are stored " - "(defaults to the local user data directory)") + "(defaults to the local user data directory)", + ) default_nat_interface: Optional[str] = Field( - None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)") + None, description="Interface used by the NAT node, default is virbr0 on Linux (requires libvirt)" + ) class ControllerSettingsResponse(ControllerSettings): - # never serialized: managed via the secrets directory, not the configuration file jwt_secret_key: Optional[str] = Field( - default=None, exclude=True, + default=None, + exclude=True, description="Secret key used to sign the JWT authentication tokens " - "(normally managed via the secrets directory, not the configuration file)") + "(normally managed via the secrets directory, not the configuration file)", + ) class IOUSettingsResponse(IOUSettings): - iourc_path: Optional[str] = Field( - None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided") + None, description="Path of your .iourc file, the file is searched in $HOME/.iourc if not provided" + ) class SettingsResponse(BaseModel): - Server: ServerSettingsResponse Controller: ControllerSettingsResponse VPCS: VPCSSettings @@ -160,14 +160,12 @@ class ControllerSettingsUpdate(BaseModel): class VPCSSettingsUpdate(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) vpcs_path: Optional[str] = None class DynamipsSettingsUpdate(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) allocate_aux_console_ports: Optional[bool] = None @@ -178,7 +176,6 @@ class DynamipsSettingsUpdate(BaseModel): class IOUSettingsUpdate(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) iourc_path: Optional[str] = None @@ -186,7 +183,6 @@ class IOUSettingsUpdate(BaseModel): class QemuSettingsUpdate(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) enable_monitor: Optional[bool] = None @@ -198,7 +194,6 @@ class QemuSettingsUpdate(BaseModel): class WebWiresharkSettingsUpdate(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) enabled: Optional[bool] = None @@ -210,7 +205,6 @@ class WebWiresharkSettingsUpdate(BaseModel): class SettingsUpdate(BaseModel): - model_config = ConfigDict(extra="forbid") Server: Optional[ServerSettingsUpdate] = None @@ -223,8 +217,7 @@ class SettingsUpdate(BaseModel): class SettingsUpdateResponse(SettingsResponse): - restart_required: List[str] = Field( default_factory=list, - description="Changed 'Section.option' settings that require a server restart to take effect" + description="Changed 'Section.option' settings that require a server restart to take effect", ) diff --git a/gns3server/schemas/controller/snapshots.py b/gns3server/schemas/controller/snapshots.py index cb6657fba..4bdd6e938 100644 --- a/gns3server/schemas/controller/snapshots.py +++ b/gns3server/schemas/controller/snapshots.py @@ -28,6 +28,7 @@ class SnapshotBase(BaseModel): name: str = Field(..., description="Name of the snapshot") description: Optional[str] = Field(None, description="Description of the snapshot") + class SnapshotCreate(SnapshotBase): """ Properties for snapshot creation. @@ -37,7 +38,6 @@ class SnapshotCreate(SnapshotBase): class Snapshot(SnapshotBase): - snapshot_id: UUID project_id: UUID name: str = Field(..., description="Name of the snapshot") diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 3c75c6b1f..b71c64772 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -43,9 +43,7 @@ class ApplianceMetadata(BaseModel): model_config = ConfigDict(extra="allow") - appliance_id: Optional[str] = Field( - None, description="ID of the appliance the template was installed from" - ) + appliance_id: Optional[str] = Field(None, description="ID of the appliance the template was installed from") description: Optional[str] = None vendor_name: Optional[str] = None vendor_url: Optional[str] = None @@ -82,12 +80,10 @@ class TemplateBase(BaseModel): pattern=r"^[a-z0-9_]+$|^$", ) tags: Optional[List[str]] = Field( - default_factory=list, - description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" + default_factory=list, description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" ) appliance_metadata: Optional[ApplianceMetadata] = Field( - None, - description="Metadata inherited from the appliance the template was installed from" + None, description="Metadata inherited from the appliance the template was installed from" ) @@ -106,7 +102,6 @@ class TemplateUpdate(TemplateBase): class Template(DateTimeModelMixin, TemplateBase): - template_id: UUID name: str category: Category @@ -117,7 +112,6 @@ class Template(DateTimeModelMixin, TemplateBase): class TemplateUsage(BaseModel): - x: int y: int name: Optional[str] = Field(None, description="Use this name to create a new node") diff --git a/gns3server/schemas/controller/templates/cloud_templates.py b/gns3server/schemas/controller/templates/cloud_templates.py index 1754911a2..e9ec0f6bf 100644 --- a/gns3server/schemas/controller/templates/cloud_templates.py +++ b/gns3server/schemas/controller/templates/cloud_templates.py @@ -16,19 +16,13 @@ from . import Category, TemplateBase -from gns3server.schemas.compute.cloud_nodes import ( - EthernetPort, - TAPPort, - UDPPort, - CloudConsoleType -) +from gns3server.schemas.compute.cloud_nodes import EthernetPort, TAPPort, UDPPort, CloudConsoleType from pydantic import Field from typing import Optional, Union, List class CloudTemplate(TemplateBase): - category: Optional[Category] = Category.guest default_name_format: Optional[str] = "Cloud{0}" symbol: Optional[str] = "cloud" @@ -40,5 +34,4 @@ class CloudTemplate(TemplateBase): class CloudTemplateUpdate(CloudTemplate): - pass diff --git a/gns3server/schemas/controller/templates/docker_templates.py b/gns3server/schemas/controller/templates/docker_templates.py index 3d7688cbb..1c7a320e3 100644 --- a/gns3server/schemas/controller/templates/docker_templates.py +++ b/gns3server/schemas/controller/templates/docker_templates.py @@ -23,13 +23,14 @@ from typing import Optional, List class DockerTemplate(TemplateBase): - category: Optional[Category] = Category.guest default_name_format: Optional[str] = "{name}-{0}" symbol: Optional[str] = "docker_guest" image: str = Field(..., description="Docker image name") adapters: Optional[int] = Field(1, ge=0, le=100, description="Number of adapters") - mac_address: Optional[str] = Field("", description="Base MAC address", pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$|^$") + mac_address: Optional[str] = Field( + "", description="Base MAC address", pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$|^$" + ) start_command: Optional[str] = Field("", description="Docker CMD entry") environment: Optional[str] = Field("", description="Docker environment variables") console_type: Optional[ConsoleType] = Field(ConsoleType.telnet, description="Console type") @@ -49,12 +50,13 @@ class DockerTemplate(TemplateBase): ) extra_hosts: Optional[str] = Field("", description="Docker extra hosts (added to /etc/hosts)") extra_volumes: Optional[List] = Field([], description="Additional directories to make persistent") - extra_configs: Optional[List[ExtraConfig]] = Field(default_factory=list, description="Configuration files injected into the container (bind-mounted read-only)") + extra_configs: Optional[List[ExtraConfig]] = Field( + default_factory=list, description="Configuration files injected into the container (bind-mounted read-only)" + ) memory: Optional[int] = Field(0, ge=0, description="Maximum amount of memory the container can use in MB") cpus: Optional[float] = Field(0, ge=0, description="Maximum amount of CPU resources the container can use") custom_adapters: Optional[List[CustomAdapter]] = Field(default_factory=list, description="Custom adapters") class DockerTemplateUpdate(DockerTemplate): - - image: Optional[str] = Field(None, description="Docker image name") \ No newline at end of file + image: Optional[str] = Field(None, description="Docker image name") diff --git a/gns3server/schemas/controller/templates/dynamips_templates.py b/gns3server/schemas/controller/templates/dynamips_templates.py index 86a359cde..90180d99b 100644 --- a/gns3server/schemas/controller/templates/dynamips_templates.py +++ b/gns3server/schemas/controller/templates/dynamips_templates.py @@ -31,7 +31,6 @@ from enum import Enum class DynamipsTemplate(TemplateBase): - category: Optional[Category] = Category.router default_name_format: Optional[str] = "R{0}" symbol: Optional[str] = "router" @@ -69,7 +68,6 @@ class DynamipsTemplate(TemplateBase): class C7200DynamipsTemplate(DynamipsTemplate): - ram: Optional[int] = Field(512, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(512, gt=0, description="Amount of NVRAM in KB") npe: Optional[DynamipsNPE] = Field(DynamipsNPE.npe_400, description="NPE model") @@ -78,13 +76,11 @@ class C7200DynamipsTemplate(DynamipsTemplate): class C7200DynamipsTemplateUpdate(C7200DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") class C3725DynamipsTemplate(DynamipsTemplate): - ram: Optional[int] = Field(128, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(256, gt=0, description="Amount of NVRAM in KB") iomem: Optional[int] = Field(5, ge=0, le=100, description="I/O memory percentage") @@ -92,13 +88,11 @@ class C3725DynamipsTemplate(DynamipsTemplate): class C3725DynamipsTemplateUpdate(C3725DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") class C3745DynamipsTemplate(DynamipsTemplate): - ram: Optional[int] = Field(256, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(256, gt=0, description="Amount of NVRAM in KB") iomem: Optional[int] = Field(5, ge=0, le=100, description="I/O memory percentage") @@ -106,20 +100,17 @@ class C3745DynamipsTemplate(DynamipsTemplate): class C3745DynamipsTemplateUpdate(C3745DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") class C3600ChassisType(str, Enum): - chassis_3620 = "3620" chassis_3640 = "3640" chassis_3660 = "3660" class C3600DynamipsTemplate(DynamipsTemplate): - chassis: Optional[C3600ChassisType] = Field("c3660", description="Chassis type") ram: Optional[int] = Field(192, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(128, gt=0, description="Amount of NVRAM in KB") @@ -128,13 +119,11 @@ class C3600DynamipsTemplate(DynamipsTemplate): class C3600DynamipsTemplateUpdate(C3600DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") class C2691DynamipsTemplate(DynamipsTemplate): - ram: Optional[int] = Field(192, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(256, gt=0, description="Amount of NVRAM in KB") iomem: Optional[int] = Field(5, ge=0, le=100, description="I/O memory percentage") @@ -142,13 +131,11 @@ class C2691DynamipsTemplate(DynamipsTemplate): class C2691DynamipsTemplateUpdate(C2691DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") class C2600ChassisType(str, Enum): - chassis_2610 = "2610" chassis_2620 = "2620" chassis_2610xm = "2610XM" @@ -161,7 +148,6 @@ class C2600ChassisType(str, Enum): class C2600DynamipsTemplate(DynamipsTemplate): - chassis: Optional[C2600ChassisType] = Field("2651XM", description="Chassis type") ram: Optional[int] = Field(160, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(128, gt=0, description="Amount of NVRAM in KB") @@ -170,13 +156,11 @@ class C2600DynamipsTemplate(DynamipsTemplate): class C2600DynamipsTemplateUpdate(C2600DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") class C1700ChassisType(str, Enum): - chassis_1720 = "1720" chassis_1721 = "1721" chassis_1750 = "1750" @@ -185,7 +169,6 @@ class C1700ChassisType(str, Enum): class C1700DynamipsTemplate(DynamipsTemplate): - chassis: Optional[C1700ChassisType] = Field("1760", description="Chassis type") ram: Optional[int] = Field(160, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(128, gt=0, description="Amount of NVRAM in KB") @@ -194,6 +177,5 @@ class C1700DynamipsTemplate(DynamipsTemplate): class C1700DynamipsTemplateUpdate(C1700DynamipsTemplate): - platform: Optional[DynamipsPlatform] = Field(None, description="Cisco router platform") image: Optional[str] = Field(None, description="Path to the IOS image") diff --git a/gns3server/schemas/controller/templates/ethernet_hub_templates.py b/gns3server/schemas/controller/templates/ethernet_hub_templates.py index 23b04241b..a5b675937 100644 --- a/gns3server/schemas/controller/templates/ethernet_hub_templates.py +++ b/gns3server/schemas/controller/templates/ethernet_hub_templates.py @@ -34,7 +34,6 @@ DEFAULT_PORTS = [ class EthernetHubTemplate(TemplateBase): - category: Optional[Category] = Category.switch default_name_format: Optional[str] = "Hub{0}" symbol: Optional[str] = "hub" @@ -42,5 +41,4 @@ class EthernetHubTemplate(TemplateBase): class EthernetHubTemplateUpdate(EthernetHubTemplate): - pass diff --git a/gns3server/schemas/controller/templates/ethernet_switch_templates.py b/gns3server/schemas/controller/templates/ethernet_switch_templates.py index f4e83a0ac..5e6f81f03 100644 --- a/gns3server/schemas/controller/templates/ethernet_switch_templates.py +++ b/gns3server/schemas/controller/templates/ethernet_switch_templates.py @@ -44,7 +44,6 @@ class ConsoleType(str, Enum): class EthernetSwitchTemplate(TemplateBase): - category: Optional[Category] = Category.switch default_name_format: Optional[str] = "Switch{0}" symbol: Optional[str] = "ethernet_switch" @@ -53,5 +52,4 @@ class EthernetSwitchTemplate(TemplateBase): class EthernetSwitchTemplateUpdate(EthernetSwitchTemplate): - pass diff --git a/gns3server/schemas/controller/templates/iou_templates.py b/gns3server/schemas/controller/templates/iou_templates.py index c0a8d398b..02ab06696 100644 --- a/gns3server/schemas/controller/templates/iou_templates.py +++ b/gns3server/schemas/controller/templates/iou_templates.py @@ -23,7 +23,6 @@ from typing import Optional class IOUTemplate(TemplateBase): - category: Optional[Category] = Category.router default_name_format: Optional[str] = "IOU{0}" symbol: Optional[str] = "multilayer_switch" @@ -46,5 +45,4 @@ class IOUTemplate(TemplateBase): class IOUTemplateUpdate(IOUTemplate): - path: Optional[str] = Field(None, description="Path of IOU executable") diff --git a/gns3server/schemas/controller/templates/qemu_templates.py b/gns3server/schemas/controller/templates/qemu_templates.py index 13abaf40d..116dfc33c 100644 --- a/gns3server/schemas/controller/templates/qemu_templates.py +++ b/gns3server/schemas/controller/templates/qemu_templates.py @@ -32,7 +32,6 @@ from typing import Optional, List class QemuTemplate(TemplateBase): - category: Optional[Category] = Category.guest default_name_format: Optional[str] = "{name}-{0}" symbol: Optional[str] = "qemu_guest" @@ -62,13 +61,21 @@ class QemuTemplate(TemplateBase): aux_type: Optional[QemuConsoleType] = Field(QemuConsoleType.none, description="Auxiliary console type") boot_priority: Optional[QemuBootPriority] = Field(QemuBootPriority.c, description="QEMU boot priority") hda_disk_image: Optional[str] = Field("", description="QEMU hda disk image path") - hda_disk_interface: Optional[QemuDiskInterfaceType] = Field(QemuDiskInterfaceType.none, description="QEMU hda interface") + hda_disk_interface: Optional[QemuDiskInterfaceType] = Field( + QemuDiskInterfaceType.none, description="QEMU hda interface" + ) hdb_disk_image: Optional[str] = Field("", description="QEMU hdb disk image path") - hdb_disk_interface: Optional[QemuDiskInterfaceType] = Field(QemuDiskInterfaceType.none, description="QEMU hdb interface") + hdb_disk_interface: Optional[QemuDiskInterfaceType] = Field( + QemuDiskInterfaceType.none, description="QEMU hdb interface" + ) hdc_disk_image: Optional[str] = Field("", description="QEMU hdc disk image path") - hdc_disk_interface: Optional[QemuDiskInterfaceType] = Field(QemuDiskInterfaceType.none, description="QEMU hdc interface") + hdc_disk_interface: Optional[QemuDiskInterfaceType] = Field( + QemuDiskInterfaceType.none, description="QEMU hdc interface" + ) hdd_disk_image: Optional[str] = Field("", description="QEMU hdd disk image path") - hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field(QemuDiskInterfaceType.none, description="QEMU hdd interface") + hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field( + QemuDiskInterfaceType.none, description="QEMU hdd interface" + ) cdrom_image: Optional[str] = Field("", description="QEMU cdrom image path") initrd: Optional[str] = Field("", description="QEMU initrd path") kernel_image: Optional[str] = Field("", description="QEMU kernel image path") @@ -82,13 +89,16 @@ class QemuTemplate(TemplateBase): ) tpm: Optional[bool] = Field(False, description="Enable Trusted Platform Module (TPM)") uefi: Optional[bool] = Field(False, description="Enable UEFI boot mode") - on_close: Optional[QemuOnCloseAction] = Field(QemuOnCloseAction.power_off, description="Action to execute on the VM is closed") + on_close: Optional[QemuOnCloseAction] = Field( + QemuOnCloseAction.power_off, description="Action to execute on the VM is closed" + ) cpu_throttling: Optional[int] = Field(0, ge=0, le=800, description="Percentage of CPU allowed for QEMU") - process_priority: Optional[QemuProcessPriority] = Field(QemuProcessPriority.normal, description="Process priority for QEMU") + process_priority: Optional[QemuProcessPriority] = Field( + QemuProcessPriority.normal, description="Process priority for QEMU" + ) options: Optional[str] = Field("", description="Additional QEMU options") custom_adapters: Optional[List[CustomAdapter]] = Field(default_factory=list, description="Custom adapters") class QemuTemplateUpdate(QemuTemplate): - pass diff --git a/gns3server/schemas/controller/templates/virtualbox_templates.py b/gns3server/schemas/controller/templates/virtualbox_templates.py index 922cae6b7..3cce1cd6e 100644 --- a/gns3server/schemas/controller/templates/virtualbox_templates.py +++ b/gns3server/schemas/controller/templates/virtualbox_templates.py @@ -19,7 +19,7 @@ from gns3server.schemas.compute.virtualbox_nodes import ( VirtualBoxConsoleType, VirtualBoxAdapterType, VirtualBoxOnCloseAction, - CustomAdapter + CustomAdapter, ) from pydantic import Field @@ -27,7 +27,6 @@ from typing import Optional, List class VirtualBoxTemplate(TemplateBase): - category: Optional[Category] = Category.guest default_name_format: Optional[str] = "{name}-{0}" symbol: Optional[str] = "vbox_guest" @@ -38,7 +37,8 @@ class VirtualBoxTemplate(TemplateBase): 1, ge=0, le=36, description="Number of adapters" ) # 36 is the maximum given by the ICH9 chipset in VirtualBox use_any_adapter: Optional[bool] = Field(False, description="Allow GNS3 to use any VirtualBox adapter") - adapter_type: Optional[VirtualBoxAdapterType] = Field(VirtualBoxAdapterType.intel_pro_1000_mt_desktop, description="VirtualBox adapter type" + adapter_type: Optional[VirtualBoxAdapterType] = Field( + VirtualBoxAdapterType.intel_pro_1000_mt_desktop, description="VirtualBox adapter type" ) first_port_name: Optional[str] = Field("", description="Optional name of the first networking port example: eth0") port_name_format: Optional[str] = Field( @@ -60,5 +60,4 @@ class VirtualBoxTemplate(TemplateBase): class VirtualBoxTemplateUpdate(VirtualBoxTemplate): - vmname: Optional[str] = Field(None, description="VirtualBox VM name (in VirtualBox itself)") diff --git a/gns3server/schemas/controller/templates/vmware_templates.py b/gns3server/schemas/controller/templates/vmware_templates.py index 555e51896..4aed1ee90 100644 --- a/gns3server/schemas/controller/templates/vmware_templates.py +++ b/gns3server/schemas/controller/templates/vmware_templates.py @@ -20,7 +20,7 @@ from gns3server.schemas.compute.vmware_nodes import ( VMwareConsoleType, VMwareAdapterType, VMwareOnCloseAction, - CustomAdapter + CustomAdapter, ) from pydantic import Field @@ -28,7 +28,6 @@ from typing import Optional, List class VMwareTemplate(TemplateBase): - category: Optional[Category] = Category.guest default_name_format: Optional[str] = "{name}-{0}" symbol: Optional[str] = "vmware_guest" @@ -48,7 +47,9 @@ class VMwareTemplate(TemplateBase): adapter_type: Optional[VMwareAdapterType] = Field(VMwareAdapterType.e1000, description="VMware adapter type") use_any_adapter: Optional[bool] = Field(False, description="Allow GNS3 to use any VMware adapter") headless: Optional[bool] = Field(False, description="Headless mode") - on_close: Optional[VMwareOnCloseAction] = Field(VMwareOnCloseAction.power_off, description="Action to execute on the VM is closed") + on_close: Optional[VMwareOnCloseAction] = Field( + VMwareOnCloseAction.power_off, description="Action to execute on the VM is closed" + ) console_type: Optional[VMwareConsoleType] = Field(VMwareConsoleType.none, description="Console type") console_auto_start: Optional[bool] = Field( False, description="Automatically start the console when the node has started" @@ -57,5 +58,4 @@ class VMwareTemplate(TemplateBase): class VMwareTemplateUpdate(VMwareTemplate): - vmx_path: Optional[str] = Field(None, description="Path to the vmx file") diff --git a/gns3server/schemas/controller/templates/vpcs_templates.py b/gns3server/schemas/controller/templates/vpcs_templates.py index 79d6f1c3e..e9f6e0b4f 100644 --- a/gns3server/schemas/controller/templates/vpcs_templates.py +++ b/gns3server/schemas/controller/templates/vpcs_templates.py @@ -23,7 +23,6 @@ from typing import Optional class VPCSTemplate(TemplateBase): - category: Optional[Category] = Category.guest default_name_format: Optional[str] = "PC{0}" symbol: Optional[str] = "vpcs_guest" @@ -35,5 +34,4 @@ class VPCSTemplate(TemplateBase): class VPCSTemplateUpdate(VPCSTemplate): - pass diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index 61945e257..29197acfa 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -19,14 +19,12 @@ from pydantic import BaseModel class Token(BaseModel): - access_token: str token_type: str refresh_token: Optional[str] = None class TokenData(BaseModel): - username: Optional[str] = None token_version: int = 0 token_use: str = "access" diff --git a/gns3server/schemas/controller/topology.py b/gns3server/schemas/controller/topology.py index 1b9dcfddc..26ff78b3e 100644 --- a/gns3server/schemas/controller/topology.py +++ b/gns3server/schemas/controller/topology.py @@ -33,12 +33,10 @@ from uuid import UUID class TopologyType(str, Enum): - topology = "topology" class TopologyContent(BaseModel): - computes: List[Compute] = Field(..., description="List of computes") drawings: List[Drawing] = Field(..., description="List of drawings") links: List[Link] = Field(..., description="List of links") @@ -46,7 +44,6 @@ class TopologyContent(BaseModel): class Topology(BaseModel): - project_id: UUID = Field(..., description="Project UUID") type: TopologyType = Field(..., description="Type of file. It's always topology") revision: int = Field(..., description="Version of the .gns3 specification") diff --git a/gns3server/schemas/controller/users.py b/gns3server/schemas/controller/users.py index aa4af2e38..4fed51d49 100644 --- a/gns3server/schemas/controller/users.py +++ b/gns3server/schemas/controller/users.py @@ -61,7 +61,6 @@ class LoggedInUserUpdate(BaseModel): class User(DateTimeModelMixin, UserBase): - user_id: UUID last_login: Optional[datetime] = None is_superadmin: bool = False @@ -93,13 +92,11 @@ class UserGroupUpdate(UserGroupBase): class UserGroup(DateTimeModelMixin, UserGroupBase): - user_group_id: UUID is_builtin: bool model_config = ConfigDict(from_attributes=True) class Credentials(BaseModel): - username: str password: str diff --git a/gns3server/schemas/qemu_disk_image.py b/gns3server/schemas/qemu_disk_image.py index 98007a4f0..a3ebccba6 100644 --- a/gns3server/schemas/qemu_disk_image.py +++ b/gns3server/schemas/qemu_disk_image.py @@ -78,7 +78,6 @@ class QemuDiskImageAdapterType(str, Enum): class QemuDiskImageBase(BaseModel): - format: QemuDiskImageFormat = Field(..., description="Image format type") size: int = Field(..., description="Image size in Megabytes") preallocation: Optional[QemuDiskImagePreallocation] = None @@ -92,12 +91,10 @@ class QemuDiskImageBase(BaseModel): class QemuDiskImageCreate(QemuDiskImageBase): - pass class QemuDiskImageUpdate(QemuDiskImageBase): - format: Optional[QemuDiskImageFormat] = Field(None, description="Image format type") size: Optional[int] = Field(None, description="Image size in Megabytes") extend: Optional[int] = Field(None, description="Number of Megabytes to extend the image") diff --git a/gns3server/schemas/version.py b/gns3server/schemas/version.py index f13074d5b..db4b95ed5 100644 --- a/gns3server/schemas/version.py +++ b/gns3server/schemas/version.py @@ -19,7 +19,6 @@ from typing import Optional class Version(BaseModel): - controller_host: Optional[str] = Field(None, description="Controller hostname or IP address") version: str = Field(..., description="Version number") local: Optional[bool] = Field(None, description="Whether this is a local server or not") diff --git a/gns3server/server.py b/gns3server/server.py index be2b62564..7d7eb3186 100644 --- a/gns3server/server.py +++ b/gns3server/server.py @@ -44,7 +44,6 @@ log = logging.getLogger(__name__) class Server: - _stream_handler = None @staticmethod @@ -157,6 +156,7 @@ class Server: def _signal_handling(self): from gns3server.controller import Controller + def signal_handler(signame, *args): try: @@ -245,11 +245,13 @@ class Server: log.info("Compute authentication is disabled") elif not config.Server.compute_password.get_secret_value(): alphabet = string.ascii_letters + string.digits + string.punctuation - generated_password = ''.join(secrets.choice(alphabet) for _ in range(16)) + generated_password = "".join(secrets.choice(alphabet) for _ in range(16)) config.Server.compute_password = SecretStr(generated_password) - log.warning(f"Compute authentication is enabled with username '{config.Server.compute_username}' and " - f"a randomly generated password. Please set a password in the config file if this compute " - f"is to be used by an external controller") + log.warning( + f"Compute authentication is enabled with username '{config.Server.compute_username}' and " + f"a randomly generated password. Please set a password in the config file if this compute " + f"is to be used by an external controller" + ) else: log.info(f"Compute authentication is enabled with username '{config.Server.compute_username}'") @@ -274,6 +276,7 @@ class Server: try: import truststore + truststore.inject_into_ssl() log.info("Using system certificate store for SSL connections") except ImportError: @@ -284,6 +287,7 @@ class Server: port = config.Server.port from gns3server.compute.port_manager import PortManager + PortManager.instance().console_host = host self._signal_handling() @@ -306,7 +310,7 @@ class Server: access_log=access_log, ssl_certfile=config.Server.certfile, ssl_keyfile=config.Server.certkey, - lifespan="on" + lifespan="on", ) # overwrite uvicorn loggers with our own logger diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index 90f943b99..3d165c55d 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -48,16 +48,15 @@ def _extract_alg(token: str) -> str: class AuthService: - def hash_password(self, password: str) -> str: salt = bcrypt.gensalt() - hashed_password = bcrypt.hashpw(password=password.encode('utf-8'), salt=salt) - return hashed_password.decode('utf-8') + hashed_password = bcrypt.hashpw(password=password.encode("utf-8"), salt=salt) + return hashed_password.decode("utf-8") def verify_password(self, password, hashed_password) -> bool: - return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8')) + return bcrypt.checkpw(password=password.encode("utf-8"), hashed_password=hashed_password.encode("utf-8")) def _create_token(self, username, token_version, token_type, expires_in, secret_key=None) -> str: """Shared helper to create any kind of signed JWT token.""" @@ -80,7 +79,9 @@ class AuthService: expires_in = Config.instance().settings.Controller.jwt_access_token_expire_minutes return self._create_token(username, token_version, "access", expires_in, secret_key) - def create_refresh_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str: + def create_refresh_token( + self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0 + ) -> str: if not expires_in: expires_in = Config.instance().settings.Controller.jwt_refresh_token_expire_minutes diff --git a/gns3server/services/templates.py b/gns3server/services/templates.py index 66b793566..10a167c89 100644 --- a/gns3server/services/templates.py +++ b/gns3server/services/templates.py @@ -158,11 +158,11 @@ BUILTIN_TEMPLATES = [ class TemplatesService: - def __init__(self, templates_repo: TemplatesRepository): self._templates_repo = templates_repo from gns3server.controller import Controller + self._controller = Controller.instance() # resolve built-in template symbols @@ -233,8 +233,10 @@ class TemplatesService: if await self._templates_repo.get_template_by_name_and_version(template_create.name, template_create.version): if template_create.version: - raise ControllerError(f"A template with name '{template_create.name}' and " - f"version {template_create.version} already exists") + raise ControllerError( + f"A template with name '{template_create.name}' and " + f"version {template_create.version} already exists" + ) else: raise ControllerError(f"A template with name '{template_create.name}' already exists") @@ -364,11 +366,7 @@ class TemplatesService: if not os.path.exists(path): return [] - return [ - {"filename": f} - for f in sorted(os.listdir(path)) - if os.path.isfile(os.path.join(path, f)) - ] + return [{"filename": f} for f in sorted(os.listdir(path)) if os.path.isfile(os.path.join(path, f))] def get_file(self, template_id: str, filename: str): safe_filename = os.path.basename(filename) @@ -395,4 +393,4 @@ class TemplatesService: with open(path, "w", encoding="utf-8") as f: f.write(content) except OSError as e: - raise ControllerError(str(e)) \ No newline at end of file + raise ControllerError(str(e)) diff --git a/gns3server/utils/__init__.py b/gns3server/utils/__init__.py index e00d24b75..c1e485692 100644 --- a/gns3server/utils/__init__.py +++ b/gns3server/utils/__init__.py @@ -111,6 +111,7 @@ def is_ipv6_enabled() -> bool: return True raise + def md5sum(filename): """ Calculate the MD5 checksum of a file. diff --git a/gns3server/utils/asyncio/__init__.py b/gns3server/utils/asyncio/__init__.py index 1b9e443a3..345cd77a8 100644 --- a/gns3server/utils/asyncio/__init__.py +++ b/gns3server/utils/asyncio/__init__.py @@ -73,7 +73,9 @@ async def subprocess_check_output(*args, cwd=None, env=None, stderr=False): proc = await asyncio.create_subprocess_exec(*args, stderr=asyncio.subprocess.PIPE, cwd=cwd, env=env) _, output = await proc.communicate() else: - proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, cwd=cwd, env=env) + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, cwd=cwd, env=env + ) output, _ = await proc.communicate() if output is None: return "" diff --git a/gns3server/utils/asyncio/aiozipstream.py b/gns3server/utils/asyncio/aiozipstream.py index c8f69c8e2..a6e1fc49b 100644 --- a/gns3server/utils/asyncio/aiozipstream.py +++ b/gns3server/utils/asyncio/aiozipstream.py @@ -56,20 +56,24 @@ def _get_compressor(compress_type, compresslevel=None): if compress_type == zipfile.ZIP_DEFLATED: from zipfile import zlib + if compresslevel is not None: return zlib.compressobj(compresslevel, zlib.DEFLATED, -15) return zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -15) elif compress_type == zipfile.ZIP_BZIP2: from zipfile import bz2 + if compresslevel is not None: return bz2.BZ2Compressor(compresslevel) return bz2.BZ2Compressor() # compresslevel is ignored for ZIP_LZMA elif compress_type == zipfile.ZIP_LZMA: from zipfile import LZMACompressor + return LZMACompressor() elif compress_type == ZIP_ZSTANDARD: import zstandard as zstd + if compresslevel is not None: # for threads param a value of -1 means to set the number of threads to the number of detected logical CPUs return zstd.ZstdCompressor(level=compresslevel, threads=-1).compressobj() @@ -141,13 +145,13 @@ class ZipInfo(zipfile.ZipInfo): class ZipFile(zipfile.ZipFile): def __init__( - self, - fileobj=None, - mode="w", - compression=zipfile.ZIP_STORED, - allowZip64=True, - compresslevel=None, - chunksize=32768 + self, + fileobj=None, + mode="w", + compression=zipfile.ZIP_STORED, + allowZip64=True, + compresslevel=None, + chunksize=32768, ): """Open the ZIP file with mode write "w".""" @@ -158,11 +162,7 @@ class ZipFile(zipfile.ZipFile): self._comment = b"" zipfile.ZipFile.__init__( - self, fileobj, - mode=mode, - compression=compression, - compresslevel=compresslevel, - allowZip64=allowZip64 + self, fileobj, mode=mode, compression=compression, compresslevel=compresslevel, allowZip64=allowZip64 ) self._chunksize = chunksize self.paths_to_write = [] @@ -232,7 +232,7 @@ class ZipFile(zipfile.ZipFile): "filename": filename, "arcname": arcname, "compress_type": compress_type, - "compresslevel": compresslevel + "compresslevel": compresslevel, } self.paths_to_write.append(kwargs) @@ -245,7 +245,7 @@ class ZipFile(zipfile.ZipFile): "arcname": arcname, "iterable": iterable, "compress_type": compress_type, - "compresslevel": compresslevel + "compresslevel": compresslevel, } self.paths_to_write.append(kwargs) diff --git a/gns3server/utils/asyncio/embed_shell.py b/gns3server/utils/asyncio/embed_shell.py index 907ace2ff..3efd0bdbc 100644 --- a/gns3server/utils/asyncio/embed_shell.py +++ b/gns3server/utils/asyncio/embed_shell.py @@ -114,7 +114,7 @@ class EmbedShell: if not cmd[0].strip(): return "" - for (name, meth) in inspect.getmembers(self): + for name, meth in inspect.getmembers(self): if name == cmd[0]: cmd.pop(0) res = await meth(*cmd) @@ -177,10 +177,10 @@ class UnstoppableEventLoop(EventLoop): self._loop = loop def close(self): - " Ignore. " + "Ignore." def stop(self): - " Ignore. " + "Ignore." def run_in_executor(self, *args, **kwargs): return self._loop.run_in_executor(*args, **kwargs) @@ -264,18 +264,18 @@ class ShellConnection(TelnetConnection): self.reset() def reset(self): - """ Resets terminal screen""" + """Resets terminal screen""" self._cli.reset() self._cli.buffers[DEFAULT_BUFFER].reset() self._cli.renderer.request_absolute_cursor_position() self._cli._redraw() def write(self, data): - """ Compat with CLI""" + """Compat with CLI""" self.send(data) def flush(self): - """ Compat with CLI""" + """Compat with CLI""" pass diff --git a/gns3server/utils/asyncio/input_stream.py b/gns3server/utils/asyncio/input_stream.py index e2d936e91..6a9c983c6 100644 --- a/gns3server/utils/asyncio/input_stream.py +++ b/gns3server/utils/asyncio/input_stream.py @@ -4,7 +4,6 @@ Parser for VT100 input stream. # Copied from prompt_toolkit/terminal/vt100_input.py due to dependency on termios (which is not available on Windows) - import re import six @@ -40,7 +39,7 @@ _mouse_event_prefix_re = re.compile("^" + re.escape("\x1b[") + r"( None: encryption_key_path = os.path.join(secrets_dir, "gns3_encryption_key") if not os.path.exists(encryption_key_path): - log.info( - f"No encryption key found, generating one in " - f"'{encryption_key_path}'..." - ) + log.info(f"No encryption key found, generating one in '{encryption_key_path}'...") try: key = Fernet.generate_key() os.makedirs(secrets_dir, exist_ok=True) @@ -55,26 +52,17 @@ def init_encryption(secrets_dir: str) -> None: # Set restrictive permissions (owner read/write only) os.chmod(encryption_key_path, 0o600) except OSError as e: - log.error( - "Could not create encryption key file " - f"'{encryption_key_path}': {e}" - ) + log.error(f"Could not create encryption key file '{encryption_key_path}': {e}") raise try: with open(encryption_key_path, encoding="utf-8") as f: key_content = f.read().strip() - key_bytes = ( - key_content.encode() - if isinstance(key_content, str) - else key_content - ) + key_bytes = key_content.encode() if isinstance(key_content, str) else key_content _fernet = Fernet(key_bytes) log.debug("Encryption initialized successfully") except OSError as e: - log.error( - f"Could not read encryption key file '{encryption_key_path}': {e}" - ) + log.error(f"Could not read encryption key file '{encryption_key_path}': {e}") raise @@ -88,9 +76,7 @@ def encrypt(plaintext: str) -> str: """ if _fernet is None: - raise RuntimeError( - "Encryption not initialized. Call init_encryption() first." - ) + raise RuntimeError("Encryption not initialized. Call init_encryption() first.") if not plaintext: return "" @@ -110,9 +96,7 @@ def decrypt(ciphertext: str) -> str: """ if _fernet is None: - raise RuntimeError( - "Encryption not initialized. Call init_encryption() first." - ) + raise RuntimeError("Encryption not initialized. Call init_encryption() first.") if not ciphertext: return "" @@ -139,10 +123,11 @@ def is_encrypted(value: str) -> bool: # Check for Fernet prefix and valid format try: # Fernet tokens always start with 'gAAAAA' in base64 - if not value.startswith('gAAAAA'): + if not value.startswith("gAAAAA"): return False # Attempt to decode as URL-safe base64 import base64 + decoded = base64.urlsafe_b64decode(value) # Fernet tokens have a specific format (minimum 32 bytes) return len(decoded) >= 32 diff --git a/gns3server/utils/get_resource.py b/gns3server/utils/get_resource.py index 0891e8f12..20ea3e009 100644 --- a/gns3server/utils/get_resource.py +++ b/gns3server/utils/get_resource.py @@ -26,6 +26,7 @@ except ImportError: from contextlib import ExitStack + resource_manager = ExitStack() atexit.register(resource_manager.close) diff --git a/gns3server/utils/hostname.py b/gns3server/utils/hostname.py index 828acf5e5..bb9291e3f 100644 --- a/gns3server/utils/hostname.py +++ b/gns3server/utils/hostname.py @@ -38,15 +38,15 @@ def to_ios_hostname(name): """ # Replace invalid characters with hyphens - name = re.sub(r'[^a-zA-Z0-9-]', '-', name) + name = re.sub(r"[^a-zA-Z0-9-]", "-", name) # Ensure the hostname starts with a letter - if not re.search(r'^[a-zA-Z]', name): - name = 'a' + name + if not re.search(r"^[a-zA-Z]", name): + name = "a" + name # Ensure the hostname ends with a letter or digit - if not re.search(r'[a-zA-Z0-9]$', name): - name = name.rstrip('-') + '0' + if not re.search(r"[a-zA-Z0-9]$", name): + name = name.rstrip("-") + "0" # Truncate the hostname to 63 characters name = name[:63] @@ -87,24 +87,24 @@ def to_rfc1123_hostname(name: str) -> str: """ # Replace invalid characters with hyphens - name = re.sub(r'[^a-zA-Z0-9-.]', '-', name) + name = re.sub(r"[^a-zA-Z0-9-.]", "-", name) # Remove trailing dot if it exists - name = name.rstrip('.') + name = name.rstrip(".") # Ensure each label is not longer than 63 characters - labels = name.split('.') + labels = name.split(".") labels = [label[:63] for label in labels] # Remove leading and trailing hyphens from each label if they exist - labels = [label.strip('-') for label in labels] + labels = [label.strip("-") for label in labels] # Check if the TLD is all-numeric and if so, replace it with "invalid" if re.match(r"[0-9]+$", labels[-1]): - labels[-1] = 'invalid' + labels[-1] = "invalid" # Join the labels back together - name = '.'.join(labels) + name = ".".join(labels) # Ensure the total length is not longer than 253 characters name = name[:253] diff --git a/gns3server/utils/images.py b/gns3server/utils/images.py index a1074191d..c06e22bec 100644 --- a/gns3server/utils/images.py +++ b/gns3server/utils/images.py @@ -55,7 +55,6 @@ async def list_images(image_type): default_directory = default_images_directory(image_type) for directory in images_directories(image_type): - # We limit recursion to path outside the default images directory # the reason is in the default directory manage file organization and # it should be flatten to keep things simple @@ -89,15 +88,19 @@ async def list_images(image_type): with open(os.path.join(root, filename), "rb") as f: # read the first 7 bytes of the file. elf_header_start = f.read(7) - if image_type == "dynamips" and elf_header_start != b'\x7fELF\x01\x02\x01': + if image_type == "dynamips" and elf_header_start != b"\x7fELF\x01\x02\x01": # IOS images must start with the ELF magic number, be 32-bit, big endian and have an ELF version of 1 log.warning(f"IOS image {filename} does not start with a valid ELF magic number, skipping...") continue - elif image_type == "iou" and elf_header_start != b'\x7fELF\x02\x01\x01' and elf_header_start != b'\x7fELF\x01\x01\x01': + elif ( + image_type == "iou" + and elf_header_start != b"\x7fELF\x02\x01\x01" + and elf_header_start != b"\x7fELF\x01\x01\x01" + ): # IOU images must start with the ELF magic number, be 32-bit or 64-bit, little endian and have an ELF version of 1 log.warning(f"IOU image {filename} does not start with a valid ELF magic number, skipping...") continue - elif image_type == "qemu" and elf_header_start[:4] == b'\x7fELF': + elif image_type == "qemu" and elf_header_start[:4] == b"\x7fELF": # QEMU images should not start with an ELF magic number log.warning(f"QEMU image {filename} starts with an ELF magic number, skipping...") continue @@ -117,7 +120,7 @@ async def list_images(image_type): def get_builtin_disks() -> List[str]: builtin_disks = [] - for entry in importlib_resources.files('gns3server').joinpath("disks").iterdir(): + for entry in importlib_resources.files("gns3server").joinpath("disks").iterdir(): if entry.is_file(): builtin_disks.append(entry.name) return builtin_disks @@ -132,8 +135,10 @@ async def read_image_info(path: str, expected_image_type: str = None) -> dict: if len(image_header) >= header_magic_len: detected_image_type = check_valid_image_header(path, image_header) if expected_image_type and detected_image_type != expected_image_type: - raise InvalidImageError(f"Detected image type for '{path}' is {detected_image_type}, " - f"expected type is {expected_image_type}") + raise InvalidImageError( + f"Detected image type for '{path}' is {detected_image_type}, " + f"expected type is {expected_image_type}" + ) else: raise InvalidImageError(f"Image '{path}' is too small to be valid") except OSError as e: @@ -306,7 +311,6 @@ def remove_checksum(path): class InvalidImageError(Exception): - def __init__(self, message: str): super().__init__() self._message = message @@ -317,14 +321,14 @@ class InvalidImageError(Exception): def check_valid_image_header(path: str, data: bytes, allow_raw_image: bool = False) -> str: - if data[:7] == b'\x7fELF\x01\x02\x01': + if data[:7] == b"\x7fELF\x01\x02\x01": # for IOS images: file must start with the ELF magic number, be 32-bit, big endian and have an ELF version of 1 return "ios" - elif data[:7] == b'\x7fELF\x01\x01\x01' or data[:7] == b'\x7fELF\x02\x01\x01': + elif data[:7] == b"\x7fELF\x01\x01\x01" or data[:7] == b"\x7fELF\x02\x01\x01": # for IOU images: file must start with the ELF magic number, be 32-bit or 64-bit, little endian and # have an ELF version of 1 (normal IOS images are big endian!) return "iou" - elif data[:4] == b'QFI\xfb' or data[:4] == b'KDMV': + elif data[:4] == b"QFI\xfb" or data[:4] == b"KDMV": # for Qemy images: file must be QCOW2 or VMDK return "qemu" else: @@ -334,12 +338,12 @@ def check_valid_image_header(path: str, data: bytes, allow_raw_image: bool = Fal async def write_image( - image_filename: str, - image_path: str, - stream: AsyncGenerator[bytes, None], - images_repo: ImagesRepository, - check_image_header=True, - allow_raw_image=False + image_filename: str, + image_path: str, + stream: AsyncGenerator[bytes, None], + images_repo: ImagesRepository, + check_image_header=True, + allow_raw_image=False, ) -> models.Image: image_dir, image_name = os.path.split(image_filename) @@ -370,15 +374,17 @@ async def write_image( image_path = os.path.abspath(os.path.join(directory, image_filename)) if os.path.exists(image_path): - raise InvalidImageError(f"File '{image_path}' already exists, " - f"please choose a different name or remove the existing image") + raise InvalidImageError( + f"File '{image_path}' already exists, please choose a different name or remove the existing image" + ) checksum = checksum.hexdigest() image_dir = os.path.dirname(image_path) duplicate_image = await images_repo.get_image_by_checksum(checksum, image_dir) if duplicate_image: - raise InvalidImageError(f"Image '{duplicate_image.filename}' with the " - f"same checksum already exists in '{image_dir}'") + raise InvalidImageError( + f"Image '{duplicate_image.filename}' with the same checksum already exists in '{image_dir}'" + ) shutil.move(tmp_path, image_path) os.chmod(image_path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) @@ -390,10 +396,5 @@ async def write_image( log.warning(f"Could not remove '{tmp_path}'") return await images_repo.add_image( - image_name, - image_type, - image_size, - image_path, - checksum, - checksum_algorithm="md5" + image_name, image_type, image_size, image_path, checksum, checksum_algorithm="md5" ) diff --git a/gns3server/utils/interfaces.py b/gns3server/utils/interfaces.py index 1bc8b8051..f48db2c8a 100644 --- a/gns3server/utils/interfaces.py +++ b/gns3server/utils/interfaces.py @@ -49,11 +49,11 @@ def _get_windows_interfaces_from_registry(): netcard, _ = winreg.QueryValueEx(hkeycard, "Description") connection = ( r"SYSTEM\CurrentControlSet\Control\Network\{4D36E972-E325-11CE-BFC1-08002BE10318}" - + fr"\{guid}\Connection" + + rf"\{guid}\Connection" ) hkeycon = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, connection) name, _ = winreg.QueryValueEx(hkeycon, "Name") - interface = fr"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{guid}" + interface = rf"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{guid}" hkeyinterface = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, interface) is_dhcp_enabled, _ = winreg.QueryValueEx(hkeyinterface, "EnableDHCP") if is_dhcp_enabled: @@ -160,7 +160,6 @@ def is_interface_up(interface): """ if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"): - if interface not in psutil.net_if_addrs(): return False @@ -218,13 +217,9 @@ def interfaces(): # legacy single-value behavior (keeps the last IPv4 seen) ip_address = addr.address netmask = addr.netmask - ip_addresses.append( - {"family": "ipv4", "address": addr.address, "netmask": addr.netmask or None} - ) + ip_addresses.append({"family": "ipv4", "address": addr.address, "netmask": addr.netmask or None}) elif addr.family == socket.AF_INET6: - ip_addresses.append( - {"family": "ipv6", "address": addr.address, "netmask": addr.netmask or None} - ) + ip_addresses.append({"family": "ipv6", "address": addr.address, "netmask": addr.netmask or None}) if addr.family == psutil.AF_LINK: mac_address = addr.address if interface.startswith("tap"): @@ -283,7 +278,7 @@ def interfaces(): "vmware", "virtualbox", "gns3", - "veb" + "veb", ): if result["name"].lower().startswith(special_interface): result["special"] = True diff --git a/gns3server/utils/packet_filter_validation.py b/gns3server/utils/packet_filter_validation.py index 2ba67f6f5..cc2634546 100644 --- a/gns3server/utils/packet_filter_validation.py +++ b/gns3server/utils/packet_filter_validation.py @@ -11,6 +11,7 @@ log = logging.getLogger(__name__) class FilterValidationError(Exception): """Raised when packet filter parameters fail validation.""" + pass @@ -46,7 +47,7 @@ def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]: # Strip "tcpdump: " prefix for prefix in ["tcpdump: "]: if line.startswith(prefix): - line = line[len(prefix):] + line = line[len(prefix) :] error_lines.append(line) error_msg = " ".join(error_lines) if error_lines else "Invalid BPF expression" log.warning("BPF syntax validation failed: %s", error_msg) @@ -56,10 +57,7 @@ def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]: return {"valid": True, "error": None} except FileNotFoundError: - log.warning( - "tcpdump not found, skipping BPF syntax validation. " - "Install tcpdump to enable BPF validation." - ) + log.warning("tcpdump not found, skipping BPF syntax validation. Install tcpdump to enable BPF validation.") return {"valid": True, "error": None} except Exception as e: @@ -85,31 +83,17 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None: "params_count": 1, "ranges": [(-1, 32767)], # min, max "names": ["Frequency"], - "units": ["th packet"] - }, - "packet_loss": { - "params_count": 1, - "ranges": [(0, 100)], - "names": ["Chance"], - "units": ["%"] + "units": ["th packet"], }, + "packet_loss": {"params_count": 1, "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]}, "delay": { "params_count": 2, # latency, jitter "ranges": [(1, 32767), (0, 32767)], # ubridge rejects latency <= 0 "names": ["Latency", "Jitter"], - "units": ["ms", "ms"] + "units": ["ms", "ms"], }, - "corrupt": { - "params_count": 1, - "ranges": [(0, 100)], - "names": ["Chance"], - "units": ["%"] - }, - "bpf": { - "params_count": 1, - "is_text": True, - "names": ["Filters"] - } + "corrupt": {"params_count": 1, "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]}, + "bpf": {"params_count": 1, "is_text": True, "names": ["Filters"]}, } if filter_type not in VALIDATION_RULES: @@ -119,18 +103,14 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None: # Check parameter count if len(values) != rules["params_count"]: - raise FilterValidationError( - f"{filter_type} expects {rules['params_count']} parameter(s), got {len(values)}" - ) + raise FilterValidationError(f"{filter_type} expects {rules['params_count']} parameter(s), got {len(values)}") # Validate each parameter for i, value in enumerate(values): if rules.get("is_text"): # Text validation (BPF) if not isinstance(value, str): - raise FilterValidationError( - f"{filter_type} parameter {rules['names'][i]} must be a string" - ) + raise FilterValidationError(f"{filter_type} parameter {rules['names'][i]} must be a string") # Validate BPF syntax using tshark (same method as gns3_copilot) # The value may be a multi-line string; each line becomes a @@ -251,4 +231,4 @@ def validate_all_filters(filters: Dict[str, List[Any]]) -> None: if not values or (isinstance(values, list) and len(values) == 0): continue - validate_filter_parameters(filter_type, values) \ No newline at end of file + validate_filter_parameters(filter_type, values) diff --git a/gns3server/utils/request_utils.py b/gns3server/utils/request_utils.py index aee3bdb2e..297a9e90b 100644 --- a/gns3server/utils/request_utils.py +++ b/gns3server/utils/request_utils.py @@ -73,7 +73,7 @@ def extract_client_info(scope: Dict[str, Any], auth_service_instance: Optional[A "path": path, "method": method, "username": username, - "user_info": user_info + "user_info": user_info, } diff --git a/gns3server/utils/uninstall_ai_features.py b/gns3server/utils/uninstall_ai_features.py index 44b545e25..141ff9ce7 100644 --- a/gns3server/utils/uninstall_ai_features.py +++ b/gns3server/utils/uninstall_ai_features.py @@ -15,7 +15,7 @@ import argparse def _find_base_dir(): """Find the project root directory.""" - if hasattr(sys, '_MEIPASS'): + if hasattr(sys, "_MEIPASS"): return os.path.dirname(sys.executable) return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -71,9 +71,7 @@ def uninstall(packages, yes=False): for package in packages: try: result = subprocess.run( - [sys.executable, "-m", "pip", "uninstall", "-y", package], - capture_output=True, - text=True + [sys.executable, "-m", "pip", "uninstall", "-y", package], capture_output=True, text=True ) if result.returncode == 0: print(f" Removed: {package}") @@ -87,13 +85,8 @@ def uninstall(packages, yes=False): def main(): - parser = argparse.ArgumentParser( - description="Uninstall AI Features dependencies (AI Copilot + MCP)" - ) - parser.add_argument( - "-y", "--yes", action="store_true", - help="Automatically confirm uninstallation" - ) + parser = argparse.ArgumentParser(description="Uninstall AI Features dependencies (AI Copilot + MCP)") + parser.add_argument("-y", "--yes", action="store_true", help="Automatically confirm uninstallation") args = parser.parse_args() packages = get_ai_packages() diff --git a/gns3server/utils/uuid_validator.py b/gns3server/utils/uuid_validator.py index 0ad316777..08524f4c9 100644 --- a/gns3server/utils/uuid_validator.py +++ b/gns3server/utils/uuid_validator.py @@ -7,10 +7,7 @@ import argparse import re -UUID_PATTERN = re.compile( - r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', - re.IGNORECASE -) +UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) def validate_uuid(value: str) -> str: diff --git a/gns3server/utils/websocket_to_websocket.py b/gns3server/utils/websocket_to_websocket.py index 6155b3ca1..88efdbfd2 100644 --- a/gns3server/utils/websocket_to_websocket.py +++ b/gns3server/utils/websocket_to_websocket.py @@ -41,7 +41,7 @@ async def websocket_proxy( target_url: str, requested_protocols: list = None, buffer_size: int = 65536, - timeout: Optional[float] = None + timeout: Optional[float] = None, ) -> None: """ Proxy binary WebSocket data between client and target WebSocket server. @@ -116,28 +116,20 @@ async def websocket_proxy( if sys.version_info >= (3, 11, 0): aws = [ asyncio.create_task(forward_client_to_target(target_ws)), - asyncio.create_task(forward_target_to_client(target_ws)) + asyncio.create_task(forward_target_to_client(target_ws)), ] else: - aws = [ - forward_client_to_target(target_ws), - forward_target_to_client(target_ws) - ] + aws = [forward_client_to_target(target_ws), forward_target_to_client(target_ws)] try: - done, pending = await asyncio.wait( - aws, - return_when=asyncio.FIRST_COMPLETED - ) + done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) except Exception as e: log.error(f"asyncio.wait raised exception: {e}") # Check for exceptions for task in done: if task.exception(): - log.warning( - f"WebSocket proxy task exception: {task.exception()}" - ) + log.warning(f"WebSocket proxy task exception: {task.exception()}") # Cancel pending tasks for task in pending: @@ -145,16 +137,10 @@ async def websocket_proxy( except aiohttp.ClientError as e: log.error(f"WebSocket proxy connection error: {e}") - await client_ws.close( - code=status.WS_1011_INTERNAL_ERROR, - reason=f"Proxy connection failed: {e}" - ) + await client_ws.close(code=status.WS_1011_INTERNAL_ERROR, reason=f"Proxy connection failed: {e}") except Exception as e: log.error(f"WebSocket proxy unexpected error: {e}") - await client_ws.close( - code=status.WS_1011_INTERNAL_ERROR, - reason=str(e) - ) + await client_ws.close(code=status.WS_1011_INTERNAL_ERROR, reason=str(e)) async def websocket_proxy_with_manual_accept( @@ -162,7 +148,7 @@ async def websocket_proxy_with_manual_accept( target_url: str, requested_protocols: list = None, buffer_size: int = 65536, - timeout: Optional[float] = None + timeout: Optional[float] = None, ) -> None: """ Proxy binary WebSocket data between client and target WebSocket server. @@ -181,7 +167,7 @@ async def websocket_proxy_with_manual_accept( Raises: aiohttp.ClientError: If connection to target fails """ - client_info = f"{client_ws.client.host}:{client_ws.client.port}" if hasattr(client_ws, 'client') else "unknown" + client_info = f"{client_ws.client.host}:{client_ws.client.port}" if hasattr(client_ws, "client") else "unknown" async def forward_client_to_target(target_ws): """Client → Target: Forward binary WebSocket data.""" @@ -252,20 +238,14 @@ async def websocket_proxy_with_manual_accept( if sys.version_info >= (3, 11, 0): aws = [ asyncio.create_task(forward_client_to_target(target_ws)), - asyncio.create_task(forward_target_to_client(target_ws)) + asyncio.create_task(forward_target_to_client(target_ws)), ] else: - aws = [ - forward_client_to_target(target_ws), - forward_target_to_client(target_ws) - ] + aws = [forward_client_to_target(target_ws), forward_target_to_client(target_ws)] log.info(f"About to call asyncio.wait with {len(aws)} tasks") try: - done, pending = await asyncio.wait( - aws, - return_when=asyncio.FIRST_COMPLETED - ) + done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) log.info(f"asyncio.wait returned. done={len(done)}, pending={len(pending)}") except Exception as e: log.error(f"asyncio.wait raised exception: {e}") @@ -274,9 +254,7 @@ async def websocket_proxy_with_manual_accept( # Check for exceptions for task in done: if task.exception(): - log.warning( - f"WebSocket proxy task exception: {task.exception()}" - ) + log.warning(f"WebSocket proxy task exception: {task.exception()}") # Cancel pending tasks for task in pending: diff --git a/gns3server/utils/zipfile_zstd/__init__.py b/gns3server/utils/zipfile_zstd/__init__.py index 61f10fd79..4aae04421 100644 --- a/gns3server/utils/zipfile_zstd/__init__.py +++ b/gns3server/utils/zipfile_zstd/__init__.py @@ -1,4 +1,3 @@ - # NOTE: this patches the standard zipfile module from . import _zipfile @@ -7,4 +6,3 @@ from zipfile import ( ZIP_ZSTANDARD, ZSTANDARD_VERSION, ) - diff --git a/gns3server/utils/zipfile_zstd/_patcher.py b/gns3server/utils/zipfile_zstd/_patcher.py index 83d7fc7c5..ee2d19812 100644 --- a/gns3server/utils/zipfile_zstd/_patcher.py +++ b/gns3server/utils/zipfile_zstd/_patcher.py @@ -2,7 +2,6 @@ import functools class patch: - originals = {} def __init__(self, host, name): @@ -17,4 +16,3 @@ class patch: setattr(self.host, self.name, func) return func - diff --git a/gns3server/utils/zipfile_zstd/_zipfile.py b/gns3server/utils/zipfile_zstd/_zipfile.py index c082fe49f..2d5d200fa 100644 --- a/gns3server/utils/zipfile_zstd/_zipfile.py +++ b/gns3server/utils/zipfile_zstd/_zipfile.py @@ -6,16 +6,16 @@ from ._patcher import patch zipfile.ZIP_ZSTANDARD = 93 -zipfile.compressor_names[zipfile.ZIP_ZSTANDARD] = 'zstandard' +zipfile.compressor_names[zipfile.ZIP_ZSTANDARD] = "zstandard" zipfile.ZSTANDARD_VERSION = 20 -@patch(zipfile, '_check_compression') +@patch(zipfile, "_check_compression") def zstd_check_compression(compression): if compression == zipfile.ZIP_ZSTANDARD: pass else: - patch.originals['_check_compression'](compression) + patch.originals["_check_compression"](compression) class ZstdDecompressObjWrapper: @@ -23,42 +23,44 @@ class ZstdDecompressObjWrapper: self.o = o def __getattr__(self, attr): - if attr == 'eof': + if attr == "eof": return False return getattr(self.o, attr) -@patch(zipfile, '_get_decompressor') +@patch(zipfile, "_get_decompressor") def zstd_get_decompressor(compress_type): if compress_type == zipfile.ZIP_ZSTANDARD: return ZstdDecompressObjWrapper(zstd.ZstdDecompressor(max_window_size=2147483648).decompressobj()) else: - return patch.originals['_get_decompressor'](compress_type) + return patch.originals["_get_decompressor"](compress_type) -if 'compresslevel' in inspect.signature(zipfile._get_compressor).parameters: - @patch(zipfile, '_get_compressor') +if "compresslevel" in inspect.signature(zipfile._get_compressor).parameters: + + @patch(zipfile, "_get_compressor") def zstd_get_compressor(compress_type, compresslevel=None): if compress_type == zipfile.ZIP_ZSTANDARD: if compresslevel is None: compresslevel = 3 return zstd.ZstdCompressor(level=compresslevel, threads=-1).compressobj() else: - return patch.originals['_get_compressor'](compress_type, compresslevel=compresslevel) + return patch.originals["_get_compressor"](compress_type, compresslevel=compresslevel) else: - @patch(zipfile, '_get_compressor') + + @patch(zipfile, "_get_compressor") def zstd_get_compressor(compress_type, compresslevel=None): if compress_type == zipfile.ZIP_ZSTANDARD: if compresslevel is None: compresslevel = 3 return zstd.ZstdCompressor(level=compresslevel, threads=-1).compressobj() else: - return patch.originals['_get_compressor'](compress_type) + return patch.originals["_get_compressor"](compress_type) -@patch(zipfile.ZipInfo, 'FileHeader') +@patch(zipfile.ZipInfo, "FileHeader") def zstd_FileHeader(self, zip64=None): if self.compress_type == zipfile.ZIP_ZSTANDARD: self.create_version = max(self.create_version, zipfile.ZSTANDARD_VERSION) self.extract_version = max(self.extract_version, zipfile.ZSTANDARD_VERSION) - return patch.originals['FileHeader'](self, zip64=zip64) + return patch.originals["FileHeader"](self, zip64=zip64) diff --git a/gns3server/version.py b/gns3server/version.py index bc810eb50..a081bec6d 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -29,6 +29,7 @@ if "dev" in __version__: try: import os import subprocess + if os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".git")): r = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"]).decode().strip("\n") __version__ += "+" + r diff --git a/justfile b/justfile new file mode 100644 index 000000000..b07fefc81 --- /dev/null +++ b/justfile @@ -0,0 +1,39 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +_default: + @just --list --unsorted --list-submodules + +# Install the server with all optional features and dev tools +[group('dev')] +install: + python -m pip install --upgrade pip + python -m pip install -e '.[ai-features,dev]' + +# Apply ruff autofixes for the enforced rules +[group('dev')] +format: + python -m ruff check --fix + python -m ruff format + +# Run ruff checks (no fixes) +[group('dev')] +lint: + python -m ruff check --exit-non-zero-on-fix + python -m ruff format --check --diff + +[group('dev')] +type-check: + python -m mypy + +# Run tests with coverage, e.g. `just unit tests/controller` +[group('test')] +unit *args='': + python -m pytest --cov --cov-report=term-missing:skip-covered --cov-report=xml {{ args }} + +[group('test')] +package: + pip check + +# Run all checks +[group('test')] +test: package lint type-check unit diff --git a/mac-requirements.txt b/mac-requirements.txt deleted file mode 100644 index bc04b4960..000000000 --- a/mac-requirements.txt +++ /dev/null @@ -1 +0,0 @@ --r requirements.txt diff --git a/pyproject.toml b/pyproject.toml index e47c297ff..21c8a9ba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,3 +55,380 @@ gns3server-uninstall-ai-features = "gns3server.utils.uninstall_ai_features:main" [tool.setuptools] packages = ["gns3server"] + + +[tool.ruff] +target-version = "py310" +line-length = 120 +extend-exclude = [ + "*.md", +] + +[tool.ruff.lint] +select = [ + "ASYNC", # flake8-async + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C90", # mccabe + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "LOG", # flake8-logging + "PLE", # pylint errors + "PLW", # pylint warnings + "RUF", # ruff-specific rules + "S", # flake8-bandit + "T10", # flake8-debugger + "UP", # pyupgrade + "W", # pycodestyle warnings +] +# Baseline: widespread rules that the existing code still violates +# (violation count at baseline time). Remove an entry, and fix its +# violations, in a dedicated small PR. Never add new entries here: +# fix the code or use a targeted "# noqa: CODE" with a reason. +ignore = [ + "ASYNC230", # 240 blocking-open-call-in-async-function + "ASYNC240", # 235 blocking-path-method-in-async-function + "B007", # 32 unused-loop-control-variable + "B008", # 119 function-call-in-default-argument + "B904", # 365 raise-without-from-inside-except + "BLE001", # 214 blind-except + "E402", # 129 module-import-not-at-top-of-file + "E501", # 655 line-too-long + "F401", # 300 unused-import + "F541", # 16 f-string-missing-placeholders + "F841", # 32 unused-variable + "I001", # 441 unsorted-imports + "PLW0603", # 21 global-statement + "PLW2901", # 30 redefined-loop-name + "RUF010", # 65 explicit-f-string-type-conversion + "RUF012", # 23 mutable-class-default + "RUF013", # 40 implicit-optional + "RUF036", # 22 none-not-at-end-of-union + "RUF059", # 29 unused-unpacked-variable + "S101", # 19 assert + "S104", # 22 hardcoded-bind-all-interfaces + "S110", # 23 try-except-pass + "UP006", # 270 non-pep585-annotation + "UP007", # 81 non-pep604-annotation-union + "UP009", # 51 utf8-encoding-declaration + "UP032", # 560 f-string + "UP035", # 106 deprecated-import + "UP045", # 1100 non-pep604-annotation-optional + "W293", # 661 blank-line-with-whitespace +] + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint.flake8-bugbear] +# FastAPI dependency markers are meant to be used as defaults: +extend-immutable-calls = [ + "fastapi.Body", + "fastapi.Cookie", + "fastapi.Depends", + "fastapi.File", + "fastapi.Form", + "fastapi.Header", + "fastapi.Path", + "fastapi.Query", + "fastapi.Security", +] + +[tool.ruff.lint.per-file-ignores] +# Tests use asserts and hardcoded credentials / temp paths: +"tests/**" = ["S101", "S105", "S106", "S108"] +# Baseline: existing violations of rules that are enforced everywhere else. +# Remove entries one small PR at a time. Never add new ones. +"gns3server/agent/__init__.py" = ["LOG015", "RUF022"] +"gns3server/agent/gns3_copilot/agent/context_manager.py" = ["RUF005", "RUF100"] +"gns3server/agent/gns3_copilot/agent/gns3_copilot.py" = ["DTZ003"] +"gns3server/agent/gns3_copilot/agent_service.py" = ["C901", "DTZ003"] +"gns3server/agent/gns3_copilot/chat_sessions_repository.py" = ["DTZ003", "S608"] +"gns3server/agent/gns3_copilot/gns3_client/__init__.py" = ["RUF022"] +"gns3server/agent/gns3_copilot/gns3_client/context_helpers.py" = ["RUF022"] +"gns3server/agent/gns3_copilot/skills/__init__.py" = ["RUF022"] +"gns3server/agent/gns3_copilot/skills/loader.py" = ["UP015"] +"gns3server/agent/gns3_copilot/skills/manager.py" = ["UP037"] +"gns3server/agent/gns3_copilot/skills/registry.py" = ["C901"] +"gns3server/agent/gns3_copilot/tools_v2/__init__.py" = ["RUF022"] +"gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py" = ["PLW1510", "S603", "S607"] +"gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py" = ["PLW1510", "RUF005", "S603", "S607"] +"gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/utils/__init__.py" = ["RUF022"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py" = ["S107"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py" = ["LOG015"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py" = ["DTZ005", "UP024"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py" = ["S107"] +"gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py" = ["RUF100"] +"gns3server/agent/gns3_copilot/utils/message_converters.py" = ["RUF019"] +"gns3server/agent/gns3_copilot/utils/parse_tool_content.py" = ["C901", "DTZ003"] +"gns3server/agent/mcp/__init__.py" = ["RUF003"] +"gns3server/agent/mcp/device_config.py" = ["E741"] +"gns3server/agent/web_wireshark/manager.py" = ["C901"] +"gns3server/agent/web_wireshark/setup_wireshark_image.py" = ["PLW1510", "S603", "S607"] +"gns3server/agent/web_wireshark/stats.py" = ["ASYNC221", "PLW1510", "S603", "S607"] +"gns3server/api/routes/compute/notifications.py" = ["W291"] +"gns3server/api/routes/compute/qemu_nodes.py" = ["F811"] +"gns3server/api/routes/controller/controller.py" = ["UP012"] +"gns3server/api/routes/controller/dependencies/authentication.py" = ["S105", "S106"] +"gns3server/api/routes/controller/links.py" = ["E722"] +"gns3server/api/routes/controller/netmiko.py" = ["RUF100"] +"gns3server/api/routes/controller/nodes.py" = ["C901", "RUF019", "UP024"] +"gns3server/api/routes/controller/projects.py" = ["UP012"] +"gns3server/api/routes/controller/templates.py" = ["S324"] +"gns3server/api/routes/controller/users.py" = ["S105", "S106"] +"gns3server/api/routes/index.py" = ["RUF021"] +"gns3server/compute/base_node.py" = ["C901", "RUF019"] +"gns3server/compute/builtin/nodes/cloud.py" = ["E713"] +"gns3server/compute/docker/__init__.py" = ["B006", "C901"] +"gns3server/compute/docker/docker_vm.py" = ["B006", "C901", "E731", "RUF003", "RUF006", "S108", "UP031"] +"gns3server/compute/docker/iol_docker_vm.py" = ["RUF002", "S108"] +"gns3server/compute/docker/vendor_docker_vm.py" = ["RUF002", "S108"] +"gns3server/compute/dynamips/__init__.py" = ["C901"] +"gns3server/compute/dynamips/dynamips_hypervisor.py" = ["C901"] +"gns3server/compute/dynamips/nodes/router.py" = ["F522", "RUF006"] +"gns3server/compute/iou/iou_vm.py" = ["C901", "S108", "S324"] +"gns3server/compute/iou/utils/iou_export.py" = ["C901", "RUF003"] +"gns3server/compute/project.py" = ["DTZ006", "S324"] +"gns3server/compute/qemu/qemu_vm.py" = ["ASYNC220", "B009", "C901", "RUF003", "S603"] +"gns3server/compute/qemu/utils/ziputils.py" = ["RUF005", "UP031"] +"gns3server/compute/ubridge/ubridge_hypervisor.py" = ["C901"] +"gns3server/compute/virtualbox/virtualbox_vm.py" = ["C901", "RUF005", "S314"] +"gns3server/compute/vmware/__init__.py" = ["C901", "UP015"] +"gns3server/compute/vmware/vmware_vm.py" = ["C901"] +"gns3server/compute/vpcs/vpcs_vm.py" = ["B018"] +"gns3server/config.py" = ["B905", "C901"] +"gns3server/controller/__init__.py" = ["S311"] +"gns3server/controller/appliance_to_template.py" = ["C901"] +"gns3server/controller/compute.py" = ["B006", "C901"] +"gns3server/controller/drawing.py" = ["S314", "S324"] +"gns3server/controller/export_project.py" = ["C901", "DTZ006"] +"gns3server/controller/gns3vm/hyperv_gns3_vm.py" = ["RUF015", "S608"] +"gns3server/controller/gns3vm/virtualbox_gns3_vm.py" = ["C901"] +"gns3server/controller/import_project.py" = ["C901"] +"gns3server/controller/node.py" = ["C901", "PLW1641"] +"gns3server/controller/node_types.py" = ["W292"] +"gns3server/controller/ports/port_factory.py" = ["B020", "C901"] +"gns3server/controller/project.py" = ["ASYNC110", "C901", "RUF006", "RUF019"] +"gns3server/controller/topology.py" = ["C901"] +"gns3server/controller/udp_link.py" = ["C901"] +"gns3server/db/models/base.py" = ["UP031"] +"gns3server/db/repositories/llm_model_configs.py" = ["DTZ003", "RUF005", "RUF019"] +"gns3server/db/repositories/rbac.py" = ["C901"] +"gns3server/db/tasks.py" = ["B009", "RUF006", "RUF019", "UP004"] +"gns3server/db_migrations/versions/7ceeddd9c9a8_init.py" = ["W291"] +"gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py" = ["S301", "S608"] +"gns3server/logger.py" = ["PLW0127", "UP031"] +"gns3server/main.py" = ["UP031"] +"gns3server/schemas/controller/settings.py" = ["S105"] +"gns3server/schemas/controller/templates/docker_templates.py" = ["W292"] +"gns3server/schemas/controller/tokens.py" = ["S105"] +"gns3server/server.py" = ["DTZ011", "RUF006", "UP036"] +"gns3server/services/authentication.py" = ["S105"] +"gns3server/services/templates.py" = ["W292"] +"gns3server/utils/__init__.py" = ["S324", "UP031"] +"gns3server/utils/asyncio/__init__.py" = ["RUF006"] +"gns3server/utils/asyncio/aiozipstream.py" = ["C901", "UP031"] +"gns3server/utils/asyncio/raw_command_server.py" = ["B006"] +"gns3server/utils/asyncio/telnet_server.py" = ["RUF006"] +"gns3server/utils/images.py" = ["RUF021", "S324"] +"gns3server/utils/packet_filter_validation.py" = ["PLW1510", "S603", "S607", "W292"] +"gns3server/utils/picture.py" = ["C901"] +"gns3server/utils/port_allocator.py" = ["S324"] +"gns3server/utils/uninstall_ai_features.py" = ["PLW1510", "S603", "UP015"] +"gns3server/utils/vmnet.py" = ["S605", "S607"] +"gns3server/utils/websocket_to_websocket.py" = ["C901"] +"gns3server/utils/zipfile_zstd/__init__.py" = ["F403"] +"gns3server/version.py" = ["S607"] +"scripts/extract_mermaid.py" = ["C901", "PLW1510", "S603", "S607"] +"scripts/tag.py" = ["S603", "S607"] +"scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607", "W291"] +"tests/agent/mcp/test_handlers.py" = ["B905"] +"tests/agent/mcp/test_tool_params.py" = ["C901"] +"tests/api/routes/controller/test_images.py" = ["S324", "W292"] +"tests/api/routes/controller/test_links.py" = ["RUF015", "W292"] +"tests/api/routes/controller/test_users.py" = ["F811"] +"tests/compute/docker/test_docker.py" = ["B018"] +"tests/compute/docker/test_docker_vm.py" = ["F811", "UP004"] +"tests/compute/docker/test_iol_docker_vm.py" = ["RUF003", "RUF015"] +"tests/compute/docker/test_vendor_docker_vm.py" = ["RUF015"] +"tests/compute/qemu/test_qemu_vm.py" = ["E741"] +"tests/compute/test_project.py" = ["RUF003", "RUF064", "S103"] +"tests/compute/virtualbox/test_virtualbox_manager.py" = ["B011"] +"tests/compute/vpcs/test_vpcs_vm.py" = ["RUF003"] +"tests/controller/gns3vm/test_vmware_gns3_vm.py" = ["UP015"] +"tests/controller/test_export_project.py" = ["E713"] +"tests/controller/test_import_project.py" = ["F811"] +"tests/controller/test_marker.py" = ["B006"] +"tests/controller/test_node.py" = ["E711", "F811"] +"tests/controller/test_project.py" = ["RUF015"] +"tests/controller/test_snapshot.py" = ["RUF015"] +"tests/controller/test_udp_link.py" = ["B006"] +"tests/test_server.py" = ["E722"] +"tests/test_topologies.py" = ["B011"] +"tests/test_utils.py" = ["F403", "F405"] +"tests/utils/test_asyncio.py" = ["B017"] +"tests/utils/test_packet_filter_validation.py" = ["W292"] + + +[tool.mypy] +files = ["gns3server"] +plugins = ["pydantic.mypy"] +# Third-party packages without type hints / stubs: +ignore_missing_imports = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +strict_equality = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] + +[[tool.mypy.overrides]] +# Baseline: modules with existing type errors (error count at baseline time). +# Remove modules from this list one small PR at a time. Never add new ones. +module = [ + "gns3server.agent", # 2 + "gns3server.agent.gns3_copilot.agent.context_manager", # 5 + "gns3server.agent.gns3_copilot.agent.model_factory", # 4 + "gns3server.agent.gns3_copilot.agent_service", # 8 + "gns3server.agent.gns3_copilot.chat_sessions_repository", # 8 + "gns3server.agent.gns3_copilot.gns3_client.api_handlers", # 10 + "gns3server.agent.gns3_copilot.gns3_client.connector_factory", # 1 + "gns3server.agent.gns3_copilot.gns3_client.project_inventory", # 2 + "gns3server.agent.gns3_copilot.skills.loader", # 1 + "gns3server.agent.gns3_copilot.skills.manager", # 4 + "gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir", # 6 + "gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir", # 6 + "gns3server.agent.gns3_copilot.tools_v2.gns3_packet_filter", # 1 + "gns3server.agent.gns3_copilot.tools_v2.gns3_start_node", # 3 + "gns3server.agent.gns3_copilot.tools_v2.gns3_stop_node", # 3 + "gns3server.agent.gns3_copilot.tools_v2.gns3_suspend_node", # 3 + "gns3server.agent.gns3_copilot.tools_v2.packet_analysis_tool", # 1 + "gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko", # 9 + "gns3server.agent.gns3_copilot.utils.custom_netmiko.scripts.list_netmiko_telnet_devices", # 1 + "gns3server.agent.gns3_copilot.utils.message_converters", # 2 + "gns3server.agent.gns3_copilot.utils.parse_tool_content", # 1 + "gns3server.agent.mcp", # 34 + "gns3server.agent.mcp.appliances", # 1 + "gns3server.agent.web_wireshark.docker_client", # 5 + "gns3server.agent.web_wireshark.manage_wireshark", # 1 + "gns3server.agent.web_wireshark.manager", # 9 + "gns3server.agent.web_wireshark.stats", # 4 + "gns3server.api.routes.compute.atm_switch_nodes", # 9 + "gns3server.api.routes.compute.capabilities", # 1 + "gns3server.api.routes.compute.cloud_nodes", # 14 + "gns3server.api.routes.compute.compute", # 1 + "gns3server.api.routes.compute.dependencies.authentication", # 3 + "gns3server.api.routes.compute.docker_nodes", # 26 + "gns3server.api.routes.compute.dynamips_nodes", # 9 + "gns3server.api.routes.compute.ethernet_hub_nodes", # 9 + "gns3server.api.routes.compute.ethernet_switch_nodes", # 16 + "gns3server.api.routes.compute.frame_relay_switch_nodes", # 9 + "gns3server.api.routes.compute.iou_nodes", # 15 + "gns3server.api.routes.compute.nat_nodes", # 10 + "gns3server.api.routes.compute.notifications", # 4 + "gns3server.api.routes.compute.projects", # 7 + "gns3server.api.routes.compute.qemu_nodes", # 18 + "gns3server.api.routes.compute.virtualbox_nodes", # 20 + "gns3server.api.routes.compute.vmware_nodes", # 10 + "gns3server.api.routes.compute.vpcs_nodes", # 9 + "gns3server.api.routes.controller.acl", # 5 + "gns3server.api.routes.controller.api_keys", # 1 + "gns3server.api.routes.controller.chat", # 1 + "gns3server.api.routes.controller.computes", # 6 + "gns3server.api.routes.controller.controller", # 10 + "gns3server.api.routes.controller.dependencies.authentication", # 11 + "gns3server.api.routes.controller.dependencies.database", # 2 + "gns3server.api.routes.controller.drawings", # 1 + "gns3server.api.routes.controller.groups", # 6 + "gns3server.api.routes.controller.images", # 9 + "gns3server.api.routes.controller.links", # 27 + "gns3server.api.routes.controller.llm_model_configs", # 32 + "gns3server.api.routes.controller.nodes", # 13 + "gns3server.api.routes.controller.pools", # 8 + "gns3server.api.routes.controller.privileges", # 1 + "gns3server.api.routes.controller.projects", # 16 + "gns3server.api.routes.controller.roles", # 5 + "gns3server.api.routes.controller.snapshots", # 1 + "gns3server.api.routes.controller.templates", # 10 + "gns3server.api.routes.controller.users", # 14 + "gns3server.api.routes.index", # 1 + "gns3server.api.server", # 2 + "gns3server.compute.docker.docker_vm", # 3 + "gns3server.compute.docker.iol_docker_vm", # 3 + "gns3server.compute.dynamips.nodes.router", # 2 + "gns3server.compute.iou.iou_vm", # 4 + "gns3server.compute.iou.utils.iou_export", # 2 + "gns3server.compute.iou.utils.iou_import", # 2 + "gns3server.compute.project", # 1 + "gns3server.compute.qemu.qemu_vm", # 2 + "gns3server.compute.virtualbox.virtualbox_vm", # 1 + "gns3server.compute.vmware.vmware_vm", # 1 + "gns3server.compute.vpcs.vpcs_vm", # 2 + "gns3server.config", # 1 + "gns3server.controller.appliance", # 2 + "gns3server.controller.appliance_manager", # 5 + "gns3server.controller.compute", # 2 + "gns3server.controller.project", # 2 + "gns3server.controller.topology", # 1 + "gns3server.db.models.acl", # 1 + "gns3server.db.models.images", # 1 + "gns3server.db.models.pools", # 1 + "gns3server.db.models.privileges", # 2 + "gns3server.db.models.roles", # 1 + "gns3server.db.models.templates", # 22 + "gns3server.db.models.users", # 3 + "gns3server.db.repositories.api_keys", # 8 + "gns3server.db.repositories.computes", # 10 + "gns3server.db.repositories.images", # 14 + "gns3server.db.repositories.llm_model_configs", # 32 + "gns3server.db.repositories.pools", # 11 + "gns3server.db.repositories.rbac", # 14 + "gns3server.db.repositories.templates", # 6 + "gns3server.db.repositories.users", # 12 + "gns3server.db.tasks", # 12 + "gns3server.db_migrations.env", # 1 + "gns3server.schemas.compute.atm_switch_nodes", # 5 + "gns3server.schemas.compute.cloud_nodes", # 1 + "gns3server.schemas.compute.docker_nodes", # 2 + "gns3server.schemas.compute.dynamips_nodes", # 1 + "gns3server.schemas.compute.frame_relay_switch_nodes", # 5 + "gns3server.schemas.compute.iou_nodes", # 3 + "gns3server.schemas.compute.nat_nodes", # 1 + "gns3server.schemas.compute.qemu_nodes", # 1 + "gns3server.schemas.compute.virtualbox_nodes", # 2 + "gns3server.schemas.compute.vmware_nodes", # 3 + "gns3server.schemas.compute.vpcs_nodes", # 1 + "gns3server.schemas.controller.appliances", # 1 + "gns3server.schemas.controller.computes", # 6 + "gns3server.schemas.controller.nodes", # 3 + "gns3server.schemas.controller.projects", # 2 + "gns3server.schemas.controller.settings", # 3 + "gns3server.schemas.controller.templates.docker_templates", # 1 + "gns3server.schemas.controller.templates.dynamips_templates", # 17 + "gns3server.schemas.controller.templates.iou_templates", # 1 + "gns3server.schemas.controller.templates.virtualbox_templates", # 1 + "gns3server.schemas.controller.templates.vmware_templates", # 1 + "gns3server.schemas.qemu_disk_image", # 2 + "gns3server.services.authentication", # 5 + "gns3server.services.computes", # 4 + "gns3server.services.templates", # 18 + "gns3server.utils.asyncio.aiozipstream", # 8 + "gns3server.utils.asyncio.embed_shell", # 1 + "gns3server.utils.encryption", # 2 + "gns3server.utils.http_client", # 4 + "gns3server.utils.images", # 4 + "gns3server.utils.packet_filter_validation", # 16 + "gns3server.utils.websocket_to_websocket", # 12 + "gns3server.utils.zipfile_zstd", # 2 + "gns3server.utils.zipfile_zstd._patcher", # 1 + "gns3server.utils.zipfile_zstd._zipfile", # 5 +] +ignore_errors = true diff --git a/scripts/extract_mermaid.py b/scripts/extract_mermaid.py index 6a299fb04..aae2044b9 100755 --- a/scripts/extract_mermaid.py +++ b/scripts/extract_mermaid.py @@ -104,10 +104,15 @@ def convert_mermaid_to_svg( result = subprocess.run( [ - "npx", "--yes", MMDC_PACKAGE, - "-i", mmd_path, - "-o", output_path, - "-b", background, + "npx", + "--yes", + MMDC_PACKAGE, + "-i", + mmd_path, + "-o", + output_path, + "-b", + background, ], capture_output=True, text=True, @@ -173,17 +178,12 @@ def process_file( def main(): - parser = argparse.ArgumentParser( - description="Extract mermaid diagrams from Markdown and convert to SVG." - ) + parser = argparse.ArgumentParser(description="Extract mermaid diagrams from Markdown and convert to SVG.") parser.add_argument( "source", nargs="?", default=None, - help=( - "Markdown file or directory to scan. " - "Defaults to all *-overview*.md in docs/gns3-copilot/implemented/." - ), + help=("Markdown file or directory to scan. Defaults to all *-overview*.md in docs/gns3-copilot/implemented/."), ) parser.add_argument( "dest", @@ -197,23 +197,27 @@ def main(): help=f"Path to Chrome binary (auto-detected: {DEFAULT_CHROME_PATH})", ) parser.add_argument( - "--background", "-b", + "--background", + "-b", default="transparent", help="SVG background color (default: transparent)", ) parser.add_argument( - "--timeout", "-t", + "--timeout", + "-t", type=int, default=30, help="Timeout in seconds per diagram (default: 30)", ) parser.add_argument( - "--check", "-c", + "--check", + "-c", action="store_true", help="Check environment and exit (no conversion).", ) parser.add_argument( - "--install", "-i", + "--install", + "-i", action="store_true", help="Install missing dependencies (Chrome, mermaid-cli).", ) @@ -236,9 +240,7 @@ def main(): # 2. Install Chrome via puppeteer chrome = args.chrome or DEFAULT_CHROME_PATH if chrome and os.path.exists(chrome): - result = subprocess.run( - [chrome, "--version"], capture_output=True, text=True, timeout=10 - ) + result = subprocess.run([chrome, "--version"], capture_output=True, text=True, timeout=10) version = result.stdout.strip() if result.returncode == 0 else "unknown" print(f" Chrome: already installed ({version})") else: @@ -259,7 +261,9 @@ def main(): print(" mermaid-cli: caching...") ret = subprocess.run( ["npx", "--yes", MMDC_PACKAGE, "--version"], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ) if ret.returncode == 0: print(" mermaid-cli: ready") @@ -285,10 +289,7 @@ def main(): # 2. npx / Node.js try: - result = subprocess.run( - ["npx", "--version"], - capture_output=True, text=True, timeout=10 - ) + result = subprocess.run(["npx", "--version"], capture_output=True, text=True, timeout=10) if result.returncode == 0: print(f" npx: {result.stdout.strip()}") else: @@ -302,10 +303,7 @@ def main(): chrome = args.chrome or DEFAULT_CHROME_PATH if chrome and os.path.exists(chrome): try: - result = subprocess.run( - [chrome, "--version"], - capture_output=True, text=True, timeout=10 - ) + result = subprocess.run([chrome, "--version"], capture_output=True, text=True, timeout=10) version = result.stdout.strip() if result.returncode == 0 else "?" print(f" Chrome: {version}") print(f" Path: {chrome}") @@ -319,8 +317,7 @@ def main(): # 4. @mermaid-js/mermaid-cli try: result = subprocess.run( - ["npx", "--yes", MMDC_PACKAGE, "--version"], - capture_output=True, text=True, timeout=30 + ["npx", "--yes", MMDC_PACKAGE, "--version"], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: print(" mermaid-cli: available") @@ -366,13 +363,10 @@ def main(): docs_dir = os.path.join(project_root, "docs", "gns3-copilot", "implemented") if os.path.isdir(docs_dir): files_to_process = sorted( - os.path.join(docs_dir, f) - for f in os.listdir(docs_dir) - if "-overview" in f and f.endswith(".md") + os.path.join(docs_dir, f) for f in os.listdir(docs_dir) if "-overview" in f and f.endswith(".md") ) else: - print("No source specified and default docs directory not found.", - file=sys.stderr) + print("No source specified and default docs directory not found.", file=sys.stderr) sys.exit(1) if not files_to_process: @@ -396,8 +390,7 @@ def main(): chrome = args.chrome or DEFAULT_CHROME_PATH if not chrome: print( - "WARNING: Chrome not found in puppeteer cache. " - "Run: npx puppeteer browsers install chrome-headless-shell", + "WARNING: Chrome not found in puppeteer cache. Run: npx puppeteer browsers install chrome-headless-shell", file=sys.stderr, ) else: diff --git a/scripts/publish_api_documentation.py b/scripts/publish_api_documentation.py index 20d33316a..ab2e15704 100644 --- a/scripts/publish_api_documentation.py +++ b/scripts/publish_api_documentation.py @@ -24,19 +24,19 @@ from gns3server.api.server import app if __name__ == "__main__": - with open("../docs/openapi.json", "w") as fd: fd.write(json.dumps(app.openapi())) - swagger_html = get_swagger_ui_html(openapi_url="openapi.json", - title=app.title + " - Swagger UI", - oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url) + swagger_html = get_swagger_ui_html( + openapi_url="openapi.json", + title=app.title + " - Swagger UI", + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + ) with open("../docs/index.html", "w") as fd: fd.write(swagger_html.body.decode()) - redoc_html = get_redoc_html(openapi_url="openapi.json", - title=app.title + " - ReDoc") + redoc_html = get_redoc_html(openapi_url="openapi.json", title=app.title + " - ReDoc") with open("../docs/redoc.html", "w") as fd: fd.write(redoc_html.body.decode()) diff --git a/scripts/tag.py b/scripts/tag.py index 27c280a5d..3b7d3e02d 100755 --- a/scripts/tag.py +++ b/scripts/tag.py @@ -10,9 +10,7 @@ from pathlib import Path def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description=( - "Read gns3server/version.py, extract __version__, and create an annotated git tag." - ) + description=("Read gns3server/version.py, extract __version__, and create an annotated git tag.") ) parser.add_argument( "--dry-run", @@ -51,7 +49,7 @@ def main() -> int: return 1 try: - tag = 'v' + get_version(repo_root) + tag = "v" + get_version(repo_root) except (OSError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 @@ -81,5 +79,3 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) - - diff --git a/scripts/welcome.py b/scripts/welcome.py index 77de8d75e..dc3d5907f 100644 --- a/scripts/welcome.py +++ b/scripts/welcome.py @@ -31,7 +31,7 @@ from dialog import Dialog, PythonDialogBug class Welcome_dialog: def __init__(self): try: - locale.setlocale(locale.LC_ALL, '') + locale.setlocale(locale.LC_ALL, "") except locale.Error: # Not supported via SSH pass @@ -45,35 +45,36 @@ class Welcome_dialog: """ Return the active IP """ - #request 'ip addr' data in JSON format from shell - ip_addr_response = subprocess.run(['ip', '--json', 'addr'],capture_output=True) - - #process response, decode and use json.loads to convert the string to a dict + # request 'ip addr' data in JSON format from shell + ip_addr_response = subprocess.run(["ip", "--json", "addr"], capture_output=True) + + # process response, decode and use json.loads to convert the string to a dict ip_addr_data = convert(ip_addr_response.stdout.decode("utf-8")) - - #search ip_addr_data for the first ip adress that is not under a virtual bridge or loopback interface + + # search ip_addr_data for the first ip adress that is not under a virtual bridge or loopback interface for i in ip_addr_data: - if ('virbr' in i['ifname']) or ('lo' in i['ifname']): + if ("virbr" in i["ifname"]) or ("lo" in i["ifname"]): continue try: - if 'UP' in i['flags']: - ip_addr = i['addr_info'][0]['local'] + if "UP" in i["flags"]: + ip_addr = i["addr_info"][0]["local"] break except: continue ip_addr = None - + return ip_addr def repair_remote_install(self): """ This method is only called by remote-install.sh during setup to ensure it is setting the same IP as shown by Dialog - """ + """ ip_addr = self.get_ip() - subprocess.run(["sed", "-i", f"s/host = 0.0.0.0/host = {ip_addr}/", "/etc/gns3/gns3_server.conf"],capture_output=False) - subprocess.run(["service", "gns3", "stop"],capture_output=False) - subprocess.run(["service", "gns3", "start"],capture_output=False) - + subprocess.run( + ["sed", "-i", f"s/host = 0.0.0.0/host = {ip_addr}/", "/etc/gns3/gns3_server.conf"], capture_output=False + ) + subprocess.run(["service", "gns3", "stop"], capture_output=False) + subprocess.run(["service", "gns3", "start"], capture_output=False) def get_config(self): """ @@ -84,16 +85,14 @@ class Welcome_dialog: config.read([path], encoding="utf-8") return config - def write_config(self, config): """ Write the config file """ - with open(os.path.expanduser("~/.config/GNS3/gns3_server.conf"), 'w') as f: + with open(os.path.expanduser("~/.config/GNS3/gns3_server.conf"), "w") as f: config.write(f) - def gns3_major_version(self): """ Returns the GNS3 major server version @@ -105,7 +104,6 @@ class Welcome_dialog: return match.group(0) return "" - def gns3_version(self): """ Return the GNS3 server version @@ -115,25 +113,34 @@ class Welcome_dialog: except (subprocess.CalledProcessError, FileNotFoundError): return None - def gns3vm_version(self): """ Return the GNS3 VM version """ try: - with open('/home/gns3/.config/GNS3/gns3vm_version') as f: + with open("/home/gns3/.config/GNS3/gns3vm_version") as f: return f.read().strip() except FileNotFoundError: return "Remote Install" - def mode(self): - if self.display.yesno("This feature is for testers only. You may break your GNS3 installation. Are you REALLY sure you want to continue?", yes_label="Exit (Safe option)", no_label="Continue") == self.display.OK: + if ( + self.display.yesno( + "This feature is for testers only. You may break your GNS3 installation. Are you REALLY sure you want to continue?", + yes_label="Exit (Safe option)", + no_label="Continue", + ) + == self.display.OK + ): return - code, tag = self.display.menu("Select the GNS3 version", - choices=[("2.1", "Stable release for this GNS3 VM (RECOMMENDED)"), - ("2.1dev", "Development version for stable release"), - ("2.2", "Latest stable release")]) + code, tag = self.display.menu( + "Select the GNS3 version", + choices=[ + ("2.1", "Stable release for this GNS3 VM (RECOMMENDED)"), + ("2.1dev", "Development version for stable release"), + ("2.2", "Latest stable release"), + ], + ) self.display.clear() if code == Dialog.OK: os.makedirs(os.path.expanduser("~/.config/GNS3"), exist_ok=True) @@ -142,7 +149,6 @@ class Welcome_dialog: self.update(force=True) - def get_release(self): try: with open(os.path.expanduser("~/.config/GNS3/gns3_release")) as f: @@ -160,15 +166,21 @@ class Welcome_dialog: except OSError: return "1.5" - def update(self, force=False): if not force: - if self.display.yesno("It is recommended to ensure all Nodes are shutdown before upgrading. Continue?") != self.display.OK: + if ( + self.display.yesno("It is recommended to ensure all Nodes are shutdown before upgrading. Continue?") + != self.display.OK + ): return - code, option = self.display.menu("Select an option", - choices=[("Upgrade GNS3", "Upgrades only the GNS3 pakage and dependences."), - ("Upgrade All", "Upgrades all avaiable packages"), - ("Dist Upgrade", "Upgrades all avaiable packages and the Linux Kernel. Requires a reboot.")]) + code, option = self.display.menu( + "Select an option", + choices=[ + ("Upgrade GNS3", "Upgrades only the GNS3 pakage and dependences."), + ("Upgrade All", "Upgrades all avaiable packages"), + ("Dist Upgrade", "Upgrades all avaiable packages and the Linux Kernel. Requires a reboot."), + ], + ) if code == Dialog.OK: if option == "Upgrade GNS3": ret = os.system( @@ -196,18 +208,23 @@ class Welcome_dialog: if self.display.yesno("Reboot now?") == self.display.OK: os.system("sudo reboot now") - def migrate(self): """ Migrate GNS3 VM data. """ - code, option = self.display.menu("Select an option", - choices=[("Setup", "Configure this VM to send data to another GNS3 VM"), - ("Send", "Send images and projects to another GNS3 VM")]) + code, option = self.display.menu( + "Select an option", + choices=[ + ("Setup", "Configure this VM to send data to another GNS3 VM"), + ("Send", "Send images and projects to another GNS3 VM"), + ], + ) self.display.clear() if code == Dialog.OK: - (answer, destination) = self.display.inputbox("What is IP address or hostname of the other GNS3 VM?", init="172.16.1.128") + (answer, destination) = self.display.inputbox( + "What is IP address or hostname of the other GNS3 VM?", init="172.16.1.128" + ) if answer != self.display.OK: return if destination == self.get_ip(): @@ -217,13 +234,19 @@ class Welcome_dialog: # first make sure they are no files belonging to root os.system("sudo chown -R gns3:gns3 /opt/gns3") # then rsync the data - command = r"rsync -az --progress -e 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /home/gns3/.ssh/gns3-vm-key' /opt/gns3 gns3@{}:/opt".format(destination) + command = r"rsync -az --progress -e 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /home/gns3/.ssh/gns3-vm-key' /opt/gns3 gns3@{}:/opt".format( + destination + ) ret = os.system('bash -c "{}"'.format(command)) time.sleep(10) if ret != 0: self.display.msgbox("Could not send data to the other GNS3 VM located at {}".format(destination)) else: - self.display.msgbox("Images and projects have been successfully sent to the other GNS3 VM located at {}".format(destination)) + self.display.msgbox( + "Images and projects have been successfully sent to the other GNS3 VM located at {}".format( + destination + ) + ) elif option == "Setup": script = """ if [ ! -f ~/.ssh/gns3-vm-key ] @@ -237,8 +260,11 @@ class Welcome_dialog: if ret != 0: self.display.msgbox("Error while setting up the migrate feature") else: - self.display.msgbox("Configuration successful, you can now send data to the GNS3 VM located at {} without password".format(destination)) - + self.display.msgbox( + "Configuration successful, you can now send data to the GNS3 VM located at {} without password".format( + destination + ) + ) def shrink_disk(self): @@ -247,7 +273,12 @@ class Welcome_dialog: self.display.msgbox("Shrinking the disk is only supported when running inside VMware") return - if self.display.yesno("Would you like to shrink the VM disk? The VM will reboot at the end of the process. Continue?") != self.display.OK: + if ( + self.display.yesno( + "Would you like to shrink the VM disk? The VM will reboot at the end of the process. Continue?" + ) + != self.display.OK + ): return os.system("sudo service gns3 stop") @@ -256,7 +287,7 @@ class Welcome_dialog: os.system("sudo vmware-toolbox-cmd disk shrink /") self.display.msgbox("The GNS3 VM will reboot") - os.execvp("sudo", ['/usr/bin/sudo', "reboot"]) + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) def vm_information(self): """ @@ -269,10 +300,11 @@ class Welcome_dialog: if version is None: content += "GNS3 is not installed please install it with sudo pip3 install gns3-server. Or download a preinstalled VM.\n\n" else: - content = "GNS3 version: {gns3_version}\nVM version: {gns3vm_version}\nKVM support available: {kvm}\n\n".format( - gns3vm_version=self.gns3vm_version(), - gns3_version=version, - kvm=self.kvm_support()) + content = ( + "GNS3 version: {gns3_version}\nVM version: {gns3vm_version}\nKVM support available: {kvm}\n\n".format( + gns3vm_version=self.gns3vm_version(), gns3_version=version, kvm=self.kvm_support() + ) + ) ip = self.get_ip() @@ -297,13 +329,12 @@ Images and projects are located in /opt/gns3 self.display.msgbox(content) # If it's an scp command or any bugs except: - os.execvp("bash", ['/bin/bash']) - + os.execvp("bash", ["/bin/bash"]) def check_internet_connectivity(self): self.display.pause("Please wait...\n\n") try: - response = urllib.request.urlopen('http://pypi.python.org/', timeout=5) + response = urllib.request.urlopen("http://pypi.python.org/", timeout=5) except urllib.request.URLError as err: self.display.infobox("Can't connect to Internet (pypi.python.org): {}".format(str(err))) time.sleep(15) @@ -311,14 +342,12 @@ Images and projects are located in /opt/gns3 self.display.infobox("Connection to Internet: OK") time.sleep(2) - def keyboard_configuration(): """ Allow user to change the keyboard layout """ os.system("/usr/bin/sudo dpkg-reconfigure keyboard-configuration") - def set_security(self): config = self.get_config() if self.display.yesno("Enable server authentication?") == self.display.OK: @@ -338,7 +367,6 @@ Images and projects are located in /opt/gns3 self.write_config(config) - def log(self): os.system("/usr/bin/sudo chmod 755 /var/log/upstart/gns3.log") with open("/var/log/upstart/gns3.log") as f: @@ -349,7 +377,6 @@ Images and projects are located in /opt/gns3 except (KeyboardInterrupt, MemoryError): return - def edit_config(self): """ Edit GNS3 configuration file @@ -361,7 +388,6 @@ Images and projects are located in /opt/gns3 else: os.system("nano ~/.config/GNS3/gns3_server.conf") - def edit_network(self): """ Edit network configuration file @@ -369,27 +395,30 @@ Images and projects are located in /opt/gns3 if self.display.yesno("The server will reboot at the end of the process. Continue?") != self.display.OK: return os.system("sudo nano /etc/network/interfaces") - os.execvp("sudo", ['/usr/bin/sudo', "reboot"]) - + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) def edit_proxy(self): """ Configure proxy settings """ - res, http_proxy = self.display.inputbox(text="HTTP proxy string, for example http://:@:. Leave empty for no proxy.") + res, http_proxy = self.display.inputbox( + text="HTTP proxy string, for example http://:@:. Leave empty for no proxy." + ) if res != self.display.OK: return - res, https_proxy = self.display.inputbox(text="HTTPS proxy string, for example http://:@:. Leave empty for no proxy.") + res, https_proxy = self.display.inputbox( + text="HTTPS proxy string, for example http://:@:. Leave empty for no proxy." + ) if res != self.display.OK: return - with open('/tmp/00proxy', 'w+') as f: + with open("/tmp/00proxy", "w+") as f: f.write('Acquire::http::Proxy "' + http_proxy + '";') os.system("sudo mv /tmp/00proxy /etc/apt/apt.conf.d/00proxy") os.system("sudo chown root /etc/apt/apt.conf.d/00proxy") os.system("sudo chmod 744 /etc/apt/apt.conf.d/00proxy") - with open('/tmp/proxy.sh', 'w+') as f: + with open("/tmp/proxy.sh", "w+") as f: f.write('export http_proxy="' + http_proxy + '"\n') f.write('export https_proxy="' + https_proxy + '"\n') f.write('export HTTP_PROXY="' + http_proxy + '"\n') @@ -400,8 +429,7 @@ Images and projects are located in /opt/gns3 os.system("sudo cp /etc/profile.d/proxy.sh /etc/default/docker") self.display.msgbox("The GNS3 VM will reboot") - os.execvp("sudo", ['/usr/bin/sudo', "reboot"]) - + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) def kvm_support(self): """ @@ -409,7 +437,6 @@ Images and projects are located in /opt/gns3 """ return subprocess.call("kvm-ok") == 0 - def kvm_control(self): """ Check if KVM is correctly configured @@ -420,40 +447,53 @@ Images and projects are located in /opt/gns3 try: if config.getboolean("Qemu", "enable_kvm") is True: if kvm_ok is False: - if self.display.yesno("KVM is not available!\n\nQemu VM will crash!!\n\nThe reason could be unsupported hardware or another virtualization solution is already running.\n\nDisable KVM and get lower performances?") == self.display.OK: + if ( + self.display.yesno( + "KVM is not available!\n\nQemu VM will crash!!\n\nThe reason could be unsupported hardware or another virtualization solution is already running.\n\nDisable KVM and get lower performances?" + ) + == self.display.OK + ): config.set("Qemu", "enable_kvm", False) self.write_config(config) - os.execvp("sudo", ['/usr/bin/sudo', "reboot"]) + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) else: if kvm_ok is True: - if self.display.yesno("KVM is available on your computer.\n\nEnable KVM and get better performances?") == self.display.OK: + if ( + self.display.yesno( + "KVM is available on your computer.\n\nEnable KVM and get better performances?" + ) + == self.display.OK + ): config.set("Qemu", "enable_kvm", True) self.write_config(config) - os.execvp("sudo", ['/usr/bin/sudo', "reboot"]) + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) except configparser.NoSectionError: return - def display_loop(self): try: while True: - code, tag = self.display.menu("GNS3 {}".format(self.gns3_version()), - choices=[("Information", "Display VM information"), - ("Upgrade", "Upgrade GNS3"), - ("Migrate", "Migrate data to another GNS3 VM"), - ("Shell", "Open a console"), - ("Security", "Configure authentication"), - ("Keyboard", "Change keyboard layout"), - ("Configure", "Edit server configuration (advanced users ONLY)"), - ("Proxy", "Configure proxy settings"), - ("Networking", "Configure networking settings"), - ("Log", "Show server log"), - ("Test", "Check internet connection"), - ("Shrink", "Shrink the VM disk"), - ("Version", "Select the GNS3 version"), - ("Restore", "Restore the VM (if you have trouble for upgrade)"), - ("Reboot", "Reboot the VM"), - ("Shutdown", "Shutdown the VM")]) + code, tag = self.display.menu( + "GNS3 {}".format(self.gns3_version()), + choices=[ + ("Information", "Display VM information"), + ("Upgrade", "Upgrade GNS3"), + ("Migrate", "Migrate data to another GNS3 VM"), + ("Shell", "Open a console"), + ("Security", "Configure authentication"), + ("Keyboard", "Change keyboard layout"), + ("Configure", "Edit server configuration (advanced users ONLY)"), + ("Proxy", "Configure proxy settings"), + ("Networking", "Configure networking settings"), + ("Log", "Show server log"), + ("Test", "Check internet connection"), + ("Shrink", "Shrink the VM disk"), + ("Version", "Select the GNS3 version"), + ("Restore", "Restore the VM (if you have trouble for upgrade)"), + ("Reboot", "Reboot the VM"), + ("Shutdown", "Shutdown the VM"), + ], + ) self.display.clear() if code == Dialog.OK: if tag == "Shell": @@ -462,11 +502,11 @@ Images and projects are located in /opt/gns3 elif tag == "Version": self.mode() elif tag == "Restore": - os.execvp("sudo", ['/usr/bin/sudo', "/usr/local/bin/gns3restore"]) + os.execvp("sudo", ["/usr/bin/sudo", "/usr/local/bin/gns3restore"]) elif tag == "Reboot": - os.execvp("sudo", ['/usr/bin/sudo', "reboot"]) + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) elif tag == "Shutdown": - os.execvp("sudo", ['/usr/bin/sudo', "poweroff"]) + os.execvp("sudo", ["/usr/bin/sudo", "poweroff"]) elif tag == "Upgrade": self.update() elif tag == "Information": @@ -492,6 +532,7 @@ Images and projects are located in /opt/gns3 except KeyboardInterrupt: sys.exit(0) + if __name__ == "__main__": ws = Welcome_dialog() ws.vm_information() diff --git a/tests/agent/mcp/conftest.py b/tests/agent/mcp/conftest.py index 3b1e35952..d286d4dc2 100644 --- a/tests/agent/mcp/conftest.py +++ b/tests/agent/mcp/conftest.py @@ -1,4 +1,5 @@ """MCP test fixtures.""" + import pytest diff --git a/tests/agent/mcp/test_device_tools.py b/tests/agent/mcp/test_device_tools.py index 5c5cd8c63..6a7c04169 100644 --- a/tests/agent/mcp/test_device_tools.py +++ b/tests/agent/mcp/test_device_tools.py @@ -4,6 +4,7 @@ Device config tool tests with mocked topology and Nornir layers. Covers the VPCS node-type guard and the error contract shared by device_config_send / device_show_run / vpcs_config_set. """ + import json import pytest @@ -18,16 +19,18 @@ def _topology_ports(node_type): class TestVPCSNodeTypeGuard: - def test_non_vpcs_node_is_rejected(self): from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands - with patch(f"{VPCS_MOD}.get_device_ports_from_topology", - return_value=_topology_ports("iou")) as topo: - result = VPCSCommands()._run(json.dumps({ - "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", - "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], - })) + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", return_value=_topology_ports("iou")) as topo: + result = VPCSCommands()._run( + json.dumps( + { + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + } + ) + ) assert topo.called assert len(result) == 1 assert result[0]["device_name"] == "PC1" @@ -37,12 +40,15 @@ class TestVPCSNodeTypeGuard: def test_missing_node_type_is_rejected(self): from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands - with patch(f"{VPCS_MOD}.get_device_ports_from_topology", - return_value={"PC1": {"port": 5000}}): - result = VPCSCommands()._run(json.dumps({ - "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", - "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], - })) + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", return_value={"PC1": {"port": 5000}}): + result = VPCSCommands()._run( + json.dumps( + { + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + } + ) + ) assert result[0]["status"] == "failed" assert "unknown-type" in result[0]["error"] @@ -54,13 +60,18 @@ class TestVPCSNodeTypeGuard: host_result = MagicMock(failed=False) host_result.result = "OK" nornir.run.return_value = {"PC1": host_result} - with patch(f"{VPCS_MOD}.get_device_ports_from_topology", - return_value=_topology_ports("vpcs")), \ - patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir): - result = tool._run(json.dumps({ - "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", - "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], - })) + with ( + patch(f"{VPCS_MOD}.get_device_ports_from_topology", return_value=_topology_ports("vpcs")), + patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir), + ): + result = tool._run( + json.dumps( + { + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + } + ) + ) assert result[0]["status"] == "success" assert result[0]["output"] == "OK" @@ -72,13 +83,18 @@ class TestVPCSNodeTypeGuard: host_result = MagicMock(failed=True) host_result.result = "Command failed (ReadTimeout)" nornir.run.return_value = {"PC1": host_result} - with patch(f"{VPCS_MOD}.get_device_ports_from_topology", - return_value=_topology_ports("vpcs")), \ - patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir): - result = tool._run(json.dumps({ - "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", - "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], - })) + with ( + patch(f"{VPCS_MOD}.get_device_ports_from_topology", return_value=_topology_ports("vpcs")), + patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir), + ): + result = tool._run( + json.dumps( + { + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + } + ) + ) assert result[0]["status"] == "failed" assert result[0]["error"] == "Command failed (ReadTimeout)" assert "output" not in result[0] @@ -93,12 +109,15 @@ class TestDeviceToolErrorContract: def test_topology_level_error_has_status(self): from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands - with patch(f"{VPCS_MOD}.get_device_ports_from_topology", - side_effect=ValueError("topology unreachable")): - result = VPCSCommands()._run(json.dumps({ - "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", - "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], - })) + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", side_effect=ValueError("topology unreachable")): + result = VPCSCommands()._run( + json.dumps( + { + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + } + ) + ) assert result == [{"status": "failed", "error": "topology unreachable"}] def test_config_tool_topology_level_error_has_status(self): @@ -106,13 +125,18 @@ class TestDeviceToolErrorContract: ExecuteMultipleDeviceConfigCommands, ) - with patch("gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir" - ".get_device_ports_from_topology", - side_effect=ValueError("no valid devices")): - result = ExecuteMultipleDeviceConfigCommands()._run(json.dumps({ - "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", - "device_configs": [{"device_name": "R1", "config_commands": ["int lo0"]}], - })) + with patch( + "gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir.get_device_ports_from_topology", + side_effect=ValueError("no valid devices"), + ): + result = ExecuteMultipleDeviceConfigCommands()._run( + json.dumps( + { + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "R1", "config_commands": ["int lo0"]}], + } + ) + ) assert result == [{"status": "failed", "error": "no valid devices"}] def test_mcp_handler_param_error_has_status(self, ctx=None): @@ -124,10 +148,12 @@ class TestDeviceToolErrorContract: for handler in (device_config_send_handler, device_show_run_handler, vpcs_config_set_handler): result = handler({}, {"server_url": "http://x", "jwt_token": "t"}) - assert result == [{ - "status": "failed", - "error": result[0]["error"], # message text may differ per handler - }] + assert result == [ + { + "status": "failed", + "error": result[0]["error"], # message text may differ per handler + } + ] assert "required" in result[0]["error"] def test_template_render_error_has_status(self): diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 87501bf30..d98f754b0 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -3,6 +3,7 @@ MCP handler unit tests with mocked Gns3Connector. Tests that handlers correctly transform tool parameters into HTTP calls. """ + import json import pytest @@ -31,11 +32,11 @@ def ctx(): class TestProject: - mod = "projects" def test_list(self, ctx): from gns3server.agent.mcp.projects import list_projects_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn([{"project_id": "p1", "name": "Test", "status": "opened"}]) result = list_projects_handler({}, ctx) @@ -43,6 +44,7 @@ class TestProject: def test_get(self, ctx): from gns3server.agent.mcp.projects import get_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"project_id": "p1"}) result = get_project_handler({"project_id": "p1"}, ctx) @@ -50,10 +52,12 @@ class TestProject: def test_get_missing_id(self, ctx): from gns3server.agent.mcp.projects import get_project_handler + assert "error" in get_project_handler({}, ctx) def test_create(self, ctx): from gns3server.agent.mcp.projects import create_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"project_id": "p1"}) m.return_value = conn @@ -65,16 +69,16 @@ class TestProject: def test_create_without_auto_close(self, ctx): from gns3server.agent.mcp.projects import create_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"project_id": "p2"}) m.return_value = conn create_project_handler({"name": "New"}, ctx) - conn.http_call.assert_called_once_with( - "post", f"{conn.base_url}/projects", json_data={"name": "New"} - ) + conn.http_call.assert_called_once_with("post", f"{conn.base_url}/projects", json_data={"name": "New"}) def test_delete(self, ctx): from gns3server.agent.mcp.projects import delete_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({}) result = delete_project_handler({"project_id": "p1"}, ctx) @@ -82,6 +86,7 @@ class TestProject: def test_open(self, ctx): from gns3server.agent.mcp.projects import open_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"status": "opened"}) result = open_project_handler({"project_id": "p1"}, ctx) @@ -89,6 +94,7 @@ class TestProject: def test_close(self, ctx): from gns3server.agent.mcp.projects import close_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"status": "closed"}) result = close_project_handler({"project_id": "p1"}, ctx) @@ -96,6 +102,7 @@ class TestProject: def test_update(self, ctx): from gns3server.agent.mcp.projects import update_project_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"name": "Updated"}) result = update_project_handler({"project_id": "p1", "name": "Updated"}, ctx) @@ -103,6 +110,7 @@ class TestProject: def test_stats(self, ctx): from gns3server.agent.mcp.projects import get_project_stats_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"nodes": 5, "links": 3}) result = get_project_stats_handler({"project_id": "p1"}, ctx) @@ -113,19 +121,21 @@ class TestProject: class TestNode: - - def test_list_fields(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler + with patch(f"{AH}._get_connector") as m: - m.return_value = _mock_conn([ - {"node_id": "n1", "name": "R1", "status": "started", "node_type": "qemu", "console": 5000}, - ]) + m.return_value = _mock_conn( + [ + {"node_id": "n1", "name": "R1", "status": "started", "node_type": "qemu", "console": 5000}, + ] + ) result = get_nodes_handler({"project_id": "p1", "fields": ["name", "status"]}, ctx) assert result == {"nodes": [{"name": "R1", "status": "started"}], "count": 1} def test_list_invalid_fields(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn([]) result = get_nodes_handler({"project_id": "p1", "fields": "not-a-list"}, ctx) @@ -133,6 +143,7 @@ class TestNode: def test_get(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"node_id": "n1", "name": "R1"}) result = get_node_handler({"project_id": "p1", "node_id": "n1"}, ctx) @@ -140,68 +151,99 @@ class TestNode: def test_create_single_passes_name(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"node_id": "n1", "name": "MyRouter"}) m.return_value = conn - result = create_node_handler({ - "project_id": "p1", "template_id": "t1", - "name": "MyRouter", "x": 100, "y": 200, - }, ctx) + result = create_node_handler( + { + "project_id": "p1", + "template_id": "t1", + "name": "MyRouter", + "x": 100, + "y": 200, + }, + ctx, + ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p1/templates/t1", + "post", + "http://192.168.1.3:3080/v3/projects/p1/templates/t1", json_data={"x": 100, "y": 200, "compute_id": "local", "name": "MyRouter"}, ) assert result == {"node_id": "n1", "name": "MyRouter"} def test_create_fields_filter(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"node_id": "n1", "name": "R1", "status": "started"}) - result = create_node_handler({ - "project_id": "p1", "template_id": "t1", - "fields": ["node_id", "name"], - }, ctx) + result = create_node_handler( + { + "project_id": "p1", + "template_id": "t1", + "fields": ["node_id", "name"], + }, + ctx, + ) assert result == {"node_id": "n1", "name": "R1"} def test_create_fields_validation(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn() m.return_value = conn - result = create_node_handler({ - "project_id": "p1", "template_id": "t1", "fields": "not-a-list", - }, ctx) + result = create_node_handler( + { + "project_id": "p1", + "template_id": "t1", + "fields": "not-a-list", + }, + ctx, + ) assert "error" in result assert "fields must be a list" in result["error"] conn.http_call.assert_not_called() def test_create_batch_inherits_template_id(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"node_id": "n1", "name": "R1"}) - result = create_node_handler({ - "project_id": "p1", "template_id": "default-tpl", - "nodes": [{"name": "R1", "x": 0, "y": 0}], - }, ctx) + result = create_node_handler( + { + "project_id": "p1", + "template_id": "default-tpl", + "nodes": [{"name": "R1", "x": 0, "y": 0}], + }, + ctx, + ) assert result[0]["status"] == "success" def test_create_batch_preserves_submission_order(self, ctx): import time from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn() + def _http_call(method, url, json_data=None, **kwargs): # first submissions sleep longest so completion order is reversed time.sleep({"slow": 0.25, "mid": 0.1}.get(json_data.get("name"), 0.0)) resp = MagicMock() resp.json.return_value = {"node_id": "n1", "name": json_data["name"]} return resp + conn.http_call.side_effect = _http_call m.return_value = conn - result = create_node_handler({ - "project_id": "p1", "template_id": "t1", - "nodes": [{"name": "slow"}, {"name": "mid"}, {"name": "fast"}], - }, ctx) + result = create_node_handler( + { + "project_id": "p1", + "template_id": "t1", + "nodes": [{"name": "slow"}, {"name": "mid"}, {"name": "fast"}], + }, + ctx, + ) assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"] def test_create_batch_default_names_created_sequentially(self, ctx): @@ -246,10 +288,12 @@ class TestNode: def test_create_missing_project_id(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + assert create_node_handler({}, ctx) == {"error": "project_id is required"} def test_delete_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({}) result = delete_node_handler({"project_id": "p1", "node_ids": ["n1", "n2"]}, ctx) @@ -259,6 +303,7 @@ class TestNode: def test_start_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import start_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"status": "started"}) result = start_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx) @@ -266,6 +311,7 @@ class TestNode: def test_stop_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import stop_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"status": "stopped"}) result = stop_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx) @@ -273,6 +319,7 @@ class TestNode: def test_suspend_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import suspend_node_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"status": "suspended"}) result = suspend_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx) @@ -281,6 +328,7 @@ class TestNode: def test_console(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler from gns3server.services import access_ticket_service + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"console_url": "ws://host/console"}) result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx) @@ -297,6 +345,7 @@ class TestNode: def test_console_vnc_uses_same_ticket(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"console_type": "vnc"}) result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx) @@ -306,6 +355,7 @@ class TestNode: def test_console_without_username_omits_token(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler + unauthenticated_ctx = {k: v for k, v in ctx.items() if k != "jwt_username"} with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"console_url": "ws://host/console"}) @@ -321,6 +371,7 @@ class TestNode: def test_file_get_keeps_trailing_newline(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: m.return_value = self._file_conn("line1\nline2\n") result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) @@ -331,6 +382,7 @@ class TestNode: def test_file_get_keeps_crlf_endings(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: m.return_value = self._file_conn("line1\r\nline2\r\n") result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) @@ -339,6 +391,7 @@ class TestNode: def test_file_get_without_trailing_newline(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: m.return_value = self._file_conn("line1\nline2") result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) @@ -346,6 +399,7 @@ class TestNode: def test_file_get_pagination(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: m.return_value = self._file_conn("line1\nline2\nline3\n") result = get_node_file_handler( @@ -361,10 +415,9 @@ class TestNode: class TestLink: - - def test_list(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_links_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn([{"link_id": "l1", "link_type": "ethernet"}]) result = get_links_handler({"project_id": "p1", "fields": ["link_id"]}, ctx) @@ -372,6 +425,7 @@ class TestLink: def test_get(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_link_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"link_id": "l1", "link_type": "ethernet"}) result = get_link_handler({"project_id": "p1", "link_id": "l1"}, ctx) @@ -379,49 +433,66 @@ class TestLink: def test_create_compact_format(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"link_id": "l1", "link_type": "ethernet", "nodes": []}) m.return_value = conn - result = create_link_handler({ - "project_id": "p1", - "nodes": ["n1", 0, 0, "n2", 0, 0], - }, ctx) + result = create_link_handler( + { + "project_id": "p1", + "nodes": ["n1", 0, 0, "n2", 0, 0], + }, + ctx, + ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p1/links", - json_data={"nodes": [ - {"node_id": "n1", "adapter_number": 0, "port_number": 0}, - {"node_id": "n2", "adapter_number": 0, "port_number": 0}, - ]}, + "post", + "http://192.168.1.3:3080/v3/projects/p1/links", + json_data={ + "nodes": [ + {"node_id": "n1", "adapter_number": 0, "port_number": 0}, + {"node_id": "n2", "adapter_number": 0, "port_number": 0}, + ] + }, ) def test_create_standard_format(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"link_id": "l1"}) - result = create_link_handler({ - "project_id": "p1", - "nodes": [ - {"node_id": "n1", "adapter_number": 0, "port_number": 0}, - {"node_id": "n2", "adapter_number": 0, "port_number": 0}, - ], - }, ctx) + result = create_link_handler( + { + "project_id": "p1", + "nodes": [ + {"node_id": "n1", "adapter_number": 0, "port_number": 0}, + {"node_id": "n2", "adapter_number": 0, "port_number": 0}, + ], + }, + ctx, + ) assert result["link_id"] == "l1" def test_create_fields_validation(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn() m.return_value = conn - result = create_link_handler({ - "project_id": "p1", "fields": "bad", - "nodes": ["n1", 0, 0, "n2", 0, 0], - }, ctx) + result = create_link_handler( + { + "project_id": "p1", + "fields": "bad", + "nodes": ["n1", 0, 0, "n2", 0, 0], + }, + ctx, + ) assert "error" in result assert "fields must be a list" in result["error"] conn.http_call.assert_not_called() def test_delete_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_link_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({}) result = delete_link_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx) @@ -432,8 +503,10 @@ class TestLink: def test_create_batch_preserves_submission_order(self, ctx): import time from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn() + def _http_call(method, url, json_data=None, **kwargs): # first submission sleeps longest so completion order is reversed first_node = json_data["nodes"][0]["node_id"] @@ -441,30 +514,41 @@ class TestLink: resp = MagicMock() resp.json.return_value = {"link_id": f"link-{first_node}"} return resp + conn.http_call.side_effect = _http_call m.return_value = conn - result = create_link_handler({ - "project_id": "p1", - "links": [ - {"nodes": ["n1", 0, 0, "n2", 0, 0]}, - {"nodes": ["n3", 0, 0, "n4", 0, 0]}, - ], - "fields": ["link_id"], - }, ctx) + result = create_link_handler( + { + "project_id": "p1", + "links": [ + {"nodes": ["n1", 0, 0, "n2", 0, 0]}, + {"nodes": ["n3", 0, 0, "n4", 0, 0]}, + ], + "fields": ["link_id"], + }, + ctx, + ) assert [r["link"]["link_id"] for r in result] == ["link-n1", "link-n3"] def test_update(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import update_link_handler + with patch(f"{AH}._get_connector") as m: m.return_value = _mock_conn({"link_id": "l1", "suspend": True}) - result = update_link_handler({ - "project_id": "p1", "link_id": "l1", "suspend": True, - }, ctx) + result = update_link_handler( + { + "project_id": "p1", + "link_id": "l1", + "suspend": True, + }, + ctx, + ) assert result["suspend"] is True def test_download_capture_file(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import download_capture_file_handler from gns3server.services import access_ticket_service + result = download_capture_file_handler({"project_id": "p1", "link_id": "l1"}, ctx) # a short path-bound ticket replaces the long Bearer JWT in the URL assert "?token=gns3t_" in result["download_url"] @@ -478,6 +562,7 @@ class TestLink: def test_download_capture_file_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import download_capture_file_handler from gns3server.services import access_ticket_service + result = download_capture_file_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx) assert result["count"] == 2 # each link gets its own ticket bound to its own download path @@ -492,12 +577,12 @@ class TestLink: class TestSymbol: - mod = "symbols" def test_get_symbol_download(self, ctx): from gns3server.agent.mcp.symbols import get_symbol_handler from gns3server.services import access_ticket_service + result = get_symbol_handler({"symbol_id": "router.svg"}, ctx) assert "?token=gns3t_" in result["download_url"] assert "Bearer" not in result["curl_command"] @@ -507,6 +592,7 @@ class TestSymbol: def test_get_symbol_without_username_omits_token(self, ctx): from gns3server.agent.mcp.symbols import get_symbol_handler + unauthenticated_ctx = {k: v for k, v in ctx.items() if k != "jwt_username"} result = get_symbol_handler({"symbol_id": "router.svg"}, unauthenticated_ctx) assert "token=" not in result["download_url"] @@ -517,11 +603,11 @@ class TestSymbol: class TestAppliance: - mod = "appliances" def test_get(self, ctx): from gns3server.agent.mcp.appliances import get_appliance_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"appliance_id": "a1", "name": "Cisco ISE"}) result = get_appliance_handler({"appliance_id": "a1"}, ctx) @@ -529,24 +615,34 @@ class TestAppliance: def test_install_with_version(self, ctx): from gns3server.agent.mcp.appliances import install_appliance_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker"}) m.return_value = conn - result = install_appliance_handler({ - "appliance_id": "a1", "version": "2.7.0.356", - }, ctx) + result = install_appliance_handler( + { + "appliance_id": "a1", + "version": "2.7.0.356", + }, + ctx, + ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/appliances/a1/install", + "post", + "http://192.168.1.3:3080/v3/appliances/a1/install", params={"version": "2.7.0.356"}, ) assert result["template"] == { - "template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker", + "template_id": "t1", + "name": "FRR", + "version": "8.2.2", + "template_type": "docker", } def test_install_empty_body(self, ctx): # a 204-style empty response must not blow up with a JSON decode error # (the template is still created server-side) from gns3server.agent.mcp.appliances import install_appliance_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn() conn.http_call.return_value.content = b"" @@ -558,6 +654,7 @@ class TestAppliance: def test_install_missing_id(self, ctx): from gns3server.agent.mcp.appliances import install_appliance_handler + result = install_appliance_handler({}, ctx) assert "error" in result @@ -566,21 +663,29 @@ class TestAppliance: class TestTemplate: - mod = "templates" def test_list_fields(self, ctx): from gns3server.agent.mcp.templates import list_templates_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: - m.return_value = _mock_conn([ - {"template_id": "t1", "name": "Cisco 7200", "template_type": "dynamips", - "category": "router", "default_name_format": "{name}-{0}"}, - ]) + m.return_value = _mock_conn( + [ + { + "template_id": "t1", + "name": "Cisco 7200", + "template_type": "dynamips", + "category": "router", + "default_name_format": "{name}-{0}", + }, + ] + ) result = list_templates_handler({"fields": ["template_id", "name"]}, ctx) assert result["templates"] == [{"template_id": "t1", "name": "Cisco 7200"}] def test_list_invalid_field(self, ctx): from gns3server.agent.mcp.templates import list_templates_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn() result = list_templates_handler({"fields": ["does_not_exist"]}, ctx) @@ -588,6 +693,7 @@ class TestTemplate: def test_get(self, ctx): from gns3server.agent.mcp.templates import get_template_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({"template_id": "t1", "name": "Test"}) result = get_template_handler({"template_id": "t1"}, ctx) @@ -595,6 +701,7 @@ class TestTemplate: def test_delete(self, ctx): from gns3server.agent.mcp.templates import delete_template_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: m.return_value = _mock_conn({}) result = delete_template_handler({"template_id": "t1"}, ctx) @@ -605,16 +712,18 @@ class TestTemplate: class TestImage: - mod = "images" def test_install_manifest(self, ctx): from gns3server.agent.mcp.images import install_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: - conn = _mock_conn({ - "created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}], - "skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}], - }) + conn = _mock_conn( + { + "created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}], + "skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}], + } + ) m.return_value = conn result = install_images_handler({}, ctx) assert result["created"][0]["name"] == "Empty VM" @@ -622,6 +731,7 @@ class TestImage: def test_install_empty_body(self, ctx): from gns3server.agent.mcp.images import install_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn() conn.http_call.return_value.content = b"" @@ -632,11 +742,11 @@ class TestImage: class TestCompute: - mod = "computes" def test_get_local_by_default(self, ctx): from gns3server.agent.mcp.computes import get_compute_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"compute_id": "local", "name": "local"}) m.return_value = conn @@ -647,6 +757,7 @@ class TestCompute: def test_get_explicit_compute_id(self, ctx): from gns3server.agent.mcp.computes import get_compute_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}) m.return_value = conn @@ -656,6 +767,7 @@ class TestCompute: def test_images_local_by_default(self, ctx): from gns3server.agent.mcp.computes import get_compute_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: conn = _mock_conn(["img1.qcow2"]) m.return_value = conn @@ -666,6 +778,7 @@ class TestCompute: def test_images_requires_emulator(self, ctx): from gns3server.agent.mcp.computes import get_compute_images_handler + assert "error" in get_compute_images_handler({}, ctx) @@ -675,101 +788,120 @@ class TestCompute: class TestLinkMarker: """link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null).""" - def test_update_direction_both_clears(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "update", - "marker_name": "icmp", "direction": "both"}, ctx, + {"project_id": "p", "link_id": "l", "action": "update", "marker_name": "icmp", "direction": "both"}, + ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + "put", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", json_data={"direction": None}, ) def test_update_direction_tx_sets(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "update", - "marker_name": "icmp", "direction": "tx"}, ctx, + {"project_id": "p", "link_id": "l", "action": "update", "marker_name": "icmp", "direction": "tx"}, + ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + "put", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", json_data={"direction": "tx"}, ) def test_update_direction_omitted_preserved(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "update", - "marker_name": "icmp", "tag": 1}, ctx, + {"project_id": "p", "link_id": "l", "action": "update", "marker_name": "icmp", "tag": 1}, + ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + "put", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", json_data={"tag": 1}, ) def test_create_direction_both_omitted(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "create", - "bpf": "icmp", "direction": "both"}, ctx, + {"project_id": "p", "link_id": "l", "action": "create", "bpf": "icmp", "direction": "both"}, + ctx, ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + "post", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers", json_data={"bpf": "icmp"}, ) def test_create_data_link_type_passthrough(self, ctx): """create passes a serial WAN encapsulation through; update ignores it.""" from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "create", - "bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, ctx, + {"project_id": "p", "link_id": "l", "action": "create", "bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, + ctx, ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + "post", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers", json_data={"bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, ) conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "update", - "marker_name": "icmp", "tag": 1, "data_link_type": "DLT_PPP_SERIAL"}, ctx, + { + "project_id": "p", + "link_id": "l", + "action": "update", + "marker_name": "icmp", + "tag": 1, + "data_link_type": "DLT_PPP_SERIAL", + }, + ctx, ) # create-only: dropped from the update body conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + "put", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", json_data={"tag": 1}, ) def test_create_direction_tx(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "icmp"}) m.return_value = conn link_marker_handler( - {"project_id": "p", "link_id": "l", "action": "create", - "bpf": "icmp", "direction": "tx"}, ctx, + {"project_id": "p", "link_id": "l", "action": "create", "bpf": "icmp", "direction": "tx"}, + ctx, ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + "post", + "http://192.168.1.3:3080/v3/projects/p/links/l/markers", json_data={"bpf": "icmp", "direction": "tx"}, ) @@ -782,67 +914,76 @@ class TestMarkerDefinition: meaning — any direction passed is ignored, never reaching the request body. """ - def test_create_builds_body(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "create", - "bpf": "arp", "tag": 1, "color": "#fff"}, ctx, + {"project_id": "p", "action": "create", "bpf": "arp", "tag": 1, "color": "#fff"}, + ctx, ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + "post", + "http://192.168.1.3:3080/v3/projects/p/marker-definitions", json_data={"bpf": "arp", "tag": 1, "color": "#fff"}, ) def test_create_ignores_direction(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "create", - "bpf": "arp", "direction": "tx"}, ctx, + {"project_id": "p", "action": "create", "bpf": "arp", "direction": "tx"}, + ctx, ) conn.http_call.assert_called_with( - "post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions", + "post", + "http://192.168.1.3:3080/v3/projects/p/marker-definitions", json_data={"bpf": "arp"}, ) def test_update_builds_body(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "update", - "def_name": "arp", "tag": 1}, ctx, + {"project_id": "p", "action": "update", "def_name": "arp", "tag": 1}, + ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + "put", + "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", json_data={"tag": 1}, ) def test_update_ignores_direction(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler + with patch(f"{AH}._get_connector") as m: conn = _mock_conn({"name": "arp"}) m.return_value = conn marker_definition_handler( - {"project_id": "p", "action": "update", - "def_name": "arp", "tag": 1, "direction": "rx"}, ctx, + {"project_id": "p", "action": "update", "def_name": "arp", "tag": 1, "direction": "rx"}, + ctx, ) conn.http_call.assert_called_with( - "put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", + "put", + "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp", json_data={"tag": 1}, ) def test_update_requires_a_field(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler + with patch(f"{AH}._get_connector"): result = marker_definition_handler( - {"project_id": "p", "action": "update", "def_name": "arp"}, ctx, + {"project_id": "p", "action": "update", "def_name": "arp"}, + ctx, ) assert "error" in result diff --git a/tests/agent/mcp/test_tool_params.py b/tests/agent/mcp/test_tool_params.py index 06f4ae173..258436462 100644 --- a/tests/agent/mcp/test_tool_params.py +++ b/tests/agent/mcp/test_tool_params.py @@ -8,6 +8,7 @@ This catches issues like: - A tool parameter is defined but never passed to the handler - A handler reads a param that was never defined or passed """ + import ast import os import sys @@ -112,7 +113,7 @@ def _get_handler_params(handler_name): filename = HANDLER_FILES.get(handler_name) if not filename: return None - filepath = (MCP_DIR if not filename.startswith('gns3server/') else REPO_ROOT) / filename + filepath = (MCP_DIR if not filename.startswith("gns3server/") else REPO_ROOT) / filename if not filepath.exists(): return None @@ -128,10 +129,13 @@ def _get_handler_params(handler_name): # request body from all params) accepts any key — return a wildcard and # let the caller skip static consistency checks for it. for sub in ast.walk(node): - if (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) - and sub.func.attr == "items" - and isinstance(sub.func.value, ast.Name) - and sub.func.value.id in ("params", "params_data")): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and sub.func.attr == "items" + and isinstance(sub.func.value, ast.Name) + and sub.func.value.id in ("params", "params_data") + ): return {"*"} # Found the handler function, search for params.get("xxx") for sub in ast.walk(node): @@ -141,8 +145,10 @@ def _get_handler_params(handler_name): continue # params.get("xxx") or params_data.get("xxx") func_obj = sub.func - if (hasattr(func_obj.value, "id") and func_obj.value.id in ("params", "params_data", "link_data", "node_data")) or \ - (hasattr(func_obj.value, "attr") and func_obj.value.attr == "get"): + if ( + hasattr(func_obj.value, "id") + and func_obj.value.id in ("params", "params_data", "link_data", "node_data") + ) or (hasattr(func_obj.value, "attr") and func_obj.value.attr == "get"): if sub.args and isinstance(sub.args[0], ast.Constant) and isinstance(sub.args[0].value, str): params.add(sub.args[0].value) return params @@ -180,8 +186,12 @@ def _initial_params_keys(fn_node): """ for stmt in ast.walk(fn_node): if isinstance(stmt, ast.Assign): - if (len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name) - and stmt.targets[0].id == "params" and isinstance(stmt.value, ast.Dict)): + if ( + len(stmt.targets) == 1 + and isinstance(stmt.targets[0], ast.Name) + and stmt.targets[0].id == "params" + and isinstance(stmt.value, ast.Dict) + ): return _dict_literal_keys(stmt.value) return None @@ -196,8 +206,13 @@ def _dispatch_args(node): """ if isinstance(node.func, ast.Name) and node.func.id == "_run_handler_sync": args = node.args - elif (isinstance(node.func, ast.Attribute) and node.func.attr == "to_thread" - and node.args and isinstance(node.args[0], ast.Name) and node.args[0].id == "_run_handler_sync"): + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "to_thread" + and node.args + and isinstance(node.args[0], ast.Name) + and node.args[0].id == "_run_handler_sync" + ): args = node.args[1:] else: return None, None @@ -238,8 +253,14 @@ def test_tool_handler_param_consistency(): exact = True elif isinstance(second_arg, ast.Name) and second_arg.id == "params": # Tool builds 'params' as a variable — resolve its initial dict literal. - fn = next((n for n in ast.walk(tree) - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == tool_name), None) + fn = next( + ( + n + for n in ast.walk(tree) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == tool_name + ), + None, + ) passed_keys = _initial_params_keys(fn) if fn is not None else None else: passed_keys = None @@ -259,9 +280,7 @@ def test_tool_handler_param_consistency(): # Check: every passed key is read by the handler extra_passed = passed_keys - handler_params - assert not extra_passed, ( - f"[{tool_name}] Params passed to handler '{handler_name}' but not read: {extra_passed}" - ) + assert not extra_passed, f"[{tool_name}] Params passed to handler '{handler_name}' but not read: {extra_passed}" if not group["exact"]: # The initial literal underestimates what the tool passes (keys may be @@ -271,11 +290,21 @@ def test_tool_handler_param_consistency(): # Check: every handler param is passed (except common/optional ones) missing = handler_params - passed_keys # Filter out well-known optional params that handlers check - known_optional = {"fields", "template", "name", "version", "compute_id", - "x", "y", "link_type", "filters", "suspend", "link_style", - "show_filters_icon", "label"} + known_optional = { + "fields", + "template", + "name", + "version", + "compute_id", + "x", + "y", + "link_type", + "filters", + "suspend", + "link_style", + "show_filters_icon", + "label", + } truly_missing = missing - known_optional if truly_missing: - pytest.fail( - f"[{tool_name}] Handler '{handler_name}' reads params not passed: {truly_missing}" - ) + pytest.fail(f"[{tool_name}] Handler '{handler_name}' reads params not passed: {truly_missing}") diff --git a/tests/api/routes/compute/test_capabilities.py b/tests/api/routes/compute/test_capabilities.py index 071c629fa..09d4186eb 100644 --- a/tests/api/routes/compute/test_capabilities.py +++ b/tests/api/routes/compute/test_capabilities.py @@ -30,28 +30,56 @@ pytestmark = pytest.mark.asyncio class TestCapabilitiesRoutes: - async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform) -> None: response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK - assert response.json() == {'node_types': ['cloud', 'ethernet_hub', 'ethernet_switch', 'nat', 'vpcs', 'virtualbox', 'dynamips', 'frame_relay_switch', 'atm_switch', 'qemu', 'vmware', 'docker', 'iou'], - 'version': __version__, - 'platform': sys.platform, - 'cpus': psutil.cpu_count(logical=True), - 'memory': psutil.virtual_memory().total, - 'disk_size': psutil.disk_usage(get_default_project_directory()).total, - } - + assert response.json() == { + "node_types": [ + "cloud", + "ethernet_hub", + "ethernet_switch", + "nat", + "vpcs", + "virtualbox", + "dynamips", + "frame_relay_switch", + "atm_switch", + "qemu", + "vmware", + "docker", + "iou", + ], + "version": __version__, + "platform": sys.platform, + "cpus": psutil.cpu_count(logical=True), + "memory": psutil.virtual_memory().total, + "disk_size": psutil.disk_usage(get_default_project_directory()).total, + } async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm) -> None: response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK - assert response.json() == {'node_types': ['cloud', 'ethernet_hub', 'ethernet_switch', 'nat', 'vpcs', 'virtualbox', 'dynamips', 'frame_relay_switch', 'atm_switch', 'qemu', 'vmware', 'docker', 'iou'], - 'version': __version__, - 'platform': sys.platform, - 'cpus': psutil.cpu_count(logical=True), - 'memory': psutil.virtual_memory().total, - 'disk_size': psutil.disk_usage(get_default_project_directory()).total, - } + assert response.json() == { + "node_types": [ + "cloud", + "ethernet_hub", + "ethernet_switch", + "nat", + "vpcs", + "virtualbox", + "dynamips", + "frame_relay_switch", + "atm_switch", + "qemu", + "vmware", + "docker", + "iou", + ], + "version": __version__, + "platform": sys.platform, + "cpus": psutil.cpu_count(logical=True), + "memory": psutil.virtual_memory().total, + "disk_size": psutil.disk_usage(get_default_project_directory()).total, + } diff --git a/tests/api/routes/compute/test_cloud_nodes.py b/tests/api/routes/compute/test_cloud_nodes.py index b19895c11..05949e5e6 100644 --- a/tests/api/routes/compute/test_cloud_nodes.py +++ b/tests/api/routes/compute/test_cloud_nodes.py @@ -29,231 +29,181 @@ pytestmark = pytest.mark.asyncio class TestCloudNodesRoutes: - @pytest_asyncio.fixture async def vm(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, on_gns3vm) -> dict: with asyncio_patch("gns3server.compute.builtin.nodes.cloud.Cloud._start_ubridge"): - response = await compute_client.post(app.url_path_for("compute:create_cloud", project_id=compute_project.id), - json={"name": "Cloud 1"}) + response = await compute_client.post( + app.url_path_for("compute:create_cloud", project_id=compute_project.id), json={"name": "Cloud 1"} + ) assert response.status_code == status.HTTP_201_CREATED return response.json() - - async def test_cloud_create( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project - ) -> None: + async def test_cloud_create(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: with asyncio_patch("gns3server.compute.builtin.nodes.cloud.Cloud._start_ubridge"): - response = await compute_client.post(app.url_path_for("compute:create_cloud", project_id=compute_project.id), - json={"name": "Cloud 1"}) + response = await compute_client.post( + app.url_path_for("compute:create_cloud", project_id=compute_project.id), json={"name": "Cloud 1"} + ) assert response.status_code == 201 assert response.json()["name"] == "Cloud 1" assert response.json()["project_id"] == compute_project.id - async def test_get_cloud( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: - response = await compute_client.get(app.url_path_for("compute:get_cloud", project_id=vm["project_id"], node_id=vm["node_id"])) + response = await compute_client.get( + app.url_path_for("compute:get_cloud", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "Cloud 1" assert response.json()["project_id"] == compute_project.id assert response.json()["status"] == "started" - async def test_cloud_nio_create_udp( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: - params = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} - - url = app.url_path_for("compute:create_cloud_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - response = await compute_client.post(url, json=params) - assert response.status_code == status.HTTP_201_CREATED - assert response.json()["type"] == "nio_udp" - - - async def test_cloud_nio_update_udp( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict - ) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1", - "filters": {"packet_loss": 10} - } + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} url = app.url_path_for( "compute:create_cloud_nio", project_id=vm["project_id"], node_id=vm["node_id"], adapter_number="0", - port_number="0") + port_number="0", + ) + response = await compute_client.post(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["type"] == "nio_udp" + + async def test_cloud_nio_update_udp( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict + ) -> None: + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"packet_loss": 10}} + + url = app.url_path_for( + "compute:create_cloud_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} params["filters"].clear() - url = app.url_path_for("compute:create_cloud_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_cloud_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" assert response.json()["filters"] == {} - async def test_cloud_delete_nio( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: - params = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} - url = app.url_path_for("compute:create_cloud_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_cloud_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) await compute_client.post(url, json=params) - url = app.url_path_for("compute:delete_cloud_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:delete_cloud_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.builtin.nodes.cloud.Cloud._start_ubridge"): response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_cloud_delete( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: response = await compute_client.delete( - app.url_path_for( - "compute:delete_cloud", - project_id=vm["project_id"], - node_id=vm["node_id"] - ) + app.url_path_for("compute:delete_cloud", project_id=vm["project_id"], node_id=vm["node_id"]) ) assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_cloud_stop( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: response = await compute_client.post( - app.url_path_for( - "compute:stop_cloud", - project_id=vm["project_id"], - node_id=vm["node_id"] - ) + app.url_path_for("compute:stop_cloud", project_id=vm["project_id"], node_id=vm["node_id"]) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - async def test_cloud_suspend( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: response = await compute_client.post( - app.url_path_for( - "compute:suspend_cloud", - project_id=vm["project_id"], - node_id=vm["node_id"] - ) + app.url_path_for("compute:suspend_cloud", project_id=vm["project_id"], node_id=vm["node_id"]) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + async def test_cloud_update(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - async def test_cloud_update( - self, app: FastAPI, - compute_client: AsyncClient, - vm: dict - ) -> None: - - response = await compute_client.put(app.url_path_for("compute:update_cloud", project_id=vm["project_id"], node_id=vm["node_id"]), - json={"name": "test"}) + response = await compute_client.put( + app.url_path_for("compute:update_cloud", project_id=vm["project_id"], node_id=vm["node_id"]), + json={"name": "test"}, + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" + async def test_cloud_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - async def test_cloud_start_capture( - self, app: FastAPI, - compute_client: AsyncClient, - vm: dict - ) -> None: - - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} with asyncio_patch("gns3server.compute.builtin.nodes.cloud.Cloud.start_capture") as mock: - response = await compute_client.post(app.url_path_for("compute:start_cloud_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0"), - json=params) + response = await compute_client.post( + app.url_path_for( + "compute:start_cloud_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ), + json=params, + ) assert response.status_code == status.HTTP_200_OK assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - async def test_cloud_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.builtin.nodes.cloud.Cloud.stop_capture") as mock: - response = await compute_client.post(app.url_path_for("compute:stop_cloud_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0")) + response = await compute_client.post( + app.url_path_for( + "compute:stop_cloud_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + ) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - # @pytest.mark.asyncio # async def test_cloud_pcap(self, compute_api, vm, compute_project): # diff --git a/tests/api/routes/compute/test_compute.py b/tests/api/routes/compute/test_compute.py index 04a38b2b8..0edb27aa6 100644 --- a/tests/api/routes/compute/test_compute.py +++ b/tests/api/routes/compute/test_compute.py @@ -30,18 +30,13 @@ pytestmark = pytest.mark.asyncio class TestComputeRoutes: + async def test_udp_allocation(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: - async def test_udp_allocation( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project - ) -> None: - - response = await compute_client.post(app.url_path_for("compute:allocate_udp_port", project_id=compute_project.id), json={}) + response = await compute_client.post( + app.url_path_for("compute:allocate_udp_port", project_id=compute_project.id), json={} + ) assert response.status_code == status.HTTP_201_CREATED - assert response.json()['udp_port'] is not None - + assert response.json()["udp_port"] is not None async def test_interfaces(self, app: FastAPI, compute_client: AsyncClient) -> None: @@ -49,33 +44,30 @@ class TestComputeRoutes: assert response.status_code == status.HTTP_200_OK assert isinstance(response.json(), list) - async def test_version_output(self, app: FastAPI, compute_client: AsyncClient) -> None: response = await compute_client.get(app.url_path_for("compute:compute_version")) assert response.status_code == status.HTTP_200_OK - assert response.json() == {'version': __version__} - + assert response.json() == {"version": __version__} async def test_compute_authentication(self, app: FastAPI, compute_client: AsyncClient) -> None: - response = await compute_client.get(app.url_path_for("compute:compute_version"), auth=("admin", "invalid_password")) + response = await compute_client.get( + app.url_path_for("compute:compute_version"), auth=("admin", "invalid_password") + ) assert response.status_code == status.HTTP_401_UNAUTHORIZED - # @pytest.mark.asyncio # async def test_debug_output(compute_api): # # response = await compute_api.get('/debug') # assert response.status_code == 200 - async def test_statistics_output(self, app: FastAPI, compute_client: AsyncClient) -> None: response = await compute_client.get(app.url_path_for("compute:compute_statistics")) assert response.status_code == status.HTTP_200_OK - async def test_compute_auth_disabled(self, app: FastAPI, compute_client: AsyncClient) -> None: mock_settings = MagicMock() @@ -85,9 +77,10 @@ class TestComputeRoutes: mock_server_settings.compute_password.get_secret_value.return_value = "testpass" mock_settings.settings.Server = mock_server_settings - with patch("gns3server.api.routes.compute.dependencies.authentication.Config.instance", return_value=mock_settings): + with patch( + "gns3server.api.routes.compute.dependencies.authentication.Config.instance", return_value=mock_settings + ): response = await compute_client.get( - app.url_path_for("compute:compute_version"), - auth=("wrong_user", "wrong_password") + app.url_path_for("compute:compute_version"), auth=("wrong_user", "wrong_password") ) assert response.status_code == status.HTTP_200_OK diff --git a/tests/api/routes/compute/test_docker_nodes.py b/tests/api/routes/compute/test_docker_nodes.py index b9fa79fd8..fecc0ef56 100644 --- a/tests/api/routes/compute/test_docker_nodes.py +++ b/tests/api/routes/compute/test_docker_nodes.py @@ -31,7 +31,6 @@ pytestmark = pytest.mark.asyncio class TestDockerNodesRoutes: - @pytest.fixture def base_params(self) -> dict: """Return standard parameters""" @@ -44,11 +43,10 @@ class TestDockerNodesRoutes: "environment": "YES=1\nNO=0", "console_type": "telnet", "console_resolution": "1280x1024", - "extra_hosts": "test:127.0.0.1" + "extra_hosts": "test:127.0.0.1", } return params - # @pytest.yield_fixture(autouse=True) # def mock_connection(): # @@ -58,30 +56,20 @@ class TestDockerNodesRoutes: # yield # Docker._instance = None - @pytest_asyncio.fixture - async def vm( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict - ) -> dict: + async def vm(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict) -> dict: with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "nginx"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "8bd8153ea8f5"}): with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="exited"): - response = await compute_client.post(app.url_path_for("compute:create_docker_node", project_id=compute_project.id), - json=base_params) + response = await compute_client.post( + app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED return response.json() - async def test_docker_create( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict ) -> None: with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "nginx"}]): @@ -99,12 +87,8 @@ class TestDockerNodesRoutes: assert response.json()["console_resolution"] == "1280x1024" assert response.json()["extra_hosts"] == "test:127.0.0.1" - async def test_docker_create_iol_startup_config( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict ) -> None: """ The controller materializes the GNS3_IOL_STARTUP_CONFIG knob into @@ -157,13 +141,13 @@ class TestDockerNodesRoutes: ), ) async def test_docker_create_with_invalid_name( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - name: str, - status_code: int + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + base_params: dict, + name: str, + status_code: int, ) -> None: base_params["name"] = name @@ -174,81 +158,71 @@ class TestDockerNodesRoutes: ) assert response.status_code == status_code - async def test_docker_start(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.start", return_value=True) as mock: - - response = await compute_client.post(app.url_path_for("compute:start_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:start_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_docker_stop(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.stop", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:stop_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:stop_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_docker_reload(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.restart", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:reload_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:reload_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_docker_delete(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.delete", return_value=True) as mock: - response = await compute_client.delete(app.url_path_for("compute:delete_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.delete( + app.url_path_for("compute:delete_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_docker_pause(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.pause", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:pause_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:pause_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_docker_unpause(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.unpause", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:unpause_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:unpause_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_docker_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} - url = app.url_path_for("compute:create_docker_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_docker_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" @@ -281,7 +255,6 @@ class TestDockerNodesRoutes: assert response.status_code == status.HTTP_201_CREATED assert response.json()["added"] == 2 - async def test_docker_update_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: params = { @@ -290,25 +263,29 @@ class TestDockerNodesRoutes: "rport": 4343, "rhost": "127.0.0.1", "filters": {"packet_loss": 10}, - "suspend": False + "suspend": False, } - url = app.url_path_for("compute:create_docker_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_docker_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} params["filters"] = {} params["suspend"] = True - url = app.url_path_for("compute:update_docker_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:update_docker_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.adapter_update_nio_binding"): response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED @@ -318,30 +295,34 @@ class TestDockerNodesRoutes: async def test_docker_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - url = app.url_path_for("compute:delete_docker_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:delete_docker_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.adapter_remove_nio_binding"): response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT - - async def test_docker_update(self, app: FastAPI, compute_client: AsyncClient, vm: dict, free_console_port: int) -> None: + async def test_docker_update( + self, app: FastAPI, compute_client: AsyncClient, vm: dict, free_console_port: int + ) -> None: params = { "name": "test", "console": free_console_port, "start_command": "yes", "environment": "GNS3=1\nGNS4=0", - "extra_hosts": "test:127.0.0.1" + "extra_hosts": "test:127.0.0.1", } with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.update") as mock: - response = await compute_client.put(app.url_path_for("compute:update_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + response = await compute_client.put( + app.url_path_for("compute:update_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == 200 assert mock.called @@ -352,10 +333,7 @@ class TestDockerNodesRoutes: assert response.json()["extra_hosts"] == "test:127.0.0.1" async def test_docker_update_empty_strings_do_not_recreate_container( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project ) -> None: """ Web clients serialize empty form fields as "" while unset values are @@ -390,14 +368,15 @@ class TestDockerNodesRoutes: assert response.json()["start_command"] is None assert response.json()["console_http_path"] == "/" - async def test_docker_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - url = app.url_path_for("compute:start_docker_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:start_docker_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with patch("gns3server.compute.docker.docker_vm.DockerVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.start_capture") as mock: @@ -407,14 +386,15 @@ class TestDockerNodesRoutes: assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - async def test_docker_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - url = app.url_path_for("compute:stop_docker_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:stop_docker_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with patch("gns3server.compute.docker.docker_vm.DockerVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.stop_capture") as mock: @@ -422,18 +402,21 @@ class TestDockerNodesRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - - async def test_docker_duplicate(self, app: FastAPI, compute_client: AsyncClient, vm: dict, base_params: dict) -> None: + async def test_docker_duplicate( + self, app: FastAPI, compute_client: AsyncClient, vm: dict, base_params: dict + ) -> None: # create destination node first with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "nginx"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "8bd8153ea8f5"}): - response = await compute_client.post(app.url_path_for("compute:create_docker_node", - project_id=vm["project_id"]), json=base_params) + response = await compute_client.post( + app.url_path_for("compute:create_docker_node", project_id=vm["project_id"]), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED params = {"destination_node_id": response.json()["node_id"]} - response = await compute_client.post(app.url_path_for("compute:duplicate_docker_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + response = await compute_client.post( + app.url_path_for("compute:duplicate_docker_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == status.HTTP_201_CREATED diff --git a/tests/api/routes/compute/test_dynamips_nodes.py b/tests/api/routes/compute/test_dynamips_nodes.py index 840d2b3d2..a586aa508 100644 --- a/tests/api/routes/compute/test_dynamips_nodes.py +++ b/tests/api/routes/compute/test_dynamips_nodes.py @@ -28,7 +28,6 @@ pytestmark = pytest.mark.asyncio class TestDynamipsNodesRoutes: - # @pytest.yield_fixture(scope="module") # async def vm(compute_api, compute_project, fake_image): # @@ -47,7 +46,6 @@ class TestDynamipsNodesRoutes: # #with asyncio_patch("gns3server.compute.dynamips.Dynamips.find_dynamips", return_value=dynamips_path): # # yield response.json - # async def test_dynamips_vm_create(compute_api, compute_project, fake_image): # # params = { @@ -66,7 +64,6 @@ class TestDynamipsNodesRoutes: # assert response.json["project_id"] == compute_project.id # assert response.json["dynamips_id"] - # def test_dynamips_vm_get(compute_api, project, vm): # response = compute_api.get("/projects/{project_id}/dynamips/nodes/{node_id}".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) # assert response.status == 200 @@ -102,7 +99,6 @@ class TestDynamipsNodesRoutes: # assert mock.called # assert response.status == 204 - # def test_vbox_nio_create_udp(compute_api, vm): # # with asyncio_patch('gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.adapter_add_nio_binding') as mock: @@ -142,48 +138,42 @@ class TestDynamipsNodesRoutes: # assert response.json["name"] == "test" # assert response.json["console"] == free_console_port - @pytest.fixture def fake_image(self, tmpdir) -> str: """Create a fake Dynamips image on disk""" path = str(tmpdir / "7200.bin") with open(path, "wb+") as f: - f.write(b'\x7fELF\x01\x02\x01') + f.write(b"\x7fELF\x01\x02\x01") os.chmod(path, stat.S_IREAD) return path - @pytest.fixture def fake_file(self, tmpdir) -> str: """Create a fake file disk""" path = str(tmpdir / "7200.txt") with open(path, "w+") as f: - f.write('1') + f.write("1") os.chmod(path, stat.S_IREAD) return path - async def test_images( - self, app: FastAPI, - compute_client: AsyncClient, - tmpdir, fake_image: str, - fake_file: str + self, app: FastAPI, compute_client: AsyncClient, tmpdir, fake_image: str, fake_file: str ) -> None: with patch("gns3server.utils.images.default_images_directory", return_value=str(tmpdir)): response = await compute_client.get(app.url_path_for("compute:get_dynamips_images")) assert response.status_code == status.HTTP_200_OK - assert response.json() == [{"filename": "7200.bin", - "path": "7200.bin", - "filesize": 7, - "md5sum": "b0d5aa897d937aced5a6b1046e8f7e2e"}] - + assert response.json() == [ + {"filename": "7200.bin", "path": "7200.bin", "filesize": 7, "md5sum": "b0d5aa897d937aced5a6b1046e8f7e2e"} + ] async def test_upload_image(self, app: FastAPI, compute_client: AsyncClient, images_dir: str) -> None: - response = await compute_client.post(app.url_path_for("compute:upload_dynamips_image", filename="test2"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_dynamips_image", filename="test2"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT with open(os.path.join(images_dir, "IOS", "test2")) as f: @@ -193,35 +183,33 @@ class TestDynamipsNodesRoutes: checksum = f.read() assert checksum == "033bd94b1168d7e4f0d644c3c95e35bf" - async def test_upload_image_forbidden_location(self, app: FastAPI, compute_client: AsyncClient) -> None: file_path = "%2e%2e/hello" - response = await compute_client.post(app.url_path_for("compute:upload_dynamips_image", filename=file_path), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_dynamips_image", filename=file_path), content=b"TEST" + ) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_download_image(self, app: FastAPI, compute_client: AsyncClient, images_dir: str) -> None: - response = await compute_client.post(app.url_path_for("compute:upload_dynamips_image", filename="test3"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_dynamips_image", filename="test3"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT response = await compute_client.get(app.url_path_for("compute:download_dynamips_image", filename="test3")) assert response.status_code == status.HTTP_200_OK - async def test_download_image_forbidden(self, app: FastAPI, compute_client: AsyncClient, tmpdir) -> None: file_path = "foo/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd" response = await compute_client.get(app.url_path_for("compute:download_dynamips_image", filename=file_path)) assert response.status_code == status.HTTP_403_FORBIDDEN - @pytest.mark.skipif(os.getuid() == 0, reason="Root can delete any image") async def test_upload_image_permission_denied( - self, app: FastAPI, - compute_client: AsyncClient, - images_dir: str + self, app: FastAPI, compute_client: AsyncClient, images_dir: str ) -> None: os.makedirs(os.path.join(images_dir, "IOS"), exist_ok=True) @@ -229,5 +217,7 @@ class TestDynamipsNodesRoutes: f.write("") os.chmod(os.path.join(images_dir, "IOS", "test2.tmp"), 0) - response = await compute_client.post(app.url_path_for("compute:upload_dynamips_image", filename="test2"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_dynamips_image", filename="test2"), content=b"TEST" + ) assert response.status_code == status.HTTP_409_CONFLICT diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index af2a12ef1..f552b8c21 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -34,12 +34,14 @@ pytestmark = pytest.mark.asyncio class TestEthernetSwitchNodesRoutes: - @pytest_asyncio.fixture(autouse=True) async def stub_ubridge(self): """Keep uBridge from really starting and capture every command.""" - with asyncio_patch(f"{_NODE}._start_ubridge"), asyncio_patch(f"{_NODE}._stop_ubridge"), \ - asyncio_patch(f"{_NODE}._ubridge_send"): + with ( + asyncio_patch(f"{_NODE}._start_ubridge"), + asyncio_patch(f"{_NODE}._stop_ubridge"), + asyncio_patch(f"{_NODE}._ubridge_send"), + ): yield @pytest_asyncio.fixture @@ -47,8 +49,7 @@ class TestEthernetSwitchNodesRoutes: params = {"name": "Ethernet Switch"} response = await compute_client.post( - app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), - json=params + app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), json=params ) assert response.status_code == status.HTTP_201_CREATED @@ -65,15 +66,12 @@ class TestEthernetSwitchNodesRoutes: return {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} async def test_ethernet_switch_create( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project ) -> None: params = {"name": "Ethernet Switch 1"} response = await compute_client.post( - app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), - json=params + app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), json=params ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "Ethernet Switch 1" @@ -83,25 +81,24 @@ class TestEthernetSwitchNodesRoutes: # creation stands up the kernel bridge with VLAN filtering node = compute_project.get_node(response.json()["node_id"]) br = node._bridge_name - node._ubridge_send.assert_has_calls([ - call(f'brctl delete "{br}"'), - call(f'brctl create "{br}"'), - call(f'link set "{br}" up'), - call(f'brctl vlanfiltering "{br}" on'), - ]) + node._ubridge_send.assert_has_calls( + [ + call(f'brctl delete "{br}"'), + call(f'brctl create "{br}"'), + call(f'link set "{br}" up'), + call(f'brctl vlanfiltering "{br}" on'), + ] + ) async def test_ethernet_switch_get( - self, app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: response = await compute_client.get( app.url_path_for( "compute:get_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"] + node_id=ethernet_switch["node_id"], ) ) assert response.status_code == status.HTTP_200_OK @@ -110,18 +107,13 @@ class TestEthernetSwitchNodesRoutes: assert response.json()["status"] == "started" async def test_ethernet_switch_duplicate( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: # create destination switch first params = {"name": "Ethernet Switch 2"} response = await compute_client.post( - app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), - json=params + app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), json=params ) assert response.status_code == status.HTTP_201_CREATED @@ -130,16 +122,14 @@ class TestEthernetSwitchNodesRoutes: app.url_path_for( "compute:duplicate_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"]), json=params + node_id=ethernet_switch["node_id"], + ), + json=params, ) assert response.status_code == status.HTTP_201_CREATED async def test_ethernet_switch_update( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: params = {"name": "test", "console_type": "none"} @@ -148,8 +138,9 @@ class TestEthernetSwitchNodesRoutes: app.url_path_for( "compute:update_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"]), - json=params + node_id=ethernet_switch["node_id"], + ), + json=params, ) assert response.status_code == status.HTTP_200_OK @@ -160,11 +151,7 @@ class TestEthernetSwitchNodesRoutes: node._ubridge_send.assert_not_called() async def test_ethernet_switch_update_ports_qinq_proto( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: # a QinQ port with the 802.1ad ethertype must switch the bridge protocol @@ -179,8 +166,9 @@ class TestEthernetSwitchNodesRoutes: app.url_path_for( "compute:update_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"]), - json=port_params + node_id=ethernet_switch["node_id"], + ), + json=port_params, ) assert response.status_code == status.HTTP_200_OK @@ -190,55 +178,60 @@ class TestEthernetSwitchNodesRoutes: @pytest.mark.parametrize( "ports_settings", ( - {"name": "Ethernet0", "port_number": 0, "type": "dot42q", "vlan": 1}, # bad type - {"name": "Ethernet0", "port_number": 0, "type": "access"}, # missing vlan - {"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 1, - "ethertype": "0x88A8"}, # ethertype only for qinq - {"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 1, - "ethertype": "0x4242"}, # bad ethertype - {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 0}, # vlan < 1 - {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 4242}, # vlan > 4094 - ) + {"name": "Ethernet0", "port_number": 0, "type": "dot42q", "vlan": 1}, # bad type + {"name": "Ethernet0", "port_number": 0, "type": "access"}, # missing vlan + { + "name": "Ethernet0", + "port_number": 0, + "type": "dot1q", + "vlan": 1, + "ethertype": "0x88A8", + }, # ethertype only for qinq + {"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 1, "ethertype": "0x4242"}, # bad ethertype + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 0}, # vlan < 1 + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 4242}, # vlan > 4094 + ), ) async def test_ethernet_switch_update_ports_invalid( - self, - app: FastAPI, - compute_client: AsyncClient, - ethernet_switch: dict, - ports_settings: dict, + self, + app: FastAPI, + compute_client: AsyncClient, + ethernet_switch: dict, + ports_settings: dict, ) -> None: response = await compute_client.put( app.url_path_for( "compute:update_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"]), - json={"ports_mapping": [ports_settings]} + node_id=ethernet_switch["node_id"], + ), + json={"ports_mapping": [ports_settings]}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT async def test_ethernet_switch_delete( - self, app: FastAPI, - compute_client: AsyncClient, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict ) -> None: response = await compute_client.delete( app.url_path_for( "compute:delete_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"] + node_id=ethernet_switch["node_id"], ) ) assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_ethernet_switch_start(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: + async def test_ethernet_switch_start( + self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict + ) -> None: response = await compute_client.post( app.url_path_for( "compute:start_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"] + node_id=ethernet_switch["node_id"], ) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED @@ -249,39 +242,39 @@ class TestEthernetSwitchNodesRoutes: app.url_path_for( "compute:stop_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"] + node_id=ethernet_switch["node_id"], ) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - async def test_ethernet_switch_suspend(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: + async def test_ethernet_switch_suspend( + self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict + ) -> None: response = await compute_client.post( app.url_path_for( "compute:suspend_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"] + node_id=ethernet_switch["node_id"], ) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - async def test_ethernet_switch_reload(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: + async def test_ethernet_switch_reload( + self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict + ) -> None: response = await compute_client.post( app.url_path_for( "compute:reload_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"] + node_id=ethernet_switch["node_id"], ) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED async def test_ethernet_switch_create_udp_access( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: url = app.url_path_for( @@ -289,7 +282,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) response = await compute_client.post(url, json=self._udp_params()) assert response.status_code == status.HTTP_201_CREATED @@ -301,23 +294,21 @@ class TestEthernetSwitchNodesRoutes: tap = f"{br}-0" relay = f"{node.id}-0" # access VLAN 1 (default): drop default PVID 1, re-add 1 as PVID/untagged - node._ubridge_send.assert_has_calls([ - call(f"bridge create {relay}"), - call(f'bridge add_nio_tap {relay} "{tap}"'), - call(f'brctl addif "{br}" "{tap}"'), - call(f'brctl vlan_del "{br}" "{tap}" 1'), - call(f'brctl vlan_add "{br}" "{tap}" 1 pvid untagged'), - call(f"bridge add_nio_udp {relay} {nio.lport} {nio.rhost} {nio.rport}"), - call(f"bridge reset_packet_filters {relay}"), - call(f"bridge start {relay}"), - ]) + node._ubridge_send.assert_has_calls( + [ + call(f"bridge create {relay}"), + call(f'bridge add_nio_tap {relay} "{tap}"'), + call(f'brctl addif "{br}" "{tap}"'), + call(f'brctl vlan_del "{br}" "{tap}" 1'), + call(f'brctl vlan_add "{br}" "{tap}" 1 pvid untagged'), + call(f"bridge add_nio_udp {relay} {nio.lport} {nio.rhost} {nio.rport}"), + call(f"bridge reset_packet_filters {relay}"), + call(f"bridge start {relay}"), + ] + ) async def test_ethernet_switch_create_udp_dot1q( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: # make port 0 a dot1q trunk with native VLAN 10 @@ -325,10 +316,13 @@ class TestEthernetSwitchNodesRoutes: app.url_path_for( "compute:update_ethernet_switch", project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"]), - json={"ports_mapping": [ - {"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 10}, - ]} + node_id=ethernet_switch["node_id"], + ), + json={ + "ports_mapping": [ + {"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 10}, + ] + }, ) node = compute_project.get_node(ethernet_switch["node_id"]) node._ubridge_send.reset_mock() @@ -338,7 +332,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) response = await compute_client.post(url, json=self._udp_params()) assert response.status_code == status.HTTP_201_CREATED @@ -346,18 +340,16 @@ class TestEthernetSwitchNodesRoutes: br = node._bridge_name tap = f"{br}-0" # trunk: drop default 1, admit all VIDs tagged, mark native 10 PVID/untagged - node._ubridge_send.assert_has_calls([ - call(f'brctl vlan_del "{br}" "{tap}" 1'), - call(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094'), - call(f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged'), - ]) + node._ubridge_send.assert_has_calls( + [ + call(f'brctl vlan_del "{br}" "{tap}" 1'), + call(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094'), + call(f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged'), + ] + ) async def test_ethernet_switch_delete_nio( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: url = app.url_path_for( @@ -365,7 +357,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) await compute_client.post(url, json=self._udp_params()) @@ -377,7 +369,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -386,17 +378,15 @@ class TestEthernetSwitchNodesRoutes: br = node._bridge_name tap = f"{br}-0" relay = f"{node.id}-0" - node._ubridge_send.assert_has_calls([ - call(f'brctl delif "{br}" "{tap}"'), - call(f"bridge delete {relay}"), - ]) + node._ubridge_send.assert_has_calls( + [ + call(f'brctl delif "{br}" "{tap}"'), + call(f"bridge delete {relay}"), + ] + ) async def test_ethernet_switch_update_nio( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: url = app.url_path_for( @@ -404,7 +394,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) params = self._udp_params() params["filters"] = {"delay": [10, 0]} @@ -421,7 +411,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED @@ -432,11 +422,7 @@ class TestEthernetSwitchNodesRoutes: node._ubridge_send.assert_any_call(f"bridge reset_packet_filters {relay}") async def test_ethernet_switch_toggle_marker( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: # a marker installed via the NIO registers in the node's filter-bridge map @@ -445,7 +431,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) params = self._udp_params() params["markers"] = {"icmp": {"bpf": "icmp", "link_id": "link-1", "enabled": True}} @@ -459,7 +445,7 @@ class TestEthernetSwitchNodesRoutes: "compute:toggle_ethernet_switch_marker", project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], - marker_name="icmp" + marker_name="icmp", ) response = await compute_client.put(url, json={"enabled": False}) assert response.status_code == status.HTTP_200_OK @@ -472,17 +458,13 @@ class TestEthernetSwitchNodesRoutes: "compute:toggle_ethernet_switch_marker", project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], - marker_name="nope" + marker_name="nope", ) response = await compute_client.put(url, json={"enabled": True}) assert response.status_code == status.HTTP_404_NOT_FOUND async def test_ethernet_switch_start_capture( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: # capture needs a wired port @@ -491,7 +473,7 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) await compute_client.post(url, json=self._udp_params()) @@ -499,11 +481,13 @@ class TestEthernetSwitchNodesRoutes: node._ubridge_send.reset_mock() params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} - url = app.url_path_for("compute:start_ethernet_switch_capture", - project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:start_ethernet_switch_capture", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_200_OK @@ -512,11 +496,7 @@ class TestEthernetSwitchNodesRoutes: node._ubridge_send.assert_any_call(f'bridge start_capture {relay} "{node.get_nio(0).pcap_output_file}"') async def test_ethernet_switch_stop_capture( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ethernet_switch: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: # start a capture first @@ -526,17 +506,19 @@ class TestEthernetSwitchNodesRoutes: project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", - port_number="0" + port_number="0", ), - json=self._udp_params() + json=self._udp_params(), ) await compute_client.post( - app.url_path_for("compute:start_ethernet_switch_capture", - project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"], - adapter_number="0", - port_number="0"), - json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} + app.url_path_for( + "compute:start_ethernet_switch_capture", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0", + ), + json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}, ) node = compute_project.get_node(ethernet_switch["node_id"]) @@ -544,11 +526,13 @@ class TestEthernetSwitchNodesRoutes: relay = f"{node.id}-0" response = await compute_client.post( - app.url_path_for("compute:stop_ethernet_switch_capture", - project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"], - adapter_number="0", - port_number="0") + app.url_path_for( + "compute:stop_ethernet_switch_capture", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0", + ) ) assert response.status_code == status.HTTP_204_NO_CONTENT node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}") diff --git a/tests/api/routes/compute/test_images.py b/tests/api/routes/compute/test_images.py index 9aff40a3d..62209eea1 100644 --- a/tests/api/routes/compute/test_images.py +++ b/tests/api/routes/compute/test_images.py @@ -24,27 +24,22 @@ pytestmark = pytest.mark.asyncio class TestImagesRoutes: - async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None: with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock: response = await compute_client.post( - app.url_path_for("compute:pull_docker_image"), - json={"image": "nginx:latest"} + app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"} ) mock.assert_called_once_with("nginx:latest", force=True) assert response.status_code == status.HTTP_204_NO_CONTENT @pytest.mark.parametrize("image", ["", " ", "nginx latest"]) async def test_pull_docker_image_rejects_invalid_name( - self, app: FastAPI, compute_client: AsyncClient, image: str + self, app: FastAPI, compute_client: AsyncClient, image: str ) -> None: with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock: - response = await compute_client.post( - app.url_path_for("compute:pull_docker_image"), - json={"image": image} - ) + response = await compute_client.post(app.url_path_for("compute:pull_docker_image"), json={"image": image}) mock.assert_not_called() assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -52,8 +47,7 @@ class TestImagesRoutes: with asyncio_patch("gns3server.compute.docker.Docker.load_image") as mock: response = await compute_client.post( - app.url_path_for("compute:load_docker_image"), - content=b"docker-save-tar-bytes" + app.url_path_for("compute:load_docker_image"), content=b"docker-save-tar-bytes" ) mock.assert_called_once() # the tar is streamed to the Docker daemon as an async iterable diff --git a/tests/api/routes/compute/test_iou_nodes.py b/tests/api/routes/compute/test_iou_nodes.py index 18143d680..565ed3070 100644 --- a/tests/api/routes/compute/test_iou_nodes.py +++ b/tests/api/routes/compute/test_iou_nodes.py @@ -32,55 +32,44 @@ pytestmark = pytest.mark.asyncio class TestIOUNodesRoutes: - @pytest.fixture def fake_iou_bin(self, images_dir) -> str: """Create a fake IOU image on disk""" path = os.path.join(images_dir, "IOU", "iou.bin") with open(path, "w+") as f: - f.write('\x7fELF\x01\x01\x01') + f.write("\x7fELF\x01\x01\x01") os.chmod(path, stat.S_IREAD | stat.S_IEXEC) return path - @pytest.fixture def base_params(self, tmpdir, fake_iou_bin) -> dict: """Return standard parameters""" return {"application_id": 42, "name": "IOU-TEST-1", "path": "iou.bin"} - @pytest_asyncio.fixture - async def vm( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict - ) -> dict: + async def vm(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict) -> dict: - response = await compute_client.post(app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=base_params) + response = await compute_client.post( + app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED return response.json() - def startup_config_file(self, compute_project: Project, vm: dict) -> str: directory = os.path.join(compute_project.path, "project-files", "iou", vm["node_id"]) os.makedirs(directory, exist_ok=True) return os.path.join(directory, "startup-config.cfg") - async def test_iou_create( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict ) -> None: - response = await compute_client.post(app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=base_params) + response = await compute_client.post( + app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "IOU-TEST-1" assert response.json()["project_id"] == compute_project.id @@ -90,13 +79,8 @@ class TestIOUNodesRoutes: assert response.json()["nvram"] == 256 assert response.json()["l1_keepalives"] is False - async def test_iou_create_with_params( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict ) -> None: params = base_params @@ -108,7 +92,9 @@ class TestIOUNodesRoutes: params["startup_config_content"] = "hostname test" params["use_default_iou_values"] = False - response = await compute_client.post(app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=params) + response = await compute_client.post( + app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "IOU-TEST-1" assert response.json()["project_id"] == compute_project.id @@ -122,7 +108,6 @@ class TestIOUNodesRoutes: with open(self.startup_config_file(compute_project, response.json())) as f: assert f.read() == "hostname test" - @pytest.mark.parametrize( "name, status_code", ( @@ -136,13 +121,13 @@ class TestIOUNodesRoutes: ), ) async def test_iou_create_with_invalid_name( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - name: str, - status_code: int + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + base_params: dict, + name: str, + status_code: int, ) -> None: base_params["name"] = name @@ -151,40 +136,33 @@ class TestIOUNodesRoutes: ) assert response.status_code == status_code - async def test_iou_create_startup_config_already_exist( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict) -> None: + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict + ) -> None: """We don't erase a startup-config if already exist at project creation""" node_id = str(uuid.uuid4()) - startup_config_file_path = self.startup_config_file(compute_project, {'node_id': node_id}) - with open(startup_config_file_path, 'w+') as f: + startup_config_file_path = self.startup_config_file(compute_project, {"node_id": node_id}) + with open(startup_config_file_path, "w+") as f: f.write("echo hello") params = base_params params["node_id"] = node_id params["startup_config_content"] = "hostname test" - response = await compute_client.post(app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=params) + response = await compute_client.post( + app.url_path_for("compute:create_iou_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED with open(self.startup_config_file(compute_project, response.json())) as f: assert f.read() == "echo hello" + async def test_iou_get(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict) -> None: - async def test_iou_get( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict - ) -> None: - - response = await compute_client.get(app.url_path_for("compute:get_iou_node", project_id=vm["project_id"], node_id=vm["node_id"])) + response = await compute_client.get( + app.url_path_for("compute:get_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "IOU-TEST-1" assert response.json()["project_id"] == compute_project.id @@ -194,73 +172,62 @@ class TestIOUNodesRoutes: assert response.json()["nvram"] == 256 assert response.json()["l1_keepalives"] is False - async def test_iou_start(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.iou.iou_vm.IOUVM.start", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:start_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json={}) + response = await compute_client.post( + app.url_path_for("compute:start_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]), json={} + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_iou_start_with_iourc(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: params = {"iourc_content": "test"} with asyncio_patch("gns3server.compute.iou.iou_vm.IOUVM.start", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:start_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + response = await compute_client.post( + app.url_path_for("compute:start_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_iou_stop(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.iou.iou_vm.IOUVM.stop", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:stop_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:stop_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_iou_reload(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.iou.iou_vm.IOUVM.reload", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:reload_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:reload_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_iou_delete(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: with asyncio_patch("gns3server.compute.iou.IOU.delete_node", return_value=True) as mock: - response = await compute_client.delete(app.url_path_for("compute:delete_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.delete( + app.url_path_for("compute:delete_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_iou_suspend(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: response = await compute_client.delete( - app.url_path_for( - "compute:suspend_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"]) + app.url_path_for("compute:suspend_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED async def test_iou_update( - self, app: FastAPI, - compute_client: AsyncClient, - vm: dict, - free_console_port: int + self, app: FastAPI, compute_client: AsyncClient, vm: dict, free_console_port: int ) -> None: params = { @@ -274,9 +241,9 @@ class TestIOUNodesRoutes: "use_default_iou_values": True, } - response = await compute_client.put(app.url_path_for("compute:update_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + response = await compute_client.put( + app.url_path_for("compute:update_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]), json=params + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" assert response.json()["console"] == free_console_port @@ -287,173 +254,141 @@ class TestIOUNodesRoutes: assert response.json()["l1_keepalives"] is True assert response.json()["use_default_iou_values"] is True - async def test_iou_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} - url = app.url_path_for("compute:create_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:create_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" - async def test_iou_nio_update_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1", - "filters": {"packet_loss": 10} - } + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"packet_loss": 10}} - url = app.url_path_for("compute:create_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:create_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} params["filters"].clear() - url = app.url_path_for("compute:update_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:update_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" assert response.json()["filters"] == {} - async def test_iou_nio_create_ethernet( - self, - app: FastAPI, - compute_client: AsyncClient, - vm: dict, - ethernet_device: str + self, app: FastAPI, compute_client: AsyncClient, vm: dict, ethernet_device: str ) -> None: - params = { - "type": "nio_ethernet", - "ethernet_device": ethernet_device - } + params = {"type": "nio_ethernet", "ethernet_device": ethernet_device} - url = app.url_path_for("compute:create_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:create_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_ethernet" assert response.json()["ethernet_device"] == ethernet_device - async def test_iou_nio_create_ethernet_different_port( - self, - app: FastAPI, - compute_client: AsyncClient, - vm: dict, - ethernet_device: str + self, app: FastAPI, compute_client: AsyncClient, vm: dict, ethernet_device: str ) -> None: - params = { - "type": "nio_ethernet", - "ethernet_device": ethernet_device - } + params = {"type": "nio_ethernet", "ethernet_device": ethernet_device} - url = app.url_path_for("compute:create_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="3") + url = app.url_path_for( + "compute:create_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="3", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_ethernet" assert response.json()["ethernet_device"] == ethernet_device - async def test_iou_nio_create_tap( - self, - app: FastAPI, - compute_client: AsyncClient, - vm: dict, - ethernet_device: str + self, app: FastAPI, compute_client: AsyncClient, vm: dict, ethernet_device: str ) -> None: - params = { - "type": "nio_tap", - "tap_device": ethernet_device - } + params = {"type": "nio_tap", "tap_device": ethernet_device} - url = app.url_path_for("compute:create_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:create_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) with patch("gns3server.compute.base_manager.BaseManager.has_privileged_access", return_value=True): response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_tap" + async def test_iou_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - async def test_iou_delete_nio( - self, - app: FastAPI, - compute_client: AsyncClient, - vm: dict - ) -> None: + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - - url = app.url_path_for("compute:create_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:create_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) await compute_client.post(url, json=params) - url = app.url_path_for("compute:delete_iou_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="1", - port_number="0") + url = app.url_path_for( + "compute:delete_iou_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="1", + port_number="0", + ) response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_iou_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} - url = app.url_path_for("compute:start_iou_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:start_iou_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with patch("gns3server.compute.iou.iou_vm.IOUVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.iou.iou_vm.IOUVM.start_capture") as mock: @@ -462,14 +397,15 @@ class TestIOUNodesRoutes: assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - async def test_iou_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - url = app.url_path_for("compute:stop_iou_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:stop_iou_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with patch("gns3server.compute.iou.iou_vm.IOUVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.iou.iou_vm.IOUVM.stop_capture") as mock: @@ -477,7 +413,6 @@ class TestIOUNodesRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - # @pytest.mark.asyncio # async def test_iou_pcap(compute_api, vm, compute_project): # @@ -486,18 +421,20 @@ class TestIOUNodesRoutes: # response = await compute_client.get("/projects/{project_id}/iou/nodes/{node_id}/adapters/0/ports/0/pcap".format(project_id=compute_project.id, node_id=vm["node_id"]), raw=True) # assert response.status_code == status.HTTP_200_OK - async def test_images(self, app: FastAPI, compute_client: AsyncClient, fake_iou_bin: str) -> None: response = await compute_client.get(app.url_path_for("compute:get_iou_images")) assert response.status_code == status.HTTP_200_OK - assert response.json() == [{"filename": "iou.bin", "path": "iou.bin", "filesize": 7, "md5sum": "e573e8f5c93c6c00783f20c7a170aa6c"}] - + assert response.json() == [ + {"filename": "iou.bin", "path": "iou.bin", "filesize": 7, "md5sum": "e573e8f5c93c6c00783f20c7a170aa6c"} + ] async def test_upload_image(self, app: FastAPI, compute_client: AsyncClient, tmpdir) -> None: with patch("gns3server.compute.IOU.get_images_directory", return_value=str(tmpdir)): - response = await compute_client.post(app.url_path_for("compute:upload_iou_image", filename="test2"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_iou_image", filename="test2"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT with open(str(tmpdir / "test2")) as f: @@ -507,39 +444,42 @@ class TestIOUNodesRoutes: checksum = f.read() assert checksum == "033bd94b1168d7e4f0d644c3c95e35bf" - async def test_upload_image_forbidden_location(self, app: FastAPI, compute_client: AsyncClient) -> None: file_path = "%2e%2e/hello" - response = await compute_client.post(app.url_path_for("compute:upload_dynamips_image", filename=file_path), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_dynamips_image", filename=file_path), content=b"TEST" + ) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_download_image(self, app: FastAPI, compute_client: AsyncClient, images_dir: str) -> None: - response = await compute_client.post(app.url_path_for("compute:upload_dynamips_image", filename="test3"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_dynamips_image", filename="test3"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT response = await compute_client.get(app.url_path_for("compute:download_dynamips_image", filename="test3")) assert response.status_code == status.HTTP_200_OK - async def test_download_image_forbidden(self, app: FastAPI, compute_client: AsyncClient, tmpdir) -> None: file_path = "foo/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd" response = await compute_client.get(app.url_path_for("compute:download_iou_image", filename=file_path)) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_iou_duplicate(self, app: FastAPI, compute_client: AsyncClient, vm: dict, base_params: dict) -> None: # create destination node first - response = await compute_client.post(app.url_path_for("compute:create_iou_node", project_id=vm["project_id"]), json=base_params) + response = await compute_client.post( + app.url_path_for("compute:create_iou_node", project_id=vm["project_id"]), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED params = {"destination_node_id": response.json()["node_id"]} - response = await compute_client.post(app.url_path_for("compute:duplicate_iou_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + response = await compute_client.post( + app.url_path_for("compute:duplicate_iou_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == status.HTTP_201_CREATED diff --git a/tests/api/routes/compute/test_nat_nodes.py b/tests/api/routes/compute/test_nat_nodes.py index fea31fa01..aafa427aa 100644 --- a/tests/api/routes/compute/test_nat_nodes.py +++ b/tests/api/routes/compute/test_nat_nodes.py @@ -28,102 +28,81 @@ pytestmark = pytest.mark.asyncio class TestNATNodesRoutes: - @pytest_asyncio.fixture async def vm( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - ubridge_path: str, - on_gns3vm + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ubridge_path: str, on_gns3vm ) -> dict: with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat._start_ubridge"): - response = await compute_client.post(app.url_path_for("compute:create_nat_node", project_id=compute_project.id), - json={"name": "Nat 1"}) + response = await compute_client.post( + app.url_path_for("compute:create_nat_node", project_id=compute_project.id), json={"name": "Nat 1"} + ) assert response.status_code == status.HTTP_201_CREATED return response.json() - async def test_nat_create( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - on_gns3vm + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, on_gns3vm ) -> None: with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat._start_ubridge"): - response = await compute_client.post(app.url_path_for("compute:create_nat_node", project_id=compute_project.id), - json={"name": "Nat 1"}) + response = await compute_client.post( + app.url_path_for("compute:create_nat_node", project_id=compute_project.id), json={"name": "Nat 1"} + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "Nat 1" assert response.json()["project_id"] == compute_project.id + async def test_nat_get(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict) -> None: - async def test_nat_get( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict - ) -> None: - - response = await compute_client.get(app.url_path_for("compute:get_nat_node", project_id=vm["project_id"], node_id=vm["node_id"])) + response = await compute_client.get( + app.url_path_for("compute:get_nat_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "Nat 1" assert response.json()["project_id"] == compute_project.id assert response.json()["status"] == "started" - async def test_nat_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} - url = app.url_path_for("compute:create_nat_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_nat_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat.add_nio"): response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" - async def test_nat_nio_update_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1", - "filters": {"packet_loss": 10} - } + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"packet_loss": 10}} - url = app.url_path_for("compute:create_nat_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_nat_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} params["filters"].clear() - url = app.url_path_for("compute:update_nat_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:update_nat_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" @@ -131,83 +110,79 @@ class TestNATNodesRoutes: async def test_nat_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} - url = app.url_path_for("compute:create_nat_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_nat_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat.add_nio"): await compute_client.post(url, json=params) - url = app.url_path_for("compute:delete_nat_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:delete_nat_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat.remove_nio") as mock: response = await compute_client.delete(url) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_nat_delete(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - response = await compute_client.delete(app.url_path_for("compute:delete_nat_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.delete( + app.url_path_for("compute:delete_nat_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_nat_update(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - response = await compute_client.put(app.url_path_for("compute:update_nat_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json={"name": "test"}) + response = await compute_client.put( + app.url_path_for("compute:update_nat_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json={"name": "test"}, + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" - async def test_nat_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} - url = app.url_path_for("compute:start_nat_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:start_nat_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat.start_capture") as mock: response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_200_OK assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - async def test_nat_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - url = app.url_path_for("compute:stop_nat_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:stop_nat_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) with asyncio_patch("gns3server.compute.builtin.nodes.nat.Nat.stop_capture") as mock: response = await compute_client.post(url) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - # @pytest.mark.asyncio # async def test_nat_pcap(self, compute_api, vm, compute_project): # diff --git a/tests/api/routes/compute/test_notifications.py b/tests/api/routes/compute/test_notifications.py index c21b9e8e5..da8663e6c 100644 --- a/tests/api/routes/compute/test_notifications.py +++ b/tests/api/routes/compute/test_notifications.py @@ -27,16 +27,16 @@ from gns3server.compute.notification_manager import NotificationManager # # FIXME: how to test websockets # pass - #with compute_api.ws("/notifications/ws") as ws: +# with compute_api.ws("/notifications/ws") as ws: - # answer = await ws.receive_text() - # print(answer) - # answer = json.loads(answer) - # - # assert answer["action"] == "ping" - # - # NotificationManager.instance().emit("test", {}) - # - # answer = await ws.receive_text() - # answer = json.loads(answer) - # assert answer["action"] == "test" +# answer = await ws.receive_text() +# print(answer) +# answer = json.loads(answer) +# +# assert answer["action"] == "ping" +# +# NotificationManager.instance().emit("test", {}) +# +# answer = await ws.receive_text() +# answer = json.loads(answer) +# assert answer["action"] == "test" diff --git a/tests/api/routes/compute/test_projects.py b/tests/api/routes/compute/test_projects.py index 6131db742..88c4b463a 100644 --- a/tests/api/routes/compute/test_projects.py +++ b/tests/api/routes/compute/test_projects.py @@ -31,23 +31,15 @@ pytestmark = pytest.mark.asyncio class TestComputeProjectRoutes: - @pytest.fixture def base_params(self, tmpdir) -> dict: """Return standard parameters""" - params = { - "name": "test", - "project_id": str(uuid.uuid4()) - } + params = {"name": "test", "project_id": str(uuid.uuid4())} return params - async def test_create_project_without_dir( - self, - app: FastAPI, - compute_client: AsyncClient, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, base_params: dict ) -> None: response = await compute_client.post(app.url_path_for("compute:create_compute_project"), json=base_params) @@ -55,32 +47,27 @@ class TestComputeProjectRoutes: assert response.json()["project_id"] == base_params["project_id"] assert response.json()["name"] == base_params["name"] - - async def test_show_project( - self, - app: FastAPI, - compute_client: AsyncClient, - base_params: dict - ) -> None: + async def test_show_project(self, app: FastAPI, compute_client: AsyncClient, base_params: dict) -> None: response = await compute_client.post(app.url_path_for("compute:create_compute_project"), json=base_params) assert response.status_code == status.HTTP_201_CREATED - response = await compute_client.get(app.url_path_for("compute:get_compute_project", project_id=base_params["project_id"])) + response = await compute_client.get( + app.url_path_for("compute:get_compute_project", project_id=base_params["project_id"]) + ) - #print(response.json().keys()) - #assert len(response.json().keys()) == 3 + # print(response.json().keys()) + # assert len(response.json().keys()) == 3 assert response.json()["project_id"] == base_params["project_id"] assert response.json()["name"] == base_params["name"] assert response.json()["variables"] is None - async def test_show_project_invalid_uuid(self, app: FastAPI, compute_client: AsyncClient) -> None: - response = await compute_client.get(app.url_path_for("compute:get_compute_project", - project_id="50010203-0405-0607-0809-0a0b0c0d0e42")) + response = await compute_client.get( + app.url_path_for("compute:get_compute_project", project_id="50010203-0405-0607-0809-0a0b0c0d0e42") + ) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_list_projects(self, app: FastAPI, compute_client: AsyncClient) -> dict: ProjectManager.instance()._projects = {} @@ -97,41 +84,43 @@ class TestComputeProjectRoutes: assert len(response.json()) == 2 assert "51010203-0405-0607-0809-0a0b0c0d0e0f" in [p["project_id"] for p in response.json()] - async def test_delete_project(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: with asyncio_patch("gns3server.compute.project.Project.delete", return_value=True) as mock: - response = await compute_client.delete(app.url_path_for("compute:delete_compute_project", project_id=compute_project.id)) + response = await compute_client.delete( + app.url_path_for("compute:delete_compute_project", project_id=compute_project.id) + ) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - async def test_update_project(self, app: FastAPI, compute_client: AsyncClient, base_params: dict) -> None: response = await compute_client.post(app.url_path_for("compute:create_compute_project"), json=base_params) assert response.status_code == status.HTTP_201_CREATED params = {"variables": [{"name": "TEST1", "value": "VAL1"}]} - response = await compute_client.put(app.url_path_for("compute:update_compute_project", project_id=base_params["project_id"]), - json=params) + response = await compute_client.put( + app.url_path_for("compute:update_compute_project", project_id=base_params["project_id"]), json=params + ) assert response.status_code == status.HTTP_200_OK assert response.json()["variables"] == [{"name": "TEST1", "value": "VAL1"}] - async def test_delete_project_invalid_uuid(self, app: FastAPI, compute_client: AsyncClient) -> None: - response = await compute_client.delete(app.url_path_for("compute:delete_compute_project", project_id=str(uuid.uuid4()))) + response = await compute_client.delete( + app.url_path_for("compute:delete_compute_project", project_id=str(uuid.uuid4())) + ) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_close_project(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: with asyncio_patch("gns3server.compute.project.Project.close", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:close_compute_project", project_id=compute_project.id)) + response = await compute_client.post( + app.url_path_for("compute:close_compute_project", project_id=compute_project.id) + ) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - # @pytest.mark.asyncio # async def test_close_project_two_client_connected(compute_api, compute_project): # @@ -141,13 +130,13 @@ class TestComputeProjectRoutes: # assert response.status_code == status.HTTP_204_NO_CONTENT # assert not mock.called - async def test_close_project_invalid_uuid(self, app: FastAPI, compute_client: AsyncClient) -> None: - response = await compute_client.post(app.url_path_for("compute:close_compute_project", project_id=str(uuid.uuid4()))) + response = await compute_client.post( + app.url_path_for("compute:close_compute_project", project_id=str(uuid.uuid4())) + ) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_get_file(self, app: FastAPI, compute_client: AsyncClient) -> None: project = ProjectManager.instance().create_project(project_id="01010203-0405-0607-0809-0a0b0c0d0e0b") @@ -155,79 +144,67 @@ class TestComputeProjectRoutes: with open(os.path.join(project.path, "hello"), "w+") as f: f.write("world") - response = await compute_client.get(app.url_path_for("compute:get_compute_project_file", project_id=project.id, file_path="hello")) + response = await compute_client.get( + app.url_path_for("compute:get_compute_project_file", project_id=project.id, file_path="hello") + ) assert response.status_code == status.HTTP_200_OK assert response.content == b"world" - response = await compute_client.get(app.url_path_for("compute:get_compute_project_file", project_id=project.id, file_path="false")) + response = await compute_client.get( + app.url_path_for("compute:get_compute_project_file", project_id=project.id, file_path="false") + ) assert response.status_code == status.HTTP_404_NOT_FOUND - response = await compute_client.get(app.url_path_for("compute:get_compute_project_file", - project_id=project.id, - file_path="../hello")) + response = await compute_client.get( + app.url_path_for("compute:get_compute_project_file", project_id=project.id, file_path="../hello") + ) assert response.status_code == status.HTTP_404_NOT_FOUND - - async def test_get_file_forbidden_location( - self, - app: FastAPI, - compute_client: AsyncClient, - config, - tmpdir - ) -> None: + async def test_get_file_forbidden_location(self, app: FastAPI, compute_client: AsyncClient, config, tmpdir) -> None: config.settings.Server.projects_path = str(tmpdir) project = ProjectManager.instance().create_project(project_id="01010203-0405-0607-0809-0a0b0c0d0e0b") file_path = "foo/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd" response = await compute_client.get( - app.url_path_for( - "compute:get_compute_project_file", - project_id=project.id, - file_path=file_path - ) + app.url_path_for("compute:get_compute_project_file", project_id=project.id, file_path=file_path) ) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_write_file(self, app: FastAPI, compute_client: AsyncClient, config, tmpdir) -> None: config.settings.Server.projects_path = str(tmpdir) project = ProjectManager.instance().create_project(project_id="01010203-0405-0607-0809-0a0b0c0d0e0b") - response = await compute_client.post(app.url_path_for("compute:write_compute_project_file", - project_id=project.id, - file_path="hello"), content=b"world") + response = await compute_client.post( + app.url_path_for("compute:write_compute_project_file", project_id=project.id, file_path="hello"), + content=b"world", + ) assert response.status_code == status.HTTP_204_NO_CONTENT with open(os.path.join(project.path, "hello")) as f: assert f.read() == "world" - response = await compute_client.post(app.url_path_for("compute:write_compute_project_file", - project_id=project.id, - file_path="../hello")) + response = await compute_client.post( + app.url_path_for("compute:write_compute_project_file", project_id=project.id, file_path="../hello") + ) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_write_file_forbidden_location( - self, - app: FastAPI, - compute_client: AsyncClient, - config, - tmpdir + self, app: FastAPI, compute_client: AsyncClient, config, tmpdir ) -> None: config.settings.Server.projects_path = str(tmpdir) project = ProjectManager.instance().create_project(project_id="01010203-0405-0607-0809-0a0b0c0d0e0b") file_path = "%2e%2e/hello" - response = await compute_client.post(app.url_path_for("compute:write_compute_project_file", - project_id=project.id, - file_path=file_path), content=b"world") + response = await compute_client.post( + app.url_path_for("compute:write_compute_project_file", project_id=project.id, file_path=file_path), + content=b"world", + ) assert response.status_code == status.HTTP_403_FORBIDDEN class TestBatchNIOEdgeCases: - @pytest.mark.asyncio async def test_dynamips_router_dispatch_to_slot_add_nio_binding(self): """_add_nio_binding dispatches Dynamips router to slot_add_nio_binding.""" @@ -276,7 +253,7 @@ class TestBatchNIOEdgeCases: dyn = _FakeDynamips() base = _FakeBase() - assert len(inspect.signature(dyn.create_nio).parameters) == 2 # Dynamips + assert len(inspect.signature(dyn.create_nio).parameters) == 2 # Dynamips assert len(inspect.signature(base.create_nio).parameters) == 1 # standard assert inspect.iscoroutinefunction(dyn.create_nio) assert not inspect.iscoroutinefunction(base.create_nio) diff --git a/tests/api/routes/compute/test_qemu_nodes.py b/tests/api/routes/compute/test_qemu_nodes.py index 1bed9cf39..3f53d3538 100644 --- a/tests/api/routes/compute/test_qemu_nodes.py +++ b/tests/api/routes/compute/test_qemu_nodes.py @@ -33,113 +33,91 @@ pytestmark = pytest.mark.asyncio class TestQemuNodesRoutes: - @pytest.fixture def fake_qemu_bin(self, monkeypatch, tmpdir) -> str: - + monkeypatch.setenv("PATH", str(tmpdir)) bin_path = os.path.join(os.environ["PATH"], "qemu-system-x86_64") with open(bin_path, "w+") as f: f.write("1") os.chmod(bin_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) return bin_path - - + @pytest.fixture def fake_qemu_vm(self, images_dir) -> str: - + img_dir = os.path.join(images_dir, "QEMU") bin_path = os.path.join(img_dir, "linux载.img") with open(bin_path, "w+") as f: f.write("1234567") os.chmod(bin_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) return bin_path - - + @pytest.fixture def fake_qemu_img_binary(self, tmpdir): - + bin_path = str(tmpdir / "qemu-img") with open(bin_path, "w+") as f: f.write("1") os.chmod(bin_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) return bin_path - - + @pytest.fixture def base_params(self, tmpdir, fake_qemu_bin) -> dict: """Return standard parameters""" - + return {"name": "QEMU-TEST-1", "qemu_path": fake_qemu_bin} - - + @pytest_asyncio.fixture async def qemu_vm( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict ) -> None: - + response = await compute_client.post( - app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), - json=base_params + app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=base_params ) assert response.status_code == status.HTTP_201_CREATED return response.json() - - + async def test_qemu_create( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_bin: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_bin: str ) -> None: - - response = await compute_client.post(app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=base_params) + + response = await compute_client.post( + app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "QEMU-TEST-1" assert response.json()["project_id"] == compute_project.id assert response.json()["qemu_path"] == fake_qemu_bin assert response.json()["platform"] == "x86_64" - - + async def test_qemu_create_platform( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_bin: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_bin: str ): - + base_params["qemu_path"] = None base_params["platform"] = "x86_64" - response = await compute_client.post(app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=base_params) + response = await compute_client.post( + app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=base_params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "QEMU-TEST-1" assert response.json()["project_id"] == compute_project.id assert response.json()["qemu_path"] == fake_qemu_bin assert response.json()["platform"] == "x86_64" - - + @pytest.mark.asyncio async def test_qemu_create_with_params( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_vm: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_vm: str ): - + params = base_params params["ram"] = 1024 params["hda_disk_image"] = "linux载.img" - response = await compute_client.post(app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=params) + response = await compute_client.post( + app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "QEMU-TEST-1" assert response.json()["project_id"] == compute_project.id @@ -147,12 +125,7 @@ class TestQemuNodesRoutes: assert response.json()["hda_disk_image"] == "linux载.img" async def test_qemu_create_discards_stale_overlay_for_replacement( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_vm: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_vm: str ): node_id = str(uuid.uuid4()) @@ -182,12 +155,7 @@ class TestQemuNodesRoutes: assert not os.path.exists(stale_checksum) async def test_qemu_create_preserves_existing_overlay_without_reset_marker( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_vm: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_vm: str ): node_id = str(uuid.uuid4()) @@ -210,12 +178,7 @@ class TestQemuNodesRoutes: assert os.path.exists(overlay) async def test_qemu_create_preserves_overlay_when_replacement_is_missing( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_vm: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_vm: str ): node_id = str(uuid.uuid4()) @@ -239,12 +202,7 @@ class TestQemuNodesRoutes: assert os.path.exists(overlay) async def test_qemu_create_resets_valid_replacement_when_another_disk_is_missing( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - fake_qemu_vm: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, base_params: dict, fake_qemu_vm: str ): node_id = str(uuid.uuid4()) @@ -267,8 +225,7 @@ class TestQemuNodesRoutes: assert response.status_code == status.HTTP_409_CONFLICT assert not os.path.exists(stale_overlay) - - + @pytest.mark.parametrize( "name, status_code", ( @@ -286,21 +243,21 @@ class TestQemuNodesRoutes: ), ) async def test_qemu_create_with_invalid_name( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - base_params: dict, - name: str, - status_code: int + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + base_params: dict, + name: str, + status_code: int, ) -> None: - + base_params["name"] = name response = await compute_client.post( app.url_path_for("compute:create_qemu_node", project_id=compute_project.id), json=base_params ) assert response.status_code == status_code - + # async def test_qemu_create_with_project_file(self, app: FastAPI, # compute_client: AsyncClient, # compute_project: Project, @@ -321,16 +278,9 @@ class TestQemuNodesRoutes: # assert response.status_code == status.HTTP_201_CREATED # assert response.json()["hda_disk_image"] == "hello.img" # assert response.json()["hda_disk_image_md5sum"] == "7d793037a0760186574b0282f2f435e7" - - - async def test_qemu_get( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - qemu_vm: dict - ): - + + async def test_qemu_get(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, qemu_vm: dict): + response = await compute_client.get( app.url_path_for("compute:get_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) ) @@ -338,151 +288,130 @@ class TestQemuNodesRoutes: assert response.json()["name"] == "QEMU-TEST-1" assert response.json()["project_id"] == compute_project.id assert response.json()["node_directory"] == os.path.join( - compute_project.path, - "project-files", - "qemu", - qemu_vm["node_id"] + compute_project.path, "project-files", "qemu", qemu_vm["node_id"] ) - - + async def test_qemu_start(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.start", return_value=True) as mock: response = await compute_client.post( - app.url_path_for("compute:start_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) + app.url_path_for( + "compute:start_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ) ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_stop(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.stop", return_value=True) as mock: response = await compute_client.post( app.url_path_for("compute:stop_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_reload(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.reload", return_value=True) as mock: response = await compute_client.post( - app.url_path_for("compute:reload_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) + app.url_path_for( + "compute:reload_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ) ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_suspend(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.suspend", return_value=True) as mock: response = await compute_client.post( - app.url_path_for("compute:suspend_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) + app.url_path_for( + "compute:suspend_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ) ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_resume(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.resume", return_value=True) as mock: response = await compute_client.post( - app.url_path_for("compute:resume_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) + app.url_path_for( + "compute:resume_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ) ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_delete(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - + with asyncio_patch("gns3server.compute.qemu.Qemu.delete_node", return_value=True) as mock: response = await compute_client.delete( - app.url_path_for("compute:delete_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]) + app.url_path_for( + "compute:delete_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ) ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_update( - self, - app: FastAPI, - compute_client: AsyncClient, - qemu_vm: dict, - free_console_port: int, - fake_qemu_vm: str + self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict, free_console_port: int, fake_qemu_vm: str ) -> None: - - params = { - "name": "test", - "console": free_console_port, - "ram": 1024, - "hdb_disk_image": "linux载.img" - } - + + params = {"name": "test", "console": free_console_port, "ram": 1024, "hdb_disk_image": "linux载.img"} + response = await compute_client.put( app.url_path_for("compute:update_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]), - json=params + json=params, ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" assert response.json()["console"] == free_console_port assert response.json()["hdb_disk_image"] == "linux载.img" assert response.json()["ram"] == 1024 - - + async def test_qemu_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.add_ubridge_udp_connection"): await compute_client.put( - app.url_path_for("compute:update_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]), - json={"adapters": 2} + app.url_path_for( + "compute:update_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ), + json={"adapters": 2}, ) - + url = app.url_path_for( "compute:create_qemu_node_nio", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="1", - port_number="0" + port_number="0", ) response = await compute_client.post(url, json=params) - + assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" - - + async def test_qemu_nio_update_udp(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1", - "filters": {"packet_loss": 10} - } - + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"packet_loss": 10}} + await compute_client.put( app.url_path_for("compute:update_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]), - json={"adapters": 2} + json={"adapters": 2}, ) - + url = app.url_path_for( "compute:create_qemu_node_nio", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="1", - port_number="0" + port_number="0", ) - + response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} @@ -493,127 +422,125 @@ class TestQemuNodesRoutes: project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="1", - port_number="0" + port_number="0", ) response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" assert response.json()["filters"] == {} - - + async def test_qemu_delete_nio(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._ubridge_send"): await compute_client.put( - app.url_path_for("compute:update_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]), - json={"adapters": 2} + app.url_path_for( + "compute:update_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ), + json={"adapters": 2}, ) - + url = app.url_path_for( "compute:create_qemu_node_nio", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="1", - port_number="0" + port_number="0", ) await compute_client.post(url, json=params) - + url = app.url_path_for( "compute:delete_qemu_node_nio", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="1", - port_number="0" + port_number="0", ) response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_images(self, app: FastAPI, compute_client: AsyncClient, fake_qemu_vm) -> None: - + response = await compute_client.get(app.url_path_for("compute:get_qemu_images")) assert response.status_code == status.HTTP_200_OK print(response.json()) - assert {"filename": "linux载.img", "path": "linux载.img", "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7} in response.json() - - + assert { + "filename": "linux载.img", + "path": "linux载.img", + "md5sum": "fcea920f7412b5da7be0cf42b8c93759", + "filesize": 7, + } in response.json() + async def test_upload_image(self, app: FastAPI, compute_client: AsyncClient, tmpdir: str) -> None: - + with patch("gns3server.compute.Qemu.get_images_directory", return_value=str(tmpdir)): - - response = await compute_client.post(app.url_path_for("compute:upload_qemu_image", - filename="test2使"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_qemu_image", filename="test2使"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT - + with open(str(tmpdir / "test2使")) as f: assert f.read() == "TEST" - + with open(str(tmpdir / "test2使.md5sum")) as f: checksum = f.read() assert checksum == "033bd94b1168d7e4f0d644c3c95e35bf" - - - async def test_upload_image_ova(self, app: FastAPI, compute_client: AsyncClient, tmpdir:str) -> None: - + + async def test_upload_image_ova(self, app: FastAPI, compute_client: AsyncClient, tmpdir: str) -> None: + with patch("gns3server.compute.Qemu.get_images_directory", return_value=str(tmpdir)): - - response = await compute_client.post(app.url_path_for("compute:upload_qemu_image", - filename="test2.ova/test2.vmdk"), content=b"TEST") + response = await compute_client.post( + app.url_path_for("compute:upload_qemu_image", filename="test2.ova/test2.vmdk"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT - + with open(str(tmpdir / "test2.ova" / "test2.vmdk")) as f: assert f.read() == "TEST" - + with open(str(tmpdir / "test2.ova" / "test2.vmdk.md5sum")) as f: checksum = f.read() assert checksum == "033bd94b1168d7e4f0d644c3c95e35bf" - - + async def test_upload_image_forbidden_location( - self, app: FastAPI, - compute_client: AsyncClient, - tmpdir: str + self, app: FastAPI, compute_client: AsyncClient, tmpdir: str ) -> None: - - response = await compute_client.post(app.url_path_for("compute:upload_qemu_image", - filename="/qemu/images/../../test2"), content=b"TEST") + + response = await compute_client.post( + app.url_path_for("compute:upload_qemu_image", filename="/qemu/images/../../test2"), content=b"TEST" + ) assert response.status_code == status.HTTP_403_FORBIDDEN - - + async def test_download_image(self, app: FastAPI, compute_client: AsyncClient, images_dir: str) -> None: - - response = await compute_client.post(app.url_path_for("compute:upload_qemu_image", filename="test3"), content=b"TEST") + + response = await compute_client.post( + app.url_path_for("compute:upload_qemu_image", filename="test3"), content=b"TEST" + ) assert response.status_code == status.HTTP_204_NO_CONTENT - + response = await compute_client.get(app.url_path_for("compute:download_qemu_image", filename="test3")) assert response.status_code == status.HTTP_200_OK - - + async def test_download_image_forbidden_location(self, app: FastAPI, compute_client: AsyncClient, tmpdir) -> None: - + file_path = "foo/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd" response = await compute_client.get(app.url_path_for("compute:download_qemu_image", filename=file_path)) assert response.status_code == status.HTTP_403_FORBIDDEN - - + @pytest.mark.skipif(os.getuid() == 0, reason="Root can delete any image") - async def test_upload_image_permission_denied(self, app: FastAPI, compute_client: AsyncClient, images_dir: str) -> None: - + async def test_upload_image_permission_denied( + self, app: FastAPI, compute_client: AsyncClient, images_dir: str + ) -> None: + with open(os.path.join(images_dir, "QEMU", "test2.tmp"), "w+") as f: f.write("") os.chmod(os.path.join(images_dir, "QEMU", "test2.tmp"), 0) - - response = await compute_client.post(app.url_path_for("compute:upload_qemu_image", filename="test2"), content=b"TEST") + + response = await compute_client.post( + app.url_path_for("compute:upload_qemu_image", filename="test2"), content=b"TEST" + ) assert response.status_code == status.HTTP_409_CONFLICT - - + # @pytest.mark.asyncio # async def test_create_img_relative(self, app: FastAPI, compute_client: AsyncClient): # @@ -665,69 +592,62 @@ class TestQemuNodesRoutes: # with asyncio_patch("gns3server.compute.Qemu.create_disk"): # response = await compute_client.post(app.url_path_for("compute:create_qemu_image"), json=params) # assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_capabilities(self, app: FastAPI, compute_client: AsyncClient) -> None: - + with asyncio_patch("gns3server.compute.Qemu.get_kvm_archs", return_value=["x86_64"]): response = await compute_client.get(app.url_path_for("compute:get_qemu_capabilities")) assert response.json()["kvm"] == ["x86_64"] - - + async def test_qemu_duplicate( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - qemu_vm: dict, - base_params: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, qemu_vm: dict, base_params: dict ) -> None: - + # create destination node first response = await compute_client.post( - app.url_path_for("compute:create_qemu_node", project_id=qemu_vm["project_id"]), - json=base_params + app.url_path_for("compute:create_qemu_node", project_id=qemu_vm["project_id"]), json=base_params ) - + assert response.status_code == status.HTTP_201_CREATED params = {"destination_node_id": response.json()["node_id"]} response = await compute_client.post( - app.url_path_for("compute:duplicate_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"]), - json=params + app.url_path_for( + "compute:duplicate_qemu_node", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"] + ), + json=params, ) assert response.status_code == status.HTTP_201_CREATED - - + async def test_qemu_create_disk_image( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - fake_qemu_img_binary: str, - qemu_vm: dict, + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + fake_qemu_img_binary: str, + qemu_vm: dict, ): - + options = { "format": "qcow2", "preallocation": "metadata", "cluster_size": 64, "refcount_bits": 12, "lazy_refcounts": "off", - "size": 30 + "size": 30, } - + with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()) as qemu_img: response = await compute_client.post( app.url_path_for( "compute:create_qemu_disk_image", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], - disk_name="disk.qcow2" + disk_name="disk.qcow2", ), - json=options + json=options, ) assert response.status_code == status.HTTP_204_NO_CONTENT - + args, kwargs = qemu_img.call_args assert args == ( fake_qemu_img_binary, @@ -743,39 +663,34 @@ class TestQemuNodesRoutes: "-o", "refcount_bits=12", os.path.join(qemu_vm["node_directory"], "disk.qcow2"), - "30M" + "30M", ) - - + async def test_qemu_create_disk_image_already_exists( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - fake_qemu_img_binary: str, - qemu_vm: dict, + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + fake_qemu_img_binary: str, + qemu_vm: dict, ): - + node = compute_project.get_node(qemu_vm["node_id"]) shutil.copy("tests/resources/empty8G.qcow2", os.path.join(node.working_dir, "disk.qcow2")) - - options = { - "format": "qcow2", - "size": 100 - } - + + options = {"format": "qcow2", "size": 100} + response = await compute_client.post( app.url_path_for( "compute:create_qemu_disk_image", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], - disk_name="disk.qcow2" + disk_name="disk.qcow2", ), - json=options + json=options, ) assert response.status_code == status.HTTP_409_CONFLICT - - + # async def test_qemu_create_disk_image_with_not_supported_characters_by_filesystem( # app: FastAPI, # compute_client: AsyncClient, @@ -803,125 +718,116 @@ class TestQemuNodesRoutes: # json=options # ) # assert response.status_code == status.HTTP_409_CONFLICT - - + async def test_qemu_update_disk_image( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - fake_qemu_img_binary: str, - qemu_vm: dict, + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + fake_qemu_img_binary: str, + qemu_vm: dict, ) -> None: - + node = compute_project.get_node(qemu_vm["node_id"]) shutil.copy("tests/resources/empty8G.qcow2", os.path.join(node.working_dir, "disk.qcow2")) - + with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()) as qemu_img: response = await compute_client.put( app.url_path_for( "compute:update_qemu_disk_image", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], - disk_name="disk.qcow2" + disk_name="disk.qcow2", ), - json={"extend": 10} + json={"extend": 10}, ) assert response.status_code == status.HTTP_204_NO_CONTENT - + assert qemu_img.called args, kwargs = qemu_img.call_args assert args == ( fake_qemu_img_binary, "resize", os.path.join(qemu_vm["node_directory"], "disk.qcow2"), - "+10M" + "+10M", ) - - + async def test_qemu_delete_disk_image( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - qemu_vm: dict, + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + qemu_vm: dict, ) -> None: - + node = compute_project.get_node(qemu_vm["node_id"]) shutil.copy("tests/resources/empty8G.qcow2", os.path.join(node.working_dir, "disk.qcow2")) - + response = await compute_client.delete( app.url_path_for( "compute:delete_qemu_disk_image", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], - disk_name="disk.qcow2" + disk_name="disk.qcow2", ) ) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_delete_disk_image_missing_image( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - qemu_vm: dict, + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + qemu_vm: dict, ) -> None: - + response = await compute_client.delete( app.url_path_for( "compute:delete_qemu_disk_image", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], - disk_name="unknown_image.qcow2" + disk_name="unknown_image.qcow2", ) ) assert response.status_code == status.HTTP_409_CONFLICT - - + @pytest.mark.asyncio async def test_qemu_start_capture(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict): - - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } - + + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} + url = app.url_path_for( "compute:start_qemu_node_capture", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) - + with patch("gns3server.compute.qemu.qemu_vm.QemuVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.start_capture") as mock: response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_200_OK assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - - + @pytest.mark.asyncio async def test_qemu_stop_capture(self, app: FastAPI, compute_client: AsyncClient, qemu_vm: dict): - + url = app.url_path_for( "compute:stop_qemu_node_capture", project_id=qemu_vm["project_id"], node_id=qemu_vm["node_id"], adapter_number="0", - port_number="0" + port_number="0", ) - + with patch("gns3server.compute.qemu.qemu_vm.QemuVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.stop_capture") as mock: response = await compute_client.post(url) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - - + # @pytest.mark.asyncio # async def test_qemu_pcap(self, app: FastAPI, compute_client: AsyncClient, vm, compute_project): # diff --git a/tests/api/routes/compute/test_virtualbox_nodes.py b/tests/api/routes/compute/test_virtualbox_nodes.py index e29f104dc..e50420518 100644 --- a/tests/api/routes/compute/test_virtualbox_nodes.py +++ b/tests/api/routes/compute/test_virtualbox_nodes.py @@ -29,129 +29,119 @@ pytestmark = pytest.mark.asyncio class TestVirtualBoxNodesRoutes: - @pytest_asyncio.fixture async def vm(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: - + vboxmanage_path = "/fake/VboxManage" - params = { - "name": "VMTEST", - "vmname": "VMTEST", - "linked_clone": False - } - - with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.create", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:create_virtualbox_node", project_id=compute_project.id), - json=params) + params = {"name": "VMTEST", "vmname": "VMTEST", "linked_clone": False} + + with asyncio_patch( + "gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.create", return_value=True + ) as mock: + response = await compute_client.post( + app.url_path_for("compute:create_virtualbox_node", project_id=compute_project.id), json=params + ) assert mock.called assert response.status_code == status.HTTP_201_CREATED - + with patch("gns3server.compute.virtualbox.VirtualBox.find_vboxmanage", return_value=vboxmanage_path): return response.json() - - + async def test_vbox_create(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: - - params = { - "name": "VM1", - "vmname": "VM1", - "linked_clone": False - } - + + params = {"name": "VM1", "vmname": "VM1", "linked_clone": False} + with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.create", return_value=True): - response = await compute_client.post(app.url_path_for("compute:create_virtualbox_node", project_id=compute_project.id), - json=params) + response = await compute_client.post( + app.url_path_for("compute:create_virtualbox_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "VM1" assert response.json()["project_id"] == compute_project.id - - - async def test_vbox_get(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict) -> None: - - response = await compute_client.get(app.url_path_for("compute:get_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + + async def test_vbox_get( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict + ) -> None: + + response = await compute_client.get( + app.url_path_for("compute:get_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "VMTEST" assert response.json()["project_id"] == compute_project.id - - + async def test_vbox_start(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.start", return_value=True) as mock: - - response = await compute_client.post(app.url_path_for("compute:start_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:start_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vbox_stop(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.stop", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:stop_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:stop_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vbox_suspend(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.suspend", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:suspend_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + + with asyncio_patch( + "gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.suspend", return_value=True + ) as mock: + response = await compute_client.post( + app.url_path_for("compute:suspend_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vbox_resume(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.resume", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:resume_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + + with asyncio_patch( + "gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.resume", return_value=True + ) as mock: + response = await compute_client.post( + app.url_path_for("compute:resume_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vbox_reload(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.reload", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:reload_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + + with asyncio_patch( + "gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.reload", return_value=True + ) as mock: + response = await compute_client.post( + app.url_path_for("compute:reload_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vbox_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - - url = app.url_path_for("compute:create_virtualbox_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - - with asyncio_patch('gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.adapter_add_nio_binding') as mock: + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} + + url = app.url_path_for( + "compute:create_virtualbox_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + + with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.adapter_add_nio_binding") as mock: response = await compute_client.post(url, json=params) assert mock.called args, kwgars = mock.call_args assert args[0] == 0 - + assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" - - + # @pytest.mark.asyncio # async def test_vbox_nio_update_udp(self, app: FastAPI, compute_client: AsyncClient, vm): # @@ -169,78 +159,76 @@ class TestVirtualBoxNodesRoutes: # # assert response.status_code == status.HTTP_201_CREATED # assert response.json()["type"] == "nio_udp" - - + async def test_vbox_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - url = app.url_path_for("compute:delete_virtualbox_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - - with asyncio_patch('gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.adapter_remove_nio_binding') as mock: + + url = app.url_path_for( + "compute:delete_virtualbox_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + + with asyncio_patch( + "gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.adapter_remove_nio_binding" + ) as mock: response = await compute_client.delete(url) assert mock.called args, kwgars = mock.call_args assert args[0] == 0 assert response.status_code == status.HTTP_204_NO_CONTENT - - + @pytest.mark.asyncio async def test_vbox_update(self, app: FastAPI, compute_client: AsyncClient, vm, free_console_port): - - params = { - "name": "test", - "console": free_console_port - } - - response = await compute_client.put(app.url_path_for("compute:update_virtualbox_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + + params = {"name": "test", "console": free_console_port} + + response = await compute_client.put( + app.url_path_for("compute:update_virtualbox_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" assert response.json()["console"] == free_console_port - - + @pytest.mark.asyncio async def test_virtualbox_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm): - - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } - - url = app.url_path_for("compute:start_virtualbox_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} + + url = app.url_path_for( + "compute:start_virtualbox_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.start_capture") as mock: response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_200_OK assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - - + @pytest.mark.asyncio async def test_virtualbox_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm): - - url = app.url_path_for("compute:stop_virtualbox_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + url = app.url_path_for( + "compute:stop_virtualbox_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.virtualbox.virtualbox_vm.VirtualBoxVM.stop_capture") as mock: response = await compute_client.post(url) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - - + # @pytest.mark.asyncio # async def test_virtualbox_pcap(self, app: FastAPI, compute_client: AsyncClient, vm, compute_project): # diff --git a/tests/api/routes/compute/test_vmware_nodes.py b/tests/api/routes/compute/test_vmware_nodes.py index ff875ab28..6b42eb61a 100644 --- a/tests/api/routes/compute/test_vmware_nodes.py +++ b/tests/api/routes/compute/test_vmware_nodes.py @@ -29,147 +29,121 @@ pytestmark = pytest.mark.asyncio class TestVMwareNodesRoutes: - @pytest_asyncio.fixture async def vm(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vmx_path: str) -> dict: - - params = { - "name": "VMTEST", - "vmx_path": vmx_path, - "linked_clone": False - } - + + params = {"name": "VMTEST", "vmx_path": vmx_path, "linked_clone": False} + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.create", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:create_vmware_node", project_id=compute_project.id), - json=params) + response = await compute_client.post( + app.url_path_for("compute:create_vmware_node", project_id=compute_project.id), json=params + ) assert mock.called assert response.status_code == status.HTTP_201_CREATED return response.json() - @pytest.fixture def vmx_path(self, tmpdir: str) -> str: """ Return a fake VMX file """ - + path = str(tmpdir / "test.vmx") - with open(path, 'w+') as f: + with open(path, "w+") as f: f.write("1") return path - - + async def test_vmware_create( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vmx_path: str + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vmx_path: str ) -> None: - - params = { - "name": "VM1", - "vmx_path": vmx_path, - "linked_clone": False - } - + + params = {"name": "VM1", "vmx_path": vmx_path, "linked_clone": False} + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.create", return_value=True): - response = await compute_client.post(app.url_path_for("compute:create_vmware_node", project_id=compute_project.id), - json=params) + response = await compute_client.post( + app.url_path_for("compute:create_vmware_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "VM1" assert response.json()["project_id"] == compute_project.id - - + async def test_vmware_get( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: - - response = await compute_client.get(app.url_path_for("compute:get_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"])) + + response = await compute_client.get( + app.url_path_for("compute:get_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "VMTEST" assert response.json()["project_id"] == compute_project.id - - + async def test_vmware_start(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.start", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:start_vmware_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:start_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vmware_stop(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.stop", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:stop_vmware_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:stop_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vmware_suspend(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.suspend", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:suspend_vmware_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:suspend_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vmware_resume(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.resume", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:resume_vmware_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:resume_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vmware_reload(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.reload", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:reload_vmware_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:reload_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vmware_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - - url = app.url_path_for("compute:create_vmware_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - - with asyncio_patch('gns3server.compute.vmware.vmware_vm.VMwareVM.adapter_add_nio_binding') as mock: + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} + + url = app.url_path_for( + "compute:create_vmware_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.adapter_add_nio_binding") as mock: response = await compute_client.post(url, json=params) assert mock.called args, kwgars = mock.call_args assert args[0] == 0 - + assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" - - + # @pytest.mark.asyncio # async def test_vmware_nio_update_udp(self, app: FastAPI, compute_client: AsyncClient, vm): # @@ -187,83 +161,74 @@ class TestVMwareNodesRoutes: # response = await compute_client.put("/projects/{project_id}/vmware/nodes/{node_id}/adapters/0/ports/0/nio".format(project_id=vm["project_id"], node_id=vm["node_id"]), params) # assert response.status_code == status.HTTP_201_CREATED # assert response.json()["type"] == "nio_udp" - - + async def test_vmware_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - url = app.url_path_for("compute:delete_vmware_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - - with asyncio_patch('gns3server.compute.vmware.vmware_vm.VMwareVM.adapter_remove_nio_binding') as mock: + + url = app.url_path_for( + "compute:delete_vmware_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + + with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.adapter_remove_nio_binding") as mock: response = await compute_client.delete(url) assert mock.called args, kwgars = mock.call_args assert args[0] == 0 - + assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vmware_update( - self, - app: FastAPI, - compute_client: AsyncClient, - vm: dict, - free_console_port: int + self, app: FastAPI, compute_client: AsyncClient, vm: dict, free_console_port: int ) -> None: - - params = { - "name": "test", - "console": free_console_port - } - - response = await compute_client.put(app.url_path_for("compute:update_vmware_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + + params = {"name": "test", "console": free_console_port} + + response = await compute_client.put( + app.url_path_for("compute:update_vmware_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" assert response.json()["console"] == free_console_port - - + async def test_vmware_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } - - url = app.url_path_for("compute:start_vmware_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} + + url = app.url_path_for( + "compute:start_vmware_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with patch("gns3server.compute.vmware.vmware_vm.VMwareVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.start_capture") as mock: - response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_200_OK assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - - + async def test_vmware_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - url = app.url_path_for("compute:stop_vmware_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + url = app.url_path_for( + "compute:stop_vmware_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with patch("gns3server.compute.vmware.vmware_vm.VMwareVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.vmware.vmware_vm.VMwareVM.stop_capture") as mock: response = await compute_client.post(url) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - - + # @pytest.mark.asyncio # async def test_vmware_pcap(self, app: FastAPI, compute_client: AsyncClient, vm, compute_project): # diff --git a/tests/api/routes/compute/test_vpcs_nodes.py b/tests/api/routes/compute/test_vpcs_nodes.py index 7de416690..5b9073332 100644 --- a/tests/api/routes/compute/test_vpcs_nodes.py +++ b/tests/api/routes/compute/test_vpcs_nodes.py @@ -29,288 +29,247 @@ pytestmark = pytest.mark.asyncio class TestVPCSNodesRoutes: - @pytest_asyncio.fixture async def vm(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: - + params = {"name": "PC TEST 1"} - response = await compute_client.post(app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params) + response = await compute_client.post( + app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED return response.json() - - + async def test_vpcs_create(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> None: - + params = {"name": "PC TEST 1"} - response = await compute_client.post(app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params) + response = await compute_client.post( + app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "PC TEST 1" assert response.json()["project_id"] == compute_project.id - - + async def test_vpcs_get( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: - - response = await compute_client.get(app.url_path_for("compute:get_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"])) + + response = await compute_client.get( + app.url_path_for("compute:get_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "PC TEST 1" assert response.json()["project_id"] == compute_project.id assert response.json()["status"] == "stopped" - - + async def test_vpcs_create_startup_script( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project ) -> None: - - params = { - "name": "PC TEST 1", - "startup_script": "ip 192.168.1.2\necho TEST" - } - - response = await compute_client.post(app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params) + + params = {"name": "PC TEST 1", "startup_script": "ip 192.168.1.2\necho TEST"} + + response = await compute_client.post( + app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "PC TEST 1" assert response.json()["project_id"] == compute_project.id - - + async def test_vpcs_create_port( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - free_console_port: int + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, free_console_port: int ) -> None: - - params = { - "name": "PC TEST 1", - "console": free_console_port - } - - response = await compute_client.post(app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params) + + params = {"name": "PC TEST 1", "console": free_console_port} + + response = await compute_client.post( + app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "PC TEST 1" assert response.json()["project_id"] == compute_project.id assert response.json()["console"] == free_console_port - - + async def test_vpcs_nio_create_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - - url = app.url_path_for("compute:create_vpcs_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} + + url = app.url_path_for( + "compute:create_vpcs_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.add_ubridge_udp_connection"): response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" - - + async def test_vpcs_nio_update_udp(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1", - "filters": {"packet_loss": 10} - } - - url = app.url_path_for("compute:create_vpcs_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"packet_loss": 10}} + + url = app.url_path_for( + "compute:create_vpcs_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.add_ubridge_udp_connection"): response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["filters"] == {"packet_loss": 10} params["filters"].clear() - url = app.url_path_for("compute:update_vpcs_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:update_vpcs_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.put(url, json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" assert response.json()["filters"] == {} - + async def test_vpcs_delete_nio(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - + + params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._ubridge_send"): - url = app.url_path_for("compute:create_vpcs_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + url = app.url_path_for( + "compute:create_vpcs_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) await compute_client.post(url, json=params) - - url = app.url_path_for("compute:delete_vpcs_node_nio", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") + + url = app.url_path_for( + "compute:delete_vpcs_node_nio", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_vpcs_start(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:start_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) - assert mock.called - assert response.status_code == status.HTTP_204_NO_CONTENT - - - async def test_vpcs_stop(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.stop", return_value=True) as mock: - - response = await compute_client.post(app.url_path_for("compute:stop_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) - assert mock.called - assert response.status_code == status.HTTP_204_NO_CONTENT - - - async def test_vpcs_reload(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.reload", return_value=True) as mock: - response = await compute_client.post(app.url_path_for("compute:reload_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) - assert mock.called - assert response.status_code == status.HTTP_204_NO_CONTENT - - - async def test_vpcs_delete(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - with asyncio_patch("gns3server.compute.vpcs.VPCS.delete_node", return_value=True) as mock: - response = await compute_client.delete(app.url_path_for("compute:delete_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"])) + response = await compute_client.post( + app.url_path_for("compute:start_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT + async def test_vpcs_stop(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: + + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.stop", return_value=True) as mock: + response = await compute_client.post( + app.url_path_for("compute:stop_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) + assert mock.called + assert response.status_code == status.HTTP_204_NO_CONTENT + + async def test_vpcs_reload(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: + + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.reload", return_value=True) as mock: + response = await compute_client.post( + app.url_path_for("compute:reload_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) + assert mock.called + assert response.status_code == status.HTTP_204_NO_CONTENT + + async def test_vpcs_delete(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: + + with asyncio_patch("gns3server.compute.vpcs.VPCS.delete_node", return_value=True) as mock: + response = await compute_client.delete( + app.url_path_for("compute:delete_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]) + ) + assert mock.called + assert response.status_code == status.HTTP_204_NO_CONTENT async def test_vpcs_suspend(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: response = await compute_client.post( - app.url_path_for( - "compute:suspend_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"] - ) + app.url_path_for("compute:suspend_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]) ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - async def test_vpcs_duplicate( - self, - app: FastAPI, - compute_client: AsyncClient, - compute_project: Project, - vm: dict + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, vm: dict ) -> None: - + # create destination node first params = {"name": "PC TEST 1"} - response = await compute_client.post(app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params) + response = await compute_client.post( + app.url_path_for("compute:create_vpcs_node", project_id=compute_project.id), json=params + ) assert response.status_code == status.HTTP_201_CREATED - + params = {"destination_node_id": response.json()["node_id"]} - response = await compute_client.post(app.url_path_for("compute:duplicate_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + response = await compute_client.post( + app.url_path_for("compute:duplicate_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == status.HTTP_201_CREATED - - + async def test_vpcs_update( - self, - app: FastAPI, - compute_client: AsyncClient, - vm: dict, - free_console_port: int + self, app: FastAPI, compute_client: AsyncClient, vm: dict, free_console_port: int ) -> None: - + console_port = free_console_port - params = { - "name": "test", - "console": console_port - } - - response = await compute_client.put(app.url_path_for("compute:update_vpcs_node", - project_id=vm["project_id"], - node_id=vm["node_id"]), json=params) + params = {"name": "test", "console": console_port} + + response = await compute_client.put( + app.url_path_for("compute:update_vpcs_node", project_id=vm["project_id"], node_id=vm["node_id"]), + json=params, + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" assert response.json()["console"] == console_port - - + async def test_vpcs_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } - - url = app.url_path_for("compute:start_vpcs_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} + + url = app.url_path_for( + "compute:start_vpcs_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_capture") as mock: response = await compute_client.post(url, json=params) assert response.status_code == status.HTTP_200_OK assert mock.called assert "test.pcap" in response.json()["pcap_file_path"] - - + async def test_vpcs_stop_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None: - - url = app.url_path_for("compute:stop_vpcs_node_capture", - project_id=vm["project_id"], - node_id=vm["node_id"], - adapter_number="0", - port_number="0") - + + url = app.url_path_for( + "compute:stop_vpcs_node_capture", + project_id=vm["project_id"], + node_id=vm["node_id"], + adapter_number="0", + port_number="0", + ) + with patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.is_running", return_value=True): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.stop_capture") as mock: response = await compute_client.post(url) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - - + # @pytest.mark.asyncio # async def test_vpcs_pcap(self, app: FastAPI, compute_client: AsyncClient, vm, compute_project: Project): # diff --git a/tests/api/routes/controller/test_access_tickets.py b/tests/api/routes/controller/test_access_tickets.py index fd5f451a6..b70f135b5 100644 --- a/tests/api/routes/controller/test_access_tickets.py +++ b/tests/api/routes/controller/test_access_tickets.py @@ -172,49 +172,38 @@ class TestAccessTicketWebSocketAuth: monkeypatch.setattr( HTTPClient, "get_client", - classmethod(lambda cls: SimpleNamespace(ws_connect=lambda *args, **kwargs: compute_ws)) + classmethod(lambda cls: SimpleNamespace(ws_connect=lambda *args, **kwargs: compute_ws)), ) return compute_ws async def test_console_ws_accepts_valid_ticket( - self, - app: FastAPI, - base_client: AsyncClient, - compute_credentials, - project: Project, - node: Node, - monkeypatch + self, app: FastAPI, base_client: AsyncClient, compute_credentials, project: Project, node: Node, monkeypatch ) -> None: # admin is the seeded superadmin, so the RBAC privilege check is skipped ticket = access_ticket_service.mint("admin", 0, project_id=project.id, node_id=node.id) - self._forward_compute_ws(monkeypatch, [ - aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "device output", None), - ]) + self._forward_compute_ws( + monkeypatch, + [ + aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "device output", None), + ], + ) async with self._ws_client(app, base_client) as client: async with aconnect_ws( - f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", - client, - params={"token": ticket} + f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", client, params={"token": ticket} ) as ws: # reaching the compute forwarding loop means ticket auth succeeded assert await ws.receive_text() == "device output" async def test_console_ws_rejects_ticket_bound_to_other_node( - self, - app: FastAPI, - base_client: AsyncClient, - project: Project, - node: Node + self, app: FastAPI, base_client: AsyncClient, project: Project, node: Node ) -> None: other_node = "00000000-0000-0000-0000-000000000000" ticket = access_ticket_service.mint("admin", 0, project_id=project.id, node_id=other_node) async with self._ws_client(app, base_client) as client: async with aconnect_ws( - f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", - client, - params={"token": ticket} + f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", client, params={"token": ticket} ) as ws: notification = await ws.receive_json() assert notification["event"]["message"] == ( @@ -224,28 +213,18 @@ class TestAccessTicketWebSocketAuth: ) async def test_console_ws_rejects_ticket_with_stale_token_version( - self, - app: FastAPI, - base_client: AsyncClient, - project: Project, - node: Node + self, app: FastAPI, base_client: AsyncClient, project: Project, node: Node ) -> None: # logging out bumps the user's token_version: outstanding tickets must die with it ticket = access_ticket_service.mint("admin", 999, project_id=project.id, node_id=node.id) async with self._ws_client(app, base_client) as client: async with aconnect_ws( - f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", - client, - params={"token": ticket} + f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", client, params={"token": ticket} ) as ws: notification = await ws.receive_json() assert "Token has been revoked for 'admin'" in notification["event"]["message"] - async def test_ticket_rejected_on_non_console_websocket( - self, - app: FastAPI, - base_client: AsyncClient - ) -> None: + async def test_ticket_rejected_on_non_console_websocket(self, app: FastAPI, base_client: AsyncClient) -> None: # the controller notification stream shares the WS auth dependency but has # no node binding: a console ticket must not authenticate it ticket = access_ticket_service.mint("admin", 0, project_id="p1", node_id="n1") @@ -263,42 +242,26 @@ class TestAccessTicketRestAuth: pytestmark = pytest.mark.asyncio - async def test_rest_accepts_path_bound_ticket( - self, - app: FastAPI, - base_client: AsyncClient - ) -> None: + async def test_rest_accepts_path_bound_ticket(self, app: FastAPI, base_client: AsyncClient) -> None: # base_client carries no Authorization header, so the ?token= parameter is used ticket = access_ticket_service.mint("admin", 0, path="/v3/projects") response = await base_client.get("/v3/projects", params={"token": ticket}) assert response.status_code == status.HTTP_200_OK - async def test_rest_rejects_ticket_bound_to_other_path( - self, - app: FastAPI, - base_client: AsyncClient - ) -> None: + async def test_rest_rejects_ticket_bound_to_other_path(self, app: FastAPI, base_client: AsyncClient) -> None: ticket = access_ticket_service.mint("admin", 0, path="/v3/symbols/router.svg/raw") response = await base_client.get("/v3/projects", params={"token": ticket}) assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "Invalid or expired access ticket" in response.text - async def test_rest_rejects_node_bound_ticket( - self, - app: FastAPI, - base_client: AsyncClient - ) -> None: + async def test_rest_rejects_node_bound_ticket(self, app: FastAPI, base_client: AsyncClient) -> None: # node-bound (console) tickets must not authenticate REST resources ticket = access_ticket_service.mint("admin", 0, project_id="p1", node_id="n1") response = await base_client.get("/v3/projects", params={"token": ticket}) assert response.status_code == status.HTTP_401_UNAUTHORIZED - async def test_rest_rejects_ticket_with_stale_token_version( - self, - app: FastAPI, - base_client: AsyncClient - ) -> None: + async def test_rest_rejects_ticket_with_stale_token_version(self, app: FastAPI, base_client: AsyncClient) -> None: # logging out bumps the user's token_version: outstanding tickets must die with it ticket = access_ticket_service.mint("admin", 999, path="/v3/projects") response = await base_client.get("/v3/projects", params={"token": ticket}) diff --git a/tests/api/routes/controller/test_acl.py b/tests/api/routes/controller/test_acl.py index 83be66e1b..915ab6da5 100644 --- a/tests/api/routes/controller/test_acl.py +++ b/tests/api/routes/controller/test_acl.py @@ -32,7 +32,6 @@ pytestmark = pytest.mark.asyncio class TestACLRoutes: - @pytest_asyncio.fixture async def group_id(self, db_session: AsyncSession) -> str: @@ -50,33 +49,18 @@ class TestACLRoutes: return role_id async def test_create_ace( - self, - app: FastAPI, - authorized_client: AsyncClient, - db_session: AsyncSession, - test_user: User, - role_id: str + self, app: FastAPI, authorized_client: AsyncClient, db_session: AsyncSession, test_user: User, role_id: str ) -> None: # allow the user to create an ACE rbac_repo = RbacRepository(db_session) admin_role_id = (await rbac_repo.get_role_by_name("Administrator")).role_id - ace = ACECreate( - path="/access/acl", - ace_type="user", - user_id=test_user.user_id, - role_id=admin_role_id - ) + ace = ACECreate(path="/access/acl", ace_type="user", user_id=test_user.user_id, role_id=admin_role_id) await rbac_repo.create_ace(ace) # add an ACE on /projects to allow user to create a project path = f"/projects" - new_ace = { - "path": path, - "ace_type": "user", - "user_id": str(test_user.user_id), - "role_id": role_id - } + new_ace = {"path": path, "ace_type": "user", "user_id": str(test_user.user_id), "role_id": role_id} response = await authorized_client.post(app.url_path_for("create_ace"), json=new_ace) assert response.status_code == status.HTTP_201_CREATED @@ -87,21 +71,11 @@ class TestACLRoutes: response = await authorized_client.post(app.url_path_for("create_project"), json={"name": "test"}) assert response.status_code == status.HTTP_201_CREATED - async def test_create_ace_not_existing_endpoint( - self, - app: FastAPI, - client: AsyncClient, - group_id: str, - role_id: str + self, app: FastAPI, client: AsyncClient, group_id: str, role_id: str ) -> None: - new_ace = { - "path": "/projects/invalid", - "ace_type": "group", - "group_id": group_id, - "role_id": role_id - } + new_ace = {"path": "/projects/invalid", "ace_type": "group", "group_id": group_id, "role_id": role_id} response = await client.post(app.url_path_for("create_ace"), json=new_ace) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -137,35 +111,23 @@ class TestACLRoutes: assert len(response.json()) == 2 async def test_update_ace( - self, app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - test_user: User, - role_id: str + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, test_user: User, role_id: str ) -> None: rbac_repo = RbacRepository(db_session) ace_in_db = await rbac_repo.get_ace_by_path(f"/projects") - update_ace = { - "path": f"/appliances", - "ace_type": "user", - "user_id": str(test_user.user_id), - "role_id": role_id - } - response = await client.put( - app.url_path_for("update_ace", ace_id=ace_in_db.ace_id), - json=update_ace - ) + update_ace = {"path": f"/appliances", "ace_type": "user", "user_id": str(test_user.user_id), "role_id": role_id} + response = await client.put(app.url_path_for("update_ace", ace_id=ace_in_db.ace_id), json=update_ace) assert response.status_code == status.HTTP_200_OK updated_ace_in_db = await rbac_repo.get_ace(ace_in_db.ace_id) assert updated_ace_in_db.path == f"/appliances" async def test_delete_ace( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, ) -> None: rbac_repo = RbacRepository(db_session) @@ -174,22 +136,17 @@ class TestACLRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT async def test_ace_cleanup( - self, - app: FastAPI, - authorized_client: AsyncClient, - db_session: AsyncSession, - test_user: User, - role_id: str, + self, + app: FastAPI, + authorized_client: AsyncClient, + db_session: AsyncSession, + test_user: User, + role_id: str, ) -> None: # allow the user to create projects rbac_repo = RbacRepository(db_session) - ace = ACECreate( - path="/projects", - ace_type="user", - user_id=test_user.user_id, - role_id=role_id - ) + ace = ACECreate(path="/projects", ace_type="user", user_id=test_user.user_id, role_id=role_id) await rbac_repo.create_ace(ace) response = await authorized_client.post(app.url_path_for("create_project"), json={"name": "test2"}) @@ -197,12 +154,7 @@ class TestACLRoutes: project_id = response.json()["project_id"] path = f"/projects/{project_id}" - ace = ACECreate( - path=path, - ace_type="user", - user_id=test_user.user_id, - role_id=role_id - ) + ace = ACECreate(path=path, ace_type="user", user_id=test_user.user_id, role_id=role_id) await rbac_repo.create_ace(ace) assert await rbac_repo.get_ace_by_path(path) diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index 356fe2b08..c37f18ddd 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -27,7 +27,6 @@ pytestmark = pytest.mark.asyncio class TestApplianceRoutes: - # @pytest.fixture(autouse=True) # def _install_builtin_appliances(self, controller: Controller): # @@ -63,7 +62,9 @@ class TestApplianceRoutes: response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id), params=params) assert response.status_code == status.HTTP_400_BAD_REQUEST - async def test_qemu_appliance_install_with_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None: + async def test_qemu_appliance_install_with_version( + self, app: FastAPI, client: AsyncClient, images_dir: str + ) -> None: shutil.copy("tests/resources/empty8G.qcow2", os.path.join(images_dir, "QEMU", "empty8G.qcow2")) appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance @@ -73,7 +74,9 @@ class TestApplianceRoutes: assert response.json()["name"] == "Empty VM" assert response.json()["version"] == "8G" - async def test_qemu_appliance_install_without_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None: + async def test_qemu_appliance_install_without_version( + self, app: FastAPI, client: AsyncClient, images_dir: str + ) -> None: appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id)) @@ -82,24 +85,18 @@ class TestApplianceRoutes: async def test_add_version_appliance(self, app: FastAPI, client: AsyncClient) -> None: appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance - new_version = { - "name": "99G", - "images": { - "hda_disk_image": "empty99G.qcow2" - } - } - response = await client.post(app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version) + new_version = {"name": "99G", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version + ) assert response.status_code == status.HTTP_201_CREATED assert new_version in response.json()["versions"] async def test_add_existing_version_appliance(self, app: FastAPI, client: AsyncClient) -> None: appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance - new_version = { - "name": "8G", - "images": { - "hda_disk_image": "empty8G.qcow2" - } - } - response = await client.post(app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version) + new_version = {"name": "8G", "images": {"hda_disk_image": "empty8G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version + ) assert response.status_code == status.HTTP_409_CONFLICT diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index 5b864f02f..ea2317821 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -30,15 +30,9 @@ from tests.utils import asyncio_patch, AsyncioMagicMock class TestComputeRoutes: - async def test_compute_create(self, app: FastAPI, client: AsyncClient) -> None: - params = { - "protocol": "http", - "host": "localhost", - "port": 84, - "user": "julien", - "password": "secure"} + params = {"protocol": "http", "host": "localhost", "port": 84, "user": "julien", "password": "secure"} response = await client.post(app.url_path_for("create_compute"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -57,7 +51,8 @@ class TestComputeRoutes: "host": "127.0.0.1", "port": 84, "user": "julien", - "password": "secure"} + "password": "secure", + } response = await client.post(app.url_path_for("create_compute"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -82,7 +77,8 @@ class TestComputeRoutes: async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None: await controller.add_compute( - compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False) + compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False + ) response = await client.get(app.url_path_for("get_compute", compute_id="local")) assert response.status_code == status.HTTP_200_OK @@ -91,13 +87,7 @@ class TestComputeRoutes: async def test_compute_update(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None: - params = { - "protocol": "http", - "host": "localhost", - "port": 42, - "user": "julien", - "password": "secure" - } + params = {"protocol": "http", "host": "localhost", "port": 42, "user": "julien", "password": "secure"} response = await client.post(app.url_path_for("create_compute"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -118,29 +108,22 @@ class TestComputeRoutes: class TestComputeFeatures: - async def test_compute_list_docker_images(self, app: FastAPI, client: AsyncClient) -> None: - params = { - "protocol": "http", - "host": "localhost", - "port": 84, - "user": "julien", - "password": "secure" - } + params = {"protocol": "http", "host": "localhost", "port": 84, "user": "julien", "password": "secure"} response = await client.post(app.url_path_for("create_compute"), json=params) assert response.status_code == status.HTTP_201_CREATED compute_id = response.json()["compute_id"] - with asyncio_patch("gns3server.controller.compute.Compute.forward", return_value=[{"image": "docker1"}, {"image": "docker2"}]) as mock: + with asyncio_patch( + "gns3server.controller.compute.Compute.forward", return_value=[{"image": "docker1"}, {"image": "docker2"}] + ) as mock: response = await client.get(app.url_path_for("docker_get_images", compute_id=compute_id)) mock.assert_called_with("GET", "docker", "images") assert response.json() == [{"image": "docker1"}, {"image": "docker2"}] - async def test_compute_pull_docker_image( - self, app: FastAPI, client: AsyncClient, test_compute: Compute - ) -> None: + async def test_compute_pull_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None: compute = MagicMock() compute.forward = AsyncioMagicMock(return_value={}) @@ -148,20 +131,14 @@ class TestComputeFeatures: controller.return_value.get_compute.return_value = compute response = await client.post( app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id), - json={"image": "nginx:latest"} + json={"image": "nginx:latest"}, ) compute.forward.assert_called_with("POST", "docker", "images/pull", data={"image": "nginx:latest"}) assert response.status_code == status.HTTP_204_NO_CONTENT async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None: - params = { - "protocol": "http", - "host": "localhost", - "port": 4242, - "user": "julien", - "password": "secure" - } + params = {"protocol": "http", "host": "localhost", "port": 4242, "user": "julien", "password": "secure"} response = await client.post(app.url_path_for("get_computes"), json=params) assert response.status_code == status.HTTP_201_CREATED compute_id = response.json()["compute_id"] @@ -173,13 +150,7 @@ class TestComputeFeatures: async def test_compute_list_vmware_vms(self, app: FastAPI, client: AsyncClient) -> None: - params = { - "protocol": "http", - "host": "localhost", - "port": 4243, - "user": "julien", - "password": "secure" - } + params = {"protocol": "http", "host": "localhost", "port": 4243, "user": "julien", "password": "secure"} response = await client.post(app.url_path_for("get_computes"), json=params) assert response.status_code == status.HTTP_201_CREATED compute_id = response.json()["compute_id"] diff --git a/tests/api/routes/controller/test_controller.py b/tests/api/routes/controller/test_controller.py index 43d672302..b71a9cf2c 100644 --- a/tests/api/routes/controller/test_controller.py +++ b/tests/api/routes/controller/test_controller.py @@ -28,7 +28,6 @@ pytestmark = pytest.mark.asyncio class TestControllerRoutes: - async def test_shutdown_local(self, app: FastAPI, client: AsyncClient, config: Config, monkeypatch) -> None: # monkeypatch (not bare assignment): a global `os.kill = MagicMock()` @@ -41,14 +40,12 @@ class TestControllerRoutes: response = await client.post(app.url_path_for("shutdown")) assert response.status_code == status.HTTP_204_NO_CONTENT assert kill_mock.called - - + async def test_shutdown_non_local(self, app: FastAPI, client: AsyncClient, config: Config) -> None: - + response = await client.post(app.url_path_for("shutdown")) assert response.status_code == status.HTTP_403_FORBIDDEN - - + # @pytest.mark.asyncio # async def test_debug(controller_api, config, tmpdir): # @@ -68,9 +65,8 @@ class TestControllerRoutes: # config.set("Server", "local", False) # response = await controller_api.post('/debug') # assert response.status_code == 403 - - + async def test_statistics_output(self, app: FastAPI, client: AsyncClient) -> None: - + response = await client.get(app.url_path_for("statistics")) assert response.status_code == status.HTTP_200_OK diff --git a/tests/api/routes/controller/test_drawings.py b/tests/api/routes/controller/test_drawings.py index 62f00d5f5..2d491f863 100644 --- a/tests/api/routes/controller/test_drawings.py +++ b/tests/api/routes/controller/test_drawings.py @@ -27,90 +27,77 @@ pytestmark = pytest.mark.asyncio class TestDrawingsRoutes: - async def test_create_drawing(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + params = { "svg": '', "x": 10, "y": 20, - "z": 0 + "z": 0, } - + response = await client.post(app.url_path_for("create_drawing", project_id=project.id), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["drawing_id"] is not None - - + async def test_get_drawing(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + params = { "svg": '', "x": 10, "y": 20, - "z": 0 + "z": 0, } - + response = await client.post(app.url_path_for("create_drawing", project_id=project.id), json=params) - response = await client.get(app.url_path_for( - "get_drawing", - project_id=project.id, - drawing_id=response.json()["drawing_id"]) + response = await client.get( + app.url_path_for("get_drawing", project_id=project.id, drawing_id=response.json()["drawing_id"]) ) assert response.status_code == 200 assert response.json()["x"] == 10 - - + async def test_update_drawing(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + params = { "svg": '', "x": 10, "y": 20, - "z": 0 + "z": 0, } - + response = await client.post(app.url_path_for("create_drawing", project_id=project.id), json=params) - response = await client.put(app.url_path_for( - "update_drawing", - project_id=project.id, - drawing_id=response.json()["drawing_id"]), - json={"x": 42} + response = await client.put( + app.url_path_for("update_drawing", project_id=project.id, drawing_id=response.json()["drawing_id"]), + json={"x": 42}, ) - + assert response.status_code == status.HTTP_200_OK assert response.json()["x"] == 42 - - + async def test_all_drawings(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + params = { "svg": '', "x": 10, "y": 20, - "z": 0 + "z": 0, } - + await client.post(app.url_path_for("create_drawing", project_id=project.id), json=params) response = await client.get(app.url_path_for("get_drawings", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 - + # test listing links from a closed project await project.close(ignore_notification=True) response = await client.get(app.url_path_for("get_drawings", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 - - + async def test_delete_drawing(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + drawing = Drawing(project) project._drawings = {drawing.id: drawing} - response = await client.delete(app.url_path_for( - "delete_drawing", - project_id=project.id, - drawing_id=drawing.id) - ) + response = await client.delete(app.url_path_for("delete_drawing", project_id=project.id, drawing_id=drawing.id)) assert response.status_code == status.HTTP_204_NO_CONTENT assert drawing.id not in project.drawings diff --git a/tests/api/routes/controller/test_gns3vm.py b/tests/api/routes/controller/test_gns3vm.py index ce10bfb8c..27a8b9184 100644 --- a/tests/api/routes/controller/test_gns3vm.py +++ b/tests/api/routes/controller/test_gns3vm.py @@ -25,33 +25,27 @@ pytestmark = pytest.mark.asyncio class TestGNS3VMRoutes: - async def test_list_vms(self, app: FastAPI, client: AsyncClient) -> None: - - with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.list", return_value=[{"vmname": "test"}]): + + with asyncio_patch( + "gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.list", return_value=[{"vmname": "test"}] + ): response = await client.get(app.url_path_for("get_vms", engine="vmware")) assert response.status_code == status.HTTP_200_OK - assert response.json() == [ - { - "vmname": "test" - } - ] - - + assert response.json() == [{"vmname": "test"}] + async def test_engines(self, app: FastAPI, client: AsyncClient) -> None: - + response = await client.get(app.url_path_for("get_engines")) assert response.status_code == status.HTTP_200_OK assert len(response.json()) > 0 - - + async def test_put_gns3vm(self, app: FastAPI, client: AsyncClient) -> None: - + response = await client.put(app.url_path_for("update_gns3vm_settings"), json={"vmname": "TEST VM"}) assert response.status_code == status.HTTP_200_OK assert response.json()["vmname"] == "TEST VM" - - + async def test_get_gns3vm(self, app: FastAPI, client: AsyncClient) -> None: response = await client.get(app.url_path_for("get_gns3vm_settings")) assert response.status_code == status.HTTP_200_OK diff --git a/tests/api/routes/controller/test_groups.py b/tests/api/routes/controller/test_groups.py index 642f283e0..69ff6183f 100644 --- a/tests/api/routes/controller/test_groups.py +++ b/tests/api/routes/controller/test_groups.py @@ -28,7 +28,6 @@ pytestmark = pytest.mark.asyncio class TestGroupRoutes: - async def test_create_group(self, app: FastAPI, client: AsyncClient) -> None: new_group = {"name": "group1"} @@ -56,47 +55,30 @@ class TestGroupRoutes: update_group = {"name": "group42"} response = await client.put( - app.url_path_for("update_user_group", user_group_id=group_in_db.user_group_id), - json=update_group + app.url_path_for("update_user_group", user_group_id=group_in_db.user_group_id), json=update_group ) assert response.status_code == status.HTTP_200_OK updated_group_in_db = await user_repo.get_user_group(group_in_db.user_group_id) assert updated_group_in_db.name == "group42" - async def test_cannot_update_admin_group( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_cannot_update_admin_group(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: user_repo = UsersRepository(db_session) group_in_db = await user_repo.get_user_group_by_name("Administrators") update_group = {"name": "Hackers"} response = await client.put( - app.url_path_for("update_user_group", user_group_id=group_in_db.user_group_id), - json=update_group + app.url_path_for("update_user_group", user_group_id=group_in_db.user_group_id), json=update_group ) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_delete_group( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_delete_group(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: user_repo = UsersRepository(db_session) group_in_db = await user_repo.get_user_group_by_name("group42") response = await client.delete(app.url_path_for("delete_user_group", user_group_id=group_in_db.user_group_id)) assert response.status_code == status.HTTP_204_NO_CONTENT - async def test_cannot_delete_admin_group( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_cannot_delete_admin_group(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: user_repo = UsersRepository(db_session) group_in_db = await user_repo.get_user_group_by_name("Administrators") @@ -105,32 +87,21 @@ class TestGroupRoutes: class TestGroupMembersRoutes: - async def test_add_to_group_already_member( - self, - app: FastAPI, - client: AsyncClient, - test_user: User, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, test_user: User, db_session: AsyncSession ) -> None: user_repo = UsersRepository(db_session) group_in_db = await user_repo.get_user_group_by_name("Users") response = await client.put( app.url_path_for( - "add_member_to_group", - user_group_id=group_in_db.user_group_id, - user_id=str(test_user.user_id) + "add_member_to_group", user_group_id=group_in_db.user_group_id, user_id=str(test_user.user_id) ) ) assert response.status_code == status.HTTP_400_BAD_REQUEST async def test_add_member_to_group( - self, - app: FastAPI, - client: AsyncClient, - test_user: User, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, test_user: User, db_session: AsyncSession ) -> None: user_repo = UsersRepository(db_session) @@ -140,9 +111,7 @@ class TestGroupMembersRoutes: group_in_db = await user_repo.create_user_group(new_user_group) response = await client.put( app.url_path_for( - "add_member_to_group", - user_group_id=group_in_db.user_group_id, - user_id=str(test_user.user_id) + "add_member_to_group", user_group_id=group_in_db.user_group_id, user_id=str(test_user.user_id) ) ) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -150,29 +119,16 @@ class TestGroupMembersRoutes: assert len(members) == 1 assert members[0].username == test_user.username - async def test_get_user_group_members( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_get_user_group_members(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: user_repo = UsersRepository(db_session) group_in_db = await user_repo.get_user_group_by_name("test_group") - response = await client.get( - app.url_path_for( - "get_user_group_members", - user_group_id=group_in_db.user_group_id) - ) + response = await client.get(app.url_path_for("get_user_group_members", user_group_id=group_in_db.user_group_id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 async def test_remove_member_from_group( - self, - app: FastAPI, - client: AsyncClient, - test_user: User, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, test_user: User, db_session: AsyncSession ) -> None: user_repo = UsersRepository(db_session) @@ -180,9 +136,7 @@ class TestGroupMembersRoutes: response = await client.delete( app.url_path_for( - "remove_member_from_group", - user_group_id=group_in_db.user_group_id, - user_id=str(test_user.user_id) + "remove_member_from_group", user_group_id=group_in_db.user_group_id, user_id=str(test_user.user_id) ), ) assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/tests/api/routes/controller/test_images.py b/tests/api/routes/controller/test_images.py index ea0b2f587..716897a30 100644 --- a/tests/api/routes/controller/test_images.py +++ b/tests/api/routes/controller/test_images.py @@ -43,7 +43,7 @@ def iou_32_bit_image(tmpdir) -> str: path = os.path.join(tmpdir, "iou_32bit.bin") with open(path, "wb+") as f: - f.write(b'\x7fELF\x01\x01\x01') + f.write(b"\x7fELF\x01\x01\x01") return path @@ -55,7 +55,7 @@ def iou_64_bit_image(tmpdir) -> str: path = os.path.join(tmpdir, "iou_64bit.bin") with open(path, "wb+") as f: - f.write(b'\x7fELF\x02\x01\x01') + f.write(b"\x7fELF\x02\x01\x01") return path @@ -67,7 +67,7 @@ def ios_image(tmpdir) -> str: path = os.path.join(tmpdir, "ios_image.bin") with open(path, "wb+") as f: - f.write(b'\x7fELF\x01\x02\x01') + f.write(b"\x7fELF\x01\x02\x01") return path @@ -79,7 +79,7 @@ def qcow2_image(tmpdir) -> str: path = os.path.join(tmpdir, "image.qcow2") with open(path, "wb+") as f: - f.write(b'QFI\xfb\x00\x00\x00') + f.write(b"QFI\xfb\x00\x00\x00") return path @@ -91,7 +91,7 @@ def invalid_image(tmpdir) -> str: path = os.path.join(tmpdir, "invalid_image.bin") with open(path, "wb+") as f: - f.write(b'\x01\x01\x01\x01') + f.write(b"\x01\x01\x01\x01") return path @@ -103,21 +103,21 @@ def empty_image(tmpdir) -> str: path = os.path.join(tmpdir, "empty_image.bin") with open(path, "wb+") as f: - f.write(b'') + f.write(b"") return path class TestImageRoutes: - async def test_create_image(self, app: FastAPI, client: AsyncClient, images_dir) -> None: Qemu.instance().create_disk_image = AsyncioMagicMock() path = os.path.join(os.path.join(images_dir, "QEMU", "new_image.qcow2")) with open(path, "wb+") as f: - f.write(b'QFI\xfb\x00\x00\x00') + f.write(b"QFI\xfb\x00\x00\x00") image_name = os.path.basename(path) response = await client.post( - app.url_path_for("create_qemu_image", image_path=image_name), json={"format": "qcow2", "size": 30}) + app.url_path_for("create_qemu_image", image_path=image_name), json={"format": "qcow2", "size": 30} + ) assert response.status_code == status.HTTP_201_CREATED @pytest.mark.parametrize( @@ -129,18 +129,18 @@ class TestImageRoutes: ("ios", "ios_image", True), ("ios", "invalid_image", False), ("qemu", "qcow2_image", True), - ("qemu", "empty_image", False) + ("qemu", "empty_image", False), ), ) async def test_upload_image( - self, - app: FastAPI, - client: AsyncClient, - images_dir: str, - image_type: str, - fixture_name: str, - valid_request: bool, - request + self, + app: FastAPI, + client: AsyncClient, + images_dir: str, + image_type: str, + fixture_name: str, + valid_request: bool, + request, ) -> None: image_path = request.getfixturevalue(fixture_name) @@ -150,9 +150,7 @@ class TestImageRoutes: image_data = f.read() image_checksum.update(image_data) - response = await client.post( - app.url_path_for("upload_image", image_path=image_name), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path=image_name), content=image_data) if valid_request: assert response.status_code == status.HTTP_201_CREATED @@ -179,21 +177,15 @@ class TestImageRoutes: with open(qcow2_image, "rb") as f: image_data = f.read() - response = await client.post( - app.url_path_for("upload_image", image_path="image1.qcow2"), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path="image1.qcow2"), content=image_data) assert response.status_code == status.HTTP_201_CREATED # same image with same name is uploaded again, it should return 409 Conflict - response = await client.post( - app.url_path_for("upload_image", image_path="image1.qcow2"), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path="image1.qcow2"), content=image_data) assert response.status_code == status.HTTP_409_CONFLICT # same image with different name but same checksum is uploaded again, it should return 409 Conflict - response = await client.post( - app.url_path_for("upload_image", image_path="image2.qcow2"), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path="image2.qcow2"), content=image_data) assert response.status_code == status.HTTP_409_CONFLICT async def test_image_delete(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None: @@ -208,14 +200,14 @@ class TestImageRoutes: response = await client.get(app.url_path_for("get_image", image_path=image_name)) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_image_deleted_on_disk(self, app: FastAPI, client: AsyncClient, images_dir: str, qcow2_image: str) -> None: + async def test_image_deleted_on_disk( + self, app: FastAPI, client: AsyncClient, images_dir: str, qcow2_image: str + ) -> None: image_name = os.path.basename(qcow2_image) with open(qcow2_image, "rb") as f: image_data = f.read() - response = await client.post( - app.url_path_for("upload_image", image_path=image_name), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path=image_name), content=image_data) assert response.status_code == status.HTTP_201_CREATED response = await client.delete(app.url_path_for("delete_image", image_path=image_name)) @@ -230,42 +222,30 @@ class TestImageRoutes: ), ) async def test_upload_image_subdir( - self, - app: FastAPI, - client: AsyncClient, - images_dir: str, - qcow2_image: str, - subdir: str, - expected_result: int, - db_session: AsyncSession + self, + app: FastAPI, + client: AsyncClient, + images_dir: str, + qcow2_image: str, + subdir: str, + expected_result: int, + db_session: AsyncSession, ) -> None: image_name = os.path.basename(qcow2_image) with open(qcow2_image, "rb") as f: image_data = f.read() image_path = os.path.join(subdir, image_name) - response = await client.post( - app.url_path_for("upload_image", image_path=image_path), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path=image_path), content=image_data) assert response.status_code == expected_result - async def test_image_delete_multiple_match( - self, - app: FastAPI, - client: AsyncClient, - qcow2_image: str - ) -> None: + async def test_image_delete_multiple_match(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None: image_name = os.path.basename(qcow2_image) response = await client.delete(app.url_path_for("delete_image", image_path=image_name)) assert response.status_code == status.HTTP_400_BAD_REQUEST - async def test_image_delete_with_subdir( - self, - app: FastAPI, - client: AsyncClient, - qcow2_image: str - ) -> None: + async def test_image_delete_with_subdir(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None: image_name = os.path.basename(qcow2_image) image_path = os.path.join("subdir", image_name) @@ -302,17 +282,20 @@ class TestImageRoutes: with open(os.path.join(project_dir, f"{name}.gns3"), "w+") as f: json.dump(topology, f) return await controller.add_project( - project_id=project_id, name=name, path=project_dir, - filename=f"{name}.gns3", status="closed", + project_id=project_id, + name=name, + path=project_dir, + filename=f"{name}.gns3", + status="closed", ) async def test_image_delete_used_by_project( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller, - db_session: AsyncSession, - tmpdir: str, + self, + app: FastAPI, + client: AsyncClient, + controller: Controller, + db_session: AsyncSession, + tmpdir: str, ) -> None: """ An image referenced by a node in a project must not be deleted, @@ -321,15 +304,21 @@ class TestImageRoutes: image_path = os.path.join(tmpdir, "used.qcow2") with open(image_path, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") images_repo = ImagesRepository(db_session) await images_repo.add_image("used.qcow2", "qemu", 42, image_path, "e342eb86c1229b6c154367a5476969b5", "md5") guarded_project = await self._add_closed_project_with_node( - controller, "Guarded", - {"node_id": str(uuid.uuid4()), "node_type": "qemu", "compute_id": "local", - "name": "n1", "properties": {"hda_disk_image_backing_file": "used.qcow2"}}, + controller, + "Guarded", + { + "node_id": str(uuid.uuid4()), + "node_type": "qemu", + "compute_id": "local", + "name": "n1", + "properties": {"hda_disk_image_backing_file": "used.qcow2"}, + }, ) response = await client.delete(app.url_path_for("delete_image", image_path="used.qcow2")) @@ -344,12 +333,12 @@ class TestImageRoutes: assert not os.path.exists(image_path) async def test_prune_images_keeps_project_referenced( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller, - db_session: AsyncSession, - tmpdir: str, + self, + app: FastAPI, + client: AsyncClient, + controller: Controller, + db_session: AsyncSession, + tmpdir: str, ) -> None: """ Pruning must keep images referenced by a project node while @@ -360,13 +349,19 @@ class TestImageRoutes: for filename in ("used.qcow2", "unused.qcow2"): image_path = os.path.join(tmpdir, filename) with open(image_path, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") await images_repo.add_image(filename, "qemu", 42, image_path, "e342eb86c1229b6c154367a5476969b5", "md5") await self._add_closed_project_with_node( - controller, "Guarded", - {"node_id": str(uuid.uuid4()), "node_type": "qemu", "compute_id": "local", - "name": "n1", "properties": {"hda_disk_image_backing_file": "used.qcow2"}}, + controller, + "Guarded", + { + "node_id": str(uuid.uuid4()), + "node_type": "qemu", + "compute_id": "local", + "name": "n1", + "properties": {"hda_disk_image_backing_file": "used.qcow2"}, + }, ) response = await client.delete(app.url_path_for("prune_images")) @@ -378,10 +373,7 @@ class TestImageRoutes: assert not os.path.exists(os.path.join(tmpdir, "unused.qcow2")) async def test_image_upload_create_appliance( - self, app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - controller: Controller + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, controller: Controller ) -> None: await controller.appliance_manager.install_builtin_appliances() @@ -393,7 +385,8 @@ class TestImageRoutes: response = await client.post( app.url_path_for("upload_image", image_path=image_name), params={"install_appliances": "true"}, - content=image_data) + content=image_data, + ) assert response.status_code == status.HTTP_201_CREATED templates_repo = TemplatesRepository(db_session) @@ -404,19 +397,14 @@ class TestImageRoutes: await templates_repo.delete_template(templates[0].template_id) async def test_install_all( - self, app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - controller: Controller + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, controller: Controller ) -> None: image_path = "tests/resources/empty100G.qcow2" image_name = os.path.basename(image_path) with open(image_path, "rb") as f: image_data = f.read() - response = await client.post( - app.url_path_for("upload_image", image_path=image_name), - content=image_data) + response = await client.post(app.url_path_for("upload_image", image_path=image_name), content=image_data) assert response.status_code == status.HTTP_201_CREATED controller.appliance_manager.load_appliances() # make sure appliances are loaded @@ -437,10 +425,7 @@ class TestImageRoutes: await templates_repo.delete_template(templates[0].template_id) async def test_install_all_skips_existing_template_name( - self, app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - controller: Controller + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, controller: Controller ) -> None: # two images matching two versions of the same appliance must not # produce two templates with the same name @@ -455,8 +440,8 @@ class TestImageRoutes: with open(image_path, "rb") as f: image_data = f.read() response = await client.post( - app.url_path_for("upload_image", image_path=os.path.basename(image_path)), - content=image_data) + app.url_path_for("upload_image", image_path=os.path.basename(image_path)), content=image_data + ) assert response.status_code == status.HTTP_201_CREATED controller.appliance_manager.load_appliances() # make sure appliances are loaded @@ -473,4 +458,4 @@ class TestImageRoutes: templates = await templates_repo.get_templates() assert len(templates) == 1 assert templates[0].name == "Empty VM" - await templates_repo.delete_template(templates[0].template_id) \ No newline at end of file + await templates_repo.delete_template(templates[0].template_id) diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index bc50e053c..a81ca094b 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -37,57 +37,43 @@ pytestmark = pytest.mark.asyncio class TestLinkRoutes: - @pytest_asyncio.fixture async def nodes(self, compute: Compute, project: Project) -> Tuple[Node, Node]: - + response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - + node1 = await project.add_node(compute, "node1", None, node_type="qemu") node1._ports = [EthernetPort("E0", 0, 0, 3)] node2 = await project.add_node(compute, "node2", None, node_type="qemu") node2._ports = [EthernetPort("E0", 0, 2, 4)] return node1, node2 - - + async def test_create_link( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - nodes: Tuple[Node, Node] + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] ) -> None: - + node1, node2 = nodes - - filters = { - "delay": [10, 0], - "frequency_drop": [50] - } + + filters = {"delay": [10, 0], "frequency_drop": [50]} with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: - response = await client.post(app.url_path_for("create_link", project_id=project.id), json={ - "nodes": [ - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 3, - "label": { - "text": "Text", - "x": 42, - "y": 0 - } - }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } - ], - "filters": filters - }) + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + { + "node_id": node1.id, + "adapter_number": 0, + "port_number": 3, + "label": {"text": "Text", "x": 42, "y": 0}, + }, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ], + "filters": filters, + }, + ) assert mock.called assert response.status_code == status.HTTP_201_CREATED @@ -96,264 +82,193 @@ class TestLinkRoutes: assert response.json()["nodes"][0]["label"]["x"] == 42 assert len(project.links) == 1 assert list(project.links.values())[0].filters == filters - - + async def test_create_link_failure( - self, - app: FastAPI, - client: AsyncClient, - compute: Compute, - project: Project + self, app: FastAPI, client: AsyncClient, compute: Compute, project: Project ) -> None: """ Make sure the link is deleted if we failed to create it. - + The failure is triggered by connecting the link to itself """ - + response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - + node1 = await project.add_node(compute, "node1", None, node_type="qemu") node1._ports = [EthernetPort("E0", 0, 0, 3), EthernetPort("E0", 0, 0, 4)] - - response = await client.post(app.url_path_for("create_link", project_id=project.id), json={ - "nodes": [ - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 3, - "label": { - "text": "Text", - "x": 42, - "y": 0 - } - }, - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 4 - } - ] - }) - - assert response.status_code == status.HTTP_409_CONFLICT - assert len(project.links) == 0 - - - async def test_get_link( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - nodes: Tuple[Node, Node] - ) -> None: - - node1, node2 = nodes - with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: - response = await client.post(app.url_path_for("create_link", project_id=project.id), json={ + + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ "nodes": [ { "node_id": node1.id, "adapter_number": 0, "port_number": 3, - "label": { - "text": "Text", - "x": 42, - "y": 0 - } + "label": {"text": "Text", "x": 42, "y": 0}, }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } + {"node_id": node1.id, "adapter_number": 0, "port_number": 4}, ] - }) - + }, + ) + + assert response.status_code == status.HTTP_409_CONFLICT + assert len(project.links) == 0 + + async def test_get_link( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + { + "node_id": node1.id, + "adapter_number": 0, + "port_number": 3, + "label": {"text": "Text", "x": 42, "y": 0}, + }, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + }, + ) + assert mock.called link_id = response.json()["link_id"] assert response.json()["nodes"][0]["label"]["x"] == 42 response = await client.get(app.url_path_for("get_link", project_id=project.id, link_id=link_id)) assert response.status_code == status.HTTP_200_OK assert response.json()["nodes"][0]["label"]["x"] == 42 - - + async def test_update_link_suspend( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - nodes: Tuple[Node, Node] + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] ) -> None: - + node1, node2 = nodes with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: - response = await client.post(app.url_path_for("create_link", project_id=project.id), json={ + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + { + "node_id": node1.id, + "adapter_number": 0, + "port_number": 3, + "label": {"text": "Text", "x": 42, "y": 0}, + }, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + }, + ) + + assert mock.called + link_id = response.json()["link_id"] + assert response.json()["nodes"][0]["label"]["x"] == 42 + + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link_id), + json={ "nodes": [ { "node_id": node1.id, "adapter_number": 0, "port_number": 3, - "label": { - "text": "Text", - "x": 42, - "y": 0 - } + "label": {"text": "Hello", "x": 64, "y": 0}, }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } - ] - }) - - assert mock.called - link_id = response.json()["link_id"] - assert response.json()["nodes"][0]["label"]["x"] == 42 - - response = await client.put(app.url_path_for("update_link", project_id=project.id, link_id=link_id), json={ - "nodes": [ - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 3, - "label": { - "text": "Hello", - "x": 64, - "y": 0 - } - }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } - ], - "suspend": True - }) - + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ], + "suspend": True, + }, + ) + assert response.status_code == status.HTTP_200_OK assert response.json()["nodes"][0]["label"]["x"] == 64 assert response.json()["suspend"] assert response.json()["filters"] == {} - - + async def test_update_link( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - nodes: Tuple[Node, Node] + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] ) -> None: - - filters = { - "delay": [10, 0], - "frequency_drop": [50] - } + + filters = {"delay": [10, 0], "frequency_drop": [50]} node1, node2 = nodes with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: - response = await client.post(app.url_path_for("create_link", project_id=project.id), json={ + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + { + "node_id": node1.id, + "adapter_number": 0, + "port_number": 3, + "label": {"text": "Text", "x": 42, "y": 0}, + }, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + }, + ) + + assert mock.called + link_id = response.json()["link_id"] + assert response.json()["nodes"][0]["label"]["x"] == 42 + + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link_id), + json={ "nodes": [ { "node_id": node1.id, "adapter_number": 0, "port_number": 3, - "label": { - "text": "Text", - "x": 42, - "y": 0 - } + "label": {"text": "Hello", "x": 64, "y": 0}, }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } - ] - }) - - assert mock.called - link_id = response.json()["link_id"] - assert response.json()["nodes"][0]["label"]["x"] == 42 - - response = await client.put(app.url_path_for("update_link", project_id=project.id, link_id=link_id), json={ - "nodes": [ - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 3, - "label": { - "text": "Hello", - "x": 64, - "y": 0 - } - }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } - ], - "filters": filters - }) - + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ], + "filters": filters, + }, + ) + assert response.status_code == status.HTTP_200_OK assert response.json()["nodes"][0]["label"]["x"] == 64 assert list(project.links.values())[0].filters == filters - - + async def test_list_link( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - nodes: Tuple[Node, Node] + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] ) -> None: - - filters = { - "delay": [10, 0], - "frequency_drop": [50] - } - + + filters = {"delay": [10, 0], "frequency_drop": [50]} + node1, node2 = nodes nodes = [ - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 3 - }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } + {"node_id": node1.id, "adapter_number": 0, "port_number": 3}, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, ] with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: - await client.post(app.url_path_for("create_link", project_id=project.id), json={ - "nodes": nodes, - "filters": filters - }) - + await client.post( + app.url_path_for("create_link", project_id=project.id), json={"nodes": nodes, "filters": filters} + ) + assert mock.called response = await client.get(app.url_path_for("get_links", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 assert response.json()[0]["filters"] == filters - + # test listing links from a closed project await project.close(ignore_notification=True) response = await client.get(app.url_path_for("get_links", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 assert response.json()[0]["filters"] == filters - - + async def test_reset_link(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + link = UDPLink(project) project._links = {link.id: link} with asyncio_patch("gns3server.controller.udp_link.UDPLink.delete") as delete_mock: @@ -362,28 +277,27 @@ class TestLinkRoutes: assert delete_mock.called assert create_mock.called assert response.status_code == status.HTTP_200_OK - - + async def test_start_capture(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + link = Link(project) project._links = {link.id: link} with asyncio_patch("gns3server.controller.link.Link.start_capture") as mock: - response = await client.post(app.url_path_for("start_capture", project_id=project.id, link_id=link.id), json={}) + response = await client.post( + app.url_path_for("start_capture", project_id=project.id, link_id=link.id), json={} + ) assert mock.called assert response.status_code == status.HTTP_201_CREATED - - + async def test_stop_capture(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + link = Link(project) project._links = {link.id: link} with asyncio_patch("gns3server.controller.link.Link.stop_capture") as mock: response = await client.post(app.url_path_for("stop_capture", project_id=project.id, link_id=link.id)) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + # async def test_pcap(controller_api, http_client, project): # # async def pcap_capture(): @@ -403,20 +317,18 @@ class TestLinkRoutes: # assert mock.called # assert response.status_code == 200 # assert b'hello' == response.body - - + async def test_delete_link(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + link = Link(project) project._links = {link.id: link} with asyncio_patch("gns3server.controller.link.Link.delete") as mock: response = await client.delete(app.url_path_for("delete_link", project_id=project.id, link_id=link.id)) assert mock.called assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_list_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + link = Link(project) project._links = {link.id: link} with patch("gns3server.controller.link.Link.available_filters", return_value=FILTERS) as mock: @@ -425,7 +337,6 @@ class TestLinkRoutes: assert response.status_code == status.HTTP_200_OK assert response.json() == FILTERS - async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """ Test getting UDP tunnel interface information from a link. @@ -433,12 +344,12 @@ class TestLinkRoutes: link = Link(project) project._links = {link.id: link} - + cloud_node = MagicMock() cloud_node.node_type = "cloud" cloud_node.id = str(uuid.uuid4()) cloud_node.name = "Cloud1" - + compute = MagicMock() response = MagicMock() response.json = { @@ -449,15 +360,15 @@ class TestLinkRoutes: "lport": 20000, "rhost": "127.0.0.1", "rport": 30000, - "name": "UDP tunnel 1" + "name": "UDP tunnel 1", } ] } compute.get = AsyncioMagicMock(return_value=response) cloud_node.compute = compute - + link._nodes = [{"node": cloud_node, "port_number": 1}] - + response = await client.get(app.url_path_for("get_iface", project_id=project.id, link_id=link.id)) assert response.status_code == status.HTTP_200_OK @@ -468,7 +379,6 @@ class TestLinkRoutes: assert result["rport"] == 30000 assert result["type"] == "udp" - async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """ Test getting ethernet interface information from a link. @@ -484,14 +394,7 @@ class TestLinkRoutes: compute = MagicMock() response = MagicMock() response.json = { - "ports_mapping": [ - { - "port_number": 1, - "type": "ethernet", - "interface": "eth0", - "name": "Ethernet 1" - } - ] + "ports_mapping": [{"port_number": 1, "type": "ethernet", "interface": "eth0", "name": "Ethernet 1"}] } compute.get = AsyncioMagicMock(return_value=response) cloud_node.compute = compute @@ -504,4 +407,4 @@ class TestLinkRoutes: result = response.json() assert result["node_id"] == cloud_node.id assert result["interface"] == "eth0" - assert result["type"] == "ethernet" \ No newline at end of file + assert result["type"] == "ethernet" diff --git a/tests/api/routes/controller/test_marker_replay.py b/tests/api/routes/controller/test_marker_replay.py index b5db7398d..072190f4c 100644 --- a/tests/api/routes/controller/test_marker_replay.py +++ b/tests/api/routes/controller/test_marker_replay.py @@ -55,27 +55,29 @@ def _add_marker(project, tag, enabled, node_id, frames=None): """Create a paused/capturing link+marker and optionally its pcap.""" link = UDPLink(project) - link._markers["icmp"] = {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None, - "highlight_duration": None, "capture_node_id": node_id, - "direction": None, "data_link_type": "DLT_EN10MB"} + link._markers["icmp"] = { + "bpf": "icmp", + "tag": tag, + "enabled": enabled, + "color": None, + "highlight_duration": None, + "capture_node_id": node_id, + "direction": None, + "data_link_type": "DLT_EN10MB", + } project._links[link.id] = link if frames is not None: - _write_pcap( - f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames - ) + _write_pcap(f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames) return link class TestReplayRoutes: - async def test_range_409_while_capturing(self, app: FastAPI, client: AsyncClient, project: Project) -> None: _add_marker(project, tag=7, enabled=False, node_id="n1") running = _add_marker(project, tag=7, enabled=True, node_id="n2") - response = await client.get( - app.url_path_for("replay_tag_range", project_id=project.id, tag=7) - ) + response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=7)) assert response.status_code == status.HTTP_409_CONFLICT assert "icmp" in response.json()["message"] assert running.id in response.json()["message"] @@ -84,23 +86,25 @@ class TestReplayRoutes: _add_marker(project, tag=7, enabled=False, node_id="n1") - response = await client.get( - app.url_path_for("replay_tag_range", project_id=project.id, tag=99) - ) + response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=99)) assert response.status_code == status.HTTP_404_NOT_FOUND async def test_range_501_without_sharkd(self, app: FastAPI, client: AsyncClient, project: Project) -> None: # A non-empty pcap: the engine must be consulted, and without sharkd # the whole feature is unavailable (hard requirement, no degraded mode). - _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - ]) + _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + ], + ) with patch("gns3server.controller.marker_replay.shutil.which", return_value=None): - response = await client.get( - app.url_path_for("replay_tag_range", project_id=project.id, tag=7) - ) + response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=7)) assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED # The app's HTTPException handler unifies the body as {"message": …}. assert "sharkd" in response.json()["message"] @@ -115,9 +119,16 @@ class TestReplayRoutes: project._links.update({r1.id: r1, r2.id: r2}) def _wire(link, node_id, frames): - link._markers["icmp"] = {"bpf": "icmp", "tag": 7, "enabled": False, "color": None, - "highlight_duration": None, "capture_node_id": node_id, - "direction": None, "data_link_type": "DLT_EN10MB"} + link._markers["icmp"] = { + "bpf": "icmp", + "tag": 7, + "enabled": False, + "color": None, + "highlight_duration": None, + "capture_node_id": node_id, + "direction": None, + "data_link_type": "DLT_EN10MB", + } _write_pcap(f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames) # r1→r2 captures at t1 and t3; r2→r3 captures at t2 and t3 (same µs @@ -132,9 +143,7 @@ class TestReplayRoutes: monkeypatch.setattr(marker_replay, "_columns_for", fake_columns) - response = await client.get( - app.url_path_for("replay_tag_range", project_id=project.id, tag=7) - ) + response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=7)) assert response.status_code == status.HTTP_200_OK body = response.json() assert body["tag"] == 7 @@ -143,8 +152,10 @@ class TestReplayRoutes: assert body["end"] == "1693472002.000000" assert [f["node_id"] for f in body["frames"]] == ["n1", "n2", "n1", "n2"] assert [f["ts"] for f in body["frames"]] == [ - "1693472000.500000", "1693472001.000000", - "1693472002.000000", "1693472002.000000", + "1693472000.500000", + "1693472001.000000", + "1693472002.000000", + "1693472002.000000", ] # Wireshark-style columns ride along on every frame entry. assert body["frames"][0]["src"] == "10.0.0.1" @@ -156,9 +167,15 @@ class TestReplayRoutes: self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch ) -> None: - _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 0, b"a" * 60), - ]) + _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 0, b"a" * 60), + ], + ) async def fake_columns(pcap, filter_expr): return {1: _cols()} @@ -172,14 +189,18 @@ class TestReplayRoutes: assert response.status_code == status.HTTP_200_OK assert response.json() == {"frames": []} - async def test_frames_window_hit( - self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch - ) -> None: + async def test_frames_window_hit(self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch) -> None: - _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 0, b"a" * 60), - (1693472000, 150000, b"a" * 60), - ]) + _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 0, b"a" * 60), + (1693472000, 150000, b"a" * 60), + ], + ) async def fake_columns(pcap, filter_expr): return {1: _cols(), 2: _cols()} @@ -191,15 +212,19 @@ class TestReplayRoutes: params={"ts": "1693472000.000000", "window_ms": 150}, ) assert response.status_code == status.HTTP_200_OK - assert [f["ts"] for f in response.json()["frames"]] == [ - "1693472000.000000", "1693472000.150000" - ] + assert [f["ts"] for f in response.json()["frames"]] == ["1693472000.000000", "1693472000.150000"] async def test_detail_404_on_ts_mismatch(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - ]) + link = _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + ], + ) response = await client.get( app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), @@ -216,9 +241,16 @@ class TestReplayRoutes: project._links.update({r1.id: r1, r2.id: r2}) def _wire(link, node_id, frames): - link._markers["icmp"] = {"bpf": "icmp", "tag": 7, "enabled": False, "color": None, - "highlight_duration": None, "capture_node_id": node_id, - "direction": None, "data_link_type": "DLT_EN10MB"} + link._markers["icmp"] = { + "bpf": "icmp", + "tag": 7, + "enabled": False, + "color": None, + "highlight_duration": None, + "capture_node_id": node_id, + "direction": None, + "data_link_type": "DLT_EN10MB", + } _write_pcap(f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames) _wire(r1, "n1", [(1693472000, 0, b"a" * 60), (1693472002, 0, b"a" * 60)]) @@ -254,15 +286,19 @@ class TestReplayRoutes: self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions ) -> None: - _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - (1693472001, 0, _tcp_syn_frame()), - ]) + _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + (1693472001, 0, _tcp_syn_frame()), + ], + ) # Unfiltered: both frames with real engine columns. - response = await client.get( - app.url_path_for("replay_tag_range", project_id=project.id, tag=7) - ) + response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=7)) assert response.status_code == status.HTTP_200_OK frames = response.json()["frames"] assert [f["proto"] for f in frames] == ["ICMP", "TCP"] @@ -285,9 +321,15 @@ class TestReplayRoutes: self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions ) -> None: - _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - ]) + _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + ], + ) response = await client.get( app.url_path_for("replay_tag_range", project_id=project.id, tag=7), @@ -308,14 +350,19 @@ class TestReplayRoutes: self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions ) -> None: - link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - ]) + link = _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + ], + ) response = await client.get( app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), - params={"ts": "1693472000.123456", "node_id": "n1", - "link_id": link.id, "marker": "icmp"}, + params={"ts": "1693472000.123456", "node_id": "n1", "link_id": link.id, "marker": "icmp"}, ) assert response.status_code == status.HTTP_200_OK body = response.json() @@ -345,12 +392,17 @@ class TestReplayRoutes: # Two frames in the same microsecond: only the explicit frame number # (from the frame list entry) tells them apart. - link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - (1693472000, 123456, _tcp_syn_frame()), - ]) - common = {"ts": "1693472000.123456", "node_id": "n1", - "link_id": link.id, "marker": "icmp"} + link = _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + (1693472000, 123456, _tcp_syn_frame()), + ], + ) + common = {"ts": "1693472000.123456", "node_id": "n1", "link_id": link.id, "marker": "icmp"} response = await client.get( app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), @@ -369,18 +421,21 @@ class TestReplayRoutes: assert response.json()["hex"] == _tcp_syn_frame().hex() @sharkd_present - async def test_detail_501_when_sharkd_disappears( - self, app: FastAPI, client: AsyncClient, project: Project - ) -> None: + async def test_detail_501_when_sharkd_disappears(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ - (1693472000, 123456, _icmp_frame()), - ]) + link = _add_marker( + project, + tag=7, + enabled=False, + node_id="n1", + frames=[ + (1693472000, 123456, _icmp_frame()), + ], + ) with patch("gns3server.controller.marker_replay.shutil.which", return_value=None): response = await client.get( app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), - params={"ts": "1693472000.123456", "node_id": "n1", - "link_id": link.id, "marker": "icmp"}, + params={"ts": "1693472000.123456", "node_id": "n1", "link_id": link.id, "marker": "icmp"}, ) assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py index 53e57e7d9..2e088ed3c 100644 --- a/tests/api/routes/controller/test_markers.py +++ b/tests/api/routes/controller/test_markers.py @@ -35,14 +35,17 @@ pytestmark = pytest.mark.asyncio def _inherited(link, name="arp"): """Inject an inherited marker so the controller's inheritance guard can fire.""" link._markers[f"global-{name}"] = { - "bpf": name, "tag": None, "enabled": True, "color": None, - "highlight_duration": None, "capture_node_id": "node-id", + "bpf": name, + "tag": None, + "enabled": True, + "color": None, + "highlight_duration": None, + "capture_node_id": "node-id", "inherited_from": name, } class TestMarkerRoutes: - # ----------------------------------------------------------------------- # Per-link markers # ----------------------------------------------------------------------- @@ -81,7 +84,9 @@ class TestMarkerRoutes: _, kwargs = mock.call_args assert kwargs["name"] == "web" - async def test_create_marker_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + async def test_create_marker_global_prefix_rejected( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: link = UDPLink(project) project._links = {link.id: link} @@ -105,7 +110,9 @@ class TestMarkerRoutes: ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - async def test_create_marker_name_too_long_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + async def test_create_marker_name_too_long_rejected( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: link = UDPLink(project) project._links = {link.id: link} @@ -119,13 +126,17 @@ class TestMarkerRoutes: async def test_get_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None: link = UDPLink(project) - link._markers["web"] = {"bpf": "tcp port 80", "tag": None, "enabled": True, - "color": None, "highlight_duration": 800, "capture_node_id": "n1"} + link._markers["web"] = { + "bpf": "tcp port 80", + "tag": None, + "enabled": True, + "color": None, + "highlight_duration": 800, + "capture_node_id": "n1", + } project._links = {link.id: link} - response = await client.get( - app.url_path_for("get_markers", project_id=project.id, link_id=link.id) - ) + response = await client.get(app.url_path_for("get_markers", project_id=project.id, link_id=link.id)) assert response.status_code == status.HTTP_200_OK assert response.json()["web"]["highlight_duration"] == 800 @@ -197,7 +208,9 @@ class TestMarkerRoutes: assert kwargs["bpf"] == "arp" assert kwargs["highlight_duration"] == 1200 - async def test_create_marker_definition_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + async def test_create_marker_definition_global_prefix_rejected( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock: response = await client.post( @@ -235,9 +248,7 @@ class TestMarkerRoutes: } project._links = {} - response = await client.get( - app.url_path_for("get_marker_definitions", project_id=project.id) - ) + response = await client.get(app.url_path_for("get_marker_definitions", project_id=project.id)) assert response.status_code == status.HTTP_200_OK body = response.json() assert body["arp"]["bpf"] == "arp" @@ -251,13 +262,17 @@ class TestMarkerRoutes: async def test_get_project_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None: link = UDPLink(project) - link._markers["icmp"] = {"bpf": "icmp", "tag": 1, "enabled": True, "color": "#ff5722", - "highlight_duration": 800, "capture_node_id": "node-1"} + link._markers["icmp"] = { + "bpf": "icmp", + "tag": 1, + "enabled": True, + "color": "#ff5722", + "highlight_duration": 800, + "capture_node_id": "node-1", + } project._links = {link.id: link} - response = await client.get( - app.url_path_for("get_project_markers", project_id=project.id) - ) + response = await client.get(app.url_path_for("get_project_markers", project_id=project.id)) assert response.status_code == status.HTTP_200_OK body = response.json() key = f"{link.id}/icmp" diff --git a/tests/api/routes/controller/test_netmiko.py b/tests/api/routes/controller/test_netmiko.py index 84fe8934c..6384161bb 100644 --- a/tests/api/routes/controller/test_netmiko.py +++ b/tests/api/routes/controller/test_netmiko.py @@ -24,7 +24,6 @@ pytestmark = pytest.mark.asyncio class TestNetmikoRoutes: - async def test_device_types(self, app: FastAPI, client: AsyncClient) -> None: """ Test listing the device types supported by the installed Netmiko library. diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py index 6183503d7..d86e13fe0 100644 --- a/tests/api/routes/controller/test_nodes.py +++ b/tests/api/routes/controller/test_nodes.py @@ -49,74 +49,69 @@ pytestmark = pytest.mark.asyncio class TestNodeRoutes: - - @pytest.fixture def node(self, project: Project, compute: Compute) -> Node: - + node = Node(project, compute, "test", node_type="vpcs") project._nodes[node.id] = node return node - - + async def test_create_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: - + response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - - response = await client.post(app.url_path_for("create_node", project_id=project.id), json={ - "name": "test", - "node_type": "vpcs", - "compute_id": "example.com", - "properties": { - "startup_script": "echo test" - } - }) - + + response = await client.post( + app.url_path_for("create_node", project_id=project.id), + json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "properties": {"startup_script": "echo test"}, + }, + ) + assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "test" assert "name" not in response.json()["properties"] - - + async def test_list_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: - + response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - - await client.post(app.url_path_for("create_node", project_id=project.id), json={ - "name": "test", - "node_type": "vpcs", - "compute_id": "example.com", - "properties": { - "startup_script": "echo test" - } - }) - + + await client.post( + app.url_path_for("create_node", project_id=project.id), + json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "properties": {"startup_script": "echo test"}, + }, + ) + response = await client.get(app.url_path_for("get_nodes", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert response.json()[0]["name"] == "test" - + # test listing nodes from a closed project await project.close(ignore_notification=True) response = await client.get(app.url_path_for("get_nodes", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert response.json()[0]["name"] == "test" + async def test_list_node_with_missing_image( self, app: FastAPI, client: AsyncClient, project: Project, node: Node ) -> None: - node._missing_images = [ - {"property": "path", "image": "i86bi-linux-l2.bin", "image_type": "iou"} - ] + node._missing_images = [{"property": "path", "image": "i86bi-linux-l2.bin", "image_type": "iou"}] response = await client.get(app.url_path_for("get_nodes", project_id=project.id)) assert response.status_code == status.HTTP_200_OK data = response.json()[0] assert data["missing_image"] is True - assert data["missing_images"] == [ - {"property": "path", "image": "i86bi-linux-l2.bin", "image_type": "iou"} - ] + assert data["missing_images"] == [{"property": "path", "image": "i86bi-linux-l2.bin", "image_type": "iou"}] @pytest.mark.parametrize( "tags, expected_match", @@ -129,37 +124,33 @@ class TestNodeRoutes: ), ) async def test_list_nodes_with_tags( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - tags: list, - expected_match: bool + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, tags: list, expected_match: bool ) -> None: response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - await client.post(app.url_path_for("create_node", project_id=project.id), json={ - "name": "test", - "node_type": "vpcs", - "compute_id": "example.com", - "tags": ["tag1", "tag2"], - "properties": { - "startup_script": "echo test" - } - }) + await client.post( + app.url_path_for("create_node", project_id=project.id), + json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "tags": ["tag1", "tag2"], + "properties": {"startup_script": "echo test"}, + }, + ) - await client.post(app.url_path_for("create_node", project_id=project.id), json={ - "name": "test2", - "node_type": "vpcs", - "compute_id": "example.com", - "tags": ["tag3", "tag4"], - "properties": { - "startup_script": "echo test" - } - }) + await client.post( + app.url_path_for("create_node", project_id=project.id), + json={ + "name": "test2", + "node_type": "vpcs", + "compute_id": "example.com", + "tags": ["tag3", "tag4"], + "properties": {"startup_script": "echo test"}, + }, + ) params = {"tags": tags} response = await client.get(app.url_path_for("get_nodes", project_id=project.id), params=params) @@ -169,161 +160,106 @@ class TestNodeRoutes: else: assert len(response.json()) == 0 - - async def test_get_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute - ) -> None: - + async def test_get_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: + response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - - response = await client.post(app.url_path_for("create_node", project_id=project.id), json={ - "name": "test", - "node_type": "vpcs", - "compute_id": "example.com", - "properties": { - "startup_script": "echo test" - } - }) - - response = await client.get(app.url_path_for("get_node", project_id=project.id, node_id=response.json()["node_id"])) + + response = await client.post( + app.url_path_for("create_node", project_id=project.id), + json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "properties": {"startup_script": "echo test"}, + }, + ) + + response = await client.get( + app.url_path_for("get_node", project_id=project.id, node_id=response.json()["node_id"]) + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" - - + async def test_update_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() response.json = {"console": 2048} compute.put = AsyncioMagicMock(return_value=response) - - response = await client.put(app.url_path_for("update_node", project_id=project.id, node_id=node.id), json={ - "name": "test", - "node_type": "vpcs", - "compute_id": "example.com", - "tags": ["tag1", "tag2"], - "properties": { - "startup_script": "echo test" - } - }) - + + response = await client.put( + app.url_path_for("update_node", project_id=project.id, node_id=node.id), + json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "tags": ["tag1", "tag2"], + "properties": {"startup_script": "echo test"}, + }, + ) + assert response.status_code == 200 assert response.json()["name"] == "test" assert "name" not in response.json()["properties"] assert response.json()["tags"] == ["tag1", "tag2"] + async def test_start_all_nodes(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: - async def test_start_all_nodes( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute - ) -> None: - compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("start_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - - async def test_stop_all_nodes( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute - ) -> None: - + + async def test_stop_all_nodes(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("stop_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_suspend_all_nodes( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("suspend_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_reload_all_nodes( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("reload_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_reset_console_all_nodes( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("reset_console_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_start_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={}) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_stop_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - + async def test_suspend_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: compute.post = AsyncioMagicMock() @@ -331,12 +267,7 @@ class TestNodeRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT async def test_suspend_node_unsupported( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: # node types without suspend support (e.g. VPCS, IOU) must surface the @@ -348,12 +279,7 @@ class TestNodeRoutes: assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED async def test_suspend_all_nodes_tolerates_unsupported( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: # suspending all nodes of a mixed project stays best-effort: nodes @@ -363,259 +289,162 @@ class TestNodeRoutes: ) response = await client.post(app.url_path_for("suspend_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - - async def test_reload_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node - ): - + + async def test_reload_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node): + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("reload_node", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_isolate_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ): - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("isolate_node", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_unisolate_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("unisolate_node", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_duplicate_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() response.json({"console": 2035}) compute.post = AsyncioMagicMock(return_value=response) - - response = await client.post(app.url_path_for("duplicate_node", project_id=project.id, node_id=node.id), - json={"x": 10, "y": 5, "z": 0}) + + response = await client.post( + app.url_path_for("duplicate_node", project_id=project.id, node_id=node.id), json={"x": 10, "y": 5, "z": 0} + ) assert response.status_code == status.HTTP_201_CREATED - - + async def test_delete_node( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + compute.post = AsyncioMagicMock() response = await client.delete(app.url_path_for("delete_node", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_dynamips_idle_pc( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() response.json = {"idlepc": "0x60606f54"} compute.get = AsyncioMagicMock(return_value=response) - + node._node_type = "dynamips" # force Dynamips node type response = await client.get(app.url_path_for("auto_idlepc", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_200_OK assert response.json()["idlepc"] == "0x60606f54" - - + async def test_dynamips_idle_pc_wrong_node_type( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = await client.get(app.url_path_for("auto_idlepc", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_400_BAD_REQUEST - - + async def test_dynamips_idlepc_proposals( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() response.json = ["0x60606f54", "0x33805a22"] compute.get = AsyncioMagicMock(return_value=response) - + node._node_type = "dynamips" # force Dynamips node type response = await client.get(app.url_path_for("idlepc_proposals", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_200_OK assert response.json() == ["0x60606f54", "0x33805a22"] - - + async def test_dynamips_idlepc_proposals_wrong_node_type( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = await client.get(app.url_path_for("idlepc_proposals", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_400_BAD_REQUEST - - + async def test_qemu_disk_image_create( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() compute.post = AsyncioMagicMock(return_value=response) - + node._node_type = "qemu" # force Qemu node type response = await client.post( app.url_path_for("create_disk_image", project_id=project.id, node_id=node.id, disk_name="hda_disk.qcow2"), - json={"format": "qcow2", "size": 30} + json={"format": "qcow2", "size": 30}, ) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_disk_image_create_wrong_node_type( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = await client.post( app.url_path_for("create_disk_image", project_id=project.id, node_id=node.id, disk_name="hda_disk.qcow2"), - json={"format": "qcow2", "size": 30} + json={"format": "qcow2", "size": 30}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST - - + async def test_qemu_disk_image_update( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() compute.put = AsyncioMagicMock(return_value=response) - + node._node_type = "qemu" # force Qemu node type response = await client.put( app.url_path_for("update_disk_image", project_id=project.id, node_id=node.id, disk_name="hda_disk.qcow2"), - json={"extend": 10} + json={"extend": 10}, ) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_disk_image_update_wrong_node_type( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = await client.put( app.url_path_for("update_disk_image", project_id=project.id, node_id=node.id, disk_name="hda_disk.qcow2"), - json={"extend": 10} + json={"extend": 10}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST - - + async def test_qemu_disk_image_delete( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = MagicMock() compute.delete = AsyncioMagicMock(return_value=response) - + node._node_type = "qemu" # force Qemu node type response = await client.delete( app.url_path_for("delete_disk_image", project_id=project.id, node_id=node.id, disk_name="hda_disk.qcow2") ) assert response.status_code == status.HTTP_204_NO_CONTENT - - + async def test_qemu_disk_image_delete_wrong_node_type( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + response = await client.delete( app.url_path_for("delete_disk_image", project_id=project.id, node_id=node.id, disk_name="hda_disk.qcow2") ) assert response.status_code == status.HTTP_400_BAD_REQUEST - - + async def test_get_file( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: # Mock the streaming response @@ -632,56 +461,52 @@ class TestNodeRoutes: compute.http_query = AsyncioMagicMock(return_value=mock_response) - response = await client.get(app.url_path_for("get_file", project_id=project.id, node_id=node.id, file_path="hello")) + response = await client.get( + app.url_path_for("get_file", project_id=project.id, node_id=node.id, file_path="hello") + ) assert response.status_code == status.HTTP_200_OK - assert response.content == b'world' + assert response.content == b"world" compute.http_query.assert_called_with( "GET", "/projects/{project_id}/files/project-files/vpcs/{node_id}/hello".format( - project_id=project.id, - node_id=node.id), + project_id=project.id, node_id=node.id + ), timeout=None, - stream=True) + stream=True, + ) - response = await client.get(app.url_path_for( - "get_file", - project_id=project.id, - node_id=node.id, - file_path="../hello")) + response = await client.get( + app.url_path_for("get_file", project_id=project.id, node_id=node.id, file_path="../hello") + ) assert response.status_code == status.HTTP_404_NOT_FOUND - - + async def test_post_file( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: - + compute.http_query = AsyncioMagicMock() - response = await client.post(app.url_path_for( - "post_file", - project_id=project.id, - node_id=node.id, - file_path="hello"), content=b"hello") + response = await client.post( + app.url_path_for("post_file", project_id=project.id, node_id=node.id, file_path="hello"), content=b"hello" + ) assert response.status_code == status.HTTP_201_CREATED # Verify http_query was called with stream parameter compute.http_query.assert_called_once() call_args = compute.http_query.call_args assert call_args[0][0] == "POST" - assert call_args[0][1] == "/projects/{project_id}/files/project-files/vpcs/{node_id}/hello".format(project_id=project.id, node_id=node.id) + assert call_args[0][1] == "/projects/{project_id}/files/project-files/vpcs/{node_id}/hello".format( + project_id=project.id, node_id=node.id + ) assert call_args[1]["timeout"] is None # data should be an async generator from request.stream() assert hasattr(call_args[1]["data"], "__aiter__") - - response = await client.get("/projects/{project_id}/nodes/{node_id}/files/../hello".format(project_id=project.id, node_id=node.id)) + + response = await client.get( + "/projects/{project_id}/nodes/{node_id}/files/../hello".format(project_id=project.id, node_id=node.id) + ) assert response.status_code == status.HTTP_404_NOT_FOUND - - + # @pytest.mark.asyncio # async def test_get_and_post_with_nested_paths_normalization(controller_api, project, node, compute): # @@ -701,8 +526,6 @@ class TestNodeRoutes: # compute.http_query.assert_called_with("POST", "/projects/{project_id}/files/project-files/vpcs/{node_id}/hello/nested".format(project_id=project.id, node_id=node.id), data=b'hello', timeout=None, raw=True) - - class FakeComputeConsoleWebSocket: """ Stand-in for the aiohttp ClientWebSocketResponse returned when connecting @@ -823,21 +646,21 @@ class TestNodeConsoleWebSocketRoutes: monkeypatch.setattr( HTTPClient, "get_client", - classmethod(lambda cls: SimpleNamespace(ws_connect=lambda *args, **kwargs: compute_ws)) + classmethod(lambda cls: SimpleNamespace(ws_connect=lambda *args, **kwargs: compute_ws)), ) return compute_ws async def test_console_forwards_compute_output_to_client( - self, - compute_credentials, - node: Node, - monkeypatch + self, compute_credentials, node: Node, monkeypatch ) -> None: - compute_ws = self._forward_compute_ws(monkeypatch, [ - aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "device output", None), - aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"\x00\x01", None), - ]) + compute_ws = self._forward_compute_ws( + monkeypatch, + [ + aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "device output", None), + aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"\x00\x01", None), + ], + ) websocket = FakeClientWebSocket() await ws_console(websocket, current_user=MagicMock(), node=node) @@ -846,19 +669,18 @@ class TestNodeConsoleWebSocketRoutes: assert compute_ws.closed async def test_console_client_disconnect_mid_stream( - self, - compute_credentials, - node: Node, - monkeypatch, - caplog + self, compute_credentials, node: Node, monkeypatch, caplog ) -> None: # regression test: the client disconnects while the compute is still # streaming console output, send must not leak WebSocketDisconnect - compute_ws = self._forward_compute_ws(monkeypatch, [ - aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "line 1", None), - aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "line 2", None), - aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "line 3", None), - ]) + compute_ws = self._forward_compute_ws( + monkeypatch, + [ + aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "line 1", None), + aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "line 2", None), + aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "line 3", None), + ], + ) websocket = FakeClientWebSocket(fail_after=1) with caplog.at_level(logging.INFO): @@ -868,17 +690,15 @@ class TestNodeConsoleWebSocketRoutes: assert compute_ws.closed assert any("has disconnected from controller console WebSocket" in record.message for record in caplog.records) - async def test_vnc_console_client_disconnect_mid_stream( - self, - compute_credentials, - node: Node, - monkeypatch - ) -> None: + async def test_vnc_console_client_disconnect_mid_stream(self, compute_credentials, node: Node, monkeypatch) -> None: # regression test: same as above for the VNC console forwarding loop - compute_ws = self._forward_compute_ws(monkeypatch, [ - aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"\x01\x02", None), - aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"\x03\x04", None), - ]) + compute_ws = self._forward_compute_ws( + monkeypatch, + [ + aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"\x01\x02", None), + aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"\x03\x04", None), + ], + ) websocket = FakeClientWebSocket(fail_after=0) await vnc_console(websocket, current_user=MagicMock(), node=node) @@ -978,10 +798,9 @@ class TestConsoleWebSocketRoutes: stack = contextlib.ExitStack() http_client = MagicMock() http_client.ws_connect = MagicMock(return_value=_FakeComputeWebSocketContext(fake_ws)) - stack.enter_context(patch( - "gns3server.api.routes.controller.nodes.HTTPClient.get_client", - return_value=http_client - )) + stack.enter_context( + patch("gns3server.api.routes.controller.nodes.HTTPClient.get_client", return_value=http_client) + ) stack.enter_context(patch.object(ASGIWebSocketAsyncNetworkStream, "send", _conforming_stream_send)) return stack @@ -1000,13 +819,7 @@ class TestConsoleWebSocketRoutes: await asyncio.sleep(0.05) async def test_console_ws_client_disconnect( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node, - caplog + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node, caplog ) -> None: """ A client disconnecting while the compute keeps streaming console output @@ -1033,27 +846,23 @@ class TestConsoleWebSocketRoutes: assert fake_ws.stream_cancelled, "the compute -> client forwarding task should have been cancelled" assert fake_ws.closed, "the compute console WebSocket should have been closed" assert any( - "has disconnected from controller console WebSocket" in record.getMessage() - for record in caplog.records + "has disconnected from controller console WebSocket" in record.getMessage() for record in caplog.records ) async def test_console_ws_compute_closes_session( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node ) -> None: """ When the compute closes the console WebSocket the client should receive the frames sent before the close, then the close itself. """ - fake_ws = _FakeComputeWebSocket(messages=[ - aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"output", ""), - aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "done", ""), - ]) + fake_ws = _FakeComputeWebSocket( + messages=[ + aiohttp.WSMessage(aiohttp.WSMsgType.BINARY, b"output", ""), + aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "done", ""), + ] + ) with self._patches(fake_ws): async with AsyncClient(base_url="http://test-api", transport=ASGIWebSocketTransport(app=app)) as ws_client: async with aconnect_ws( @@ -1069,13 +878,7 @@ class TestConsoleWebSocketRoutes: assert fake_ws.closed async def test_vnc_console_ws_client_disconnect( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - compute: Compute, - node: Node, - caplog + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node, caplog ) -> None: """ Same as the console test, for the VNC endpoint (binary frames only). @@ -1097,6 +900,5 @@ class TestConsoleWebSocketRoutes: assert fake_ws.stream_cancelled, "the compute -> client forwarding task should have been cancelled" assert fake_ws.closed, "the compute VNC console WebSocket should have been closed" assert any( - "has disconnected from controller VNC console WebSocket" in record.getMessage() - for record in caplog.records + "has disconnected from controller VNC console WebSocket" in record.getMessage() for record in caplog.records ) diff --git a/tests/api/routes/controller/test_pools.py b/tests/api/routes/controller/test_pools.py index 38782e442..f3b78cccb 100644 --- a/tests/api/routes/controller/test_pools.py +++ b/tests/api/routes/controller/test_pools.py @@ -32,7 +32,6 @@ pytestmark = pytest.mark.asyncio class TestPoolRoutes: - async def test_resource_pool(self, app: FastAPI, client: AsyncClient) -> None: new_group = {"name": "pool1"} @@ -60,28 +59,23 @@ class TestPoolRoutes: update_pool = {"name": "pool42"} response = await client.put( - app.url_path_for("update_resource_pool", resource_pool_id=pool_in_db.resource_pool_id), - json=update_pool + app.url_path_for("update_resource_pool", resource_pool_id=pool_in_db.resource_pool_id), json=update_pool ) assert response.status_code == status.HTTP_200_OK updated_pool_in_db = await pools_repo.get_resource_pool(pool_in_db.resource_pool_id) assert updated_pool_in_db.name == "pool42" - async def test_resource_group( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_resource_group(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: pools_repo = ResourcePoolsRepository(db_session) pool_in_db = await pools_repo.get_resource_pool_by_name("pool42") - response = await client.delete(app.url_path_for("delete_resource_pool", resource_pool_id=pool_in_db.resource_pool_id)) + response = await client.delete( + app.url_path_for("delete_resource_pool", resource_pool_id=pool_in_db.resource_pool_id) + ) assert response.status_code == status.HTTP_204_NO_CONTENT class TestResourcesPoolRoutes: - @pytest_asyncio.fixture async def project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> Project: project_id = str(uuid.uuid4()) @@ -90,11 +84,7 @@ class TestResourcesPoolRoutes: return controller.get_project(project_id) async def test_add_resource_to_pool( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - project: Project + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, project: Project ) -> None: pools_repo = ResourcePoolsRepository(db_session) @@ -104,9 +94,7 @@ class TestResourcesPoolRoutes: pool_in_db = await pools_repo.create_resource_pool(new_resource_pool) response = await client.put( app.url_path_for( - "add_resource_to_pool", - resource_pool_id=pool_in_db.resource_pool_id, - resource_id=str(project.id) + "add_resource_to_pool", resource_pool_id=pool_in_db.resource_pool_id, resource_id=str(project.id) ) ) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -115,11 +103,7 @@ class TestResourcesPoolRoutes: assert str(resources[0].resource_id) == project.id async def test_add_to_resource_already_in_resource_pool( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - project: Project + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, project: Project ) -> None: pools_repo = ResourcePoolsRepository(db_session) @@ -132,34 +116,23 @@ class TestResourcesPoolRoutes: app.url_path_for( "add_resource_to_pool", resource_pool_id=pool_in_db.resource_pool_id, - resource_id=str(resource.resource_id) + resource_id=str(resource.resource_id), ) ) assert response.status_code == status.HTTP_400_BAD_REQUEST - async def test_get_pool_resources( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_get_pool_resources(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: pools_repo = ResourcePoolsRepository(db_session) pool_in_db = await pools_repo.get_resource_pool_by_name("pool1") response = await client.get( - app.url_path_for( - "get_pool_resources", - resource_pool_id=pool_in_db.resource_pool_id) + app.url_path_for("get_pool_resources", resource_pool_id=pool_in_db.resource_pool_id) ) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 2 async def test_remove_resource_from_pool( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - project: Project + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, project: Project ) -> None: pools_repo = ResourcePoolsRepository(db_session) @@ -173,9 +146,7 @@ class TestResourcesPoolRoutes: response = await client.delete( app.url_path_for( - "remove_resource_from_pool", - resource_pool_id=pool_in_db.resource_pool_id, - resource_id=str(project.id) + "remove_resource_from_pool", resource_pool_id=pool_in_db.resource_pool_id, resource_id=str(project.id) ), ) assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/tests/api/routes/controller/test_privileges.py b/tests/api/routes/controller/test_privileges.py index 273ac1bec..6efcae597 100644 --- a/tests/api/routes/controller/test_privileges.py +++ b/tests/api/routes/controller/test_privileges.py @@ -19,7 +19,6 @@ pytestmark = pytest.mark.asyncio class TestPrivilegesRoute: - async def test_get_privileges(self, app: FastAPI, client: AsyncClient) -> None: response = await client.get(app.url_path_for("get_privileges")) assert response.status_code == status.HTTP_200_OK diff --git a/tests/api/routes/controller/test_projects.py b/tests/api/routes/controller/test_projects.py index c5d32922a..e94b7e273 100644 --- a/tests/api/routes/controller/test_projects.py +++ b/tests/api/routes/controller/test_projects.py @@ -35,202 +35,157 @@ pytestmark = pytest.mark.asyncio class TestControllerProjectRoutes: - @pytest_asyncio.fixture async def project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> Project: - + project_id = str(uuid.uuid4()) params = {"name": "test", "project_id": project_id} await client.post(app.url_path_for("create_project"), json=params) return controller.get_project(project_id) - - + async def test_create_project_with_path( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller, - config + self, app: FastAPI, client: AsyncClient, controller: Controller, config ) -> None: - - params = {"name": "test", "path": str(config.settings.Server.projects_path), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"} + + params = { + "name": "test", + "path": str(config.settings.Server.projects_path), + "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f", + } response = await client.post(app.url_path_for("create_project"), json=params) # The projects directory itself must never become a project directory assert response.status_code == status.HTTP_403_FORBIDDEN - params = {"name": "test", "path": os.path.join(str(config.settings.Server.projects_path), "custom"), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"} + params = { + "name": "test", + "path": os.path.join(str(config.settings.Server.projects_path), "custom"), + "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f", + } response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "test" assert response.json()["project_id"] == "00010203-0405-0607-0809-0a0b0c0d0e0f" assert response.json()["status"] == "opened" - - - async def test_create_project_without_dir( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller - ) -> None: - + + async def test_create_project_without_dir(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: + params = {"name": "test", "project_id": "10010203-0405-0607-0809-0a0b0c0d0e0f"} response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["project_id"] == "10010203-0405-0607-0809-0a0b0c0d0e0f" assert response.json()["name"] == "test" - - - async def test_create_project_with_uuid( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller - ) -> None: - + + async def test_create_project_with_uuid(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: + params = {"name": "test", "project_id": "30010203-0405-0607-0809-0a0b0c0d0e0f"} response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == 201 assert response.json()["project_id"] == "30010203-0405-0607-0809-0a0b0c0d0e0f" assert response.json()["name"] == "test" - - + async def test_create_project_with_variables( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller + self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: - - variables = [ - {"name": "TEST1"}, - {"name": "TEST2", "value": "value1"} - ] + + variables = [{"name": "TEST1"}, {"name": "TEST2", "value": "value1"}] params = {"name": "test", "project_id": "30010203-0405-0607-0809-0a0b0c0d0e0f", "variables": variables} response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == 201 - assert response.json()["variables"] == [ - {"name": "TEST1"}, - {"name": "TEST2", "value": "value1"} - ] - + assert response.json()["variables"] == [{"name": "TEST1"}, {"name": "TEST2", "value": "value1"}] async def test_create_project_with_supplier( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller + self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: - - supplier = { - 'logo': 'logo.png', - 'url': 'http://example.com/' - } + + supplier = {"logo": "logo.png", "url": "http://example.com/"} params = {"name": "test", "project_id": "30010203-0405-0607-0809-0a0b0c0d0e0f", "supplier": supplier} response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["supplier"] == supplier - - + async def test_update_project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: - + params = {"name": "test", "project_id": "10010203-0405-0607-0809-0a0b0c0d0e0f"} response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["project_id"] == "10010203-0405-0607-0809-0a0b0c0d0e0f" assert response.json()["name"] == "test" - + params = {"name": "test2"} - response = await client.put(app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f"), - json=params) + response = await client.put( + app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f"), json=params + ) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test2" - - + async def test_update_project_with_variables( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller + self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: - - variables = [ - {"name": "TEST1"}, - {"name": "TEST2", "value": "value1"} - ] + + variables = [{"name": "TEST1"}, {"name": "TEST2", "value": "value1"}] params = {"name": "test", "project_id": "10010203-0405-0607-0809-0a0b0c0d0e0f", "variables": variables} response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == status.HTTP_201_CREATED - + params = {"name": "test2"} - response = await client.put(app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f"), - json=params) + response = await client.put( + app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f"), json=params + ) assert response.status_code == status.HTTP_200_OK assert response.json()["variables"] == variables - - + async def test_list_projects(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: - + params = {"name": "test", "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"} await client.post(app.url_path_for("create_project"), json=params) response = await client.get(app.url_path_for("get_projects")) assert response.status_code == status.HTTP_200_OK projects = response.json() assert projects[0]["name"] == "test" - - + async def test_get_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + response = await client.get(app.url_path_for("get_project", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" - - + async def test_delete_project( - self, - app: FastAPI, - client: AsyncClient, - project: Project, - controller: Controller + self, app: FastAPI, client: AsyncClient, project: Project, controller: Controller ) -> None: - + with asyncio_patch("gns3server.controller.project.Project.delete", return_value=True) as mock: response = await client.delete(app.url_path_for("delete_project", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called assert project not in controller.projects - - + async def test_delete_project_invalid_uuid(self, app: FastAPI, client: AsyncClient) -> None: - + response = await client.delete(app.url_path_for("delete_project", project_id=str(uuid.uuid4()))) assert response.status_code == status.HTTP_404_NOT_FOUND - - + async def test_close_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + with asyncio_patch("gns3server.controller.project.Project.close", return_value=True) as mock: response = await client.post(app.url_path_for("close_project", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT assert mock.called - - + async def test_open_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + with asyncio_patch("gns3server.controller.project.Project.open", return_value=True) as mock: response = await client.post(app.url_path_for("open_project", project_id=project.id)) assert response.status_code == status.HTTP_201_CREATED assert mock.called - - + async def test_load_project(self, app: FastAPI, client: AsyncClient, project: Project, config) -> None: - + with asyncio_patch("gns3server.controller.Controller.load_project", return_value=project) as mock: response = await client.post(app.url_path_for("load_project"), json={"path": "/tmp/test.gns3"}) assert response.status_code == status.HTTP_201_CREATED mock.assert_called_with("/tmp/test.gns3") assert response.json()["project_id"] == project.id - - + # @pytest.mark.asyncio # async def test_notification(controller_api, http_client, project, controller): # @@ -250,8 +205,7 @@ class TestControllerProjectRoutes: # # response = await controller_api.get("/projects/{project_id}/notifications".format(project_id=uuid.uuid4())) # assert response.status_code == 404 - - + # @pytest.mark.asyncio # async def test_notification_ws(controller_api, http_client, controller, project): # @@ -269,179 +223,155 @@ class TestControllerProjectRoutes: # await ws.close() # # assert project.status_code == "opened" - - - async def test_export_with_images( - self, - app: FastAPI, - client: AsyncClient, - tmpdir, - project: Project - ) -> None: - + + async def test_export_with_images(self, app: FastAPI, client: AsyncClient, tmpdir, project: Project) -> None: + project.dump = MagicMock() os.makedirs(project.path, exist_ok=True) - with open(os.path.join(project.path, 'a'), 'w+') as f: - f.write('hello') - + with open(os.path.join(project.path, "a"), "w+") as f: + f.write("hello") + os.makedirs(str(tmpdir / "IOS")) with open(str(tmpdir / "IOS" / "test.image"), "w+") as f: f.write("AAA") - - topology = { - "topology": { - "nodes": [ - { - "properties": { - "image": "test.image" - }, - "node_type": "dynamips" - } - ] - } - } - with open(os.path.join(project.path, "test.gns3"), 'w+') as f: + + topology = {"topology": {"nodes": [{"properties": {"image": "test.image"}, "node_type": "dynamips"}]}} + with open(os.path.join(project.path, "test.gns3"), "w+") as f: json.dump(topology, f) - + with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS")): - response = await client.get(app.url_path_for("export_project", project_id=project.id), - params={"include_images": "yes"}) + response = await client.get( + app.url_path_for("export_project", project_id=project.id), params={"include_images": "yes"} + ) assert response.status_code == status.HTTP_200_OK - assert response.headers['CONTENT-TYPE'] == 'application/gns3project' - assert response.headers['CONTENT-DISPOSITION'] == 'attachment; filename="{name}.gns3project"; filename*=UTF-8\'\'{name}.gns3project'.format(name=project.name) - - with open(str(tmpdir / 'project.zip'), 'wb+') as f: + assert response.headers["CONTENT-TYPE"] == "application/gns3project" + assert response.headers[ + "CONTENT-DISPOSITION" + ] == "attachment; filename=\"{name}.gns3project\"; filename*=UTF-8''{name}.gns3project".format( + name=project.name + ) + + with open(str(tmpdir / "project.zip"), "wb+") as f: f.write(response.content) - - with zipfile_zstd.ZipFile(str(tmpdir / 'project.zip')) as myzip: + + with zipfile_zstd.ZipFile(str(tmpdir / "project.zip")) as myzip: with myzip.open("a") as myfile: content = myfile.read() assert content == b"hello" myzip.getinfo("images/IOS/test.image") - - + async def test_export_without_images(self, app: FastAPI, client: AsyncClient, tmpdir, project: Project) -> None: - + project.dump = MagicMock() os.makedirs(project.path, exist_ok=True) - with open(os.path.join(project.path, 'a'), 'w+') as f: - f.write('hello') - + with open(os.path.join(project.path, "a"), "w+") as f: + f.write("hello") + os.makedirs(str(tmpdir / "IOS")) with open(str(tmpdir / "IOS" / "test.image"), "w+") as f: f.write("AAA") - - topology = { - "topology": { - "nodes": [ - { - "properties": { - "image": "test.image" - }, - "node_type": "dynamips" - } - ] - } - } - with open(os.path.join(project.path, "test.gns3"), 'w+') as f: + + topology = {"topology": {"nodes": [{"properties": {"image": "test.image"}, "node_type": "dynamips"}]}} + with open(os.path.join(project.path, "test.gns3"), "w+") as f: json.dump(topology, f) - - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): - response = await client.get(app.url_path_for("export_project", project_id=project.id), - params={"include_images": "0"}) + + with patch( + "gns3server.compute.Dynamips.get_images_directory", + return_value=str(tmpdir / "IOS"), + ): + response = await client.get( + app.url_path_for("export_project", project_id=project.id), params={"include_images": "0"} + ) assert response.status_code == status.HTTP_200_OK - assert response.headers['CONTENT-TYPE'] == 'application/gns3project' - assert response.headers['CONTENT-DISPOSITION'] == 'attachment; filename="{name}.gns3project"; filename*=UTF-8\'\'{name}.gns3project'.format(name=project.name) - with open(str(tmpdir / 'project.zip'), 'wb+') as f: + assert response.headers["CONTENT-TYPE"] == "application/gns3project" + assert response.headers[ + "CONTENT-DISPOSITION" + ] == "attachment; filename=\"{name}.gns3project\"; filename*=UTF-8''{name}.gns3project".format( + name=project.name + ) + with open(str(tmpdir / "project.zip"), "wb+") as f: f.write(response.content) - - with zipfile_zstd.ZipFile(str(tmpdir / 'project.zip')) as myzip: + + with zipfile_zstd.ZipFile(str(tmpdir / "project.zip")) as myzip: with myzip.open("a") as myfile: content = myfile.read() assert content == b"hello" # Image should not exported with pytest.raises(KeyError): myzip.getinfo("images/IOS/test.image") - - + @pytest.mark.parametrize( "compression, compression_level, status_code", ( - ("none", None, status.HTTP_200_OK), - ("none", 4, status.HTTP_400_BAD_REQUEST), - ("zip", None, status.HTTP_200_OK), - ("zip", 1, status.HTTP_200_OK), - ("zip", 12, status.HTTP_400_BAD_REQUEST), - ("bzip2", None, status.HTTP_200_OK), - ("bzip2", 1, status.HTTP_200_OK), - ("bzip2", 13, status.HTTP_400_BAD_REQUEST), - ("lzma", None, status.HTTP_200_OK), - ("lzma", 1, status.HTTP_400_BAD_REQUEST), - ("zstd", None, status.HTTP_200_OK), - ("zstd", 12, status.HTTP_200_OK), - ("zstd", 23, status.HTTP_400_BAD_REQUEST), - ) + ("none", None, status.HTTP_200_OK), + ("none", 4, status.HTTP_400_BAD_REQUEST), + ("zip", None, status.HTTP_200_OK), + ("zip", 1, status.HTTP_200_OK), + ("zip", 12, status.HTTP_400_BAD_REQUEST), + ("bzip2", None, status.HTTP_200_OK), + ("bzip2", 1, status.HTTP_200_OK), + ("bzip2", 13, status.HTTP_400_BAD_REQUEST), + ("lzma", None, status.HTTP_200_OK), + ("lzma", 1, status.HTTP_400_BAD_REQUEST), + ("zstd", None, status.HTTP_200_OK), + ("zstd", 12, status.HTTP_200_OK), + ("zstd", 23, status.HTTP_400_BAD_REQUEST), + ), ) async def test_export_compression( - self, - app: FastAPI, - client: AsyncClient, - tmpdir, - project: Project, - compression: str, - compression_level: int, - status_code: int + self, + app: FastAPI, + client: AsyncClient, + tmpdir, + project: Project, + compression: str, + compression_level: int, + status_code: int, ) -> None: - + project.dump = MagicMock() os.makedirs(project.path, exist_ok=True) - - topology = { - "topology": { - "nodes": [ - { - "node_type": "qemu" - } - ] - } - } - with open(os.path.join(project.path, "test.gns3"), 'w+') as f: + + topology = {"topology": {"nodes": [{"node_type": "qemu"}]}} + with open(os.path.join(project.path, "test.gns3"), "w+") as f: json.dump(topology, f) - + params = {"compression": compression} if compression_level: params["compression_level"] = compression_level response = await client.get(app.url_path_for("export_project", project_id=project.id), params=params) assert response.status_code == status_code - + if response.status_code == status.HTTP_200_OK: - assert response.headers['CONTENT-TYPE'] == 'application/gns3project' - assert response.headers['CONTENT-DISPOSITION'] == 'attachment; filename="{name}.gns3project"; filename*=UTF-8\'\'{name}.gns3project'.format(name=project.name) - with open(str(tmpdir / 'project.zip'), 'wb+') as f: + assert response.headers["CONTENT-TYPE"] == "application/gns3project" + assert response.headers[ + "CONTENT-DISPOSITION" + ] == "attachment; filename=\"{name}.gns3project\"; filename*=UTF-8''{name}.gns3project".format( + name=project.name + ) + with open(str(tmpdir / "project.zip"), "wb+") as f: f.write(response.content) - - with zipfile_zstd.ZipFile(str(tmpdir / 'project.zip')) as myzip: + + with zipfile_zstd.ZipFile(str(tmpdir / "project.zip")) as myzip: with myzip.open("project.gns3") as myfile: myfile.read() - - + async def test_get_file(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + os.makedirs(project.path, exist_ok=True) - with open(os.path.join(project.path, 'hello'), 'w+') as f: - f.write('world') - + with open(os.path.join(project.path, "hello"), "w+") as f: + f.write("world") + response = await client.get(app.url_path_for("get_file", project_id=project.id, file_path="hello")) assert response.status_code == status.HTTP_200_OK assert response.content == b"world" - + response = await client.get(app.url_path_for("get_file", project_id=project.id, file_path="false")) assert response.status_code == status.HTTP_404_NOT_FOUND - + response = await client.get(app.url_path_for("get_file", project_id=project.id, file_path="../hello")) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_get_project_gns3_file(self, app: FastAPI, client: AsyncClient, project: Project) -> None: response = await client.get(app.url_path_for("get_project_gns3_file", project_id=project.id)) @@ -451,7 +381,6 @@ class TestControllerProjectRoutes: assert topology["name"] == "test" assert "topology" in topology - async def test_get_project_gns3_file_raw_content(self, app: FastAPI, client: AsyncClient, project: Project) -> None: # the endpoint must serve the raw file content, without the project being opened @@ -470,145 +399,142 @@ class TestControllerProjectRoutes: assert response.status_code == status.HTTP_200_OK assert response.json() == topology - async def test_get_project_gns3_file_project_not_found(self, app: FastAPI, client: AsyncClient) -> None: response = await client.get(app.url_path_for("get_project_gns3_file", project_id=str(uuid.uuid4()))) assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_get_file_forbidden_location(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + file_path = "foo/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd" response = await client.get(app.url_path_for("get_file", project_id=project.id, file_path=file_path)) assert response.status_code == status.HTTP_403_FORBIDDEN - - + async def test_write_file(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - - response = await client.post(app.url_path_for("write_file", project_id=project.id, file_path="hello"), - content=b"world") + + response = await client.post( + app.url_path_for("write_file", project_id=project.id, file_path="hello"), content=b"world" + ) assert response.status_code == status.HTTP_204_NO_CONTENT - + with open(os.path.join(project.path, "hello")) as f: assert f.read() == "world" - + response = await client.post(app.url_path_for("write_file", project_id=project.id, file_path="../hello")) assert response.status_code == status.HTTP_404_NOT_FOUND - - + async def test_write_file_forbidden_location(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + file_path = "%2e%2e/hello" - response = await client.post(app.url_path_for("write_file", project_id=project.id, file_path=file_path), - content=b"world") + response = await client.post( + app.url_path_for("write_file", project_id=project.id, file_path=file_path), content=b"world" + ) assert response.status_code == status.HTTP_403_FORBIDDEN - - + async def test_write_and_get_file_with_leading_slashes_in_filename( - self, - app: FastAPI, - client: AsyncClient, - project: Project) -> None: - - response = await client.post(app.url_path_for("write_file", project_id=project.id, file_path="//hello"), - content=b"world") + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: + + response = await client.post( + app.url_path_for("write_file", project_id=project.id, file_path="//hello"), content=b"world" + ) assert response.status_code == status.HTTP_403_FORBIDDEN - + response = await client.get(app.url_path_for("get_file", project_id=project.id, file_path="//hello")) assert response.status_code == status.HTTP_403_FORBIDDEN - - + async def test_import(self, app: FastAPI, client: AsyncClient, tmpdir, controller: Controller) -> None: - - with zipfile_zstd.ZipFile(str(tmpdir / "test.zip"), 'w') as myzip: - myzip.writestr("project.gns3", b'{"project_id": "c6992992-ac72-47dc-833b-54aa334bcd05", "version": "2.0.0", "name": "test"}') + + with zipfile_zstd.ZipFile(str(tmpdir / "test.zip"), "w") as myzip: + myzip.writestr( + "project.gns3", + b'{"project_id": "c6992992-ac72-47dc-833b-54aa334bcd05", "version": "2.0.0", "name": "test"}', + ) myzip.writestr("demo", b"hello") - + project_id = str(uuid.uuid4()) with open(str(tmpdir / "test.zip"), "rb") as f: response = await client.post(app.url_path_for("import_project", project_id=project_id), content=f.read()) assert response.status_code == status.HTTP_201_CREATED - + project = controller.get_project(project_id) with open(os.path.join(project.path, "demo")) as f: content = f.read() assert content == "hello" - - + async def test_import_with_project_name( - self, - app: FastAPI, - client: AsyncClient, - tmpdir, - controller: Controller + self, app: FastAPI, client: AsyncClient, tmpdir, controller: Controller ) -> None: - - with zipfile_zstd.ZipFile(str(tmpdir / "test.zip"), 'w') as myzip: - myzip.writestr("project.gns3", b'{"project_id": "c6992992-ac72-47dc-833b-54aa334bcd05", "version": "2.0.0", "name": "test"}') + + with zipfile_zstd.ZipFile(str(tmpdir / "test.zip"), "w") as myzip: + myzip.writestr( + "project.gns3", + b'{"project_id": "c6992992-ac72-47dc-833b-54aa334bcd05", "version": "2.0.0", "name": "test"}', + ) myzip.writestr("demo", b"hello") - + project_id = str(uuid.uuid4()) with open(str(tmpdir / "test.zip"), "rb") as f: response = await client.post( app.url_path_for("import_project", project_id=project_id), content=f.read(), - params={"name": "my-imported-project-name"} + params={"name": "my-imported-project-name"}, ) assert response.status_code == status.HTTP_201_CREATED project = controller.get_project(project_id) assert project.name == "my-imported-project-name" - - + async def test_duplicate(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - - response = await client.post(app.url_path_for("duplicate_project", project_id=project.id), json={"name": "hello"}) + + response = await client.post( + app.url_path_for("duplicate_project", project_id=project.id), json={"name": "hello"} + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "hello" - - + async def test_lock_unlock(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: - + # add a drawing and node to the project params = { "svg": '', "x": 10, "y": 20, - "z": 0 + "z": 0, } - + response = await client.post(app.url_path_for("create_drawing", project_id=project.id), json=params) assert response.status_code == status.HTTP_201_CREATED - + response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - - response = await client.post(app.url_path_for("create_node", project_id=project.id), json={ - "name": "test", - "node_type": "vpcs", - "compute_id": "example.com", - "properties": { - "startup_script": "echo test" - } - }) + + response = await client.post( + app.url_path_for("create_node", project_id=project.id), + json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "properties": {"startup_script": "echo test"}, + }, + ) assert response.status_code == status.HTTP_201_CREATED - + response = await client.post(app.url_path_for("lock_project", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - + for drawing in project.drawings.values(): assert drawing.locked is True for node in project.nodes.values(): assert node.locked is True - + response = await client.get(app.url_path_for("locked_project", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert response.json() is True - + response = await client.post(app.url_path_for("unlock_project", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT - + for drawing in project.drawings.values(): assert drawing.locked is False for node in project.nodes.values(): diff --git a/tests/api/routes/controller/test_roles.py b/tests/api/routes/controller/test_roles.py index a98be8bf0..bdc6f6651 100644 --- a/tests/api/routes/controller/test_roles.py +++ b/tests/api/routes/controller/test_roles.py @@ -27,7 +27,6 @@ pytestmark = pytest.mark.asyncio class TestRolesRoutes: - async def test_create_role(self, app: FastAPI, client: AsyncClient) -> None: new_role = {"name": "role1"} @@ -54,36 +53,22 @@ class TestRolesRoutes: role_in_db = await rbac_repo.get_role_by_name("role1") update_role = {"name": "role42"} - response = await client.put( - app.url_path_for("update_role", role_id=role_in_db.role_id), - json=update_role - ) + response = await client.put(app.url_path_for("update_role", role_id=role_in_db.role_id), json=update_role) assert response.status_code == status.HTTP_200_OK updated_role_in_db = await rbac_repo.get_role(role_in_db.role_id) assert updated_role_in_db.name == "role42" async def test_cannot_update_builtin_user_role( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession ) -> None: rbac_repo = RbacRepository(db_session) role_in_db = await rbac_repo.get_role_by_name("User") update_role = {"name": "Hackers"} - response = await client.put( - app.url_path_for("update_role", role_id=role_in_db.role_id), - json=update_role - ) + response = await client.put(app.url_path_for("update_role", role_id=role_in_db.role_id), json=update_role) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_delete_role( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_delete_role(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: rbac_repo = RbacRepository(db_session) role_in_db = await rbac_repo.get_role_by_name("role42") @@ -91,10 +76,7 @@ class TestRolesRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT async def test_cannot_delete_builtin_administrator_role( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession ) -> None: rbac_repo = RbacRepository(db_session) @@ -104,13 +86,7 @@ class TestRolesRoutes: class TestRolesPrivilegesRoutes: - - async def test_add_privilege_to_role( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_add_privilege_to_role(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: rbac_repo = RbacRepository(db_session) role_in_db = await rbac_repo.get_role_by_name("User") @@ -118,38 +94,24 @@ class TestRolesPrivilegesRoutes: response = await client.put( app.url_path_for( - "add_privilege_to_role", - role_id=role_in_db.role_id, - privilege_id=str(privilege.privilege_id) + "add_privilege_to_role", role_id=role_in_db.role_id, privilege_id=str(privilege.privilege_id) ) ) assert response.status_code == status.HTTP_204_NO_CONTENT privileges = await rbac_repo.get_role_privileges(role_in_db.role_id) assert len(privileges) == 27 # 25 default privileges + 2 LLMConfig privileges - async def test_get_role_privileges( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_get_role_privileges(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: rbac_repo = RbacRepository(db_session) role_in_db = await rbac_repo.get_role_by_name("User") - response = await client.get( - app.url_path_for( - "get_role_privileges", - role_id=role_in_db.role_id) - ) + response = await client.get(app.url_path_for("get_role_privileges", role_id=role_in_db.role_id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 27 # 25 default privileges + 2 LLMConfig privileges async def test_remove_privilege_from_role( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession ) -> None: rbac_repo = RbacRepository(db_session) @@ -158,9 +120,7 @@ class TestRolesPrivilegesRoutes: response = await client.delete( app.url_path_for( - "remove_privilege_from_role", - role_id=role_in_db.role_id, - privilege_id=str(privilege.privilege_id) + "remove_privilege_from_role", role_id=role_in_db.role_id, privilege_id=str(privilege.privilege_id) ), ) assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/tests/api/routes/controller/test_settings.py b/tests/api/routes/controller/test_settings.py index 21f6d6e56..200ca9c6d 100644 --- a/tests/api/routes/controller/test_settings.py +++ b/tests/api/routes/controller/test_settings.py @@ -46,15 +46,12 @@ def stable_jwt_secret(config): class TestSettingsRoutes: - async def test_get_settings(self, app: FastAPI, client: AsyncClient) -> None: response = await client.get(app.url_path_for("get_server_settings")) assert response.status_code == status.HTTP_200_OK body = response.json() - assert sorted(body.keys()) == [ - "Controller", "Dynamips", "IOU", "Qemu", "Server", "VPCS", "WebWireshark" - ] + assert sorted(body.keys()) == ["Controller", "Dynamips", "IOU", "Qemu", "Server", "VPCS", "WebWireshark"] # deprecated sections are not exposed assert "VirtualBox" not in body assert "VMware" not in body @@ -71,7 +68,8 @@ class TestSettingsRoutes: # underlying httpx client and its default headers depend on the fixture # instantiation order response = await client.get( - app.url_path_for("get_server_settings"), headers={"Authorization": "Bearer invalid_token"}) + app.url_path_for("get_server_settings"), headers={"Authorization": "Bearer invalid_token"} + ) assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_get_settings_forbidden(self, app: FastAPI, client: AsyncClient, test_user) -> None: @@ -79,23 +77,28 @@ class TestSettingsRoutes: # the "User" role has no Server.Audit privilege token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY) response = await client.get( - app.url_path_for("get_server_settings"), headers={"Authorization": f"Bearer {token}"}) + app.url_path_for("get_server_settings"), headers={"Authorization": f"Bearer {token}"} + ) assert response.status_code == status.HTTP_403_FORBIDDEN - async def test_put_settings(self, app: FastAPI, client: AsyncClient, config: Config, - stable_jwt_secret: str) -> None: + async def test_put_settings( + self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str + ) -> None: - response = await client.put(app.url_path_for("update_server_settings"), json={ - "Server": { - "port": 3081, - "allowed_interfaces": ["eth0"], - "default_symbol_theme": "Classic", - "report_errors": False, + response = await client.put( + app.url_path_for("update_server_settings"), + json={ + "Server": { + "port": 3081, + "allowed_interfaces": ["eth0"], + "default_symbol_theme": "Classic", + "report_errors": False, + }, + "Qemu": { + "enable_monitor": False, + }, }, - "Qemu": { - "enable_monitor": False, - }, - }) + ) assert response.status_code == status.HTTP_200_OK body = response.json() assert body["Server"]["port"] == 3081 @@ -116,7 +119,8 @@ class TestSettingsRoutes: assert parsed["Qemu"]["enable_monitor"] == "False" async def test_put_settings_preserves_unknown_options( - self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str + ) -> None: with open(config._main_config_file, "w") as f: f.write("[Server]\nhost = 127.0.0.1\nfrobnicate = 42\n") @@ -130,11 +134,13 @@ class TestSettingsRoutes: assert parsed["Server"]["host"] == "127.0.0.1" async def test_put_settings_secrets( - self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str + ) -> None: # masked secret means "unchanged": nothing is written response = await client.put( - app.url_path_for("update_server_settings"), json={"Server": {"compute_password": SECRET_MASK}}) + app.url_path_for("update_server_settings"), json={"Server": {"compute_password": SECRET_MASK}} + ) assert response.status_code == status.HTTP_200_OK parsed = configparser.ConfigParser() parsed.read(config._main_config_file) @@ -142,7 +148,8 @@ class TestSettingsRoutes: # empty string means "unchanged" too response = await client.put( - app.url_path_for("update_server_settings"), json={"Server": {"compute_password": ""}}) + app.url_path_for("update_server_settings"), json={"Server": {"compute_password": ""}} + ) assert response.status_code == status.HTTP_200_OK parsed = configparser.ConfigParser() parsed.read(config._main_config_file) @@ -150,7 +157,8 @@ class TestSettingsRoutes: # an explicit new value is written in clear text (like a hand-edited file) response = await client.put( - app.url_path_for("update_server_settings"), json={"Server": {"compute_password": "secret123"}}) + app.url_path_for("update_server_settings"), json={"Server": {"compute_password": "secret123"}} + ) assert response.status_code == status.HTTP_200_OK assert response.json()["Server"]["compute_password"] == SECRET_MASK # masked in the response parsed = configparser.ConfigParser() @@ -158,7 +166,8 @@ class TestSettingsRoutes: assert parsed["Server"]["compute_password"] == "secret123" async def test_put_settings_null_removes_option( - self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str + ) -> None: with open(config._main_config_file, "w") as f: f.write("[Server]\nhost = 127.0.0.1\n") @@ -172,7 +181,8 @@ class TestSettingsRoutes: assert response.json()["Server"]["host"] == "0.0.0.0" # default restored async def test_put_settings_validation_failure( - self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str + ) -> None: with open(config._main_config_file, "w") as f: f.write("[Server]\nhost = 127.0.0.1\n") @@ -180,22 +190,25 @@ class TestSettingsRoutes: content_before = f.read() # cross-field violation: console_end_port_range must be > console_start_port_range - response = await client.put(app.url_path_for("update_server_settings"), json={ - "Server": {"console_start_port_range": 10000, "console_end_port_range": 5000}}) + response = await client.put( + app.url_path_for("update_server_settings"), + json={"Server": {"console_start_port_range": 10000, "console_end_port_range": 5000}}, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST with open(config._main_config_file) as f: assert f.read() == content_before async def test_put_settings_unknown_option_rejected( - self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str + ) -> None: - response = await client.put( - app.url_path_for("update_server_settings"), json={"Server": {"prot": "http"}}) + response = await client.put(app.url_path_for("update_server_settings"), json={"Server": {"prot": "http"}}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT async def test_put_settings_deprecated_sections_rejected( - self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str + ) -> None: response = await client.put(app.url_path_for("update_server_settings"), json={"VirtualBox": {}}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -203,14 +216,17 @@ class TestSettingsRoutes: assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT async def test_put_settings_jwt_secret_key_rejected( - self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str) -> None: + self, app: FastAPI, client: AsyncClient, stable_jwt_secret: str + ) -> None: response = await client.put( - app.url_path_for("update_server_settings"), json={"Controller": {"jwt_secret_key": "nope"}}) + app.url_path_for("update_server_settings"), json={"Controller": {"jwt_secret_key": "nope"}} + ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT async def test_put_settings_conflict( - self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str, tmpdir) -> None: + self, app: FastAPI, client: AsyncClient, config: Config, stable_jwt_secret: str, tmpdir + ) -> None: override_path = str(tmpdir / "override.conf") with open(override_path, "w") as f: @@ -218,7 +234,9 @@ class TestSettingsRoutes: # a later configuration file takes precedence over the main one Config.instance()._files.append(override_path) - response = await client.put(app.url_path_for("update_server_settings"), json={"Server": {"host": "192.168.1.1"}}) + response = await client.put( + app.url_path_for("update_server_settings"), json={"Server": {"host": "192.168.1.1"}} + ) assert response.status_code == status.HTTP_409_CONFLICT async def test_put_settings_empty_body(self, app: FastAPI, client: AsyncClient, config: Config) -> None: diff --git a/tests/api/routes/controller/test_snapshots.py b/tests/api/routes/controller/test_snapshots.py index 5ef2cbe8b..1c2f60a5e 100644 --- a/tests/api/routes/controller/test_snapshots.py +++ b/tests/api/routes/controller/test_snapshots.py @@ -31,48 +31,52 @@ pytestmark = pytest.mark.asyncio class TestSnapshotRoutes: - @pytest_asyncio.fixture async def project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> Project: - + u = str(uuid.uuid4()) params = {"name": "test", "project_id": u} await client.post(app.url_path_for("create_project"), json=params) controller_project = controller.get_project(u) return controller_project - - + @pytest_asyncio.fixture async def snapshot(self, project: Project): - + controller_snapshot = await project.snapshot("test") return controller_snapshot - - - async def test_list_snapshots(self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot) -> None: - + + async def test_list_snapshots( + self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot + ) -> None: + assert snapshot.name == "test" response = await client.get(app.url_path_for("get_snapshots", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 - - - async def test_delete_snapshot(self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot) -> None: - - response = await client.delete(app.url_path_for("delete_snapshot", project_id=project.id, snapshot_id=snapshot.id)) + + async def test_delete_snapshot( + self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot + ) -> None: + + response = await client.delete( + app.url_path_for("delete_snapshot", project_id=project.id, snapshot_id=snapshot.id) + ) assert response.status_code == status.HTTP_204_NO_CONTENT assert not os.path.exists(snapshot.path) - - - async def test_restore_snapshot(self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot) -> None: - - response = await client.post(app.url_path_for("restore_snapshot", project_id=project.id, snapshot_id=snapshot.id)) + + async def test_restore_snapshot( + self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot + ) -> None: + + response = await client.post( + app.url_path_for("restore_snapshot", project_id=project.id, snapshot_id=snapshot.id) + ) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == project.name - - + async def test_create_snapshot(self, app: FastAPI, client: AsyncClient, project: Project) -> None: - + response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"}) assert response.status_code == status.HTTP_201_CREATED assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2 diff --git a/tests/api/routes/controller/test_symbols.py b/tests/api/routes/controller/test_symbols.py index 24e607205..7c28bff26 100644 --- a/tests/api/routes/controller/test_symbols.py +++ b/tests/api/routes/controller/test_symbols.py @@ -29,42 +29,39 @@ pytestmark = pytest.mark.asyncio class TestSymbolRoutes: - async def test_symbols(self, app: FastAPI, client: AsyncClient) -> None: - + response = await client.get(app.url_path_for("get_symbols")) - + assert response.status_code == status.HTTP_200_OK assert { - 'symbol_id': ':/symbols/classic/firewall.svg', - 'filename': 'firewall.svg', - 'builtin': True, - 'theme': 'Classic' + "symbol_id": ":/symbols/classic/firewall.svg", + "filename": "firewall.svg", + "builtin": True, + "theme": "Classic", } in response.json() - - + async def test_get(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: - + controller.symbols.theme = "Classic" - url = app.url_path_for("get_symbol", symbol_id=urllib.parse.quote(':/symbols/classic/firewall.svg')) + url = app.url_path_for("get_symbol", symbol_id=urllib.parse.quote(":/symbols/classic/firewall.svg")) response = await client.get(url) assert response.status_code == status.HTTP_200_OK - assert response.headers['CONTENT-TYPE'] == 'image/svg+xml' - assert response.headers['CONTENT-LENGTH'] == '9381' - assert '' in response.text - + assert response.headers["CONTENT-TYPE"] == "image/svg+xml" + assert response.headers["CONTENT-LENGTH"] == "9381" + assert "" in response.text + # Reply with the default symbol response = await client.get(app.url_path_for("get_symbol", symbol_id="404.png")) assert response.status_code == status.HTTP_200_OK - - + async def test_upload(self, app: FastAPI, client: AsyncClient, symbols_dir: str) -> None: - + response = await client.post(app.url_path_for("upload_symbol", symbol_id="test2"), content=b"TEST") assert response.status_code == status.HTTP_204_NO_CONTENT - + with open(os.path.join(symbols_dir, "test2")) as f: assert f.read() == "TEST" - + response = await client.get(app.url_path_for("get_symbol", symbol_id="test2")) assert response.status_code == status.HTTP_200_OK diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index a1bf507e9..864cc1d57 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -41,7 +41,6 @@ pytestmark = pytest.mark.asyncio class TestTemplateRoutes: - async def test_route_exist(self, app: FastAPI, client: AsyncClient) -> None: new_template = { @@ -54,7 +53,7 @@ class TestTemplateRoutes: "compute_id": "local", "symbol": ":/symbols/vpcs_guest.svg", "template_type": "vpcs", - "tags": ["tag1", "tag2"] + "tags": ["tag1", "tag2"], } response = await client.post(app.url_path_for("create_template"), json=new_template) @@ -78,11 +77,7 @@ class TestTemplateRoutes: ), ) async def test_template_list_with_tags( - self, - app: FastAPI, - client: AsyncClient, - tags: list, - expected_match: bool + self, app: FastAPI, client: AsyncClient, tags: list, expected_match: bool ) -> None: params = {"tags": tags} @@ -100,11 +95,13 @@ class TestTemplateRoutes: async def test_template_get(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "VPCS_TEST", - "version": "1.0", - "compute_id": "local", - "template_type": "vpcs"} + params = { + "template_id": template_id, + "name": "VPCS_TEST", + "version": "1.0", + "compute_id": "local", + "template_type": "vpcs", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -114,26 +111,22 @@ class TestTemplateRoutes: assert response.json()["template_id"] == template_id async def test_template_create_same_name_and_version( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller + self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: - params = {"name": "VPCS_TEST", - "version": "1.0", - "compute_id": "local", - "template_type": "vpcs"} + params = {"name": "VPCS_TEST", "version": "1.0", "compute_id": "local", "template_type": "vpcs"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_409_CONFLICT async def test_template_create_wrong_type(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: - params = {"name": "VPCS_TEST", - "version": "2.0", - "compute_id": "local", - "template_type": "invalid_template_type"} + params = { + "name": "VPCS_TEST", + "version": "2.0", + "compute_id": "local", + "template_type": "invalid_template_type", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @@ -148,7 +141,7 @@ class TestTemplateRoutes: "compute_id": "local", "template_type": "vpcs", "tags": ["tag1", "tag2"], - "netmiko_device_type": "generic_termserver_telnet" + "netmiko_device_type": "generic_termserver_telnet", } response = await client.post(app.url_path_for("create_template"), json=params) @@ -215,11 +208,13 @@ class TestTemplateRoutes: async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "VPCS_TEST", - "version": "4.0", - "compute_id": "local", - "template_type": "vpcs"} + params = { + "template_id": template_id, + "name": "VPCS_TEST", + "version": "4.0", + "compute_id": "local", + "template_type": "vpcs", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -228,32 +223,34 @@ class TestTemplateRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT async def test_template_delete_with_prune_images( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - tmpdir: str, + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, + tmpdir: str, ) -> None: image1 = os.path.join(tmpdir, "image1.qcow2") with open(image1, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") image2 = os.path.join(tmpdir, "image2.qcow2") with open(image2, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") images_repo = ImagesRepository(db_session) await images_repo.add_image("image1.qcow2", "qemu", 42, image1, "e342eb86c1229b6c154367a5476969b5", "md5") await images_repo.add_image("image2.qcow2", "qemu", 42, image2, "e342eb86c1229b6c154367a5476969b5", "md5") template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "QEMU_TEMPLATE", - "compute_id": "local", - "hda_disk_image": "image1.qcow2", - "hdb_disk_image": "image2.qcow2", - "template_type": "qemu"} + params = { + "template_id": template_id, + "name": "QEMU_TEMPLATE", + "compute_id": "local", + "hda_disk_image": "image1.qcow2", + "hdb_disk_image": "image2.qcow2", + "template_type": "qemu", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -263,8 +260,7 @@ class TestTemplateRoutes: assert len(images) == 2 response = await client.delete( - app.url_path_for("delete_template", template_id=template_id), - params={"prune_images": True} + app.url_path_for("delete_template", template_id=template_id), params={"prune_images": True} ) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -291,15 +287,18 @@ class TestTemplateRoutes: # an explicit project_id marks this as an existing on-disk project # (same path load_project takes at startup) return await controller.add_project( - project_id=project_id, name=name, path=project_dir, - filename=f"{name}.gns3", status="closed", + project_id=project_id, + name=name, + path=project_dir, + filename=f"{name}.gns3", + status="closed", ) async def test_template_delete_used_by_project( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller, + self, + app: FastAPI, + client: AsyncClient, + controller: Controller, ) -> None: """ A template referenced by a node in a project must not be deleted, @@ -307,18 +306,22 @@ class TestTemplateRoutes: """ template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "VPCS_GUARDED", - "compute_id": "local", - "template_type": "vpcs"} + params = {"template_id": template_id, "name": "VPCS_GUARDED", "compute_id": "local", "template_type": "vpcs"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED guarded_project = await self._add_closed_project_with_node( - controller, "Guarded", - {"node_id": str(uuid.uuid4()), "node_type": "vpcs", "compute_id": "local", - "name": "n1", "template_id": template_id, "properties": {}}, + controller, + "Guarded", + { + "node_id": str(uuid.uuid4()), + "node_type": "vpcs", + "compute_id": "local", + "name": "n1", + "template_id": template_id, + "properties": {}, + }, ) response = await client.delete(app.url_path_for("delete_template", template_id=template_id)) @@ -335,12 +338,12 @@ class TestTemplateRoutes: assert response.status_code == status.HTTP_204_NO_CONTENT async def test_template_delete_with_prune_images_used_by_project( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller, - db_session: AsyncSession, - tmpdir: str, + self, + app: FastAPI, + client: AsyncClient, + controller: Controller, + db_session: AsyncSession, + tmpdir: str, ) -> None: """ Pruning an image still referenced by a project node must be refused @@ -349,17 +352,19 @@ class TestTemplateRoutes: image_path = os.path.join(tmpdir, "used.qcow2") with open(image_path, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") images_repo = ImagesRepository(db_session) await images_repo.add_image("used.qcow2", "qemu", 42, image_path, "e342eb86c1229b6c154367a5476969b5", "md5") template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "QEMU_GUARDED", - "compute_id": "local", - "hda_disk_image": "used.qcow2", - "template_type": "qemu"} + params = { + "template_id": template_id, + "name": "QEMU_GUARDED", + "compute_id": "local", + "hda_disk_image": "used.qcow2", + "template_type": "qemu", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -367,14 +372,19 @@ class TestTemplateRoutes: # the node references the image but not the template: the template # check passes, the image check must still refuse the prune guarded_project = await self._add_closed_project_with_node( - controller, "Guarded", - {"node_id": str(uuid.uuid4()), "node_type": "qemu", "compute_id": "local", - "name": "n1", "properties": {"hda_disk_image_backing_file": "used.qcow2"}}, + controller, + "Guarded", + { + "node_id": str(uuid.uuid4()), + "node_type": "qemu", + "compute_id": "local", + "name": "n1", + "properties": {"hda_disk_image_backing_file": "used.qcow2"}, + }, ) response = await client.delete( - app.url_path_for("delete_template", template_id=template_id), - params={"prune_images": True} + app.url_path_for("delete_template", template_id=template_id), params={"prune_images": True} ) assert response.status_code == status.HTTP_409_CONFLICT assert "Guarded" in response.json()["message"] @@ -386,8 +396,7 @@ class TestTemplateRoutes: controller.remove_project(guarded_project) response = await client.delete( - app.url_path_for("delete_template", template_id=template_id), - params={"prune_images": True} + app.url_path_for("delete_template", template_id=template_id), params={"prune_images": True} ) assert response.status_code == status.HTTP_204_NO_CONTENT assert not os.path.exists(image_path) @@ -415,34 +424,25 @@ class TestTemplateRoutes: async def test_get_base_config(self, app: FastAPI, client: AsyncClient): async def mock_get_current_active_user(): - return schemas.User( - username="admin", - user_id=uuid.uuid4(), - is_superadmin=True, - is_active=True - ) + return schemas.User(username="admin", user_id=uuid.uuid4(), is_superadmin=True, is_active=True) + app.dependency_overrides[get_current_active_user] = mock_get_current_active_user try: - create_resp = await client.post(app.url_path_for("create_template"), json={ - "name": "TEST", - "compute_id": "local", - "template_type": "vpcs" - }) + create_resp = await client.post( + app.url_path_for("create_template"), + json={"name": "TEST", "compute_id": "local", "template_type": "vpcs"}, + ) assert create_resp.status_code == 201 template_id = create_resp.json()["template_id"] await client.put( app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"), - json={"content": "hello"} + json={"content": "hello"}, ) response = await client.get( - app.url_path_for( - "get_base_config", - template_id=template_id, - filename="test.txt" - ) + app.url_path_for("get_base_config", template_id=template_id, filename="test.txt") ) assert response.status_code == 200 @@ -453,27 +453,21 @@ class TestTemplateRoutes: async def test_update_base_config(self, app: FastAPI, client: AsyncClient): async def mock_get_current_active_user(): - return schemas.User( - username="admin", - user_id=uuid.uuid4(), - is_superadmin=True, - is_active=True - ) + return schemas.User(username="admin", user_id=uuid.uuid4(), is_superadmin=True, is_active=True) + app.dependency_overrides[get_current_active_user] = mock_get_current_active_user try: template_name = f"TEST_UPDATE_{uuid.uuid4().hex[:8]}" - create_resp = await client.post(app.url_path_for("create_template"), json={ - "name": template_name, - "compute_id": "local", - "template_type": "vpcs" - }) + create_resp = await client.post( + app.url_path_for("create_template"), + json={"name": template_name, "compute_id": "local", "template_type": "vpcs"}, + ) assert create_resp.status_code == 201 template_id = create_resp.json()["template_id"] payload = {"content": "hello world"} response = await client.put( - app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"), - json=payload + app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"), json=payload ) assert response.status_code == 200 @@ -484,26 +478,20 @@ class TestTemplateRoutes: async def test_update_base_config_missing_content(self, app: FastAPI, client: AsyncClient): async def mock_get_current_active_user(): - return schemas.User( - username="admin", - user_id=uuid.uuid4(), - is_superadmin=True, - is_active=True - ) + return schemas.User(username="admin", user_id=uuid.uuid4(), is_superadmin=True, is_active=True) + app.dependency_overrides[get_current_active_user] = mock_get_current_active_user try: template_name = f"TEST_MISSING_{uuid.uuid4().hex[:8]}" - create_resp = await client.post(app.url_path_for("create_template"), json={ - "name": template_name, - "compute_id": "local", - "template_type": "vpcs" - }) + create_resp = await client.post( + app.url_path_for("create_template"), + json={"name": template_name, "compute_id": "local", "template_type": "vpcs"}, + ) assert create_resp.status_code == 201 template_id = create_resp.json()["template_id"] response = await client.put( - app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"), - json={} + app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"), json={} ) assert response.status_code in (400, 422) @@ -520,35 +508,28 @@ class TestTemplateRoutes: async def test_list_base_configs(self, app: FastAPI, client: AsyncClient): async def mock_get_current_active_user(): - return schemas.User( - username="admin", - user_id=uuid.uuid4(), - is_superadmin=True, - is_active=True - ) + return schemas.User(username="admin", user_id=uuid.uuid4(), is_superadmin=True, is_active=True) + app.dependency_overrides[get_current_active_user] = mock_get_current_active_user try: template_name = f"TEST_LIST_{uuid.uuid4().hex[:8]}" - create_resp = await client.post(app.url_path_for("create_template"), json={ - "name": template_name, - "compute_id": "local", - "template_type": "vpcs" - }) + create_resp = await client.post( + app.url_path_for("create_template"), + json={"name": template_name, "compute_id": "local", "template_type": "vpcs"}, + ) assert create_resp.status_code == 201 template_id = create_resp.json()["template_id"] await client.put( app.url_path_for("update_base_config", template_id=template_id, filename="config1.txt"), - json={"content": "file1"} + json={"content": "file1"}, ) await client.put( app.url_path_for("update_base_config", template_id=template_id, filename="config2.txt"), - json={"content": "file2"} + json={"content": "file2"}, ) - response = await client.get( - app.url_path_for("list_base_configs", template_id=template_id) - ) + response = await client.get(app.url_path_for("list_base_configs", template_id=template_id)) assert response.status_code == 200 filenames = [item["filename"] for item in response.json()] @@ -559,14 +540,10 @@ class TestTemplateRoutes: class TestDuplicateTemplates: - async def test_template_duplicate(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "VPCS_TEST", - "compute_id": "local", - "template_type": "vpcs"} + params = {"template_id": template_id, "name": "VPCS_TEST", "compute_id": "local", "template_type": "vpcs"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -582,10 +559,7 @@ class TestDuplicateTemplates: assert len(response.json()) == 9 # includes builtin templates async def test_template_duplicate_invalid_template_id( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller + self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: template_id = str(uuid.uuid4()) @@ -594,7 +568,6 @@ class TestDuplicateTemplates: class TestBuiltinTemplates: - async def test_list_builtin_templates(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: response = await client.get(app.url_path_for("get_templates")) @@ -627,10 +600,7 @@ class TestBuiltinTemplates: assert response.status_code == status.HTTP_403_FORBIDDEN async def test_list_builtin_templates_not_enabled( - self, - app: FastAPI, - client: AsyncClient, - controller: Controller + self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: config = Config.instance() @@ -640,14 +610,15 @@ class TestBuiltinTemplates: class TestDynamipsTemplate: - async def test_c7200_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c7200 template", - "platform": "c7200", - "compute_id": "local", - "image": "c7200-adventerprisek9-mz.124-24.T5.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c7200 template", + "platform": "c7200", + "compute_id": "local", + "image": "c7200-adventerprisek9-mz.124-24.T5.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -655,45 +626,49 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c7200-adventerprisek9-mz.124-24.T5.image", - "mac_addr": "", - "midplane": "vxr", - "mmap": True, - "name": "Cisco c7200 template", - "npe": "npe-400", - "nvram": 512, - "platform": "c7200", - "private_config": "", - "ram": 512, - "sparsemem": True, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c7200-adventerprisek9-mz.124-24.T5.image", + "mac_addr": "", + "midplane": "vxr", + "mmap": True, + "name": "Cisco c7200 template", + "npe": "npe-400", + "nvram": 512, + "platform": "c7200", + "private_config": "", + "ram": 512, + "sparsemem": True, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c3745_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c3745 template", - "platform": "c3745", - "compute_id": "local", - "image": "c3745-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c3745 template", + "platform": "c3745", + "compute_id": "local", + "image": "c3745-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -701,44 +676,48 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c3745-adventerprisek9-mz.124-25d.image", - "mac_addr": "", - "mmap": True, - "name": "Cisco c3745 template", - "iomem": 5, - "nvram": 256, - "platform": "c3745", - "private_config": "", - "ram": 256, - "sparsemem": True, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c3745-adventerprisek9-mz.124-25d.image", + "mac_addr": "", + "mmap": True, + "name": "Cisco c3745 template", + "iomem": 5, + "nvram": 256, + "platform": "c3745", + "private_config": "", + "ram": 256, + "sparsemem": True, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c3725_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c3725 template", - "platform": "c3725", - "compute_id": "local", - "image": "c3725-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c3725 template", + "platform": "c3725", + "compute_id": "local", + "image": "c3725-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -746,45 +725,49 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c3725-adventerprisek9-mz.124-25d.image", - "mac_addr": "", - "mmap": True, - "name": "Cisco c3725 template", - "iomem": 5, - "nvram": 256, - "platform": "c3725", - "private_config": "", - "ram": 128, - "sparsemem": True, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c3725-adventerprisek9-mz.124-25d.image", + "mac_addr": "", + "mmap": True, + "name": "Cisco c3725 template", + "iomem": 5, + "nvram": 256, + "platform": "c3725", + "private_config": "", + "ram": 128, + "sparsemem": True, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c3600_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c3600 template", - "platform": "c3600", - "chassis": "3660", - "compute_id": "local", - "image": "c3660-a3jk9s-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c3600 template", + "platform": "c3600", + "chassis": "3660", + "compute_id": "local", + "image": "c3660-a3jk9s-mz.124-25d.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -792,57 +775,63 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c3660-a3jk9s-mz.124-25d.image", - "mac_addr": "", - "mmap": True, - "name": "Cisco c3600 template", - "iomem": 5, - "nvram": 128, - "platform": "c3600", - "chassis": "3660", - "private_config": "", - "ram": 192, - "sparsemem": True, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c3660-a3jk9s-mz.124-25d.image", + "mac_addr": "", + "mmap": True, + "name": "Cisco c3600 template", + "iomem": 5, + "nvram": 128, + "platform": "c3600", + "chassis": "3660", + "private_config": "", + "ram": 192, + "sparsemem": True, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c3600_dynamips_template_create_wrong_chassis(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c3600 template with wrong chassis", - "platform": "c3600", - "chassis": "3650", - "compute_id": "local", - "image": "c3660-a3jk9s-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c3600 template with wrong chassis", + "platform": "c3600", + "chassis": "3650", + "compute_id": "local", + "image": "c3660-a3jk9s-mz.124-25d.image", + "template_type": "dynamips", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_400_BAD_REQUEST async def test_c2691_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c2691 template", - "platform": "c2691", - "compute_id": "local", - "image": "c2691-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c2691 template", + "platform": "c2691", + "compute_id": "local", + "image": "c2691-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -850,45 +839,49 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c2691-adventerprisek9-mz.124-25d.image", - "mac_addr": "", - "mmap": True, - "name": "Cisco c2691 template", - "iomem": 5, - "nvram": 256, - "platform": "c2691", - "private_config": "", - "ram": 192, - "sparsemem": True, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c2691-adventerprisek9-mz.124-25d.image", + "mac_addr": "", + "mmap": True, + "name": "Cisco c2691 template", + "iomem": 5, + "nvram": 256, + "platform": "c2691", + "private_config": "", + "ram": 192, + "sparsemem": True, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c2600_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c2600 template", - "platform": "c2600", - "chassis": "2651XM", - "compute_id": "local", - "image": "c2600-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c2600 template", + "platform": "c2600", + "chassis": "2651XM", + "compute_id": "local", + "image": "c2600-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -896,58 +889,64 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c2600-adventerprisek9-mz.124-25d.image", - "mac_addr": "", - "mmap": True, - "name": "Cisco c2600 template", - "iomem": 15, - "nvram": 128, - "platform": "c2600", - "chassis": "2651XM", - "private_config": "", - "ram": 160, - "sparsemem": True, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c2600-adventerprisek9-mz.124-25d.image", + "mac_addr": "", + "mmap": True, + "name": "Cisco c2600 template", + "iomem": 15, + "nvram": 128, + "platform": "c2600", + "chassis": "2651XM", + "private_config": "", + "ram": 160, + "sparsemem": True, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c2600_dynamips_template_create_wrong_chassis(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c2600 template with wrong chassis", - "platform": "c2600", - "chassis": "2660XM", - "compute_id": "local", - "image": "c2600-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c2600 template with wrong chassis", + "platform": "c2600", + "chassis": "2660XM", + "compute_id": "local", + "image": "c2600-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_400_BAD_REQUEST async def test_c1700_dynamips_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c1700 template", - "platform": "c1700", - "chassis": "1760", - "compute_id": "local", - "image": "c1700-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c1700 template", + "platform": "c1700", + "chassis": "1760", + "compute_id": "local", + "image": "c1700-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -955,71 +954,73 @@ class TestDynamipsTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "dynamips", - "auto_delete_disks": False, - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "R{0}", - "disk0": 0, - "disk1": 0, - "exec_area": 64, - "idlemax": 500, - "idlepc": "", - "idlesleep": 30, - "image": "c1700-adventerprisek9-mz.124-25d.image", - "mac_addr": "", - "mmap": True, - "name": "Cisco c1700 template", - "iomem": 15, - "nvram": 128, - "platform": "c1700", - "chassis": "1760", - "private_config": "", - "ram": 160, - "sparsemem": False, - "startup_config": "ios_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "system_id": "FTX0945W0MY"} + expected_response = { + "template_type": "dynamips", + "auto_delete_disks": False, + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "R{0}", + "disk0": 0, + "disk1": 0, + "exec_area": 64, + "idlemax": 500, + "idlepc": "", + "idlesleep": 30, + "image": "c1700-adventerprisek9-mz.124-25d.image", + "mac_addr": "", + "mmap": True, + "name": "Cisco c1700 template", + "iomem": 15, + "nvram": 128, + "platform": "c1700", + "chassis": "1760", + "private_config": "", + "ram": 160, + "sparsemem": False, + "startup_config": "ios_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "system_id": "FTX0945W0MY", + } for item, value in expected_response.items(): assert response.json().get(item) == value async def test_c1700_dynamips_template_create_wrong_chassis(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c1700 template with wrong chassis", - "platform": "c1700", - "chassis": "1770", - "compute_id": "local", - "image": "c1700-adventerprisek9-mz.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c1700 template with wrong chassis", + "platform": "c1700", + "chassis": "1770", + "compute_id": "local", + "image": "c1700-adventerprisek9-mz.124-25d.image", + "template_type": "dynamips", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_400_BAD_REQUEST async def test_dynamips_template_create_wrong_platform(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cisco c3900 template", - "platform": "c3900", - "compute_id": "local", - "image": "c3900-test.124-25d.image", - "template_type": "dynamips"} + params = { + "name": "Cisco c3900 template", + "platform": "c3900", + "compute_id": "local", + "image": "c3900-test.124-25d.image", + "template_type": "dynamips", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_400_BAD_REQUEST class TestIOUTemplate: - async def test_iou_template_create(self, app: FastAPI, client: AsyncClient) -> None: image_path = str(Path("/path/to/i86bi_linux-ipbase-ms-12.4.bin")) - params = {"name": "IOU template", - "compute_id": "local", - "path": image_path, - "template_type": "iou"} + params = {"name": "IOU template", "compute_id": "local", "path": image_path, "template_type": "iou"} with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -1027,75 +1028,81 @@ class TestIOUTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "iou", - "builtin": False, - "category": "router", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "IOU{0}", - "ethernet_adapters": 2, - "name": "IOU template", - "nvram": 256, - "path": image_path, - "private_config": "", - "ram": 1024, - "serial_adapters": 2, - "startup_config": "iou_l3_base_startup-config.txt", - "symbol": unittest.mock.ANY, - "use_default_iou_values": False, - "l1_keepalives": False} + expected_response = { + "template_type": "iou", + "builtin": False, + "category": "router", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "IOU{0}", + "ethernet_adapters": 2, + "name": "IOU template", + "nvram": 256, + "path": image_path, + "private_config": "", + "ram": 1024, + "serial_adapters": 2, + "startup_config": "iou_l3_base_startup-config.txt", + "symbol": unittest.mock.ANY, + "use_default_iou_values": False, + "l1_keepalives": False, + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestDockerTemplate: - async def test_docker_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Docker template", - "compute_id": "local", - "image": "gns3/endhost:latest", - "template_type": "docker"} + params = { + "name": "Docker template", + "compute_id": "local", + "image": "gns3/endhost:latest", + "template_type": "docker", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"adapters": 1, - "template_type": "docker", - "builtin": False, - "category": "guest", - "compute_id": "local", - "console_auto_start": False, - "console_http_path": "/", - "console_http_port": 80, - "console_resolution": "1024x768", - "console_type": "telnet", - "default_name_format": "{name}-{0}", - "environment": "", - "extra_hosts": "", - "image": "gns3/endhost:latest", - "name": "Docker template", - "start_command": "", - "symbol": unittest.mock.ANY, - "custom_adapters": []} + expected_response = { + "adapters": 1, + "template_type": "docker", + "builtin": False, + "category": "guest", + "compute_id": "local", + "console_auto_start": False, + "console_http_path": "/", + "console_http_port": 80, + "console_resolution": "1024x768", + "console_type": "telnet", + "default_name_format": "{name}-{0}", + "environment": "", + "extra_hosts": "", + "image": "gns3/endhost:latest", + "name": "Docker template", + "start_command": "", + "symbol": unittest.mock.ANY, + "custom_adapters": [], + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestQemuTemplate: - async def test_qemu_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Qemu template", - "compute_id": "local", - "platform": "i386", - "hda_disk_image": "IOSvL2-15.2.4.0.55E.qcow2", - "ram": 512, - "template_type": "qemu"} + params = { + "name": "Qemu template", + "compute_id": "local", + "platform": "i386", + "hda_disk_image": "IOSvL2-15.2.4.0.55E.qcow2", + "ram": 512, + "template_type": "qemu", + } with asyncio_patch("gns3server.services.templates.TemplatesService._find_images", return_value=[]) as mock: response = await client.post(app.url_path_for("create_template"), json=params) @@ -1103,368 +1110,314 @@ class TestQemuTemplate: assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"adapter_type": "e1000", - "adapters": 1, - "template_type": "qemu", - "bios_image": "", - "boot_priority": "c", - "builtin": False, - "category": "guest", - "cdrom_image": "", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "cpu_throttling": 0, - "cpus": 1, - "default_name_format": "{name}-{0}", - "first_port_name": "", - "hda_disk_image": "IOSvL2-15.2.4.0.55E.qcow2", - "hda_disk_interface": "none", - "hdb_disk_image": "", - "hdb_disk_interface": "none", - "hdc_disk_image": "", - "hdc_disk_interface": "none", - "hdd_disk_image": "", - "hdd_disk_interface": "none", - "initrd": "", - "kernel_command_line": "", - "kernel_image": "", - "linked_clone": True, - "mac_address": "", - "name": "Qemu template", - "on_close": "power_off", - "options": "", - "platform": "i386", - "port_name_format": "Ethernet{0}", - "port_segment_size": 0, - "process_priority": "normal", - "qemu_path": "", - "ram": 512, - "symbol": unittest.mock.ANY, - "usage": "", - "custom_adapters": []} + expected_response = { + "adapter_type": "e1000", + "adapters": 1, + "template_type": "qemu", + "bios_image": "", + "boot_priority": "c", + "builtin": False, + "category": "guest", + "cdrom_image": "", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "cpu_throttling": 0, + "cpus": 1, + "default_name_format": "{name}-{0}", + "first_port_name": "", + "hda_disk_image": "IOSvL2-15.2.4.0.55E.qcow2", + "hda_disk_interface": "none", + "hdb_disk_image": "", + "hdb_disk_interface": "none", + "hdc_disk_image": "", + "hdc_disk_interface": "none", + "hdd_disk_image": "", + "hdd_disk_interface": "none", + "initrd": "", + "kernel_command_line": "", + "kernel_image": "", + "linked_clone": True, + "mac_address": "", + "name": "Qemu template", + "on_close": "power_off", + "options": "", + "platform": "i386", + "port_name_format": "Ethernet{0}", + "port_segment_size": 0, + "process_priority": "normal", + "qemu_path": "", + "ram": 512, + "symbol": unittest.mock.ANY, + "usage": "", + "custom_adapters": [], + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestVMwareTemplate: - async def test_vmware_template_create(self, app: FastAPI, client: AsyncClient) -> None: vmx_path = str(Path("/path/to/vm.vmx")) - params = {"name": "VMware template", - "compute_id": "local", - "template_type": "vmware", - "vmx_path": vmx_path} + params = {"name": "VMware template", "compute_id": "local", "template_type": "vmware", "vmx_path": vmx_path} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"adapter_type": "e1000", - "adapters": 1, - "template_type": "vmware", - "builtin": False, - "category": "guest", - "compute_id": "local", - "console_auto_start": False, - "console_type": "none", - "default_name_format": "{name}-{0}", - "first_port_name": "", - "headless": False, - "linked_clone": False, - "name": "VMware template", - "on_close": "power_off", - "port_name_format": "Ethernet{0}", - "port_segment_size": 0, - "symbol": unittest.mock.ANY, - "use_any_adapter": False, - "vmx_path": vmx_path, - "custom_adapters": []} + expected_response = { + "adapter_type": "e1000", + "adapters": 1, + "template_type": "vmware", + "builtin": False, + "category": "guest", + "compute_id": "local", + "console_auto_start": False, + "console_type": "none", + "default_name_format": "{name}-{0}", + "first_port_name": "", + "headless": False, + "linked_clone": False, + "name": "VMware template", + "on_close": "power_off", + "port_name_format": "Ethernet{0}", + "port_segment_size": 0, + "symbol": unittest.mock.ANY, + "use_any_adapter": False, + "vmx_path": vmx_path, + "custom_adapters": [], + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestVirtualBoxTemplate: - async def test_virtualbox_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "VirtualBox template", - "compute_id": "local", - "template_type": "virtualbox", - "vmname": "My VirtualBox VM"} + params = { + "name": "VirtualBox template", + "compute_id": "local", + "template_type": "virtualbox", + "vmname": "My VirtualBox VM", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"adapter_type": "Intel PRO/1000 MT Desktop (82540EM)", - "adapters": 1, - "template_type": "virtualbox", - "builtin": False, - "category": "guest", - "compute_id": "local", - "console_auto_start": False, - "console_type": "none", - "default_name_format": "{name}-{0}", - "first_port_name": "", - "headless": False, - "linked_clone": False, - "name": "VirtualBox template", - "on_close": "power_off", - "port_name_format": "Ethernet{0}", - "port_segment_size": 0, - "ram": 256, - "symbol": unittest.mock.ANY, - "use_any_adapter": False, - "vmname": "My VirtualBox VM", - "custom_adapters": []} + expected_response = { + "adapter_type": "Intel PRO/1000 MT Desktop (82540EM)", + "adapters": 1, + "template_type": "virtualbox", + "builtin": False, + "category": "guest", + "compute_id": "local", + "console_auto_start": False, + "console_type": "none", + "default_name_format": "{name}-{0}", + "first_port_name": "", + "headless": False, + "linked_clone": False, + "name": "VirtualBox template", + "on_close": "power_off", + "port_name_format": "Ethernet{0}", + "port_segment_size": 0, + "ram": 256, + "symbol": unittest.mock.ANY, + "use_any_adapter": False, + "vmname": "My VirtualBox VM", + "custom_adapters": [], + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestVPCSTemplate: - async def test_vpcs_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "VPCS template", - "compute_id": "local", - "template_type": "vpcs"} + params = {"name": "VPCS template", "compute_id": "local", "template_type": "vpcs"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "vpcs", - "base_script_file": "vpcs_base_config.txt", - "builtin": False, - "category": "guest", - "compute_id": "local", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "PC{0}", - "name": "VPCS template", - "symbol": unittest.mock.ANY} + expected_response = { + "template_type": "vpcs", + "base_script_file": "vpcs_base_config.txt", + "builtin": False, + "category": "guest", + "compute_id": "local", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "PC{0}", + "name": "VPCS template", + "symbol": unittest.mock.ANY, + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestEthernetSwitchTemplate: - async def test_ethernet_switch_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Ethernet switch template", - "compute_id": "local", - "template_type": "ethernet_switch"} + params = {"name": "Ethernet switch template", "compute_id": "local", "template_type": "ethernet_switch"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "ethernet_switch", - "builtin": False, - "category": "switch", - "compute_id": "local", - "console_type": "none", - "default_name_format": "Switch{0}", - "name": "Ethernet switch template", - "ports_mapping": [{"ethertype": "0x8100", - "name": "Ethernet0", - "port_number": 0, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet1", - "port_number": 1, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet2", - "port_number": 2, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet3", - "port_number": 3, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet4", - "port_number": 4, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet5", - "port_number": 5, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet6", - "port_number": 6, - "type": "access", - "vlan": 1 - }, - {"ethertype": "0x8100", - "name": "Ethernet7", - "port_number": 7, - "type": "access", - "vlan": 1 - }], - "symbol": unittest.mock.ANY} + expected_response = { + "template_type": "ethernet_switch", + "builtin": False, + "category": "switch", + "compute_id": "local", + "console_type": "none", + "default_name_format": "Switch{0}", + "name": "Ethernet switch template", + "ports_mapping": [ + {"ethertype": "0x8100", "name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet2", "port_number": 2, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet3", "port_number": 3, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet4", "port_number": 4, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet5", "port_number": 5, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet6", "port_number": 6, "type": "access", "vlan": 1}, + {"ethertype": "0x8100", "name": "Ethernet7", "port_number": 7, "type": "access", "vlan": 1}, + ], + "symbol": unittest.mock.ANY, + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestHubTemplate: - async def test_ethernet_hub_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Ethernet hub template", - "compute_id": "local", - "template_type": "ethernet_hub"} + params = {"name": "Ethernet hub template", "compute_id": "local", "template_type": "ethernet_hub"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"ports_mapping": [{"port_number": 0, - "name": "Ethernet0" - }, - {"port_number": 1, - "name": "Ethernet1" - }, - {"port_number": 2, - "name": "Ethernet2" - }, - {"port_number": 3, - "name": "Ethernet3" - }, - {"port_number": 4, - "name": "Ethernet4" - }, - {"port_number": 5, - "name": "Ethernet5" - }, - {"port_number": 6, - "name": "Ethernet6" - }, - {"port_number": 7, - "name": "Ethernet7" - }], - "compute_id": "local", - "name": "Ethernet hub template", - "symbol": unittest.mock.ANY, - "default_name_format": "Hub{0}", - "template_type": "ethernet_hub", - "category": "switch", - "builtin": False} + expected_response = { + "ports_mapping": [ + {"port_number": 0, "name": "Ethernet0"}, + {"port_number": 1, "name": "Ethernet1"}, + {"port_number": 2, "name": "Ethernet2"}, + {"port_number": 3, "name": "Ethernet3"}, + {"port_number": 4, "name": "Ethernet4"}, + {"port_number": 5, "name": "Ethernet5"}, + {"port_number": 6, "name": "Ethernet6"}, + {"port_number": 7, "name": "Ethernet7"}, + ], + "compute_id": "local", + "name": "Ethernet hub template", + "symbol": unittest.mock.ANY, + "default_name_format": "Hub{0}", + "template_type": "ethernet_hub", + "category": "switch", + "builtin": False, + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestCloudTemplate: - async def test_cloud_template_create(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Cloud template", - "compute_id": "local", - "template_type": "cloud"} + params = {"name": "Cloud template", "compute_id": "local", "template_type": "cloud"} response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["template_id"] is not None - expected_response = {"template_type": "cloud", - "builtin": False, - "category": "guest", - "compute_id": "local", - "default_name_format": "Cloud{0}", - "name": "Cloud template", - "ports_mapping": [], - "symbol": unittest.mock.ANY, - "remote_console_host": "127.0.0.1", - "remote_console_port": 23, - "remote_console_type": "none", - "remote_console_http_path": "/"} + expected_response = { + "template_type": "cloud", + "builtin": False, + "category": "guest", + "compute_id": "local", + "default_name_format": "Cloud{0}", + "name": "Cloud template", + "ports_mapping": [], + "symbol": unittest.mock.ANY, + "remote_console_host": "127.0.0.1", + "remote_console_port": 23, + "remote_console_type": "none", + "remote_console_http_path": "/", + } for item, value in expected_response.items(): assert response.json().get(item) == value class TestImageAssociationWithTemplate: - @pytest.mark.parametrize( "image_name, image_type, params", ( - ( - "c7200-adventerprisek9-mz.124-24.T5.image", - "ios", - { - "template_id": "6d85c8db-640f-4547-8955-bc132f7d7196", - "name": "Cisco c7200 template", - "platform": "c7200", - "compute_id": "local", - "image": "", - "template_type": "dynamips" - } - ), - ( - "i86bi_linux-ipbase-ms-12.4.bin", - "iou", - { - "template_id": "0014185e-bdfe-454b-86cd-9009c23900c5", - "name": "IOU template", - "compute_id": "local", - "path": "", - "template_type": "iou" - } - ), - ( - "image.qcow2", - "qemu", - { - "template_id": "97ef56a5-7ae4-4795-ad4c-e7dcdd745cff", - "name": "Qemu template", - "compute_id": "local", - "platform": "i386", - "hda_disk_image": "", - "hdb_disk_image": "", - "hdc_disk_image": "", - "hdd_disk_image": "", - "cdrom_image": "", - "kernel_image": "", - "bios_image": "", - "ram": 512, - "template_type": "qemu" - } - ), + ( + "c7200-adventerprisek9-mz.124-24.T5.image", + "ios", + { + "template_id": "6d85c8db-640f-4547-8955-bc132f7d7196", + "name": "Cisco c7200 template", + "platform": "c7200", + "compute_id": "local", + "image": "", + "template_type": "dynamips", + }, + ), + ( + "i86bi_linux-ipbase-ms-12.4.bin", + "iou", + { + "template_id": "0014185e-bdfe-454b-86cd-9009c23900c5", + "name": "IOU template", + "compute_id": "local", + "path": "", + "template_type": "iou", + }, + ), + ( + "image.qcow2", + "qemu", + { + "template_id": "97ef56a5-7ae4-4795-ad4c-e7dcdd745cff", + "name": "Qemu template", + "compute_id": "local", + "platform": "i386", + "hda_disk_image": "", + "hdb_disk_image": "", + "hdc_disk_image": "", + "hdd_disk_image": "", + "cdrom_image": "", + "kernel_image": "", + "bios_image": "", + "ram": 512, + "template_type": "qemu", + }, + ), ), ) async def test_template_create_with_images( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - tmpdir: str, - image_name: str, - image_type: str, - params: dict + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, + tmpdir: str, + image_name: str, + image_type: str, + params: dict, ) -> None: path = os.path.join(tmpdir, image_name) with open(path, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") images_repo = ImagesRepository(db_session) await images_repo.add_image(image_name, image_type, 42, path, "e342eb86c1229b6c154367a5476969b5", "md5") for key, value in params.items(): @@ -1481,53 +1434,53 @@ class TestImageAssociationWithTemplate: @pytest.mark.parametrize( "image_name, image_type, template_id, params", ( - ( - "c7200-adventerprisek9-mz.155-2.XB.image", - "ios", - "6d85c8db-640f-4547-8955-bc132f7d7196", - { - "image": "", - } - ), - ( - "i86bi-linux-l2-adventerprisek9-15.2d.bin", - "iou", - "0014185e-bdfe-454b-86cd-9009c23900c5", - { - "path": "", - } - ), - ( - "new_image.qcow2", - "qemu", - "97ef56a5-7ae4-4795-ad4c-e7dcdd745cff", - { - "hda_disk_image": "", - "hdb_disk_image": "", - "hdc_disk_image": "", - "hdd_disk_image": "", - "cdrom_image": "", - "kernel_image": "", - "bios_image": "", - } - ), + ( + "c7200-adventerprisek9-mz.155-2.XB.image", + "ios", + "6d85c8db-640f-4547-8955-bc132f7d7196", + { + "image": "", + }, + ), + ( + "i86bi-linux-l2-adventerprisek9-15.2d.bin", + "iou", + "0014185e-bdfe-454b-86cd-9009c23900c5", + { + "path": "", + }, + ), + ( + "new_image.qcow2", + "qemu", + "97ef56a5-7ae4-4795-ad4c-e7dcdd745cff", + { + "hda_disk_image": "", + "hdb_disk_image": "", + "hdc_disk_image": "", + "hdd_disk_image": "", + "cdrom_image": "", + "kernel_image": "", + "bios_image": "", + }, + ), ), ) async def test_template_update_with_images( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - tmpdir: str, - image_name: str, - image_type: str, - template_id: str, - params: dict + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, + tmpdir: str, + image_name: str, + image_type: str, + template_id: str, + params: dict, ) -> None: path = os.path.join(tmpdir, image_name) with open(path, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") images_repo = ImagesRepository(db_session) await images_repo.add_image(image_name, image_type, 42, path, "e342eb86c1229b6c154367a5476969b5", "md5") @@ -1545,39 +1498,34 @@ class TestImageAssociationWithTemplate: @pytest.mark.parametrize( "template_id, params", ( - ( - "6d85c8db-640f-4547-8955-bc132f7d7196", - { - "image": "", - } - ), - ( - "0014185e-bdfe-454b-86cd-9009c23900c5", - { - "path": "", - } - ), - ( - "97ef56a5-7ae4-4795-ad4c-e7dcdd745cff", - { - "hda_disk_image": "", - "hdb_disk_image": "", - "hdc_disk_image": "", - "hdd_disk_image": "", - "cdrom_image": "", - "kernel_image": "", - "bios_image": "", - } - ), + ( + "6d85c8db-640f-4547-8955-bc132f7d7196", + { + "image": "", + }, + ), + ( + "0014185e-bdfe-454b-86cd-9009c23900c5", + { + "path": "", + }, + ), + ( + "97ef56a5-7ae4-4795-ad4c-e7dcdd745cff", + { + "hda_disk_image": "", + "hdb_disk_image": "", + "hdc_disk_image": "", + "hdd_disk_image": "", + "cdrom_image": "", + "kernel_image": "", + "bios_image": "", + }, + ), ), ) async def test_remove_images_from_template( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - template_id: str, - params: dict + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, template_id: str, params: dict ) -> None: for key, value in params.items(): @@ -1591,25 +1539,27 @@ class TestImageAssociationWithTemplate: assert len(db_template.images) == 0 async def test_template_create_with_image_in_subdir( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - tmpdir: str, + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, + tmpdir: str, ) -> None: - params = {"name": "Qemu template", - "version": "1.0", - "compute_id": "local", - "platform": "i386", - "hda_disk_image": "subdir/image.qcow2", - "ram": 512, - "template_type": "qemu"} + params = { + "name": "Qemu template", + "version": "1.0", + "compute_id": "local", + "platform": "i386", + "hda_disk_image": "subdir/image.qcow2", + "ram": 512, + "template_type": "qemu", + } path = os.path.join(tmpdir, "subdir", "image.qcow2") os.makedirs(os.path.dirname(path)) with open(path, "wb+") as f: - f.write(b'\x42\x42\x42\x42') + f.write(b"\x42\x42\x42\x42") images_repo = ImagesRepository(db_session) await images_repo.add_image("image.qcow2", "qemu", 42, path, "e342eb86c1229b6c154367a5476969b5", "md5") @@ -1624,12 +1574,14 @@ class TestImageAssociationWithTemplate: async def test_template_create_with_non_existing_image(self, app: FastAPI, client: AsyncClient) -> None: - params = {"name": "Qemu template with non existing image", - "compute_id": "local", - "platform": "i386", - "hda_disk_image": "unkown_image.qcow2", - "ram": 512, - "template_type": "qemu"} + params = { + "name": "Qemu template with non existing image", + "compute_id": "local", + "platform": "i386", + "hda_disk_image": "unkown_image.qcow2", + "ram": 512, + "template_type": "qemu", + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py index e30719d02..8bdf437ad 100644 --- a/tests/api/routes/controller/test_users.py +++ b/tests/api/routes/controller/test_users.py @@ -35,7 +35,6 @@ pytestmark = pytest.mark.asyncio class TestUserRoutes: - async def test_route_exist(self, app: FastAPI, client: AsyncClient) -> None: new_user = {"username": "user1", "email": "user1@email.com", "password": "test_password"} @@ -43,10 +42,7 @@ class TestUserRoutes: assert response.status_code == status.HTTP_201_CREATED async def test_users_can_register_successfully( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession ) -> None: user_repo = UsersRepository(db_session) @@ -73,21 +69,21 @@ class TestUserRoutes: @pytest.mark.parametrize( "attr, value, status_code", ( - ("email", "user2@email.com", status.HTTP_400_BAD_REQUEST), - ("username", "user2", status.HTTP_400_BAD_REQUEST), - ("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("password", "short", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("username", "ab", status.HTTP_422_UNPROCESSABLE_CONTENT), - ) + ("email", "user2@email.com", status.HTTP_400_BAD_REQUEST), + ("username", "user2", status.HTTP_400_BAD_REQUEST), + ("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("password", "short", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("username", "ab", status.HTTP_422_UNPROCESSABLE_CONTENT), + ), ) async def test_user_registration_fails_when_credentials_are_taken( - self, - app: FastAPI, - client: AsyncClient, - attr: str, - value: str, - status_code: int, + self, + app: FastAPI, + client: AsyncClient, + attr: str, + value: str, + status_code: int, ) -> None: new_user = {"email": "not_taken@email.com", "username": "not_taken_username", "password": "test_password"} @@ -98,26 +94,26 @@ class TestUserRoutes: @pytest.mark.parametrize( "attr, value, status_code", ( - ("email", "user@email.com", status.HTTP_200_OK), - ("email", "user@email.com", status.HTTP_400_BAD_REQUEST), - ("username", "user2", status.HTTP_400_BAD_REQUEST), - ("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("password", "short", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("username", "ab", status.HTTP_422_UNPROCESSABLE_CONTENT), - ("full_name", "John Doe", status.HTTP_200_OK), - ("password", "password123", status.HTTP_200_OK), - ("is_active", True, status.HTTP_200_OK), - ) + ("email", "user@email.com", status.HTTP_200_OK), + ("email", "user@email.com", status.HTTP_400_BAD_REQUEST), + ("username", "user2", status.HTTP_400_BAD_REQUEST), + ("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("password", "short", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("username", "ab", status.HTTP_422_UNPROCESSABLE_CONTENT), + ("full_name", "John Doe", status.HTTP_200_OK), + ("password", "password123", status.HTTP_200_OK), + ("is_active", True, status.HTTP_200_OK), + ), ) async def test_update_user( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession, - attr: str, - value: str, - status_code: int, + self, + app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, + attr: str, + value: str, + status_code: int, ) -> None: user_repo = UsersRepository(db_session) @@ -128,10 +124,7 @@ class TestUserRoutes: assert response.status_code == status_code async def test_users_saved_password_is_hashed( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession + self, app: FastAPI, client: AsyncClient, db_session: AsyncSession ) -> None: user_repo = UsersRepository(db_session) @@ -156,13 +149,8 @@ class TestUserRoutes: class TestAuthTokens: - async def test_can_create_token_successfully( - self, - app: FastAPI, - client: AsyncClient, - test_user: User, - config: Config + self, app: FastAPI, client: AsyncClient, test_user: User, config: Config ) -> None: jwt_secret = config.settings.Controller.jwt_secret_key @@ -173,11 +161,7 @@ class TestAuthTokens: assert username == test_user.username async def test_decode_token_with_wrong_algorithm( - self, - app: FastAPI, - client: AsyncClient, - test_user: User, - config: Config + self, app: FastAPI, client: AsyncClient, test_user: User, config: Config ) -> None: jwt_secret = config.settings.Controller.jwt_secret_key @@ -185,12 +169,7 @@ class TestAuthTokens: with pytest.raises(ValueError): jwt.decode(token, jwt_secret, algorithms=["HS256"]) - async def test_can_retrieve_username_from_token( - self, - app: FastAPI, - client: AsyncClient, - test_user: User - ) -> None: + async def test_can_retrieve_username_from_token(self, app: FastAPI, client: AsyncClient, test_user: User) -> None: token = auth_service.create_access_token(test_user.username) username = auth_service.get_username_from_token(token) @@ -199,19 +178,19 @@ class TestAuthTokens: @pytest.mark.parametrize( "wrong_secret, wrong_token", ( - ("use correct secret", "asdf"), # use wrong token - ("use correct secret", ""), # use wrong token - (OctKey.generate_key().as_dict()['k'], "use correct token"), # use wrong secret + ("use correct secret", "asdf"), # use wrong token + ("use correct secret", ""), # use wrong token + (OctKey.generate_key().as_dict()["k"], "use correct token"), # use wrong secret ), ) async def test_error_when_token_or_secret_is_wrong( - self, - app: FastAPI, - client: AsyncClient, - test_user: User, - wrong_secret: str, - wrong_token: Optional[str], - config, + self, + app: FastAPI, + client: AsyncClient, + test_user: User, + wrong_secret: str, + wrong_token: Optional[str], + config, ) -> None: token = auth_service.create_access_token(test_user.username) @@ -224,13 +203,8 @@ class TestAuthTokens: class TestUserLogin: - async def test_user_can_login_successfully_and_receives_valid_token( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, - config: Config + self, app: FastAPI, unauthorized_client: AsyncClient, test_user: User, config: Config ) -> None: jwt_secret = config.settings.Controller.jwt_secret_key @@ -290,22 +264,18 @@ class TestUserLogin: class TestUnauthorizedUser: - async def test_user_cannot_access_own_data_if_not_authenticated( - self, app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: response = await unauthorized_client.get(app.url_path_for("get_logged_in_user")) assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_user_can_authenticate_using_json( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, - config: Config + self, app: FastAPI, unauthorized_client: AsyncClient, test_user: User, config: Config ) -> None: credentials = { @@ -321,13 +291,13 @@ class TestUnauthorizedUser: response = await unauthorized_client.get(app.url_path_for("statistics"), params={"token": token}) assert response.status_code == status.HTTP_200_OK -class TestUserMe: +class TestUserMe: async def test_authenticated_user_can_retrieve_own_data( - self, - app: FastAPI, - authorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + authorized_client: AsyncClient, + test_user: User, ) -> None: response = await authorized_client.get(app.url_path_for("get_logged_in_user")) @@ -338,10 +308,10 @@ class TestUserMe: assert user.user_id == test_user.user_id async def test_authenticated_user_can_update_own_data( - self, - app: FastAPI, - authorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + authorized_client: AsyncClient, + test_user: User, ) -> None: response = await authorized_client.get(app.url_path_for("get_logged_in_user")) @@ -355,37 +325,33 @@ class TestUserMe: @pytest.mark.parametrize( "attr, value, status_code", ( - ("email", "user42@email.com", status.HTTP_200_OK), - ("email", "user42@email.com", status.HTTP_400_BAD_REQUEST), - ("full_name", "John Doe", status.HTTP_200_OK), - ("password", "password123", status.HTTP_200_OK), - ) + ("email", "user42@email.com", status.HTTP_200_OK), + ("email", "user42@email.com", status.HTTP_400_BAD_REQUEST), + ("full_name", "John Doe", status.HTTP_200_OK), + ("password", "password123", status.HTTP_200_OK), + ), ) async def test_authenticated_user_can_update_own_data( - self, - app: FastAPI, - authorized_client: AsyncClient, - attr: str, - value: str, - status_code: int, + self, + app: FastAPI, + authorized_client: AsyncClient, + attr: str, + value: str, + status_code: int, ) -> None: update_user = {} update_user[attr] = value - response = await authorized_client.put( - app.url_path_for("update_logged_in_user"), - json=update_user - ) + response = await authorized_client.put(app.url_path_for("update_logged_in_user"), json=update_user) assert response.status_code == status_code class TestLogout: - async def test_logout_returns_no_content( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: # login to get a fresh token that includes token_version @@ -395,17 +361,16 @@ class TestLogout: token = response.json()["access_token"] response = await unauthorized_client.post( - app.url_path_for("logout"), - headers={"Authorization": f"Bearer {token}"} + app.url_path_for("logout"), headers={"Authorization": f"Bearer {token}"} ) assert response.status_code == status.HTTP_204_NO_CONTENT async def test_token_is_rejected_after_logout( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, - db_session: AsyncSession, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + db_session: AsyncSession, ) -> None: # login and get a token @@ -415,24 +380,20 @@ class TestLogout: token = response.json()["access_token"] # logout — increments token_version - await unauthorized_client.post( - app.url_path_for("logout"), - headers={"Authorization": f"Bearer {token}"} - ) + await unauthorized_client.post(app.url_path_for("logout"), headers={"Authorization": f"Bearer {token}"}) # old token must now be rejected response = await unauthorized_client.get( - app.url_path_for("get_logged_in_user"), - headers={"Authorization": f"Bearer {token}"} + app.url_path_for("get_logged_in_user"), headers={"Authorization": f"Bearer {token}"} ) assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json()["message"] == f"Token has been revoked for '{test_user.username}'" async def test_new_token_works_after_logout_and_relogin( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: credentials = {"username": test_user.username, "password": "user1_password"} @@ -440,10 +401,7 @@ class TestLogout: # login, then logout response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) old_token = response.json()["access_token"] - await unauthorized_client.post( - app.url_path_for("logout"), - headers={"Authorization": f"Bearer {old_token}"} - ) + await unauthorized_client.post(app.url_path_for("logout"), headers={"Authorization": f"Bearer {old_token}"}) # login again to get a fresh token response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) @@ -452,18 +410,17 @@ class TestLogout: # new token must work response = await unauthorized_client.get( - app.url_path_for("get_logged_in_user"), - headers={"Authorization": f"Bearer {new_token}"} + app.url_path_for("get_logged_in_user"), headers={"Authorization": f"Bearer {new_token}"} ) assert response.status_code == status.HTTP_200_OK assert response.json()["username"] == test_user.username async def test_stale_version_token_is_rejected( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, - db_session: AsyncSession, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + db_session: AsyncSession, ) -> None: # craft a token with ver=0 while the user's token_version is already higher @@ -475,15 +432,14 @@ class TestLogout: stale_token = auth_service.create_access_token(test_user.username, token_version=0) response = await unauthorized_client.get( - app.url_path_for("get_logged_in_user"), - headers={"Authorization": f"Bearer {stale_token}"} + app.url_path_for("get_logged_in_user"), headers={"Authorization": f"Bearer {stale_token}"} ) assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_logout_without_token_returns_unauthorized( - self, - app: FastAPI, - unauthorized_client: AsyncClient, + self, + app: FastAPI, + unauthorized_client: AsyncClient, ) -> None: response = await unauthorized_client.post(app.url_path_for("logout")) @@ -491,27 +447,26 @@ class TestLogout: class TestRefreshToken: - async def test_login_returns_refresh_token( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: credentials = {"username": test_user.username, "password": "user1_password"} - response = await unauthorized_client.post(app.url_path_for("login"), - data=credentials, - headers={"content-type": "application/x-www-form-urlencoded"}) + response = await unauthorized_client.post( + app.url_path_for("login"), data=credentials, headers={"content-type": "application/x-www-form-urlencoded"} + ) assert response.status_code == status.HTTP_200_OK assert "refresh_token" in response.json() assert response.json().get("refresh_token") is not None async def test_authenticate_returns_refresh_token( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: credentials = {"username": test_user.username, "password": "user1_password"} @@ -521,10 +476,10 @@ class TestRefreshToken: assert response.json().get("refresh_token") is not None async def test_refresh_endpoint_returns_new_tokens( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: # authenticate to get a refresh token @@ -544,10 +499,10 @@ class TestRefreshToken: assert response.json().get("refresh_token") is not None async def test_new_access_token_from_refresh_works_on_protected_route( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: credentials = {"username": test_user.username, "password": "user1_password"} @@ -570,10 +525,10 @@ class TestRefreshToken: assert response.json()["username"] == test_user.username async def test_refresh_with_stale_token_after_logout( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: # authenticate and get a refresh token @@ -583,10 +538,7 @@ class TestRefreshToken: # logout — bumps token_version, invalidating the refresh token access_token = auth_response.json()["access_token"] - await unauthorized_client.post( - app.url_path_for("logout"), - headers={"Authorization": f"Bearer {access_token}"} - ) + await unauthorized_client.post(app.url_path_for("logout"), headers={"Authorization": f"Bearer {access_token}"}) # /refresh must now reject the stale refresh token response = await unauthorized_client.post( @@ -596,10 +548,10 @@ class TestRefreshToken: assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_refresh_rejects_access_token( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: # an access token presented at /refresh must be rejected @@ -614,10 +566,10 @@ class TestRefreshToken: assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_refresh_rejects_expired_token( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: # a refresh token with an already-expired timestamp @@ -630,9 +582,9 @@ class TestRefreshToken: assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_refresh_rejects_invalid_token( - self, - app: FastAPI, - unauthorized_client: AsyncClient, + self, + app: FastAPI, + unauthorized_client: AsyncClient, ) -> None: response = await unauthorized_client.post( @@ -642,10 +594,10 @@ class TestRefreshToken: assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test_refresh_token_rejected_as_bearer( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - test_user: User, + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, ) -> None: # a refresh token used as a bearer access token must be rejected @@ -661,25 +613,14 @@ class TestRefreshToken: class TestSuperAdmin: - - async def test_super_admin_exists( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_super_admin_exists(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: user_repo = UsersRepository(db_session) admin_in_db = await user_repo.get_user_by_username("admin") assert admin_in_db is not None assert auth_service.verify_password("admin", admin_in_db.hashed_password) - async def test_cannot_delete_super_admin( - self, - app: FastAPI, - client: AsyncClient, - db_session: AsyncSession - ) -> None: + async def test_cannot_delete_super_admin(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None: user_repo = UsersRepository(db_session) admin_in_db = await user_repo.get_user_by_username("admin") @@ -687,10 +628,7 @@ class TestSuperAdmin: assert response.status_code == status.HTTP_403_FORBIDDEN async def test_admin_can_login_after_password_recovery( - self, - app: FastAPI, - unauthorized_client: AsyncClient, - db_session: AsyncSession + self, app: FastAPI, unauthorized_client: AsyncClient, db_session: AsyncSession ) -> None: # set the admin password to null in the database @@ -705,4 +643,3 @@ class TestSuperAdmin: } response = await unauthorized_client.post(app.url_path_for("login"), data=login_data) assert response.status_code == status.HTTP_200_OK - diff --git a/tests/api/routes/controller/test_version.py b/tests/api/routes/controller/test_version.py index 95824067a..f8c205744 100644 --- a/tests/api/routes/controller/test_version.py +++ b/tests/api/routes/controller/test_version.py @@ -26,40 +26,36 @@ pytestmark = pytest.mark.asyncio class TestVersionRoutes: - - async def test_version_output(self, app: FastAPI, client: AsyncClient) -> None: response = await client.get(app.url_path_for("get_version")) assert response.status_code == status.HTTP_200_OK - assert response.json() == {'controller_host': '127.0.0.1', 'local': False, 'version': __version__} - - + assert response.json() == {"controller_host": "127.0.0.1", "local": False, "version": __version__} + async def test_version_input(self, app: FastAPI, client: AsyncClient) -> None: - - params = {'version': __version__} + + params = {"version": __version__} response = await client.post(app.url_path_for("check_version"), json=params) assert response.status_code == status.HTTP_200_OK - assert response.json() == {'version': __version__} - - + assert response.json() == {"version": __version__} + async def test_version_invalid_input(self, app: FastAPI, client: AsyncClient) -> None: - - params = {'version': "0.4.2"} + + params = {"version": "0.4.2"} response = await client.post(app.url_path_for("check_version"), json=params) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {'message': 'Client version 0.4.2 is not the same as server version {}'.format(__version__)} - - + assert response.json() == { + "message": "Client version 0.4.2 is not the same as server version {}".format(__version__) + } + async def test_version_invalid_input_schema(self, app: FastAPI, client: AsyncClient) -> None: - - params = {'version': "0.4.2", "bla": "blu"} + + params = {"version": "0.4.2", "bla": "blu"} response = await client.post(app.url_path_for("check_version"), json=params) assert response.status_code == status.HTTP_409_CONFLICT - - + async def test_version_invalid_json(self, app: FastAPI, client: AsyncClient) -> None: - + params = "BOUM" response = await client.post(app.url_path_for("check_version"), json=params) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT diff --git a/tests/api/routes/test_index.py b/tests/api/routes/test_index.py index 93cef6ddb..9cdb09851 100644 --- a/tests/api/routes/test_index.py +++ b/tests/api/routes/test_index.py @@ -30,12 +30,10 @@ pytestmark = pytest.mark.asyncio class TestIndexRoutes: - def get_static(self, filename): current_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(os.path.abspath(os.path.join(current_dir, '../..', '..', 'gns3server', 'static')), filename) - + return os.path.join(os.path.abspath(os.path.join(current_dir, "../..", "..", "gns3server", "static")), filename) async def test_debug(self, app: FastAPI, client: AsyncClient) -> None: @@ -45,7 +43,6 @@ class TestIndexRoutes: assert "Website" in html assert __version__ in html - # @pytest.mark.asyncio # async def test_controller(http_client, controller): # @@ -61,7 +58,6 @@ class TestIndexRoutes: # response = await http_client.get('/compute') # assert response.status_code == 200 - # @pytest.mark.asyncio # async def test_project(http_client, controller): # @@ -69,16 +65,14 @@ class TestIndexRoutes: # response = await http_client.get('/projects/{}'.format(project.id)) # assert response.status_code == 200 - async def test_web_ui(self, app: FastAPI, client: AsyncClient) -> None: response = await client.get(app.url_path_for("web_ui", file_path="index.html")) assert response.status_code == status.HTTP_200_OK - async def test_web_ui_not_found(self, app: FastAPI, client: AsyncClient, tmpdir: str) -> None: - with patch('gns3server.utils.get_resource.get_resource') as mock: + with patch("gns3server.utils.get_resource.get_resource") as mock: mock.return_value = str(tmpdir) response = await client.get(app.url_path_for("web_ui", file_path="not-found.txt")) # should serve web-ui/index.html diff --git a/tests/api/routes/test_routes.py b/tests/api/routes/test_routes.py index bc357d668..8628e427d 100644 --- a/tests/api/routes/test_routes.py +++ b/tests/api/routes/test_routes.py @@ -25,7 +25,6 @@ from httpx_ws.transport import ASGIWebSocketTransport from typing import Iterator, Sequence, Tuple - pytestmark = pytest.mark.asyncio ALLOWED_CONTROLLER_ENDPOINTS = [ @@ -64,14 +63,14 @@ def _join_paths(prefix: str, path: str) -> str: def _iter_routes( - routes: Sequence[BaseRoute], - prefix: str = "", - include_mounted_routes: bool = False + routes: Sequence[BaseRoute], prefix: str = "", include_mounted_routes: bool = False ) -> Iterator[Tuple[str, BaseRoute]]: for route in routes: if isinstance(route, _IncludedRouter): include_prefix = route.include_context.prefix or "" - yield from _iter_routes(route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes) + yield from _iter_routes( + route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes + ) continue if isinstance(route, (APIRoute, APIWebSocketRoute)): @@ -85,12 +84,9 @@ def _iter_routes( class TestRoutes: - # Controller endpoints have a OAuth2 bearer token authentication async def test_controller_endpoints_require_authentication( - self, - app: FastAPI, - unauthorized_client: AsyncClient + self, app: FastAPI, unauthorized_client: AsyncClient ) -> None: for path, route in _iter_routes(app.routes): @@ -99,23 +95,22 @@ class TestRoutes: if (path, method) not in ALLOWED_CONTROLLER_ENDPOINTS: request_path = path.rstrip("/") response = await getattr(unauthorized_client, method.lower())(request_path) - assert response.status_code == status.HTTP_401_UNAUTHORIZED, f"{method} {request_path} -> {response.status_code}" + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"{method} {request_path} -> {response.status_code}" + ) elif isinstance(route, APIWebSocketRoute) and not path.startswith("/v3/compute"): params = {"token": "wrong_token"} async with AsyncClient(base_url="http://test-api", transport=ASGIWebSocketTransport(app=app)) as client: async with aconnect_ws(path, client, params=params) as ws: json_notification = await ws.receive_json() - assert json_notification['event'] == { - 'message': 'Could not authenticate while connecting to controller WebSocket: ' - 'Invalid token (DecodeError) (received token sha256 prefix: 4d4f92fb)' + assert json_notification["event"] == { + "message": "Could not authenticate while connecting to controller WebSocket: " + "Invalid token (DecodeError) (received token sha256 prefix: 4d4f92fb)" } - # Compute endpoints have a basic HTTP authentication async def test_compute_endpoints_require_authentication( - self, - app: FastAPI, - unauthorized_client: AsyncClient + self, app: FastAPI, unauthorized_client: AsyncClient ) -> None: for path, route in _iter_routes(app.routes, include_mounted_routes=True): @@ -126,13 +121,15 @@ class TestRoutes: for method in list(route.methods): request_path = path.rstrip("/") response = await getattr(unauthorized_client, method.lower())(request_path) - #if response.status_code == status.HTTP_307_TEMPORARY_REDIRECT: + # if response.status_code == status.HTTP_307_TEMPORARY_REDIRECT: # response = await getattr(unauthorized_client, method.lower())(response.headers["location"]) - assert response.status_code == status.HTTP_401_UNAUTHORIZED, f"{method} {request_path} -> {response.status_code}" + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"{method} {request_path} -> {response.status_code}" + ) elif isinstance(route, APIWebSocketRoute): async with AsyncClient(base_url="http://test-api", transport=ASGIWebSocketTransport(app=app)) as client: async with aconnect_ws(path, client, auth=("wrong_user", "password123")) as ws: json_notification = await ws.receive_json() - assert json_notification['event'] == { - 'message': 'Could not authenticate while connecting to compute WebSocket: Could not validate credentials' + assert json_notification["event"] == { + "message": "Could not authenticate while connecting to compute WebSocket: Could not validate credentials" } diff --git a/tests/compute/builtin/nodes/test_cloud.py b/tests/compute/builtin/nodes/test_cloud.py index 60dbc1541..a25e390df 100644 --- a/tests/compute/builtin/nodes/test_cloud.py +++ b/tests/compute/builtin/nodes/test_cloud.py @@ -27,6 +27,7 @@ from tests.utils import asyncio_patch pytestmark = pytest.mark.asyncio + @pytest.fixture def nio(): @@ -63,19 +64,39 @@ async def test_json_with_ports(on_gns3vm, compute_project, manager): "remote_console_type": "none", "status": "stopped", "node_directory": cloud.working_dir, - "ports_mapping": [ - { - "interface": "virbr0", - "name": "virbr0", - "port_number": 0, - "type": "ethernet" - } - ], + "ports_mapping": [{"interface": "virbr0", "name": "virbr0", "port_number": 0, "type": "ethernet"}], "interfaces": [ - {'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}, - {'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']}, - {'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']} - ] + { + "name": "eth0", + "special": False, + "type": "ethernet", + "ip_addresses": [], + "status": "up", + "speed": 1000, + "mtu": 1500, + "flags": ["up", "broadcast", "running", "multicast"], + }, + { + "name": "eth1", + "special": False, + "type": "ethernet", + "ip_addresses": [], + "status": "down", + "speed": 0, + "mtu": 1500, + "flags": ["broadcast"], + }, + { + "name": "virbr0", + "special": True, + "type": "ethernet", + "ip_addresses": [], + "status": "up", + "speed": 10000, + "mtu": 1500, + "flags": ["up", "broadcast", "running", "multicast"], + }, + ], } @@ -97,24 +118,41 @@ async def test_json_without_ports(on_gns3vm, compute_project, manager): "status": "stopped", "node_directory": cloud.working_dir, "ports_mapping": [ - { - "interface": "eth0", - "name": "eth0", - "port_number": 0, - "type": "ethernet" - }, - { - "interface": "eth1", - "name": "eth1", - "port_number": 1, - "type": "ethernet" - } + {"interface": "eth0", "name": "eth0", "port_number": 0, "type": "ethernet"}, + {"interface": "eth1", "name": "eth1", "port_number": 1, "type": "ethernet"}, ], "interfaces": [ - {'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}, - {'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']}, - {'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']} - ] + { + "name": "eth0", + "special": False, + "type": "ethernet", + "ip_addresses": [], + "status": "up", + "speed": 1000, + "mtu": 1500, + "flags": ["up", "broadcast", "running", "multicast"], + }, + { + "name": "eth1", + "special": False, + "type": "ethernet", + "ip_addresses": [], + "status": "down", + "speed": 0, + "mtu": 1500, + "flags": ["broadcast"], + }, + { + "name": "virbr0", + "special": True, + "type": "ethernet", + "ip_addresses": [], + "status": "up", + "speed": 10000, + "mtu": 1500, + "flags": ["up", "broadcast", "running", "multicast"], + }, + ], } @@ -124,49 +162,22 @@ async def test_update_port_mappings(on_gns3vm, compute_project): """ ports1 = [ - { - "interface": "eth0", - "name": "eth0", - "port_number": 0, - "type": "ethernet" - }, - { - "interface": "eth1", - "name": "eth1", - "port_number": 1, - "type": "ethernet" - } + {"interface": "eth0", "name": "eth0", "port_number": 0, "type": "ethernet"}, + {"interface": "eth1", "name": "eth1", "port_number": 1, "type": "ethernet"}, ] cloud = Cloud("cloud1", str(uuid.uuid4()), compute_project, MagicMock(), ports=ports1) assert cloud.ports_mapping == ports1 ports2 = [ - { - "interface": "eth0", - "name": "eth0", - "port_number": 0, - "type": "ethernet" - }, - { - "interface": "eth1", - "name": "eth1", - "port_number": 2, - "type": "ethernet" - } + {"interface": "eth0", "name": "eth0", "port_number": 0, "type": "ethernet"}, + {"interface": "eth1", "name": "eth1", "port_number": 2, "type": "ethernet"}, ] cloud = Cloud("cloud2", str(uuid.uuid4()), compute_project, MagicMock(), ports=ports2) assert cloud.ports_mapping == ports1 async def test_linux_ethernet_raw_add_nio(linux_platform, compute_project, nio): - ports = [ - { - "interface": "eth0", - "name": "eth0", - "port_number": 0, - "type": "ethernet" - } - ] + ports = [{"interface": "eth0", "name": "eth0", "port_number": 0, "type": "ethernet"}] cloud = Cloud("cloud1", str(uuid.uuid4()), compute_project, MagicMock(), ports=ports) cloud.status = "started" @@ -176,43 +187,42 @@ async def test_linux_ethernet_raw_add_nio(linux_platform, compute_project, nio): with patch("gns3server.compute.builtin.nodes.cloud.Cloud._interfaces", return_value=[{"name": "eth0"}]): await cloud.add_nio(nio, 0) - ubridge_mock.assert_has_calls([ - call("bridge create {}-0".format(cloud._id)), - call("bridge add_nio_udp {}-0 4242 127.0.0.1 4343".format(cloud._id)), - call('bridge reset_packet_filters {}-0'.format(cloud._id)), - call("bridge add_nio_linux_raw {}-0 \"eth0\"".format(cloud._id)), - call("bridge start {}-0".format(cloud._id)), - ]) + ubridge_mock.assert_has_calls( + [ + call("bridge create {}-0".format(cloud._id)), + call("bridge add_nio_udp {}-0 4242 127.0.0.1 4343".format(cloud._id)), + call("bridge reset_packet_filters {}-0".format(cloud._id)), + call('bridge add_nio_linux_raw {}-0 "eth0"'.format(cloud._id)), + call("bridge start {}-0".format(cloud._id)), + ] + ) async def test_linux_ethernet_raw_add_nio_bridge(linux_platform, compute_project, nio): """ Bridge can't be connected directly to a cloud we use a tap in the middle """ - ports = [ - { - "interface": "bridge0", - "name": "bridge0", - "port_number": 0, - "type": "ethernet" - } - ] + ports = [{"interface": "bridge0", "name": "bridge0", "port_number": 0, "type": "ethernet"}] cloud = Cloud("cloud1", str(uuid.uuid4()), compute_project, MagicMock(), ports=ports) cloud.status = "started" with patch("shutil.which", return_value="/bin/ubridge"): with patch("gns3server.compute.base_manager.BaseManager.has_privileged_access", return_value=True): with asyncio_patch("gns3server.compute.builtin.nodes.cloud.Cloud._ubridge_send") as ubridge_mock: - with patch("gns3server.compute.builtin.nodes.cloud.Cloud._interfaces", return_value=[{"name": "bridge0"}]): + with patch( + "gns3server.compute.builtin.nodes.cloud.Cloud._interfaces", return_value=[{"name": "bridge0"}] + ): with patch("gns3server.utils.interfaces.is_interface_bridge", return_value=True): await cloud.add_nio(nio, 0) tap = "gns3tap0-0" - ubridge_mock.assert_has_calls([ - call("bridge create {}-0".format(cloud._id)), - call("bridge add_nio_udp {}-0 4242 127.0.0.1 4343".format(cloud._id)), - call('bridge reset_packet_filters {}-0'.format(cloud._id)), - call("bridge add_nio_tap \"{}-0\" \"{}\"".format(cloud._id, tap)), - call("brctl addif \"bridge0\" \"{}\"".format(tap)), - call("bridge start {}-0".format(cloud._id)), - ]) + ubridge_mock.assert_has_calls( + [ + call("bridge create {}-0".format(cloud._id)), + call("bridge add_nio_udp {}-0 4242 127.0.0.1 4343".format(cloud._id)), + call("bridge reset_packet_filters {}-0".format(cloud._id)), + call('bridge add_nio_tap "{}-0" "{}"'.format(cloud._id, tap)), + call('brctl addif "bridge0" "{}"'.format(tap)), + call("bridge start {}-0".format(cloud._id)), + ] + ) diff --git a/tests/compute/builtin/nodes/test_nat.py b/tests/compute/builtin/nodes/test_nat.py index bd89aeafe..3c0f7e06d 100644 --- a/tests/compute/builtin/nodes/test_nat.py +++ b/tests/compute/builtin/nodes/test_nat.py @@ -30,14 +30,7 @@ def test_json_gns3vm(on_gns3vm, compute_project): "node_id": nat.id, "project_id": compute_project.id, "status": "started", - "ports_mapping": [ - { - "interface": "virbr0", - "name": "nat0", - "port_number": 0, - "type": "ethernet" - } - ], + "ports_mapping": [{"interface": "virbr0", "name": "nat0", "port_number": 0, "type": "ethernet"}], "interfaces": [ { "name": "virbr0", @@ -51,9 +44,13 @@ def test_json_gns3vm(on_gns3vm, compute_project): def test_json_darwin(darwin_platform, compute_project): - with patch("gns3server.utils.interfaces.interfaces", return_value=[ + with patch( + "gns3server.utils.interfaces.interfaces", + return_value=[ {"name": "eth0", "special": False, "type": "ethernet"}, - {"name": "vmnet8", "special": True, "type": "ethernet"}]): + {"name": "vmnet8", "special": True, "type": "ethernet"}, + ], + ): nat = Nat("nat1", str(uuid.uuid4()), compute_project, MagicMock()) assert nat.asdict() == { "name": "nat1", @@ -61,14 +58,7 @@ def test_json_darwin(darwin_platform, compute_project): "node_id": nat.id, "project_id": compute_project.id, "status": "started", - "ports_mapping": [ - { - "interface": "vmnet8", - "name": "nat0", - "port_number": 0, - "type": "ethernet" - } - ], + "ports_mapping": [{"interface": "vmnet8", "name": "nat0", "port_number": 0, "type": "ethernet"}], "interfaces": [ { "name": "vmnet8", @@ -81,8 +71,10 @@ def test_json_darwin(darwin_platform, compute_project): def test_json_windows_with_full_name_of_interface(windows_platform, project): - with patch("gns3server.utils.interfaces.interfaces", return_value=[ - {"name": "VMware Network Adapter VMnet8", "special": True, "type": "ethernet"}]): + with patch( + "gns3server.utils.interfaces.interfaces", + return_value=[{"name": "VMware Network Adapter VMnet8", "special": True, "type": "ethernet"}], + ): nat = Nat("nat1", str(uuid.uuid4()), project, MagicMock()) assert nat.asdict() == { "name": "nat1", @@ -91,12 +83,7 @@ def test_json_windows_with_full_name_of_interface(windows_platform, project): "project_id": project.id, "status": "started", "ports_mapping": [ - { - "interface": "VMware Network Adapter VMnet8", - "name": "nat0", - "port_number": 0, - "type": "ethernet" - } + {"interface": "VMware Network Adapter VMnet8", "name": "nat0", "port_number": 0, "type": "ethernet"} ], "interfaces": [ { diff --git a/tests/compute/docker/test_docker.py b/tests/compute/docker/test_docker.py index c00d6c11b..14d347ba9 100644 --- a/tests/compute/docker/test_docker.py +++ b/tests/compute/docker/test_docker.py @@ -40,7 +40,7 @@ async def test_query_success(vm): response = MagicMock() response.status = 200 - response.headers = {'CONTENT-TYPE': 'application/json'} + response.headers = {"CONTENT-TYPE": "application/json"} async def read(): return b'{"c": false}' @@ -48,12 +48,14 @@ async def test_query_success(vm): response.read.side_effect = read vm._session.request = AsyncioMagicMock(return_value=response) data = await vm.query("POST", "test", data={"a": True}, params={"b": 1}) - vm._session.request.assert_called_with('POST', - 'http://docker/v{}/test'.format(DOCKER_MINIMUM_API_VERSION), - data='{"a": true}', - headers={'content-type': 'application/json'}, - params={'b': 1}, - timeout=300) + vm._session.request.assert_called_with( + "POST", + "http://docker/v{}/test".format(DOCKER_MINIMUM_API_VERSION), + data='{"a": true}', + headers={"content-type": "application/json"}, + params={"b": 1}, + timeout=300, + ) assert data == {"c": False} @@ -71,12 +73,14 @@ async def test_query_error(vm): vm._session.request = AsyncioMagicMock(return_value=response) with pytest.raises(DockerError): await vm.query("POST", "test", data={"a": True}, params={"b": 1}) - vm._session.request.assert_called_with('POST', - 'http://docker/v{}/test'.format(DOCKER_MINIMUM_API_VERSION), - data='{"a": true}', - headers={'content-type': 'application/json'}, - params={'b': 1}, - timeout=300) + vm._session.request.assert_called_with( + "POST", + "http://docker/v{}/test".format(DOCKER_MINIMUM_API_VERSION), + data='{"a": true}', + headers={"content-type": "application/json"}, + params={"b": 1}, + timeout=300, + ) @pytest.mark.asyncio @@ -92,12 +96,14 @@ async def test_query_error_json(vm): vm._session.request = AsyncioMagicMock(return_value=response) with pytest.raises(DockerError): await vm.query("POST", "test", data={"a": True}, params={"b": 1}) - vm._session.request.assert_called_with('POST', - 'http://docker/v{}/test'.format(DOCKER_MINIMUM_API_VERSION), - data='{"a": true}', - headers={'content-type': 'application/json'}, - params={'b': 1}, - timeout=300) + vm._session.request.assert_called_with( + "POST", + "http://docker/v{}/test".format(DOCKER_MINIMUM_API_VERSION), + data='{"a": true}', + headers={"content-type": "application/json"}, + params={"b": 1}, + timeout=300, + ) @pytest.mark.asyncio @@ -105,28 +111,20 @@ async def test_list_images(): response = [ { - "RepoTags": [ - "ubuntu:12.04", - "ubuntu:precise", - "ubuntu:latest" - ], + "RepoTags": ["ubuntu:12.04", "ubuntu:precise", "ubuntu:latest"], "Id": "8dbd9e392a964056420e5d58ca5cc376ef18e2de93b5cc90e868a1bbc8318c1c", "Created": 1365714795, "Size": 131506275, - "VirtualSize": 131506275 + "VirtualSize": 131506275, }, { - "RepoTags": [ - "ubuntu:12.10", - "ubuntu:quantal", - ":" - ], + "RepoTags": ["ubuntu:12.10", "ubuntu:quantal", ":"], "ParentId": "27cf784147099545", "Id": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc", "Created": 1364102658, "Size": 24653, - "VirtualSize": 180116135 - } + "VirtualSize": 180116135, + }, ] with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: @@ -196,7 +194,6 @@ async def test_force_pull_image(): async def test_pull_image_error(): class Content: - def __init__(self): self._chunks = [b'{"error": "image not found"}', b""] @@ -217,7 +214,6 @@ async def test_pull_image_error(): async def test_pull_image_rejects_incomplete_response(): class Content: - def __init__(self): self._read = False @@ -241,7 +237,6 @@ async def test_pull_image_rejects_incomplete_response(): async def test_pull_image_propagates_timeout(): class Content: - async def read(self, size): raise asyncio.TimeoutError @@ -259,7 +254,6 @@ async def test_pull_image_propagates_timeout(): async def test_load_image(): class Content: - def __init__(self): self._chunks = [b'{"stream": "Loaded image ID: sha256:e90e34656806"}', b""] @@ -285,7 +279,6 @@ async def test_load_image(): async def test_load_image_error(): class Content: - def __init__(self): self._chunks = [b'{"error": "invalid tar file"}', b""] @@ -307,13 +300,12 @@ async def test_load_image_error(): @pytest.mark.asyncio async def test_docker_check_connection_docker_minimum_version(vm): - response = { - 'ApiVersion': '1.01', - 'Version': '1.12' - } + response = {"ApiVersion": "1.01", "Version": "1.12"} - with patch("gns3server.compute.docker.Docker.connector"), \ - asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): + with ( + patch("gns3server.compute.docker.Docker.connector"), + asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response), + ): vm._connected = False with pytest.raises(DockerError): await vm._check_connection() @@ -323,13 +315,14 @@ async def test_docker_check_connection_docker_minimum_version(vm): async def test_docker_check_connection_docker_preferred_version_against_newer(vm): response = { - 'ApiVersion': '1.52', - 'Version': '29.0.1', - + "ApiVersion": "1.52", + "Version": "29.0.1", } - with patch("gns3server.compute.docker.Docker.connector"), \ - asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): + with ( + patch("gns3server.compute.docker.Docker.connector"), + asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response), + ): vm._connected = False await vm._check_connection() assert vm._api_version == DOCKER_PREFERRED_API_VERSION @@ -338,14 +331,12 @@ async def test_docker_check_connection_docker_preferred_version_against_newer(vm @pytest.mark.asyncio async def test_docker_check_connection_docker_preferred_version_against_older(vm): - response = { - 'ApiVersion': '1.43', - 'Version': '24.0.2', - 'MinAPIVersion': '1.40' - } + response = {"ApiVersion": "1.43", "Version": "24.0.2", "MinAPIVersion": "1.40"} - with patch("gns3server.compute.docker.Docker.connector"), \ - asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): + with ( + patch("gns3server.compute.docker.Docker.connector"), + asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response), + ): vm._connected = False await vm._check_connection() assert vm._api_version == DOCKER_MINIMUM_API_VERSION @@ -355,12 +346,14 @@ async def test_docker_check_connection_docker_preferred_version_against_older(vm async def test_docker_check_connection_docker_unsupported_version(vm): response = { - 'ApiVersion': '1.25', - 'Version': '1.13.1', + "ApiVersion": "1.25", + "Version": "1.13.1", } - with patch("gns3server.compute.docker.Docker.connector"), \ - asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): + with ( + patch("gns3server.compute.docker.Docker.connector"), + asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response), + ): vm._connected = False with pytest.raises(DockerError) as e: await vm._check_connection() @@ -370,12 +363,14 @@ async def test_docker_check_connection_docker_unsupported_version(vm): async def test_install_busybox(): mock_process = MagicMock() - mock_process.returncode = 1 # means that busybox is not dynamically linked + mock_process.returncode = 1 # means that busybox is not dynamically linked mock_process.communicate = AsyncioMagicMock(return_value=(b"", b"not a dynamic executable")) with patch("gns3server.compute.docker.os.path.isfile", return_value=False): with patch("gns3server.compute.docker.shutil.which", return_value="/usr/bin/busybox"): - with asyncio_patch("gns3server.compute.docker.asyncio.create_subprocess_exec", return_value=mock_process) as create_subprocess_mock: + with asyncio_patch( + "gns3server.compute.docker.asyncio.create_subprocess_exec", return_value=mock_process + ) as create_subprocess_mock: with patch("gns3server.compute.docker.shutil.copy2") as copy2_mock: dst_dir = Docker.resources_path() await Docker.install_busybox(dst_dir) @@ -396,12 +391,18 @@ async def test_install_busybox_dynamic_linked(): mock_process.communicate = AsyncioMagicMock(return_value=(b"Dynamically linked library", b"")) with patch("os.path.isfile", return_value=False): - with patch("gns3server.compute.docker.shutil.which", side_effect=lambda name: "/usr/bin/busybox" if name == "busybox" else None): + with patch( + "gns3server.compute.docker.shutil.which", + side_effect=lambda name: "/usr/bin/busybox" if name == "busybox" else None, + ): with asyncio_patch("gns3server.compute.docker.asyncio.create_subprocess_exec", return_value=mock_process): with pytest.raises(DockerError) as e: dst_dir = Docker.resources_path() await Docker.install_busybox(dst_dir) - assert str(e.value) == "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH" + assert ( + str(e.value) + == "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH" + ) @pytest.mark.asyncio @@ -412,7 +413,10 @@ async def test_install_busybox_no_executables(): with pytest.raises(DockerError) as e: dst_dir = Docker.resources_path() await Docker.install_busybox(dst_dir) - assert str(e.value) == "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH" + assert ( + str(e.value) + == "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH" + ) @pytest.mark.asyncio @@ -494,4 +498,4 @@ async def test_check_host_readiness_continues_past_unreadable_key(caplog): message = " ".join(r.message for r in caplog.records) assert "max_user_instances=128" in message # collected before the gap - assert "modprobe fuse" in message # FUSE check still ran + assert "modprobe fuse" in message # FUSE check still ran diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 5ad4eb4f8..6e340b21d 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -51,7 +51,7 @@ async def vm(compute_project, manager): vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", aux_type="none") vm._cid = "e90e34656842" - vm.mac_address = '02:42:3d:b7:93:00' + vm.mac_address = "02:42:3d:b7:93:00" # Interface monitoring has its own focused tests below. Keep unrelated # lifecycle tests isolated from a real Docker exec stream. vm._start_interface_monitor = AsyncioMagicMock() @@ -62,30 +62,30 @@ async def vm(compute_project, manager): def test_json(vm, compute_project): assert vm.asdict() == { - 'container_id': 'e90e34656842', - 'image': 'ubuntu:latest', - 'name': 'test', - 'project_id': compute_project.id, - 'node_id': vm.id, - 'adapters': 1, - 'mac_address': '02:42:3d:b7:93:00', - 'console': vm.console, - 'console_type': 'telnet', - 'aux_type': 'none', - 'console_resolution': '1024x768', - 'console_http_port': 80, - 'console_http_path': '/', - 'extra_hosts': None, - 'extra_volumes': [], - 'extra_configs': [], - 'memory': 0, - 'cpus': 0, - 'aux': vm.aux, - 'start_command': vm.start_command, - 'environment': vm.environment, - 'node_directory': vm.working_dir, - 'status': 'stopped', - 'usage': '' + "container_id": "e90e34656842", + "image": "ubuntu:latest", + "name": "test", + "project_id": compute_project.id, + "node_id": vm.id, + "adapters": 1, + "mac_address": "02:42:3d:b7:93:00", + "console": vm.console, + "console_type": "telnet", + "aux_type": "none", + "console_resolution": "1024x768", + "console_http_port": 80, + "console_http_path": "/", + "extra_hosts": None, + "extra_volumes": [], + "extra_configs": [], + "memory": 0, + "cpus": 0, + "aux": vm.aux, + "start_command": vm.start_command, + "environment": vm.environment, + "node_directory": vm.working_dir, + "status": "stopped", + "usage": "", } @@ -100,164 +100,143 @@ def test_start_command(vm): @pytest.mark.asyncio async def test_create(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest") await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } + "Target": "/gns3volumes/etc/network", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @pytest.mark.asyncio async def test_create_with_tag(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:16.04") await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } + "Target": "/gns3volumes/etc/network", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:16.04", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:16.04", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @pytest.mark.asyncio async def test_create_vnc(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", console_type="vnc", console=5900) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu", console_type="vnc", console=5900 + ) vm._start_vnc = MagicMock() vm._display = 42 await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, { "Type": "bind", "Source": f"/tmp/.X11-unix/X{vm._display}", "Target": f"/tmp/.X11-unix/X{vm._display}", - "ReadOnly": True - } + "ReadOnly": True, + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network", - "QT_GRAPHICSSYSTEM=native", - "DISPLAY=:42" + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": [ + "container=docker", + "GNS3_MAX_ETHERNET=eth0", + "GNS3_VOLUMES=/etc/network", + "QT_GRAPHICSSYSTEM=native", + "DISPLAY=:42", ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._start_vnc.called assert vm._cid == "e90e34656806" assert vm._console_type == "vnc" @@ -267,10 +246,7 @@ async def test_create_vnc(compute_project, manager): async def test_create_with_extra_hosts(compute_project, manager): extra_hosts = "test:199.199.199.1\ntest2:199.199.199.1" - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: @@ -294,7 +270,7 @@ async def test_create_applies_env_host_config(compute_project, manager): "GNS3_SHM_SIZE=1024\n" "GNS3_DEVICES=/dev/fuse\n" "GNS3_EVIL=should-be-dropped\n" # GNS3_ -> never forwarded as env - "FOO=bar" # normal var -> forwarded + "FOO=bar" # normal var -> forwarded ) response = {"Id": "e90e34656806", "Warnings": []} @@ -310,10 +286,9 @@ async def test_create_applies_env_host_config(compute_project, manager): ] env = data["Env"] assert "FOO=bar" in env - assert not any( - e.startswith(("GNS3_SHM_SIZE=", "GNS3_DEVICES=", "GNS3_EVIL=")) - for e in env - ), "GNS3_ user vars must not leak into the container environment" + assert not any(e.startswith(("GNS3_SHM_SIZE=", "GNS3_DEVICES=", "GNS3_EVIL=")) for e in env), ( + "GNS3_ user vars must not leak into the container environment" + ) @pytest.mark.asyncio @@ -331,8 +306,9 @@ async def test_create_masks_systemd_units(compute_project, manager): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", environment=environment) await vm.create() - masked = {m["Target"] for m in mock.call_args[1]["data"]["HostConfig"]["Mounts"] - if m.get("Source") == "/dev/null"} + masked = { + m["Target"] for m in mock.call_args[1]["data"]["HostConfig"]["Mounts"] if m.get("Source") == "/dev/null" + } for unit in DockerVM._UDEV_UNITS: assert f"/etc/systemd/system/{unit}" in masked for path in DockerVM._UDEVADM_PATHS: @@ -382,10 +358,7 @@ async def test_create_with_extra_configs_invalid_target(compute_project, manager @pytest.mark.asyncio async def test_create_with_colon_in_project_name(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): @@ -399,10 +372,7 @@ async def test_create_with_colon_in_project_name(compute_project, manager): async def test_create_with_extra_hosts_wrong_format(compute_project, manager): extra_hosts = "test" - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): @@ -415,30 +385,24 @@ async def test_create_with_extra_hosts_wrong_format(compute_project, manager): async def test_create_with_empty_extra_hosts(compute_project, manager): extra_hosts = "test:\n" - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", extra_hosts=extra_hosts) await vm.create() called_kwargs = mock.call_args[1] - assert len([ e for e in called_kwargs["data"]["Env"] if "GNS3_EXTRA_HOSTS" in e]) == 0 + assert len([e for e in called_kwargs["data"]["Env"] if "GNS3_EXTRA_HOSTS" in e]) == 0 @pytest.mark.asyncio async def test_create_with_project_variables(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} compute_project.variables = [ {"name": "VAR1"}, {"name": "VAR2", "value": "VAL1"}, - {"name": "VAR3", "value": "2x${VAR2}"} + {"name": "VAR3", "value": "2x${VAR2}"}, ] with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): @@ -455,52 +419,43 @@ async def test_create_with_project_variables(compute_project, manager): @pytest.mark.asyncio async def test_create_start_cmd(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest") vm._start_command = "/bin/ls" await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } + "Target": "/gns3volumes/etc/network", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/ls"], - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ] - }) + "Volumes": {}, + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/ls"], + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + }, + ) assert vm._cid == "e90e34656806" @@ -511,21 +466,18 @@ async def test_create_environment(compute_project, manager): internal variables """ - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu") vm.environment = "YES=1\nNO=0\nGNS3_MAX_ETHERNET=eth2" await vm.create() - assert mock.call_args[1]['data']['Env'] == [ + assert mock.call_args[1]["data"]["Env"] == [ "container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network", "YES=1", - "NO=0" + "NO=0", ] @@ -536,21 +488,18 @@ async def test_create_environment_with_last_new_line_character(compute_project, internal variables """ - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu") vm.environment = "YES=1\nNO=0\nGNS3_MAX_ETHERNET=eth2\n" await vm.create() - assert mock.call_args[1]['data']['Env'] == [ + assert mock.call_args[1]["data"]["Env"] == [ "container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network", "YES=1", - "NO=0" + "NO=0", ] @@ -568,13 +517,11 @@ async def test_create_image_not_available(compute_project, manager): @pytest.mark.asyncio async def test_create_image_digest_match(compute_project, manager): - response = { - "Id": "sha256:" + "a" * 64, - "Warnings": [] - } + response = {"Id": "sha256:" + "a" * 64, "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", - image_digest="sha256:" + "a" * 64) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", image_digest="sha256:" + "a" * 64 + ) await vm.create() # the last query is the container creation: the digest check let it through assert mock.call_args[0] == ("POST", "containers/create?name={}".format(vm.docker_name)) @@ -584,12 +531,10 @@ async def test_create_image_digest_match(compute_project, manager): @pytest.mark.asyncio async def test_create_image_digest_mismatch(compute_project, manager): - response = { - "Id": "sha256:" + "b" * 64, - "Warnings": [] - } - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", - image_digest="sha256:" + "a" * 64) + response = {"Id": "sha256:" + "b" * 64, "Warnings": []} + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", image_digest="sha256:" + "a" * 64 + ) with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as query_mock: with pytest.raises(ImageMissingError, match="ubuntu:latest"): await vm.create() @@ -603,63 +548,58 @@ async def test_create_with_user(compute_project, manager): response = { "Id": "e90e34656806", "Warnings": [], - "Config" : { - "User" : "test", + "Config": { + "User": "test", }, } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest") await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "User": "root", - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "User": "root", + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } + "Target": "/gns3volumes/etc/network", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network", - "GNS3_USER=test" + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": [ + "container=docker", + "GNS3_MAX_ETHERNET=eth0", + "GNS3_VOLUMES=/etc/network", + "GNS3_USER=test", ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @pytest.mark.asyncio async def test_create_with_extra_volumes_invalid_format_1(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["vol1"]) @@ -670,13 +610,12 @@ async def test_create_with_extra_volumes_invalid_format_1(compute_project, manag @pytest.mark.asyncio async def test_create_with_extra_volumes_invalid_format_2(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol1", ""]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol1", ""] + ) with pytest.raises(DockerError): await vm.create() @@ -684,13 +623,12 @@ async def test_create_with_extra_volumes_invalid_format_2(compute_project, manag @pytest.mark.asyncio async def test_create_with_extra_volumes_invalid_format_3(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol1/.."]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol1/.."] + ) with pytest.raises(DockerError): await vm.create() @@ -701,58 +639,52 @@ async def test_create_with_extra_volumes_duplicate_1_image(compute_project, mana response = { "Id": "e90e34656806", "Warnings": [], - "Config" : { - "Volumes" : { - "/vol/1": None - }, + "Config": { + "Volumes": {"/vol/1": None}, }, } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/1"]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/1"] + ) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, { "Type": "bind", "Source": os.path.join(vm.working_dir, "vol", "1"), - "Target": "/gns3volumes/vol/1" - } + "Target": "/gns3volumes/vol/1", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network:/vol/1" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network:/vol/1"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -765,50 +697,46 @@ async def test_create_with_extra_volumes_duplicate_2_user(compute_project, manag } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/1", "/vol/1"]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/1", "/vol/1"] + ) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, { "Type": "bind", "Source": os.path.join(vm.working_dir, "vol", "1"), - "Target": "/gns3volumes/vol/1" - } + "Target": "/gns3volumes/vol/1", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network:/vol/1" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network:/vol/1"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -821,50 +749,46 @@ async def test_create_with_extra_volumes_duplicate_3_subdir(compute_project, man } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/1/", "/vol"]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/1/", "/vol"] + ) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, { "Type": "bind", "Source": os.path.join(vm.working_dir, "vol"), - "Target": "/gns3volumes/vol" - } + "Target": "/gns3volumes/vol", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network:/vol" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network:/vol"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -877,50 +801,46 @@ async def test_create_with_extra_volumes_duplicate_4_backslash(compute_project, } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol//", "/vol"]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol//", "/vol"] + ) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, { "Type": "bind", "Source": os.path.join(vm.working_dir, "vol"), - "Target": "/gns3volumes/vol" - } + "Target": "/gns3volumes/vol", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network:/vol" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network:/vol"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -935,43 +855,37 @@ async def test_create_with_extra_volumes_duplicate_5_subdir_issue_1595(compute_p with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/etc"]) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc"), - "Target": "/gns3volumes/etc" - } + "Target": "/gns3volumes/etc", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -984,45 +898,46 @@ async def test_create_with_extra_volumes_duplicate_6_subdir_issue_1595(compute_p } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/etc/test", "/etc"]) + vm = DockerVM( + "test", + str(uuid.uuid4()), + compute_project, + manager, + "ubuntu:latest", + extra_volumes=["/etc/test", "/etc"], + ) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc"), - "Target": "/gns3volumes/etc" - } + "Target": "/gns3volumes/etc", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -1032,64 +947,58 @@ async def test_create_with_extra_volumes(compute_project, manager): response = { "Id": "e90e34656806", "Warnings": [], - "Config" : { - "Volumes" : { - "/vol/1": None - }, + "Config": { + "Volumes": {"/vol/1": None}, }, } with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/2"]) + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", extra_volumes=["/vol/2"] + ) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, { "Type": "bind", "Source": os.path.join(vm.working_dir, "vol", "1"), - "Target": "/gns3volumes/vol/1" + "Target": "/gns3volumes/vol/1", }, { "Type": "bind", "Source": os.path.join(vm.working_dir, "vol", "2"), - "Target": "/gns3volumes/vol/2" - } + "Target": "/gns3volumes/vol/2", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 0, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network:/vol/1:/vol/2" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network:/vol/1:/vol/2"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @@ -1106,7 +1015,7 @@ async def test_get_container_state(vm): "Pid": 0, "Restarting": False, "Running": True, - "StartedAt": "2015-01-06T15:47:32.072697474Z" + "StartedAt": "2015-01-06T15:47:32.072697474Z", } } with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): @@ -1126,12 +1035,7 @@ async def test_get_container_state(vm): @pytest.mark.asyncio async def test_is_running(vm): - response = { - "State": { - "Running": False, - "Paused": False - } - } + response = {"State": {"Running": False, "Paused": False}} with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): assert await vm.is_running() is False @@ -1173,7 +1077,9 @@ async def test_start(vm, manager, free_console_port, tmpdir): vm._add_ubridge_connection = AsyncioMagicMock() vm._start_console = AsyncioMagicMock() - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) await vm.adapter_add_nio_binding(0, nio) with patch("gns3server.compute.docker.Docker.install_busybox"): @@ -1221,17 +1127,25 @@ async def test_start_namespace_failed(vm, manager, free_console_port): assert vm.status != "started" vm.adapters = 1 - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) await vm.adapter_add_nio_binding(0, nio) with patch("gns3server.compute.docker.Docker.install_busybox"): with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="stopped"): with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query: with asyncio_patch("gns3server.compute.docker.DockerVM._start_ubridge") as mock_start_ubridge: - with asyncio_patch("gns3server.compute.docker.DockerVM._get_namespace", return_value=42) as mock_namespace: - with asyncio_patch("gns3server.compute.docker.DockerVM._add_ubridge_connection", side_effect=UbridgeNamespaceError()) as mock_add_ubridge_connection: - with asyncio_patch("gns3server.compute.docker.DockerVM._get_log", return_value='Hello not available') as mock_log: - + with asyncio_patch( + "gns3server.compute.docker.DockerVM._get_namespace", return_value=42 + ) as mock_namespace: + with asyncio_patch( + "gns3server.compute.docker.DockerVM._add_ubridge_connection", + side_effect=UbridgeNamespaceError(), + ) as mock_add_ubridge_connection: + with asyncio_patch( + "gns3server.compute.docker.DockerVM._get_log", return_value="Hello not available" + ) as mock_log: with pytest.raises(DockerError): await vm.start() @@ -1255,8 +1169,12 @@ async def test_start_without_nio(vm): with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query: with asyncio_patch("gns3server.compute.docker.DockerVM._start_ubridge") as mock_start_ubridge: with asyncio_patch("gns3server.compute.docker.DockerVM._get_namespace", return_value=42): - with asyncio_patch("gns3server.compute.docker.DockerVM._add_ubridge_connection") as mock_add_ubridge_connection: - with asyncio_patch("gns3server.compute.docker.DockerVM._start_console") as mock_start_console: + with asyncio_patch( + "gns3server.compute.docker.DockerVM._add_ubridge_connection" + ) as mock_add_ubridge_connection: + with asyncio_patch( + "gns3server.compute.docker.DockerVM._start_console" + ) as mock_start_console: await vm.start() mock_query.assert_called_with("POST", "containers/e90e34656842/start") @@ -1302,32 +1220,40 @@ async def test_restart(vm): async def test_read_interface_statuses(vm): reader = MagicMock() - reader.readline = AsyncMock(side_effect=[ - b"eth0=0x1003\r\n", - b"eth0=0x1003\r\n", # duplicate must not emit again - b"eth0=0x1002\r\n", - b"unknown=0x1003\r\n", - b"eth0=invalid\r\n", - b"", - ]) + reader.readline = AsyncMock( + side_effect=[ + b"eth0=0x1003\r\n", + b"eth0=0x1003\r\n", # duplicate must not emit again + b"eth0=0x1002\r\n", + b"unknown=0x1003\r\n", + b"eth0=invalid\r\n", + b"", + ] + ) with patch.object(vm.project, "emit") as emit: await DockerVM._read_interface_statuses(vm, reader) assert emit.call_args_list == [ - call("node.interface_status", { - "project_id": vm.project.id, - "node_id": vm.id, - "adapter_number": 0, - "port_number": 0, - "status": "started", - }), - call("node.interface_status", { - "project_id": vm.project.id, - "node_id": vm.id, - "adapter_number": 0, - "port_number": 0, - "status": "stopped", - }), + call( + "node.interface_status", + { + "project_id": vm.project.id, + "node_id": vm.id, + "adapter_number": 0, + "port_number": 0, + "status": "started", + }, + ), + call( + "node.interface_status", + { + "project_id": vm.project.id, + "node_id": vm.id, + "adapter_number": 0, + "port_number": 0, + "status": "stopped", + }, + ), ] @@ -1441,10 +1367,7 @@ async def test_stop_paused_container(vm): @pytest.mark.asyncio async def test_update(vm): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} original_console = vm.console original_aux = vm.aux @@ -1455,43 +1378,37 @@ async def test_update(vm): await vm.update() mock_query.assert_any_call("DELETE", "containers/e90e34656842", params={"force": 1, "v": 1}) - mock_query.assert_any_call("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { - "CapAdd": ["ALL"], - "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, - { - "Type": "bind", - "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } - ], - "Privileged": True, - "Memory": 0, - "NanoCpus": 0, - "UsernsMode": "host" + mock_query.assert_any_call( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { + "CapAdd": ["ALL"], + "Mounts": [ + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, + { + "Type": "bind", + "Source": os.path.join(vm.working_dir, "etc", "network"), + "Target": "/gns3volumes/etc/network", + }, + ], + "Privileged": True, + "Memory": 0, + "NanoCpus": 0, + "UsernsMode": "host", + }, + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + ) assert vm.console == original_console assert vm.aux == original_aux @@ -1499,10 +1416,7 @@ async def test_update(vm): @pytest.mark.asyncio async def test_update_vnc(vm): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} vm._console_type = "vnc" vm._console = 5900 @@ -1523,57 +1437,50 @@ async def test_update_vnc(vm): @pytest.mark.asyncio async def test_update_running(vm): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} original_console = vm.console vm.start = MagicMock() - with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]) as mock_list_images: + with asyncio_patch( + "gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}] + ) as mock_list_images: with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="running"): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock_query: await vm.update() mock_query.assert_any_call("DELETE", "containers/e90e34656842", params={"force": 1, "v": 1}) - mock_query.assert_any_call("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { - "CapAdd": ["ALL"], - "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, - { - "Type": "bind", - "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } - ], - "Privileged": True, - "Memory": 0, - "NanoCpus": 0, - "UsernsMode": "host" + mock_query.assert_any_call( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { + "CapAdd": ["ALL"], + "Mounts": [ + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, + { + "Type": "bind", + "Source": os.path.join(vm.working_dir, "etc", "network"), + "Target": "/gns3volumes/etc/network", + }, + ], + "Privileged": True, + "Memory": 0, + "NanoCpus": 0, + "UsernsMode": "host", + }, + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + ) assert vm.console == original_console assert vm.start.called @@ -1591,10 +1498,7 @@ async def test_delete(vm): @pytest.mark.asyncio async def test_close(vm, port_manager): - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) await vm.adapter_add_nio_binding(0, nio) @@ -1625,11 +1529,7 @@ async def test_close_vnc(vm): @pytest.mark.asyncio async def test_get_namespace(vm): - response = { - "State": { - "Pid": 42 - } - } + response = {"State": {"Pid": 42}} with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock_query: assert await vm._get_namespace() == 42 mock_query.assert_called_with("GET", "containers/e90e34656842/json") @@ -1638,10 +1538,7 @@ async def test_get_namespace(vm): @pytest.mark.asyncio async def test_add_ubridge_connection(vm): - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) nio.start_packet_capture("/tmp/capture.pcap") vm._ubridge_hypervisor = MagicMock() @@ -1649,15 +1546,15 @@ async def test_add_ubridge_connection(vm): await vm._add_ubridge_connection(nio, 0) calls = [ - call.send('bridge create bridge0'), + call.send("bridge create bridge0"), call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"), - call.send('docker move_to_ns tap-gns3-e0 42 eth0'), - call.send('bridge add_nio_udp bridge0 4242 127.0.0.1 4343'), + call.send("docker move_to_ns tap-gns3-e0 42 eth0"), + call.send("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"), call.send('bridge start_capture bridge0 "/tmp/capture.pcap"'), - call.send('bridge start bridge0'), - call.send('bridge set_nio_tap_carrier bridge0 on') + call.send("bridge start bridge0"), + call.send("bridge set_nio_tap_carrier bridge0 on"), ] - assert 'bridge0' in vm._bridges + assert "bridge0" in vm._bridges # We need to check any_order otherwise mock is confused by asyncio vm._ubridge_hypervisor.assert_has_calls(calls, any_order=True) @@ -1670,11 +1567,7 @@ async def test_add_ubridge_connections_with_base_mac_address(vm): vm.adapters = 2 vm.mac_address = "02:42:42:42:42:00" - nio_params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio_params = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio_params) await vm._add_ubridge_connection(nio, 0) @@ -1683,10 +1576,10 @@ async def test_add_ubridge_connections_with_base_mac_address(vm): await vm._add_ubridge_connection(nio, 1) calls = [ - call.send('bridge create bridge0'), - call.send('bridge create bridge1'), - call.send('docker set_mac_addr tap-gns3-e0 02:42:42:42:42:00'), - call.send('docker set_mac_addr tap-gns3-e0 02:42:42:42:42:01') + call.send("bridge create bridge0"), + call.send("bridge create bridge1"), + call.send("docker set_mac_addr tap-gns3-e0 02:42:42:42:42:00"), + call.send("docker set_mac_addr tap-gns3-e0 02:42:42:42:42:01"), ] # We need to check any_order otherwise mock is confused by asyncio @@ -1703,12 +1596,11 @@ async def test_add_ubridge_connection_none_nio(vm): await vm._add_ubridge_connection(nio, 0) calls = [ - call.send('bridge create bridge0'), + call.send("bridge create bridge0"), call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"), - call.send('docker move_to_ns tap-gns3-e0 42 eth0'), - + call.send("docker move_to_ns tap-gns3-e0 42 eth0"), ] - assert 'bridge0' in vm._bridges + assert "bridge0" in vm._bridges # We need to check any_order ortherwise mock is confused by asyncio vm._ubridge_hypervisor.assert_has_calls(calls, any_order=True) @@ -1721,19 +1613,18 @@ async def test_set_adapter_carrier(vm): await vm._set_adapter_carrier(2, True) await vm._set_adapter_carrier(2, False) - vm._ubridge_send.assert_has_calls([ - call("bridge set_nio_tap_carrier bridge2 on"), - call("bridge set_nio_tap_carrier bridge2 off"), - ]) + vm._ubridge_send.assert_has_calls( + [ + call("bridge set_nio_tap_carrier bridge2 on"), + call("bridge set_nio_tap_carrier bridge2 off"), + ] + ) @pytest.mark.asyncio async def test_add_ubridge_connection_invalid_adapter_number(vm): - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) with pytest.raises(DockerError): await vm._add_ubridge_connection(nio, 12) @@ -1742,13 +1633,9 @@ async def test_add_ubridge_connection_invalid_adapter_number(vm): @pytest.mark.asyncio async def test_add_ubridge_connection_no_free_interface(vm): - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) with pytest.raises(DockerError): - # We create fake ethernet interfaces for docker interfaces = ["tap-gns3-e{}".format(index) for index in range(4096)] @@ -1759,10 +1646,7 @@ async def test_add_ubridge_connection_no_free_interface(vm): @pytest.mark.asyncio async def test_adapter_add_nio_binding_1(vm): - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) await vm.adapter_add_nio_binding(0, nio) assert vm._ethernet_adapters[0].get_nio(0) == nio @@ -1771,9 +1655,7 @@ async def test_adapter_add_nio_binding_1(vm): @pytest.mark.asyncio async def test_adapter_add_nio_binding_sets_carrier(vm): - nio = vm.manager.create_nio({ - "type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1" - }) + nio = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) vm.status = "started" vm._ubridge_hypervisor = MagicMock() vm._connect_nio = AsyncioMagicMock() @@ -1788,10 +1670,7 @@ async def test_adapter_add_nio_binding_sets_carrier(vm): async def test_adapter_udpate_nio_binding_bridge_not_started(vm): vm._ubridge_apply_filters = AsyncioMagicMock() - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="running"): await vm.adapter_add_nio_binding(0, nio) @@ -1802,9 +1681,7 @@ async def test_adapter_udpate_nio_binding_bridge_not_started(vm): @pytest.mark.asyncio async def test_adapter_update_nio_binding_sets_suspended_carrier(vm): - nio = vm.manager.create_nio({ - "type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1" - }) + nio = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) nio.suspend = True vm.status = "started" vm._ubridge_hypervisor = MagicMock() @@ -1820,10 +1697,7 @@ async def test_adapter_update_nio_binding_sets_suspended_carrier(vm): @pytest.mark.asyncio async def test_adapter_add_nio_binding_invalid_adapter(vm): - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) with pytest.raises(DockerError): await vm.adapter_add_nio_binding(12, nio) @@ -1835,10 +1709,7 @@ async def test_adapter_remove_nio_binding(vm): vm.ubridge = MagicMock() vm.ubridge.is_running.return_value = True - nio = {"type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1"} + nio = {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} nio = vm.manager.create_nio(nio) await vm.adapter_add_nio_binding(0, nio) vm.status = "started" @@ -1846,9 +1717,9 @@ async def test_adapter_remove_nio_binding(vm): with asyncio_patch("gns3server.compute.docker.DockerVM._ubridge_send") as delete_ubridge_mock: await vm.adapter_remove_nio_binding(0) assert vm._ethernet_adapters[0].get_nio(0) is None - delete_ubridge_mock.assert_any_call('bridge stop bridge0') - delete_ubridge_mock.assert_any_call('bridge remove_nio_udp bridge0 4242 127.0.0.1 4343') - delete_ubridge_mock.assert_any_call('bridge set_nio_tap_carrier bridge0 off') + delete_ubridge_mock.assert_any_call("bridge stop bridge0") + delete_ubridge_mock.assert_any_call("bridge remove_nio_udp bridge0 4242 127.0.0.1 4343") + delete_ubridge_mock.assert_any_call("bridge set_nio_tap_carrier bridge0 off") @pytest.mark.asyncio @@ -1862,7 +1733,9 @@ async def test_adapter_remove_nio_binding_invalid_adapter(vm): async def test_start_capture(vm, tmpdir, manager, free_console_port): output_file = str(tmpdir / "test.pcap") - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) await vm.adapter_add_nio_binding(0, nio) await vm.start_capture(0, output_file) assert vm._ethernet_adapters[0].get_nio(0).capturing @@ -1872,7 +1745,9 @@ async def test_start_capture(vm, tmpdir, manager, free_console_port): async def test_stop_capture(vm, tmpdir, manager, free_console_port): output_file = str(tmpdir / "test.pcap") - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) await vm.adapter_add_nio_binding(0, nio) await vm.start_capture(0, output_file) assert vm._ethernet_adapters[0].get_nio(0).capturing @@ -1884,7 +1759,7 @@ async def test_stop_capture(vm, tmpdir, manager, free_console_port): async def test_get_log(vm): async def read(): - return b'Hello\nWorld' + return b"Hello\nWorld" mock_query = MagicMock() mock_query.read = read @@ -1907,32 +1782,17 @@ async def test_get_image_information(compute_project, manager): @pytest.mark.asyncio async def test_mount_binds(vm): - image_infos = { - "Config": { - "Volumes": { - "/test/experimental": {} - } - } - } + image_infos = {"Config": {"Volumes": {"/test/experimental": {}}}} dst = os.path.join(vm.working_dir, "test/experimental") assert vm._mount_binds(image_infos) == [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" + "Target": "/gns3volumes/etc/network", }, - { - "Type": "bind", - "Source": dst, - "Target": "/gns3volumes/test/experimental" - } + {"Type": "bind", "Source": dst, "Target": "/gns3volumes/test/experimental"}, ] assert vm._volumes == ["/etc/network", "/test/experimental"] @@ -1948,7 +1808,25 @@ async def test_start_vnc(vm): with asyncio_patch("asyncio.create_subprocess_exec") as mock_exec: await vm._start_vnc() assert vm._display is not None - assert mock_exec.call_args[0] == ("/bin/Xtigervnc", "-extension", "MIT-SHM", "-geometry", vm.console_resolution, "-depth", "16", "-interface", "127.0.0.1", "-rfbport", str(vm.console), "-AlwaysShared", "-SecurityTypes", "None", "-desktop", "test", ":{}".format(vm._display)) + assert mock_exec.call_args[0] == ( + "/bin/Xtigervnc", + "-extension", + "MIT-SHM", + "-geometry", + vm.console_resolution, + "-depth", + "16", + "-interface", + "127.0.0.1", + "-rfbport", + str(vm.console), + "-AlwaysShared", + "-SecurityTypes", + "None", + "-desktop", + "test", + ":{}".format(vm._display), + ) mock_wait.assert_called_with("/tmp/.X11-unix/X{}".format(vm._display)) @@ -1973,7 +1851,7 @@ async def test_start_aux(vm): "/dev/null", stderr=asyncio.subprocess.STDOUT, stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE + stdout=asyncio.subprocess.PIPE, ) @@ -2001,7 +1879,18 @@ async def test_fix_permission(vm): process.returncode = 0 with asyncio_patch("asyncio.subprocess.create_subprocess_exec", return_value=process) as mock_exec: await vm._fix_permissions() - mock_exec.assert_called_with('docker', 'exec', 'e90e34656842', '/gns3/bin/busybox', 'sh', '-c', '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format(os.getuid(), os.getgid()), stderr=asyncio.subprocess.PIPE) + mock_exec.assert_called_with( + "docker", + "exec", + "e90e34656842", + "/gns3/bin/busybox", + "sh", + "-c", + '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format( + os.getuid(), os.getgid() + ), + stderr=asyncio.subprocess.PIPE, + ) assert process.wait.called @@ -2015,7 +1904,18 @@ async def test_fix_permission_not_running(vm): with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_start: with asyncio_patch("asyncio.subprocess.create_subprocess_exec", return_value=process) as mock_exec: await vm._fix_permissions() - mock_exec.assert_called_with('docker', 'exec', 'e90e34656842', '/gns3/bin/busybox', 'sh', '-c', '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format(os.getuid(), os.getgid()), stderr=asyncio.subprocess.PIPE) + mock_exec.assert_called_with( + "docker", + "exec", + "e90e34656842", + "/gns3/bin/busybox", + "sh", + "-c", + '(/gns3/bin/busybox find "/etc" -depth -print0 | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c \'%a:%u:%g:%n\' > "/etc/.gns3_perms") && /gns3/bin/busybox chmod -R u+rX "/etc" && /gns3/bin/busybox chown {}:{} -R "/etc"'.format( + os.getuid(), os.getgid() + ), + stderr=asyncio.subprocess.PIPE, + ) assert mock_start.called assert process.wait.called @@ -2024,6 +1924,7 @@ async def test_fix_permission_not_running(vm): # Ownership reclaim for files the container left owned by root # --------------------------------------------------------------------------- + def _reclaim_proc(returncode=0, stderr=b""): process = MagicMock() @@ -2094,8 +1995,10 @@ async def test_reclaim_fails_when_helper_errors(vm, tmp_path): with patch.object(vm, "_directory_has_foreign_files", return_value=True): with patch.object(vm.manager, "resources_path", return_value="/gns3-share"): - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_reclaim_proc(returncode=1, stderr=b"chown: failed")): + with patch( + "asyncio.subprocess.create_subprocess_exec", + return_value=_reclaim_proc(returncode=1, stderr=b"chown: failed"), + ): assert await vm._reclaim_directory_ownership(str(directory)) is False @@ -2123,11 +2026,14 @@ async def test_delete_retries_after_reclaim(vm): real_rmtree(directory, onerror=onerror) with patch.object(vm, "close", new_callable=AsyncioMagicMock): - with patch.object(vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock, return_value=True) as mock_reclaim: + with patch.object( + vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock, return_value=True + ) as mock_reclaim: # First rmtree hits the root-owned leftovers, the retry (after # the reclaim) succeeds and really deletes the directory. - with patch("gns3server.compute.base_node.shutil.rmtree", - side_effect=rmtree_first_fails_then_deletes) as mock_rmtree: + with patch( + "gns3server.compute.base_node.shutil.rmtree", side_effect=rmtree_first_fails_then_deletes + ) as mock_rmtree: await vm.delete() assert mock_rmtree.call_count == 2 mock_reclaim.assert_called_once_with(vm.working_dir) @@ -2138,8 +2044,7 @@ async def test_delete_reports_root_files_when_reclaim_fails(vm): with patch.object(vm, "close", new_callable=AsyncioMagicMock): with patch.object(vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock, return_value=False): - with patch("gns3server.compute.base_node.shutil.rmtree", - side_effect=OSError("permission denied")): + with patch("gns3server.compute.base_node.shutil.rmtree", side_effect=OSError("permission denied")): with pytest.raises(ComputeError, match="owned by another user"): await vm.delete() @@ -2164,7 +2069,7 @@ async def test_read_console_output_with_binary_mode(vm): input_stream = InputStreamMock() output_stream = MagicMock() - with asyncio_patch('gns3server.compute.docker.docker_vm.DockerVM.stop'): + with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.stop"): await vm._read_console_output(input_stream, output_stream) output_stream.feed_data.assert_called_once_with(b"test") @@ -2172,104 +2077,87 @@ async def test_read_console_output_with_binary_mode(vm): @pytest.mark.asyncio async def test_cpus(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", cpus=0.5) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } + "Target": "/gns3volumes/etc/network", + }, ], "Privileged": True, "Memory": 0, "NanoCpus": 500000000, - "UsernsMode": "host" + "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" @pytest.mark.asyncio async def test_memory(compute_project, manager): - response = { - "Id": "e90e34656806", - "Warnings": [] - } + response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", memory=32) await vm.create() - mock.assert_called_with("POST", "containers/create?name={}".format(vm.docker_name), data={ - "Tty": True, - "OpenStdin": True, - "StdinOnce": False, - "HostConfig": - { + mock.assert_called_with( + "POST", + "containers/create?name={}".format(vm.docker_name), + data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": { "CapAdd": ["ALL"], "Mounts": [ - { - "Type": "bind", - "Source": Docker.resources_path(), - "Target": "/gns3", - "ReadOnly": True - }, + {"Type": "bind", "Source": Docker.resources_path(), "Target": "/gns3", "ReadOnly": True}, { "Type": "bind", "Source": os.path.join(vm.working_dir, "etc", "network"), - "Target": "/gns3volumes/etc/network" - } + "Target": "/gns3volumes/etc/network", + }, ], "Privileged": True, "Memory": 33554432, # 32MB in bytes "NanoCpus": 0, "UsernsMode": "host", }, - "Volumes": {}, - "NetworkDisabled": True, - "Hostname": "test", - "Image": "ubuntu:latest", - "Env": [ - "container=docker", - "GNS3_MAX_ETHERNET=eth0", - "GNS3_VOLUMES=/etc/network" - ], - "Entrypoint": ["/gns3/init.sh"], - "Cmd": ["/bin/sh"] - }) + "Volumes": {}, + "NetworkDisabled": True, + "Hostname": "test", + "Image": "ubuntu:latest", + "Env": ["container=docker", "GNS3_MAX_ETHERNET=eth0", "GNS3_VOLUMES=/etc/network"], + "Entrypoint": ["/gns3/init.sh"], + "Cmd": ["/bin/sh"], + }, + ) assert vm._cid == "e90e34656806" + @pytest.mark.asyncio async def test_stop_exited_container_no_stop_query(vm): @@ -2280,10 +2168,7 @@ async def test_stop_exited_container_no_stop_query(vm): with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query: vm._permissions_fixed = False await vm.stop() - assert not any( - call.args[:2] == ("POST", "containers/e90e34656842/kill") - for call in mock_query.mock_calls - ) + assert not any(call.args[:2] == ("POST", "containers/e90e34656842/kill") for call in mock_query.mock_calls) assert vm.status == "stopped" @@ -2343,8 +2228,14 @@ async def test_create_with_extra_configs_directory_target_rejected(compute_proje for bad in ("/", "/etc/", "///"): with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", - extra_configs=[{"target": bad, "content": "x"}]) + vm = DockerVM( + "test", + str(uuid.uuid4()), + compute_project, + manager, + "ubuntu", + extra_configs=[{"target": bad, "content": "x"}], + ) with pytest.raises(DockerError): await vm.create() @@ -2374,13 +2265,21 @@ async def test_create_warns_when_extra_config_under_volume(compute_project, mana """ import logging + extra_configs = [{"target": "/xr-storage/config/foo.cfg", "content": "x"}] response = {"Id": "e90e34656806", "Warnings": []} with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): - vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", - extra_configs=extra_configs, extra_volumes=["/xr-storage"]) + vm = DockerVM( + "test", + str(uuid.uuid4()), + compute_project, + manager, + "ubuntu", + extra_configs=extra_configs, + extra_volumes=["/xr-storage"], + ) with caplog.at_level(logging.WARNING, logger="gns3server.compute.docker.docker_vm"): await vm.create() diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 4826b00a7..66861c8c5 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -72,13 +72,20 @@ async def manager(port_manager): return m -def _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1", - extra_volumes=None, adapters=4, console_type="telnet"): +def _make_vm( + compute_project, manager, environment="GNS3_IOL_RUNNER=1", extra_volumes=None, adapters=4, console_type="telnet" +): """Build an IOLDockerVM with a fake cid (no create() called).""" vm = IOLDockerVM( - "iol-xe-1", str(uuid.uuid4()), compute_project, manager, "iol-xe/iol-xe:17-18-02", - console_type=console_type, environment=environment, - extra_volumes=extra_volumes or [], adapters=adapters, + "iol-xe-1", + str(uuid.uuid4()), + compute_project, + manager, + "iol-xe/iol-xe:17-18-02", + console_type=console_type, + environment=environment, + extra_volumes=extra_volumes or [], + adapters=adapters, ) vm._cid = "e90e34656842" # mirrors the controller flow, which always delivers an allocated id @@ -127,40 +134,37 @@ def _wiring_dir(vm): # Factory selection # --------------------------------------------------------------------------- + def test_factory_selects_iol_for_env_marker(manager): - assert manager._select_node_class(console_type="telnet", - environment="GNS3_IOL_RUNNER=1") is IOLDockerVM + assert manager._select_node_class(console_type="telnet", environment="GNS3_IOL_RUNNER=1") is IOLDockerVM def test_factory_tolerates_whitespace_and_comma(manager): - assert manager._select_node_class(console_type="telnet", - environment=" GNS3_IOL_RUNNER=1,\nFOO=bar") is IOLDockerVM + assert manager._select_node_class(console_type="telnet", environment=" GNS3_IOL_RUNNER=1,\nFOO=bar") is IOLDockerVM def test_factory_docker_exec_wins_over_iol_marker(manager): - assert manager._select_node_class(console_type="docker_exec", - environment="GNS3_IOL_RUNNER=1") is VendorDockerVM + assert manager._select_node_class(console_type="docker_exec", environment="GNS3_IOL_RUNNER=1") is VendorDockerVM def test_factory_plain_environment_is_base(manager): - assert manager._select_node_class(console_type="telnet", - environment="FOO=bar\nGNS3_BAZ=nope") is DockerVM + assert manager._select_node_class(console_type="telnet", environment="FOO=bar\nGNS3_BAZ=nope") is DockerVM def test_factory_generic_unix_knob_selects_vendor(manager): - assert manager._select_node_class(console_type="telnet", - environment="GNS3_UNIX_SOCKET_NIO=1") is VendorDockerVM + assert manager._select_node_class(console_type="telnet", environment="GNS3_UNIX_SOCKET_NIO=1") is VendorDockerVM # --------------------------------------------------------------------------- # Knob parsing # --------------------------------------------------------------------------- + def test_marker_forces_skip_init_and_unix_nio(compute_project, manager): vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1") @@ -172,12 +176,10 @@ def test_marker_forces_skip_init_and_unix_nio(compute_project, manager): def test_iol_memory_knob(compute_project, manager): - vm = _make_vm(compute_project, manager, - environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=4096") + vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=4096") assert vm._iol_memory == 4096 - vm = _make_vm(compute_project, manager, - environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=notanumber") + vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=notanumber") assert vm._iol_memory == 2048 @@ -185,15 +187,13 @@ def test_iol_memory_knob(compute_project, manager): # create() # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_create_keeps_image_entrypoint(compute_project, manager): - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "iol-xe"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=_create_response()) as mock: - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_seed_proc()): + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=_create_response()) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=_seed_proc()): vm = _make_vm(compute_project, manager) await vm.create() sent = mock.call_args.kwargs["data"] @@ -205,12 +205,9 @@ async def test_create_keeps_image_entrypoint(compute_project, manager): @pytest.mark.asyncio async def test_create_auto_adds_config_and_tmp_run_volumes(compute_project, manager): - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "iol-xe"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=_create_response()) as mock: - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_seed_proc()): + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=_create_response()) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=_seed_proc()): vm = _make_vm(compute_project, manager, extra_volumes=[]) await vm.create() sent = mock.call_args.kwargs["data"] @@ -235,12 +232,9 @@ async def test_create_start_command_becomes_runner_flags(compute_project, manage vm = _make_vm(compute_project, manager) vm.start_command = "-keep" - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "iol-xe"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=_create_response()) as mock: - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_seed_proc()): + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=_create_response()) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=_seed_proc()): await vm.create() sent = mock.call_args.kwargs["data"] # start_command is the container CMD = extra iol-runner flags @@ -251,6 +245,7 @@ async def test_create_start_command_becomes_runner_flags(compute_project, manage # start() — runtime preparation # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_start_writes_iol_config(compute_project, manager): @@ -402,6 +397,7 @@ async def test_restart_is_graceful_stop_then_start(compute_project, manager): # Wiring — unix-socket NIO # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_add_ubridge_connection_unix_wiring(compute_project, manager): @@ -463,8 +459,7 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man async def raise_timeout(path, timeout=60): raise asyncio.TimeoutError() - with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", - side_effect=raise_timeout): + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", side_effect=raise_timeout): with pytest.raises(DockerError) as excinfo: await vm._add_ubridge_connection(None, 0) # the message names the adapter and the exact wiring path @@ -497,6 +492,7 @@ async def test_add_ubridge_connection_without_nio_still_wires(compute_project, m # IOU-style port model — 1 adapter = 4 ethernet ports # --------------------------------------------------------------------------- + def test_adapters_are_four_port_units(compute_project, manager): vm = _make_vm(compute_project, manager, adapters=2) @@ -523,7 +519,7 @@ async def test_wiring_addresses_ports_within_adapters(compute_project, manager): await vm._add_ubridge_connection(nio, 1, port_number=2) flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) - assert 'bridge add_nio_unix bridge1_2 ' in flat + assert "bridge add_nio_unix bridge1_2 " in flat assert f'"{os.path.join(wiring_dir, "c06.sock")}"' in flat assert f'"{os.path.join(wiring_dir, "s06.sock")}"' in flat assert "add_nio_udp bridge1_2 4242 127.0.0.1 4343" in flat @@ -547,33 +543,59 @@ async def test_nio_binding_rejects_port_out_of_range(compute_project, manager): # Generic GNS3_UNIX_SOCKET_NIO knob on plain VendorDockerVM # --------------------------------------------------------------------------- + def test_env_unix_socket_nio_parsing(compute_project, manager): - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks", + ) assert vm._unix_socket_nio is True assert vm._unix_socket_dir == "/var/run/socks" # invalid dirs are rejected, keeping the default - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=yes\nGNS3_UNIX_SOCKET_DIR=../../etc") + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=yes\nGNS3_UNIX_SOCKET_DIR=../../etc", + ) assert vm._unix_socket_nio is True assert vm._unix_socket_dir == "/tmp" # off by default / explicit off - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", environment="GNS3_SKIP_INIT=1") + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1", + ) assert vm._unix_socket_nio is False def test_unix_socket_dir_bound_from_runtime_dir(compute_project, manager): - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", - extra_volumes=[]) + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", + extra_volumes=[], + ) # the socket directory is an ephemeral per-node directory from the # runtime dir, not a volume: writable by the (unprivileged) agent and # short enough for AF_UNIX @@ -584,10 +606,16 @@ def test_unix_socket_dir_bound_from_runtime_dir(compute_project, manager): assert socket_binds[0]["Source"] == _wiring_dir(vm) # a socket dir already covered by a persisted volume gets no extra bind - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", - extra_volumes=["/tmp"]) + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", + extra_volumes=["/tmp"], + ) binds = vm._mount_binds({"Config": {"Volumes": {}}}) assert not any(b.get("Source") == _wiring_dir(vm) for b in binds) assert any(b.get("Target") == "/tmp" for b in binds) @@ -596,9 +624,15 @@ def test_unix_socket_dir_bound_from_runtime_dir(compute_project, manager): @pytest.mark.asyncio async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manager): - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks", + ) _mock_wiring(vm) wiring_dir = _wiring_dir(vm) os.makedirs(wiring_dir, exist_ok=True) @@ -659,10 +693,19 @@ async def test_tap_wired_vendor_keeps_carrier_and_monitor(compute_project, manag behavior: the guards only apply to unix-socket NIO. """ - vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", - console_type="docker_exec", environment="GNS3_SKIP_INIT=1") - with patch.object(DockerVM, "_set_adapter_carrier", new=AsyncioMagicMock()) as carrier, \ - patch.object(DockerVM, "_start_interface_monitor", new=AsyncioMagicMock()) as monitor: + vm = VendorDockerVM( + "vendor-1", + str(uuid.uuid4()), + compute_project, + manager, + "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1", + ) + with ( + patch.object(DockerVM, "_set_adapter_carrier", new=AsyncioMagicMock()) as carrier, + patch.object(DockerVM, "_start_interface_monitor", new=AsyncioMagicMock()) as monitor, + ): await vm._set_adapter_carrier(0, True) await vm._start_interface_monitor() # the base-class attribute is replaced by a plain mock (no descriptor diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index e453a1b62..2839e93c8 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -50,6 +50,7 @@ from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Err # Helpers / fixtures # --------------------------------------------------------------------------- + def _create_response(vm, entrypoint=None, volumes=None): """Build the Docker /containers/create response (with image info merged).""" return { @@ -71,13 +72,18 @@ async def manager(port_manager): return m -def _make_vm(compute_project, manager, environment=None, console_type="docker_exec", - extra_volumes=None, adapters=4): +def _make_vm(compute_project, manager, environment=None, console_type="docker_exec", extra_volumes=None, adapters=4): """Build a VendorDockerVM with a fake cid (no create() called).""" vm = VendorDockerVM( - "srlinux-1", str(uuid.uuid4()), compute_project, manager, "srlinux:latest", - console_type=console_type, environment=environment, - extra_volumes=extra_volumes or [], adapters=adapters, + "srlinux-1", + str(uuid.uuid4()), + compute_project, + manager, + "srlinux:latest", + console_type=console_type, + environment=environment, + extra_volumes=extra_volumes or [], + adapters=adapters, ) vm._cid = "e90e34656842" # Interface monitoring belongs to DockerVM and is covered by its focused @@ -91,6 +97,7 @@ def _make_vm(compute_project, manager, environment=None, console_type="docker_ex # Factory selection # --------------------------------------------------------------------------- + def test_factory_selects_vendor_when_docker_exec(manager): assert manager._select_node_class(console_type="docker_exec") is VendorDockerVM @@ -123,12 +130,10 @@ async def test_create_node_sets_node_class(manager, compute_project, monkeypatch fake_super_create_node, ) - await manager.create_node("v", compute_project.id, str(uuid.uuid4()), - "srlinux:latest", console_type="docker_exec") + await manager.create_node("v", compute_project.id, str(uuid.uuid4()), "srlinux:latest", console_type="docker_exec") assert captured["cls"] is VendorDockerVM - await manager.create_node("v", compute_project.id, str(uuid.uuid4()), - "ubuntu:latest", console_type="telnet") + await manager.create_node("v", compute_project.id, str(uuid.uuid4()), "ubuntu:latest", console_type="telnet") assert captured["cls"] is DockerVM @@ -136,6 +141,7 @@ async def test_create_node_sets_node_class(manager, compute_project, monkeypatch # GNS3_* env parsing # --------------------------------------------------------------------------- + def test_env_skip_init_true(compute_project, manager): vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") @@ -150,24 +156,23 @@ def test_env_skip_init_absent_defaults_true(compute_project, manager): def test_env_interface_names(compute_project, manager): - vm = _make_vm(compute_project, manager, - environment="GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") + vm = _make_vm(compute_project, manager, environment="GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") assert vm._interface_names == ["mgmt0", "e1-1", "e1-2", "e1-3"] def test_env_console_cmd(compute_project, manager): - vm = _make_vm(compute_project, manager, - environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli") + vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli") assert vm._console_cmd == "/opt/srlinux/bin/sr_cli" def test_env_multiple_lines(compute_project, manager): - vm = _make_vm(compute_project, manager, - environment=("GNS3_SKIP_INIT=1\n" - "GNS3_INTERFACE_NAMES=mgmt0,e1-1\n" - "GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli\n")) + vm = _make_vm( + compute_project, + manager, + environment=("GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1\nGNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli\n"), + ) assert vm._gns3_init is False assert vm._interface_names == ["mgmt0", "e1-1"] assert vm._console_cmd == "/opt/srlinux/bin/sr_cli" @@ -183,18 +188,22 @@ def test_env_console_cmd_default_none(compute_project, manager): # create() — init.sh skip, GNS3_MAX_ETHERNET, /etc/network drop # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_create_skip_init_omits_init_sh(compute_project, manager): response = _create_response(None, entrypoint=["/init"]) - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "srlinux"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=response) as mock: - vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, - manager, "srlinux:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1") + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = VendorDockerVM( + "srlinux-1", + str(uuid.uuid4()), + compute_project, + manager, + "srlinux:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1", + ) await vm.create() # the Entrypoint must NOT contain /gns3/init.sh sent = mock.call_args.kwargs["data"] @@ -206,13 +215,11 @@ async def test_create_skip_init_omits_init_sh(compute_project, manager): async def test_create_without_skip_init_prepends_init_sh(compute_project, manager): response = _create_response(None, entrypoint=["/init"]) - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "srlinux"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=response) as mock: - vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, - manager, "srlinux:latest", - console_type="docker_exec") + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = VendorDockerVM( + "srlinux-1", str(uuid.uuid4()), compute_project, manager, "srlinux:latest", console_type="docker_exec" + ) await vm.create() sent = mock.call_args.kwargs["data"] # init.sh IS prepended when not skipping @@ -223,14 +230,18 @@ async def test_create_without_skip_init_prepends_init_sh(compute_project, manage async def test_create_interface_names_sets_max_ethernet(compute_project, manager): response = _create_response(None) - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "srlinux"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=response) as mock: - vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, - manager, "srlinux:latest", adapters=4, - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = VendorDockerVM( + "srlinux-1", + str(uuid.uuid4()), + compute_project, + manager, + "srlinux:latest", + adapters=4, + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3", + ) await vm.create() sent = mock.call_args.kwargs["data"] # last interface (adapter index 3) should be e1-3, not eth3 @@ -244,17 +255,19 @@ async def test_create_drops_etc_network_for_skip_init(compute_project, manager): seed_proc = MagicMock() seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid", b"")) seed_proc.returncode = 0 - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "srlinux"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=response) as mock: - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=seed_proc): - vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, - manager, "srlinux:latest", - console_type="docker_exec", - environment="GNS3_SKIP_INIT=1", - extra_volumes=["/etc/opt/srlinux"]) + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=seed_proc): + vm = VendorDockerVM( + "srlinux-1", + str(uuid.uuid4()), + compute_project, + manager, + "srlinux:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1", + extra_volumes=["/etc/opt/srlinux"], + ) await vm.create() sent = mock.call_args.kwargs["data"] targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] @@ -277,13 +290,11 @@ async def test_create_drops_etc_network_for_skip_init(compute_project, manager): async def test_create_keeps_etc_network_without_skip_init(compute_project, manager): response = _create_response(None) - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "srlinux"}]): - with asyncio_patch("gns3server.compute.docker.Docker.query", - return_value=response) as mock: - vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, - manager, "srlinux:latest", - console_type="docker_exec") + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = VendorDockerVM( + "srlinux-1", str(uuid.uuid4()), compute_project, manager, "srlinux:latest", console_type="docker_exec" + ) await vm.create() sent = mock.call_args.kwargs["data"] targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] @@ -294,11 +305,11 @@ async def test_create_keeps_etc_network_without_skip_init(compute_project, manag # Interface renaming (_get_container_ifname / move_to_ns) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_move_to_ns_uses_renamed_interface(compute_project, manager): - vm = _make_vm(compute_project, manager, - environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") vm._ubridge_hypervisor = MagicMock() vm._namespace = 42 nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) @@ -325,6 +336,7 @@ async def test_move_to_ns_falls_back_to_eth(compute_project, manager): # start() — docker_exec console dispatch + volume bridge + permission fix # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_start_docker_exec_dispatches_console(compute_project, manager): @@ -373,6 +385,7 @@ async def test_start_without_skip_init_skips_vendor_passes(compute_project, mana # _fix_permissions — container-side, skips dead containers, targets volume paths # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_fix_permissions_skips_dead_container(compute_project, manager): @@ -411,8 +424,7 @@ async def test_fix_permissions_targets_volume_paths(compute_project, manager): proc.stderr = MagicMock() proc.stderr.read = AsyncioMagicMock(return_value=b"") - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=proc) as mock_exec: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=proc) as mock_exec: await vm._fix_permissions() # one exec per volume assert mock_exec.call_count == 2 @@ -422,13 +434,14 @@ async def test_fix_permissions_targets_volume_paths(compute_project, manager): script = call_obj.args[-1] # last positional arg is the sh -c script assert "/gns3volumes" not in script assert '"/etc/opt/srlinux"' in script or '"/var/log/srlinux"' in script - assert 'chown' in script + assert "chown" in script # --------------------------------------------------------------------------- # _prepare_volumes — host-side seeding (docker create + cp + rm) # --------------------------------------------------------------------------- + def _seed_proc(stdout=b"seedcid\n", returncode=0): proc = MagicMock() proc.communicate = AsyncioMagicMock(return_value=(stdout, b"")) @@ -439,12 +452,10 @@ def _seed_proc(stdout=b"seedcid\n", returncode=0): @pytest.mark.asyncio async def test_prepare_volumes_seeds_unmarked_volume(compute_project, manager): - vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", - extra_volumes=["/etc/opt/srlinux"]) + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", extra_volumes=["/etc/opt/srlinux"]) image_info = {"Config": {"Volumes": {}}} - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_seed_proc()) as mock_exec: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=_seed_proc()) as mock_exec: await vm._prepare_volumes(image_info) # docker create + docker cp + docker rm assert mock_exec.call_count == 3 @@ -465,8 +476,7 @@ async def test_prepare_volumes_never_overwrites_marked_volume(compute_project, m """Regression guard: a volume that ever started (marker present) holds the node's saved configuration — re-seeding would reset it to factory.""" - vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", - extra_volumes=["/etc/opt/srlinux"]) + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", extra_volumes=["/etc/opt/srlinux"]) host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux") os.makedirs(host_dir, exist_ok=True) marker = os.path.join(host_dir, ".gns3_perms") @@ -475,8 +485,7 @@ async def test_prepare_volumes_never_overwrites_marked_volume(compute_project, m with open(saved, "w") as f: f.write('{"user": "config"}') - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_seed_proc()) as mock_exec: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=_seed_proc()) as mock_exec: await vm._prepare_volumes({"Config": {"Volumes": {}}}) mock_exec.assert_not_called() with open(saved) as f: @@ -488,8 +497,7 @@ async def test_prepare_volumes_tolerates_missing_image_path(compute_project, man """A volume path the image does not contain (e.g. XRd's /xr-storage-shadow) starts empty — cp fails, the marker is still written, no raise.""" - vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", - extra_volumes=["/xr-storage-shadow"]) + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", extra_volumes=["/xr-storage-shadow"]) calls = {"n": 0} def proc_factory(*args, **kwargs): @@ -500,8 +508,7 @@ async def test_prepare_volumes_tolerates_missing_image_path(compute_project, man calls["n"] += 1 return proc - with patch("asyncio.subprocess.create_subprocess_exec", - side_effect=proc_factory): + with patch("asyncio.subprocess.create_subprocess_exec", side_effect=proc_factory): await vm._prepare_volumes({"Config": {"Volumes": {}}}) assert calls["n"] == 3 # rm still ran (finally path) host_dir = os.path.join(vm.working_dir, "xr-storage-shadow") @@ -512,8 +519,7 @@ async def test_prepare_volumes_tolerates_missing_image_path(compute_project, man async def test_prepare_volumes_skips_without_skip_init(compute_project, manager): vm = _make_vm(compute_project, manager) # no SKIP_INIT - with patch("asyncio.subprocess.create_subprocess_exec", - return_value=_seed_proc()) as mock_exec: + with patch("asyncio.subprocess.create_subprocess_exec", return_value=_seed_proc()) as mock_exec: await vm._prepare_volumes({"Config": {"Volumes": {"/etc/opt/srlinux": None}}}) mock_exec.assert_not_called() @@ -523,8 +529,7 @@ async def test_prepare_volumes_raises_when_seed_container_fails(compute_project, """If `docker create` itself fails, creation must abort loudly instead of binding an empty directory over the NOS's config path.""" - vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", - extra_volumes=["/etc/opt/srlinux"]) + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", extra_volumes=["/etc/opt/srlinux"]) proc = _seed_proc(stdout=b"", returncode=1) with patch("asyncio.subprocess.create_subprocess_exec", return_value=proc): @@ -536,6 +541,7 @@ async def test_prepare_volumes_raises_when_seed_container_fails(compute_project, # _cleanup_console_resources # --------------------------------------------------------------------------- + def test_cleanup_console_resources_closes_writer(compute_project, manager): vm = _make_vm(compute_project, manager) @@ -558,11 +564,15 @@ def test_cleanup_console_resources_no_writer(compute_project, manager): # _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic # --------------------------------------------------------------------------- + def _make_lazy_server(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"): """Build a _LazyExecTelnetServer with _create_exec mocked out (no docker).""" vm = _make_vm(compute_project, manager, environment=environment) srv = _LazyExecTelnetServer( - vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli", + vm, + manager, + "e90e34656842", + "/opt/srlinux/bin/sr_cli", allow_resize=vm._console_resize, ) srv._create_exec = AsyncioMagicMock() @@ -655,7 +665,8 @@ async def test_client_naws_ignored_when_resize_disabled(compute_project, manager geometry (paging CLIs need the tall default for concurrent netmiko).""" srv = _make_lazy_server( - compute_project, manager, + compute_project, + manager, environment="GNS3_CONSOLE_RESIZE=0", ) assert srv._allow_resize is False @@ -784,6 +795,7 @@ async def test_create_exec_cmd_has_no_while_true(compute_project, manager): # Container termination (graceful stop for vendor NOS) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_terminate_container_graceful_stop(compute_project, manager): """With graceful=True (explicit user stop) vendor containers are SIGTERMed @@ -797,8 +809,7 @@ async def test_terminate_container_graceful_stop(compute_project, manager): await vm._terminate_container(graceful=True) - manager.http_query.assert_called_once_with( - "POST", "containers/e90e34656842/stop", params={"t": 60}, timeout=90) + manager.http_query.assert_called_once_with("POST", "containers/e90e34656842/stop", params={"t": 60}, timeout=90) manager.query.assert_not_called() # no kill on the graceful path @@ -826,8 +837,7 @@ async def test_terminate_container_already_stopped_is_silent(compute_project, ma from gns3server.compute.docker.docker_error import DockerHttp304Error vm = _make_vm(compute_project, manager) - manager.http_query = AsyncioMagicMock( - side_effect=DockerHttp304Error("Docker has returned an error: 304")) + manager.http_query = AsyncioMagicMock(side_effect=DockerHttp304Error("Docker has returned an error: 304")) await vm._terminate_container(graceful=True) # must not raise @@ -840,13 +850,9 @@ async def test_stop_uses_graceful_termination(compute_project, manager): vm = _make_vm(compute_project, manager) with patch.object(DockerVM, "_clean_servers", new=AsyncioMagicMock()): with patch.object(DockerVM, "_stop_ubridge", new=AsyncioMagicMock()): - with patch.object( - DockerVM, "_get_container_state", new=AsyncioMagicMock(return_value="running") - ): + with patch.object(DockerVM, "_get_container_state", new=AsyncioMagicMock(return_value="running")): vm._permissions_fixed = True - with patch.object( - VendorDockerVM, "_terminate_container", new=AsyncioMagicMock() - ) as mock_term: + with patch.object(VendorDockerVM, "_terminate_container", new=AsyncioMagicMock()) as mock_term: await vm.stop() mock_term.assert_called_once_with(graceful=False) @@ -880,8 +886,7 @@ async def test_terminate_container_uses_env_timeout(compute_project, manager): await vm._terminate_container(graceful=True) - manager.http_query.assert_called_once_with( - "POST", "containers/e90e34656842/stop", params={"t": 120}, timeout=150) + manager.http_query.assert_called_once_with("POST", "containers/e90e34656842/stop", params={"t": 120}, timeout=150) @pytest.mark.asyncio @@ -890,13 +895,11 @@ async def test_create_reparse_refreshes_env_knobs(compute_project, manager): not on the next project reload: create() re-parses the vendor knobs.""" response = _create_response(None, entrypoint=["/init"]) - vm = _make_vm(compute_project, manager, - environment="GNS3_SKIP_INIT=1\nGNS3_STOP_TIMEOUT=120") + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1\nGNS3_STOP_TIMEOUT=120") assert vm._gns3_init is False and vm._stop_timeout == 120 vm._environment = "GNS3_STOP_TIMEOUT=5" # knob removed + value changed - with asyncio_patch("gns3server.compute.docker.Docker.list_images", - return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "srlinux"}]): with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): await vm.create() diff --git a/tests/compute/dynamips/test_dynamips_manager.py b/tests/compute/dynamips/test_dynamips_manager.py index 26fc280ca..d08a10271 100644 --- a/tests/compute/dynamips/test_dynamips_manager.py +++ b/tests/compute/dynamips/test_dynamips_manager.py @@ -104,30 +104,20 @@ async def test_duplicate_node(manager, compute_project): Duplicate dynamips do nothing it's manage outside the filesystem """ - with asyncio_patch('gns3server.compute.dynamips.nodes.c7200.C7200.create'): - source_node = await manager.create_node( - 'R1', - compute_project.id, - str(uuid.uuid4()), - platform="c7200" - ) - destination_node = await manager.create_node( - 'R2', - compute_project.id, - str(uuid.uuid4()), - platform="c7200" - ) + with asyncio_patch("gns3server.compute.dynamips.nodes.c7200.C7200.create"): + source_node = await manager.create_node("R1", compute_project.id, str(uuid.uuid4()), platform="c7200") + destination_node = await manager.create_node("R2", compute_project.id, str(uuid.uuid4()), platform="c7200") destination_node._hypervisor = AsyncioMagicMock() - with open(os.path.join(source_node.working_dir, 'c3600_i1_nvram'), 'w+') as f: + with open(os.path.join(source_node.working_dir, "c3600_i1_nvram"), "w+") as f: f.write("1") - with open(source_node.startup_config_path, 'w+') as f: - f.write('hostname R1\necho TEST') + with open(source_node.startup_config_path, "w+") as f: + f.write("hostname R1\necho TEST") await manager.duplicate_node(source_node.id, destination_node.id) - assert not os.path.exists(os.path.join(destination_node.working_dir, 'c3600_i1_nvram')) + assert not os.path.exists(os.path.join(destination_node.working_dir, "c3600_i1_nvram")) with open(destination_node.startup_config_path) as f: content = f.read() - assert content == '!\nhostname R2\necho TEST' + assert content == "!\nhostname R2\necho TEST" with pytest.raises(DynamipsError): source_node.status = "started" await manager.duplicate_node(source_node.id, destination_node.id) diff --git a/tests/compute/dynamips/test_ethernet_switch.py b/tests/compute/dynamips/test_ethernet_switch.py index cfe49ddeb..c34be1952 100644 --- a/tests/compute/dynamips/test_ethernet_switch.py +++ b/tests/compute/dynamips/test_ethernet_switch.py @@ -28,10 +28,10 @@ def test_mac_command(): node.nios[0].name = "Ethernet0" node.nios[1] = NIOUDP(55, "127.0.0.1", 56) node.nios[1].name = "Ethernet1" - #node._hypervisor.send = AsyncioMagicMock(return_value=["0050.7966.6801 1 Ethernet0", "0050.7966.6802 1 Ethernet1"]) - #console = EthernetSwitchConsole(node) - #assert async_run(console.mac()) == \ + # node._hypervisor.send = AsyncioMagicMock(return_value=["0050.7966.6801 1 Ethernet0", "0050.7966.6802 1 Ethernet1"]) + # console = EthernetSwitchConsole(node) + # assert async_run(console.mac()) == \ # "Port Mac VLAN\n" \ # "Ethernet0 00:50:79:66:68:01 1\n" \ # "Ethernet1 00:50:79:66:68:02 1\n" - #node._hypervisor.send.assert_called_with("ethsw show_mac_addr_table Test") + # node._hypervisor.send.assert_called_with("ethsw show_mac_addr_table Test") diff --git a/tests/compute/iou/test_iou_vm.py b/tests/compute/iou/test_iou_vm.py index a3ec3318a..05ee7de4b 100644 --- a/tests/compute/iou/test_iou_vm.py +++ b/tests/compute/iou/test_iou_vm.py @@ -67,7 +67,7 @@ def fake_iou_bin(images_dir): path = os.path.join(images_dir, "iou.bin") with open(path, "w+") as f: - f.write('\x7fELF\x01\x01\x01') + f.write("\x7fELF\x01\x01\x01") os.chmod(path, stat.S_IREAD | stat.S_IEXEC) return path @@ -103,7 +103,7 @@ async def test_start(vm): mock_process.communicate = AsyncioMagicMock(return_value=(None, None)) await vm.start() assert vm.is_running() - assert vm.command_line == ' '.join(mock_exec.call_args[0]) + assert vm.command_line == " ".join(mock_exec.call_args[0]) assert vm._check_requirements.called assert vm._check_iou_license.called @@ -183,10 +183,10 @@ async def test_rename_nvram_file(vm): It should rename the nvram file to the correct name before launching the VM """ - with open(os.path.join(vm.working_dir, "nvram_0000{}".format(vm.application_id + 1)), 'w+') as f: + with open(os.path.join(vm.working_dir, "nvram_0000{}".format(vm.application_id + 1)), "w+") as f: f.write("1") - with open(os.path.join(vm.working_dir, "vlan.dat-0000{}".format(vm.application_id + 1)), 'w+') as f: + with open(os.path.join(vm.working_dir, "vlan.dat-0000{}".format(vm.application_id + 1)), "w+") as f: f.write("1") vm._rename_nvram_file() @@ -455,14 +455,18 @@ async def test_build_command(vm): def test_get_startup_config(vm): - content = "service timestamps debug datetime msec\nservice timestamps log datetime msec\nno service password-encryption" + content = ( + "service timestamps debug datetime msec\nservice timestamps log datetime msec\nno service password-encryption" + ) vm.startup_config = content assert vm.startup_config == content def test_update_startup_config(vm): - content = "service timestamps debug datetime msec\nservice timestamps log datetime msec\nno service password-encryption" + content = ( + "service timestamps debug datetime msec\nservice timestamps log datetime msec\nno service password-encryption" + ) vm.startup_config_content = content filepath = os.path.join(vm.working_dir, "startup-config.cfg") assert os.path.exists(filepath) @@ -472,7 +476,9 @@ def test_update_startup_config(vm): def test_update_startup_config_empty(vm): - content = "service timestamps debug datetime msec\nservice timestamps log datetime msec\nno service password-encryption" + content = ( + "service timestamps debug datetime msec\nservice timestamps log datetime msec\nno service password-encryption" + ) vm.startup_config_content = content filepath = os.path.join(vm.working_dir, "startup-config.cfg") assert os.path.exists(filepath) @@ -496,7 +502,7 @@ def test_change_name(vm): path = os.path.join(vm.working_dir, "startup-config.cfg") vm.name = "world" - with open(path, 'w+') as f: + with open(path, "w+") as f: f.write("hostname world") vm.name = "hello" assert vm.name == "hello" @@ -504,7 +510,7 @@ def test_change_name(vm): assert f.read() == "hostname hello" # support hostname not sync vm.name = "alpha" - with open(path, 'w+') as f: + with open(path, "w+") as f: f.write("no service password-encryption\nhostname beta\nno ip icmp rate-limit unreachable") vm.name = "charlie" assert vm.name == "charlie" @@ -526,13 +532,18 @@ async def test_library_check(vm): @pytest.mark.asyncio async def test_enable_l1_keepalives(vm): - with asyncio_patch("gns3server.utils.asyncio.subprocess_check_output", return_value="***************************************************************\n\n-l Enable Layer 1 keepalive messages\n-u UDP port base for distributed networks\n"): + with asyncio_patch( + "gns3server.utils.asyncio.subprocess_check_output", + return_value="***************************************************************\n\n-l Enable Layer 1 keepalive messages\n-u UDP port base for distributed networks\n", + ): command = ["test"] await vm._enable_l1_keepalives(command) assert command == ["test", "-l"] - with asyncio_patch("gns3server.utils.asyncio.subprocess_check_output", return_value="***************************************************************\n\n-u UDP port base for distributed networks\n"): - + with asyncio_patch( + "gns3server.utils.asyncio.subprocess_check_output", + return_value="***************************************************************\n\n-u UDP port base for distributed networks\n", + ): command = ["test"] with pytest.raises(IOUError): await vm._enable_l1_keepalives(command) @@ -543,7 +554,9 @@ async def test_enable_l1_keepalives(vm): async def test_start_capture(vm, tmpdir, manager, free_console_port): output_file = str(tmpdir / "test.pcap") - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) await vm.adapter_add_nio_binding(0, 0, nio) await vm.start_capture(0, 0, output_file) assert vm._adapters[0].get_nio(0).capturing @@ -553,7 +566,9 @@ async def test_start_capture(vm, tmpdir, manager, free_console_port): async def test_stop_capture(vm, tmpdir, manager, free_console_port): output_file = str(tmpdir / "test.pcap") - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) await vm.adapter_add_nio_binding(0, 0, nio) await vm.start_capture(0, 0, output_file) assert vm._adapters[0].get_nio(0).capturing diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index 5917870d7..dcd71c3f9 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -26,8 +26,8 @@ from gns3server.compute.marker.marker_listener import MarkerListener # Registry # --------------------------------------------------------------------------- -class TestMarkerRegistry: +class TestMarkerRegistry: def test_register_and_lookup(self): MarkerManager.reset() mgr = MarkerManager.instance() @@ -85,6 +85,7 @@ class TestMarkerRegistry: # MarkerListener parsing # --------------------------------------------------------------------------- + class FakeMarkerManager: def __init__(self): self.events = [] @@ -100,13 +101,10 @@ class FakeMarkerManager: self.events.append((project_id, event)) def register(self, project_id, node_id, filter_name, link_id, tag): - self._entries[(node_id, filter_name)] = { - "project_id": project_id, "link_id": link_id, "tag": tag - } + self._entries[(node_id, filter_name)] = {"project_id": project_id, "link_id": link_id, "tag": tag} class TestMarkerListener: - def test_parses_valid_mark_datagram(self): fmgr = FakeMarkerManager() fmgr.register("p1", "n1", "f1", "l1", tag=7) @@ -193,9 +191,7 @@ class TestMarkerListener: fmgr.register("p", "n", "f", "registry-link", tag=1) lis = MarkerListener(fmgr) lis.connection_made(None) - lis.datagram_received( - b"MARK 3.0 node=n filter=f link=signal-link tag=1 len=42\n", None - ) + lis.datagram_received(b"MARK 3.0 node=n filter=f link=signal-link tag=1 len=42\n", None) assert fmgr.events[0][1]["link_id"] == "signal-link" def test_link_dash_falls_back_to_registry_link(self): @@ -249,9 +245,9 @@ class TestMarkerListener: # UDP round-trip # --------------------------------------------------------------------------- + @pytest.mark.asyncio class TestMarkerManagerUDP: - async def test_listener_receives_and_dispatches(self): MarkerManager.reset() mgr = MarkerManager.instance() @@ -273,12 +269,8 @@ class TestMarkerManagerUDP: self.transport = transport sp = SendProto() - transport, _ = await loop.create_datagram_endpoint( - lambda: sp, remote_addr=("127.0.0.1", mgr.port) - ) - transport.sendto( - b"MARK 123.456 node=node-rt filter=filt-rt tag=10 len=88\n" - ) + transport, _ = await loop.create_datagram_endpoint(lambda: sp, remote_addr=("127.0.0.1", mgr.port)) + transport.sendto(b"MARK 123.456 node=node-rt filter=filt-rt tag=10 len=88\n") await asyncio.sleep(0.15) transport.close() diff --git a/tests/compute/qemu/test_qcow2.py b/tests/compute/qemu/test_qcow2.py index c75368e9c..dfd86a2e0 100644 --- a/tests/compute/qemu/test_qcow2.py +++ b/tests/compute/qemu/test_qcow2.py @@ -28,10 +28,7 @@ def qemu_img(): We can't use shutil.which because for safety reason we break the PATH to avoid test interacting with real binaries """ - paths = [ - "/usr/bin/qemu-img", - "/usr/local/bin/qemu-img" - ] + paths = ["/usr/bin/qemu-img", "/usr/local/bin/qemu-img"] for path in paths: if os.path.exists(path): return path @@ -60,9 +57,9 @@ def test_invalid_file(): def test_invalid_empty_file(tmpdir): - open(str(tmpdir / 'a'), 'w+').close() + open(str(tmpdir / "a"), "w+").close() with pytest.raises(Qcow2Error): - Qcow2(str(tmpdir / 'a')) + Qcow2(str(tmpdir / "a")) @pytest.mark.skipif(qemu_img() is None, reason="qemu-img is not available") diff --git a/tests/compute/qemu/test_qemu_manager.py b/tests/compute/qemu/test_qemu_manager.py index 663420445..a75bfceaf 100644 --- a/tests/compute/qemu/test_qemu_manager.py +++ b/tests/compute/qemu/test_qemu_manager.py @@ -44,7 +44,10 @@ def fake_qemu_img_binary(tmpdir): @pytest.mark.asyncio async def test_get_qemu_version(): - with asyncio_patch("gns3server.compute.qemu.subprocess_check_output", return_value="QEMU emulator version 2.2.0, Copyright (c) 2003-2008 Fabrice Bellard"): + with asyncio_patch( + "gns3server.compute.qemu.subprocess_check_output", + return_value="QEMU emulator version 2.2.0, Copyright (c) 2003-2008 Fabrice Bellard", + ): version = await Qemu.get_qemu_version("/tmp/qemu-test") assert version == "2.2.0" @@ -61,7 +64,10 @@ async def test_binary_list(monkeypatch, tmpdir): f.write("1") os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) - with asyncio_patch("gns3server.compute.qemu.subprocess_check_output", return_value="QEMU emulator version 2.2.0, Copyright (c) 2003-2008 Fabrice Bellard") as mock: + with asyncio_patch( + "gns3server.compute.qemu.subprocess_check_output", + return_value="QEMU emulator version 2.2.0, Copyright (c) 2003-2008 Fabrice Bellard", + ) as mock: version = "2.2.0" qemus = await Qemu.binary_list() @@ -121,8 +127,8 @@ async def test_get_kvm_archs_kvm_ok(): with patch("os.path.exists", return_value=True): archs = await Qemu.get_kvm_archs() - if platform.machine() == 'x86_64': - assert archs == ['x86_64', 'i386'] + if platform.machine() == "x86_64": + assert archs == ["x86_64", "i386"] else: assert archs == [platform.machine()] diff --git a/tests/compute/qemu/test_qemu_vm.py b/tests/compute/qemu/test_qemu_vm.py index fa16bb560..d5f0842d3 100644 --- a/tests/compute/qemu/test_qemu_vm.py +++ b/tests/compute/qemu/test_qemu_vm.py @@ -102,21 +102,28 @@ async def test_vm(compute_project, manager, fake_qemu_binary): async def test_vm_create(compute_project, manager, fake_qemu_binary): vm = QemuVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, qemu_path=fake_qemu_binary) - fake_img = os.path.join(vm.working_dir, 'hello') - with open(fake_img, 'w+') as f: - f.write('hello') + fake_img = os.path.join(vm.working_dir, "hello") + with open(fake_img, "w+") as f: + f.write("hello") vm._hda_disk_image = fake_img await vm.create() # tests if `create` created md5sums - assert os.path.exists(os.path.join(vm.working_dir, 'hello.md5sum')) + assert os.path.exists(os.path.join(vm.working_dir, "hello.md5sum")) @pytest.mark.asyncio async def test_vm_invalid_qemu_with_platform(compute_project, manager, fake_qemu_binary): - vm = QemuVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, qemu_path="/usr/fake/bin/qemu-system-64", platform="x86_64") + vm = QemuVM( + "test", + "00010203-0405-0607-0809-0a0b0c0d0e0f", + compute_project, + manager, + qemu_path="/usr/fake/bin/qemu-system-64", + platform="x86_64", + ) assert vm.qemu_path == fake_qemu_binary assert vm.platform == "x86_64" @@ -125,7 +132,13 @@ async def test_vm_invalid_qemu_with_platform(compute_project, manager, fake_qemu @pytest.mark.asyncio async def test_vm_invalid_qemu_without_platform(compute_project, manager, fake_qemu_binary): - vm = QemuVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, qemu_path="/usr/fake/bin/qemu-system-x86_64") + vm = QemuVM( + "test", + "00010203-0405-0607-0809-0a0b0c0d0e0f", + compute_project, + manager, + qemu_path="/usr/fake/bin/qemu-system-x86_64", + ) assert vm.qemu_path == fake_qemu_binary assert vm.platform == "x86_64" @@ -149,7 +162,7 @@ async def test_start(vm, running_subprocess_mock): with asyncio_patch("asyncio.create_subprocess_exec", return_value=running_subprocess_mock) as mock: await vm.start() assert vm.is_running() - assert vm.command_line == ' '.join(mock.call_args[0]) + assert vm.command_line == " ".join(mock.call_args[0]) @pytest.mark.asyncio @@ -232,7 +245,9 @@ async def test_suspend(vm, running_subprocess_mock): @pytest.mark.asyncio async def test_add_nio_binding_udp(vm): - nio = Qemu.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = Qemu.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) assert nio.lport == 4242 await vm.adapter_add_nio_binding(0, nio) assert nio.lport == 4242 @@ -241,7 +256,9 @@ async def test_add_nio_binding_udp(vm): @pytest.mark.asyncio async def test_port_remove_nio_binding(vm): - nio = Qemu.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = Qemu.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) await vm.adapter_add_nio_binding(0, nio) await vm.adapter_remove_nio_binding(0) assert vm._ethernet_adapters[0].ports[0] is None @@ -271,9 +288,9 @@ def test_set_qemu_path(vm, tmpdir, fake_qemu_binary): vm.qemu_path = None # Should not crash with unicode characters - path = str(tmpdir / "\u62FF" / "qemu-system-mips") + path = str(tmpdir / "\u62ff" / "qemu-system-mips") - os.makedirs(str(tmpdir / "\u62FF")) + os.makedirs(str(tmpdir / "\u62ff")) # Raise because file doesn't exists with pytest.raises(QemuError): @@ -360,17 +377,38 @@ async def test_disk_options(vm, tmpdir, fake_qemu_img_binary): open(vm._hda_disk_image, "w+").close() open(vm._hdb_disk_image, "w+").close() - with (asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._find_disk_file_format", return_value="qcow2")): + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM._find_disk_file_format", return_value="qcow2"): with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()) as process: options = await vm._disk_options() assert process.called args, kwargs = process.call_args - assert args == (fake_qemu_img_binary, "create", "-o", "backing_file={}".format(vm._hda_disk_image), "-F", "qcow2", "-f", "qcow2", os.path.join(vm.working_dir, "hda_disk.qcow2")) or \ - args == (fake_qemu_img_binary, "create", "-o", "backing_file={}".format(vm._hdb_disk_image), "-F", "qcow2", "-f", "qcow2", os.path.join(vm.working_dir, "hdb_disk.qcow2")) + assert args == ( + fake_qemu_img_binary, + "create", + "-o", + "backing_file={}".format(vm._hda_disk_image), + "-F", + "qcow2", + "-f", + "qcow2", + os.path.join(vm.working_dir, "hda_disk.qcow2"), + ) or args == ( + fake_qemu_img_binary, + "create", + "-o", + "backing_file={}".format(vm._hdb_disk_image), + "-F", + "qcow2", + "-f", + "qcow2", + os.path.join(vm.working_dir, "hdb_disk.qcow2"), + ) assert options == [ - '-drive', 'file=' + os.path.join(vm.working_dir, "hda_disk.qcow2") + ',if=ide,index=0,media=disk,id=drive0', - '-drive', 'file=' + os.path.join(vm.working_dir, "hdb_disk.qcow2") + ',if=none,index=1,media=disk,id=drive1', + "-drive", + "file=" + os.path.join(vm.working_dir, "hda_disk.qcow2") + ",if=ide,index=0,media=disk,id=drive0", + "-drive", + "file=" + os.path.join(vm.working_dir, "hdb_disk.qcow2") + ",if=none,index=1,media=disk,id=drive1", ] @@ -382,7 +420,7 @@ async def test_cdrom_option(vm, tmpdir, fake_qemu_img_binary): options = await vm._build_command() - assert ' '.join(['-cdrom', str(tmpdir / "test.iso")]) in ' '.join(options) + assert " ".join(["-cdrom", str(tmpdir / "test.iso")]) in " ".join(options) @pytest.mark.asyncio @@ -391,7 +429,7 @@ async def test_bios_option(vm, tmpdir, fake_qemu_img_binary): vm._bios_image = str(tmpdir / "test.img") open(vm._bios_image, "w+").close() options = await vm._build_command() - assert ' '.join(['-bios', str(tmpdir / "test.img")]) in ' '.join(options) + assert " ".join(["-bios", str(tmpdir / "test.img")]) in " ".join(options) @pytest.mark.skipif(sys.platform.startswith("win"), reason="Test not working on Windows") @@ -407,14 +445,16 @@ async def test_uefi_boot_mode_option(vm, tmpdir, images_dir, fake_qemu_img_binar else: ovmf_code_path = os.path.join(images_dir, "OVMF_CODE_4M.fd") with open(ovmf_code_path, "w+") as f: - f.write('1') + f.write("1") ovmf_vars_path = os.path.join(images_dir, "OVMF_VARS_4M.fd") with open(ovmf_vars_path, "w+") as f: - f.write('1') + f.write("1") options = await vm._build_command() - assert ' '.join(["-drive", "if=pflash,format=raw,readonly,file={}".format(ovmf_code_path)]) in ' '.join(options) - assert ' '.join(["-drive", "if=pflash,format=raw,file={}".format(os.path.join(vm.working_dir, "OVMF_VARS_4M.fd"))]) in ' '.join(options) + assert " ".join(["-drive", "if=pflash,format=raw,readonly,file={}".format(ovmf_code_path)]) in " ".join(options) + assert " ".join( + ["-drive", "if=pflash,format=raw,file={}".format(os.path.join(vm.working_dir, "OVMF_VARS_4M.fd"))] + ) in " ".join(options) @pytest.mark.asyncio @@ -429,20 +469,20 @@ async def test_uefi_with_bios_image_already_configured(vm, tmpdir, fake_qemu_img @pytest.mark.asyncio async def test_vnc_option(vm, fake_qemu_img_binary): - vm._console_type = 'vnc' + vm._console_type = "vnc" vm._console = 5905 options = await vm._build_command() - assert '-vnc 127.0.0.1:5' in ' '.join(options) + assert "-vnc 127.0.0.1:5" in " ".join(options) @pytest.mark.asyncio async def test_spice_option(vm, fake_qemu_img_binary): - vm._console_type = 'spice' + vm._console_type = "spice" vm._console = 5905 options = await vm._build_command() - assert '-spice addr=127.0.0.1,port=5905,disable-ticketing' in ' '.join(options) - assert '-vga qxl' in ' '.join(options) + assert "-spice addr=127.0.0.1,port=5905,disable-ticketing" in " ".join(options) + assert "-vga qxl" in " ".join(options) @pytest.mark.asyncio @@ -452,9 +492,10 @@ async def test_tpm_option(vm, tmpdir, fake_qemu_img_binary): tpm_sock = os.path.join(vm.temporary_directory, "swtpm.sock") with patch("os.path.exists", return_value=True) as os_path: options = await vm._build_command() - assert '-chardev socket,id=chrtpm,path={}'.format(tpm_sock) in ' '.join(options) - assert '-tpmdev emulator,id=tpm0,chardev=chrtpm' in ' '.join(options) - assert '-device tpm-tis,tpmdev=tpm0' in ' '.join(options) + assert "-chardev socket,id=chrtpm,path={}".format(tpm_sock) in " ".join(options) + assert "-tpmdev emulator,id=tpm0,chardev=chrtpm" in " ".join(options) + assert "-device tpm-tis,tpmdev=tpm0" in " ".join(options) + @pytest.mark.asyncio async def test_disk_options_multiple_disk(vm, tmpdir, fake_qemu_img_binary): @@ -477,10 +518,14 @@ async def test_disk_options_multiple_disk(vm, tmpdir, fake_qemu_img_binary): options = await vm._disk_options() assert options == [ - '-drive', 'file=' + os.path.join(vm.working_dir, "hda_disk.qcow2") + ',if=ide,index=0,media=disk,id=drive0', - '-drive', 'file=' + os.path.join(vm.working_dir, "hdb_disk.qcow2") + ',if=ide,index=1,media=disk,id=drive1', - '-drive', 'file=' + os.path.join(vm.working_dir, "hdc_disk.qcow2") + ',if=ide,index=2,media=disk,id=drive2', - '-drive', 'file=' + os.path.join(vm.working_dir, "hdd_disk.qcow2") + ',if=ide,index=3,media=disk,id=drive3' + "-drive", + "file=" + os.path.join(vm.working_dir, "hda_disk.qcow2") + ",if=ide,index=0,media=disk,id=drive0", + "-drive", + "file=" + os.path.join(vm.working_dir, "hdb_disk.qcow2") + ",if=ide,index=1,media=disk,id=drive1", + "-drive", + "file=" + os.path.join(vm.working_dir, "hdc_disk.qcow2") + ",if=ide,index=2,media=disk,id=drive2", + "-drive", + "file=" + os.path.join(vm.working_dir, "hdd_disk.qcow2") + ",if=ide,index=3,media=disk,id=drive3", ] @@ -534,7 +579,6 @@ async def test_control_vm_expect_text(vm, running_subprocess_mock): reader = MagicMock() writer = MagicMock() with asyncio_patch("asyncio.open_connection", return_value=(reader, writer)): - future = asyncio.Future() future.set_result(b"epic product") reader.readline.return_value = future @@ -578,7 +622,7 @@ async def test_build_command(vm, fake_qemu_binary): "-netdev", "socket,id=gns3-0,udp=127.0.0.1:{},localaddr=127.0.0.1:{}".format(nio.rport, nio.lport), "-display", - "none" + "none", ] @@ -648,7 +692,7 @@ async def test_build_command_kvm_2_4(linux_platform, vm, fake_qemu_binary): "e1000,mac={},bus=pci-bridge1,addr=0x00,netdev=gns3-0".format(vm._mac_address), "-netdev", "socket,id=gns3-0,udp=127.0.0.1:{},localaddr=127.0.0.1:{}".format(nio.rport, nio.lport), - "-nographic" + "-nographic", ] @@ -697,10 +741,12 @@ async def test_build_command_two_adapters(vm, fake_qemu_binary): "-netdev", "socket,id=gns3-0,udp=127.0.0.1:{},localaddr=127.0.0.1:{}".format(nio1.rport, nio1.lport), "-device", - "e1000,mac={},bus=pci-bridge1,addr=0x01,netdev=gns3-1".format(int_to_macaddress(macaddress_to_int(vm._mac_address) + 1)), + "e1000,mac={},bus=pci-bridge1,addr=0x01,netdev=gns3-1".format( + int_to_macaddress(macaddress_to_int(vm._mac_address) + 1) + ), "-netdev", "socket,id=gns3-1,udp=127.0.0.1:{},localaddr=127.0.0.1:{}".format(nio2.rport, nio2.lport), - "-nographic" + "-nographic", ] @@ -725,7 +771,6 @@ async def test_build_command_two_adapters_mac_address(vm): mac_1 = int_to_macaddress(macaddress_to_int(vm._mac_address) + 1) assert mac_0[:8] == "00:42:ab" with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()): - cmd = await vm._build_command() assert "e1000,mac={},bus=pci-bridge1,addr=0x00,netdev=gns3-0".format(mac_0) in cmd assert "e1000,mac={},bus=pci-bridge1,addr=0x01,netdev=gns3-1".format(mac_1) in cmd @@ -747,7 +792,7 @@ async def test_build_command_large_number_of_adapters(vm): cmd = await vm._build_command() # Count if we have 100 e1000 adapters in the command - assert len([l for l in cmd if "e1000" in l ]) == 100 + assert len([l for l in cmd if "e1000" in l]) == 100 assert len(vm._ethernet_adapters) == 100 assert "e1000,mac={},bus=pci-bridge1,addr=0x00,netdev=gns3-0".format(mac_0) in cmd diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 6420d905b..db2418d76 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -113,7 +113,9 @@ def test_aux(compute_project, manager, port_manager): aux = port_manager.get_free_tcp_port(compute_project) port_manager.release_tcp_port(aux, compute_project) - node = DockerVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, "ubuntu", aux=aux, aux_type="telnet") + node = DockerVM( + "test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, "ubuntu", aux=aux, aux_type="telnet" + ) assert node.aux == aux node.aux = None assert node.aux is None @@ -125,13 +127,17 @@ def test_allocate_aux(compute_project, manager): assert node.aux is None # Docker has an aux port by default - node = DockerVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, "ubuntu", aux_type="telnet") + node = DockerVM( + "test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, "ubuntu", aux_type="telnet" + ) assert node.aux is not None def test_change_aux_port(compute_project, manager, port_manager): - node = DockerVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, "ubuntu", aux_type="telnet") + node = DockerVM( + "test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, "ubuntu", aux_type="telnet" + ) port1 = port_manager.get_free_tcp_port(node.project) port2 = port_manager.get_free_tcp_port(node.project) port_manager.release_tcp_port(port1, node.project) @@ -145,25 +151,20 @@ def test_change_aux_port(compute_project, manager, port_manager): @pytest.mark.asyncio async def test_update_ubridge_udp_connection(node): - filters = { - "latency": [10] - } + filters = {"latency": [10]} snio = NIOUDP(1245, "localhost", 1246) dnio = NIOUDP(1245, "localhost", 1244) dnio.filters = filters with asyncio_patch("gns3server.compute.base_node.BaseNode._ubridge_apply_filters") as mock: - await node.update_ubridge_udp_connection('VPCS-10', snio, dnio) + await node.update_ubridge_udp_connection("VPCS-10", snio, dnio) mock.assert_called_with("VPCS-10", filters) @pytest.mark.asyncio async def test_ubridge_apply_filters(node): - filters = OrderedDict(( - ('latency', [10]), - ('bpf', ["icmp[icmptype] == 8\ntcp src port 53"]) - )) + filters = OrderedDict((("latency", [10]), ("bpf", ["icmp[icmptype] == 8\ntcp src port 53"]))) node._ubridge_send = AsyncioMagicMock() await node._ubridge_apply_filters("VPCS-10", filters) node._ubridge_send.assert_any_call("bridge reset_packet_filters VPCS-10") @@ -173,14 +174,12 @@ async def test_ubridge_apply_filters(node): @pytest.mark.asyncio async def test_ubridge_apply_bpf_filters(node): - filters = { - "bpf": ["icmp[icmptype] == 8\ntcp src port 53"] - } + filters = {"bpf": ["icmp[icmptype] == 8\ntcp src port 53"]} node._ubridge_send = AsyncioMagicMock() await node._ubridge_apply_filters("VPCS-10", filters) node._ubridge_send.assert_any_call("bridge reset_packet_filters VPCS-10") - node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter0 bpf \"icmp[icmptype] == 8\"") - node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter1 bpf \"tcp src port 53\"") + node._ubridge_send.assert_any_call('bridge add_packet_filter VPCS-10 filter0 bpf "icmp[icmptype] == 8"') + node._ubridge_send.assert_any_call('bridge add_packet_filter VPCS-10 filter1 bpf "tcp src port 53"') @pytest.mark.asyncio @@ -259,8 +258,16 @@ async def test_apply_markers_appends_linktype_for_serial(compute_project, manage node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) node._ubridge_send = AsyncioMagicMock() nio = NIOUDP(1234, "127.0.0.1", 4321) - nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", - "direction": None, "data_link_type": "DLT_C_HDLC", "enabled": True}} + nio.markers = { + "m": { + "bpf": "icmp", + "tag": None, + "link_id": "L1", + "direction": None, + "data_link_type": "DLT_C_HDLC", + "enabled": True, + } + } with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: mm.instance.return_value.register = MagicMock() await node._ubridge_apply_markers("VPCS-10", nio) @@ -274,8 +281,16 @@ async def test_apply_markers_omits_linktype_for_ethernet(compute_project, manage node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) node._ubridge_send = AsyncioMagicMock() nio = NIOUDP(1234, "127.0.0.1", 4321) - nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", - "direction": None, "data_link_type": "DLT_EN10MB", "enabled": True}} + nio.markers = { + "m": { + "bpf": "icmp", + "tag": None, + "link_id": "L1", + "direction": None, + "data_link_type": "DLT_EN10MB", + "enabled": True, + } + } with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: mm.instance.return_value.register = MagicMock() await node._ubridge_apply_markers("VPCS-10", nio) @@ -334,8 +349,7 @@ async def test_delete_marker_capture_drops_from_nio_markers(compute_project, man # nio.markers and starting the node reinstalls it (empty pcap reappears). node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) nio = NIOUDP(1234, "127.0.0.1", 4321) - nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", - "direction": None, "enabled": True}} + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", "direction": None, "enabled": True}} await node.delete_marker_capture("m", "L1", nio) assert "m" not in nio.markers @@ -404,16 +418,20 @@ async def test_apply_markers_rebuilds_changed_bpf(compute_project, manager): node._ubridge_hypervisor = MagicMock() node._ubridge_hypervisor.is_running.return_value = True node._marker_filter_bridges["m", "L1"] = "VPCS-10" - node._marker_specs["m", "L1"] = {"bpf": "icmp", "tag": None, "direction": None, - "data_link_type": None, "enabled": True} + node._marker_specs["m", "L1"] = { + "bpf": "icmp", + "tag": None, + "direction": None, + "data_link_type": None, + "enabled": True, + } nio = NIOUDP(1234, "127.0.0.1", 4321) - nio.markers = {"m": {"bpf": "tcp", "tag": None, "link_id": "L1", - "direction": None, "enabled": True}} + nio.markers = {"m": {"bpf": "tcp", "tag": None, "link_id": "L1", "direction": None, "enabled": True}} with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: mm.instance.return_value.register = MagicMock() await node._ubridge_apply_markers("VPCS-10", nio) cmds = [c.args[0] for c in node._ubridge_send.call_args_list] - assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) # old removed + assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) # old removed assert any("add_packet_filter VPCS-10 m mark" in c and "tcp" in c for c in cmds) # new added @@ -441,10 +459,10 @@ async def test_apply_markers_preserves_markers_on_other_bridges(compute_project, mm.instance.return_value.unregister = MagicMock() await node._ubridge_apply_markers("VPCS-10", nio) cmds = [c.args[0] for c in node._ubridge_send.call_args_list] - assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) # L1 removed on its bridge - assert not any("VPCS-20" in c for c in cmds) # other bridge untouched + assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) # L1 removed on its bridge + assert not any("VPCS-20" in c for c in cmds) # other bridge untouched assert ("m", "L1") not in node._marker_filter_bridges - assert ("m", "L2") in node._marker_filter_bridges # L2 preserved + assert ("m", "L2") in node._marker_filter_bridges # L2 preserved @pytest.mark.asyncio @@ -467,6 +485,7 @@ class _GoneConsoleWebsocket: def __init__(self): from types import SimpleNamespace + self.client = SimpleNamespace(host="127.0.0.1", port=5000) async def receive(self): @@ -474,12 +493,14 @@ class _GoneConsoleWebsocket: async def send_bytes(self, data): from starlette.websockets import WebSocketDisconnect + raise WebSocketDisconnect(code=1006) @pytest.mark.asyncio async def test_console_websocket_client_disconnect_while_node_output_streams( - compute_project, manager, port_manager, monkeypatch, caplog): + compute_project, manager, port_manager, monkeypatch, caplog +): # regression test: the client disconnects while the node is still streaming # console output. telnet_forward used to let the (empty-str) WebSocketDisconnect # from send_bytes escape, logging a message-less WARNING. @@ -505,10 +526,7 @@ async def test_console_websocket_client_disconnect_while_node_output_streams( assert telnet_writer.close.called assert not [r for r in caplog.records if r.levelno >= logging.WARNING] - assert any( - "has disconnected from compute console WebSocket while node output" in r.message - for r in caplog.records - ) + assert any("has disconnected from compute console WebSocket while node output" in r.message for r in caplog.records) @pytest.mark.asyncio diff --git a/tests/compute/test_manager.py b/tests/compute/test_manager.py index cddbeb3ea..304fa972d 100644 --- a/tests/compute/test_manager.py +++ b/tests/compute/test_manager.py @@ -81,10 +81,10 @@ def test_get_abs_image_path(qemu, tmpdir, config): os.makedirs(str(tmpdir / "QEMU")) path1 = force_unix_path(str(tmpdir / "test1.bin")) - open(path1, 'w+').close() + open(path1, "w+").close() path2 = force_unix_path(str(tmpdir / "QEMU" / "test2.bin")) - open(path2, 'w+').close() + open(path2, "w+").close() config.settings.Server.images_path = str(tmpdir) assert qemu.get_abs_image_path(path1) == path1 @@ -180,21 +180,21 @@ def test_get_relative_image_path(qemu, tmpdir, config): os.makedirs(str(tmpdir / "images1" / "QEMU")) os.makedirs(str(tmpdir / "images1" / "VBOX")) path1 = force_unix_path(str(tmpdir / "images1" / "test1.bin")) - open(path1, 'w+').close() + open(path1, "w+").close() path2 = force_unix_path(str(tmpdir / "images1" / "QEMU" / "test2.bin")) - open(path2, 'w+').close() + open(path2, "w+").close() os.makedirs(str(tmpdir / "images2")) path3 = force_unix_path(str(tmpdir / "images2" / "test3.bin")) - open(path3, 'w+').close() + open(path3, "w+").close() path4 = force_unix_path(str(tmpdir / "test4.bin")) - open(path4, 'w+').close() + open(path4, "w+").close() # The user use an image of another emulator we return the full path path5 = force_unix_path(str(tmpdir / "images1" / "VBOX" / "test5.bin")) - open(path5, 'w+').close() + open(path5, "w+").close() config.settings.Server.images_path = str(tmpdir / "images1") config.settings.Server.additional_images_paths = str(tmpdir / "images2") @@ -221,9 +221,9 @@ async def test_list_images(qemu, tmpdir): f.write("1234567") with patch("gns3server.utils.images.default_images_directory", return_value=str(tmp_images_dir)): - assert sorted(await qemu.list_images(), key=lambda k: k['filename']) == [ + assert sorted(await qemu.list_images(), key=lambda k: k["filename"]) == [ {"filename": "a.qcow2", "path": "a.qcow2", "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7}, - {"filename": "b.qcow2", "path": "b.qcow2", "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7} + {"filename": "b.qcow2", "path": "b.qcow2", "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7}, ] @@ -243,11 +243,15 @@ async def test_list_images_recursives(qemu, tmpdir): f.write("1234567") with patch("gns3server.utils.images.default_images_directory", return_value=str(tmp_images_dir)): - - assert sorted(await qemu.list_images(), key=lambda k: k['filename']) == [ + assert sorted(await qemu.list_images(), key=lambda k: k["filename"]) == [ {"filename": "a.qcow2", "path": "a.qcow2", "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7}, {"filename": "b.qcow2", "path": "b.qcow2", "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7}, - {"filename": "c.qcow2", "path": force_unix_path(os.path.sep.join(["c", "c.qcow2"])), "md5sum": "fcea920f7412b5da7be0cf42b8c93759", "filesize": 7} + { + "filename": "c.qcow2", + "path": force_unix_path(os.path.sep.join(["c", "c.qcow2"])), + "md5sum": "fcea920f7412b5da7be0cf42b8c93759", + "filesize": 7, + }, ] @@ -299,10 +303,10 @@ async def test_duplicate_vpcs(vpcs, compute_project): @pytest.mark.asyncio async def test_duplicate_ethernet_switch(compute_project): - with asyncio_patch('gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create'): + with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create"): dynamips_manager = Dynamips.instance() source_node_id = str(uuid.uuid4()) - await dynamips_manager.create_node("SW-1", compute_project.id, source_node_id, node_type='ethernet_switch') + await dynamips_manager.create_node("SW-1", compute_project.id, source_node_id, node_type="ethernet_switch") destination_node_id = str(uuid.uuid4()) - await dynamips_manager.create_node("SW-2", compute_project.id, destination_node_id, node_type='ethernet_switch') + await dynamips_manager.create_node("SW-2", compute_project.id, destination_node_id, node_type="ethernet_switch") await dynamips_manager.duplicate_node(source_node_id, destination_node_id) diff --git a/tests/compute/test_notification_manager.py b/tests/compute/test_notification_manager.py index b3173429d..3daea1a8c 100644 --- a/tests/compute/test_notification_manager.py +++ b/tests/compute/test_notification_manager.py @@ -34,7 +34,7 @@ async def test_queue(): notifications.emit("test", {"a": 1}) res = await queue.get(5) - assert res == ('test', {"a": 1}, {}) + assert res == ("test", {"a": 1}, {}) assert len(notifications._listeners) == 0 diff --git a/tests/compute/test_project.py b/tests/compute/test_project.py index 50f967925..7380df23f 100644 --- a/tests/compute/test_project.py +++ b/tests/compute/test_project.py @@ -48,8 +48,8 @@ async def node(compute_project, manager): @pytest.mark.asyncio async def test_affect_uuid(): - p = Project(project_id='00010203-0405-0607-0809-0a0b0c0d0e0f') - assert p.id == '00010203-0405-0607-0809-0a0b0c0d0e0f' + p = Project(project_id="00010203-0405-0607-0809-0a0b0c0d0e0f") + assert p.id == "00010203-0405-0607-0809-0a0b0c0d0e0f" @pytest.mark.asyncio @@ -58,14 +58,14 @@ async def test_clean_tmp_directory(): The tmp directory should be clean at project open and close """ - p = Project(project_id='00010203-0405-0607-0809-0a0b0c0d0e0f') + p = Project(project_id="00010203-0405-0607-0809-0a0b0c0d0e0f") path = p.tmp_working_directory() os.makedirs(path) await p.close() assert not os.path.exists(path) os.makedirs(path) - p = Project(project_id='00010203-0405-0607-0809-0a0b0c0d0e0f') + p = Project(project_id="00010203-0405-0607-0809-0a0b0c0d0e0f") assert not os.path.exists(path) @@ -99,11 +99,7 @@ async def test_variables(): async def test_json(): p = Project(project_id=str(uuid4())) - assert p.asdict() == { - "name": p.name, - "project_id": p.id, - "variables": None - } + assert p.asdict() == {"name": p.name, "project_id": p.id, "variables": None} @pytest.mark.asyncio @@ -111,11 +107,7 @@ async def test_json_with_variables(): variables = [{"name": "VAR1", "value": "VAL1"}] p = Project(project_id=str(uuid4()), variables=variables) - assert p.asdict() == { - "name": p.name, - "project_id": p.id, - "variables": variables - } + assert p.asdict() == {"name": p.name, "project_id": p.id, "variables": variables} @pytest.mark.asyncio @@ -123,7 +115,7 @@ async def test_node_working_directory(node, projects_dir): directory = projects_dir p = Project(project_id=str(uuid4())) - assert p.node_working_directory(node) == os.path.join(directory, p.id, 'project-files', node.module_name, node.id) + assert p.node_working_directory(node) == os.path.join(directory, p.id, "project-files", node.module_name, node.id) assert os.path.exists(p.node_working_directory(node)) @@ -132,7 +124,7 @@ async def test_node_working_path(node, projects_dir): directory = projects_dir p = Project(project_id=str(uuid4())) - assert p.node_working_path(node) == os.path.join(directory, p.id, 'project-files', node.module_name, node.id) + assert p.node_working_path(node) == os.path.join(directory, p.id, "project-files", node.module_name, node.id) # after this execution directory structure should not be created assert not os.path.exists(p.node_working_path(node)) @@ -193,14 +185,8 @@ async def test_list_files(): files = await project.list_files() assert files == [ - { - "path": "test.txt", - "md5sum": "ad0234829205b9033196ba818f7a872b" - }, - { - "path": os.path.join("vm-1", "dynamips", "test.bin"), - "md5sum": "098f6bcd4621d373cade4e832627b4f6" - } + {"path": "test.txt", "md5sum": "ad0234829205b9033196ba818f7a872b"}, + {"path": os.path.join("vm-1", "dynamips", "test.bin"), "md5sum": "098f6bcd4621d373cade4e832627b4f6"}, ] diff --git a/tests/compute/test_project_manager.py b/tests/compute/test_project_manager.py index 1d6b50372..fe52e8e22 100644 --- a/tests/compute/test_project_manager.py +++ b/tests/compute/test_project_manager.py @@ -24,12 +24,12 @@ from gns3server.compute.project_manager import ProjectManager def test_create_project(): pm = ProjectManager.instance() - project = pm.create_project(project_id='00010203-0405-0607-0809-0a0b0c0d0e0f') - assert project == pm.get_project('00010203-0405-0607-0809-0a0b0c0d0e0f') + project = pm.create_project(project_id="00010203-0405-0607-0809-0a0b0c0d0e0f") + assert project == pm.get_project("00010203-0405-0607-0809-0a0b0c0d0e0f") def test_project_not_found(): pm = ProjectManager.instance() with pytest.raises(ComputeNotFoundError): - pm.get_project('00010203-0405-0607-0809-000000000000') + pm.get_project("00010203-0405-0607-0809-000000000000") diff --git a/tests/compute/test_statistics.py b/tests/compute/test_statistics.py index 8fb721adc..f7c993674 100644 --- a/tests/compute/test_statistics.py +++ b/tests/compute/test_statistics.py @@ -9,13 +9,15 @@ def test_compute_statistics_preserves_load_average_precision() -> None: swap = SimpleNamespace(total=1024, free=512, used=512, percent=50) disk = SimpleNamespace(total=16384, used=4096, free=12288, percent=25) - with patch("gns3server.api.routes.compute.compute.CpuPercent.get", return_value=10), \ - patch("gns3server.api.routes.compute.compute.get_cpu_model", return_value="Test CPU"), \ - patch("gns3server.api.routes.compute.compute.psutil.virtual_memory", return_value=memory), \ - patch("gns3server.api.routes.compute.compute.psutil.swap_memory", return_value=swap), \ - patch("gns3server.api.routes.compute.compute.psutil.disk_usage", return_value=disk), \ - patch("gns3server.api.routes.compute.compute.psutil.cpu_count", return_value=4), \ - patch("gns3server.api.routes.compute.compute.psutil.getloadavg", return_value=(1.25, 2.5, 3.75)): + with ( + patch("gns3server.api.routes.compute.compute.CpuPercent.get", return_value=10), + patch("gns3server.api.routes.compute.compute.get_cpu_model", return_value="Test CPU"), + patch("gns3server.api.routes.compute.compute.psutil.virtual_memory", return_value=memory), + patch("gns3server.api.routes.compute.compute.psutil.swap_memory", return_value=swap), + patch("gns3server.api.routes.compute.compute.psutil.disk_usage", return_value=disk), + patch("gns3server.api.routes.compute.compute.psutil.cpu_count", return_value=4), + patch("gns3server.api.routes.compute.compute.psutil.getloadavg", return_value=(1.25, 2.5, 3.75)), + ): statistics = compute_statistics() assert statistics["load_average"] == [1.25, 2.5, 3.75] diff --git a/tests/compute/ubridge/test_hypervisor.py b/tests/compute/ubridge/test_hypervisor.py index 7cba76a2c..1ee5a555a 100644 --- a/tests/compute/ubridge/test_hypervisor.py +++ b/tests/compute/ubridge/test_hypervisor.py @@ -50,6 +50,7 @@ def _make(transport, tmp_path, monkeypatch, node_id="abc123", host="127.0.0.1"): # __init__: transport selection # --------------------------------------------------------------------------- + def test_init_unix_creates_socket_dir_and_path(tmp_path, monkeypatch): hyp = _make("unix", tmp_path, monkeypatch, node_id="abc123") @@ -84,6 +85,7 @@ def test_init_tcp_sets_host_port(tmp_path, monkeypatch): # _build_command + endpoint # --------------------------------------------------------------------------- + def test_build_command_unix(tmp_path, monkeypatch): hyp = _make("unix", tmp_path, monkeypatch) @@ -134,6 +136,7 @@ def test_endpoint_tcp(tmp_path, monkeypatch): # stop: AF_UNIX socket cleanup # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_stop_unlinks_unix_socket(tmp_path, monkeypatch): @@ -163,6 +166,7 @@ async def test_stop_tcp_has_no_socket_to_unlink(tmp_path, monkeypatch): # version requirement # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_requires_ubridge_1_2_3(tmp_path, monkeypatch): @@ -194,6 +198,7 @@ async def test_accepts_ubridge_1_2_3(tmp_path, monkeypatch): # start: fail-fast on an immediately-exiting uBridge # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_start_detects_immediate_exit(tmp_path, monkeypatch): # An unsupported flag (e.g. -U on an old ubridge) makes the process exit at @@ -203,8 +208,10 @@ async def test_start_detects_immediate_exit(tmp_path, monkeypatch): proc = MagicMock() proc.pid = 1234 proc.returncode = 2 # already exited - with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \ - patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc): + with ( + patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc), + ): with pytest.raises(UbridgeError, match="exited immediately"): await hyp.start() @@ -216,7 +223,9 @@ async def test_start_proceeds_when_process_keeps_running(tmp_path, monkeypatch): proc = MagicMock() proc.pid = 1234 proc.returncode = None # still running - with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \ - patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc): + with ( + patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc), + ): await hyp.start() # must NOT raise assert hyp._process is proc diff --git a/tests/compute/virtualbox/test_virtualbox_manager.py b/tests/compute/virtualbox/test_virtualbox_manager.py index 54d7e9f1f..48315245c 100644 --- a/tests/compute/virtualbox/test_virtualbox_manager.py +++ b/tests/compute/virtualbox/test_virtualbox_manager.py @@ -76,12 +76,14 @@ def test_vboxmanage_path(manager, config, tmpdir): @pytest.mark.asyncio async def test_list_vms(manager): - vm_list = ['"Windows 8.1" {27b4d095-ff5f-4ac4-bb9d-5f2c7861c1f1}', - '"Carriage', - 'Return" {27b4d095-ff5f-4ac4-bb9d-5f2c7861c3f3}', - '', - '"" {42b4d095-ff5f-4ac4-bb9d-5f2c7861c1f1}', - '"Linux Microcore 4.7.1" {ccd8c50b-c172-457d-99fa-dd69371ede0e}'] + vm_list = [ + '"Windows 8.1" {27b4d095-ff5f-4ac4-bb9d-5f2c7861c1f1}', + '"Carriage', + 'Return" {27b4d095-ff5f-4ac4-bb9d-5f2c7861c3f3}', + "", + '"" {42b4d095-ff5f-4ac4-bb9d-5f2c7861c1f1}', + '"Linux Microcore 4.7.1" {ccd8c50b-c172-457d-99fa-dd69371ede0e}', + ] async def execute_mock(cmd, args): if cmd == "list": @@ -96,7 +98,4 @@ async def test_list_vms(manager): with asyncio_patch("gns3server.compute.virtualbox.VirtualBox.execute") as mock: mock.side_effect = execute_mock vms = await manager.list_vms() - assert vms == [ - {"vmname": "Windows 8.1", "ram": 512}, - {"vmname": "Linux Microcore 4.7.1", "ram": 256} - ] + assert vms == [{"vmname": "Windows 8.1", "ram": 512}, {"vmname": "Linux Microcore 4.7.1", "ram": 256}] diff --git a/tests/compute/virtualbox/test_virtualbox_vm.py b/tests/compute/virtualbox/test_virtualbox_vm.py index 5303834e3..8bf127be0 100644 --- a/tests/compute/virtualbox/test_virtualbox_vm.py +++ b/tests/compute/virtualbox/test_virtualbox_vm.py @@ -99,7 +99,9 @@ async def test_vm_invalid_virtualbox_api_version(compute_project, manager): @pytest.mark.asyncio async def test_vm_adapter_add_nio_binding_adapter_not_exist(vm, manager, free_console_port): - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) with pytest.raises(VirtualBoxError): await vm.adapter_add_nio_binding(15, nio) diff --git a/tests/compute/vmware/test_vmware_vm.py b/tests/compute/vmware/test_vmware_vm.py index 631f07e17..61b53b843 100644 --- a/tests/compute/vmware/test_vmware_vm.py +++ b/tests/compute/vmware/test_vmware_vm.py @@ -58,7 +58,9 @@ async def test_json(vm, tmpdir, compute_project): async def test_start_capture(vm, tmpdir, manager, free_console_port): output_file = str(tmpdir / "test.pcap") - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) vm.adapters = 1 await vm.adapter_add_nio_binding(0, nio) await vm.start_capture(0, output_file) @@ -69,7 +71,9 @@ async def test_start_capture(vm, tmpdir, manager, free_console_port): async def test_stop_capture(vm, tmpdir, manager, free_console_port): output_file = str(tmpdir / "test.pcap") - nio = manager.create_nio({"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": free_console_port, "rport": free_console_port, "rhost": "127.0.0.1"} + ) vm.adapters = 1 await vm.adapter_add_nio_binding(0, nio) await vm.start_capture(0, output_file) diff --git a/tests/compute/vpcs/test_vpcs_vm.py b/tests/compute/vpcs/test_vpcs_vm.py index e85604f2d..65761cbd7 100644 --- a/tests/compute/vpcs/test_vpcs_vm.py +++ b/tests/compute/vpcs/test_vpcs_vm.py @@ -60,7 +60,10 @@ async def test_vm(compute_project, manager): @pytest.mark.asyncio async def test_vm_check_vpcs_version(vm): - with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.subprocess_check_output", return_value="Welcome to Virtual PC Simulator, version 0.9"): + with asyncio_patch( + "gns3server.compute.vpcs.vpcs_vm.subprocess_check_output", + return_value="Welcome to Virtual PC Simulator, version 0.9", + ): await vm._check_vpcs_version() assert vm._vpcs_version == parse_version("0.9") @@ -68,7 +71,10 @@ async def test_vm_check_vpcs_version(vm): @pytest.mark.asyncio async def test_vm_check_vpcs_version_0_6_1(vm): - with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.subprocess_check_output", return_value="Welcome to Virtual PC Simulator, version 0.6.1"): + with asyncio_patch( + "gns3server.compute.vpcs.vpcs_vm.subprocess_check_output", + return_value="Welcome to Virtual PC Simulator, version 0.6.1", + ): await vm._check_vpcs_version() assert vm._vpcs_version == parse_version("0.6.1") @@ -76,9 +82,14 @@ async def test_vm_check_vpcs_version_0_6_1(vm): @pytest.mark.asyncio async def test_vm_invalid_vpcs_version(vm, manager): - with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.subprocess_check_output", return_value="Welcome to Virtual PC Simulator, version 0.1"): + with asyncio_patch( + "gns3server.compute.vpcs.vpcs_vm.subprocess_check_output", + return_value="Welcome to Virtual PC Simulator, version 0.1", + ): with pytest.raises(VPCSError): - nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = manager.create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) await vm.port_add_nio_binding(0, nio) await vm._check_vpcs_version() assert vm.name == "test" @@ -110,22 +121,25 @@ async def test_start(vm): with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): await vm.start() - assert mock_exec.call_args[0] == (vm._vpcs_path(), - '-p', - str(vm._internal_console_port), - '-m', '1', - '-i', - '1', - '-F', - '-R', - '-s', - ANY, - '-c', - ANY, - '-t', - '127.0.0.1') + assert mock_exec.call_args[0] == ( + vm._vpcs_path(), + "-p", + str(vm._internal_console_port), + "-m", + "1", + "-i", + "1", + "-F", + "-R", + "-s", + ANY, + "-c", + ANY, + "-t", + "127.0.0.1", + ) assert vm.is_running() - assert vm.command_line == ' '.join(mock_exec.call_args[0]) + assert vm.command_line == " ".join(mock_exec.call_args[0]) (action, event, kwargs) = await queue.get(1) assert action == "node.updated" assert event == vm @@ -145,22 +159,27 @@ async def test_start_0_6_1(vm): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: - nio = VPCS.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = VPCS.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) await vm.port_add_nio_binding(0, nio) await vm.start() - assert mock_exec.call_args[0] == (vm._vpcs_path(), - '-p', - str(vm._internal_console_port), - '-m', '1', - '-i', - '1', - '-F', - '-s', - ANY, - '-c', - ANY, - '-t', - '127.0.0.1') + assert mock_exec.call_args[0] == ( + vm._vpcs_path(), + "-p", + str(vm._internal_console_port), + "-m", + "1", + "-i", + "1", + "-F", + "-s", + ANY, + "-c", + ANY, + "-t", + "127.0.0.1", + ) assert vm.is_running() @@ -178,7 +197,9 @@ async def test_stop(vm): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): with asyncio_patch("asyncio.create_subprocess_exec", return_value=process): - nio = VPCS.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = VPCS.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) await vm.port_add_nio_binding(0, nio) await vm.start() @@ -211,7 +232,9 @@ async def test_reload(vm): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True): with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): with asyncio_patch("asyncio.create_subprocess_exec", return_value=process): - nio = VPCS.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = VPCS.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) await vm.port_add_nio_binding(0, nio) await vm.start() assert vm.is_running() @@ -227,7 +250,9 @@ async def test_reload(vm): @pytest.mark.asyncio async def test_add_nio_binding_udp(vm): - nio = VPCS.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + nio = VPCS.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) await vm.port_add_nio_binding(0, nio) assert nio.lport == 4242 @@ -254,7 +279,7 @@ def test_update_startup_script(vm): content = "echo GNS3 VPCS\nip 192.168.1.2\n" vm.startup_script = content - filepath = os.path.join(vm.working_dir, 'startup.vpc') + filepath = os.path.join(vm.working_dir, "startup.vpc") assert os.path.exists(filepath) with open(filepath) as f: assert f.read() == content @@ -290,8 +315,8 @@ def test_get_startup_script_using_default_script(vm): # Reset script file location vm._script_file = None - filepath = os.path.join(vm.working_dir, 'startup.vpc') - with open(filepath, 'wb+') as f: + filepath = os.path.join(vm.working_dir, "startup.vpc") + with open(filepath, "wb+") as f: assert f.write(content.encode("utf-8")) assert vm.startup_script == content @@ -300,16 +325,16 @@ def test_get_startup_script_using_default_script(vm): def test_change_name(vm): - path = os.path.join(vm.working_dir, 'startup.vpc') + path = os.path.join(vm.working_dir, "startup.vpc") vm.name = "world" - with open(path, 'w+') as f: + with open(path, "w+") as f: f.write("set pcname world") vm.name = "hello" assert vm.name == "hello" with open(path) as f: assert f.read() == "set pcname hello" # Support when the name is not sync with config - with open(path, 'w+') as f: + with open(path, "w+") as f: f.write("set pcname alpha") vm.name = "beta" assert vm.name == "beta" diff --git a/tests/conftest.py b/tests/conftest.py index b0994ebb8..17acbb91a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,6 +54,7 @@ def pytest_configure(config): async def app() -> AsyncGenerator[Any, Any]: from gns3server.api.server import app as gns3app + yield gns3app @@ -101,9 +102,9 @@ async def base_client(app: FastAPI, db_session: AsyncSession) -> AsyncGenerator[ app.dependency_overrides[get_db_session] = _get_test_db async with AsyncClient( - base_url="http://test-api", - headers={"Content-Type": "application/json"}, - transport=ASGIWebSocketTransport(app=app) + base_url="http://test-api", + headers={"Content-Type": "application/json"}, + transport=ASGIWebSocketTransport(app=app), ) as async_client: yield async_client @@ -132,12 +133,7 @@ async def test_user(db_session: AsyncSession) -> User: async def test_compute(db_session: AsyncSession) -> Compute: new_compute = schemas.ComputeCreate( - compute_id=uuid.uuid4(), - protocol=Protocol.http, - host="localhost", - port=4242, - user="julien", - password="secure" + compute_id=uuid.uuid4(), protocol=Protocol.http, host="localhost", port=4242, user="julien", password="secure" ) compute_repo = ComputesRepository(db_session) @@ -344,10 +340,41 @@ def on_gns3vm(linux_platform): Mock the hostname to emulate the GNS3 VM """ - with patch("gns3server.utils.interfaces.interfaces", return_value=[ - {"name": "eth0", "special": False, "type": "ethernet", "ip_addresses": [], "status": "up", "speed": 1000, "mtu": 1500, "flags": ["up", "broadcast", "running", "multicast"]}, - {"name": "eth1", "special": False, "type": "ethernet", "ip_addresses": [], "status": "down", "speed": 0, "mtu": 1500, "flags": ["broadcast"]}, - {"name": "virbr0", "special": True, "type": "ethernet", "ip_addresses": [], "status": "up", "speed": 10000, "mtu": 1500, "flags": ["up", "broadcast", "running", "multicast"]}]): + with patch( + "gns3server.utils.interfaces.interfaces", + return_value=[ + { + "name": "eth0", + "special": False, + "type": "ethernet", + "ip_addresses": [], + "status": "up", + "speed": 1000, + "mtu": 1500, + "flags": ["up", "broadcast", "running", "multicast"], + }, + { + "name": "eth1", + "special": False, + "type": "ethernet", + "ip_addresses": [], + "status": "down", + "speed": 0, + "mtu": 1500, + "flags": ["broadcast"], + }, + { + "name": "virbr0", + "special": True, + "type": "ethernet", + "ip_addresses": [], + "status": "up", + "speed": 10000, + "mtu": 1500, + "flags": ["up", "broadcast", "running", "multicast"], + }, + ], + ): with patch("socket.gethostname", return_value="gns3vm"): yield @@ -356,6 +383,7 @@ def on_gns3vm(linux_platform): def ethernet_device(): import psutil + return sorted(psutil.net_if_addrs().keys())[0] @@ -367,7 +395,7 @@ def ubridge_path(config): path = config.settings.Server.ubridge_path os.makedirs(os.path.dirname(path), exist_ok=True) - open(path, 'w+').close() + open(path, "w+").close() return path @@ -384,27 +412,27 @@ def run_around_tests(monkeypatch, config, port_manager): config.settings.Controller.jwt_secret_key = DEFAULT_JWT_SECRET_KEY - secrets_dir = os.path.join(tmppath, 'secrets') + secrets_dir = os.path.join(tmppath, "secrets") os.makedirs(secrets_dir) config.settings.Server.secrets_dir = secrets_dir - projects_dir = os.path.join(tmppath, 'projects') + projects_dir = os.path.join(tmppath, "projects") os.makedirs(projects_dir) config.settings.Server.projects_path = projects_dir - symbols_dir = os.path.join(tmppath, 'symbols') + symbols_dir = os.path.join(tmppath, "symbols") os.makedirs(symbols_dir) config.settings.Server.symbols_path = symbols_dir - images_dir = os.path.join(tmppath, 'images') + images_dir = os.path.join(tmppath, "images") os.makedirs(images_dir) config.settings.Server.images_path = images_dir - appliances_dir = os.path.join(tmppath, 'appliances') + appliances_dir = os.path.join(tmppath, "appliances") os.makedirs(appliances_dir) config.settings.Server.appliances_path = appliances_dir - config.settings.Server.ubridge_path = os.path.join(tmppath, 'bin', 'ubridge') + config.settings.Server.ubridge_path = os.path.join(tmppath, "bin", "ubridge") # Prevent executions of the VM if we forgot to mock something config.settings.VirtualBox.vboxmanage_path = tmppath @@ -424,7 +452,10 @@ def run_around_tests(monkeypatch, config, port_manager): # test body) freezes the patched object into the importing module's namespace # forever, and a closed-over path would then point at a deleted directory in # every later test (order-dependent FileNotFoundError in psutil.disk_usage). - monkeypatch.setattr("gns3server.utils.path.get_default_project_directory", lambda *args: Config.instance().settings.Server.projects_path) + monkeypatch.setattr( + "gns3server.utils.path.get_default_project_directory", + lambda *args: Config.instance().settings.Server.projects_path, + ) # Force sys.platform to the original value. Because it seems not be restored correctly after each test sys.platform = sys.original_platform diff --git a/tests/controller/gns3vm/test_remote_gns3_vm.py b/tests/controller/gns3vm/test_remote_gns3_vm.py index b9a54c0fc..5b475a174 100644 --- a/tests/controller/gns3vm/test_remote_gns3_vm.py +++ b/tests/controller/gns3vm/test_remote_gns3_vm.py @@ -21,6 +21,7 @@ from gns3server.controller.gns3vm.remote_gns3_vm import RemoteGNS3VM from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError from pydantic import SecretStr + @pytest.fixture def gns3vm(controller): @@ -38,14 +39,16 @@ async def test_list(gns3vm, controller): @pytest.mark.asyncio async def test_start(gns3vm, controller): - await controller.add_compute("r1", - name="R1", - protocol="https", - host="r1.local", - port=8484, - user="hello", - password=SecretStr("world"), - connect=False) + await controller.add_compute( + "r1", + name="R1", + protocol="https", + host="r1.local", + port=8484, + user="hello", + password=SecretStr("world"), + connect=False, + ) gns3vm.vmname = "R1" await gns3vm.start() @@ -60,14 +63,16 @@ async def test_start(gns3vm, controller): @pytest.mark.asyncio async def test_start_invalid_vm(gns3vm, controller): - await controller.add_compute("r1", - connect=False, - name="R1", - protocol="https", - host="r1.local", - port=8484, - user="hello", - password=SecretStr("world")) + await controller.add_compute( + "r1", + connect=False, + name="R1", + protocol="https", + host="r1.local", + port=8484, + user="hello", + password=SecretStr("world"), + ) gns3vm.vmname = "R2" with pytest.raises(GNS3VMError): diff --git a/tests/controller/gns3vm/test_virtualbox_gns3_vm.py b/tests/controller/gns3vm/test_virtualbox_gns3_vm.py index 0a9be84fb..6ae3cc5a6 100644 --- a/tests/controller/gns3vm/test_virtualbox_gns3_vm.py +++ b/tests/controller/gns3vm/test_virtualbox_gns3_vm.py @@ -55,9 +55,11 @@ vcpfps=25 GuestMemoryBalloon=0 """ - with asyncio_patch("gns3server.controller.gns3vm.virtualbox_gns3_vm.VirtualBoxGNS3VM._execute", return_value=showvminfo) as mock: + with asyncio_patch( + "gns3server.controller.gns3vm.virtualbox_gns3_vm.VirtualBoxGNS3VM._execute", return_value=showvminfo + ) as mock: res = await gns3vm._look_for_interface("nat") - mock.assert_called_with('showvminfo', ['GNS3 VM', '--machinereadable']) + mock.assert_called_with("showvminfo", ["GNS3 VM", "--machinereadable"]) assert res == 2 # with asyncio_patch("gns3server.controller.gns3vm.virtualbox_gns3_vm.VirtualBoxGNS3VM._execute") as mock: @@ -71,6 +73,7 @@ GuestMemoryBalloon=0 async def test_cpu_vendor_id(gns3vm): from cpuinfo import get_cpu_info + cpu_info = await wait_run_in_executor(get_cpu_info) - vendor_id = cpu_info.get('vendor_id_raw') + vendor_id = cpu_info.get("vendor_id_raw") assert vendor_id # vendor id should not be empty diff --git a/tests/controller/gns3vm/test_vmware_gns3_vm.py b/tests/controller/gns3vm/test_vmware_gns3_vm.py index d0f661c1c..89d551e75 100644 --- a/tests/controller/gns3vm/test_vmware_gns3_vm.py +++ b/tests/controller/gns3vm/test_vmware_gns3_vm.py @@ -41,20 +41,19 @@ async def test_set_extra_options(gns3vm, vmx_path, windows_platform): gns3vm._vmx_path = vmx_path # when there is not an entry, we modify it - with open(vmx_path, 'w') as f: + with open(vmx_path, "w") as f: f.write("") await gns3vm._set_extra_options() - with open(vmx_path, 'r') as f: + with open(vmx_path, "r") as f: assert f.read() == 'vhv.enable = "TRUE"\n' # when there is an entry, we don't modify it - with open(vmx_path, 'w') as f: + with open(vmx_path, "w") as f: f.write('vhv.enable = "FALSE"\n') await gns3vm._set_extra_options() - with open(vmx_path, 'r') as f: + with open(vmx_path, "r") as f: assert f.read() == 'vhv.enable = "FALSE"\n' - diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py index 74f5c3aec..969d90d54 100644 --- a/tests/controller/test_appliance.py +++ b/tests/controller/test_appliance.py @@ -54,9 +54,7 @@ XRD_V8 = { {"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"}, {"adapter_number": 1, "port_name": "Gi0/0/0/0"}, ], - "extra_configs": [ - {"target": "/firstboot.cfg", "content": "!\nend\n"} - ], + "extra_configs": [{"target": "/firstboot.cfg", "content": "!\nend\n"}], }, } ], @@ -141,8 +139,7 @@ def test_v8_docker_settings_require_image(): appliance = dict( XRD_V8, settings=[ - {"name": "only", "default": True, "template_type": "docker", - "template_properties": {"adapters": 2}}, + {"name": "only", "default": True, "template_type": "docker", "template_properties": {"adapters": 2}}, ], ) with pytest.raises(pydantic.ValidationError): @@ -158,8 +155,12 @@ def test_v8_template_properties_validated_against_template_type(): appliance = dict( XRD_V8, settings=[ - {"name": "only", "default": True, "template_type": "qemu", - "template_properties": {"ram": 512, "boot_priority": "zzz"}}, + { + "name": "only", + "default": True, + "template_type": "qemu", + "template_properties": {"ram": 512, "boot_priority": "zzz"}, + }, ], ) with pytest.raises(pydantic.ValidationError): @@ -170,8 +171,12 @@ def test_v8_qemu_kvm_property_validates(): appliance = dict( XRD_V8, settings=[ - {"name": "only", "default": True, "template_type": "qemu", - "template_properties": {"ram": 512, "kvm": "disable"}}, + { + "name": "only", + "default": True, + "template_type": "qemu", + "template_properties": {"ram": 512, "kvm": "disable"}, + }, ], ) model = ApplianceModel.model_validate(appliance) @@ -184,8 +189,12 @@ def test_v8_qemu_cpu_throttling_range(): qemu template (int, 0-800). """ - settings = {"name": "only", "default": True, "template_type": "qemu", - "template_properties": {"ram": 512, "cpu_throttling": 500}} + settings = { + "name": "only", + "default": True, + "template_type": "qemu", + "template_properties": {"ram": 512, "cpu_throttling": 500}, + } model = ApplianceModel.model_validate(dict(XRD_V8, settings=[settings])) assert model.settings[0].template_properties.cpu_throttling == 500 diff --git a/tests/controller/test_appliance_manager.py b/tests/controller/test_appliance_manager.py index 2df0cedc4..38c343181 100644 --- a/tests/controller/test_appliance_manager.py +++ b/tests/controller/test_appliance_manager.py @@ -75,8 +75,12 @@ async def test_install_docker_version_skips_image_resolution(monkeypatch): _FakeTemplatesService.created = [] appliance_data = _v8_appliance( [ - {"name": "default", "default": True, "template_type": "docker", - "template_properties": {"image": "xrd:latest"}}, + { + "name": "default", + "default": True, + "template_type": "docker", + "template_properties": {"image": "xrd:latest"}, + }, ], versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}], ) @@ -113,8 +117,12 @@ async def test_install_iou_version_maps_image_to_path(monkeypatch, tmp_path): _FakeTemplatesService.created = [] appliance_data = _v8_appliance( [ - {"name": "default", "default": True, "template_type": "iou", - "template_properties": {"ethernet_adapters": 4, "ram": 256}}, + { + "name": "default", + "default": True, + "template_type": "iou", + "template_properties": {"ethernet_adapters": 4, "ram": 256}, + }, ], versions=[{"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}], ) @@ -141,8 +149,7 @@ async def test_install_iou_version_maps_image_to_path(monkeypatch, tmp_path): async def test_install_version_not_found(monkeypatch): manager = ApplianceManager() appliance_data = _v8_appliance( - [{"name": "only", "default": True, "template_type": "docker", - "template_properties": {"image": "xrd:latest"}}], + [{"name": "only", "default": True, "template_type": "docker", "template_properties": {"image": "xrd:latest"}}], versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}], ) appliance = Appliance("test.gns3a", appliance_data) diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py index 0ce40cef4..a50338370 100644 --- a/tests/controller/test_appliance_to_template.py +++ b/tests/controller/test_appliance_to_template.py @@ -327,8 +327,7 @@ def test_v8_iou_version_images_mapped_to_path(): "name": "IOU L3", "category": "router", "settings": [ - {"name": "only", "template_type": "iou", - "template_properties": {"ethernet_adapters": 4, "ram": 256}}, + {"name": "only", "template_type": "iou", "template_properties": {"ethernet_adapters": 4, "ram": 256}}, ], } version = {"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}} @@ -346,8 +345,7 @@ def test_v8_dynamips_idlepc_from_version(): "name": "Cisco 7200", "category": "router", "settings": [ - {"name": "only", "template_type": "dynamips", - "template_properties": {"ram": 512, "platform": "c7200"}}, + {"name": "only", "template_type": "dynamips", "template_properties": {"ram": 512, "platform": "c7200"}}, ], } version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}} @@ -368,8 +366,7 @@ def test_v8_dynamips_settings_idlepc_precedence(): "name": "Cisco 7200", "category": "router", "settings": [ - {"name": "only", "template_type": "dynamips", - "template_properties": {"ram": 512, "idlepc": "0x6142da40"}}, + {"name": "only", "template_type": "dynamips", "template_properties": {"ram": 512, "idlepc": "0x6142da40"}}, ], } version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}} @@ -390,8 +387,11 @@ def test_v8_qemu_kvm_disable_forces_accel_tcg(): "name": "QEMU VM", "category": "guest", "settings": [ - {"name": "only", "template_type": "qemu", - "template_properties": {"ram": 512, "options": "-m 512", "kvm": "disable"}}, + { + "name": "only", + "template_type": "qemu", + "template_properties": {"ram": 512, "options": "-m 512", "kvm": "disable"}, + }, ], } @@ -407,8 +407,11 @@ def test_v8_qemu_kvm_allow_keeps_options(): "name": "QEMU VM", "category": "guest", "settings": [ - {"name": "only", "template_type": "qemu", - "template_properties": {"ram": 512, "options": "-m 512", "kvm": "allow"}}, + { + "name": "only", + "template_type": "qemu", + "template_properties": {"ram": 512, "options": "-m 512", "kvm": "allow"}, + }, ], } @@ -429,10 +432,17 @@ def test_v8_no_cross_type_inheritance(): "name": "Mixed", "category": "router", "settings": [ - {"name": "default qemu", "default": True, "template_type": "qemu", - "template_properties": {"ram": 2048, "adapters": 10}}, - {"name": "docker alt", "template_type": "docker", - "template_properties": {"image": "xrd:latest", "adapters": 2}}, + { + "name": "default qemu", + "default": True, + "template_type": "qemu", + "template_properties": {"ram": 2048, "adapters": 10}, + }, + { + "name": "docker alt", + "template_type": "docker", + "template_properties": {"image": "xrd:latest", "adapters": 2}, + }, ], } version = {"name": "1.0", "settings": "docker alt", "images": {"image": "xrd:1.0"}} @@ -456,10 +466,8 @@ def test_v8_multiple_defaults_inherit_first_same_type(): "name": "Multi", "category": "router", "settings": [ - {"name": "default one", "default": True, "template_type": "qemu", - "template_properties": {"ram": 1024}}, - {"name": "default two", "default": True, "template_type": "qemu", - "template_properties": {"ram": 2048}}, + {"name": "default one", "default": True, "template_type": "qemu", "template_properties": {"ram": 1024}}, + {"name": "default two", "default": True, "template_type": "qemu", "template_properties": {"ram": 2048}}, {"name": "alt", "template_type": "qemu", "template_properties": {"cpus": 2}}, ], } @@ -482,8 +490,7 @@ def test_v8_symbol_fallback_uses_effective_category(): "name": "Test", "category": "router", "settings": [ - {"name": "only", "template_type": "qemu", - "template_properties": {"ram": 512, "category": "guest"}}, + {"name": "only", "template_type": "qemu", "template_properties": {"ram": 512, "category": "guest"}}, ], } template = ApplianceToTemplate().new_template(appliance, None, "local") @@ -514,13 +521,16 @@ def test_v8_reserved_keys_cannot_be_overridden(): "name": "Test", "category": "router", "settings": [ - {"name": "only", "template_type": "docker", - "template_properties": { - "image": "xrd:latest", - "template_type": "qemu", - "compute_id": "evil-compute", - "version": "9.9", - }}, + { + "name": "only", + "template_type": "docker", + "template_properties": { + "image": "xrd:latest", + "template_type": "qemu", + "compute_id": "evil-compute", + "version": "9.9", + }, + }, ], } @@ -543,10 +553,8 @@ def test_v8_get_template_type(): "name": "Mixed", "category": "router", "settings": [ - {"name": "default", "default": True, "template_type": "qemu", - "template_properties": {"ram": 512}}, - {"name": "alt", "template_type": "docker", - "template_properties": {"image": "xrd:latest"}}, + {"name": "default", "default": True, "template_type": "qemu", "template_properties": {"ram": 512}}, + {"name": "alt", "template_type": "docker", "template_properties": {"image": "xrd:latest"}}, ], } diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 671b8e7a0..04ed1d740 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -68,9 +68,9 @@ def test_getUrl(controller): def test_get_url(controller): compute = Compute("my_compute_id", protocol="https", host="localhost", port=84, controller=controller) - with patch('gns3server.controller.compute.Compute._getUrl', return_value="returned") as getURL: - assert compute.get_url("/test") == 'returned' - getURL.assert_called_once_with('/test') + with patch("gns3server.controller.compute.Compute._getUrl", return_value="returned") as getURL: + assert compute.get_url("/test") == "returned" + getURL.assert_called_once_with("/test") def test_host_ip(controller): @@ -85,7 +85,9 @@ def test_name(): assert c.name == "https://example.com:84" c = Compute("world", protocol="https", host="example.com", port=84, controller=MagicMock(), name="hello") assert c.name == "hello" - c = Compute("world", protocol="https", host="example.com", port=84, controller=MagicMock(), user="azertyuiopqsdfghjklkm") + c = Compute( + "world", protocol="https", host="example.com", port=84, controller=MagicMock(), user="azertyuiopqsdfghjklkm" + ) assert c.name == "https://azertyuiopq...@example.com:84" @@ -97,7 +99,16 @@ async def test_compute_httpQuery(compute): response.status = 200 await compute.post("/projects", {"a": "b"}) await compute.close() - mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", headers={'content-type': 'application/json'}, data=b'{"a": "b"}', auth=None, params=None, chunked=None, timeout=120) + mock.assert_called_with( + "POST", + "https://example.com:84/v3/compute/projects", + headers={"content-type": "application/json"}, + data=b'{"a": "b"}', + auth=None, + params=None, + chunked=None, + timeout=120, + ) assert compute._auth is None @@ -112,13 +123,22 @@ async def test_compute_httpQueryAuth(compute): compute.password = SecretStr("toor") await compute.post("/projects", {"a": "b"}) await compute.close() - mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", headers={'content-type': 'application/json'}, data=b'{"a": "b"}', auth=compute._auth, params=None, chunked=None, timeout=120) + mock.assert_called_with( + "POST", + "https://example.com:84/v3/compute/projects", + headers={"content-type": "application/json"}, + data=b'{"a": "b"}', + auth=compute._auth, + params=None, + chunked=None, + timeout=120, + ) assert compute._auth.login == "root" assert compute._auth.password == "toor" # @pytest.mark.asyncio -#async def test_compute_httpQueryNotConnected(compute, controller): +# async def test_compute_httpQueryNotConnected(compute, controller): # # controller._notification = MagicMock() # compute._connected = False @@ -136,7 +156,7 @@ async def test_compute_httpQueryAuth(compute): # @pytest.mark.asyncio -#async def test_compute_httpQueryNotConnectedGNS3vmNotRunning(compute, controller): +# async def test_compute_httpQueryNotConnectedGNS3vmNotRunning(compute, controller): # """ # We are not connected to the remote and it's a GNS3 VM. So we need to start it # """ @@ -172,7 +192,16 @@ async def test_compute_httpQueryNotConnectedInvalidVersion(compute): with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: with pytest.raises(ControllerError): await compute.post("/projects", {"a": "b"}) - mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120) + mock.assert_any_call( + "GET", + "https://example.com:84/v3/compute/capabilities", + headers={"content-type": "application/json"}, + data=None, + auth=None, + params=None, + chunked=None, + timeout=120, + ) await compute.close() @@ -181,12 +210,21 @@ async def test_compute_httpQueryNotConnectedNonGNS3Server(compute): compute._connected = False response = AsyncioMagicMock() - response.read = AsyncioMagicMock(return_value=b'Blocked by super antivirus') + response.read = AsyncioMagicMock(return_value=b"Blocked by super antivirus") response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: with pytest.raises(ControllerError): await compute.post("/projects", {"a": "b"}) - mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120) + mock.assert_any_call( + "GET", + "https://example.com:84/v3/compute/capabilities", + headers={"content-type": "application/json"}, + data=None, + auth=None, + params=None, + chunked=None, + timeout=120, + ) await compute.close() @@ -195,12 +233,21 @@ async def test_compute_httpQueryNotConnectedNonGNS3Server2(compute): compute._connected = False response = AsyncioMagicMock() - response.read = AsyncioMagicMock(return_value=b'{}') + response.read = AsyncioMagicMock(return_value=b"{}") response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: with pytest.raises(ControllerError): await compute.post("/projects", {"a": "b"}) - mock.assert_any_call("GET", "https://example.com:84/v3/compute/capabilities", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120) + mock.assert_any_call( + "GET", + "https://example.com:84/v3/compute/capabilities", + headers={"content-type": "application/json"}, + data=None, + auth=None, + params=None, + chunked=None, + timeout=120, + ) @pytest.mark.asyncio @@ -234,22 +281,32 @@ async def test_compute_httpQuery_project(compute): response = MagicMock() with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: response.status = 200 - with patch('gns3server.controller.project.Project.emit_controller_notification') as mock_notification: + with patch("gns3server.controller.project.Project.emit_controller_notification") as mock_notification: project = Project(name="Test") mock_notification.assert_called() await compute.post("/projects", project) - mock.assert_called_with("POST", "https://example.com:84/v3/compute/projects", headers={'content-type': 'application/json'}, data=json.dumps(project.asdict()), auth=None, params=None, chunked=None, timeout=120) + mock.assert_called_with( + "POST", + "https://example.com:84/v3/compute/projects", + headers={"content-type": "application/json"}, + data=json.dumps(project.asdict()), + auth=None, + params=None, + chunked=None, + timeout=120, + ) await compute.close() + # FIXME: https://github.com/aio-libs/aiohttp/issues/2525 # @pytest.mark.asyncio -#async def test_connectNotification(compute): +# async def test_connectNotification(compute): # # ws_mock = AsyncioMagicMock() # call = 0 # # @pytest.mark.asyncio -#async def receive(): +# async def receive(): # nonlocal call # call += 1 # if call == 1: @@ -282,7 +339,7 @@ async def test_compute_httpQuery_project(compute): # call = 0 # # @pytest.mark.asyncio -#async def receive(): +# async def receive(): # nonlocal call # call += 1 # if call == 1: @@ -306,6 +363,7 @@ async def test_compute_httpQuery_project(compute): # assert args[1]["memory_usage_percent"] == 80.7 # assert args[1]["cpu_usage_percent"] == 35.7 + @pytest.mark.asyncio async def test_json(compute): @@ -322,14 +380,7 @@ async def test_json(compute): "disk_usage_percent": 0, "connected": True, "last_error": None, - "capabilities": { - "version": "", - "platform": "", - "cpus": 0, - "memory": 0, - "disk_size": 0, - "node_types": [] - } + "capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": []}, } assert compute.asdict(topology_dump=True) == { "compute_id": "my_compute_id", @@ -347,7 +398,9 @@ async def test_downloadFile(project, compute): response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: await compute.download_file(project, "test/titi") - mock.assert_called_with("GET", "https://example.com:84/v3/compute/projects/{}/files/test/titi".format(project.id), auth=None) + mock.assert_called_with( + "GET", "https://example.com:84/v3/compute/projects/{}/files/test/titi".format(project.id), auth=None + ) await compute.close() @@ -382,7 +435,16 @@ async def test_forward_get(compute): response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: await compute.forward("GET", "qemu", "images") - mock.assert_called_with("GET", "https://example.com:84/v3/compute/qemu/images", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=None) + mock.assert_called_with( + "GET", + "https://example.com:84/v3/compute/qemu/images", + headers={"content-type": "application/json"}, + data=None, + auth=None, + params=None, + chunked=None, + timeout=None, + ) await compute.close() @@ -405,7 +467,16 @@ async def test_forward_post(compute): response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: await compute.forward("POST", "qemu", "img", data={"id": 42}) - mock.assert_called_with("POST", "https://example.com:84/v3/compute/qemu/img", headers={'content-type': 'application/json'}, data=b'{"id": 42}', auth=None, params=None, chunked=None, timeout=None) + mock.assert_called_with( + "POST", + "https://example.com:84/v3/compute/qemu/img", + headers={"content-type": "application/json"}, + data=b'{"id": 42}', + auth=None, + params=None, + chunked=None, + timeout=None, + ) await compute.close() @@ -418,7 +489,16 @@ async def test_list_files(project, compute): response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: assert await compute.list_files(project) == res - mock.assert_any_call("GET", "https://example.com:84/v3/compute/projects/{}/files".format(project.id), headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=None) + mock.assert_any_call( + "GET", + "https://example.com:84/v3/compute/projects/{}/files".format(project.id), + headers={"content-type": "application/json"}, + data=None, + auth=None, + params=None, + chunked=None, + timeout=None, + ) await compute.close() @@ -432,7 +512,7 @@ async def test_interfaces(compute): "mac_address": "00:50:56:c0:00:63", "name": "vmnet99", "netmask": "255.255.255.0", - "type": "ethernet" + "type": "ethernet", } ] response = AsyncioMagicMock() @@ -440,7 +520,16 @@ async def test_interfaces(compute): response.status = 200 with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: assert await compute.interfaces() == res - mock.assert_any_call("GET", "https://example.com:84/v3/compute/network/interfaces", headers={'content-type': 'application/json'}, data=None, auth=None, params=None, chunked=None, timeout=120) + mock.assert_any_call( + "GET", + "https://example.com:84/v3/compute/network/interfaces", + headers={"content-type": "application/json"}, + data=None, + auth=None, + params=None, + chunked=None, + timeout=120, + ) await compute.close() @@ -449,64 +538,32 @@ async def test_get_ip_on_same_subnet(controller): compute1 = Compute("compute1", host="192.168.1.1", controller=controller) compute1._interfaces_cache = [ - { - "ip_address": "127.0.0.1", - "netmask": "255.255.255.255" - }, - { - "ip_address": "192.168.2.1", - "netmask": "255.255.255.0" - }, - { - "ip_address": "192.168.1.1", - "netmask": "255.255.255.0" - }, + {"ip_address": "127.0.0.1", "netmask": "255.255.255.255"}, + {"ip_address": "192.168.2.1", "netmask": "255.255.255.0"}, + {"ip_address": "192.168.1.1", "netmask": "255.255.255.0"}, ] # Case 1 both host are on the same network compute2 = Compute("compute2", host="192.168.1.2", controller=controller) compute2._interfaces_cache = [ - { - "ip_address": "127.0.0.1", - "netmask": "255.255.255.255" - }, - { - "ip_address": "192.168.2.2", - "netmask": "255.255.255.0" - }, - { - "ip_address": "192.168.1.2", - "netmask": "255.255.255.0" - } + {"ip_address": "127.0.0.1", "netmask": "255.255.255.255"}, + {"ip_address": "192.168.2.2", "netmask": "255.255.255.0"}, + {"ip_address": "192.168.1.2", "netmask": "255.255.255.0"}, ] assert await compute1.get_ip_on_same_subnet(compute2) == ("192.168.1.1", "192.168.1.2") # Case 2 compute2 host is on a different network but a common interface is available compute2 = Compute("compute2", host="127.0.0.1", controller=controller) compute2._interfaces_cache = [ - { - "ip_address": "127.0.0.1", - "netmask": "255.255.255.255" - }, - { - "ip_address": "192.168.4.2", - "netmask": "255.255.255.0" - }, - { - "ip_address": "192.168.1.2", - "netmask": "255.255.255.0" - } + {"ip_address": "127.0.0.1", "netmask": "255.255.255.255"}, + {"ip_address": "192.168.4.2", "netmask": "255.255.255.0"}, + {"ip_address": "192.168.1.2", "netmask": "255.255.255.0"}, ] assert await compute1.get_ip_on_same_subnet(compute2) == ("192.168.1.1", "192.168.1.2") - #No common interface + # No common interface compute2 = Compute("compute2", host="127.0.0.1", controller=controller) - compute2._interfaces_cache = [ - { - "ip_address": "127.0.0.1", - "netmask": "255.255.255.255" - } - ] + compute2._interfaces_cache = [{"ip_address": "127.0.0.1", "netmask": "255.255.255.255"}] with pytest.raises(ValueError): await compute1.get_ip_on_same_subnet(compute2) @@ -514,26 +571,14 @@ async def test_get_ip_on_same_subnet(controller): compute2 = Compute("compute2", host="192.168.1.2", controller=controller) compute1 = Compute("compute1", host="192.168.2.1", controller=controller) compute1._interfaces_cache = [ - { - "ip_address": "127.0.0.1", - "netmask": "255.255.255.255" - }, - { - "ip_address": "169.254.1.1", - "netmask": "255.255.0.0" - }, + {"ip_address": "127.0.0.1", "netmask": "255.255.255.255"}, + {"ip_address": "169.254.1.1", "netmask": "255.255.0.0"}, ] compute2._interfaces_cache = [ - { - "ip_address": "127.0.0.1", - "netmask": "255.255.255.255" - }, - { - "ip_address": "169.254.2.1", - "netmask": "255.255.0.0" - }, + {"ip_address": "127.0.0.1", "netmask": "255.255.255.255"}, + {"ip_address": "169.254.2.1", "netmask": "255.255.0.0"}, ] - assert await compute1.get_ip_on_same_subnet(compute2) == ('192.168.2.1', '192.168.1.2') + assert await compute1.get_ip_on_same_subnet(compute2) == ("192.168.2.1", "192.168.1.2") class FakeWebSocket: @@ -573,7 +618,12 @@ async def test_connect_notification_poison_frame_autoreconnects(compute, monkeyp emit_mock = MagicMock() monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock) frames = [ - _text_frame({"action": "ping", "event": {"cpu_usage_percent": 10.0, "memory_usage_percent": 20.0, "disk_usage_percent": 30.0}}), + _text_frame( + { + "action": "ping", + "event": {"cpu_usage_percent": 10.0, "memory_usage_percent": 20.0, "disk_usage_percent": 30.0}, + } + ), _text_frame({"event": {"poison": True}}), # missing "action": raises KeyError in the receive loop ] session = MagicMock() @@ -590,6 +640,7 @@ async def test_connect_notification_poison_frame_autoreconnects(compute, monkeyp async def fake_connect(): compute._reconnect_attempted = True + monkeypatch.setattr(compute, "connect", fake_connect) # must not raise despite the poison frame diff --git a/tests/controller/test_controller.py b/tests/controller/test_controller.py index 412a9b019..18edb22ee 100644 --- a/tests/controller/test_controller.py +++ b/tests/controller/test_controller.py @@ -120,7 +120,9 @@ async def test_load_projects_skip_unexpected_errors(controller, projects_dir): with open(os.path.join(projects_dir, "broken_project", "broken.gns3"), "w+") as f: f.write("") - with asyncio_patch("gns3server.controller.Controller.load_project", side_effect=Exception("boom")) as mock_load_project: + with asyncio_patch( + "gns3server.controller.Controller.load_project", side_effect=Exception("boom") + ) as mock_load_project: await controller.load_projects() mock_load_project.assert_called_with(os.path.join(projects_dir, "broken_project", "broken.gns3"), load=False) @@ -340,16 +342,12 @@ async def test_getProject(controller): @pytest.mark.asyncio async def test_start(controller): - controller.gns3vm.settings = { - "enable": False, - "engine": "vmware", - "vmname": "GNS3 VM" - } + controller.gns3vm.settings = {"enable": False, "engine": "vmware", "vmname": "GNS3 VM"} - #with asyncio_patch("gns3server.controller.compute.Compute.connect") as mock: + # with asyncio_patch("gns3server.controller.compute.Compute.connect") as mock: with asyncio_patch("gns3server.controller.Controller._install_builtin_disks", return_value=[]): await controller.start() - #assert mock.called + # assert mock.called assert len(controller.computes) == 1 # Local compute is created assert controller.computes["local"].name == f"{socket.gethostname()} (controller)" @@ -360,11 +358,7 @@ async def test_start_vm(controller): Start the controller with a GNS3 VM """ - controller.gns3vm.settings = { - "enable": True, - "engine": "vmware", - "vmname": "GNS3 VM" - } + controller.gns3vm.settings = {"enable": True, "engine": "vmware", "vmname": "GNS3 VM"} with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.start") as mock: with asyncio_patch("gns3server.controller.gns3vm.GNS3VM._check_network"): @@ -392,12 +386,7 @@ async def test_stop_vm(controller): Stop GNS3 VM if configured """ - controller.gns3vm.settings = { - "enable": True, - "engine": "vmware", - "when_exit": "stop", - "vmname": "GNS3 VM" - } + controller.gns3vm.settings = {"enable": True, "engine": "vmware", "when_exit": "stop", "vmname": "GNS3 VM"} controller.gns3vm.current_engine().running = True with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.stop") as mock: @@ -411,12 +400,7 @@ async def test_suspend_vm(controller): Suspend GNS3 VM if configured """ - controller.gns3vm.settings = { - "enable": True, - "engine": "vmware", - "when_exit": "suspend", - "vmname": "GNS3 VM" - } + controller.gns3vm.settings = {"enable": True, "engine": "vmware", "when_exit": "suspend", "vmname": "GNS3 VM"} controller.gns3vm.current_engine().running = True with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.suspend") as mock: @@ -430,12 +414,7 @@ async def test_keep_vm(controller): Keep GNS3 VM if configured """ - controller.gns3vm.settings = { - "enable": True, - "engine": "vmware", - "when_exit": "keep", - "vmname": "GNS3 VM" - } + controller.gns3vm.settings = {"enable": True, "engine": "vmware", "when_exit": "keep", "vmname": "GNS3 VM"} controller.gns3vm.current_engine().running = True with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.suspend") as mock: @@ -457,18 +436,18 @@ async def test_get_free_project_name(controller): async def test_install_base_configs(controller, config, tmpdir): config.settings.Server.configs_path = str(tmpdir) - with open(str(tmpdir / 'iou_l2_base_startup-config.txt'), 'w+') as f: - f.write('test') + with open(str(tmpdir / "iou_l2_base_startup-config.txt"), "w+") as f: + f.write("test") await controller._install_base_configs() - assert os.path.exists(str(tmpdir / 'iou_l3_base_startup-config.txt')) + assert os.path.exists(str(tmpdir / "iou_l3_base_startup-config.txt")) # the IOL docker base config ships with the server too (referenced by the # GNS3_IOL_STARTUP_CONFIG knob in the documented template) - assert os.path.exists(str(tmpdir / 'iol-xe-base.txt')) + assert os.path.exists(str(tmpdir / "iol-xe-base.txt")) # Check is the file has not been overwritten - with open(str(tmpdir / 'iou_l2_base_startup-config.txt')) as f: - assert f.read() == 'test' + with open(str(tmpdir / "iou_l2_base_startup-config.txt")) as f: + assert f.read() == "test" @pytest.mark.asyncio @@ -486,8 +465,8 @@ async def test_install_base_configs(controller, config, tmpdir): "empty200G.qcow2", "empty250G.qcow2", "empty500G.qcow2", - "empty1T.qcow2" - ] + "empty1T.qcow2", + ], ) async def test_install_builtin_disks(controller, config, tmpdir, builtin_disk): @@ -500,17 +479,12 @@ async def test_install_builtin_disks(controller, config, tmpdir, builtin_disk): @pytest.mark.asyncio async def test_appliances(controller, config, tmpdir): - my_appliance = { - "name": "My Appliance", - "status": "stable" - } - with open(str(tmpdir / "my_appliance.gns3a"), 'w+') as f: + my_appliance = {"name": "My Appliance", "status": "stable"} + with open(str(tmpdir / "my_appliance.gns3a"), "w+") as f: json.dump(my_appliance, f) # A broken appliance - my_appliance = { - "name": "Broken" - } - with open(str(tmpdir / "my_appliance2.gns3a"), 'w+') as f: + my_appliance = {"name": "Broken"} + with open(str(tmpdir / "my_appliance2.gns3a"), "w+") as f: json.dump(my_appliance, f) config.settings.Server.appliances_path = str(tmpdir) @@ -624,8 +598,12 @@ async def test_find_projects_skips_unreadable_topology(controller): project = await controller.add_project(name="Broken") await project.add_node( - compute, "n1", None, node_type="vpcs", - template_id=str(uuid.uuid4()), properties={"hda_disk_image": "lost.qcow2"}, + compute, + "n1", + None, + node_type="vpcs", + template_id=str(uuid.uuid4()), + properties={"hda_disk_image": "lost.qcow2"}, ) await project.close() os.remove(project.topology_file) diff --git a/tests/controller/test_drawing.py b/tests/controller/test_drawing.py index f8ea464a4..cd7946e10 100644 --- a/tests/controller/test_drawing.py +++ b/tests/controller/test_drawing.py @@ -55,7 +55,7 @@ def test_json(project): "z": i.z, "locked": i.locked, "svg": i.svg, - "rotation": i.rotation + "rotation": i.rotation, } assert i.asdict(topology_dump=True) == { "drawing_id": i.id, @@ -64,7 +64,7 @@ def test_json(project): "z": i.z, "rotation": i.rotation, "locked": i.locked, - "svg": i.svg + "svg": i.svg, } @@ -99,7 +99,7 @@ def test_image_base64(project): If image are embed as base 64 we need to dump them on disk """ - svg = "\n\n" + svg = '\n\n' drawing = Drawing(project, None, svg=svg) assert drawing._svg == "8418154b760b4e8023650e04c4992e24.png" @@ -113,10 +113,12 @@ def test_image_svg(project): Large SVG are dump on disk """ - svg = "\n" + svg = ( + '\n' + ) for i in range(0, 1000): - svg += "" + svg += '' svg += "" drawing = Drawing(project, None, svg=svg) diff --git a/tests/controller/test_export_project.py b/tests/controller/test_export_project.py index f3128e9d8..709f8ad07 100644 --- a/tests/controller/test_export_project.py +++ b/tests/controller/test_export_project.py @@ -59,7 +59,7 @@ async def node(controller, project): @pytest.mark.asyncio async def write_file(path, z): - with open(path, 'wb') as f: + with open(path, "wb") as f: async for chunk in z: f.write(chunk) @@ -85,7 +85,7 @@ async def test_export(tmpdir, project): f.write("AAA") # The .gns3 should be renamed project.gns3 in order to simplify import - with open(os.path.join(path, "test.gns3"), 'w+') as f: + with open(os.path.join(path, "test.gns3"), "w+") as f: data = { "topology": { "computes": [ @@ -94,52 +94,53 @@ async def test_export(tmpdir, project): "host": "127.0.0.1", "name": "Remote 1", "port": 8001, - "protocol": "http" + "protocol": "http", } ], "nodes": [ { "compute_id": "6b7149c8-7d6e-4ca0-ab6b-daa8ab567be0", "node_type": "dynamips", - "properties": { - "image": "test.image" - } + "properties": {"image": "test.image"}, } - ] + ], } } json.dump(data, f) - with open(os.path.join(path, "vm-1", "dynamips", "test"), 'w+') as f: + with open(os.path.join(path, "vm-1", "dynamips", "test"), "w+") as f: f.write("HELLO") - with open(os.path.join(path, "vm-1", "dynamips", "test_log.txt"), 'w+') as f: + with open(os.path.join(path, "vm-1", "dynamips", "test_log.txt"), "w+") as f: f.write("LOG") os.makedirs(os.path.join(path, "vm-1", "dynamips", "empty-dir")) os.makedirs(os.path.join(path, "project-files", "snapshots")) - with open(os.path.join(path, "project-files", "snapshots", "test"), 'w+') as f: + with open(os.path.join(path, "project-files", "snapshots", "test"), "w+") as f: f.write("WORLD") os.symlink("/tmp/anywhere", os.path.join(path, "vm-1", "dynamips", "symlink")) with aiozipstream.ZipFile() as z: - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): + with patch( + "gns3server.compute.Dynamips.get_images_directory", + return_value=str(tmpdir / "IOS"), + ): await export_project(z, project, str(tmpdir), include_images=False) - await write_file(str(tmpdir / 'zipfile.zip'), z) + await write_file(str(tmpdir / "zipfile.zip"), z) - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip: with myzip.open("vm-1/dynamips/test") as myfile: content = myfile.read() assert content == b"HELLO" - assert 'test.gns3' not in myzip.namelist() - assert 'project.gns3' in myzip.namelist() - assert 'vm-1/dynamips/empty-dir/' in myzip.namelist() - assert 'project-files/snapshots/test' not in myzip.namelist() - assert 'vm-1/dynamips/test_log.txt' not in myzip.namelist() - assert 'images/IOS/test.image' not in myzip.namelist() + assert "test.gns3" not in myzip.namelist() + assert "project.gns3" in myzip.namelist() + assert "vm-1/dynamips/empty-dir/" in myzip.namelist() + assert "project-files/snapshots/test" not in myzip.namelist() + assert "vm-1/dynamips/test_log.txt" not in myzip.namelist() + assert "images/IOS/test.image" not in myzip.namelist() - assert 'vm-1/dynamips/symlink' in myzip.namelist() - zip_info = myzip.getinfo('vm-1/dynamips/symlink') + assert "vm-1/dynamips/symlink" in myzip.namelist() + zip_info = myzip.getinfo("vm-1/dynamips/symlink") assert stat.S_ISLNK(zip_info.external_attr >> 16) with myzip.open("project.gns3") as myfile: @@ -194,17 +195,9 @@ async def test_export_disallow_running(tmpdir, project, node): path = project.path - topology = { - "topology": { - "nodes": [ - { - "node_type": "dynamips" - } - ] - } - } + topology = {"topology": {"nodes": [{"node_type": "dynamips"}]}} - with open(os.path.join(path, "test.gns3"), 'w+') as f: + with open(os.path.join(path, "test.gns3"), "w+") as f: json.dump(topology, f) node._status = "started" @@ -221,17 +214,9 @@ async def test_export_disallow_some_type(tmpdir, project): path = project.path - topology = { - "topology": { - "nodes": [ - { - "node_type": "vmware" - } - ] - } - } + topology = {"topology": {"nodes": [{"node_type": "vmware"}]}} - with open(os.path.join(path, "test.gns3"), 'w+') as f: + with open(os.path.join(path, "test.gns3"), "w+") as f: json.dump(topology, f) with pytest.raises(ControllerError): @@ -241,19 +226,8 @@ async def test_export_disallow_some_type(tmpdir, project): await export_project(z, project, str(tmpdir), allow_all_nodes=True) # VirtualBox is always disallowed - topology = { - "topology": { - "nodes": [ - { - "node_type": "virtualbox", - "properties": { - "linked_clone": True - } - } - ] - } - } - with open(os.path.join(path, "test.gns3"), 'w+') as f: + topology = {"topology": {"nodes": [{"node_type": "virtualbox", "properties": {"linked_clone": True}}]}} + with open(os.path.join(path, "test.gns3"), "w+") as f: json.dump(topology, f) with pytest.raises(ControllerError): with aiozipstream.ZipFile() as z: @@ -271,30 +245,20 @@ async def test_export_fix_path(tmpdir, project): topology = { "topology": { "nodes": [ - { - "properties": { - "image": "/tmp/c3725-adventerprisek9-mz.124-25d.image" - }, - "node_type": "dynamips" - }, - { - "properties": { - "image": "gns3/webterm:lastest" - }, - "node_type": "docker" - } + {"properties": {"image": "/tmp/c3725-adventerprisek9-mz.124-25d.image"}, "node_type": "dynamips"}, + {"properties": {"image": "gns3/webterm:lastest"}, "node_type": "docker"}, ] } } - with open(os.path.join(path, "test.gns3"), 'w+') as f: + with open(os.path.join(path, "test.gns3"), "w+") as f: json.dump(topology, f) with aiozipstream.ZipFile() as z: await export_project(z, project, str(tmpdir)) - await write_file(str(tmpdir / 'zipfile.zip'), z) + await write_file(str(tmpdir / "zipfile.zip"), z) - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip: with myzip.open("project.gns3") as myfile: content = myfile.read().decode() topology = json.loads(content) @@ -313,28 +277,20 @@ async def test_export_with_images(tmpdir, project): with open(str(tmpdir / "IOS" / "test.image"), "w+") as f: f.write("AAA") - topology = { - "topology": { - "nodes": [ - { - "properties": { - "image": "test.image" - }, - "node_type": "dynamips" - } - ] - } - } + topology = {"topology": {"nodes": [{"properties": {"image": "test.image"}, "node_type": "dynamips"}]}} - with open(os.path.join(path, "test.gns3"), 'w+') as f: + with open(os.path.join(path, "test.gns3"), "w+") as f: json.dump(topology, f) with aiozipstream.ZipFile() as z: - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): + with patch( + "gns3server.compute.Dynamips.get_images_directory", + return_value=str(tmpdir / "IOS"), + ): await export_project(z, project, str(tmpdir), include_images=True) - await write_file(str(tmpdir / 'zipfile.zip'), z) + await write_file(str(tmpdir / "zipfile.zip"), z) - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip: myzip.getinfo("images/IOS/test.image") @@ -345,7 +301,7 @@ async def test_export_keep_compute_ids(tmpdir, project): in the file """ - with open(os.path.join(project.path, "test.gns3"), 'w+') as f: + with open(os.path.join(project.path, "test.gns3"), "w+") as f: data = { "topology": { "computes": [ @@ -354,24 +310,19 @@ async def test_export_keep_compute_ids(tmpdir, project): "host": "127.0.0.1", "name": "Remote 1", "port": 8001, - "protocol": "http" + "protocol": "http", } ], - "nodes": [ - { - "compute_id": "6b7149c8-7d6e-4ca0-ab6b-daa8ab567be0", - "node_type": "vpcs" - } - ] + "nodes": [{"compute_id": "6b7149c8-7d6e-4ca0-ab6b-daa8ab567be0", "node_type": "vpcs"}], } } json.dump(data, f) with aiozipstream.ZipFile() as z: await export_project(z, project, str(tmpdir), keep_compute_ids=True) - await write_file(str(tmpdir / 'zipfile.zip'), z) + await write_file(str(tmpdir / "zipfile.zip"), z) - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip: with myzip.open("project.gns3") as myfile: topo = json.loads(myfile.read().decode())["topology"] assert topo["nodes"][0]["compute_id"] == "6b7149c8-7d6e-4ca0-ab6b-daa8ab567be0" @@ -404,36 +355,25 @@ async def test_export_images_from_vm(tmpdir, project): mock_response.status = 200 compute.download_image = AsyncioMagicMock(return_value=mock_response) - project._project_created_on_compute.add(compute) path = project.path os.makedirs(os.path.join(path, "vm-1", "dynamips")) topology = { - "topology": { - "nodes": [ - { - "compute_id": "vm", - "properties": { - "image": "test.image" - }, - "node_type": "dynamips" - } - ] - } + "topology": {"nodes": [{"compute_id": "vm", "properties": {"image": "test.image"}, "node_type": "dynamips"}]} } # The .gns3 should be renamed project.gns3 in order to simplify import - with open(os.path.join(path, "test.gns3"), 'w+') as f: + with open(os.path.join(path, "test.gns3"), "w+") as f: f.write(json.dumps(topology)) with aiozipstream.ZipFile() as z: await export_project(z, project, str(tmpdir), include_images=True) assert compute.list_files.called - await write_file(str(tmpdir / 'zipfile.zip'), z) + await write_file(str(tmpdir / "zipfile.zip"), z) - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: + with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip: with myzip.open("vm-1/dynamips/test") as myfile: content = myfile.read() assert content == b"HELLO" @@ -446,7 +386,7 @@ async def test_export_images_from_vm(tmpdir, project): @pytest.mark.asyncio async def test_export_with_ignoring_snapshots(tmpdir, project): - with open(os.path.join(project.path, "test.gns3"), 'w+') as f: + with open(os.path.join(project.path, "test.gns3"), "w+") as f: data = { "topology": { "computes": [ @@ -455,27 +395,22 @@ async def test_export_with_ignoring_snapshots(tmpdir, project): "host": "127.0.0.1", "name": "Remote 1", "port": 8001, - "protocol": "http" + "protocol": "http", } ], - "nodes": [ - { - "compute_id": "6b7149c8-7d6e-4ca0-ab6b-daa8ab567be0", - "node_type": "vpcs" - } - ] + "nodes": [{"compute_id": "6b7149c8-7d6e-4ca0-ab6b-daa8ab567be0", "node_type": "vpcs"}], } } json.dump(data, f) # create snapshot directory - snapshots_dir = os.path.join(project.path, 'snapshots') + snapshots_dir = os.path.join(project.path, "snapshots") os.makedirs(snapshots_dir) - Path(os.path.join(snapshots_dir, 'snap.gns3project')).touch() + Path(os.path.join(snapshots_dir, "snap.gns3project")).touch() with aiozipstream.ZipFile() as z: await export_project(z, project, str(tmpdir), keep_compute_ids=True) - await write_file(str(tmpdir / 'zipfile.zip'), z) + await write_file(str(tmpdir / "zipfile.zip"), z) - with zipfile.ZipFile(str(tmpdir / 'zipfile.zip')) as myzip: - assert not os.path.join('snapshots', 'snap.gns3project') in [f.filename for f in myzip.filelist] + with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip: + assert not os.path.join("snapshots", "snap.gns3project") in [f.filename for f in myzip.filelist] diff --git a/tests/controller/test_gns3vm.py b/tests/controller/test_gns3vm.py index 041cd44ce..48af40493 100644 --- a/tests/controller/test_gns3vm.py +++ b/tests/controller/test_gns3vm.py @@ -52,10 +52,15 @@ def dummy_gns3vm(controller, dummy_engine): async def test_list(controller): vm = GNS3VM(controller) - with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.list", return_value=[{"vmname": "test", "vmx_path": "test"}]): + with asyncio_patch( + "gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.list", + return_value=[{"vmname": "test", "vmx_path": "test"}], + ): res = await vm.list("vmware") assert res == [{"vmname": "test"}] # Information specific to VMware is stripped - with asyncio_patch("gns3server.controller.gns3vm.virtualbox_gns3_vm.VirtualBoxGNS3VM.list", return_value=[{"vmname": "test"}]): + with asyncio_patch( + "gns3server.controller.gns3vm.virtualbox_gns3_vm.VirtualBoxGNS3VM.list", return_value=[{"vmname": "test"}] + ): res = await vm.list("virtualbox") assert res == [{"vmname": "test"}] with pytest.raises(NotImplementedError): @@ -73,11 +78,7 @@ async def test_json(controller): async def test_update_settings(controller): vm = GNS3VM(controller) - vm.settings = { - "enable": True, - "engine": "vmware", - "vmname": "GNS3 VM" - } + vm.settings = {"enable": True, "engine": "vmware", "vmname": "GNS3 VM"} with asyncio_patch("gns3server.controller.gns3vm.vmware_gns3_vm.VMwareGNS3VM.start"): with asyncio_patch("gns3server.controller.gns3vm.GNS3VM._check_network"): diff --git a/tests/controller/test_import_project.py b/tests/controller/test_import_project.py index 0d5cff67f..b7d7ea042 100644 --- a/tests/controller/test_import_project.py +++ b/tests/controller/test_import_project.py @@ -43,18 +43,17 @@ async def test_import_project(tmpdir, controller): "name": "test", "auto_open": True, "auto_start": True, - "topology": { - }, - "version": "2.0.0" + "topology": {}, + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) - with open(str(tmpdir / "b.png"), 'w+') as f: + with open(str(tmpdir / "b.png"), "w+") as f: f.write("B") zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") myzip.write(str(tmpdir / "b.png"), "b.png") myzip.write(str(tmpdir / "b.png"), "project-files/dynamips/test") @@ -90,19 +89,13 @@ async def test_import_project_override(projects_dir, controller): tmpdir = Path(projects_dir) / "override-location" tmpdir.mkdir(parents=True, exist_ok=True) project_id = str(uuid.uuid4()) - topology = { - "project_id": project_id, - "name": "test", - "topology": { - }, - "version": "2.0.0" - } + topology = {"project_id": project_id, "name": "test", "topology": {}, "version": "2.0.0"} - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: @@ -120,7 +113,7 @@ async def test_import_project_override(projects_dir, controller): async def write_file(path, z): - with open(path, 'wb') as f: + with open(path, "wb") as f: async for chunk in z: f.write(chunk) @@ -136,12 +129,11 @@ def export_project_with_symlink(tmpdir, controller): "name": "test", "auto_open": True, "auto_start": True, - "topology": { - }, - "version": "2.0.0" + "topology": {}, + "version": "2.0.0", } - with open(os.path.join(project.path, "project.gns3"), 'w+') as f: + with open(os.path.join(project.path, "project.gns3"), "w+") as f: json.dump(topology, f) os.makedirs(os.path.join(project.path, "vm1", "dynamips")) @@ -150,7 +142,10 @@ def export_project_with_symlink(tmpdir, controller): zip_path = str(tmpdir / "project.zip") with aiozipstream.ZipFile() as z: - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): + with patch( + "gns3server.compute.Dynamips.get_images_directory", + return_value=str(tmpdir / "IOS"), + ): await export_project(z, project, str(tmpdir), include_images=False) await write_file(zip_path, z) @@ -218,19 +213,13 @@ async def test_import_upgrade(tmpdir, controller): """ project_id = str(uuid.uuid4()) - topology = { - "project_id": str(uuid.uuid4()), - "name": "test", - "topology": { - }, - "version": "1.4.2" - } + topology = {"project_id": str(uuid.uuid4()), "name": "test", "topology": {}, "version": "1.4.2"} - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: @@ -245,22 +234,16 @@ async def test_import_upgrade(tmpdir, controller): async def test_import_with_images(config, tmpdir, controller): project_id = str(uuid.uuid4()) - topology = { - "project_id": str(uuid.uuid4()), - "name": "test", - "topology": { - }, - "version": "2.0.0" - } + topology = {"project_id": str(uuid.uuid4()), "name": "test", "topology": {}, "version": "2.0.0"} - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) - with open(str(tmpdir / "test.image"), 'w+') as f: + with open(str(tmpdir / "test.image"), "w+") as f: f.write("B") zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") myzip.write(str(tmpdir / "test.image"), "images/IOS/test.image") @@ -287,27 +270,20 @@ async def test_import_iou_linux_no_vm(linux_platform, tmpdir, controller): "name": "test", "type": "topology", "topology": { - "nodes": [ - { - "compute_id": "local", - "node_type": "iou", - "name": "test", - "properties": {} - } - ], + "nodes": [{"compute_id": "local", "node_type": "iou", "name": "test", "properties": {}}], "links": [], "computes": [], - "drawings": [] + "drawings": [], }, "revision": 5, - "version": "2.0.0" + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: @@ -338,22 +314,22 @@ async def test_import_iou_linux_with_vm(linux_platform, tmpdir, controller): "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "node_type": "iou", "name": "test", - "properties": {} + "properties": {}, } ], "links": [], "computes": [], - "drawings": [] + "drawings": [], }, "revision": 5, - "version": "2.0.0" + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: @@ -384,22 +360,22 @@ async def test_import_nat_non_linux(windows_platform, tmpdir, controller): "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "node_type": "nat", "name": "test", - "properties": {} + "properties": {}, } ], "links": [], "computes": [], - "drawings": [] + "drawings": [], }, "revision": 5, - "version": "2.0.0" + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: @@ -430,41 +406,43 @@ async def test_import_iou_non_linux(windows_platform, tmpdir, controller): "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "node_type": "iou", "name": "test", - "properties": {} + "properties": {}, }, - { - "compute_id": "local", - "node_type": "vpcs", - "name": "test2", - "properties": {} - } + {"compute_id": "local", "node_type": "vpcs", "name": "test2", "properties": {}}, ], "links": [], "computes": [], - "drawings": [] + "drawings": [], }, "revision": 5, - "version": "2.0.0" + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: with asyncio_patch("gns3server.controller.import_project._move_files_to_compute") as mock: project = await import_project(controller, project_id, f) - controller._computes["vm"].post.assert_called_with('/projects', data={'name': 'test', 'project_id': project_id}) + controller._computes["vm"].post.assert_called_with( + "/projects", data={"name": "test", "project_id": project_id} + ) with open(os.path.join(project.path, "test.gns3")) as f: topo = json.load(f) assert topo["topology"]["nodes"][0]["compute_id"] == "vm" assert topo["topology"]["nodes"][1]["compute_id"] == "local" - mock.assert_called_with(controller._computes["vm"], project_id, project.path, os.path.join('project-files', 'iou', topo["topology"]["nodes"][0]['node_id'])) + mock.assert_called_with( + controller._computes["vm"], + project_id, + project.path, + os.path.join("project-files", "iou", topo["topology"]["nodes"][0]["node_id"]), + ) @pytest.mark.asyncio @@ -487,54 +465,40 @@ async def test_import_node_id(linux_platform, tmpdir, controller): "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "node_type": "iou", "name": "test", - "properties": {} + "properties": {}, }, { "compute_id": "local", "node_id": "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", "node_type": "iou", "name": "test2", - "properties": {} - } + "properties": {}, + }, ], "links": [ { "link_id": "b570a150-c09f-47d9-8d32-9ca5b03234d6", "nodes": [ - { - "adapter_number": 0, - "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", - "port_number": 0 - }, - { - "adapter_number": 0, - "node_id": "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", - "port_number": 0 - } - ] + {"adapter_number": 0, "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "port_number": 0}, + {"adapter_number": 0, "node_id": "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", "port_number": 0}, + ], } ], "computes": [], "drawings": [ - { - "drawing_id": "08d665ba-e982-4d54-82b4-aa0c4d5ba6a3", - "rotation": 0, - "x": -210, - "y": -108, - "z": 0 - } - ] + {"drawing_id": "08d665ba-e982-4d54-82b4-aa0c4d5ba6a3", "rotation": 0, "x": -210, "y": -108, "z": 0} + ], }, "revision": 5, - "version": "2.0.0" + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) # Fake .gns3project zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") myzip.writestr("project-files/iou/0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b/startup.cfg", "test") myzip.writestr("project-files/iou/c3ae286c-c81f-40d9-a2d0-5874b2f2478d/startup.cfg", "test") @@ -545,20 +509,32 @@ async def test_import_node_id(linux_platform, tmpdir, controller): with open(os.path.join(project.path, "test.gns3")) as f: topo = json.load(f) # Node id should have change - assert topo["topology"]["nodes"][0]["node_id"] not in ["0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "c3ae286c-c81f-40d9-a2d0-5874b2f2478d"] + assert topo["topology"]["nodes"][0]["node_id"] not in [ + "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", + "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", + ] # Link should have change link = topo["topology"]["links"][0] assert link["link_id"] != "b570a150-c09f-47d9-8d32-9ca5b03234d6" - assert link["nodes"][0]["node_id"] not in ["0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "c3ae286c-c81f-40d9-a2d0-5874b2f2478d"] + assert link["nodes"][0]["node_id"] not in [ + "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", + "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", + ] # Drawing id should change assert topo["topology"]["drawings"][0]["drawing_id"] != "08d665ba-e982-4d54-82b4-aa0c4d5ba6a3" # Node files should have moved to the new node id - assert not os.path.exists(os.path.join(project.path, "project-files", "iou", "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "startup.cfg")) - assert not os.path.exists(os.path.join(project.path, "project-files", "iou", "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", "startup.cfg")) - assert os.path.exists(os.path.join(project.path, "project-files", "iou", topo["topology"]["nodes"][0]["node_id"], "startup.cfg")) + assert not os.path.exists( + os.path.join(project.path, "project-files", "iou", "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "startup.cfg") + ) + assert not os.path.exists( + os.path.join(project.path, "project-files", "iou", "c3ae286c-c81f-40d9-a2d0-5874b2f2478d", "startup.cfg") + ) + assert os.path.exists( + os.path.join(project.path, "project-files", "iou", topo["topology"]["nodes"][0]["node_id"], "startup.cfg") + ) @pytest.mark.asyncio @@ -581,22 +557,22 @@ async def test_import_keep_compute_ids(windows_platform, tmpdir, controller): "node_id": "0fd3dd4d-dc93-4a04-a9b9-7396a9e22e8b", "node_type": "iou", "name": "test", - "properties": {} + "properties": {}, } ], "links": [], "computes": [], - "drawings": [] + "drawings": [], }, "revision": 5, - "version": "2.0.0" + "version": "2.0.0", } - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: @@ -619,8 +595,18 @@ async def test_move_files_to_compute(tmpdir): with asyncio_patch("gns3server.controller.import_project._upload_file") as mock: await _move_files_to_compute(None, project_id, str(tmpdir), os.path.join("project-files", "docker")) - mock.assert_any_call(None, project_id, str(tmpdir / "project-files" / "docker" / "test"), os.path.join("project-files", "docker", "test")) - mock.assert_any_call(None, project_id, str(tmpdir / "project-files" / "docker" / "test2"), os.path.join("project-files", "docker", "test2")) + mock.assert_any_call( + None, + project_id, + str(tmpdir / "project-files" / "docker" / "test"), + os.path.join("project-files", "docker", "test"), + ) + mock.assert_any_call( + None, + project_id, + str(tmpdir / "project-files" / "docker" / "test2"), + os.path.join("project-files", "docker", "test2"), + ) assert not os.path.exists(str(tmpdir / "project-files" / "docker")) @@ -632,19 +618,13 @@ async def test_import_project_name_and_location(projects_dir, controller): tmpdir = Path(projects_dir) project_id = str(uuid.uuid4()) - topology = { - "project_id": str(uuid.uuid4()), - "name": "test", - "topology": { - }, - "version": "2.0.0" - } + topology = {"project_id": str(uuid.uuid4()), "name": "test", "topology": {}, "version": "2.0.0"} - with open(str(tmpdir / "project.gns3"), 'w+') as f: + with open(str(tmpdir / "project.gns3"), "w+") as f: json.dump(topology, f) zip_path = str(tmpdir / "project.zip") - with zipfile.ZipFile(zip_path, 'w') as myzip: + with zipfile.ZipFile(zip_path, "w") as myzip: myzip.write(str(tmpdir / "project.gns3"), "project.gns3") with open(zip_path, "rb") as f: diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 0aa29b219..1cfcdb9bf 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -65,10 +65,10 @@ async def test_add_node(project, compute): "port": node1._ports[0], "adapter_number": 0, "port_number": 4, - 'label': { - 'text': '0/4', - 'style': 'font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;' - } + "label": { + "text": "0/4", + "style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;", + }, } ] assert project.dump.called @@ -205,32 +205,32 @@ async def test_json(project, compute): "node_id": node1.id, "adapter_number": 0, "port_number": 4, - 'label': { - 'text': '0/4', - 'style': 'font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;' - } + "label": { + "text": "0/4", + "style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;", + }, }, { "node_id": node2.id, "adapter_number": 1, "port_number": 3, - 'label': { - 'text': '1/3', - 'style': 'font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;' - } - } + "label": { + "text": "1/3", + "style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;", + }, + }, ], "filters": {}, "markers": {}, "show_filters_icon": True, "link_style": {}, "suspend": False, - 'wireshark': False, + "wireshark": False, "link_type": "ethernet", "capturing": False, "capture_file_name": None, "capture_file_path": None, - "capture_compute_id": None + "capture_compute_id": None, } assert link.asdict(topology_dump=True) == { "link_id": link.id, @@ -239,26 +239,26 @@ async def test_json(project, compute): "node_id": node1.id, "adapter_number": 0, "port_number": 4, - 'label': { - 'text': '0/4', - 'style': 'font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;' - } + "label": { + "text": "0/4", + "style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;", + }, }, { "node_id": node2.id, "adapter_number": 1, "port_number": 3, - 'label': { - 'text': '1/3', - 'style': 'font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;' - } - } + "label": { + "text": "1/3", + "style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;", + }, + }, ], "link_style": {}, "filters": {}, "markers": {}, "show_filters_icon": True, - "suspend": False + "suspend": False, } @@ -295,7 +295,6 @@ async def test_default_capture_file_name(project, compute): @pytest.mark.asyncio async def test_start_capture(link): - async def fake_reader(): return AsyncioBytesIO() @@ -355,16 +354,8 @@ async def test_update_filters(project, compute): link.update = AsyncioMagicMock() assert link._created - await link.update_filters({ - "packet_loss": [10], - "delay": [50, 10], - "frequency_drop": [0], - "bpf": [" \n "] - }) - assert link.filters == { - "packet_loss": [10], - "delay": [50, 10] - } + await link.update_filters({"packet_loss": [10], "delay": [50, 10], "frequency_drop": [0], "bpf": [" \n "]}) + assert link.filters == {"packet_loss": [10], "delay": [50, 10]} assert link.update.called @@ -404,14 +395,16 @@ async def test_update_link_style(project, compute): await link.add_node(node1, 0, 4) await link.add_node(node2, 1, 3) - await link.update_link_style({ - "color": "#00ff00", - "width": 3, - "type": 1, - "link_type": "flowchart", - "bezier_curviness": 150, - "flowchart_roundness": 20, - }) + await link.update_link_style( + { + "color": "#00ff00", + "width": 3, + "type": 1, + "link_type": "flowchart", + "bezier_curviness": 150, + "flowchart_roundness": 20, + } + ) assert link.asdict()["link_style"] == { "color": "#00ff00", diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index 7bacef1f2..ec23b5d2d 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -99,6 +99,7 @@ async def _make_link(project, port_cls=EthernetPort, node_types=("vpcs", "vpcs") # UDPLink.start_marker # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_start_marker_stores_entry(project): @@ -204,8 +205,9 @@ async def test_start_marker_rejects_duplicate(project): async def test_start_marker_rejects_invalid_bpf(project): link = await _make_link(project) - with patch("gns3server.controller.udp_link.validate_bpf_syntax", - return_value={"valid": False, "error": "bad expression"}): + with patch( + "gns3server.controller.udp_link.validate_bpf_syntax", return_value={"valid": False, "error": "bad expression"} + ): with pytest.raises(ControllerError): await link.start_marker("bad", "not a real bpf") @@ -214,6 +216,7 @@ async def test_start_marker_rejects_invalid_bpf(project): # UDPLink.stop_marker # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_stop_marker_removes(project): @@ -260,6 +263,7 @@ async def test_stop_marker_unknown_raises(project): # UDPLink.update_marker # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_update_marker_preserves_render_hints(project): """A partial update (bpf only) must not reset color/highlight_duration/tag.""" @@ -271,9 +275,9 @@ async def test_update_marker_preserves_render_hints(project): entry = link.markers["m"] assert entry["bpf"] == "tcp port 80" - assert entry["color"] == "#ff5722" # preserved - assert entry["highlight_duration"] == 800 # preserved - assert entry["tag"] == 1 # preserved + assert entry["color"] == "#ff5722" # preserved + assert entry["highlight_duration"] == 800 # preserved + assert entry["tag"] == 1 # preserved @pytest.mark.asyncio @@ -315,6 +319,7 @@ async def test_update_marker_inherited_bypass(project): # Link.inherit_marker + persistence # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_inherit_marker_creates_global_copy(project): @@ -408,8 +413,8 @@ async def test_load_marker_preserves_direction_and_highlight_duration(project): # The link survives (2 attached nodes); pull it back from the project. link = project._links[link_id] entry = link._markers["icmp"] - assert entry["direction"] == "rx" # dropped before the fix - assert entry["highlight_duration"] == 800 # dropped before the fix + assert entry["direction"] == "rx" # dropped before the fix + assert entry["highlight_duration"] == 800 # dropped before the fix assert entry["tag"] == 7 assert entry["color"] == "#ff5722" assert entry["capture_node_id"] == capture_node_id @@ -435,6 +440,7 @@ async def test_asdict_markers_runtime_vs_dump(project): # Project-level marker definitions # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_create_marker_definition_fans_out(project): @@ -553,23 +559,28 @@ async def test_apply_defs_to_new_link(project): async def test_create_marker_definition_validates_bpf_once(project): # A definition validates its BPF once (project layer); the inherited fan-out # to every link must NOT re-validate — no tcpdump subprocess per link. - with patch("gns3server.controller.project.validate_bpf_syntax", - return_value={"valid": True, "error": None}) as proj_val, \ - patch("gns3server.controller.udp_link.validate_bpf_syntax", - return_value={"valid": True, "error": None}) as link_val: + with ( + patch( + "gns3server.controller.project.validate_bpf_syntax", return_value={"valid": True, "error": None} + ) as proj_val, + patch( + "gns3server.controller.udp_link.validate_bpf_syntax", return_value={"valid": True, "error": None} + ) as link_val, + ): await _make_link(project) await _make_link(project) await project.create_marker_definition("arp", "arp") - assert proj_val.call_count == 1 # validated once at the def layer - assert link_val.call_count == 0 # fan-out skipped per-link validation + assert proj_val.call_count == 1 # validated once at the def layer + assert link_val.call_count == 0 # fan-out skipped per-link validation @pytest.mark.asyncio async def test_create_marker_definition_rejects_invalid_bpf(project): - with patch("gns3server.controller.project.validate_bpf_syntax", - return_value={"valid": False, "error": "syntax error"}): + with patch( + "gns3server.controller.project.validate_bpf_syntax", return_value={"valid": False, "error": "syntax error"} + ): with pytest.raises(ControllerError): await project.create_marker_definition("arp", "not a real bpf") assert "arp" not in project.marker_definitions @@ -579,25 +590,30 @@ async def test_create_marker_definition_rejects_invalid_bpf(project): async def test_update_marker_definition_skips_per_link_validation(project): # Updating a def's BPF validates once more (project); the per-link sync # (update_marker with inherited=True) must NOT re-validate. - with patch("gns3server.controller.project.validate_bpf_syntax", - return_value={"valid": True, "error": None}) as proj_val, \ - patch("gns3server.controller.udp_link.validate_bpf_syntax", - return_value={"valid": True, "error": None}) as link_val: + with ( + patch( + "gns3server.controller.project.validate_bpf_syntax", return_value={"valid": True, "error": None} + ) as proj_val, + patch( + "gns3server.controller.udp_link.validate_bpf_syntax", return_value={"valid": True, "error": None} + ) as link_val, + ): await _make_link(project) await _make_link(project) await project.create_marker_definition("arp", "arp") await project.update_marker_definition("arp", bpf="arp or rarp") - assert proj_val.call_count == 2 # once on create, once on update - assert link_val.call_count == 0 # sync skipped per-link validation + assert proj_val.call_count == 2 # once on create, once on update + assert link_val.call_count == 0 # sync skipped per-link validation @pytest.mark.asyncio async def test_start_marker_skips_validation_for_inherited(project): # An inherited marker rides an already-validated definition BPF, so # start_marker must not call validate_bpf_syntax. - with patch("gns3server.controller.udp_link.validate_bpf_syntax", - return_value={"valid": True, "error": None}) as link_val: + with patch( + "gns3server.controller.udp_link.validate_bpf_syntax", return_value={"valid": True, "error": None} + ) as link_val: link = await _make_link(project) await link.inherit_marker("arp", {"bpf": "arp"}) @@ -608,8 +624,9 @@ async def test_start_marker_skips_validation_for_inherited(project): @pytest.mark.asyncio async def test_start_marker_validates_for_private(project): # A private (non-inherited) marker still validates inline. - with patch("gns3server.controller.udp_link.validate_bpf_syntax", - return_value={"valid": True, "error": None}) as link_val: + with patch( + "gns3server.controller.udp_link.validate_bpf_syntax", return_value={"valid": True, "error": None} + ) as link_val: link = await _make_link(project) await link.start_marker("icmp", "icmp") @@ -636,6 +653,7 @@ async def test_markers_aggregation(project): # Direction clear/preserve semantics (sentinel _UNSET vs explicit None) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_update_marker_clears_direction(project): # Explicit direction=None clears the filter back to "both directions" — @@ -685,6 +703,7 @@ async def test_update_marker_definition_clears_direction(project): # Capture-node routing + capability validation # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_pinned_marker_routes_only_to_chosen_node(project): # The marker rides only the pinned capture node's NIO; the far endpoint sees nothing. @@ -725,6 +744,7 @@ async def test_start_marker_rejects_non_capable_capture_node(project): # Part A/B: enabled reaches uBridge + instant per-filter toggle # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_markers_for_node_keeps_disabled_and_carries_enabled(project): # Part A: a disabled marker is NOT dropped from the NIO payload (so uBridge @@ -790,6 +810,7 @@ async def test_update_marker_ui_only_does_not_push(project): # Per-definition pause/resume (toggle every inherited global-{name} copy) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_pause_marker_definition_toggles_copies_off(project): with _valid_bpf(): @@ -843,7 +864,7 @@ async def test_create_marker_definition_rejects_directional(project): @pytest.mark.asyncio async def test_create_marker_definition_allows_both(project): - await project.create_marker_definition("arp", "arp") # default both + await project.create_marker_definition("arp", "arp") # default both await project.create_marker_definition("icmp", "icmp", direction=None) assert project.marker_definitions["arp"]["direction"] is None assert project.marker_definitions["icmp"]["direction"] is None @@ -851,7 +872,7 @@ async def test_create_marker_definition_allows_both(project): @pytest.mark.asyncio async def test_update_marker_definition_rejects_directional(project): - await project.create_marker_definition("arp", "arp") # both + await project.create_marker_definition("arp", "arp") # both with pytest.raises(ControllerError): await project.update_marker_definition("arp", direction="tx") # omitted direction and explicit clear-to-both are both fine @@ -872,6 +893,4 @@ async def test_stop_marker_deletes_capture_pcap(project): capture.delete = AsyncioMagicMock() await link.stop_marker("icmp") - capture.delete.assert_called_once_with( - "/adapters/0/ports/0/markers/icmp", params={"link_id": link.id} - ) + capture.delete.assert_called_once_with("/adapters/0/ports/0/markers/icmp", params={"link_id": link.id}) diff --git a/tests/controller/test_marker_replay.py b/tests/controller/test_marker_replay.py index 19888bbd0..e958d3888 100644 --- a/tests/controller/test_marker_replay.py +++ b/tests/controller/test_marker_replay.py @@ -98,8 +98,9 @@ def _icmp_frame(): icmp = bytes([8, 0, 0, 0]) + struct.pack(">HHH", 1, 1, 0) + b"payload12" icmp = icmp[:2] + struct.pack(">H", _cksum(icmp)) + icmp[4:] - ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(icmp), 1, 0, 64, 1, 0, - bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3])) + ip0 = struct.pack( + ">BBHHHBBH4s4s", 0x45, 0, 20 + len(icmp), 1, 0, 64, 1, 0, bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3]) + ) ip = ip0[:10] + struct.pack(">H", _cksum(ip0)) + ip0[12:] return bytes.fromhex("0200000000020200000000010800") + ip + icmp @@ -108,8 +109,9 @@ def _tcp_syn_frame(): """A minimal TCP SYN (10.0.0.1:472 → 10.0.0.3:22).""" tcp = struct.pack(">HHIIBBHHH", 472, 22, 0, 0, 0x50, 0x02, 64240, 0, 0) - ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(tcp), 2, 0, 64, 6, 0, - bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3])) + ip0 = struct.pack( + ">BBHHHBBH4s4s", 0x45, 0, 20 + len(tcp), 2, 0, 64, 6, 0, bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3]) + ) ip = ip0[:10] + struct.pack(">H", _cksum(ip0)) + ip0[12:] tcp = tcp[:16] + struct.pack(">H", _cksum(ip0[12:] + tcp)) + tcp[18:] return bytes.fromhex("0200000000020200000000010800") + ip + tcp @@ -122,10 +124,17 @@ def _fake_project(tmp_path, markers, markers_dir=None): def _marker_entry(tag, enabled=True, node_id="node-1"): - return {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None, - "highlight_duration": None, "capture_node_id": node_id, - "direction": None, "data_link_type": "DLT_EN10MB", - "node_id": node_id} + return { + "bpf": "icmp", + "tag": tag, + "enabled": enabled, + "color": None, + "highlight_duration": None, + "capture_node_id": node_id, + "direction": None, + "data_link_type": "DLT_EN10MB", + "node_id": node_id, + } def _fake_columns(monkeypatch, mapping): @@ -142,8 +151,7 @@ def _fake_columns(monkeypatch, mapping): def _cols(src="10.0.0.1", dst="10.0.0.3", proto="ICMP", info="Echo (ping) request"): - return {"src": src, "dst": dst, "proto": proto, "info": info, - "bg": "ffffff", "fg": "000000"} + return {"src": src, "dst": dst, "proto": proto, "info": info, "bg": "ffffff", "fg": "000000"} class _FakeSession: @@ -180,17 +188,20 @@ def _pretend_sharkd(monkeypatch): # pcap scanning (engine-free backbone) # --------------------------------------------------------------------------- -class TestScanPcap: +class TestScanPcap: async def test_scans_frames_and_truncated_tail(self, tmp_path): pcap = tmp_path / "a.pcap" - _write_pcap(pcap, [ - (1693472000, 123456, b"x" * 60), - (1693472001, 654321, b"y" * 40), - ]) + _write_pcap( + pcap, + [ + (1693472000, 123456, b"x" * 60), + (1693472001, 654321, b"y" * 40), + ], + ) # Tear the final record in half: a snapshot mid-write must not raise. data = bytearray(pcap.read_bytes()) - pcap.write_bytes(data[:len(data) - 20]) + pcap.write_bytes(data[: len(data) - 20]) frames = scan_pcap_frames(str(pcap)) assert frames == [(1693472000, 123456, 60)] @@ -202,10 +213,13 @@ class TestScanPcap: async def test_read_frame_bytes_offsets(self, tmp_path): pcap = tmp_path / "b.pcap" - _write_pcap(pcap, [ - (100, 0, b"first" + b"0" * 55), # 60 bytes - (200, 0, b"second"), # 6 bytes - ]) + _write_pcap( + pcap, + [ + (100, 0, b"first" + b"0" * 55), # 60 bytes + (200, 0, b"second"), # 6 bytes + ], + ) assert read_frame_bytes(str(pcap), 2) == b"second".hex() assert read_frame_bytes(str(pcap), 1) == (b"first" + b"0" * 55).hex() assert read_frame_bytes(str(pcap), 3) is None @@ -221,18 +235,21 @@ class TestScanPcap: # Tag gate (engine-free — raised before any sharkd work) # --------------------------------------------------------------------------- -class TestGate: +class TestGate: async def test_unknown_tag_404(self, tmp_path): project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=1)}) with pytest.raises(ControllerNotFoundError): await build_timeline(project, tag=7) async def test_gate_409_while_capturing(self, tmp_path): - project = _fake_project(tmp_path, { - "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), - "linkB/icmp": _marker_entry(tag=7, enabled=True, node_id="n2"), - }) + project = _fake_project( + tmp_path, + { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + "linkB/icmp": _marker_entry(tag=7, enabled=True, node_id="n2"), + }, + ) with pytest.raises(ControllerError, match="linkB"): await build_timeline(project, tag=7) @@ -247,26 +264,38 @@ class TestGate: # Timeline assembly (columns injected — no engine needed) # --------------------------------------------------------------------------- -class TestTimeline: +class TestTimeline: async def test_merge_orders_by_ts_with_stable_tiebreak(self, tmp_path, monkeypatch): # Two sources, deliberately interleaved in time, colliding on one µs. - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 500000, b"a" * 60), # t1 sourceA - (1693472002, 000000, b"a" * 60), # t3 sourceA - ]) - _write_pcap(tmp_path / "n2_linkB_icmp.pcap", [ - (1693472001, 000000, b"b" * 60), # t2 sourceB - (1693472002, 000000, b"b" * 60), # t3 sourceB — same µs as t3 sourceA - ]) - _fake_columns(monkeypatch, { - "n1_linkA_icmp.pcap": {1: _cols(), 2: _cols()}, - "n2_linkB_icmp.pcap": {1: _cols(src="10.0.0.2"), 2: _cols(src="10.0.0.2")}, - }) - project = _fake_project(tmp_path, { - "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), - "linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"), - }) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 500000, b"a" * 60), # t1 sourceA + (1693472002, 000000, b"a" * 60), # t3 sourceA + ], + ) + _write_pcap( + tmp_path / "n2_linkB_icmp.pcap", + [ + (1693472001, 000000, b"b" * 60), # t2 sourceB + (1693472002, 000000, b"b" * 60), # t3 sourceB — same µs as t3 sourceA + ], + ) + _fake_columns( + monkeypatch, + { + "n1_linkA_icmp.pcap": {1: _cols(), 2: _cols()}, + "n2_linkB_icmp.pcap": {1: _cols(src="10.0.0.2"), 2: _cols(src="10.0.0.2")}, + }, + ) + project = _fake_project( + tmp_path, + { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + "linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"), + }, + ) timeline = await build_timeline(project, tag=7) assert timeline["frame_count"] == 4 @@ -306,10 +335,14 @@ class TestTimeline: async def test_frame_list_is_uncapped(self, tmp_path, monkeypatch): # The list is the whole contract — no truncation flag, no buckets, # however many frames the tag holds. - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 0, b"a" * 60), (1693472000, 500000, b"a" * 60), - (1693472001, 0, b"a" * 60), - ]) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 0, b"a" * 60), + (1693472000, 500000, b"a" * 60), + (1693472001, 0, b"a" * 60), + ], + ) _fake_columns(monkeypatch, {"n1_linkA_icmp.pcap": {1: _cols(), 2: _cols(), 3: _cols()}}) project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")}) @@ -321,11 +354,14 @@ class TestTimeline: async def test_filter_applies_before_count_and_slice(self, tmp_path, monkeypatch): # Three frames; the injected "matching set" (what a real engine would # return for the filter) contains only frames 1 and 3. - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 0, b"a" * 60), - (1693472001, 0, b"a" * 60), - (1693472002, 0, b"a" * 60), - ]) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 0, b"a" * 60), + (1693472001, 0, b"a" * 60), + (1693472002, 0, b"a" * 60), + ], + ) _fake_columns(monkeypatch, {"n1_linkA_icmp.pcap": {1: _cols(), 3: _cols(proto="TCP")}}) project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")}) @@ -346,14 +382,20 @@ class TestLinkFilter: counting/slicing/bucketing; sources stay the full inventory.""" def _project(self, tmp_path, monkeypatch, columns_override=None): - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 500000, b"a" * 60), - (1693472002, 000000, b"a" * 60), - ]) - _write_pcap(tmp_path / "n2_linkB_icmp.pcap", [ - (1693472001, 000000, b"b" * 60), - (1693472002, 000000, b"b" * 60), - ]) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 500000, b"a" * 60), + (1693472002, 000000, b"a" * 60), + ], + ) + _write_pcap( + tmp_path / "n2_linkB_icmp.pcap", + [ + (1693472001, 000000, b"b" * 60), + (1693472002, 000000, b"b" * 60), + ], + ) async def fake_columns(pcap, filter_expr): if columns_override is not None: @@ -362,10 +404,13 @@ class TestLinkFilter: return {1: _cols(src=src), 2: _cols(src=src)} monkeypatch.setattr(marker_replay, "_columns_for", fake_columns) - return _fake_project(tmp_path, { - "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), - "linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"), - }) + return _fake_project( + tmp_path, + { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + "linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"), + }, + ) async def test_link_narrows_before_count_and_slice(self, tmp_path, monkeypatch): timeline = await build_timeline(self._project(tmp_path, monkeypatch), tag=7, link_id="linkA") @@ -373,9 +418,7 @@ class TestLinkFilter: assert timeline["start"] == "1693472000.500000" assert [f["link_id"] for f in timeline["frames"]] == ["linkA", "linkA"] # sources stay the FULL inventory with engine-free totals. - assert sorted((s["link_id"], s["count"]) for s in timeline["sources"]) == [ - ("linkA", 2), ("linkB", 2) - ] + assert sorted((s["link_id"], s["count"]) for s in timeline["sources"]) == [("linkA", 2), ("linkB", 2)] async def test_unknown_link_is_empty_success(self, tmp_path, monkeypatch): timeline = await build_timeline(self._project(tmp_path, monkeypatch), tag=7, link_id="nope") @@ -403,35 +446,35 @@ class TestLinkFilter: async def test_query_frames_window_over_link_stream(self, tmp_path, monkeypatch): project = self._project(tmp_path, monkeypatch) - result = await query_frames(project, tag=7, ts="1693472002.000000", - window_ms=0, link_id="linkB") + result = await query_frames(project, tag=7, ts="1693472002.000000", window_ms=0, link_id="linkB") assert [f["link_id"] for f in result["frames"]] == ["linkB"] class TestQueryFrames: - def _project(self, tmp_path, monkeypatch): - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 0, b"a" * 60), - (1693472000, 150000, b"a" * 60), - (1693472005, 0, b"a" * 60), - ]) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 0, b"a" * 60), + (1693472000, 150000, b"a" * 60), + (1693472005, 0, b"a" * 60), + ], + ) _fake_columns(monkeypatch, {"n1_linkA_icmp.pcap": {i: _cols() for i in (1, 2, 3)}}) return _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")}) async def test_window_inclusive_bounds(self, tmp_path, monkeypatch): - result = await query_frames(self._project(tmp_path, monkeypatch), tag=7, - ts="1693472000.000000", window_ms=150) + result = await query_frames(self._project(tmp_path, monkeypatch), tag=7, ts="1693472000.000000", window_ms=150) assert [f["ts"] for f in result["frames"]] == ["1693472000.000000", "1693472000.150000"] async def test_window_miss_is_empty_success(self, tmp_path, monkeypatch): - result = await query_frames(self._project(tmp_path, monkeypatch), tag=7, - ts="1693472001.000000", window_ms=100) + result = await query_frames(self._project(tmp_path, monkeypatch), tag=7, ts="1693472001.000000", window_ms=100) assert result == {"frames": []} async def test_limit_applies(self, tmp_path, monkeypatch): - result = await query_frames(self._project(tmp_path, monkeypatch), tag=7, - ts="1693472000.000000", window_ms=150, limit=1) + result = await query_frames( + self._project(tmp_path, monkeypatch), tag=7, ts="1693472000.000000", window_ms=150, limit=1 + ) assert len(result["frames"]) == 1 @@ -439,20 +482,30 @@ class TestQueryFrames: # Tree key renaming # --------------------------------------------------------------------------- -class TestRename: +class TestRename: async def test_renames_closed_key_set_and_drops_hf_id(self): node = { - "t": "proto", "l": "Time to Live: 64", "fn": "ip.ttl", - "f": "ip.ttl == 64", "h": [22, 1], "s": None, "g": False, + "t": "proto", + "l": "Time to Live: 64", + "fn": "ip.ttl", + "f": "ip.ttl == 64", + "h": [22, 1], + "s": None, + "g": False, "e": 8472, "n": [{"l": "nested", "h": [23, 2], "n": []}], } renamed = _rename_value(node) assert renamed == { - "element": "proto", "label": "Time to Live: 64", "name": "ip.ttl", - "filter_expr": "ip.ttl == 64", "pos": 22, "size": 1, - "expert": None, "generated": False, + "element": "proto", + "label": "Time to Live: 64", + "name": "ip.ttl", + "filter_expr": "ip.ttl == 64", + "pos": 22, + "size": 1, + "expert": None, + "generated": False, "children": [{"label": "nested", "pos": 23, "size": 2, "children": []}], } @@ -470,8 +523,8 @@ class TestRename: # sharkd sessions + engine-backed behaviour # --------------------------------------------------------------------------- -class TestSessions: +class TestSessions: async def test_missing_sharkd_raises_501_error(self, tmp_path): _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [(1693472000, 0, b"a" * 60)]) project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")}) @@ -539,9 +592,7 @@ class TestSessions: pcap = tmp_path / "n1_linkA_icmp.pcap" _write_pcap(pcap, [(1693472000, 0, b"a" * 60)]) with patch.object(manager, "_spawn", side_effect=slow_spawn): - first, second = await asyncio.gather( - manager._acquire(str(pcap)), manager._acquire(str(pcap)) - ) + first, second = await asyncio.gather(manager._acquire(str(pcap)), manager._acquire(str(pcap))) assert first is second # concurrent requests share one spawn assert len(spawns) == 1 await manager.close_all() @@ -553,12 +604,12 @@ class TestSessions: # A process that never answers: the session must die (never serve the # late reply to a later request) instead of staying resident. proc = await asyncio.create_subprocess_exec( - "sleep", "60", - stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - ) - session = marker_replay._SharkdSession( - str(pcap), str(tmp_path / "scratch"), "unused", proc, os.stat(str(pcap)) + "sleep", + "60", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, ) + session = marker_replay._SharkdSession(str(pcap), str(tmp_path / "scratch"), "unused", proc, os.stat(str(pcap))) with pytest.raises(SharkdError, match="timed out"): await session.rpc("frames", {}) assert session.alive() is False @@ -579,12 +630,12 @@ class TestSessions: pcap = tmp_path / "n1_linkA_icmp.pcap" _write_pcap(pcap, [(1693472000, 0, b"a" * 60)]) proc = await asyncio.create_subprocess_exec( - sys.executable, str(fake), - stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - ) - session = marker_replay._SharkdSession( - str(pcap), str(tmp_path / "scratch"), "unused", proc, os.stat(str(pcap)) + sys.executable, + str(fake), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, ) + session = marker_replay._SharkdSession(str(pcap), str(tmp_path / "scratch"), "unused", proc, os.stat(str(pcap))) with pytest.raises(SharkdError, match="id mismatch"): await session.rpc("frames", {}) assert session.alive() is False @@ -635,10 +686,13 @@ class TestSessions: @sharkd_present async def test_real_session_columns_and_filter(self, tmp_path): - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 123456, _icmp_frame()), - (1693472001, 0, _tcp_syn_frame()), - ]) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 123456, _icmp_frame()), + (1693472001, 0, _tcp_syn_frame()), + ], + ) manager = marker_replay._get_manager() try: columns = await marker_replay._columns_for(str(tmp_path / "n1_linkA_icmp.pcap"), None) @@ -683,47 +737,51 @@ class TestSessions: class TestDecodeFrame: - def _project(self, tmp_path): - _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ - (1693472000, 123456, _icmp_frame()), - ]) - return _fake_project(tmp_path, { - "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), - }) + _write_pcap( + tmp_path / "n1_linkA_icmp.pcap", + [ + (1693472000, 123456, _icmp_frame()), + ], + ) + return _fake_project( + tmp_path, + { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + }, + ) async def test_ts_mismatch_guard_404(self, tmp_path): project = self._project(tmp_path) with pytest.raises(ControllerNotFoundError, match="rebuilt"): - await decode_frame(project, tag=7, ts="1.000000", - node_id="n1", link_id="linkA", marker="icmp") + await decode_frame(project, tag=7, ts="1.000000", node_id="n1", link_id="linkA", marker="icmp") async def test_unknown_source_404(self, tmp_path): project = self._project(tmp_path) with pytest.raises(ControllerNotFoundError): - await decode_frame(project, tag=7, ts="1693472000.123456", - node_id="nobody", link_id="linkA", marker="icmp") + await decode_frame(project, tag=7, ts="1693472000.123456", node_id="nobody", link_id="linkA", marker="icmp") async def test_explicit_frame_number_out_of_range_404(self, tmp_path): project = self._project(tmp_path) with pytest.raises(ControllerNotFoundError, match="rebuilt"): - await decode_frame(project, tag=7, ts="1693472000.123456", - node_id="n1", link_id="linkA", marker="icmp", frame_number=5) + await decode_frame( + project, tag=7, ts="1693472000.123456", node_id="n1", link_id="linkA", marker="icmp", frame_number=5 + ) async def test_explicit_frame_number_ts_mismatch_404(self, tmp_path): # The frame number must still land on the exact round-tripped ts — # a rebuilt capture cannot be decoded by stale coordinates. project = self._project(tmp_path) with pytest.raises(ControllerNotFoundError, match="rebuilt"): - await decode_frame(project, tag=7, ts="1693472001.000000", - node_id="n1", link_id="linkA", marker="icmp", frame_number=1) + await decode_frame( + project, tag=7, ts="1693472001.000000", node_id="n1", link_id="linkA", marker="icmp", frame_number=1 + ) async def test_hex_read_failure_is_404_not_null_hex(self, tmp_path, monkeypatch): project = self._project(tmp_path) monkeypatch.setattr(marker_replay, "read_frame_bytes", lambda path, n: None) with pytest.raises(ControllerNotFoundError, match="rebuilt"): - await decode_frame(project, tag=7, ts="1693472000.123456", - node_id="n1", link_id="linkA", marker="icmp") + await decode_frame(project, tag=7, ts="1693472000.123456", node_id="n1", link_id="linkA", marker="icmp") @sharkd_present async def test_same_microsecond_frames_disambiguated_by_frame_number(self, tmp_path): @@ -732,16 +790,20 @@ class TestDecodeFrame: pcap = tmp_path / "n1_linkA_icmp.pcap" icmp, tcp = _icmp_frame(), _tcp_syn_frame() _write_pcap(pcap, [(1693472000, 123456, icmp), (1693472000, 123456, tcp)]) - project = _fake_project(tmp_path, { - "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), - }) + project = _fake_project( + tmp_path, + { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + }, + ) manager = marker_replay._get_manager() try: - default = await decode_frame(project, tag=7, ts="1693472000.123456", - node_id="n1", link_id="linkA", marker="icmp") - second = await decode_frame(project, tag=7, ts="1693472000.123456", - node_id="n1", link_id="linkA", marker="icmp", - frame_number=2) + default = await decode_frame( + project, tag=7, ts="1693472000.123456", node_id="n1", link_id="linkA", marker="icmp" + ) + second = await decode_frame( + project, tag=7, ts="1693472000.123456", node_id="n1", link_id="linkA", marker="icmp", frame_number=2 + ) finally: await manager.close_all() # Without a frame number: first ts match. @@ -757,8 +819,9 @@ class TestDecodeFrame: manager = marker_replay._get_manager() try: project = self._project(tmp_path) - detail = await decode_frame(project, tag=7, ts="1693472000.123456", - node_id="n1", link_id="linkA", marker="icmp") + detail = await decode_frame( + project, tag=7, ts="1693472000.123456", node_id="n1", link_id="linkA", marker="icmp" + ) finally: await manager.close_all() @@ -789,10 +852,13 @@ class TestDecodeFrame: trees, sharkd's raw key set stays within the census-known keys.""" pcap = tmp_path / "proto_mix.pcap" - _write_pcap(pcap, [ - (1693472000, 100000, _icmp_frame()), - (1693472001, 0, _tcp_syn_frame()), - ]) + _write_pcap( + pcap, + [ + (1693472000, 100000, _icmp_frame()), + (1693472001, 0, _tcp_syn_frame()), + ], + ) manager = marker_replay._get_manager() try: known = set(marker_replay._KEY_RENAME) | {"h", "e"} diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 8ce0d428b..300d410bd 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -40,11 +40,15 @@ def compute(): @pytest.fixture def node(compute, project): - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="vpcs", - console_type="vnc", - properties={"startup_script": "echo test"}) + node = Node( + project, + compute, + "demo", + node_id=str(uuid.uuid4()), + node_type="vpcs", + console_type="vnc", + properties={"startup_script": "echo test"}, + ) return node @@ -55,22 +59,29 @@ def test_docker_iol_ports_grouped_in_four_port_units(compute, project): the native IOU node type. Plain docker nodes keep the flat eth naming. """ - node = Node(project, compute, "iol", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"adapters": 2, "environment": "GNS3_IOL_RUNNER=1"}) + node = Node( + project, + compute, + "iol", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"adapters": 2, "environment": "GNS3_IOL_RUNNER=1"}, + ) ports = node.ports assert [p.asdict()["name"] for p in ports] == [ - "Ethernet0/0", "Ethernet0/1", "Ethernet0/2", "Ethernet0/3", - "Ethernet1/0", "Ethernet1/1", "Ethernet1/2", "Ethernet1/3", + "Ethernet0/0", + "Ethernet0/1", + "Ethernet0/2", + "Ethernet0/3", + "Ethernet1/0", + "Ethernet1/1", + "Ethernet1/2", + "Ethernet1/3", ] assert (ports[5].adapter_number, ports[5].port_number) == (1, 1) assert ports[5].short_name == "e1/1" - node = Node(project, compute, "web", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"adapters": 2}) + node = Node(project, compute, "web", node_id=str(uuid.uuid4()), node_type="docker", properties={"adapters": 2}) assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"] @@ -82,10 +93,14 @@ def test_docker_iol_startup_config_materialized_once(compute, project, monkeypat afterwards the node's configuration lives in its NVRAM on the compute. """ - node = Node(project, compute, "iol", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=my-iol-config.txt"}) + node = Node( + project, + compute, + "iol", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=my-iol-config.txt"}, + ) monkeypatch.setattr(node, "_base_config_file_content", lambda path: "hostname %h\n") data = node._node_data() @@ -100,10 +115,14 @@ def test_docker_iol_startup_config_materialized_once(compute, project, monkeypat def test_docker_iol_startup_config_missing_file_keeps_knob(compute, project, monkeypatch): - node = Node(project, compute, "iol", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=gone.txt"}) + node = Node( + project, + compute, + "iol", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=gone.txt"}, + ) monkeypatch.setattr(node, "_base_config_file_content", lambda path: None) data = node._node_data() @@ -132,23 +151,35 @@ def test_name(compute, project): If node use a name template generate names """ - node = Node(project, compute, "PC", - node_id=str(uuid.uuid4()), - node_type="vpcs", - console_type="vnc", - properties={"startup_script": "echo test"}) + node = Node( + project, + compute, + "PC", + node_id=str(uuid.uuid4()), + node_type="vpcs", + console_type="vnc", + properties={"startup_script": "echo test"}, + ) assert node.name == "PC" - node = Node(project, compute, "PC{0}", - node_id=str(uuid.uuid4()), - node_type="vpcs", - console_type="vnc", - properties={"startup_script": "echo test"}) + node = Node( + project, + compute, + "PC{0}", + node_id=str(uuid.uuid4()), + node_type="vpcs", + console_type="vnc", + properties={"startup_script": "echo test"}, + ) assert node.name == "PC1" - node = Node(project, compute, "PC{0}", - node_id=str(uuid.uuid4()), - node_type="vpcs", - console_type="vnc", - properties={"startup_script": "echo test"}) + node = Node( + project, + compute, + "PC{0}", + node_id=str(uuid.uuid4()), + node_type="vpcs", + console_type="vnc", + properties={"startup_script": "echo test"}, + ) assert node.name == "PC2" @@ -158,10 +189,7 @@ def test_vmname(compute, project): field """ - node = Node(project, compute, "PC", - node_id=str(uuid.uuid4()), - node_type="virtualbox", - vmname="test") + node = Node(project, compute, "PC", node_id=str(uuid.uuid4()), node_type="virtualbox", vmname="test") assert node.properties["vmname"] == "test" @@ -169,13 +197,9 @@ def test_empty_properties(compute, project): """ Empty properties need to be ignored """ - node = Node(project, compute, "PC", - node_id=str(uuid.uuid4()), - node_type="virtualbox", - aa="", - bb=None, - category=2, - cc="xx") + node = Node( + project, compute, "PC", node_id=str(uuid.uuid4()), node_type="virtualbox", aa="", bb=None, category=2, cc="xx" + ) assert "aa" not in node.properties assert "bb" not in node.properties assert "cc" in node.properties @@ -188,7 +212,9 @@ async def test_eq(compute, project, node, controller): assert node == Node(project, compute, "demo1", node_id=node.id, node_type="qemu") assert node != "a" assert node != Node(project, compute, "demo2", node_id=str(uuid.uuid4()), node_type="qemu") - assert node != Node(Project(str(uuid.uuid4()), controller=controller), compute, "demo3", node_id=node.id, node_type="qemu") + assert node != Node( + Project(str(uuid.uuid4()), controller=controller), compute, "demo3", node_id=node.id, node_type="qemu" + ) def test_json(node, compute): @@ -233,11 +259,11 @@ def test_json(node, compute): "link_type": "ethernet", "name": "Ethernet0", "port_number": 0, - "short_name": "e0" + "short_name": "e0", } ], "missing_image": False, - "missing_images": [] + "missing_images": [], } assert node.asdict(topology_dump=True) == { @@ -272,9 +298,7 @@ def test_json(node, compute): def test_init_without_uuid(project, compute): - node = Node(project, compute, "demo", - node_type="vpcs", - console_type="vnc") + node = Node(project, compute, "demo", node_type="vpcs", console_type="vnc") assert node.id is not None @@ -287,13 +311,7 @@ async def test_create(node, compute): compute.post = AsyncioMagicMock(return_value=response) assert await node.create() is True - data = { - "console": 2048, - "console_type": "vnc", - "node_id": node.id, - "startup_script": "echo test", - "name": "demo" - } + data = {"console": 2048, "console_type": "vnc", "node_id": node.id, "startup_script": "echo test", "name": "demo"} compute.post.assert_called_with("/projects/{}/vpcs/nodes".format(node.project.id), data=data, timeout=1200) assert node._console == 2048 assert node._properties == {"startup_script": "echo test"} @@ -319,7 +337,7 @@ async def test_create_image_missing(node, compute): node._upload_missing_image = AsyncioMagicMock(return_value=True) assert await node.create() is True - #assert node._upload_missing_image.called is True + # assert node._upload_missing_image.called is True @pytest.mark.asyncio @@ -330,15 +348,19 @@ async def test_create_image_missing_kept_in_degraded_state(project, compute, tmp """ config.settings.Server.images_path = str(tmpdir) - node = Node(project, compute, "r1", - node_id=str(uuid.uuid4()), - node_type="qemu", - properties={"hda_disk_image": "missing.qcow2", "ram": 256}) + node = Node( + project, + compute, + "r1", + node_id=str(uuid.uuid4()), + node_type="qemu", + properties={"hda_disk_image": "missing.qcow2", "ram": 256}, + ) async def resp(*args, **kwargs): raise ComputeConflictError( "/projects/{}/qemu/nodes".format(project.id), - {"message": "The image is missing", "image": "missing.qcow2", "exception": "ImageMissingError"} + {"message": "The image is missing", "image": "missing.qcow2", "exception": "ImageMissingError"}, ) compute.post = AsyncioMagicMock(side_effect=resp) @@ -346,9 +368,7 @@ async def test_create_image_missing_kept_in_degraded_state(project, compute, tmp assert await node.create(allow_missing_image=True) is False assert node.missing_image is True - assert node.missing_images == [ - {"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"} - ] + assert node.missing_images == [{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}] @pytest.mark.asyncio @@ -359,15 +379,19 @@ async def test_create_image_missing_raises_by_default(project, compute, tmpdir, """ config.settings.Server.images_path = str(tmpdir) - node = Node(project, compute, "r1", - node_id=str(uuid.uuid4()), - node_type="qemu", - properties={"hda_disk_image": "missing.qcow2", "ram": 256}) + node = Node( + project, + compute, + "r1", + node_id=str(uuid.uuid4()), + node_type="qemu", + properties={"hda_disk_image": "missing.qcow2", "ram": 256}, + ) async def resp(*args, **kwargs): raise ComputeConflictError( "/projects/{}/qemu/nodes".format(project.id), - {"message": "The image is missing", "image": "missing.qcow2", "exception": "ImageMissingError"} + {"message": "The image is missing", "image": "missing.qcow2", "exception": "ImageMissingError"}, ) compute.post = AsyncioMagicMock(side_effect=resp) @@ -478,15 +502,19 @@ async def test_create_docker_image_missing_after_failed_pull(project, compute): degraded state instead of aborting the project open. """ - node = Node(project, compute, "web", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"image": "ghost:latest", "adapters": 1}) + node = Node( + project, + compute, + "web", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"image": "ghost:latest", "adapters": 1}, + ) async def resp(*args, **kwargs): raise ComputeConflictError( "/projects/{}/docker/nodes".format(project.id), - {"message": "The image is missing", "image": "ghost:latest", "exception": "ImageMissingError"} + {"message": "The image is missing", "image": "ghost:latest", "exception": "ImageMissingError"}, ) compute.post = AsyncioMagicMock(side_effect=resp) @@ -496,9 +524,7 @@ async def test_create_docker_image_missing_after_failed_pull(project, compute): assert await node.create(allow_missing_image=True) is False assert node.missing_image is True - assert node.missing_images == [ - {"property": "image", "image": "ghost:latest", "image_type": "docker"} - ] + assert node.missing_images == [{"property": "image", "image": "ghost:latest", "image_type": "docker"}] @pytest.mark.asyncio @@ -507,15 +533,19 @@ async def test_create_docker_image_missing_pull_error_raises_by_default(project, Without allow_missing_image a failed Docker pull is still surfaced. """ - node = Node(project, compute, "web", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"image": "ghost:latest", "adapters": 1}) + node = Node( + project, + compute, + "web", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"image": "ghost:latest", "adapters": 1}, + ) async def resp(*args, **kwargs): raise ComputeConflictError( "/projects/{}/docker/nodes".format(project.id), - {"message": "The image is missing", "image": "ghost:latest", "exception": "ImageMissingError"} + {"message": "The image is missing", "image": "ghost:latest", "exception": "ImageMissingError"}, ) compute.post = AsyncioMagicMock(side_effect=resp) @@ -534,15 +564,19 @@ def test_compute_missing_images_lists_all_unavailable_slots(project, compute, tm """ config.settings.Server.images_path = str(tmpdir) - node = Node(project, compute, "r1", - node_id=str(uuid.uuid4()), - node_type="qemu", - properties={ - "hda_disk_image": "present.qcow2", - "hdc_disk_image": "missing.qcow2", - "initrd": "missing.initrd", - "ram": 256, - }) + node = Node( + project, + compute, + "r1", + node_id=str(uuid.uuid4()), + node_type="qemu", + properties={ + "hda_disk_image": "present.qcow2", + "hdc_disk_image": "missing.qcow2", + "initrd": "missing.initrd", + "ram": 256, + }, + ) # make one image available on the controller os.makedirs(os.path.join(str(tmpdir), "QEMU"), exist_ok=True) with open(os.path.join(str(tmpdir), "QEMU", "present.qcow2"), "w") as f: @@ -556,9 +590,7 @@ def test_compute_missing_images_lists_all_unavailable_slots(project, compute, tm @pytest.mark.asyncio async def test_start_missing_image(node): - node._missing_images = [ - {"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"} - ] + node._missing_images = [{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}] with pytest.raises(ControllerError): await node.start() @@ -570,9 +602,7 @@ async def test_update_recreates_missing_image_node(project, compute, node): the missing image state. """ - node._missing_images = [ - {"property": "image", "image": "missing.image", "image_type": "ios"} - ] + node._missing_images = [{"property": "image", "image": "missing.image", "image_type": "ios"}] response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) @@ -600,9 +630,7 @@ async def test_update_replaces_qemu_linked_clone_backing_image(project, compute) "hda_disk_image_md5sum": "old-checksum", }, ) - qemu_node._missing_images = [ - {"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"} - ] + qemu_node._missing_images = [{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}] response = MagicMock() response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) @@ -627,18 +655,14 @@ async def test_update_replaces_qemu_linked_clone_backing_image(project, compute) async def test_update_missing_image_node_rolls_back_after_create_error(project, compute, node): original_properties = {"image": "missing.image"} node._properties = original_properties.copy() - node._missing_images = [ - {"property": "image", "image": "missing.image", "image_type": "ios"} - ] + node._missing_images = [{"property": "image", "image": "missing.image", "image_type": "ios"}] compute.post = AsyncioMagicMock(side_effect=ControllerError("create failed")) with pytest.raises(ControllerError): await node.update(properties={"image": "invalid.image"}) assert node.properties == original_properties - assert node.missing_images == [ - {"property": "image", "image": "missing.image", "image_type": "ios"} - ] + assert node.missing_images == [{"property": "image", "image": "missing.image", "image_type": "ios"}] @pytest.mark.asyncio @@ -659,9 +683,7 @@ async def test_start_retries_ready_deferred_links(node, project): @pytest.mark.asyncio async def test_stop_missing_image_node_does_not_contact_compute(node, compute): - node._missing_images = [ - {"property": "image", "image": "missing.image", "image_type": "ios"} - ] + node._missing_images = [{"property": "image", "image": "missing.image", "image_type": "ios"}] compute.post = AsyncioMagicMock() await node.stop() @@ -673,8 +695,8 @@ async def test_stop_missing_image_node_does_not_contact_compute(node, compute): async def test_create_base_script(node, config, compute, tmpdir): config.settings.Server.configs_path = str(tmpdir) - with open(str(tmpdir / 'test.txt'), 'w+') as f: - f.write('hostname test') + with open(str(tmpdir / "test.txt"), "w+") as f: + f.write("hostname test") node._properties = {"base_script_file": "test.txt"} node._console = 2048 @@ -689,7 +711,7 @@ async def test_create_base_script(node, config, compute, tmpdir): "console_type": "vnc", "node_id": node.id, "startup_script": "hostname test", - "name": "demo" + "name": "demo", } compute.post.assert_called_with("/projects/{}/vpcs/nodes".format(node.project.id), data=data, timeout=1200) @@ -713,7 +735,9 @@ def test_symbol(node, symbols_dir): assert node.height == 71 assert node.label["x"] is None assert node.label["y"] == -40 - assert node.label["style"] == None#"font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;" + assert ( + node.label["style"] == None + ) # "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;" shutil.copy(os.path.join("gns3server", "symbols", "classic", "cloud.svg"), os.path.join(symbols_dir, "cloud2.svg")) node.symbol = "cloud2.svg" @@ -734,15 +758,14 @@ def test_label_with_default_label_font(node): the correct color """ node.project.controller.settings = { - "GraphicsView": { - "default_label_color": "#ff0000", - "default_label_font": "TypeWriter,10,-1,5,75,0,0,0,0,0" - } + "GraphicsView": {"default_label_color": "#ff0000", "default_label_font": "TypeWriter,10,-1,5,75,0,0,0,0,0"} } node._label = None node.symbol = ":/symbols/dslam.svg" - assert node.label["style"] == None #"font-family: TypeWriter;font-size: 10;font-weight: bold;fill: #ff0000;fill-opacity: 1.0;" + assert ( + node.label["style"] == None + ) # "font-family: TypeWriter;font-size: 10;font-weight: bold;fill: #ff0000;fill-opacity: 1.0;" @pytest.mark.asyncio @@ -755,17 +778,12 @@ async def test_update(node, compute, project, controller): project.dump = MagicMock() await node.update(x=42, console=2048, console_type="vnc", properties={"startup_script": "echo test"}, name="demo") - data = { - "console": 2048, - "console_type": "vnc", - "startup_script": "echo test", - "name": "demo" - } + data = {"console": 2048, "console_type": "vnc", "startup_script": "echo test", "name": "demo"} compute.put.assert_called_with("/projects/{}/vpcs/nodes/{}".format(node.project.id, node.id), data=data) assert node._console == 2048 assert node.x == 42 assert node._properties == {"startup_script": "echo test"} - #controller._notification.emit.assert_called_with("node.updated", node.asdict()) + # controller._notification.emit.assert_called_with("node.updated", node.asdict()) assert project.dump.called @@ -780,12 +798,7 @@ async def test_update_properties(node, compute, controller): controller._notification = AsyncioMagicMock() await node.update(x=42, console=2048, console_type="vnc", properties={"startup_script": "hello world"}, name="demo") - data = { - "console": 2048, - "console_type": "vnc", - "startup_script": "hello world", - "name": "demo" - } + data = {"console": 2048, "console_type": "vnc", "startup_script": "hello world", "name": "demo"} compute.put.assert_called_with("/projects/{}/vpcs/nodes/{}".format(node.project.id, node.id), data=data) assert node._console == 2048 assert node.x == 42 @@ -793,9 +806,9 @@ async def test_update_properties(node, compute, controller): # The notif should contain the old properties because it's the compute that will emit # the correct info - #node_notif = copy.deepcopy(node.asdict()) - #node_notif["properties"]["startup_script"] = "echo test" - #controller._notification.emit.assert_called_with("node.updated", node_notif) + # node_notif = copy.deepcopy(node.asdict()) + # node_notif["properties"]["startup_script"] = "echo test" + # controller._notification.emit.assert_called_with("node.updated", node_notif) @pytest.mark.asyncio @@ -888,8 +901,7 @@ def test_default_credentials_from_template_kwargs(compute, project): default credentials without sending them to the compute. """ - node = Node(project, compute, "test", node_type="vpcs", - default_username="root", default_password="cisco123") + node = Node(project, compute, "test", node_type="vpcs", default_username="root", default_password="cisco123") assert node.default_username == "root" assert node.default_password == "cisco123" assert "default_username" not in node.properties @@ -927,18 +939,20 @@ async def test_start(node, compute): @pytest.mark.asyncio async def test_start_iou(compute, project, controller): - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="iou") + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="iou") compute.post = AsyncioMagicMock() # Without licence configured it should raise an error - #with pytest.raises(aiohttp.web.HTTPConflict): + # with pytest.raises(aiohttp.web.HTTPConflict): # async_run(node.start()) controller._iou_license_settings = {"license_check": True, "iourc_content": "aa"} await node.start() - compute.post.assert_called_with("/projects/{}/iou/nodes/{}/start".format(node.project.id, node.id), timeout=240, data={"license_check": True, "iourc_content": "aa"}) + compute.post.assert_called_with( + "/projects/{}/iou/nodes/{}/start".format(node.project.id, node.id), + timeout=240, + data={"license_check": True, "iourc_content": "aa"}, + ) @pytest.mark.asyncio @@ -947,7 +961,9 @@ async def test_stop(node, compute): compute.post = AsyncioMagicMock() await node.stop() - compute.post.assert_called_with("/projects/{}/vpcs/nodes/{}/stop".format(node.project.id, node.id), timeout=240, dont_connect=True) + compute.post.assert_called_with( + "/projects/{}/vpcs/nodes/{}/stop".format(node.project.id, node.id), timeout=240, dont_connect=True + ) @pytest.mark.asyncio @@ -977,12 +993,7 @@ async def test_create_without_console(node, compute): compute.post = AsyncioMagicMock(return_value=response) await node.create() - data = { - "console_type": "vnc", - "node_id": node.id, - "startup_script": "echo test", - "name": "demo" - } + data = {"console_type": "vnc", "node_id": node.id, "startup_script": "echo test", "name": "demo"} compute.post.assert_called_with("/projects/{}/vpcs/nodes".format(node.project.id), data=data, timeout=1200) assert node._console == 2048 assert node._properties == {"test_value": "success", "startup_script": "echo test"} @@ -1017,7 +1028,9 @@ async def test_dynamips_idle_pc(node, compute): response.json = {"idlepc": "0x60606f54"} compute.get = AsyncioMagicMock(return_value=response) await node.dynamips_auto_idlepc() - compute.get.assert_called_with("/projects/{}/dynamips/nodes/{}/auto_idlepc".format(node.project.id, node.id), timeout=240) + compute.get.assert_called_with( + "/projects/{}/dynamips/nodes/{}/auto_idlepc".format(node.project.id, node.id), timeout=240 + ) @pytest.mark.asyncio @@ -1028,18 +1041,24 @@ async def test_dynamips_idlepc_proposals(node, compute): response.json = ["0x60606f54", "0x30ff6f37"] compute.get = AsyncioMagicMock(return_value=response) await node.dynamips_idlepc_proposals() - compute.get.assert_called_with("/projects/{}/dynamips/nodes/{}/idlepc_proposals".format(node.project.id, node.id), timeout=240) + compute.get.assert_called_with( + "/projects/{}/dynamips/nodes/{}/idlepc_proposals".format(node.project.id, node.id), timeout=240 + ) @pytest.mark.asyncio async def test_upload_missing_image(compute, controller, images_dir): project = Project(str(uuid.uuid4()), controller=controller) - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="qemu", - properties={"hda_disk_image": "linux.img"}) - open(os.path.join(images_dir, "linux.img"), 'w+').close() + node = Node( + project, + compute, + "demo", + node_id=str(uuid.uuid4()), + node_type="qemu", + properties={"hda_disk_image": "linux.img"}, + ) + open(os.path.join(images_dir, "linux.img"), "w+").close() assert await node._upload_missing_image("qemu", "linux.img") is True compute.post.assert_called_with("/qemu/images/linux.img", data=ANY, timeout=None) @@ -1048,10 +1067,14 @@ async def test_upload_missing_image(compute, controller, images_dir): async def test_upload_missing_image_from_nested_directory(compute, controller, images_dir): project = Project(str(uuid.uuid4()), controller=controller) - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="qemu", - properties={"hda_disk_image": "nested.img"}) + node = Node( + project, + compute, + "demo", + node_id=str(uuid.uuid4()), + node_type="qemu", + properties={"hda_disk_image": "nested.img"}, + ) nested_dir = os.path.join(images_dir, "vendor") os.makedirs(nested_dir) open(os.path.join(nested_dir, "nested.img"), "w+").close() @@ -1064,10 +1087,9 @@ async def test_upload_missing_image_from_nested_directory(compute, controller, i async def test_sync_missing_docker_image_from_controller_daemon(compute, controller): project = Project(str(uuid.uuid4()), controller=controller) - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"image": "gns3/frr:latest"}) + node = Node( + project, compute, "demo", node_id=str(uuid.uuid4()), node_type="docker", properties={"image": "gns3/frr:latest"} + ) response = MagicMock() response.content = MagicMock() @@ -1082,10 +1104,9 @@ async def test_sync_missing_docker_image_from_controller_daemon(compute, control async def test_sync_missing_docker_image_pull_fallback(compute, controller): project = Project(str(uuid.uuid4()), controller=controller) - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"image": "nginx:latest"}) + node = Node( + project, compute, "demo", node_id=str(uuid.uuid4()), node_type="docker", properties={"image": "nginx:latest"} + ) with asyncio_patch("gns3server.compute.docker.Docker.http_query", side_effect=DockerError("404")): assert await node._upload_missing_image("docker", "nginx:latest") is True @@ -1096,10 +1117,14 @@ async def test_sync_missing_docker_image_pull_fallback(compute, controller): async def test_create_docker_node_pins_image_digest(compute, controller): project = Project(str(uuid.uuid4()), controller=controller) - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="docker", - properties={"image": "gns3/frr:latest", "adapters": 1}) + node = Node( + project, + compute, + "demo", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"image": "gns3/frr:latest", "adapters": 1}, + ) response = MagicMock() response.status = 200 diff --git a/tests/controller/test_node_port_name.py b/tests/controller/test_node_port_name.py index 26c054d23..bfd108197 100644 --- a/tests/controller/test_node_port_name.py +++ b/tests/controller/test_node_port_name.py @@ -33,11 +33,15 @@ def compute(): @pytest.fixture def node(compute, project): - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="qemu", - console_type="vnc", - properties={"startup_script": "echo test"}) + node = Node( + project, + compute, + "demo", + node_id=str(uuid.uuid4()), + node_type="qemu", + console_type="vnc", + properties={"startup_script": "echo test"}, + ) return node @@ -53,7 +57,7 @@ def test_list_ports(node): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", } ] @@ -71,7 +75,7 @@ def test_list_ports_vpcs(node): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", } ] @@ -90,7 +94,7 @@ def test_list_ports_docker(node): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "eth1", @@ -98,8 +102,8 @@ def test_list_ports_docker(node): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 1, - "link_type": "ethernet" - } + "link_type": "ethernet", + }, ] @@ -153,7 +157,7 @@ def test_list_ports_adapters(node): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet1", @@ -161,8 +165,8 @@ def test_list_ports_adapters(node): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 1, - "link_type": "ethernet" - } + "link_type": "ethernet", + }, ] @@ -171,17 +175,8 @@ def test_list_ports_adapters_cloud(project, compute): List port using adapters properties """ - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="cloud") - node.properties["ports_mapping"] = [ - { - "interface": "eth0", - "name": "eth0", - "port_number": 0, - "type": "ethernet" - } - ] + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="cloud") + node.properties["ports_mapping"] = [{"interface": "eth0", "name": "eth0", "port_number": 0, "type": "ethernet"}] assert node.asdict()["ports"] == [ { @@ -190,7 +185,7 @@ def test_list_ports_adapters_cloud(project, compute): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", } ] @@ -200,18 +195,10 @@ def test_list_ports_ethernet_hub(project, compute): List port for atm switch """ - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="ethernet_hub") + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="ethernet_hub") node.properties["ports_mapping"] = [ - { - "name": "Ethernet0", - "port_number": 0 - }, - { - "name": "Ethernet1", - "port_number": 1 - } + {"name": "Ethernet0", "port_number": 0}, + {"name": "Ethernet1", "port_number": 1}, ] assert node.asdict()["ports"] == [ @@ -221,7 +208,7 @@ def test_list_ports_ethernet_hub(project, compute): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet1", @@ -229,8 +216,8 @@ def test_list_ports_ethernet_hub(project, compute): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 1, "adapter_number": 0, - "link_type": "ethernet" - } + "link_type": "ethernet", + }, ] @@ -239,12 +226,8 @@ def test_list_ports_atm_switch(project, compute): List port for atm switch """ - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="atm_switch") - node.properties["mappings"] = { - "1:0:100": "10:0:200" - } + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="atm_switch") + node.properties["mappings"] = {"1:0:100": "10:0:200"} assert node.asdict()["ports"] == [ { @@ -253,7 +236,7 @@ def test_list_ports_atm_switch(project, compute): "data_link_types": {"ATM": "DLT_ATM_RFC1483"}, "port_number": 1, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "10", @@ -261,8 +244,8 @@ def test_list_ports_atm_switch(project, compute): "data_link_types": {"ATM": "DLT_ATM_RFC1483"}, "port_number": 10, "adapter_number": 0, - "link_type": "serial" - } + "link_type": "serial", + }, ] @@ -271,13 +254,8 @@ def test_list_ports_frame_relay_switch(project, compute): List port for frame relay switch """ - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="frame_relay_switch") - node.properties["mappings"] = { - "1:101": "10:202", - "2:102": "11:203" - } + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="frame_relay_switch") + node.properties["mappings"] = {"1:101": "10:202", "2:102": "11:203"} assert node.asdict()["ports"] == [ { @@ -286,7 +264,7 @@ def test_list_ports_frame_relay_switch(project, compute): "data_link_types": {"Frame Relay": "DLT_FRELAY"}, "port_number": 1, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "2", @@ -294,7 +272,7 @@ def test_list_ports_frame_relay_switch(project, compute): "data_link_types": {"Frame Relay": "DLT_FRELAY"}, "port_number": 2, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "10", @@ -302,7 +280,7 @@ def test_list_ports_frame_relay_switch(project, compute): "data_link_types": {"Frame Relay": "DLT_FRELAY"}, "port_number": 10, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "11", @@ -310,8 +288,8 @@ def test_list_ports_frame_relay_switch(project, compute): "data_link_types": {"Frame Relay": "DLT_FRELAY"}, "port_number": 11, "adapter_number": 0, - "link_type": "serial" - } + "link_type": "serial", + }, ] @@ -320,9 +298,7 @@ def test_list_ports_iou(compute, project): IOU has a special behavior 4 port by adapters """ - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="iou") + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="iou") node.properties["serial_adapters"] = 2 node.properties["ethernet_adapters"] = 3 assert node.asdict()["ports"] == [ @@ -332,7 +308,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet0/1", @@ -340,7 +316,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 1, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet0/2", @@ -348,7 +324,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 2, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet0/3", @@ -356,7 +332,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 3, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet1/0", @@ -364,7 +340,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 1, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet1/1", @@ -372,7 +348,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 1, "adapter_number": 1, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet1/2", @@ -380,7 +356,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 2, "adapter_number": 1, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet1/3", @@ -388,7 +364,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 3, "adapter_number": 1, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet2/0", @@ -396,7 +372,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 2, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet2/1", @@ -404,7 +380,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 1, "adapter_number": 2, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet2/2", @@ -412,7 +388,7 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 2, "adapter_number": 2, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Ethernet2/3", @@ -420,104 +396,72 @@ def test_list_ports_iou(compute, project): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 3, "adapter_number": 2, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Serial3/0", "short_name": "s3/0", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 0, "adapter_number": 3, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial3/1", "short_name": "s3/1", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 1, "adapter_number": 3, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial3/2", "short_name": "s3/2", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 2, "adapter_number": 3, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial3/3", "short_name": "s3/3", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 3, "adapter_number": 3, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial4/0", "short_name": "s4/0", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 0, "adapter_number": 4, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial4/1", "short_name": "s4/1", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 1, "adapter_number": 4, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial4/2", "short_name": "s4/2", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 2, "adapter_number": 4, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial4/3", "short_name": "s4/3", - "data_link_types": { - "Frame Relay": "DLT_FRELAY", - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL" - }, + "data_link_types": {"Frame Relay": "DLT_FRELAY", "Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL"}, "port_number": 3, "adapter_number": 4, - "link_type": "serial" - } + "link_type": "serial", + }, ] @@ -526,9 +470,7 @@ def test_list_ports_dynamips(project, compute): List port for dynamips """ - node = Node(project, compute, "demo", - node_id=str(uuid.uuid4()), - node_type="dynamips") + node = Node(project, compute, "demo", node_id=str(uuid.uuid4()), node_type="dynamips") node.properties["slot0"] = "C7200-IO-FE" node.properties["slot1"] = "GT96100-FE" node.properties["wic0"] = "WIC-2T" @@ -541,7 +483,7 @@ def test_list_ports_dynamips(project, compute): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 0, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "FastEthernet1/0", @@ -549,7 +491,7 @@ def test_list_ports_dynamips(project, compute): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 0, "adapter_number": 1, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "FastEthernet1/1", @@ -557,52 +499,40 @@ def test_list_ports_dynamips(project, compute): "data_link_types": {"Ethernet": "DLT_EN10MB"}, "port_number": 1, "adapter_number": 1, - "link_type": "ethernet" + "link_type": "ethernet", }, { "name": "Serial0/0", "short_name": "s0/0", - "data_link_types": { - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL", - "Frame Relay": "DLT_FRELAY"}, + "data_link_types": {"Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL", "Frame Relay": "DLT_FRELAY"}, "port_number": 16, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial0/1", "short_name": "s0/1", - "data_link_types": { - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL", - "Frame Relay": "DLT_FRELAY"}, + "data_link_types": {"Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL", "Frame Relay": "DLT_FRELAY"}, "port_number": 17, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial0/2", "short_name": "s0/2", - "data_link_types": { - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL", - "Frame Relay": "DLT_FRELAY"}, + "data_link_types": {"Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL", "Frame Relay": "DLT_FRELAY"}, "port_number": 32, "adapter_number": 0, - "link_type": "serial" + "link_type": "serial", }, { "name": "Serial0/3", "short_name": "s0/3", - "data_link_types": { - "Cisco HDLC": "DLT_C_HDLC", - "Cisco PPP": "DLT_PPP_SERIAL", - "Frame Relay": "DLT_FRELAY"}, + "data_link_types": {"Cisco HDLC": "DLT_C_HDLC", "Cisco PPP": "DLT_PPP_SERIAL", "Frame Relay": "DLT_FRELAY"}, "port_number": 33, "adapter_number": 0, - "link_type": "serial" - } + "link_type": "serial", + }, ] diff --git a/tests/controller/test_notification.py b/tests/controller/test_notification.py index 754559154..1369a9b11 100644 --- a/tests/controller/test_notification.py +++ b/tests/controller/test_notification.py @@ -43,10 +43,10 @@ async def test_emit_to_all(controller, project): notif = controller.notification with notif.project_queue(project.id) as queue: assert len(notif._project_listeners[project.id]) == 1 - await queue.get(0.1) # ping - notif.project_emit('test', {}) + await queue.get(0.1) # ping + notif.project_emit("test", {}) msg = await queue.get(5) - assert msg == ('test', {}, {}) + assert msg == ("test", {}, {}) assert len(notif._project_listeners[project.id]) == 0 @@ -62,10 +62,10 @@ async def test_emit_to_project(controller, project): assert len(notif._project_listeners[project.id]) == 1 await queue.get(0.1) # ping # This event has not listener - notif.project_emit('ignore', {"project_id": 42}) - notif.project_emit('test', {"project_id": project.id}) + notif.project_emit("ignore", {"project_id": 42}) + notif.project_emit("test", {"project_id": project.id}) msg = await queue.get(5) - assert msg == ('test', {"project_id": project.id}, {}) + assert msg == ("test", {"project_id": project.id}, {}) assert len(notif._project_listeners[project.id]) == 0 @@ -79,7 +79,7 @@ async def test_dispatch(controller, project): await queue.get(0.1) # ping await notif.dispatch("test", {}, project_id=project.id, compute_id=1) msg = await queue.get(5) - assert msg == ('test', {}, {}) + assert msg == ("test", {}, {}) @pytest.mark.asyncio @@ -91,7 +91,7 @@ async def test_dispatch_ping(controller, project): await queue.get(0.1) # ping await notif.dispatch("ping", {}, project_id=project.id, compute_id=12) msg = await queue.get(5) - assert msg == ('ping', {'compute_id': 12}, {}) + assert msg == ("ping", {"compute_id": 12}, {}) @pytest.mark.asyncio @@ -105,14 +105,12 @@ async def test_dispatch_node_updated(controller, node, project): with notif.project_queue(project.id) as queue: assert len(notif._project_listeners[project.id]) == 1 await queue.get(0.1) # ping - await notif.dispatch("node.updated", { - "node_id": node.id, - "project_id": project.id, - "name": "hello", - "startup_config": "ip 192" - }, + await notif.dispatch( + "node.updated", + {"node_id": node.id, "project_id": project.id, "name": "hello", "startup_config": "ip 192"}, project_id=project.id, - compute_id=1) + compute_id=1, + ) assert node.name == "hello" action, event, _ = await queue.get(5) assert action == "node.updated" @@ -128,18 +126,16 @@ async def test_dispatch_marker_routed_to_marker_channel(controller, project): """ notif = controller.notification - with notif.project_queue(project.id) as project_q, \ - notif.project_marker_queue(project.id) as marker_q: + with notif.project_queue(project.id) as project_q, notif.project_marker_queue(project.id) as marker_q: assert len(notif._project_marker_listeners[project.id]) == 1 await project_q.get(0.1) # consume initial ping await marker_q.get(0.1) # consume initial ping - await notif.dispatch("marker.match", {"link_id": "abc"}, - project_id=project.id, compute_id=1) + await notif.dispatch("marker.match", {"link_id": "abc"}, project_id=project.id, compute_id=1) # marker.match lands on the marker channel... msg = await marker_q.get(5) - assert msg == ('marker.match', {"link_id": "abc"}, {}) + assert msg == ("marker.match", {"link_id": "abc"}, {}) # ...and does NOT land on the main project queue (times out -> ping) msg = await project_q.get(0.1) assert msg[0] == "ping" diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 978a5d4d8..6b8d2651e 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -53,18 +53,18 @@ async def node(controller, project): @pytest.mark.asyncio async def test_affect_uuid(): - with patch('gns3server.controller.project.Project.emit_controller_notification') as mock_notification: + with patch("gns3server.controller.project.Project.emit_controller_notification") as mock_notification: p = Project(name="Test") mock_notification.assert_called() assert len(p.id) == 36 - p = Project(project_id='00010203-0405-0607-0809-0a0b0c0d0e0f', name="Test 2") - assert p.id == '00010203-0405-0607-0809-0a0b0c0d0e0f' + p = Project(project_id="00010203-0405-0607-0809-0a0b0c0d0e0f", name="Test 2") + assert p.id == "00010203-0405-0607-0809-0a0b0c0d0e0f" @pytest.mark.asyncio async def test_json(): - with patch('gns3server.controller.project.Project.emit_controller_notification') as mock_notification: + with patch("gns3server.controller.project.Project.emit_controller_notification") as mock_notification: p = Project(name="Test") mock_notification.assert_called() @@ -89,14 +89,14 @@ async def test_json(): "supplier": None, "variables": None, "marker_definitions": {}, - "created_by": None + "created_by": None, } @pytest.mark.asyncio async def test_created_by(): - with patch('gns3server.controller.project.Project.emit_controller_notification'): + with patch("gns3server.controller.project.Project.emit_controller_notification"): p = Project(name="Test", created_by="admin") assert p.created_by == "admin" @@ -124,7 +124,7 @@ async def test_update_on_compute(controller): project._project_created_on_compute = [compute] project.emit_notification = MagicMock() await project.update(variables=variables) - compute.put.assert_any_call('/projects/{}'.format(project.id), {"variables": variables}) + compute.put.assert_any_call("/projects/{}".format(project.id), {"variables": variables}) @pytest.mark.asyncio @@ -132,7 +132,7 @@ async def test_path(projects_dir): directory = projects_dir with patch("gns3server.utils.path.get_default_project_directory", return_value=directory): - with patch('gns3server.controller.project.Project.emit_controller_notification') as mock_notification: + with patch("gns3server.controller.project.Project.emit_controller_notification") as mock_notification: p = Project(project_id=str(uuid4()), name="Test") mock_notification.assert_called() assert p.path == os.path.join(directory, p.id) @@ -153,7 +153,7 @@ def test_path_exist(tmpdir): @pytest.mark.asyncio async def test_init_path(projects_dir): - with patch('gns3server.controller.project.Project.emit_controller_notification') as mock_notification: + with patch("gns3server.controller.project.Project.emit_controller_notification") as mock_notification: project_id = str(uuid4()) p = Project(project_id=project_id, name="Test") mock_notification.assert_called() @@ -164,15 +164,15 @@ async def test_init_path(projects_dir): async def test_changing_path_with_quote_not_allowed(projects_dir): with pytest.raises(ControllerForbiddenError): - with patch('gns3server.controller.project.Project.emit_controller_notification'): + with patch("gns3server.controller.project.Project.emit_controller_notification"): p = Project(project_id=str(uuid4()), name="Test") - p.path = os.path.join(projects_dir, "project\"53") + p.path = os.path.join(projects_dir, 'project"53') @pytest.mark.asyncio async def test_captures_directory(tmpdir): - with patch('gns3server.controller.project.Project.emit_controller_notification'): + with patch("gns3server.controller.project.Project.emit_controller_notification"): p = Project(name="Test") assert p.captures_directory == str(p.path + os.path.sep + "project-files" + os.path.sep + "captures") assert os.path.exists(p.captures_directory) @@ -196,16 +196,19 @@ async def test_add_node_local(controller): node = await project.add_node(compute, "test", None, node_type="vpcs", properties={"startup_script": "test.cfg"}) assert node.id in project._nodes - compute.post.assert_any_call('/projects', data={ - "name": project._name, - "project_id": project._id, - "path": project._path, - }) - compute.post.assert_any_call('/projects/{}/vpcs/nodes'.format(project.id), - data={'node_id': node.id, - 'startup_script': 'test.cfg', - 'name': 'test'}, - timeout=1200) + compute.post.assert_any_call( + "/projects", + data={ + "name": project._name, + "project_id": project._id, + "path": project._path, + }, + ) + compute.post.assert_any_call( + "/projects/{}/vpcs/nodes".format(project.id), + data={"node_id": node.id, "startup_script": "test.cfg", "name": "test"}, + timeout=1200, + ) assert compute in project._project_created_on_compute project.emit_notification.assert_any_call("node.created", node.asdict()) @@ -269,13 +272,12 @@ async def test_add_node_non_local(controller): node = await project.add_node(compute, "test", None, node_type="vpcs", properties={"startup_script": "test.cfg"}) - compute.post.assert_any_call('/projects', data={ - "name": project._name, - "project_id": project._id - }) - compute.post.assert_any_call('/projects/{}/vpcs/nodes'.format(project.id), data={'node_id': node.id, - 'startup_script': 'test.cfg', - 'name': 'test'}, timeout=1200) + compute.post.assert_any_call("/projects", data={"name": project._name, "project_id": project._id}) + compute.post.assert_any_call( + "/projects/{}/vpcs/nodes".format(project.id), + data={"node_id": node.id, "startup_script": "test.cfg", "name": "test"}, + timeout=1200, + ) assert compute in project._project_created_on_compute project.emit_notification.assert_any_call("node.created", node.asdict()) @@ -538,7 +540,7 @@ async def test_delete_node(controller): await project.delete_node(node.id) assert node.id not in project._nodes - compute.delete.assert_any_call('/projects/{}/vpcs/nodes/{}'.format(project.id, node.id)) + compute.delete.assert_any_call("/projects/{}/vpcs/nodes/{}".format(project.id, node.id)) project.emit_notification.assert_any_call("node.deleted", node.asdict()) @@ -585,7 +587,7 @@ async def test_delete_node_delete_link(controller): assert node.id not in project._nodes assert link.id not in project._links - compute.delete.assert_any_call('/projects/{}/vpcs/nodes/{}'.format(project.id, node.id)) + compute.delete.assert_any_call("/projects/{}/vpcs/nodes/{}".format(project.id, node.id)) project.emit_notification.assert_any_call("node.deleted", node.asdict()) project.emit_notification.assert_any_call("link.deleted", link.asdict()) @@ -763,10 +765,7 @@ async def test_clean_pictures_and_keep_supplier_logo(project): When a project is close old pictures should be removed """ - project.supplier = { - 'logo': 'logo.png', - 'url': 'http://acme.com' - } + project.supplier = {"logo": "logo.png", "url": "http://acme.com"} drawing = await project.add_drawing() drawing._svg = "test.png" @@ -838,8 +837,8 @@ async def test_dump(projects_dir): directory = projects_dir with patch("gns3server.utils.path.get_default_project_directory", return_value=directory): - with patch('gns3server.controller.project.Project.emit_controller_notification'): - p = Project(project_id='00010203-0405-0607-0809-0a0b0c0d0e0f', name="Test") + with patch("gns3server.controller.project.Project.emit_controller_notification"): + p = Project(project_id="00010203-0405-0607-0809-0a0b0c0d0e0f", name="Test") p.dump() with open(os.path.join(directory, p.id, "Test.gns3")) as f: content = f.read() @@ -849,7 +848,7 @@ async def test_dump(projects_dir): @pytest.mark.asyncio async def test_open_close(controller): - with patch('gns3server.controller.project.Project.emit_controller_notification'): + with patch("gns3server.controller.project.Project.emit_controller_notification"): project = Project(controller=controller, name="Test") assert project.status == "opened" await project.close() @@ -866,13 +865,13 @@ async def test_open_close(controller): @pytest.mark.asyncio async def test_open_auto_start(controller): - with patch('gns3server.controller.project.Project.emit_controller_notification'): + with patch("gns3server.controller.project.Project.emit_controller_notification"): project = Project(controller=controller, name="Test", auto_start=True) assert project.status == "opened" await project.close() - #project.start_all = AsyncioMagicMock() - #await project.open() - #assert project.start_all.called + # project.start_all = AsyncioMagicMock() + # await project.open() + # assert project.start_all.called def test_is_running(project, node): @@ -901,10 +900,14 @@ async def test_duplicate(project, controller): response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - remote_vpcs = await project.add_node(compute, "test", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) + remote_vpcs = await project.add_node( + compute, "test", None, node_type="vpcs", properties={"startup_config": "test.cfg"} + ) # We allow node not allowed for standard import / export - remote_virtualbox = await project.add_node(compute, "test", None, node_type="vmware", properties={"startup_config": "test.cfg"}) + remote_virtualbox = await project.add_node( + compute, "test", None, node_type="vmware", properties={"startup_config": "test.cfg"} + ) new_project = await project.duplicate(name="Hello") assert new_project.id != project.id @@ -1092,13 +1095,21 @@ async def test_node_name(project): response.json = {"console": 2048} compute.post = AsyncioMagicMock(return_value=response) - node = await project.add_node(compute, "test-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) + node = await project.add_node( + compute, "test-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"} + ) assert node.name == "test-1" - node = await project.add_node(compute, "test-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) + node = await project.add_node( + compute, "test-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"} + ) assert node.name == "test-2" - node = await project.add_node(compute, "hello world-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) + node = await project.add_node( + compute, "hello world-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"} + ) assert node.name == "helloworld-1" - node = await project.add_node(compute, "hello world-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) + node = await project.add_node( + compute, "hello world-{0}", None, node_type="vpcs", properties={"startup_config": "test.cfg"} + ) assert node.name == "helloworld-2" node = await project.add_node(compute, "VPCS-1", None, node_type="vpcs", properties={"startup_config": "test.cfg"}) assert node.name == "VPCS-1" @@ -1119,13 +1130,8 @@ async def test_duplicate_node(project): compute.post = AsyncioMagicMock(return_value=response) original = await project.add_node( - compute, - "test", - None, - node_type="vpcs", - properties={ - "startup_config": "test.cfg" - }) + compute, "test", None, node_type="vpcs", properties={"startup_config": "test.cfg"} + ) new_node = await project.duplicate_node(original, 42, 10, 11) assert new_node.x == 42 @@ -1164,9 +1170,7 @@ async def test_add_node_missing_image_kept_in_degraded_state(controller): ) assert node.missing_image is True assert node.id in project._nodes - assert node.missing_images == [ - {"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"} - ] + assert node.missing_images == [{"property": "hda_disk_image", "image": "missing.qcow2", "image_type": "qemu"}] @pytest.mark.asyncio diff --git a/tests/controller/test_project_open.py b/tests/controller/test_project_open.py index 073fa3877..1d5ce49d9 100644 --- a/tests/controller/test_project_open.py +++ b/tests/controller/test_project_open.py @@ -42,22 +42,16 @@ def demo_topology(): "revision": 5, "topology": { "computes": [ - { - "compute_id": "local", - "host": "127.0.0.1", - "name": "atlantis", - "port": 3080, - "protocol": "http" - } + {"compute_id": "local", "host": "127.0.0.1", "name": "atlantis", "port": 3080, "protocol": "http"} ], "drawings": [ { "drawing_id": "48bdaa23-326a-4de0-bf7d-cc22709689ec", "rotation": 0, - "svg": "", + "svg": '', "x": -226, "y": 57, - "z": 0 + "z": 0, } ], "links": [ @@ -71,10 +65,10 @@ def demo_topology(): "style": "font-family: TypeWriter;font-size: 10;font-weight: bold;fill: #000000;fill-opacity: 1.0;", "text": "Ethernet0", "x": 72, - "y": 32 + "y": 32, }, "node_id": "64ba8408-afbf-4b66-9cdd-1fd854427478", - "port_number": 0 + "port_number": 0, }, { "adapter_number": 0, @@ -83,12 +77,12 @@ def demo_topology(): "style": "font-family: TypeWriter;font-size: 10;font-weight: bold;fill: #000000;fill-opacity: 1.0;", "text": "Ethernet0", "x": -7, - "y": 26 + "y": 26, }, "node_id": "748bcd89-624a-40eb-a8d3-1d2e85c99b51", - "port_number": 0 - } - ] + "port_number": 0, + }, + ], } ], "nodes": [ @@ -102,18 +96,17 @@ def demo_topology(): "style": "font-family: TypeWriter;font-size: 10;font-weight: bold;fill: #000000;fill-opacity: 1.0;", "text": "PC1", "x": 18, - "y": -25 + "y": -25, }, "name": "PC1", "node_id": "64ba8408-afbf-4b66-9cdd-1fd854427478", "node_type": "vpcs", - "properties": { - }, + "properties": {}, "symbol": ":/symbols/computer.svg", "width": 65, "x": -300, "y": -118, - "z": 1 + "z": 1, }, { "compute_id": "vm", @@ -125,28 +118,27 @@ def demo_topology(): "style": "font-family: TypeWriter;font-size: 10;font-weight: bold;fill: #000000;fill-opacity: 1.0;", "text": "PC2", "x": 18, - "y": -25 + "y": -25, }, "name": "PC2", "node_id": "748bcd89-624a-40eb-a8d3-1d2e85c99b51", "node_type": "vpcs", - "properties": { - }, + "properties": {}, "symbol": ":/symbols/computer.svg", "width": 65, "x": -71, "y": -98, - "z": 1 - } - ] + "z": 1, + }, + ], }, "type": "topology", - "version": "2.0.0" + "version": "2.0.0", } # @pytest.mark.asyncio -#async def test_load_project(controller, tmpdir, demo_topology, http_client): +# async def test_load_project(controller, tmpdir, demo_topology, http_client): # # with open(str(tmpdir / "demo.gns3"), "w+") as f: # json.dump(demo_topology, f) @@ -182,14 +174,9 @@ async def test_open(controller, projects_dir): "name": "demo", "project_id": "3c1be6f9-b4ba-4737-b209-63c47c23359f", "revision": 5, - "topology": { - "computes": [], - "drawings": [], - "links": [], - "nodes": [] - }, + "topology": {"computes": [], "drawings": [], "links": [], "nodes": []}, "type": "topology", - "version": "2.0.0" + "version": "2.0.0", } project_dir = os.path.join(projects_dir, "demo") @@ -197,12 +184,14 @@ async def test_open(controller, projects_dir): with open(os.path.join(project_dir, "demo.gns3"), "w+") as f: json.dump(simple_topology, f) - project = Project(name="demo", - project_id="64ba8408-afbf-4b66-9cdd-1fd854427478", - path=project_dir, - controller=controller, - filename="demo.gns3", - status="closed") + project = Project( + name="demo", + project_id="64ba8408-afbf-4b66-9cdd-1fd854427478", + path=project_dir, + controller=controller, + filename="demo.gns3", + status="closed", + ) await project.open() assert project.status == "opened" @@ -212,7 +201,7 @@ async def test_open(controller, projects_dir): # @pytest.mark.asyncio -#async def test_open_missing_compute(controller, tmpdir, demo_topology, http_client): +# async def test_open_missing_compute(controller, tmpdir, demo_topology, http_client): # """ # If a compute is missing the project should not be open and the .gns3 should # be the one before opening the project diff --git a/tests/controller/test_rbac.py b/tests/controller/test_rbac.py index 29fb38f4e..b332f20e0 100644 --- a/tests/controller/test_rbac.py +++ b/tests/controller/test_rbac.py @@ -51,7 +51,6 @@ pytestmark = pytest.mark.asyncio class TestPrivileges: - @pytest.mark.parametrize( "privilege, path, result", ( @@ -69,25 +68,14 @@ class TestPrivileges: ), ) async def test_default_privileges_user_group( - self, - test_user: User, - db_session: AsyncSession, - privilege: str, - path: str, - result: bool + self, test_user: User, db_session: AsyncSession, privilege: str, path: str, result: bool ) -> None: # add an ACE for path if result: group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id - ace = ACECreate( - path=path, - ace_type="group", - propagate=False, - group_id=str(group_id), - role_id=str(role_id) - ) + ace = ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role_id)) await RbacRepository(db_session).create_ace(ace) authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege) @@ -122,13 +110,7 @@ class TestPrivileges: # privileges on deeper levels replace those inherited from an upper level. group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id - ace = ACECreate( - path=path, - ace_type="group", - propagate=False, - group_id=str(group_id), - role_id=str(role_id) - ) + ace = ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role_id)) await RbacRepository(db_session).create_ace(ace) authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege) @@ -136,7 +118,6 @@ class TestPrivileges: class TestResourcePools: - async def test_resource_pool(self, test_user: User, db_session: AsyncSession): project_id = uuid.uuid4() @@ -157,7 +138,7 @@ class TestResourcePools: ace_type="group", propagate=False, group_id=str(group_id), - role_id=str(role_id) + role_id=str(role_id), ) await RbacRepository(db_session).create_ace(ace) @@ -167,12 +148,7 @@ class TestResourcePools: assert authorized is True async def test_list_projects_in_resource_pool( - self, - app: FastAPI, - controller: Controller, - base_client: AsyncClient, - db_session: AsyncSession, - test_user: User + self, app: FastAPI, controller: Controller, base_client: AsyncClient, db_session: AsyncSession, test_user: User ) -> None: # Clean up any existing ACEs from previous tests @@ -204,16 +180,16 @@ class TestResourcePools: ace_type="group", propagate=False, group_id=str(group_id), - role_id=str(role_id) + role_id=str(role_id), ) await RbacRepository(db_session).create_ace(ace) # Create a new client with test user authentication access_token = auth_service.create_access_token(test_user.username) async with AsyncClient( - base_url="http://test-api", - headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}, - transport=ASGIWebSocketTransport(app=app) + base_url="http://test-api", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}, + transport=ASGIWebSocketTransport(app=app), ) as user_client: # user should see only uuid2 (from resource pool) response = await user_client.get(app.url_path_for("get_projects")) @@ -228,11 +204,7 @@ class TestResourcePools: # Now give user access to /projects (in addition to resource pool) ace = ACECreate( - path="/projects", - ace_type="group", - propagate=True, - group_id=str(group_id), - role_id=str(role_id) + path="/projects", ace_type="group", propagate=True, group_id=str(group_id), role_id=str(role_id) ) await RbacRepository(db_session).create_ace(ace) @@ -277,66 +249,66 @@ class TestResourcePools: # permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id) # assert len(permissions_in_db) == 1 # assert permissions_in_db[0].path == f"/projects/{project_id}/*" - # - # response = await authorized_client.get(app.url_path_for("get_projects")) - # assert response.status_code == status.HTTP_200_OK - # projects = response.json() - # assert len(projects) == 1 +# +# response = await authorized_client.get(app.url_path_for("get_projects")) +# assert response.status_code == status.HTTP_200_OK +# projects = response.json() +# assert len(projects) == 1 - # async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient): - # - # response = await client.get(app.url_path_for("get_projects")) - # assert response.status_code == status.HTTP_200_OK - # projects = response.json() - # assert len(projects) == 2 - # - # async def test_admin_user_give_permission_on_project( - # self, - # app: FastAPI, - # client: AsyncClient, - # test_user: User - # ): - # - # response = await client.get(app.url_path_for("get_projects")) - # assert response.status_code == status.HTTP_200_OK - # projects = response.json() - # project_id = None - # for project in projects: - # if project["name"] == "Admin project": - # project_id = project["project_id"] - # break - # - # new_permission = { - # "methods": ["GET"], - # "path": f"/projects/{project_id}", - # "action": "ALLOW" - # } - # response = await client.post(app.url_path_for("create_permission"), json=new_permission) - # assert response.status_code == status.HTTP_201_CREATED - # permission_id = response.json()["permission_id"] - # - # response = await client.put( - # app.url_path_for( - # "add_permission_to_user", - # user_id=test_user.user_id, - # permission_id=permission_id - # ) - # ) - # assert response.status_code == status.HTTP_204_NO_CONTENT - # - # async def test_user_access_admin_project( - # self, - # app: FastAPI, - # authorized_client: AsyncClient, - # test_user: User, - # db_session: AsyncSession - # ) -> None: - # - # response = await authorized_client.get(app.url_path_for("get_projects")) - # assert response.status_code == status.HTTP_200_OK - # projects = response.json() - # assert len(projects) == 2 - # +# async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient): +# +# response = await client.get(app.url_path_for("get_projects")) +# assert response.status_code == status.HTTP_200_OK +# projects = response.json() +# assert len(projects) == 2 +# +# async def test_admin_user_give_permission_on_project( +# self, +# app: FastAPI, +# client: AsyncClient, +# test_user: User +# ): +# +# response = await client.get(app.url_path_for("get_projects")) +# assert response.status_code == status.HTTP_200_OK +# projects = response.json() +# project_id = None +# for project in projects: +# if project["name"] == "Admin project": +# project_id = project["project_id"] +# break +# +# new_permission = { +# "methods": ["GET"], +# "path": f"/projects/{project_id}", +# "action": "ALLOW" +# } +# response = await client.post(app.url_path_for("create_permission"), json=new_permission) +# assert response.status_code == status.HTTP_201_CREATED +# permission_id = response.json()["permission_id"] +# +# response = await client.put( +# app.url_path_for( +# "add_permission_to_user", +# user_id=test_user.user_id, +# permission_id=permission_id +# ) +# ) +# assert response.status_code == status.HTTP_204_NO_CONTENT +# +# async def test_user_access_admin_project( +# self, +# app: FastAPI, +# authorized_client: AsyncClient, +# test_user: User, +# db_session: AsyncSession +# ) -> None: +# +# response = await authorized_client.get(app.url_path_for("get_projects")) +# assert response.status_code == status.HTTP_200_OK +# projects = response.json() +# assert len(projects) == 2 +# # class TestTemplatesWithRbac: # diff --git a/tests/controller/test_snapshot.py b/tests/controller/test_snapshot.py index 8881b98f1..008d0bb57 100644 --- a/tests/controller/test_snapshot.py +++ b/tests/controller/test_snapshot.py @@ -67,7 +67,7 @@ def test_json(project): "project_id": project.id, "description": "Snapshot 'snapshot_test' taken on 2016-07-26 at 10:04:39", "filename": "snapshot_test_260716_100439.gns3project", - "created_at": 1469527479 + "created_at": 1469527479, } # new style snapshot @@ -78,9 +78,10 @@ def test_json(project): "project_id": project.id, "filename": "snapshot_test2.gns3snapshot", "description": mock.ANY, - "created_at": mock.ANY + "created_at": mock.ANY, } + def test_invalid_snapshot_filename(project): with pytest.raises(ValueError): diff --git a/tests/controller/test_statistics.py b/tests/controller/test_statistics.py index f184a92b8..eaf5c12f0 100644 --- a/tests/controller/test_statistics.py +++ b/tests/controller/test_statistics.py @@ -7,8 +7,10 @@ def test_server_uptime_uses_process_creation_time() -> None: process = MagicMock() process.create_time.return_value = 50.25 - with patch("gns3server.api.routes.controller.controller.time.time", return_value=200.75), \ - patch("gns3server.api.routes.controller.controller.psutil.Process", return_value=process): + with ( + patch("gns3server.api.routes.controller.controller.time.time", return_value=200.75), + patch("gns3server.api.routes.controller.controller.psutil.Process", return_value=process), + ): uptime = get_server_uptime_seconds() assert uptime == 150 diff --git a/tests/controller/test_symbols.py b/tests/controller/test_symbols.py index 17966193d..3856427fb 100644 --- a/tests/controller/test_symbols.py +++ b/tests/controller/test_symbols.py @@ -29,16 +29,16 @@ def test_list(symbols_dir): symbols = Symbols() assert { - 'symbol_id': ':/symbols/classic/firewall.svg', - 'filename': 'firewall.svg', - 'builtin': True, - 'theme': 'Classic' + "symbol_id": ":/symbols/classic/firewall.svg", + "filename": "firewall.svg", + "builtin": True, + "theme": "Classic", } in symbols.list() assert { - 'symbol_id': 'linux.svg', - 'filename': 'linux.svg', - 'builtin': False, - 'theme': 'Custom symbols' + "symbol_id": "linux.svg", + "filename": "linux.svg", + "builtin": False, + "theme": "Custom symbols", } in symbols.list() @@ -46,7 +46,7 @@ def test_get_path(): symbols = Symbols() symbols.theme = "Classic" - assert symbols.get_path(':/symbols/classic/firewall.svg') == get_resource("symbols/classic/firewall.svg") + assert symbols.get_path(":/symbols/classic/firewall.svg") == get_resource("symbols/classic/firewall.svg") def test_get_path_with_themed_symbols(): @@ -62,4 +62,4 @@ def test_get_size(): symbols = Symbols() symbols.theme = "Classic" - assert symbols.get_size(':/symbols/classic/firewall.svg') == (66, 45, 'svg') + assert symbols.get_size(":/symbols/classic/firewall.svg") == (66, 45, "svg") diff --git a/tests/controller/test_topology.py b/tests/controller/test_topology.py index bc87c1ee3..3419aa794 100644 --- a/tests/controller/test_topology.py +++ b/tests/controller/test_topology.py @@ -31,7 +31,7 @@ from gns3server.version import __version__ @pytest.mark.asyncio async def test_project_to_topology_empty(tmpdir): - with patch('gns3server.controller.project.Project.emit_controller_notification'): + with patch("gns3server.controller.project.Project.emit_controller_notification"): project = Project(name="Test") topo = project_to_topology(project) assert topo == { @@ -50,18 +50,13 @@ async def test_project_to_topology_empty(tmpdir): "snap_to_grid": False, "grid_size": 75, "drawing_grid_size": 25, - "topology": { - "nodes": [], - "links": [], - "computes": [], - "drawings": [] - }, + "topology": {"nodes": [], "links": [], "computes": [], "drawings": []}, "type": "topology", "supplier": None, "variables": None, "version": __version__, "marker_definitions": {}, - "created_by": None + "created_by": None, } @@ -94,14 +89,8 @@ async def test_basic_topology(controller): @pytest.mark.asyncio async def test_project_to_topology(controller): - variables = [ - {"name": "TEST1"}, - {"name": "TEST2", "value": "value1"} - ] - supplier = { - 'logo': 'logo.png', - 'url': 'http://example.com' - } + variables = [{"name": "TEST1"}, {"name": "TEST2", "value": "value1"}] + supplier = {"logo": "logo.png", "url": "http://example.com"} project = Project(name="Test", controller=controller) compute = Compute("my_compute", controller) @@ -119,14 +108,10 @@ def test_load_topology(tmpdir): "project_id": "69f26504-7aa3-48aa-9f29-798d44841211", "name": "Test", "revision": GNS3_FILE_FORMAT_REVISION, - "topology": { - "nodes": [], - "links": [], - "computes": [], - "drawings": [] - }, + "topology": {"nodes": [], "links": [], "computes": [], "drawings": []}, "type": "topology", - "version": __version__} + "version": __version__, + } path = str(tmpdir / "test.gns3") with open(path, "w+") as f: @@ -146,9 +131,7 @@ def test_load_topology_file_error_schema_error(tmpdir): path = str(tmpdir / "test.gns3") with open(path, "w+") as f: - json.dump({ - "revision": GNS3_FILE_FORMAT_REVISION - }, f) + json.dump({"revision": GNS3_FILE_FORMAT_REVISION}, f) with pytest.raises(ControllerError): load_topology(path) @@ -163,10 +146,10 @@ def test_load_newer_topology(tmpdir): "project_id": "69f26504-7aa3-48aa-9f29-798d44841211", "name": "Test", "revision": 42, - "topology": { - }, + "topology": {}, "type": "topology", - "version": __version__} + "version": __version__, + } path = str(tmpdir / "test.gns3") with open(path, "w+") as f: @@ -177,23 +160,16 @@ def test_load_newer_topology(tmpdir): def test_load_topology_with_variables(tmpdir): - variables = [ - {"name": "TEST1"}, - {"name": "TEST2", "value": "value1"} - ] + variables = [{"name": "TEST1"}, {"name": "TEST2", "value": "value1"}] data = { "project_id": "69f26504-7aa3-48aa-9f29-798d44841211", "name": "Test", "revision": GNS3_FILE_FORMAT_REVISION, - "topology": { - "nodes": [], - "links": [], - "computes": [], - "drawings": [] - }, + "topology": {"nodes": [], "links": [], "computes": [], "drawings": []}, "variables": variables, "type": "topology", - "version": __version__} + "version": __version__, + } path = str(tmpdir / "test.gns3") with open(path, "w+") as f: @@ -204,23 +180,16 @@ def test_load_topology_with_variables(tmpdir): def test_load_topology_with_supplier(tmpdir): - supplier = { - 'logo': 'logo.png', - 'url': 'http://example.com' - } + supplier = {"logo": "logo.png", "url": "http://example.com"} data = { "project_id": "69f26504-7aa3-48aa-9f29-798d44841211", "name": "Test", "revision": GNS3_FILE_FORMAT_REVISION, - "topology": { - "nodes": [], - "links": [], - "computes": [], - "drawings": [] - }, + "topology": {"nodes": [], "links": [], "computes": [], "drawings": []}, "supplier": supplier, "type": "topology", - "version": __version__} + "version": __version__, + } path = str(tmpdir / "test.gns3") with open(path, "w+") as f: diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index f8934b1b4..7c433a6fb 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -72,25 +72,33 @@ async def test_create(project): compute2.host = "example.org" await link.add_node(node2, 3, 1) - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ - "lport": 1024, - "rhost": "192.168.1.2", - "rport": 2048, - "type": "nio_udp", - "filters": {"delay": [10, 0]}, - "markers": {}, - "suspend": False, - }, timeout=120) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), + data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "filters": {"delay": [10, 0]}, + "markers": {}, + "suspend": False, + }, + timeout=120, + ) - compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ - "lport": 2048, - "rhost": "192.168.1.1", - "rport": 1024, - "type": "nio_udp", - "filters": {}, - "markers": {}, - "suspend": False, - }, timeout=120) + compute2.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), + data={ + "lport": 2048, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "filters": {}, + "markers": {}, + "suspend": False, + }, + timeout=120, + ) @pytest.mark.asyncio @@ -142,27 +150,37 @@ async def test_create_one_side_failure(project): with pytest.raises(ControllerError): await link.add_node(node2, 3, 1) - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ - "lport": 1024, - "rhost": "192.168.1.2", - "rport": 2048, - "type": "nio_udp", - "filters": {}, - "markers": {}, - "suspend": False, - }, timeout=120) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), + data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "filters": {}, + "markers": {}, + "suspend": False, + }, + timeout=120, + ) - compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ - "lport": 2048, - "rhost": "192.168.1.1", - "rport": 1024, - "type": "nio_udp", - "filters": {}, - "markers": {}, - "suspend": False, - }, timeout=120) + compute2.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), + data={ + "lport": 2048, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "filters": {}, + "markers": {}, + "suspend": False, + }, + timeout=120, + ) # The link creation has failed we rollback the nio - compute1.delete.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), timeout=120) + compute1.delete.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), timeout=120 + ) @pytest.mark.asyncio @@ -183,8 +201,12 @@ async def test_delete(project): await link.delete() - compute1.delete.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), timeout=120) - compute2.delete.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), timeout=120) + compute1.delete.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), timeout=120 + ) + compute2.delete.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), timeout=120 + ) @pytest.mark.asyncio @@ -286,15 +308,19 @@ async def test_reset(project): assert link.debug_link_data[0]["lport"] != link.debug_link_data[0]["rport"] assert link.debug_link_data[1]["lport"] != link.debug_link_data[1]["rport"] # the committed NIO carries the fresh pair, not the released one - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ - "lport": 4096, - "rhost": "192.168.1.2", - "rport": 8192, - "type": "nio_udp", - "filters": {}, - "markers": {}, - "suspend": False, - }, timeout=120) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), + data={ + "lport": 4096, + "rhost": "192.168.1.2", + "rport": 8192, + "type": "nio_udp", + "filters": {}, + "markers": {}, + "suspend": False, + }, + timeout=120, + ) @pytest.mark.asyncio @@ -373,15 +399,17 @@ async def test_capture(project): await link.start_capture() assert link.capturing - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/capture/start".format(project.id, node_vpcs.id), data={ - "capture_file_name": link.default_capture_file_name(), - "data_link_type": "DLT_EN10MB" - }) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/capture/start".format(project.id, node_vpcs.id), + data={"capture_file_name": link.default_capture_file_name(), "data_link_type": "DLT_EN10MB"}, + ) await link.stop_capture() assert link.capturing is False - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/capture/stop".format(project.id, node_vpcs.id)) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/capture/stop".format(project.id, node_vpcs.id) + ) @pytest.mark.asyncio @@ -453,40 +481,49 @@ async def test_update(project): compute2.host = "example.org" await link.add_node(node2, 3, 1) - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ - "lport": 1024, - "rhost": "192.168.1.2", - "rport": 2048, - "type": "nio_udp", - "suspend": False, - "markers": {}, - "filters": {"delay": [10, 0]} - }, timeout=120) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), + data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "suspend": False, + "markers": {}, + "filters": {"delay": [10, 0]}, + }, + timeout=120, + ) - compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ - "lport": 2048, - "rhost": "192.168.1.1", - "rport": 1024, - "type": "nio_udp", - "suspend": False, - "markers": {}, - "filters": {} - }, timeout=120) + compute2.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), + data={ + "lport": 2048, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "suspend": False, + "markers": {}, + "filters": {}, + }, + timeout=120, + ) assert link.created await link.update_filters({"frequency_drop": [5], "bpf": ["icmp[icmptype] == 8"]}) - compute1.put.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ - "lport": 1024, - "rhost": "192.168.1.2", - "rport": 2048, - "type": "nio_udp", - "suspend": False, - "markers": {}, - "filters": { - "frequency_drop": [5], - "bpf": ["icmp[icmptype] == 8"] - } - }, timeout=120) + compute1.put.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), + data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "suspend": False, + "markers": {}, + "filters": {"frequency_drop": [5], "bpf": ["icmp[icmptype] == 8"]}, + }, + timeout=120, + ) @pytest.mark.asyncio @@ -533,8 +570,9 @@ async def test_update_ethernet_switch_nio(project): "type": "nio_udp", "suspend": False, "markers": {}, - "filters": {} - }, timeout=221 + "filters": {}, + }, + timeout=221, ) @@ -585,22 +623,30 @@ async def test_update_suspend(project): compute2.host = "example.org" await link.add_node(node2, 3, 1) - compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ - "lport": 1024, - "rhost": "192.168.1.2", - "rport": 2048, - "type": "nio_udp", - "filters": {"frequency_drop": [-1]}, - "markers": {}, - "suspend": True - }, timeout=120) + compute1.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), + data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "filters": {"frequency_drop": [-1]}, + "markers": {}, + "suspend": True, + }, + timeout=120, + ) - compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ - "lport": 2048, - "rhost": "192.168.1.1", - "rport": 1024, - "type": "nio_udp", - "filters": {}, - "markers": {}, - "suspend": True - }, timeout=120) + compute2.post.assert_any_call( + "/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), + data={ + "lport": 2048, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "filters": {}, + "markers": {}, + "suspend": True, + }, + timeout=120, + ) diff --git a/tests/stress/benchmark_running_server.py b/tests/stress/benchmark_running_server.py index 9ee61ccc4..6400aebe1 100644 --- a/tests/stress/benchmark_running_server.py +++ b/tests/stress/benchmark_running_server.py @@ -32,23 +32,19 @@ async def create_test_projects(base_url, headers, count, prefix="perf_test_proje for i in range(batch_start, batch_end): name = f"{prefix}_{i}_{uuid.uuid4().hex[:8]}" - task = client.post( - f"{base_url}/projects", - headers=headers, - json={"name": name} - ) + task = client.post(f"{base_url}/projects", headers=headers, json={"name": name}) batch_tasks.append(task) results = await asyncio.gather(*batch_tasks, return_exceptions=True) for i, result in enumerate(results, batch_start): - if hasattr(result, 'status_code'): + if hasattr(result, "status_code"): if result.status_code == 201: - print(f" Created project {i+1}/{count}") + print(f" Created project {i + 1}/{count}") else: - print(f" Failed to create project {i+1}: {result.status_code}") + print(f" Failed to create project {i + 1}: {result.status_code}") else: - print(f" Error creating project {i+1}: {result}") + print(f" Error creating project {i + 1}: {result}") async def cleanup_test_projects(base_url, headers, prefix="perf_test_project"): @@ -62,7 +58,7 @@ async def cleanup_test_projects(base_url, headers, prefix="perf_test_project"): if response.status_code == 200: projects = response.json() - test_projects = [p for p in projects if p.get('name', '').startswith(prefix)] + test_projects = [p for p in projects if p.get("name", "").startswith(prefix)] print(f" Found {len(test_projects)} test projects to delete") batch_size = 50 @@ -72,21 +68,18 @@ async def cleanup_test_projects(base_url, headers, prefix="perf_test_project"): batch_tasks = [] for project in test_projects[batch_start:batch_end]: - task = client.delete( - f"{base_url}/projects/{project['project_id']}", - headers=headers - ) + task = client.delete(f"{base_url}/projects/{project['project_id']}", headers=headers) batch_tasks.append(task) results = await asyncio.gather(*batch_tasks, return_exceptions=True) for i, result in enumerate(results, batch_start): project = test_projects[i] - if hasattr(result, 'status_code') and result.status_code == 204: + if hasattr(result, "status_code") and result.status_code == 204: total_deleted += 1 elapsed = time.time() - cleanup_start - print(f" Deleted {total_deleted} projects in {elapsed:.1f}s ({total_deleted/elapsed:.0f} projects/sec)") + print(f" Deleted {total_deleted} projects in {elapsed:.1f}s ({total_deleted / elapsed:.0f} projects/sec)") except Exception as e: print(f"Error during cleanup: {type(e).__name__}: {e}") @@ -95,11 +88,11 @@ async def cleanup_test_projects(base_url, headers, prefix="perf_test_project"): async def benchmark_get_projects(base_url, headers, project_count, iterations=10, prefix="perf_test_project"): """Benchmark GET /projects endpoint""" - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"GET /projects Performance Benchmark") - print(f"{'='*60}") + print(f"{'=' * 60}") print(f"Server: {base_url}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") if project_count > 0: await create_test_projects(base_url, headers, project_count, prefix) @@ -137,11 +130,11 @@ async def benchmark_get_projects(base_url, headers, project_count, iterations=10 projects = response.json() response_times.append(elapsed_ms) project_counts.append(len(projects)) - print(f" Iteration {i+1}: {elapsed_ms:.2f}ms ({len(projects)} projects)") + print(f" Iteration {i + 1}: {elapsed_ms:.2f}ms ({len(projects)} projects)") else: - print(f" Iteration {i+1}: ERROR {response.status_code}") + print(f" Iteration {i + 1}: ERROR {response.status_code}") except Exception as e: - print(f" Iteration {i+1}: Exception - {type(e).__name__}: {str(e)}") + print(f" Iteration {i + 1}: Exception - {type(e).__name__}: {str(e)}") # Cleanup test projects if project_count > 0: @@ -155,17 +148,17 @@ async def benchmark_get_projects(base_url, headers, project_count, iterations=10 median_time = sorted(response_times)[len(response_times) // 2] avg_project_count = sum(project_counts) / len(project_counts) if project_counts else 0 - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") if project_count > 0: print(f"Results for {project_count} projects (created specifically):") else: print(f"Results for existing projects (~{avg_project_count:.0f} per request):") - print(f"{'='*60}") + print(f"{'=' * 60}") print(f"Average: {avg_time:.2f}ms") print(f"Median: {median_time:.2f}ms") print(f"Min: {min_time:.2f}ms") print(f"Max: {max_time:.2f}ms") - print(f"{'='*60}") + print(f"{'=' * 60}") # Performance assessment if avg_time < 100: @@ -185,36 +178,43 @@ async def benchmark_get_projects(base_url, headers, project_count, iterations=10 total_seconds = sum(response_times) / 1000 projects_per_second = total_projects / total_seconds print(f"Throughput: {projects_per_second:.1f} projects/sec") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") return { - 'project_count': project_count, - 'avg_time_ms': avg_time, - 'median_time_ms': median_time, - 'min_time_ms': min_time, - 'max_time_ms': max_time, - 'projects_per_second': projects_per_second if project_counts and sum(response_times) > 0 else 0 + "project_count": project_count, + "avg_time_ms": avg_time, + "median_time_ms": median_time, + "min_time_ms": min_time, + "max_time_ms": max_time, + "projects_per_second": projects_per_second if project_counts and sum(response_times) > 0 else 0, } async def main(): - parser = argparse.ArgumentParser(description='Benchmark GET /projects performance on running server') - parser.add_argument('--host', type=str, default='127.0.0.1', help='GNS3 server host') - parser.add_argument('--port', type=int, default=3080, help='GNS3 server port') - parser.add_argument('--username', type=str, required=True, help='Username for authentication') - parser.add_argument('--password', type=str, required=True, help='Password for authentication') - parser.add_argument('--project-counts', type=int, nargs='+', default=[10, 50, 100], - help='Numbers of projects to test (default: 10 50 100)') - parser.add_argument('--iterations', type=int, default=10, - help='Number of iterations per test (default: 10)') - parser.add_argument('--full-scale', action='store_true', - help='Run full scale test: 10, 50, 100, 200, 500 projects') - parser.add_argument('--no-cleanup', action='store_true', - help='Do not create/cleanup test projects (use existing projects)') - parser.add_argument('--prefix', type=str, default='perf_test_project', - help='Project name prefix for test/cleanup (default: perf_test_project)') - parser.add_argument('--cleanup-only', action='store_true', - help='Only clean up test projects, do not run benchmark') + parser = argparse.ArgumentParser(description="Benchmark GET /projects performance on running server") + parser.add_argument("--host", type=str, default="127.0.0.1", help="GNS3 server host") + parser.add_argument("--port", type=int, default=3080, help="GNS3 server port") + parser.add_argument("--username", type=str, required=True, help="Username for authentication") + parser.add_argument("--password", type=str, required=True, help="Password for authentication") + parser.add_argument( + "--project-counts", + type=int, + nargs="+", + default=[10, 50, 100], + help="Numbers of projects to test (default: 10 50 100)", + ) + parser.add_argument("--iterations", type=int, default=10, help="Number of iterations per test (default: 10)") + parser.add_argument("--full-scale", action="store_true", help="Run full scale test: 10, 50, 100, 200, 500 projects") + parser.add_argument( + "--no-cleanup", action="store_true", help="Do not create/cleanup test projects (use existing projects)" + ) + parser.add_argument( + "--prefix", + type=str, + default="perf_test_project", + help="Project name prefix for test/cleanup (default: perf_test_project)", + ) + parser.add_argument("--cleanup-only", action="store_true", help="Only clean up test projects, do not run benchmark") args = parser.parse_args() @@ -227,8 +227,7 @@ async def main(): try: async with httpx.AsyncClient() as client: login_response = await client.post( - f"{base_url}/access/users/login", - data={"username": args.username, "password": args.password} + f"{base_url}/access/users/login", data={"username": args.username, "password": args.password} ) if login_response.status_code != 200: @@ -274,13 +273,13 @@ async def main(): # Summary if len(results) > 1: - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print("Summary - Performance Scaling") - print(f"{'='*60}") + print(f"{'=' * 60}") print(f"{'Projects':<12} {'Avg (ms)':<12} {'Status':<12}") - print(f"{'-'*40}") + print(f"{'-' * 40}") for r in results: - avg = r['avg_time_ms'] + avg = r["avg_time_ms"] if avg < 100: status = "EXCELLENT" elif avg < 500: @@ -290,7 +289,7 @@ async def main(): else: status = "CRITICAL" print(f"{r['project_count']:<12} {avg:<12.2f} {status:<12}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") if __name__ == "__main__": diff --git a/tests/stress/telnet_race_condition_test.py b/tests/stress/telnet_race_condition_test.py index 59960adab..05d245201 100644 --- a/tests/stress/telnet_race_condition_test.py +++ b/tests/stress/telnet_race_condition_test.py @@ -18,10 +18,7 @@ from typing import List, Optional import sys # Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") log = logging.getLogger(__name__) @@ -41,8 +38,7 @@ class TelnetClient: try: log.debug(f"Client {self.client_id}: Connecting to {self.host}:{self.port}...") self.reader, self.writer = await asyncio.wait_for( - asyncio.open_connection(self.host, self.port), - timeout=5.0 + asyncio.open_connection(self.host, self.port), timeout=5.0 ) self.connected = True log.debug(f"Client {self.client_id}: Connected successfully") @@ -57,7 +53,7 @@ class TelnetClient: return False try: - self.writer.write(command.encode() + b'\r\n') + self.writer.write(command.encode() + b"\r\n") await asyncio.wait_for(self.writer.drain(), timeout=2.0) log.debug(f"Client {self.client_id}: Sent command: {command.strip()}") return True @@ -73,7 +69,7 @@ class TelnetClient: try: data = await asyncio.wait_for(self.reader.read(1024), timeout=timeout) if data: - response = data.decode('utf-8', errors='ignore') + response = data.decode("utf-8", errors="ignore") log.debug(f"Client {self.client_id}: Received: {response[:50]}...") return response except asyncio.TimeoutError: @@ -121,7 +117,7 @@ async def rapid_fire_client( max_delay: float = 0.01, receive_before_disconnect: bool = False, immediate_disconnect: bool = True, - device_type: str = "iou-l3" + device_type: str = "iou-l3", ): """ A client that rapidly connects, sends commands, and disconnects. @@ -220,13 +216,7 @@ async def rapid_fire_client( return success_count, fail_count -async def long_lived_client( - client_id: int, - host: str, - port: str, - duration: float, - send_interval: float = 1.0 -): +async def long_lived_client(client_id: int, host: str, port: str, duration: float, send_interval: float = 1.0): """ A long-lived client that stays connected and periodically sends commands. @@ -273,7 +263,7 @@ async def run_stress_test( long_lived_clients: int, iterations_per_client: int, test_duration: float, - device_type: str = "iou-l3" + device_type: str = "iou-l3", ): """ Run the stress test with multiple concurrent clients. @@ -306,13 +296,7 @@ async def run_stress_test( # Start long-lived clients first (simulate web console users) for i in range(long_lived_clients): task = asyncio.create_task( - long_lived_client( - client_id=1000 + i, - host=host, - port=port, - duration=test_duration, - send_interval=2.0 - ) + long_lived_client(client_id=1000 + i, host=host, port=port, duration=test_duration, send_interval=2.0) ) tasks.append(task) await asyncio.sleep(0.1) # Stagger connections @@ -332,10 +316,10 @@ async def run_stress_test( port=port, iterations=iterations_per_client, min_delay=0.001, # 1ms - very fast - max_delay=0.01, # 10ms - still fast + max_delay=0.01, # 10ms - still fast receive_before_disconnect=False, # Don't wait for response immediate_disconnect=True, # Abrupt close - device_type=device_type + device_type=device_type, ) ) rapid_tasks.append(task) @@ -393,62 +377,31 @@ Device Types: iou-l3 - Cisco IOS L3 router (uses show/run/write commands that trigger broadcast) vpcs - VPCS simulator (simple commands) generic - Generic device (basic test commands) - """ + """, ) - parser.add_argument( - '--host', - default='127.0.0.1', - help='Telnet server host (default: 127.0.0.1)' - ) + parser.add_argument("--host", default="127.0.0.1", help="Telnet server host (default: 127.0.0.1)") + + parser.add_argument("--port", type=int, default=2000, help="Telnet server port (default: 2000)") parser.add_argument( - '--port', - type=int, - default=2000, - help='Telnet server port (default: 2000)' + "--rapid-clients", type=int, default=10, help="Number of rapid connect/disconnect clients (default: 10)" ) - parser.add_argument( - '--rapid-clients', - type=int, - default=10, - help='Number of rapid connect/disconnect clients (default: 10)' - ) + parser.add_argument("--long-lived", type=int, default=2, help="Number of long-lived clients (default: 2)") + + parser.add_argument("--iterations", type=int, default=50, help="Iterations per rapid client (default: 50)") + + parser.add_argument("--duration", type=float, default=30.0, help="Test duration in seconds (default: 30.0)") parser.add_argument( - '--long-lived', - type=int, - default=2, - help='Number of long-lived clients (default: 2)' + "--device-type", + default="iou-l3", + choices=["iou-l3", "vpcs", "generic"], + help="Device type for commands (default: iou-l3)", ) - parser.add_argument( - '--iterations', - type=int, - default=50, - help='Iterations per rapid client (default: 50)' - ) - - parser.add_argument( - '--duration', - type=float, - default=30.0, - help='Test duration in seconds (default: 30.0)' - ) - - parser.add_argument( - '--device-type', - default='iou-l3', - choices=['iou-l3', 'vpcs', 'generic'], - help='Device type for commands (default: iou-l3)' - ) - - parser.add_argument( - '--verbose', - action='store_true', - help='Enable verbose logging' - ) + parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") args = parser.parse_args() @@ -456,19 +409,21 @@ Device Types: logging.getLogger().setLevel(logging.DEBUG) try: - asyncio.run(run_stress_test( - host=args.host, - port=args.port, - rapid_clients=args.rapid_clients, - long_lived_clients=args.long_lived, - iterations_per_client=args.iterations, - test_duration=args.duration, - device_type=args.device_type - )) + asyncio.run( + run_stress_test( + host=args.host, + port=args.port, + rapid_clients=args.rapid_clients, + long_lived_clients=args.long_lived, + iterations_per_client=args.iterations, + test_duration=args.duration, + device_type=args.device_type, + ) + ) except KeyboardInterrupt: log.info("Test interrupted by user") sys.exit(0) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/test_config.py b/tests/test_config.py index 3537ae76c..64cf66d7a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -60,40 +60,28 @@ def write_config(tmpdir, settings): @pytest.mark.parametrize( "setting, value, result", ( - ("allowed_interfaces", "", []), - ("allowed_interfaces", "eth0", ["eth0"]), - ("allowed_interfaces", "eth1,eth2", ["eth1", "eth2"]), - ("additional_images_paths", "", []), - ("additional_images_paths", "/path/to/dir1", ["/path/to/dir1"]), - ("additional_images_paths", "/path/to/dir1;/path/to/dir2", ["/path/to/dir1", "/path/to/dir2"]) - ) + ("allowed_interfaces", "", []), + ("allowed_interfaces", "eth0", ["eth0"]), + ("allowed_interfaces", "eth1,eth2", ["eth1", "eth2"]), + ("additional_images_paths", "", []), + ("additional_images_paths", "/path/to/dir1", ["/path/to/dir1"]), + ("additional_images_paths", "/path/to/dir1;/path/to/dir2", ["/path/to/dir1", "/path/to/dir2"]), + ), ) def test_server_settings_to_list(tmpdir, setting: str, value: str, result: str): - config = load_config(tmpdir, { - "Server": { - setting: value - } - }) + config = load_config(tmpdir, {"Server": {setting: value}}) assert config.settings.model_dump(exclude_unset=True)["Server"][setting] == result def test_reload(tmpdir): - config = load_config(tmpdir, { - "Server": { - "host": "127.0.0.1" - } - }) + config = load_config(tmpdir, {"Server": {"host": "127.0.0.1"}}) assert config.settings.Server.host == "127.0.0.1" - write_config(tmpdir, { - "Server": { - "host": "192.168.1.2" - } - }) + write_config(tmpdir, {"Server": {"host": "192.168.1.2"}}) config.reload() assert config.settings.Server.host == "192.168.1.2" @@ -110,24 +98,24 @@ def test_server_password_hidden(): @pytest.mark.parametrize( "settings, exception_expected", ( - ({"protocol": "https1"}, True), - ({"console_start_port_range": 15000, "console_end_port_range": 20000}, False), - ({"console_start_port_range": 0}, True), - ({"console_start_port_range": 68000}, True), - ({"console_end_port_range": 15000}, False), - ({"console_end_port_range": 0}, True), - ({"console_end_port_range": 68000}, True), - ({"console_start_port_range": 10000, "console_end_port_range": 5000}, True), - ({"vnc_console_start_port_range": 6000}, False), - ({"vnc_console_start_port_range": 1000}, True), - ({"vnc_console_end_port_range": 6000}, False), - ({"vnc_console_end_port_range": 1000}, True), - ({"vnc_console_start_port_range": 7000, "vnc_console_end_port_range": 6000}, True), - ({"enable_ssl": True, "certfile": "/path/to/certfile", "certkey": "/path/to/certkey"}, True), - ({"enable_ssl": True}, True), - ({"enable_ssl": True, "certfile": "/path/to/certfile"}, True), - ({"enable_ssl": True, "certkey": "/path/to/certkey"}, True) - ) + ({"protocol": "https1"}, True), + ({"console_start_port_range": 15000, "console_end_port_range": 20000}, False), + ({"console_start_port_range": 0}, True), + ({"console_start_port_range": 68000}, True), + ({"console_end_port_range": 15000}, False), + ({"console_end_port_range": 0}, True), + ({"console_end_port_range": 68000}, True), + ({"console_start_port_range": 10000, "console_end_port_range": 5000}, True), + ({"vnc_console_start_port_range": 6000}, False), + ({"vnc_console_start_port_range": 1000}, True), + ({"vnc_console_end_port_range": 6000}, False), + ({"vnc_console_end_port_range": 1000}, True), + ({"vnc_console_start_port_range": 7000, "vnc_console_end_port_range": 6000}, True), + ({"enable_ssl": True, "certfile": "/path/to/certfile", "certkey": "/path/to/certkey"}, True), + ({"enable_ssl": True}, True), + ({"enable_ssl": True, "certfile": "/path/to/certfile"}, True), + ({"enable_ssl": True, "certkey": "/path/to/certkey"}, True), + ), ) def test_server_settings(settings: dict, exception_expected: bool): @@ -143,13 +131,13 @@ def test_server_settings(settings: dict, exception_expected: bool): @pytest.mark.parametrize( "settings, exception_expected", ( - ({"vmnet_start_range": 0}, True), - ({"vmnet_start_range": 256}, True), - ({"vmnet_end_range": 0}, True), - ({"vmnet_end_range": 256}, True), - ({"vmnet_start_range": 2, "vmnet_end_range": 10}, False), - ({"vmnet_start_range": 5, "vmnet_end_range": 3}, True) - ) + ({"vmnet_start_range": 0}, True), + ({"vmnet_start_range": 256}, True), + ({"vmnet_end_range": 0}, True), + ({"vmnet_end_range": 256}, True), + ({"vmnet_start_range": 2, "vmnet_end_range": 10}, False), + ({"vmnet_start_range": 5, "vmnet_end_range": 3}, True), + ), ) def test_vmware_settings(settings: dict, exception_expected: bool): @@ -169,15 +157,17 @@ def test_update_config_writes_ini_types(tmpdir): f.write("# a comment\n[Server]\nhost = 127.0.0.1\nfrobnicate = 42\n") config = Config(files=[path]) - changed = config.update_config({ - "Server": { - "port": 3081, - "report_errors": False, - "allowed_interfaces": ["eth0", "eth1"], - "default_symbol_theme": "Classic", - "additional_images_paths": ["/path/to/dir1", "/path/to/dir2"], + changed = config.update_config( + { + "Server": { + "port": 3081, + "report_errors": False, + "allowed_interfaces": ["eth0", "eth1"], + "default_symbol_theme": "Classic", + "additional_images_paths": ["/path/to/dir1", "/path/to/dir2"], + } } - }) + ) parsed = configparser.ConfigParser() parsed.read(path) diff --git a/tests/test_server.py b/tests/test_server.py index 14829349f..da4abd306 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -33,12 +33,12 @@ def test_locale_check(): except: # Locale is not available on the server return Server._locale_check() - assert locale.getlocale() == ('fr_FR', 'UTF-8') + assert locale.getlocale() == ("fr_FR", "UTF-8") def parse_arguments(server, argv): - parser, args= parse(argv) + parser, args = parse(argv) return server._load_config_and_set_defaults(parser, args, argv) @@ -87,7 +87,7 @@ def test_parse_arguments(capsys, config, tmpdir): assert parse_arguments(server, []).port == 8003 assert parse_arguments(server, ["--ssl"]).ssl - assert parse_arguments(server,[]).ssl is False + assert parse_arguments(server, []).ssl is False with tempfile.NamedTemporaryFile(dir=str(tmpdir)) as f: server_config.certfile = f.name server_config.certkey = f.name @@ -95,28 +95,28 @@ def test_parse_arguments(capsys, config, tmpdir): assert parse_arguments(server, []).ssl assert parse_arguments(server, ["--certfile", "bla"]).certfile == "bla" - assert parse_arguments(server,["--certkey", "blu"]).certkey == "blu" + assert parse_arguments(server, ["--certkey", "blu"]).certkey == "blu" - assert parse_arguments(server,["-L"]).local - assert parse_arguments(server,["--local"]).local + assert parse_arguments(server, ["-L"]).local + assert parse_arguments(server, ["--local"]).local server_config.local = False - assert parse_arguments(server,[]).local is False + assert parse_arguments(server, []).local is False server_config.local = True - assert parse_arguments(server,[]).local + assert parse_arguments(server, []).local - assert parse_arguments(server,["-A"]).allow - assert parse_arguments(server,["--allow"]).allow - assert parse_arguments(server,[]).allow is False + assert parse_arguments(server, ["-A"]).allow + assert parse_arguments(server, ["--allow"]).allow + assert parse_arguments(server, []).allow is False server_config.allow_remote_console = True - assert parse_arguments(server,[]).allow + assert parse_arguments(server, []).allow - assert parse_arguments(server,["-q"]).quiet - assert parse_arguments(server,["--quiet"]).quiet - assert parse_arguments(server,[]).quiet is False + assert parse_arguments(server, ["-q"]).quiet + assert parse_arguments(server, ["--quiet"]).quiet + assert parse_arguments(server, []).quiet is False - assert parse_arguments(server,["-d"]).debug - assert parse_arguments(server,["--debug"]).debug - assert parse_arguments(server,[]).debug is False + assert parse_arguments(server, ["-d"]).debug + assert parse_arguments(server, ["--debug"]).debug + assert parse_arguments(server, []).debug is False def test_set_config_with_args(tmpdir): @@ -126,18 +126,23 @@ def test_set_config_with_args(tmpdir): with tempfile.NamedTemporaryFile(dir=str(tmpdir)) as f: certfile = f.name certkey = f.name - args = parse_arguments(server, ["--host", - "192.168.1.1", - "--local", - "--allow", - "--port", - "8001", - "--ssl", - "--certfile", - certfile, - "--certkey", - certkey, - "--debug"]) + args = parse_arguments( + server, + [ + "--host", + "192.168.1.1", + "--local", + "--allow", + "--port", + "8001", + "--ssl", + "--certfile", + certfile, + "--certkey", + certkey, + "--debug", + ], + ) server._set_config_defaults_from_command_line(args) server_config = config.settings.Server diff --git a/tests/test_topologies.py b/tests/test_topologies.py index 1033a5651..e763d47b9 100644 --- a/tests/test_topologies.py +++ b/tests/test_topologies.py @@ -67,7 +67,9 @@ def test_convert(directory, tmpdir): assert work_topology if "revision" not in before_topology or before_topology["revision"] < GNS3_FILE_FORMAT_REVISION: - assert os.path.exists(os.path.join(work_directory, gns3_file + ".backup{}".format(before_topology.get("revision", 0)))) + assert os.path.exists( + os.path.join(work_directory, gns3_file + ".backup{}".format(before_topology.get("revision", 0))) + ) # We should have the same file in after directory and the work directory for root, dirs, files in os.walk(after_directory): @@ -80,10 +82,14 @@ def test_convert(directory, tmpdir): if file_path.endswith(".gns3project"): size = os.stat(file_path).st_size other_size = os.stat(os.path.join(os.path.join(root, file))).st_size - assert size in range(other_size - 100, other_size + 100), "File {} is different".format(os.path.join(directory, file)) + assert size in range(other_size - 100, other_size + 100), "File {} is different".format( + os.path.join(directory, file) + ) # For non .gns3 file we check if the file are the same elif not file_path.endswith(".gns3"): - assert os.stat(file_path).st_size == os.stat(os.path.join(os.path.join(root, file))).st_size, "File {} is different".format(os.path.join(directory, file)) + assert os.stat(file_path).st_size == os.stat(os.path.join(os.path.join(root, file))).st_size, ( + "File {} is different".format(os.path.join(directory, file)) + ) # Check if we don't have unexpected file in work directory for root, dirs, files in os.walk(work_directory): @@ -109,7 +115,13 @@ def compare_dict(path, source, reference): for key in sorted(reference.keys()): val = reference[key] assert key in source, "{} is missing in {}".format(key, source) - if isinstance(val, str) or isinstance(val, float) or isinstance(val, int) or isinstance(val, bool) or val is None: + if ( + isinstance(val, str) + or isinstance(val, float) + or isinstance(val, int) + or isinstance(val, bool) + or val is None + ): if val == "ANYSTR": pass elif val == "ANYUUID" and len(source[key]) == 36: @@ -125,7 +137,9 @@ def compare_dict(path, source, reference): elif isinstance(val, dict): compare_dict(path + key + "/", source[key], val) elif isinstance(val, list): - assert len(val) == len(source[key]), "Not enough value in {} ({}/{}) it shoud be {} not {}".format(key, len(val), len(source[key]), val, source[key]) + assert len(val) == len(source[key]), "Not enough value in {} ({}/{}) it shoud be {} not {}".format( + key, len(val), len(source[key]), val, source[key] + ) for idx, element in enumerate(source[key]): if isinstance(element, dict): compare_dict(path + key + "/", element, val[idx]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2839fb587..9662d9956 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -36,16 +36,16 @@ def test_int_to_macaddress(): def test_parse_version(): - assert parse_version('1') == ('000001', '00000', '000000', 'final') - assert parse_version('1.3') == ('000001', '000003', '000000', 'final') - assert parse_version('1.3.dev3') == ('000001', '000003', '000000', 'dev', '000003') - assert parse_version('1.3a1') == ('000001', '000003', '000000', 'a', '000001') - assert parse_version('1.3rc1') == ('000001', '000003', '000000', 'c', '000001') + assert parse_version("1") == ("000001", "00000", "000000", "final") + assert parse_version("1.3") == ("000001", "000003", "000000", "final") + assert parse_version("1.3.dev3") == ("000001", "000003", "000000", "dev", "000003") + assert parse_version("1.3a1") == ("000001", "000003", "000000", "a", "000001") + assert parse_version("1.3rc1") == ("000001", "000003", "000000", "c", "000001") - assert parse_version('1.2.3') > parse_version('1.2.2') - assert parse_version('1.3') > parse_version('1.2.2') - assert parse_version('1.3') > parse_version('1.3alpha1') - assert parse_version('1.3') > parse_version('1.3rc1') - assert parse_version('1.3rc1') > parse_version('1.3alpha3') - assert parse_version('1.3dev1') > parse_version('1.3rc1') - assert parse_version('1.2.3') > parse_version('1.2') + assert parse_version("1.2.3") > parse_version("1.2.2") + assert parse_version("1.3") > parse_version("1.2.2") + assert parse_version("1.3") > parse_version("1.3alpha1") + assert parse_version("1.3") > parse_version("1.3rc1") + assert parse_version("1.3rc1") > parse_version("1.3alpha3") + assert parse_version("1.3dev1") > parse_version("1.3rc1") + assert parse_version("1.2.3") > parse_version("1.2") diff --git a/tests/utils.py b/tests/utils.py index 6e4ade7d0..5e640e478 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -22,7 +22,6 @@ import unittest.mock class _asyncio_patch: - """ A wrapper around python patch supporting asyncio. Like the original patch you can use it as context @@ -52,11 +51,13 @@ class _asyncio_patch: def asyncio_patch(function, *args, **kwargs): return _asyncio_patch(function, *args, **kwargs) + # monkey patch MagicMock, # FIXME: would probably be better to use asynctest or pytest.asyncio to test our asyncio code async def async_magic(): pass + unittest.mock.MagicMock.__await__ = lambda x: async_magic().__await__() diff --git a/tests/utils/asyncio/test_embed_shell.py b/tests/utils/asyncio/test_embed_shell.py index 706292c34..930151e10 100644 --- a/tests/utils/asyncio/test_embed_shell.py +++ b/tests/utils/asyncio/test_embed_shell.py @@ -17,9 +17,9 @@ import asyncio -#from gns3server.utils.asyncio.embed_shell import EmbedShell +# from gns3server.utils.asyncio.embed_shell import EmbedShell -#FIXME: this is broken with recent Python >= 3.6 +# FIXME: this is broken with recent Python >= 3.6 # def test_embed_shell_help(async_run): # class Application(EmbedShell): # diff --git a/tests/utils/asyncio/test_ssh_server.py b/tests/utils/asyncio/test_ssh_server.py index 2a9ca5a7a..7f6d094a9 100644 --- a/tests/utils/asyncio/test_ssh_server.py +++ b/tests/utils/asyncio/test_ssh_server.py @@ -11,6 +11,7 @@ from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer # Helpers # --------------------------------------------------------------------------- + class DummyUpstreamWriter: def __init__(self): self.buffer = bytearray() @@ -49,15 +50,14 @@ async def _wait_for_session_count(server, expected_count, timeout=2): if last_count == expected_count: return await asyncio.sleep(0.05) - assert last_count == expected_count, ( - f"Expected {expected_count} sessions, got {last_count} after {timeout}s" - ) + assert last_count == expected_count, f"Expected {expected_count} sessions, got {last_count} after {timeout}s" # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_ssh_server_forwards_client_input_to_upstream_writer(): """Data typed by a connected SSH client must reach the upstream writer.""" diff --git a/tests/utils/asyncio/test_telnet_server.py b/tests/utils/asyncio/test_telnet_server.py index d91fb91c9..08c2d8acd 100644 --- a/tests/utils/asyncio/test_telnet_server.py +++ b/tests/utils/asyncio/test_telnet_server.py @@ -113,9 +113,7 @@ async def test_telnet_server_naws_callback_uses_negotiated_dimensions(): client_writer = None try: - _, client_writer = await telnetlib3.open_connection( - "127.0.0.1", port, encoding=False, cols=132, rows=44 - ) + _, client_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False, cols=132, rows=44) await asyncio.wait_for(callback_event.wait(), timeout=2) assert callback_result == {"columns": 132, "rows": 44} diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index cb59bfe39..f196d65c5 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -22,7 +22,6 @@ import pytest from gns3server.utils.asyncio import wait_run_in_executor, subprocess_check_output, locking - @pytest.mark.asyncio async def test_wait_run_in_executor(): @@ -59,7 +58,6 @@ async def test_lock_decorator(): """ class TestLock: - def __init__(self): self._test_val = 0 @@ -72,4 +70,9 @@ async def test_lock_decorator(): i = TestLock() res = set(await asyncio.gather(i.method_to_lock(), i.method_to_lock())) - assert res == set((0, 1,)) # We use a set to test this to avoid order issue + assert res == set( + ( + 0, + 1, + ) + ) # We use a set to test this to avoid order issue diff --git a/tests/utils/test_file_watcher.py b/tests/utils/test_file_watcher.py index 003d6162c..83b9761ec 100644 --- a/tests/utils/test_file_watcher.py +++ b/tests/utils/test_file_watcher.py @@ -23,7 +23,7 @@ from unittest.mock import MagicMock from gns3server.utils.file_watcher import FileWatcher -@pytest.mark.parametrize("strategy", ['mtime', 'hash']) +@pytest.mark.parametrize("strategy", ["mtime", "hash"]) @pytest.mark.asyncio async def test_file_watcher(tmpdir, strategy): @@ -38,7 +38,7 @@ async def test_file_watcher(tmpdir, strategy): callback.assert_called_with(str(file)) -@pytest.mark.parametrize("strategy", ['mtime', 'hash']) +@pytest.mark.parametrize("strategy", ["mtime", "hash"]) @pytest.mark.asyncio async def test_file_watcher_not_existing(tmpdir, strategy): @@ -52,7 +52,7 @@ async def test_file_watcher_not_existing(tmpdir, strategy): callback.assert_called_with(str(file)) -@pytest.mark.parametrize("strategy", ['mtime', 'hash']) +@pytest.mark.parametrize("strategy", ["mtime", "hash"]) @pytest.mark.asyncio async def test_file_watcher_list(tmpdir, strategy): diff --git a/tests/utils/test_images.py b/tests/utils/test_images.py index 9b0cabaac..7fa9a86c1 100644 --- a/tests/utils/test_images.py +++ b/tests/utils/test_images.py @@ -49,48 +49,48 @@ def test_images_directories(tmpdir, config): def test_md5sum(tmpdir): - fake_img = str(tmpdir / 'hello载') + fake_img = str(tmpdir / "hello载") - with open(fake_img, 'w+') as f: - f.write('hello') + with open(fake_img, "w+") as f: + f.write("hello") - assert md5sum(fake_img) == '5d41402abc4b2a76b9719d911017c592' - with open(str(tmpdir / 'hello载.md5sum')) as f: - assert f.read() == '5d41402abc4b2a76b9719d911017c592' + assert md5sum(fake_img) == "5d41402abc4b2a76b9719d911017c592" + with open(str(tmpdir / "hello载.md5sum")) as f: + assert f.read() == "5d41402abc4b2a76b9719d911017c592" def test_md5sum_stopped_event(tmpdir): - fake_img = str(tmpdir / 'hello_stopped') - with open(fake_img, 'w+') as f: - f.write('hello') + fake_img = str(tmpdir / "hello_stopped") + with open(fake_img, "w+") as f: + f.write("hello") event = threading.Event() event.set() assert md5sum(fake_img, stopped_event=event) is None - assert not os.path.exists(str(tmpdir / 'hello_stopped.md5sum')) + assert not os.path.exists(str(tmpdir / "hello_stopped.md5sum")) def test_md5sum_existing_digest(tmpdir): - fake_img = str(tmpdir / 'hello') + fake_img = str(tmpdir / "hello") - with open(fake_img, 'w+') as f: - f.write('hello') + with open(fake_img, "w+") as f: + f.write("hello") - with open(str(tmpdir / 'hello.md5sum'), 'w+') as f: - f.write('aaaaa02abc4b2a76b9719d911017c592') + with open(str(tmpdir / "hello.md5sum"), "w+") as f: + f.write("aaaaa02abc4b2a76b9719d911017c592") - assert md5sum(fake_img) == 'aaaaa02abc4b2a76b9719d911017c592' + assert md5sum(fake_img) == "aaaaa02abc4b2a76b9719d911017c592" def test_md5sum_existing_digest_but_missing_image(tmpdir): - fake_img = str(tmpdir / 'hello') + fake_img = str(tmpdir / "hello") - with open(str(tmpdir / 'hello.md5sum'), 'w+') as f: - f.write('aaaaa02abc4b2a76b9719d911017c592') + with open(str(tmpdir / "hello.md5sum"), "w+") as f: + f.write("aaaaa02abc4b2a76b9719d911017c592") assert md5sum(fake_img) is None @@ -102,13 +102,13 @@ def test_md5sum_none(tmpdir): def test_remove_checksum(tmpdir): - with open(str(tmpdir / 'hello.md5sum'), 'w+') as f: - f.write('aaaaa02abc4b2a76b9719d911017c592') - remove_checksum(str(tmpdir / 'hello')) + with open(str(tmpdir / "hello.md5sum"), "w+") as f: + f.write("aaaaa02abc4b2a76b9719d911017c592") + remove_checksum(str(tmpdir / "hello")) - assert not os.path.exists(str(tmpdir / 'hello.md5sum')) + assert not os.path.exists(str(tmpdir / "hello.md5sum")) - remove_checksum(str(tmpdir / 'not_exists')) + remove_checksum(str(tmpdir / "not_exists")) @pytest.mark.asyncio @@ -116,34 +116,33 @@ async def test_list_images(tmpdir, config): # IOS image in the images directory ios_image_1 = tmpdir / "images1" / "IOS" / "ios_image_1.image" - ios_image_1.write(b'\x7fELF\x01\x02\x01', ensure=True) + ios_image_1.write(b"\x7fELF\x01\x02\x01", ensure=True) ios_image_1 = force_unix_path(str(ios_image_1)) # IOS image in an additional images path ios_image_2 = tmpdir / "images2" / "ios_image_2.image" - ios_image_2.write(b'\x7fELF\x01\x02\x01', ensure=True) + ios_image_2.write(b"\x7fELF\x01\x02\x01", ensure=True) ios_image_2 = force_unix_path(str(ios_image_2)) # Not a valid elf file not_elf_file = tmpdir / "images1" / "IOS" / "not_elf.image" - not_elf_file.write(b'NOTANELF', ensure=True) + not_elf_file.write(b"NOTANELF", ensure=True) not_elf_file = force_unix_path(str(not_elf_file)) # Invalid image because it is very small small_file = tmpdir / "images1" / "too_small.image" - small_file.write(b'1', ensure=True) + small_file.write(b"1", ensure=True) if sys.platform.startswith("linux"): # 64-bit IOU image iou_image_1 = tmpdir / "images1" / "IOU" / "iou64.bin" - iou_image_1.write(b'\x7fELF\x02\x01\x01', ensure=True) + iou_image_1.write(b"\x7fELF\x02\x01\x01", ensure=True) iou_image_1 = force_unix_path(str(iou_image_1)) # 32-bit IOU image iou_image_2 = tmpdir / "images1" / "IOU" / "iou32.bin" - iou_image_2.write(b'\x7fELF\x01\x01\x01', ensure=True) # 32-bit IOU image + iou_image_2.write(b"\x7fELF\x01\x01\x01", ensure=True) # 32-bit IOU image iou_image_2 = force_unix_path(str(iou_image_2)) - # Qemu image qemu_image_1 = tmpdir / "images1" / "QEMU" / "qemu_image.qcow2" qemu_image_1.write("1234567", ensure=True) @@ -151,7 +150,7 @@ async def test_list_images(tmpdir, config): # ELF file inside the Qemu elf_file = tmpdir / "images1" / "QEMU" / "elf_file.bin" - elf_file.write(b'\x7fELF\x02\x01\x01', ensure=True) # ELF file + elf_file.write(b"\x7fELF\x02\x01\x01", ensure=True) # ELF file elf_file = force_unix_path(str(elf_file)) md5sum_file = tmpdir / "images1" / "QEMU" / "image.qcow2.md5sum" @@ -161,42 +160,32 @@ async def test_list_images(tmpdir, config): config.settings.Server.images_path = str(tmpdir / "images1") config.settings.Server.additional_images_paths = "/tmp/null24564;" + str(tmpdir / "images2") - assert sorted(await list_images("dynamips"), key=lambda k: k['filename']) == [ + assert sorted(await list_images("dynamips"), key=lambda k: k["filename"]) == [ { - 'filename': 'ios_image_1.image', - 'filesize': 7, - 'md5sum': 'b0d5aa897d937aced5a6b1046e8f7e2e', - 'path': 'ios_image_1.image' + "filename": "ios_image_1.image", + "filesize": 7, + "md5sum": "b0d5aa897d937aced5a6b1046e8f7e2e", + "path": "ios_image_1.image", }, { - 'filename': 'ios_image_2.image', - 'filesize': 7, - 'md5sum': 'b0d5aa897d937aced5a6b1046e8f7e2e', - 'path': str(ios_image_2) - } + "filename": "ios_image_2.image", + "filesize": 7, + "md5sum": "b0d5aa897d937aced5a6b1046e8f7e2e", + "path": str(ios_image_2), + }, ] if sys.platform.startswith("linux"): - assert sorted(await list_images("iou"), key=lambda k: k['filename']) == [ - { - 'filename': 'iou32.bin', - 'filesize': 7, - 'md5sum': 'e573e8f5c93c6c00783f20c7a170aa6c', - 'path': 'iou32.bin' - }, - { - 'filename': 'iou64.bin', - 'filesize': 7, - 'md5sum': 'c73626d23469519894d58bc98bee9655', - 'path': 'iou64.bin' - } + assert sorted(await list_images("iou"), key=lambda k: k["filename"]) == [ + {"filename": "iou32.bin", "filesize": 7, "md5sum": "e573e8f5c93c6c00783f20c7a170aa6c", "path": "iou32.bin"}, + {"filename": "iou64.bin", "filesize": 7, "md5sum": "c73626d23469519894d58bc98bee9655", "path": "iou64.bin"}, ] assert await list_images("qemu") == [ { - 'filename': 'qemu_image.qcow2', - 'filesize': 7, - 'md5sum': 'fcea920f7412b5da7be0cf42b8c93759', - 'path': 'qemu_image.qcow2' + "filename": "qemu_image.qcow2", + "filesize": 7, + "md5sum": "fcea920f7412b5da7be0cf42b8c93759", + "path": "qemu_image.qcow2", } ] diff --git a/tests/utils/test_packet_filter_validation.py b/tests/utils/test_packet_filter_validation.py index d15ae1fc3..a5ba0cbe8 100644 --- a/tests/utils/test_packet_filter_validation.py +++ b/tests/utils/test_packet_filter_validation.py @@ -7,7 +7,7 @@ from gns3server.utils.packet_filter_validation import ( validate_filter_parameters, validate_all_filters, filter_inactive_filters, - FilterValidationError + FilterValidationError, ) @@ -143,17 +143,14 @@ class TestPacketFilterValidation: def test_validate_all_filters(self): """Test validating multiple filters at once.""" - filters = { - "frequency_drop": [10], - "delay": [100, 50] - } + filters = {"frequency_drop": [10], "delay": [100, 50]} validate_all_filters(filters) # Should not raise def test_validate_all_filters_with_invalid(self): """Test validate_all_filters with invalid filter.""" filters = { "frequency_drop": [10], - "packet_loss": [150] # Invalid: over 100% + "packet_loss": [150], # Invalid: over 100% } with pytest.raises(FilterValidationError): validate_all_filters(filters) @@ -242,16 +239,13 @@ class TestFilterInactiveFilters: def test_filter_inactive_multiple_filters_mixed(self): """Test multiple filters with mixed active/inactive states.""" filters = { - "delay": [0, 0], # Disabled: [0, 0] - "packet_loss": [0], # Disabled: 0% - "corrupt": [2], # Active: 2% - "frequency_drop": [10] # Active: every 10th packet + "delay": [0, 0], # Disabled: [0, 0] + "packet_loss": [0], # Disabled: 0% + "corrupt": [2], # Active: 2% + "frequency_drop": [10], # Active: every 10th packet } result = filter_inactive_filters(filters) - assert result == { - "corrupt": [2], - "frequency_drop": [10] - } + assert result == {"corrupt": [2], "frequency_drop": [10]} def test_filter_inactive_empty_filters(self): """Test empty filters dictionary.""" @@ -262,4 +256,4 @@ class TestFilterInactiveFilters: def test_filter_inactive_none_filters(self): """Test None filters.""" result = filter_inactive_filters(None) - assert result == {} \ No newline at end of file + assert result == {} diff --git a/win-requirements.txt b/win-requirements.txt deleted file mode 100644 index 1cc7ba1e2..000000000 --- a/win-requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ --r requirements.txt - -pywin32==311 -wmi==1.5.1