Merge branch '3.1' into fix/node-update-while-running

This commit is contained in:
Guobin Yue 2026-10-16 16:56:40 +08:00 committed by GitHub
commit d2b851f148
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 590 additions and 25 deletions

View File

@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- uses: actions/checkout@v7

View File

@ -24,10 +24,10 @@ langchain-deepseek>=1.0.1
langchain-xai>=1.2.2
# Token Counting
tiktoken>=0.8.0
tiktoken>=0.14.0
# LangSmith SDK
langsmith>=0.7.7
langsmith>=0.14.7
# Network Automation
paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405
@ -41,7 +41,7 @@ nornir-salt>=0.23.4
python-dotenv>=1.2.1
# Authentication
PyJWT>=2.10.1
PyJWT>=2.15.1
psycopg-pool>=3.1.0
# Skills Repository Management

View File

@ -67,7 +67,7 @@ class GNS3TopologyTool(BaseTool):
Output: Dictionary with:
- `project_id`, `name`, `status`: Project metadata
- `nodes`: Dict of node details (node_id, name, ports, console_port,
type, etc.)
console_host, type, etc.)
- `links`: List of link connections
Use this to understand network structure before making changes.

View File

@ -43,9 +43,9 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
Build an inventory-style dict keyed by node name.
Shape (per node name):
{server, name, node_id, console_port, console_type, type, ports,
status, x, y, tags, netmiko_device_type, default_username,
default_password}
{server, name, node_id, console_port, console_host, console_type,
type, ports, status, x, y, tags, netmiko_device_type,
default_username, default_password}
"""
inventory: dict[str, Any] = {}
for n in nodes:
@ -54,6 +54,9 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
"name": n.get("name"),
"node_id": n.get("node_id"),
"console_port": n.get("console"),
# Where the console port is bound: the compute's own host for
# remote computes, not the controller running this code.
"console_host": n.get("console_host"),
"console_type": n.get("console_type"),
"type": n.get("node_type"),
"ports": n.get("ports"),

View File

@ -451,7 +451,7 @@ class VPCSCommands(BaseTool):
continue
# VPCS devices use gns3_vpcs_telnet device type
hosts_data[device_name] = {
host_entry = {
"port": port,
"platform": "vpcs",
"groups": ["vpcs_devices"], # All VPCS devices share one group
@ -465,6 +465,12 @@ class VPCSCommands(BaseTool):
}
},
}
# Remote computes bind console ports on their own host; carry the
# per-node hostname from the topology so it overrides the group's
# controller-wide default.
if "hostname" in device_ports[device_name]:
host_entry["hostname"] = device_ports[device_name]["hostname"]
hosts_data[device_name] = host_entry
return hosts_data

View File

@ -68,7 +68,12 @@ def get_device_ports_from_topology(
}
}
}
Devices that don't exist or missing console_port will not be included
Devices that don't exist or missing console_port will not be included.
Nodes whose console is bound on a remote compute also carry a
host-level "hostname" (copied from the topology's console_host) so
netmiko dials the compute hosting the console port instead of the
controller; for everything else the field is omitted and the shared
group hostname (the controller host) applies.
"""
# Log received parameters
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
@ -167,6 +172,15 @@ def get_device_ports_from_topology(
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
}
# Per-node console hostname. Remote computes bind console ports
# on their own host, not on the controller running this code, so
# the group's controller-wide hostname cannot reach them. A
# host-level hostname (which wins over the group in nornir) fixes
# that; unset or wildcard values keep the group inheritance.
console_host = node_info.get("console_host")
if console_host and console_host not in ("0.0.0.0", "::"):
host_entry["hostname"] = console_host
# Per-node default credentials (seeded from the template appliance
# metadata) override the group's empty fallback. Only inject when
# set, so credential-less devices keep inheriting the group values.

View File

