mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-18 00:50:09 +03:00
Merge branch '3.1' into fix/node-update-while-running
This commit is contained in:
commit
d2b851f148
2
.github/workflows/testing.yml
vendored
2
.github/workflows/testing.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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")
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
]
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user