1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 20:26:48 +00:00

fix(vrtManager): libvirt connections open within a time limit

libvirt has no timeout for opening a connection. Measured in the test
setup: an unreachable host blocked qemu+tcp for 135 s and qemu+ssh for
133 s; a daemon that accepts and never answers blocks forever. Gunicorn
runs sync workers and the instance list connects to every compute, so
one unreachable compute held a worker for minutes per page load.

- A connection is opened in a helper thread, at most one per
  connection at a time. The request waits LIBVIRT_CONNECT_TIMEOUT
  (default 5 s). An open nobody waits for any more keeps running; until
  it ends, requests get its error at once, and if it succeeds later the
  connection is used. A failed open is not repeated for
  LIBVIRT_RETRY_AFTER (default 30 s). A stuck host holds one thread and
  does not keep other hosts from connecting.
- qemu+ssh runs ssh through vrtManager/libvirt-ssh (the URI's command
  parameter), which adds ConnectTimeout=10 and BatchMode=yes, so an
  abandoned ssh process ends and no prompt can hang. The user's ssh
  configuration (aliases, ProxyJump, keys) still applies.
- The close callback acts only for the connection it was registered
  for: a late callback of a replaced connection dropped its successor.
  The reason is reported as text. A connection that closed before it
  was published is an error.
- Compute.status is a bool (it returned the exception object, so the
  "host is down" check in refresh_instance_database never fired); the
  message is Compute.connection_error.

Unit tests cover the timeout, retry delay, late success, single open,
a stuck host next to a healthy one and the close callback. Live tests
(vrtManager/test_live_connection.py), for TCP and SSH computes of the
test setup: an unreachable address (no ssh left after 10 s), an endpoint
that never answers, a slow open completing later, a host that goes
silent on an established connection (keepalive ends calls within 45 s,
the next request reconnects), an ssh_config alias and a ProxyJump host.
This commit is contained in:
catborise 2026-10-04 23:02:58 +03:00
parent 99eadaab31
commit 10b46afd36
9 changed files with 517 additions and 86 deletions

View file

