mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-22 02:42:22 +03:00
Merge branch '3.1' into dependabot/pip/3.1/nornir-utils-gte-0.3.0
This commit is contained in:
commit
7b8bb7140f
54
.githooks/pre-commit
Executable file
54
.githooks/pre-commit
Executable file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-commit checks mirroring the CI checks that are fast enough to run on
|
||||||
|
# every commit (justfile "lint" + "type-check"; pytest and pip check are not
|
||||||
|
# run here). Only staged Python files are checked.
|
||||||
|
#
|
||||||
|
# Install with: just install-hooks
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prefer the repo virtualenv, fall back to whatever python is on PATH
|
||||||
|
PYTHON="venv/bin/python"
|
||||||
|
if [ ! -x "$PYTHON" ]; then
|
||||||
|
PYTHON="python"
|
||||||
|
fi
|
||||||
|
if ! command -v "$PYTHON" >/dev/null 2>&1; then
|
||||||
|
echo "pre-commit: no python interpreter found, skipping checks" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Staged .py files (deletions excluded)
|
||||||
|
mapfile -t staged < <(git diff --cached --name-only --diff-filter=d -- '*.py')
|
||||||
|
if [ ${#staged[@]} -eq 0 ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
status=0
|
||||||
|
|
||||||
|
echo "pre-commit: ruff check"
|
||||||
|
"$PYTHON" -m ruff check --exit-non-zero-on-fix "${staged[@]}" || status=1
|
||||||
|
|
||||||
|
echo "pre-commit: ruff format --check"
|
||||||
|
"$PYTHON" -m ruff format --check --diff "${staged[@]}" || status=1
|
||||||
|
|
||||||
|
# mypy is configured with files = ["gns3server"] in pyproject.toml, i.e. CI
|
||||||
|
# never type-checks tests: only pass staged files from that package.
|
||||||
|
mypy_targets=()
|
||||||
|
for f in "${staged[@]}"; do
|
||||||
|
case "$f" in
|
||||||
|
gns3server/*) mypy_targets+=("$f") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#mypy_targets[@]} -gt 0 ]; then
|
||||||
|
echo "pre-commit: mypy"
|
||||||
|
"$PYTHON" -m mypy "${mypy_targets[@]}" || status=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$status" -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "pre-commit: checks failed. Fix the issues above (try 'just format')," >&2
|
||||||
|
echo "or bypass with: git commit --no-verify" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
2
.github/workflows/testing.yml
vendored
2
.github/workflows/testing.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|||||||
@ -24,10 +24,10 @@ langchain-deepseek>=1.0.1
|
|||||||
langchain-xai>=1.2.2
|
langchain-xai>=1.2.2
|
||||||
|
|
||||||
# Token Counting
|
# Token Counting
|
||||||
tiktoken>=0.8.0
|
tiktoken>=0.14.0
|
||||||
|
|
||||||
# LangSmith SDK
|
# LangSmith SDK
|
||||||
langsmith>=0.7.7
|
langsmith>=0.14.7
|
||||||
|
|
||||||
# Network Automation
|
# Network Automation
|
||||||
paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405
|
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
|
python-dotenv>=1.2.1
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
PyJWT>=2.10.1
|
PyJWT>=2.15.1
|
||||||
psycopg-pool>=3.1.0
|
psycopg-pool>=3.1.0
|
||||||
|
|
||||||
# Skills Repository Management
|
# Skills Repository Management
|
||||||
|
|||||||
@ -67,7 +67,7 @@ class GNS3TopologyTool(BaseTool):
|
|||||||
Output: Dictionary with:
|
Output: Dictionary with:
|
||||||
- `project_id`, `name`, `status`: Project metadata
|
- `project_id`, `name`, `status`: Project metadata
|
||||||
- `nodes`: Dict of node details (node_id, name, ports, console_port,
|
- `nodes`: Dict of node details (node_id, name, ports, console_port,
|
||||||
type, etc.)
|
console_host, type, etc.)
|
||||||
- `links`: List of link connections
|
- `links`: List of link connections
|
||||||
|
|
||||||
Use this to understand network structure before making changes.
|
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.
|
Build an inventory-style dict keyed by node name.
|
||||||
|
|
||||||
Shape (per node name):
|
Shape (per node name):
|
||||||
{server, name, node_id, console_port, console_type, type, ports,
|
{server, name, node_id, console_port, console_host, console_type,
|
||||||
status, x, y, tags, netmiko_device_type, default_username,
|
type, ports, status, x, y, tags, netmiko_device_type,
|
||||||
default_password}
|
default_username, default_password}
|
||||||
"""
|
"""
|
||||||
inventory: dict[str, Any] = {}
|
inventory: dict[str, Any] = {}
|
||||||
for n in nodes:
|
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"),
|
"name": n.get("name"),
|
||||||
"node_id": n.get("node_id"),
|
"node_id": n.get("node_id"),
|
||||||
"console_port": n.get("console"),
|
"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"),
|
"console_type": n.get("console_type"),
|
||||||
"type": n.get("node_type"),
|
"type": n.get("node_type"),
|
||||||
"ports": n.get("ports"),
|
"ports": n.get("ports"),
|
||||||
|
|||||||
@ -451,7 +451,7 @@ class VPCSCommands(BaseTool):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# VPCS devices use gns3_vpcs_telnet device type
|
# VPCS devices use gns3_vpcs_telnet device type
|
||||||
hosts_data[device_name] = {
|
host_entry = {
|
||||||
"port": port,
|
"port": port,
|
||||||
"platform": "vpcs",
|
"platform": "vpcs",
|
||||||
"groups": ["vpcs_devices"], # All VPCS devices share one group
|
"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
|
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
|
# 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)
|
||||||
@ -167,6 +172,15 @@ def get_device_ports_from_topology(
|
|||||||
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
|
"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
|
# Per-node default credentials (seeded from the template appliance
|
||||||
# metadata) override the group's empty fallback. Only inject when
|
# metadata) override the group's empty fallback. Only inject when
|
||||||
# set, so credential-less devices keep inheriting the group values.
|
# 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
|
import gns3server.db.models as models
|
||||||
from gns3server import schemas
|
from gns3server import schemas
|
||||||
from gns3server.controller import Controller
|
from gns3server.controller import Controller
|
||||||
|
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
|
||||||
from gns3server.db.repositories.computes import ComputesRepository
|
from gns3server.db.repositories.computes import ComputesRepository
|
||||||
from gns3server.db.repositories.rbac import RbacRepository
|
from gns3server.db.repositories.rbac import RbacRepository
|
||||||
from gns3server.services.computes import ComputesService
|
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)
|
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(
|
@router.get(
|
||||||
"/{compute_id}",
|
"/{compute_id}",
|
||||||
response_model=schemas.Compute,
|
response_model=schemas.Compute,
|
||||||
|
|||||||
@ -201,10 +201,12 @@ class Controller:
|
|||||||
if computes:
|
if computes:
|
||||||
for c in computes:
|
for c in computes:
|
||||||
try:
|
try:
|
||||||
# FIXME: Task exception was never retrieved
|
|
||||||
await self.add_compute(
|
await self.add_compute(
|
||||||
compute_id=str(c.compute_id),
|
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"}),
|
**c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}),
|
||||||
ssl_context=self._ssl_context,
|
ssl_context=self._ssl_context,
|
||||||
)
|
)
|
||||||
@ -625,9 +627,12 @@ class Controller:
|
|||||||
if connect:
|
if connect:
|
||||||
if wait_connection:
|
if wait_connection:
|
||||||
await compute.connect()
|
await compute.connect()
|
||||||
else:
|
elif not hasattr(sys, "_called_from_test"):
|
||||||
# call compute.connect() later to give time to the controller to be fully started
|
# connect in the background to give time to the controller to be fully
|
||||||
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute.connect()))
|
# 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())
|
self.notification.controller_emit("compute.created", compute.asdict())
|
||||||
return compute
|
return compute
|
||||||
else:
|
else:
|
||||||
@ -645,6 +650,17 @@ class Controller:
|
|||||||
if compute in project.computes:
|
if compute in project.computes:
|
||||||
await project.close()
|
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):
|
def compute_has_open_project(self, compute):
|
||||||
"""
|
"""
|
||||||
Check is compute has a project opened.
|
Check is compute has a project opened.
|
||||||
@ -652,10 +668,7 @@ class Controller:
|
|||||||
:returns: True if a project is open
|
:returns: True if a project is open
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for project in self._projects.values():
|
return bool(self.get_compute_open_projects(compute))
|
||||||
if compute in project.computes and project.status == "opened":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def delete_compute(self, compute_id):
|
async def delete_compute(self, compute_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -61,6 +61,7 @@ class Compute:
|
|||||||
name=None,
|
name=None,
|
||||||
console_host=None,
|
console_host=None,
|
||||||
ssl_context=None,
|
ssl_context=None,
|
||||||
|
auto_connect=False,
|
||||||
):
|
):
|
||||||
self._http_session = None
|
self._http_session = None
|
||||||
assert controller is not None
|
assert controller is not None
|
||||||
@ -80,6 +81,11 @@ class Compute:
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
self._notifications = None
|
self._notifications = None
|
||||||
self._closed = False # Close mean we are destroying the compute node
|
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._controller = controller
|
||||||
self._set_auth(user, password)
|
self._set_auth(user, password)
|
||||||
self._cpu_usage_percent = 0
|
self._cpu_usage_percent = 0
|
||||||
@ -154,6 +160,11 @@ class Compute:
|
|||||||
|
|
||||||
async def update(self, **kwargs):
|
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:
|
for kw in kwargs:
|
||||||
if kw not in ("user", "password"):
|
if kw not in ("user", "password"):
|
||||||
setattr(self, kw, kwargs[kw])
|
setattr(self, kw, kwargs[kw])
|
||||||
@ -323,6 +334,7 @@ class Compute:
|
|||||||
"host": self._host,
|
"host": self._host,
|
||||||
"port": self._port,
|
"port": self._port,
|
||||||
"user": self._user,
|
"user": self._user,
|
||||||
|
"auto_connect": self._auto_connect,
|
||||||
"connected": self._connected,
|
"connected": self._connected,
|
||||||
"cpu_usage_percent": self._cpu_usage_percent,
|
"cpu_usage_percent": self._cpu_usage_percent,
|
||||||
"memory_usage_percent": self._memory_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
|
: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:
|
if self._id == "vm" and not self._controller.gns3vm.running:
|
||||||
await self._controller.gns3vm.start()
|
await self._controller.gns3vm.start()
|
||||||
await self.connect()
|
await self.connect()
|
||||||
@ -381,6 +395,9 @@ class Compute:
|
|||||||
"""
|
"""
|
||||||
We catch error during reconnect
|
We catch error during reconnect
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if self._manual_disconnect:
|
||||||
|
return # stop reconnecting, the compute was manually disconnected
|
||||||
try:
|
try:
|
||||||
await self.connect()
|
await self.connect()
|
||||||
except ControllerError:
|
except ControllerError:
|
||||||
@ -413,6 +430,8 @@ class Compute:
|
|||||||
Check if remote server is accessible
|
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:
|
if not self._connected and not self._closed and self.host:
|
||||||
try:
|
try:
|
||||||
log.info(f"Connecting to compute '{self._id}'")
|
log.info(f"Connecting to compute '{self._id}'")
|
||||||
@ -474,6 +493,31 @@ class Compute:
|
|||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
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):
|
async def _connect_notification(self):
|
||||||
"""
|
"""
|
||||||
Connect to the notification stream
|
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)
|
# 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
|
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}'")
|
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):
|
def _getUrl(self, path):
|
||||||
host = self._host
|
host = self._host
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# 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
|
from .base import GUID, BaseTable
|
||||||
|
|
||||||
@ -30,3 +30,4 @@ class Compute(BaseTable):
|
|||||||
port = Column(Integer)
|
port = Column(Integer)
|
||||||
user = Column(String)
|
user = Column(String)
|
||||||
password = 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,
|
port=compute_create.port,
|
||||||
user=compute_create.user,
|
user=compute_create.user,
|
||||||
password=compute_create.password.get_secret_value() if compute_create.password else None,
|
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)
|
self._db_session.add(db_compute)
|
||||||
await self._db_session.commit()
|
await self._db_session.commit()
|
||||||
@ -74,6 +75,9 @@ class ComputesRepository(BaseRepository):
|
|||||||
update_values = compute_update.model_dump(exclude_unset=True)
|
update_values = compute_update.model_dump(exclude_unset=True)
|
||||||
if compute_update.password is not None:
|
if compute_update.password is not None:
|
||||||
update_values["password"] = compute_update.password.get_secret_value()
|
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)
|
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
|
user: Optional[str] = None
|
||||||
password: Optional[SecretStr] = None
|
password: Optional[SecretStr] = None
|
||||||
name: Optional[str] = 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)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
|
||||||
@ -87,6 +90,7 @@ class ComputeUpdate(ComputeBase):
|
|||||||
|
|
||||||
user: Optional[str] = None
|
user: Optional[str] = None
|
||||||
password: Optional[SecretStr] = None
|
password: Optional[SecretStr] = None
|
||||||
|
auto_connect: Optional[bool] = None # no default: update schemas must not publish defaults
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
@ -107,6 +111,8 @@ class Compute(DateTimeModelMixin, ComputeBase):
|
|||||||
port: int = Field(..., gt=0, le=65535)
|
port: int = Field(..., gt=0, le=65535)
|
||||||
compute_id: Union[str, uuid.UUID]
|
compute_id: Union[str, uuid.UUID]
|
||||||
name: str
|
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")
|
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)
|
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)
|
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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import gns3server.db.models as models
|
import gns3server.db.models as models
|
||||||
from gns3server import schemas
|
from gns3server import schemas
|
||||||
from gns3server.controller import Controller
|
from gns3server.controller import Controller
|
||||||
|
from gns3server.controller.compute import Compute
|
||||||
from gns3server.controller.controller_error import (
|
from gns3server.controller.controller_error import (
|
||||||
ControllerBadRequestError,
|
ControllerBadRequestError,
|
||||||
|
ControllerError,
|
||||||
ControllerNotFoundError,
|
ControllerNotFoundError,
|
||||||
)
|
)
|
||||||
from gns3server.db.repositories.computes import ComputesRepository
|
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:
|
class ComputesService:
|
||||||
def __init__(self, computes_repo: ComputesRepository):
|
def __init__(self, computes_repo: ComputesRepository):
|
||||||
|
|
||||||
@ -50,6 +75,13 @@ class ComputesService:
|
|||||||
**compute_create.model_dump(exclude_unset=True, exclude={"compute_id"}),
|
**compute_create.model_dump(exclude_unset=True, exclude={"compute_id"}),
|
||||||
)
|
)
|
||||||
self._controller.notification.controller_emit("compute.created", compute.asdict())
|
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
|
return db_compute
|
||||||
|
|
||||||
async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]:
|
async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]:
|
||||||
@ -68,11 +100,26 @@ class ComputesService:
|
|||||||
) -> models.Compute:
|
) -> models.Compute:
|
||||||
|
|
||||||
compute = self._controller.get_compute(str(compute_id))
|
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))
|
await compute.update(**compute_update.model_dump(exclude_unset=True))
|
||||||
db_compute = await self._computes_repo.update_compute(compute_id, compute_update)
|
db_compute = await self._computes_repo.update_compute(compute_id, compute_update)
|
||||||
if not db_compute:
|
if not db_compute:
|
||||||
raise ControllerNotFoundError(f"Compute '{compute_id}' not found")
|
raise ControllerNotFoundError(f"Compute '{compute_id}' not found")
|
||||||
self._controller.notification.controller_emit("compute.updated", compute.asdict())
|
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
|
return db_compute
|
||||||
|
|
||||||
async def delete_compute(self, compute_id: Union[str, UUID]) -> None:
|
async def delete_compute(self, compute_id: Union[str, UUID]) -> None:
|
||||||
|
|||||||
5
justfile
5
justfile
@ -9,6 +9,11 @@ install:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install -e '.[ai-features,dev]'
|
python -m pip install -e '.[ai-features,dev]'
|
||||||
|
|
||||||
|
# Install the git pre-commit hook (lint + type-check on staged files)
|
||||||
|
[group('dev')]
|
||||||
|
install-hooks:
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
|
||||||
# Apply ruff autofixes for the enforced rules
|
# Apply ruff autofixes for the enforced rules
|
||||||
[group('dev')]
|
[group('dev')]
|
||||||
format:
|
format:
|
||||||
|
|||||||
@ -25,6 +25,7 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Programming Language :: Python :: 3.15",
|
||||||
"Programming Language :: Python :: Implementation :: CPython"
|
"Programming Language :: Python :: Implementation :: CPython"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -130,6 +130,36 @@ class TestVPCSNodeTypeGuard:
|
|||||||
assert "output" not in result[0]
|
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:
|
class TestDeviceToolErrorContract:
|
||||||
"""
|
"""
|
||||||
Every in-band error entry carries status "failed" plus an "error"
|
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"] == []
|
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():
|
def test_links_summary_resolves_names_and_ports():
|
||||||
"""
|
"""
|
||||||
links_summary maps raw link endpoint lists to
|
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 "password" not in hosts["R2"]
|
||||||
assert "username" not in hosts["R3"]
|
assert "username" not in hosts["R3"]
|
||||||
assert "password" 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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ import pytest
|
|||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from gns3server.controller import Controller
|
||||||
from gns3server.schemas.controller.computes import Compute
|
from gns3server.schemas.controller.computes import Compute
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@ -62,7 +64,7 @@ class TestComputeRoutes:
|
|||||||
for param, value in params.items():
|
for param, value in params.items():
|
||||||
assert response.json()[param] == value
|
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"))
|
response = await client.get(app.url_path_for("get_computes"))
|
||||||
assert response.status_code == status.HTTP_200_OK
|
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)
|
# response = await client.post(app.url_path_for("autoidlepc", compute_id=compute_id) + "/auto_idlepc", json=params)
|
||||||
# assert mock.called
|
# assert mock.called
|
||||||
# assert response.status_code == status.HTTP_200_OK
|
# 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.compute import Compute
|
||||||
from gns3server.controller.controller_error import (
|
from gns3server.controller.controller_error import (
|
||||||
ComputeConflictError,
|
ComputeConflictError,
|
||||||
|
ComputeError,
|
||||||
ControllerError,
|
ControllerError,
|
||||||
ControllerNotFoundError,
|
ControllerNotFoundError,
|
||||||
ControllerUnauthorizedError,
|
ControllerUnauthorizedError,
|
||||||
)
|
)
|
||||||
from gns3server.controller.project import Project
|
from gns3server.controller.project import Project
|
||||||
|
from gns3server.version import __version__
|
||||||
from tests.utils import AsyncioMagicMock, asyncio_patch
|
from tests.utils import AsyncioMagicMock, asyncio_patch
|
||||||
|
|
||||||
|
|
||||||
@ -376,6 +378,7 @@ async def test_json(compute):
|
|||||||
"host": "example.com",
|
"host": "example.com",
|
||||||
"port": 84,
|
"port": 84,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
|
"auto_connect": False,
|
||||||
"cpu_usage_percent": 0,
|
"cpu_usage_percent": 0,
|
||||||
"memory_usage_percent": 0,
|
"memory_usage_percent": 0,
|
||||||
"disk_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]
|
actions = [c.args[0] for c in emit_mock.call_args_list]
|
||||||
assert "compute.updated" in actions
|
assert "compute.updated" in actions
|
||||||
assert scheduled_delays == [5] # first exponential backoff delay
|
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):
|
async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config):
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
image_file(config)
|
image_file(config)
|
||||||
await scan(inventory)
|
await scan(inventory)
|
||||||
@ -414,7 +415,12 @@ async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, c
|
|||||||
cfg = Config()
|
cfg = Config()
|
||||||
cfg.set_main_option("script_location", "gns3server:db_migrations")
|
cfg.set_main_option("script_location", "gns3server:db_migrations")
|
||||||
cfg.attributes["connection"] = connection
|
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")
|
command.downgrade(cfg, "c7e4a9f1d2b6")
|
||||||
assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1
|
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"]
|
assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user