@ -26,6 +26,7 @@ from fastapi import APIRouter, Body, Depends, Query, status
import gns3server.db.models as models
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services.computes import ComputesService
@ -80,6 +81,42 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None:
await compute.connect(report_failed_connection=True)
@router.post(
"/{compute_id}/disconnect",
status_code=status.HTTP_204_NO_CONTENT,
responses={409: {"model": schemas.ErrorMessage, "description": "Compute is used by opened projects"}},
# dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui
)
async def disconnect_compute(compute_id: Union[str, UUID], force: Optional[bool] = False) -> None:
"""
Disconnect from a compute on the controller.
The compute is not reconnected until an explicit connection is requested,
or the controller restarts with auto_connect enabled for this compute.
Rejected with 409 when an opened project still uses the compute, unless
force is true. Forcing does not stop anything on the compute: only the
controller-side control channel (notification stream and HTTP session) is
torn down, nodes keep running unmanaged.
Required privilege: Compute.Audit
"""
if str(compute_id) == "local":
raise ControllerBadRequestError("Cannot disconnect the local compute")
controller = Controller.instance()
compute = controller.get_compute(str(compute_id))
if not force:
open_projects = controller.get_compute_open_projects(compute)
if open_projects:
project_names = ", ".join(f"'{project.name}'" for project in open_projects)
raise ControllerError(
f"Compute '{compute_id}' is used by opened projects ({project_names}), "
"disconnect anyway with force=true"
)
await compute.disconnect()
@router.get(
"/{compute_id}",
response_model=schemas.Compute,

View File

@ -201,10 +201,12 @@ class Controller:
if computes:
for c in computes:
try:
# FIXME: Task exception was never retrieved
await self.add_compute(
compute_id=str(c.compute_id),
connect=False,
# computes flagged auto_connect connect in the
# background, one second after the controller started
connect=c.auto_connect,
wait_connection=False,
**c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}),
ssl_context=self._ssl_context,
)
@ -625,9 +627,12 @@ class Controller:
if connect:
if wait_connection:
await compute.connect()
else:
# call compute.connect() later to give time to the controller to be fully started
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute.connect()))
elif not hasattr(sys, "_called_from_test"):
# connect in the background to give time to the controller to be fully
# started; goes through _try_reconnect so connection errors don't die
# as unretrieved task exceptions. Skipped during tests: the timer would
# leak the connection attempt into another test's event loop.
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute._try_reconnect()))
self.notification.controller_emit("compute.created", compute.asdict())
return compute
else:
@ -645,6 +650,17 @@ class Controller:
if compute in project.computes:
await project.close()
def get_compute_open_projects(self, compute):
"""
Return the opened projects using a compute.
:returns: List of opened projects
"""
return [
project for project in self._projects.values() if compute in project.computes and project.status == "opened"
]
def compute_has_open_project(self, compute):
"""
Check is compute has a project opened.
@ -652,10 +668,7 @@ class Controller:
:returns: True if a project is open
"""
for project in self._projects.values():
if compute in project.computes and project.status == "opened":
return True
return False
return bool(self.get_compute_open_projects(compute))
async def delete_compute(self, compute_id):
"""

View File

@ -61,6 +61,7 @@ class Compute:
name=None,
console_host=None,
ssl_context=None,
auto_connect=False,
):
self._http_session = None
assert controller is not None
@ -80,6 +81,11 @@ class Compute:
self._connected = False
self._notifications = None
self._closed = False # Close mean we are destroying the compute node
# True when the user asked to disconnect: blocks every reconnection
# path (retry backoff, notification stream reconnect, lazy connect)
# until connect() is called again
self._manual_disconnect = False
self._auto_connect = auto_connect
self._controller = controller
self._set_auth(user, password)
self._cpu_usage_percent = 0
@ -154,6 +160,11 @@ class Compute:
async def update(self, **kwargs):
if "auto_connect" in kwargs:
# stored separately from the generic attributes below so that
# asdict() reports it (it is persisted in the database, the
# controller-side copy is only a mirror for notifications)
self._auto_connect = kwargs.pop("auto_connect")
for kw in kwargs:
if kw not in ("user", "password"):
setattr(self, kw, kwargs[kw])
@ -323,6 +334,7 @@ class Compute:
"host": self._host,
"port": self._port,
"user": self._user,
"auto_connect": self._auto_connect,
"connected": self._connected,
"cpu_usage_percent": self._cpu_usage_percent,
"memory_usage_percent": self._memory_usage_percent,
@ -368,7 +380,9 @@ class Compute:
:param params: Optional dict of query parameters to append to the URL
"""
if not self._connected and not dont_connect:
# a manually disconnected compute is not lazily reconnected either: the
# request fails like for any unreachable compute
if not self._connected and not self._manual_disconnect and not dont_connect:
if self._id == "vm" and not self._controller.gns3vm.running:
await self._controller.gns3vm.start()
await self.connect()
@ -381,6 +395,9 @@ class Compute:
"""
We catch error during reconnect
"""
if self._manual_disconnect:
return # stop reconnecting, the compute was manually disconnected
try:
await self.connect()
except ControllerError:
@ -413,6 +430,8 @@ class Compute:
Check if remote server is accessible
"""
# an explicit connection request cancels a manual disconnect
self._manual_disconnect = False
if not self._connected and not self._closed and self.host:
try:
log.info(f"Connecting to compute '{self._id}'")
@ -474,6 +493,31 @@ class Compute:
self._last_error = None
self._controller.notification.controller_emit("compute.updated", self.asdict())
async def disconnect(self):
"""
Manually disconnect from the compute. The controller stops reconnecting
(retry backoff, notification stream reconnect and lazy connection are
all blocked) until connect() is called again, or the controller
restarts with auto_connect enabled for this compute.
"""
self._manual_disconnect = True
if self._notifications:
# Cancelling the notification task runs its finally block: the
# compute is marked disconnected and clients are notified. The
# flag set above keeps it from scheduling a reconnection.
self._notifications.cancel()
try:
await self._notifications
except asyncio.CancelledError:
pass
self._notifications = None
if self._http_session and not self._http_session.closed:
await self._http_session.close()
if self._connected:
self._connected = False
self._controller.notification.controller_emit("compute.updated", self.asdict())
async def _connect_notification(self):
"""
Connect to the notification stream
@ -526,9 +570,12 @@ class Compute:
# 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"):
# The reconnection goes through _try_reconnect (not connect
# directly) so that a manual disconnect landing within the 1
# second window is still honoured by the time the timer fires
if not self._manual_disconnect and 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()))
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self._try_reconnect()))
def _getUrl(self, path):
host = self._host

