mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-27 02:32:23 +02:00
Merge pull request #3000 from yueguobin/feat/compute-auto-connect
Auto-connect flag and disconnect endpoint for computes
This commit is contained in:
commit
638a8be180
@ -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:
|
||||||
|
|||||||
@ -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