@ -1,6 +1,9 @@
import os
import re
import socket
import threading
import time
from urllib.parse import quote
import libvirt
from django.conf import settings
@ -16,6 +19,20 @@ TLS_PORT = 16514
SSH_PORT = 22
TCP_PORT = 16509
# libvirt has no timeout for opening a connection: an unreachable host
# blocks for the kernel's TCP connect timeout (about 2 minutes), a daemon
# that accepts but never answers blocks forever, and such an open cannot be
# cancelled. Connections are opened in a helper thread, at most one per
# connection at a time, so a stuck host holds one thread and nothing else;
# the caller waits at most CONNECT_TIMEOUT seconds, and a host that failed is
# not tried again for RETRY_AFTER seconds.
CONNECT_TIMEOUT = getattr(settings, "LIBVIRT_CONNECT_TIMEOUT", 5)
RETRY_AFTER = getattr(settings, "LIBVIRT_RETRY_AFTER", 30)
CLOSE_REASONS = {0: "error", 1: "end of file", 2: "keepalive timeout", 3: "client closed"}
# ssh with a connect timeout for qemu+ssh (libvirt runs the "command" binary
# in place of ssh and never passes one itself)
SSH_COMMAND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libvirt-ssh")
class wvmEventLoop(threading.Thread):
""" Event Loop Class"""
@ -58,6 +75,8 @@ class wvmConnection(object):
self.connection_state_lock = threading.Lock()
self.connection = None
self.last_error = None
self._opening = None # threading.Event of the open in progress
self._retry_at = 0.0 # monotonic time before which no new open is tried
# credentials
self.host = host
@ -66,39 +85,70 @@ class wvmConnection(object):
self.type = conn
def connect(self):
self.connection_state_lock.acquire()
try:
# recheck if we have a connection (it may have been
if not self.connected:
if self.type == CONN_TCP:
self.__connect_tcp()
elif self.type == CONN_SSH:
self.__connect_ssh()
elif self.type == CONN_TLS:
self.__connect_tls()
elif self.type == CONN_SOCKET:
self.__connect_socket()
else:
raise ValueError(f'"{self.type}" is not a valid connection type')
"""Open the connection if needed, waiting at most CONNECT_TIMEOUT.
On failure last_error says why and connected stays False."""
with self.connection_state_lock:
if self.connected:
return
if self._opening is not None and self._opening.abandoned:
# A caller already waited for this open in vain; nobody waits
# again until it ends (one thread per connection, however long).
return
if time.monotonic() < self._retry_at:
return # failed recently: answer with the last error at once
if self._opening is None:
self._opening = threading.Event()
self._opening.abandoned = False
threading.Thread(target=self._open, args=(self._opening,), daemon=True).start()
opening = self._opening
if self.connected:
# do some preprocessing of the connection:
# * set keep alive interval
# * set connection close/fail handler
try:
self.connection.setKeepAlive(
connection_manager.keepalive_interval, connection_manager.keepalive_count
)
try:
self.connection.registerCloseCallback(self.__connection_close_callback, None)
except Exception:
# Temporary fix for libvirt > libvirt-0.10.2-41
pass
except libvirtError as e:
# hypervisor driver does not seem to support persistent connections
self.last_error = str(e)
if not opening.wait(CONNECT_TIMEOUT):
with self.connection_state_lock:
if self._opening is opening: # still running: give up waiting, not the open
self.last_error = f"Connection Failed: {self.host} did not answer within {CONNECT_TIMEOUT} s"
opening.abandoned = True
def _open(self, opening):
"""Helper thread: open, configure and publish the connection."""
connection = error = None
try:
connection = self._open_transport()
if not connection.isAlive(): # closed again before it was published
raise util.OperationError("the connection closed right after opening")
# Drivers without keepalive or close callbacks work without them
try:
connection.setKeepAlive(connection_manager.keepalive_interval, connection_manager.keepalive_count)
except libvirtError:
pass
try:
connection.registerCloseCallback(self.__connection_close_callback, None)
except libvirtError:
pass
except Exception as e: # libvirtError, or anything else from the transport
error = f"Connection Failed: {str(e)}"
finally:
self.connection_state_lock.release()
with self.connection_state_lock:
if error is None:
self.connection = connection
self.last_error = None
self._retry_at = 0.0
else:
self.connection = None
self.last_error = error
self._retry_at = time.monotonic() + RETRY_AFTER
self._opening = None
opening.set()
def _open_transport(self):
if self.type == CONN_TCP:
return self.__connect_tcp()
if self.type == CONN_SSH:
return self.__connect_ssh()
if self.type == CONN_TLS:
return self.__connect_tls()
if self.type == CONN_SOCKET:
return self.__connect_socket()
raise ValueError(f'"{self.type}" is not a valid connection type')
@property
def connected(self):
@ -121,65 +171,30 @@ class wvmConnection(object):
return 0
def __connection_close_callback(self, connection, reason, opaque=None):
self.connection_state_lock.acquire()
try:
# on server shutdown libvirt module gets freed before the close callbacks are called
# so we just check here if it is still present
if libvirt is not None:
self.last_error = reason
with self.connection_state_lock:
# A late callback of a connection that was already replaced must
# not drop its successor.
if connection is not self.connection:
return
self.last_error = f"Connection closed ({CLOSE_REASONS.get(reason, reason)})"
# prevent other threads from using the connection (in the future)
self.connection = None
finally:
self.connection_state_lock.release()
def __connect_tcp(self):
flags = [libvirt.VIR_CRED_AUTHNAME, libvirt.VIR_CRED_PASSPHRASE]
auth = [flags, self.__libvirt_auth_credentials_callback, None]
uri = f"qemu+tcp://{self.host}/system"
try:
self.connection = libvirt.openAuth(uri, auth, 0)
self.last_error = None
except libvirtError as e:
self.last_error = f"Connection Failed: {str(e)}"
self.connection = None
return libvirt.openAuth(f"qemu+tcp://{self.host}/system", auth, 0)
def __connect_ssh(self):
uri = "qemu+ssh://%s@%s/system" % (self.login, self.host)
try:
self.connection = libvirt.open(uri)
self.last_error = None
except libvirtError as e:
self.last_error = f"Connection Failed: {str(e)} --- " + repr(libvirt.virGetLastError())
self.connection = None
return libvirt.open(f"qemu+ssh://{self.login}@{self.host}/system?command={quote(SSH_COMMAND)}")
def __connect_tls(self):
flags = [libvirt.VIR_CRED_AUTHNAME, libvirt.VIR_CRED_PASSPHRASE]
auth = [flags, self.__libvirt_auth_credentials_callback, None]
uri = "qemu+tls://%s@%s/system" % (self.login, self.host)
try:
self.connection = libvirt.openAuth(uri, auth, 0)
self.last_error = None
except libvirtError as e:
self.last_error = f"Connection Failed: {str(e)}"
self.connection = None
return libvirt.openAuth(f"qemu+tls://{self.login}@{self.host}/system", auth, 0)
def __connect_socket(self):
uri = "qemu:///system"
try:
self.connection = libvirt.open(uri)
self.last_error = None
except libvirtError as e:
self.last_error = f"Connection Failed: {str(e)}"
self.connection = None
return libvirt.open("qemu:///system")
def close(self):
"""
@ -286,7 +301,7 @@ class wvmConnectionManager(object):
self._connections_lock.release()
if not connection.connected:
# (re-)connect; the connection's own lock serializes concurrent attempts
# (re-)connect within CONNECT_TIMEOUT; one open per connection at a time
connection.connect()
if connection.connected: