1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/instances/addresses.py
catborise 6709681508 feat(webvirtcloud): a request waiting on a host holds one thread, not a worker
A host that is up but does not answer (a hung NFS mount, for one) keeps
a libvirt call waiting: libvirt's keepalive does not end it, since the
daemon still answers keepalives. Each such request held a whole sync
worker, and once they held every worker the panel stopped answering,
pages without libvirt too; F5 added another.

gunicorn now runs threaded workers, 4 threads each, and a worker takes
no more connections than it has threads, so one whose threads are all
busy leaves new requests to the others. A threaded worker keeps
reporting in while a thread waits, so gunicorn's timeout no longer ends
the request: the worker now exits itself when a request runs past the
timeout (600 s), and gunicorn starts a new one. Short claims expire on
that bound.

The address search runs one at a time in a process, as it did with one
request per worker.
2026-11-02 21:19:01 +03:00

318 lines
13 KiB
Python

"""
The VMs that use an IP address or an address in a network, and an inventory
of every VM with its addresses and operating system, read live from
libvirt: DHCP leases of libvirt's networks, the host's ARP table, and the
guest agent. An address counts only on one of the VM's own NICs (by MAC), so
interfaces inside the guest such as docker0 never match.
"""
import re
import threading
import time
from concurrent import futures
from dataclasses import dataclass
from ipaddress import ip_address, ip_network
from urllib.parse import urlparse
from libvirt import (
VIR_CONNECT_LIST_DOMAINS_ACTIVE,
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT,
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE,
VIR_DOMAIN_RUNNING,
libvirtError,
)
from lxml import etree
from vrtManager.instance import agent_command, agent_interfaces
# in the order a match lists its sources
SOURCES = {
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE: "lease",
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP: "ARP",
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT: "agent",
}
# A search asks the hosts in parallel, at most MAX_WORKERS threads at once.
# A read may outlast the search (a guest agent has AGENT_TIMEOUT seconds, a
# host its connection's timeouts): its thread goes on after the search
# answers, so a search starts only while its threads fit in MAX_RUNNING
# search threads in this process, and one search at a time.
MAX_WORKERS = 8
MAX_RUNNING = 16
_running = 0
_running_lock = threading.Lock()
_searching = threading.Lock()
class Busy(Exception):
pass
@dataclass
class Target:
"""A compute to search, and the UUIDs of its VMs to search; None for all of them."""
compute: object
uuids: set | None
def parse_query(text):
"""An address as a one-address network, or a network; ValueError otherwise."""
return ip_network(text.strip(), strict=False)
MAC = re.compile(r"[0-9a-f]{2}([:-]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}")
def parse_mac(text):
"""A MAC address (aa:bb:..., aa-bb-..., aabb...) in lower-case colon form; None otherwise."""
text = text.strip().lower()
if not MAC.fullmatch(text):
return None
digits = text.replace(":", "").replace("-", "")
return ":".join(digits[i:i + 2] for i in range(0, 12, 2))
def nic_macs(xml):
return {mac.lower() for mac in etree.fromstring(xml).xpath("/domain/devices/interface/mac/@address")}
def agent_connected(xml):
states = etree.fromstring(xml).xpath(
"/domain/devices/channel/target[@name='org.qemu.guest_agent.0']/@state")
return states == ["connected"]
def read(dom, source):
"""(MAC, address) pairs the source reports for the domain's interfaces
that have a MAC (an agent's tunnels have none); None if it fails."""
try:
if source == VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT:
answer = agent_interfaces(dom)
else:
answer = dom.interfaceAddresses(source)
except libvirtError:
return None
return [(iface["hwaddr"].lower(), ip_address(addr["addr"]))
for iface in answer.values() if iface.get("hwaddr")
for addr in iface.get("addrs") or []]
def _counted(func, *args):
global _running
with _running_lock:
_running += 1
try:
return func(*args)
finally:
with _running_lock:
_running -= 1
def os_family(name):
"""Windows, FreeBSD or Linux, from a guest agent's OS id or a libosinfo
URL's host; "" for none."""
if not name:
return ""
if "microsoft" in name or name == "mswindows":
return "Windows"
if "freebsd" in name:
return "FreeBSD"
return "Linux"
def defined_os(xml):
"""(name, family) of the libosinfo id a tool such as virt-install wrote
into the definition (http://almalinux.org/almalinux/9: almalinux 9)."""
ids = etree.fromstring(xml).xpath("/domain/metadata/*[local-name()='libosinfo']/*[local-name()='os']/@id")
if not ids:
return "", ""
url = urlparse(ids[0])
return " ".join(url.path.strip("/").split("/")), os_family(url.netloc)
def _matching(dom, vm, source, macs, network):
"""The source's addresses on the domain's own NICs (in the network, if
one is given), as (vm, MAC, address, source) observations; vm is
(compute id, UUID, name). None if the source could not be read."""
pairs = read(dom, source)
if pairs is None:
return None
return [(vm, mac, address, source) for mac, address in pairs
if mac in macs and (network is None or address in network)]
def _read_os(dom):
"""The guest agent's OS as (name, family); None if it gives none."""
try:
info = agent_command(dom, "guest-get-osinfo")
except libvirtError:
return None
if not info.get("id"):
return None
return info.get("pretty-name") or info.get("name") or info["id"], os_family(info["id"])
def _read_host(target, network, everything, mac):
"""The cheap first phase on one compute: leases and ARP of each running
VM in scope (with everything, the shut-off ones are listed too). Returns
the VMs' records, the observations, the (domain, vm, MACs) whose agent is
to be asked, the number of running VMs read, of those without a
connected agent, and of paused ones whose agent cannot answer; None when the compute cannot be reached (connecting may
take the connect timeout, so this too runs on the compute's thread). A
VM that stops or leaves meanwhile is skipped. With a mac, only the VMs
with a NIC of that MAC, and only that NIC's addresses."""
if target.compute.status is not True:
return None
records, found, agents, checked, no_agent, paused = [], [], [], 0, 0, 0
listing = 0 if everything else VIR_CONNECT_LIST_DOMAINS_ACTIVE
for dom in target.compute.connection.listAllDomains(listing):
try:
uuid = dom.UUIDString()
if target.uuids is not None and uuid not in target.uuids:
continue
vm = (target.compute.id, uuid, dom.name())
xml = dom.XMLDesc(0)
state, _, memory, vcpus, _ = dom.info()
active = dom.isActive()
except libvirtError:
continue
macs = nic_macs(xml)
if mac:
if mac not in macs:
continue
macs = {mac}
os, family = defined_os(xml)
records.append({"vm": vm, "state": state, "vcpus": vcpus, "memory": memory // 1024,
"macs": sorted(macs), "os": os, "os_family": family})
if not active:
continue
checked += 1
for source in (VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP):
found += _matching(dom, vm, source, macs, network) or []
if not agent_connected(xml):
no_agent += 1
elif state == VIR_DOMAIN_RUNNING:
agents.append((dom, vm, macs))
else:
paused += 1
return records, found, agents, checked, no_agent, paused
def _rows(observations):
"""One row per VM, MAC and address with its sources; sorted by address.
A VM is its compute and UUID: one UUID may be defined on two hosts."""
sources = {}
for vm, mac, address, source in observations:
sources.setdefault((vm, mac, address), set()).add(source)
users = {}
for vm, _, address in sources:
users.setdefault(address, set()).add(vm[:2])
rows = [{"compute_id": vm[0], "uuid": vm[1], "name": vm[2], "mac": mac, "address": str(address),
"sources": [SOURCES[s] for s in SOURCES if s in found], "conflict": len(users[address]) > 1,
"_key": (address.version, address, vm[2], vm[0])}
for (vm, mac, address), found in sources.items()]
rows.sort(key=lambda row: row.pop("_key"))
return rows
def _survey(targets, network, deadline, everything, with_os=False, mac=None):
"""
Read the targets by the deadline (seconds): first each compute's VMs,
leases and ARP, then each VM's guest agent on its own thread, so a hung
agent holds back only its VM. What was not read in time is counted, not
waited for: hosts not reached, hosts not finished, and agents not read
(late or failed). Returns the VMs' records, the observations, the agents'
OS by vm, and the counts.
"""
if _running > MAX_RUNNING - MAX_WORKERS or not _searching.acquire(blocking=False):
raise Busy
end = time.monotonic() + deadline
records, observations, hosts_down, agent_reads, os_reads, systems = [], [], [], [], {}, {}
checked = no_agent = unread = 0
pool = futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
try:
hosts = {pool.submit(_counted, _read_host, target, network, everything, mac): target.compute
for target in targets}
waiting = set(hosts)
try:
for done in futures.as_completed(hosts, timeout=max(0, end - time.monotonic())):
waiting.discard(done)
try:
read_host = done.result()
except libvirtError:
read_host = None
if read_host is None:
hosts_down.append(hosts[done].name)
continue
host_records, found, agents, host_checked, host_no_agent, host_paused = read_host
records += host_records
observations += found
checked += host_checked
no_agent += host_no_agent
unread += host_paused # a paused guest cannot answer its agent
# the OS on its own thread: a hung OS read keeps the addresses read
for dom, vm, macs in agents:
agent_reads.append(pool.submit(_counted, _matching, dom, vm,
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, macs, network))
if with_os:
os_reads[pool.submit(_counted, _read_os, dom)] = vm
except futures.TimeoutError:
pass
futures.wait(agent_reads + list(os_reads), timeout=max(0, end - time.monotonic()))
for done in agent_reads:
found = done.result() if done.done() and not done.cancelled() else None
if found is None:
unread += 1
else:
observations += found
for done, vm in os_reads.items():
if done.done() and not done.cancelled() and done.result():
systems[vm] = done.result()
finally:
pool.shutdown(wait=False, cancel_futures=True)
_searching.release()
return records, observations, systems, {
"checked": checked, "no_agent": no_agent, "unread": unread, "hosts_down": sorted(hosts_down),
"hosts_unfinished": sorted(hosts[future].name for future in waiting)}
def find(targets, network, deadline=5.0):
"""The running VMs of the targets with an address in the network, one
row per VM, MAC and address, with what could not be read."""
_, observations, _, counts = _survey(targets, network, deadline, everything=False)
return {"matches": _rows(observations), **counts}
def find_mac(targets, mac, deadline=5.0):
"""The VMs of the targets with a NIC of the MAC, shut-off ones too, one
row per VM and address of that NIC (one row without an address if it
has none). One MAC on more than one VM is a conflict."""
records, observations, _, counts = _survey(targets, None, deadline, everything=True, mac=mac)
found = {}
for row in _rows(observations):
if not ip_address(row["address"]).is_link_local: # made from the MAC: not a finding
found.setdefault((row["compute_id"], row["uuid"]), []).append(row)
conflict = len({record["vm"][:2] for record in records}) > 1
matches = []
for record in sorted(records, key=lambda record: (record["vm"][2], record["vm"][0])):
compute_id, uuid, name = record["vm"]
rows = found.get((compute_id, uuid)) or [
{"compute_id": compute_id, "uuid": uuid, "name": name, "mac": mac, "address": "", "sources": []}]
matches += [{**row, "conflict": conflict} for row in rows]
return {"matches": matches, **counts}
def inventory(targets, deadline=20.0):
"""Every VM of the targets, shut-off ones too, with its addresses and OS
(the agent's, else the definition's libosinfo id), sorted by name."""
records, observations, systems, counts = _survey(targets, None, deadline, everything=True, with_os=True)
addresses = {}
for row in _rows(observations):
vm = (row["compute_id"], row["uuid"], row["name"])
addresses.setdefault(vm, []).append((row["address"], row["sources"]))
vms = []
for record in records:
vm = record.pop("vm")
os, family = systems.get(vm, (record["os"], record["os_family"]))
vms.append({"compute_id": vm[0], "uuid": vm[1], "name": vm[2], **record, "os": os, "os_family": family,
"addresses": addresses.get(vm, [])})
vms.sort(key=lambda row: (row["name"], row["compute_id"]))
return {"vms": vms, **counts}