View File

@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String
from sqlalchemy import Boolean, Column, Integer, String, false
from .base import GUID, BaseTable
@ -30,3 +30,4 @@ class Compute(BaseTable):
port = Column(Integer)
user = Column(String)
password = Column(String)
auto_connect = Column(Boolean, nullable=False, server_default=false())

View File

@ -61,6 +61,7 @@ class ComputesRepository(BaseRepository):
port=compute_create.port,
user=compute_create.user,
password=compute_create.password.get_secret_value() if compute_create.password else None,
auto_connect=bool(compute_create.auto_connect),
)
self._db_session.add(db_compute)
await self._db_session.commit()
@ -74,6 +75,9 @@ class ComputesRepository(BaseRepository):
update_values = compute_update.model_dump(exclude_unset=True)
if compute_update.password is not None:
update_values["password"] = compute_update.password.get_secret_value()
if update_values.get("auto_connect") is None:
# the column is NOT NULL: treat an explicit null as "leave unchanged"
update_values.pop("auto_connect", None)
query = update(models.Compute).where(models.Compute.compute_id == compute_id).values(update_values)

View File

@ -0,0 +1,26 @@
"""add auto_connect to computes table
Revision ID: f7d3a9c2e5b1
Revises: d9e8a2b7c401
Create Date: 2026-10-15 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f7d3a9c2e5b1"
down_revision = "d9e8a2b7c401"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("computes", sa.Column("auto_connect", sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade() -> None:
op.drop_column("computes", "auto_connect")

View File

@ -49,6 +49,9 @@ class ComputeBase(BaseModel):
user: Optional[str] = None
password: Optional[SecretStr] = None
name: Optional[str] = None
auto_connect: Optional[bool] = Field(
False, description="Whether to automatically connect to the compute when the controller starts"
)
model_config = ConfigDict(use_enum_values=True)
@ -87,6 +90,7 @@ class ComputeUpdate(ComputeBase):
user: Optional[str] = None
password: Optional[SecretStr] = None
auto_connect: Optional[bool] = None # no default: update schemas must not publish defaults
model_config = ConfigDict(
json_schema_extra={
"example": {
@ -107,6 +111,8 @@ class Compute(DateTimeModelMixin, ComputeBase):
port: int = Field(..., gt=0, le=65535)
compute_id: Union[str, uuid.UUID]
name: str
# defaulted: topology files and the local compute dict may not carry it
auto_connect: bool = False
connected: Optional[bool] = Field(None, description="Whether the controller is connected to the compute or not")
cpu_usage_percent: Optional[float] = Field(None, description="CPU usage of the compute", ge=0, le=100)
memory_usage_percent: Optional[float] = Field(None, description="Memory usage of the compute", ge=0, le=100)

View File

@ -15,19 +15,44 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
from typing import List, Union
from uuid import UUID
import gns3server.db.models as models
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import (
ControllerBadRequestError,
ControllerError,
ControllerNotFoundError,
)
from gns3server.db.repositories.computes import ComputesRepository
async def _connect_in_background(compute: Compute) -> None:
try:
await compute.connect()
except ControllerError:
# the connection failure is reported via compute.updated and the
# retry backoff is already scheduled by the connect attempt itself
pass
# fire-and-forget background connections: keep a reference so the task is not
# garbage-collected mid-flight, drop it once it is done
_background_connect_tasks: set[asyncio.Task] = set()
def _schedule_background_connect(compute: Compute) -> None:
task = asyncio.ensure_future(_connect_in_background(compute))
_background_connect_tasks.add(task)
task.add_done_callback(_background_connect_tasks.discard)
class ComputesService:
def __init__(self, computes_repo: ComputesRepository):
@ -50,6 +75,13 @@ class ComputesService:
**compute_create.model_dump(exclude_unset=True, exclude={"compute_id"}),
)
self._controller.notification.controller_emit("compute.created", compute.asdict())
# auto_connect connects in the background — including when the compute
# was already registered on the controller — so the create request
# never waits for, or fails on, the connection. An explicit
# ?connect=true keeps its synchronous behavior via add_compute.
if compute_create.auto_connect and not compute.connected:
_schedule_background_connect(compute)
return db_compute
async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]:
@ -68,11 +100,26 @@ class ComputesService:
) -> models.Compute:
compute = self._controller.get_compute(str(compute_id))
# remember the previous value to detect the false -> true transition below
previous_auto_connect = False
if compute_update.auto_connect:
previous_db_compute = await self._computes_repo.get_compute(compute_id)
if previous_db_compute:
previous_auto_connect = bool(previous_db_compute.auto_connect)
await compute.update(**compute_update.model_dump(exclude_unset=True))
db_compute = await self._computes_repo.update_compute(compute_id, compute_update)
if not db_compute:
raise ControllerNotFoundError(f"Compute '{compute_id}' not found")
self._controller.notification.controller_emit("compute.updated", compute.asdict())
# Enabling auto_connect (false -> true) connects in the background: the
# request returns immediately and the connection status flips via
# compute.updated. The true -> true case is skipped on purpose so that
# saving an edit does not reconnect a manually disconnected compute.
if compute_update.auto_connect and not previous_auto_connect and not compute.connected:
_schedule_background_connect(compute)
return db_compute
async def delete_compute(self, compute_id: Union[str, UUID]) -> None:

View File

@ -25,6 +25,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Implementation :: CPython"
]

View File

@ -130,6 +130,36 @@ class TestVPCSNodeTypeGuard:
assert "output" not in result[0]
class TestVPCSConsoleHostPropagation:
"""
VPCS builds its own nornir host entries, so the per-node console
hostname resolved by get_device_ports_from_topology must be carried
over instead of silently falling back to the controller host.
"""
def test_remote_compute_hostname_reaches_nornir_inventory(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, "node_type": "vpcs", "hostname": "192.168.122.10"},
"PC2": {"port": 5001, "node_type": "vpcs"},
},
):
hosts_data = VPCSCommands()._prepare_device_hosts_data(
[
{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]},
{"device_name": "PC2", "commands": ["ip 10.0.0.2/24"]},
],
PROJECT_ID,
)
# remote-compute console hostname lands at host level
assert hosts_data["PC1"]["hostname"] == "192.168.122.10"
# local-compute entries keep inheriting the group hostname
assert "hostname" not in hosts_data["PC2"]
class TestDeviceToolErrorContract:
"""
Every in-band error entry carries status "failed" plus an "error"

