feat: add auto_connect flag and disconnect endpoint for computes

Computes can now be flagged auto_connect in the database. The controller
connects to flagged computes in the background at startup (reusing the
one-second delayed fire-and-forget mechanism), and enabling the flag via
PUT /computes/{id} triggers a background connection without the request
ever waiting for it. Disabling the flag does not drop an existing
connection.

Add POST /computes/{id}/disconnect to tear a compute down manually: the
notification stream and HTTP session are closed and every reconnection
path (retry backoff, notification stream reconnect, lazy connection in
http_query) is blocked until connect() is called again or the controller
restarts with auto_connect enabled. Disconnecting a compute used by an
opened project is rejected with 409 unless force=true; forcing only
drops the controller-side control channel, nodes keep running unmanaged.
The local compute cannot be disconnected.

Also make test_compute_list create its own compute (it relied on rows
from earlier tests and failed under reordering), and stop scheduling the
delayed add_compute connection during tests: the timer leaked real
connection attempts into other tests' event loops.
This commit is contained in:
YueGuobin committed 2026-10-15 22:50:22 +08:00
1 parent 716edc3a6a
commit 6734d2d666
10 files changed
+433 -14

No files matched your search

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