View File

@ -64,6 +64,35 @@ def test_nodes_inventory_emits_default_credentials():
assert inventory["R1"]["tags"] == []
def test_nodes_inventory_carries_console_host():
"""
The inventory must keep the per-node console_host: remote computes bind
console ports on their own host, and the Nornir inventory needs it to
dial devices outside the controller machine.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
build_nodes_inventory,
)
nodes = [
{
"name": "R1",
"node_id": "0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
"node_type": "dynamips",
"console": 5000,
"console_host": "192.168.122.10",
"console_type": "telnet",
"status": "started",
},
]
inventory = build_nodes_inventory(nodes, "127.0.0.1")
assert inventory["R1"]["console_host"] == "192.168.122.10"
# the controller-wide "server" stays for backward compatibility
assert inventory["R1"]["server"] == "127.0.0.1"
def test_links_summary_resolves_names_and_ports():
"""
links_summary maps raw link endpoint lists to
@ -234,3 +263,60 @@ def test_device_ports_inject_default_credentials(monkeypatch):
assert "password" not in hosts["R2"]
assert "username" not in hosts["R3"]
assert "password" not in hosts["R3"]
def test_device_ports_carry_per_node_console_host(monkeypatch):
"""
Remote computes bind console ports on their own host: console_host from
the topology becomes a host-level nornir hostname (overriding the
group's controller-wide default). Missing or wildcard values keep the
group inheritance.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot import gns3_client
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
class _FakeTopology:
def _run(self, project_id=None, jwt_token=None, url=None):
return {
"nodes": {
# node on a remote compute (another GNS3 VM)
"R1": {
"console_port": 5000,
"console_host": "192.168.122.10",
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
},
# node on the local compute
"R2": {
"console_port": 5001,
"console_host": "127.0.0.1",
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
},
# legacy topology payload without console_host
"R3": {
"console_port": 5002,
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
},
# wildcard host cannot be dialed; keep group inheritance
"R4": {
"console_port": 5003,
"console_host": "0.0.0.0",
"tags": [],
"netmiko_device_type": "cisco_ios_telnet",
},
}
}
# the function does a lazy from-import inside the body
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3", "R4"])
# remote and local consoles dial their own host
assert hosts["R1"]["hostname"] == "192.168.122.10"
assert hosts["R2"]["hostname"] == "127.0.0.1"
# missing or wildcard console_host keeps inheriting the group hostname
assert "hostname" not in hosts["R3"]
assert "hostname" not in hosts["R4"]

View File

@ -15,6 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import uuid
from unittest.mock import MagicMock, patch
@ -22,6 +23,7 @@ import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.controller import Controller
from gns3server.schemas.controller.computes import Compute
pytestmark = pytest.mark.asyncio
@ -62,7 +64,7 @@ class TestComputeRoutes:
for param, value in params.items():
assert response.json()[param] == value
async def test_compute_list(self, app: FastAPI, client: AsyncClient) -> None:
async def test_compute_list(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
response = await client.get(app.url_path_for("get_computes"))
assert response.status_code == status.HTTP_200_OK
@ -263,3 +265,152 @@ class TestComputeFeatures:
# response = await client.post(app.url_path_for("autoidlepc", compute_id=compute_id) + "/auto_idlepc", json=params)
# assert mock.called
# assert response.status_code == status.HTTP_200_OK
class TestComputeAutoConnect:
async def test_compute_create_with_auto_connect(self, app: FastAPI, client: AsyncClient) -> None:
params = {
"protocol": "http",
"host": "localhost",
"port": 84,
"user": "julien",
"password": "secure",
"auto_connect": True,
}
with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock:
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["auto_connect"] is True
# the create request returns immediately, the connection happens
# in the background
await asyncio.sleep(0)
assert connect_mock.call_count == 1
# persisted, not just echoed back
compute_id = response.json()["compute_id"]
response = await client.get(app.url_path_for("get_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["auto_connect"] is True
async def test_compute_create_auto_connect_defaults_to_false(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 85, "user": "julien", "password": "secure"}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["auto_connect"] is False
async def test_compute_update_enable_auto_connect_connects(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 86, "name": "auto-connect-target"}
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 patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock:
response = await client.put(
app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["auto_connect"] is True
await asyncio.sleep(0) # give the background connection task a tick
assert connect_mock.call_count == 1
# saving the same value again must not reconnect: a manual
# disconnect has to survive an unrelated edit of the compute
response = await client.put(
app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True}
)
assert response.status_code == status.HTTP_200_OK
await asyncio.sleep(0)
assert connect_mock.call_count == 1
async def test_compute_update_preserves_auto_connect(self, app: FastAPI, client: AsyncClient) -> None:
params = {
"protocol": "http",
"host": "localhost",
"port": 87,
"user": "julien",
"password": "secure",
"auto_connect": True,
}
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"]
response = await client.put(app.url_path_for("update_compute", compute_id=compute_id), json={"name": "renamed"})
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "renamed"
response = await client.get(app.url_path_for("get_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["auto_connect"] is True
class TestComputeDisconnect:
async def test_compute_disconnect(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 88, "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"]
compute = Controller.instance().get_compute(compute_id)
compute._connected = True
response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert compute._manual_disconnect is True
assert compute.connected is False
async def test_compute_disconnect_in_use_rejected(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 89, "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"]
compute = Controller.instance().get_compute(compute_id)
compute._connected = True
open_project = MagicMock()
open_project.name = "project-using-the-compute"
with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[open_project]):
response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_409_CONFLICT
# the offending project names are reported to the client
assert "project-using-the-compute" in response.json()["message"]
# the compute is left untouched
assert compute._manual_disconnect is False
assert compute.connected is True
async def test_compute_disconnect_in_use_forced(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 90, "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"]
compute = Controller.instance().get_compute(compute_id)
compute._connected = True
with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[MagicMock()]):
response = await client.post(
app.url_path_for("disconnect_compute", compute_id=compute_id), params={"force": True}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert compute._manual_disconnect is True
assert compute.connected is False
async def test_compute_disconnect_local_rejected(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.post(app.url_path_for("disconnect_compute", compute_id="local"))
assert response.status_code == status.HTTP_400_BAD_REQUEST
async def test_compute_disconnect_unknown(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.post(app.url_path_for("disconnect_compute", compute_id=str(uuid.uuid4())))
assert response.status_code == status.HTTP_404_NOT_FOUND

View File

@ -29,11 +29,13 @@ from gns3server.api.server import app as gns3_app
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import (
ComputeConflictError,
ComputeError,
ControllerError,
ControllerNotFoundError,
ControllerUnauthorizedError,
)
from gns3server.controller.project import Project
from gns3server.version import __version__
from tests.utils import AsyncioMagicMock, asyncio_patch
@ -376,6 +378,7 @@ async def test_json(compute):
"host": "example.com",
"port": 84,
"user": "test",
"auto_connect": False,
"cpu_usage_percent": 0,
"memory_usage_percent": 0,
"disk_usage_percent": 0,
@ -696,3 +699,87 @@ async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, m
actions = [c.args[0] for c in emit_mock.call_args_list]
assert "compute.updated" in actions
assert scheduled_delays == [5] # first exponential backoff delay
def test_auto_connect_constructor_default(controller):
assert Compute("default_auto_connect", controller=controller).asdict()["auto_connect"] is False
assert Compute("enabled_auto_connect", controller=controller, auto_connect=True).asdict()["auto_connect"] is True
@pytest.mark.asyncio
async def test_update_auto_connect(compute, controller):
compute._controller._notification = MagicMock()
compute._controller.save = MagicMock()
assert compute.asdict()["auto_connect"] is False
await compute.update(auto_connect=True)
assert compute.asdict()["auto_connect"] is True
controller.notification.controller_emit.assert_called_with("compute.updated", compute.asdict())
@pytest.mark.asyncio
async def test_disconnect_stops_the_notification_stream(compute, controller):
"""
disconnect() tears down the notification stream, marks the compute
disconnected and notifies clients — without scheduling any reconnection.
"""
assert compute.connected is True
compute._controller._notification = MagicMock()
started = asyncio.Event()
async def fake_notification_stream():
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
raise
compute._notifications = asyncio.gather(asyncio.create_task(fake_notification_stream()))
await started.wait()
await compute.disconnect()
assert compute._manual_disconnect is True
assert compute.connected is False
assert compute._notifications is None
compute._controller._notification.controller_emit.assert_called_with("compute.updated", compute.asdict())
@pytest.mark.asyncio
async def test_manual_disconnect_blocks_reconnect_and_lazy_connect(compute):
"""
After a manual disconnect no reconnection path may bring the compute back:
not the retry backoff, not the lazy connection of http_query.
"""
await compute.disconnect()
with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock:
await compute._try_reconnect()
connect_mock.assert_not_called()
with pytest.raises(ComputeError):
await compute.http_query("GET", "/capabilities")
connect_mock.assert_not_called()
@pytest.mark.asyncio
async def test_connect_cancels_manual_disconnect(compute, monkeypatch):
compute._manual_disconnect = True
async def fake_capabilities(method, path, **kwargs):
return SimpleNamespace(json={"version": __version__})
async def fake_notification_stream():
return None
monkeypatch.setattr(compute, "_run_http_query", fake_capabilities)
monkeypatch.setattr(compute, "_connect_notification", fake_notification_stream)
await compute.connect()
assert compute._manual_disconnect is False
assert compute.connected is True

View File

@ -401,6 +401,7 @@ async def test_path_matching_does_not_interpret_sql_wildcards(inventory, config)
async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config):
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
image_file(config)
await scan(inventory)
@ -414,7 +415,12 @@ async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, c
cfg = Config()
cfg.set_main_option("script_location", "gns3server:db_migrations")
cfg.attributes["connection"] = connection
command.stamp(cfg, "d9e8a2b7c401")
# The catalog is built from the models, so it already carries the head
# schema. Stamping head (not the revision that happened to be newest when
# this test was written) keeps the round-trip valid for later migrations:
# every revision above the inventory one is rolled back before it is
# replayed, instead of being applied on top of its own schema.
command.stamp(cfg, ScriptDirectory.from_config(cfg).get_current_head())
command.downgrade(cfg, "c7e4a9f1d2b6")
assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1
assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"]