mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
feat(instances): find the VMs using an IP address or network
The Instances page's filters and the address search become one control: the state list, the search box and a "Find IP" button. "Find IP" opens a dialog (with the address typed in the search box, if any). An IP address or a network (CIDR) finds the running VMs whose network cards use it, read live from libvirt within 5 seconds: libvirt's DHCP leases, the host's ARP table and the guest agent. Only addresses on the VM's own NICs count, so interfaces inside the guest such as docker0 never match. Each match shows the VM, its MAC, where the address came from, and a possible- conflict mark when two VMs report one address. A coverage line says how many running VMs were checked, how many had no connected guest agent (a static address on them cannot be seen), which agents could not be read, which hosts were not reached or not finished in time, and when it was read. The hosts are read in parallel: leases and ARP first, then each guest agent on its own thread, so an agent that never answers holds back only its own VM; libvirt waits for an agent without limit, so a search starts only while its threads fit in 16 search threads in the worker. Administrators search every compute, VMs the panel has not registered yet included, and see the hosts; other users find only their own VMs and see no hosts or host errors.
This commit is contained in:
parent
3730a95c02
commit
52b721e6df
8 changed files with 765 additions and 8 deletions
196
instances/addresses.py
Normal file
196
instances/addresses.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""
|
||||
The VMs that use an IP address or an address in a network, 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 threading
|
||||
import time
|
||||
from concurrent import futures
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
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
|
||||
|
||||
# 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 guest agent may never answer (libvirt waits without limit by default):
|
||||
# its thread stays blocked after the search answers (and holds up the
|
||||
# worker's exit until it does), so a search starts only while its threads
|
||||
# fit in MAX_RUNNING search threads in this process.
|
||||
MAX_WORKERS = 8
|
||||
MAX_RUNNING = 16
|
||||
_running = 0
|
||||
_running_lock = 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)
|
||||
|
||||
|
||||
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:
|
||||
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 _matching(dom, vm, source, macs, network):
|
||||
"""The source's addresses in the network on the domain's own NICs, 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 address in network]
|
||||
|
||||
|
||||
def _read_host(target, network):
|
||||
"""The cheap first phase on one compute: leases and ARP of each running VM
|
||||
in scope. Returns the observations, the (domain, vm, MACs) whose agent is
|
||||
to be asked, the number of VMs read and of those without a connected
|
||||
agent; 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."""
|
||||
if target.compute.status is not True:
|
||||
return None
|
||||
found, agents, checked, no_agent = [], [], 0, 0
|
||||
for dom in target.compute.connection.listAllDomains(VIR_CONNECT_LIST_DOMAINS_ACTIVE):
|
||||
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)
|
||||
running = dom.state()[0] == VIR_DOMAIN_RUNNING # a paused guest cannot answer
|
||||
except libvirtError:
|
||||
continue
|
||||
macs = nic_macs(xml)
|
||||
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 running:
|
||||
agents.append((dom, vm, macs))
|
||||
return found, agents, checked, no_agent
|
||||
|
||||
|
||||
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 find(targets, network, deadline=5.0):
|
||||
"""
|
||||
The running VMs of the targets with an address in the network, read by
|
||||
the deadline (seconds). First each compute's 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).
|
||||
"""
|
||||
if _running > MAX_RUNNING - MAX_WORKERS:
|
||||
raise Busy
|
||||
end = time.monotonic() + deadline
|
||||
observations, hosts_down, agent_reads = [], [], []
|
||||
checked = no_agent = unread = 0
|
||||
pool = futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
||||
try:
|
||||
hosts = {pool.submit(_counted, _read_host, target, network): 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
|
||||
found, agents, host_checked, host_no_agent = read_host
|
||||
observations += found
|
||||
checked += host_checked
|
||||
no_agent += host_no_agent
|
||||
agent_reads += [pool.submit(_counted, _matching, dom, vm,
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, macs, network)
|
||||
for dom, vm, macs in agents]
|
||||
except futures.TimeoutError:
|
||||
pass
|
||||
finished, late = futures.wait(agent_reads, timeout=max(0, end - time.monotonic()))
|
||||
unread = len(late)
|
||||
for done in finished:
|
||||
found = done.result()
|
||||
if found is None:
|
||||
unread += 1
|
||||
else:
|
||||
observations += found
|
||||
finally:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
return {"matches": _rows(observations), "checked": checked, "no_agent": no_agent, "unread": unread,
|
||||
"hosts_down": sorted(hosts_down), "hosts_unfinished": sorted(hosts[f].name for f in waiting)}
|
||||
|
|
@ -8,14 +8,19 @@
|
|||
{% if request.user.is_superuser %}
|
||||
{% include 'create_inst_block.html' %}
|
||||
{% endif %}
|
||||
{% include 'search_block.html' %}
|
||||
{# the list's filters (state, text) and the address search, as one control #}
|
||||
<div class="float-end search">
|
||||
<select id="state-filter" class="form-select" aria-label="{% trans 'State' %}">
|
||||
<option value="">{% trans 'All states' %}</option>
|
||||
{% for state in states %}
|
||||
<option>{{ state }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="input-group">
|
||||
<select id="state-filter" class="form-select flex-grow-0 w-auto" aria-label="{% trans 'State' %}">
|
||||
<option value="">{% trans 'All states' %}</option>
|
||||
{% for state in states %}
|
||||
<option>{{ state }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input id="filter" class="form-control" type="text" placeholder="{% trans 'Search' %}" aria-label="{% trans 'Search' %}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="find-address-open"
|
||||
data-bs-toggle="modal" data-bs-target="#find-address">{% trans "Find IP" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
|
|
@ -42,9 +47,54 @@
|
|||
{% include 'allinstances_index_nongrouped.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal fade" id="find-address" tabindex="-1" aria-labelledby="find-address-title" aria-hidden="true"
|
||||
data-url="{% url 'instances:find_address' %}" data-failed="{% trans "The search did not go through. Try again." %}"
|
||||
data-none="{% trans "No running VM reports this address." %}"
|
||||
data-unregistered="{% trans "(not registered in the panel)" %}" data-conflict="{% trans "possible conflict" %}"
|
||||
data-checked="{% trans "Running VMs checked: %(n)s." %}"
|
||||
data-no-agent="{% trans "%(n)s had no connected guest agent; a static address on them cannot be seen." %}"
|
||||
data-unread="{% trans "Guest agents not read (no answer in time, or an error): %(n)s." %}"
|
||||
data-hosts-down="{% trans "Not reached: %(hosts)s." %}"
|
||||
data-hosts-unfinished="{% trans "Not finished in time: %(hosts)s." %}"
|
||||
data-read-at="{% trans "Read at %(time)s." %}"
|
||||
data-some-unchecked="{% trans "Some of your VMs could not be checked." %}">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="find-address-title">{% trans "Find the VMs using an IP address" %}</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="find-address-form" class="d-flex gap-2 mb-3">
|
||||
<label class="visually-hidden" for="find-address-q">{% trans "IP address or network" %}</label>
|
||||
<input type="text" class="form-control" name="q" id="find-address-q" required
|
||||
placeholder="192.0.2.10 {% trans "or" %} 192.0.2.0/24">
|
||||
<button type="submit" class="btn btn-primary">{% trans "Find" %}</button>
|
||||
</form>
|
||||
<div class="alert alert-danger d-none" role="alert" id="find-address-error"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm d-none" id="find-address-results">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Address" %}</th>
|
||||
<th scope="col">{% trans "VM" %}</th>
|
||||
<th scope="col">{% trans "MAC" %}</th>
|
||||
<th scope="col">{% trans "Source" %}</th>
|
||||
{% if show_hosts %}<th scope="col">{% trans "Host" %}</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="small text-body-secondary mb-0" id="find-address-coverage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
{% block script %}
|
||||
<script src="{% static 'js/instance_filter.js' %}"></script>
|
||||
<script src="{% static 'js/find_address.js' %}"></script>
|
||||
{% if request.user.is_superuser %}
|
||||
<script>
|
||||
// The new-VM dialog's CPU bars: each compute's load over one second,
|
||||
|
|
|
|||
235
instances/tests/test_addresses.py
Normal file
235
instances/tests/test_addresses.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Finding the VMs that use an address (instances/addresses.py), with libvirt
|
||||
answers faked per source."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from ipaddress import ip_address, ip_network
|
||||
from types import SimpleNamespace
|
||||
|
||||
from instances import addresses
|
||||
from libvirt import (
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT,
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE,
|
||||
libvirtError,
|
||||
)
|
||||
|
||||
AGENT, ARP, LEASE = (VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE)
|
||||
|
||||
MAC1 = "52:54:00:00:00:01"
|
||||
MAC2 = "52:54:00:00:00:02"
|
||||
|
||||
|
||||
def iface(mac, *addrs):
|
||||
"""One interface of an interfaceAddresses answer: addrs are (type, addr, prefix)."""
|
||||
return {"hwaddr": mac, "addrs": [{"type": t, "addr": a, "prefix": p} for t, a, p in addrs]}
|
||||
|
||||
|
||||
def domain_xml(*macs, agent=None):
|
||||
nics = "".join(f"<interface type='bridge'><mac address='{mac}'/></interface>" for mac in macs)
|
||||
channel = ("<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0' "
|
||||
f"state='{agent}'/></channel>") if agent else ""
|
||||
return f"<domain><devices>{nics}{channel}</devices></domain>"
|
||||
|
||||
|
||||
class FakeDomain:
|
||||
"""answers: source -> dict, an exception to raise, or an Event to wait on (a hung agent)."""
|
||||
|
||||
def __init__(self, answers, uuid="uuid-a", name="vm-a", xml=None, state=1):
|
||||
self.answers, self.uuid, self.vname, self.state_ = answers, uuid, name, state
|
||||
self.xml = xml or domain_xml(MAC1, agent="connected")
|
||||
|
||||
def interfaceAddresses(self, source):
|
||||
answer = self.answers.get(source, {})
|
||||
if isinstance(answer, threading.Event):
|
||||
answer.wait()
|
||||
return {}
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
|
||||
def UUIDString(self):
|
||||
return self.uuid
|
||||
|
||||
def name(self):
|
||||
return self.vname
|
||||
|
||||
def state(self):
|
||||
return [self.state_, 0]
|
||||
|
||||
def XMLDesc(self, flags=0):
|
||||
return self.xml
|
||||
|
||||
|
||||
def compute(name, *domains, pk=1, down=False):
|
||||
connection = SimpleNamespace(listAllDomains=lambda flags=0: list(domains))
|
||||
return SimpleNamespace(id=pk, name=name, status=not down or Exception("unreachable"), connection=connection)
|
||||
|
||||
|
||||
def vm(uuid, mac, address, source=ARP, agent="connected"):
|
||||
return FakeDomain({source: {"eth0": iface(mac, (0, address, 24))}}, uuid=uuid, name=f"vm-{uuid}",
|
||||
xml=domain_xml(mac, agent=agent))
|
||||
|
||||
|
||||
class ReadAndMatchTests(unittest.TestCase):
|
||||
def test_a_query_is_an_address_or_a_network(self):
|
||||
self.assertEqual(addresses.parse_query(" 192.0.2.10 "), ip_network("192.0.2.10/32"))
|
||||
self.assertEqual(addresses.parse_query("192.0.2.7/24"), ip_network("192.0.2.0/24"))
|
||||
self.assertEqual(addresses.parse_query("2001:DB8::1"), ip_network("2001:db8::1/128"))
|
||||
for text in ("", "abc", "192.0.2.300"):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
addresses.parse_query(text)
|
||||
|
||||
def test_the_nics_and_the_agent_come_from_the_definition(self):
|
||||
xml = domain_xml(MAC1, MAC2.upper(), agent="connected")
|
||||
self.assertEqual(addresses.nic_macs(xml), {MAC1, MAC2})
|
||||
self.assertTrue(addresses.agent_connected(xml))
|
||||
self.assertFalse(addresses.agent_connected(domain_xml(MAC1, agent="disconnected")))
|
||||
self.assertFalse(addresses.agent_connected(domain_xml(MAC1)))
|
||||
|
||||
def test_read_gives_each_address_with_its_mac(self):
|
||||
dom = FakeDomain({VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT: {
|
||||
"eth0": iface(MAC1, (0, "192.0.2.10", 24), (1, "2001:db8::10", 64), (1, "fe80::1", 64)),
|
||||
}})
|
||||
self.assertEqual(addresses.read(dom, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT),
|
||||
[(MAC1, ip_address("192.0.2.10")), (MAC1, ip_address("2001:db8::10")),
|
||||
(MAC1, ip_address("fe80::1"))])
|
||||
failing = FakeDomain({VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP: libvirtError("no ARP")})
|
||||
self.assertIsNone(addresses.read(failing, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP))
|
||||
|
||||
def test_an_interface_without_a_mac_is_skipped(self):
|
||||
# the agent gives tunnels (wg0, tun0) no hardware address: libvirt's hwaddr is None
|
||||
dom = FakeDomain({AGENT: {"wg0": {"hwaddr": None, "addrs": [{"type": 0, "addr": "198.51.100.5", "prefix": 32}]},
|
||||
"eth0": iface(MAC1, (0, "192.0.2.10", 24))}})
|
||||
self.assertEqual(addresses.read(dom, AGENT), [(MAC1, ip_address("192.0.2.10"))])
|
||||
|
||||
|
||||
class FindTests(unittest.TestCase):
|
||||
def find(self, *targets, query="192.0.2.10", deadline=5.0):
|
||||
return addresses.find(list(targets), addresses.parse_query(query), deadline=deadline)
|
||||
|
||||
def test_an_address_is_found_with_all_its_sources(self):
|
||||
dom = FakeDomain({ARP: {"e": iface(MAC1, (0, "192.0.2.10", 24))},
|
||||
AGENT: {"eth0": iface(MAC1, (0, "192.0.2.10", 24))}})
|
||||
(match,) = self.find(addresses.Target(compute("c1", dom), None))["matches"]
|
||||
self.assertEqual((match["uuid"], match["mac"], match["address"]), ("uuid-a", MAC1, "192.0.2.10"))
|
||||
self.assertEqual(match["sources"], ["ARP", "agent"])
|
||||
self.assertFalse(match["conflict"])
|
||||
|
||||
def test_a_network_lists_every_address_in_it_sorted(self):
|
||||
c = compute("c1", vm("a", MAC1, "192.0.2.30"), vm("b", MAC2, "192.0.2.4"),
|
||||
vm("c", "52:54:00:00:00:03", "198.51.100.1"))
|
||||
result = self.find(addresses.Target(c, None), query="192.0.2.0/24")
|
||||
self.assertEqual([m["address"] for m in result["matches"]], ["192.0.2.4", "192.0.2.30"])
|
||||
|
||||
def test_two_vms_with_one_address_are_a_conflict(self):
|
||||
c = compute("c1", vm("a", MAC1, "192.0.2.10"), vm("b", MAC2, "192.0.2.10", source=LEASE))
|
||||
matches = self.find(addresses.Target(c, None))["matches"]
|
||||
self.assertEqual([(m["uuid"], m["conflict"]) for m in matches], [("a", True), ("b", True)])
|
||||
|
||||
def test_a_guest_interface_that_is_not_a_nic_never_matches(self):
|
||||
dom = FakeDomain({AGENT: {"docker0": iface("02:42:ac:11:00:01", (0, "198.51.100.77", 16))}})
|
||||
self.assertEqual(self.find(addresses.Target(compute("c1", dom), None), query="198.51.100.77")["matches"], [])
|
||||
|
||||
def test_owners_see_only_their_vms(self):
|
||||
c = compute("c1", vm("uuid-a", MAC1, "192.0.2.10"), vm("uuid-b", MAC2, "192.0.2.10"))
|
||||
matches = self.find(addresses.Target(c, {"uuid-a"}))["matches"]
|
||||
self.assertEqual([m["uuid"] for m in matches], ["uuid-a"])
|
||||
|
||||
def test_a_vm_without_a_connected_agent_is_counted(self):
|
||||
c = compute("c1", vm("a", MAC1, "192.0.2.10"), vm("b", MAC2, "192.0.2.11", agent="disconnected"))
|
||||
result = self.find(addresses.Target(c, None))
|
||||
self.assertEqual((result["checked"], result["no_agent"]), (2, 1))
|
||||
|
||||
def test_a_hung_agent_does_not_hold_the_answer(self):
|
||||
hung = threading.Event()
|
||||
try:
|
||||
stuck = FakeDomain({AGENT: hung}, uuid="hung", xml=domain_xml(MAC2, agent="connected"))
|
||||
c = compute("c1", vm("a", MAC1, "192.0.2.10"), stuck)
|
||||
started = time.monotonic()
|
||||
result = self.find(addresses.Target(c, None), deadline=0.3)
|
||||
self.assertLess(time.monotonic() - started, 1)
|
||||
self.assertEqual([m["uuid"] for m in result["matches"]], ["a"])
|
||||
self.assertEqual(result["unread"], 1)
|
||||
finally:
|
||||
hung.set()
|
||||
|
||||
def test_a_down_host_is_reported_and_the_others_answer(self):
|
||||
up = compute("c1", vm("a", MAC1, "192.0.2.10"))
|
||||
down = compute("c2", vm("b", MAC2, "192.0.2.10"), pk=2, down=True)
|
||||
result = self.find(addresses.Target(up, None), addresses.Target(down, None))
|
||||
self.assertEqual(result["hosts_down"], ["c2"])
|
||||
self.assertEqual([m["uuid"] for m in result["matches"]], ["a"])
|
||||
|
||||
def test_slow_down_hosts_are_waited_for_together(self):
|
||||
# connecting to an unreachable host takes the connect timeout: on the
|
||||
# compute's own thread, two of them cost one timeout, not two
|
||||
class Slow(SimpleNamespace):
|
||||
@property
|
||||
def status(self):
|
||||
time.sleep(0.3)
|
||||
return Exception("unreachable")
|
||||
|
||||
started = time.monotonic()
|
||||
result = self.find(addresses.Target(Slow(id=1, name="c1"), None), addresses.Target(Slow(id=2, name="c2"), None))
|
||||
self.assertLess(time.monotonic() - started, 0.55)
|
||||
self.assertEqual(result["hosts_down"], ["c1", "c2"])
|
||||
|
||||
def test_too_many_running_searches_are_refused(self):
|
||||
addresses._running = addresses.MAX_RUNNING
|
||||
try:
|
||||
with self.assertRaises(addresses.Busy):
|
||||
self.find(addresses.Target(compute("c1"), None))
|
||||
finally:
|
||||
addresses._running = 0
|
||||
|
||||
def test_a_link_local_address_matches_a_link_local_query(self):
|
||||
dom = FakeDomain({AGENT: {"eth0": iface(MAC1, (1, "fe80::1234", 64))}})
|
||||
(match,) = self.find(addresses.Target(compute("c1", dom), None), query="fe80::1234")["matches"]
|
||||
self.assertEqual(match["address"], "fe80::1234")
|
||||
|
||||
def test_a_vm_that_vanishes_keeps_the_hosts_other_results(self):
|
||||
class Gone(FakeDomain):
|
||||
def XMLDesc(self, flags=0):
|
||||
raise libvirtError("Domain not found")
|
||||
|
||||
c = compute("c1", Gone({}, uuid="gone"), vm("a", MAC1, "192.0.2.10"))
|
||||
result = self.find(addresses.Target(c, None))
|
||||
self.assertEqual(result["hosts_down"], [])
|
||||
self.assertEqual([m["uuid"] for m in result["matches"]], ["a"])
|
||||
|
||||
def test_a_slow_host_is_unfinished_not_down(self):
|
||||
slow = threading.Event()
|
||||
try:
|
||||
def listing(flags=0):
|
||||
slow.wait()
|
||||
return []
|
||||
|
||||
c = SimpleNamespace(id=2, name="c2", status=True, connection=SimpleNamespace(listAllDomains=listing))
|
||||
result = self.find(addresses.Target(compute("c1", vm("a", MAC1, "192.0.2.10")), None),
|
||||
addresses.Target(c, None), deadline=0.3)
|
||||
self.assertEqual((result["hosts_down"], result["hosts_unfinished"]), ([], ["c2"]))
|
||||
self.assertEqual([m["uuid"] for m in result["matches"]], ["a"])
|
||||
finally:
|
||||
slow.set()
|
||||
|
||||
def test_a_search_needs_room_for_all_its_threads(self):
|
||||
addresses._running = addresses.MAX_RUNNING - addresses.MAX_WORKERS + 1
|
||||
try:
|
||||
with self.assertRaises(addresses.Busy):
|
||||
self.find(addresses.Target(compute("c1"), None))
|
||||
finally:
|
||||
addresses._running = 0
|
||||
|
||||
def test_a_failed_agent_read_is_not_a_read(self):
|
||||
dom = FakeDomain({AGENT: libvirtError("guest-network-get-interfaces is disabled")})
|
||||
result = self.find(addresses.Target(compute("c1", dom), None))
|
||||
self.assertEqual(result["unread"], 1)
|
||||
|
||||
def test_a_vm_on_two_computes_is_two_vms(self):
|
||||
# one UUID on two hosts (a copied definition): rows and conflicts keep them apart
|
||||
result = self.find(addresses.Target(compute("c1", vm("same", MAC1, "192.0.2.10")), None),
|
||||
addresses.Target(compute("c2", vm("same", MAC2, "192.0.2.10"), pk=2), None))
|
||||
self.assertEqual([(m["compute_id"], m["conflict"]) for m in result["matches"]], [(1, True), (2, True)])
|
||||
115
instances/tests/test_find_address.py
Normal file
115
instances/tests/test_find_address.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""The Instances page's search for the VMs using an address
|
||||
(instances.views.find_address): scope by permission, and what each user sees."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances import addresses
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
|
||||
UUID_A = "11111111-2222-3333-4444-00000000000a"
|
||||
UUID_B = "11111111-2222-3333-4444-00000000000b"
|
||||
UNKNOWN = "11111111-2222-3333-4444-00000000000c"
|
||||
|
||||
|
||||
def row(uuid, name, compute_id, address="192.0.2.10"):
|
||||
return {"uuid": uuid, "name": name, "compute_id": compute_id, "mac": "52:54:00:00:00:01",
|
||||
"address": address, "sources": ["agent"], "conflict": False}
|
||||
|
||||
|
||||
class FindAddressTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_superuser("fa-admin", "fa@example.com", "pw")
|
||||
self.owner = User.objects.create_user("fa-owner", password="pw")
|
||||
self.c1 = Compute.objects.create(name="host-one", hostname="192.0.2.1", login="u", password="p", type=1)
|
||||
self.c2 = Compute.objects.create(name="host-two", hostname="192.0.2.2", login="u", password="p", type=1)
|
||||
self.a = Instance.objects.create(compute=self.c1, name="vm-a", uuid=UUID_A)
|
||||
self.b = Instance.objects.create(compute=self.c2, name="vm-b", uuid=UUID_B)
|
||||
UserInstance.objects.create(user=self.owner, instance=self.a)
|
||||
|
||||
def search(self, user, q="192.0.2.10", result=None, side_effect=None):
|
||||
self.client.force_login(user)
|
||||
answer = {"matches": [], "checked": 1, "no_agent": 0, "unread": 0, "hosts_down": [],
|
||||
"hosts_unfinished": [], **(result or {})}
|
||||
with patch("instances.addresses.find", return_value=answer, side_effect=side_effect) as find:
|
||||
response = self.client.get(reverse("instances:find_address"), {"q": q})
|
||||
return response, find
|
||||
|
||||
def test_an_administrator_gets_hosts_and_unregistered_vms(self):
|
||||
response, find = self.search(self.admin, result={
|
||||
"matches": [row(UUID_A, "vm-a", self.c1.id), row(UNKNOWN, "made-with-virsh", self.c2.id)],
|
||||
"hosts_down": ["host-three"], "hosts_unfinished": ["host-four"],
|
||||
})
|
||||
targets = find.call_args.args[0]
|
||||
self.assertEqual({(t.compute.id, t.uuids) for t in targets}, {(self.c1.id, None), (self.c2.id, None)})
|
||||
body = response.json()
|
||||
known, unknown = body["matches"]
|
||||
self.assertEqual((known["host"], known["registered"], known["vm"]), ("host-one", True, "vm-a"))
|
||||
self.assertEqual(known["url"], reverse("instances:instance", args=[self.a.id]) + "#network")
|
||||
self.assertEqual((unknown["host"], unknown["registered"], unknown["url"]), ("host-two", False, None))
|
||||
self.assertEqual((body["coverage"]["hosts_down"], body["coverage"]["hosts_unfinished"]),
|
||||
(["host-three"], ["host-four"]))
|
||||
self.assertIn("read_at", body)
|
||||
|
||||
def test_an_owner_is_scoped_to_their_vms_and_sees_no_hosts(self):
|
||||
response, find = self.search(self.owner, result={
|
||||
"matches": [row(UUID_A, "vm-a", self.c1.id), row(UUID_B, "vm-b", self.c2.id)],
|
||||
"hosts_down": ["host-two"],
|
||||
})
|
||||
(target,) = find.call_args.args[0]
|
||||
self.assertEqual((target.compute.id, target.uuids), (self.c1.id, {UUID_A}))
|
||||
body = response.json()
|
||||
self.assertEqual([m["vm"] for m in body["matches"]], ["vm-a"])
|
||||
self.assertNotIn("host", body["matches"][0])
|
||||
self.assertNotIn("hosts_down", body["coverage"])
|
||||
self.assertTrue(body["coverage"]["some_unchecked"])
|
||||
self.assertNotIn("host-two", response.content.decode())
|
||||
|
||||
def test_an_owners_vm_on_two_computes_is_searched_on_both(self):
|
||||
# one UUID on two hosts: Instance is unique per compute, not per UUID
|
||||
twin = Instance.objects.create(compute=self.c2, name="vm-a-copy", uuid=UUID_A)
|
||||
UserInstance.objects.create(user=self.owner, instance=twin)
|
||||
response, find = self.search(self.owner, result={
|
||||
"matches": [row(UUID_A, "vm-a", self.c1.id), row(UUID_A, "vm-a-copy", self.c2.id)]})
|
||||
self.assertEqual({(t.compute.id, frozenset(t.uuids)) for t in find.call_args.args[0]},
|
||||
{(self.c1.id, frozenset({UUID_A})), (self.c2.id, frozenset({UUID_A}))})
|
||||
self.assertEqual([m["url"] for m in response.json()["matches"]],
|
||||
[reverse("instances:instance", args=[i.id]) + "#network" for i in (self.a, twin)])
|
||||
|
||||
def test_a_bad_query_is_refused_without_libvirt(self):
|
||||
for q in ("", "abc", "192.0.2.300"):
|
||||
with self.subTest(q=q):
|
||||
response, find = self.search(self.admin, q=q)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("192.0.2.0/24", response.json()["error"])
|
||||
find.assert_not_called()
|
||||
|
||||
def test_too_many_searches_answer_409(self):
|
||||
response, _ = self.search(self.admin, side_effect=addresses.Busy)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Too many searches are still running; try again shortly.")
|
||||
|
||||
|
||||
class FindAddressPageTests(TestCase):
|
||||
"""static/js/find_address.js fills the dialog; only administrators get a Host column."""
|
||||
|
||||
def page(self, user):
|
||||
self.client.force_login(user)
|
||||
return html.fromstring(self.client.get(reverse("instances:index")).content)
|
||||
|
||||
def test_the_page_offers_the_search(self):
|
||||
User = get_user_model()
|
||||
for user, host_column in ((User.objects.create_superuser("fp-admin", "fp@example.com", "pw"), True),
|
||||
(User.objects.create_user("fp-owner", password="pw"), False)):
|
||||
with self.subTest(user=user.username):
|
||||
doc = self.page(user)
|
||||
self.assertEqual(len(doc.xpath("//button[@id='find-address-open']")), 1)
|
||||
self.assertEqual(len(doc.xpath("//div[@id='find-address']//input[@name='q']")), 1)
|
||||
headers = [" ".join(th.text_content().split()) for th in doc.xpath("//div[@id='find-address']//th")]
|
||||
self.assertEqual("Host" in headers, host_column)
|
||||
|
|
@ -6,6 +6,7 @@ app_name = "instances"
|
|||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("find_address/", views.find_address, name="find_address"),
|
||||
path("flavor/create/", views.flavor_create, name="flavor_create"),
|
||||
path("flavor/<int:pk>/update/", views.flavor_update, name="flavor_update"),
|
||||
path("flavor/<int:pk>/delete/", views.flavor_delete, name="flavor_delete"),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from django.http import Http404, HttpResponse, JsonResponse
|
|||
from django.db.models import prefetch_related_objects
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_noop as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
|
||||
|
|
@ -51,7 +52,7 @@ from vrtManager.util import randomPasswd
|
|||
|
||||
from instances.models import Instance
|
||||
|
||||
from . import utils
|
||||
from . import addresses, utils
|
||||
from .forms import ConsoleForm, FlavorForm, NewVMForm
|
||||
from .models import Flavor
|
||||
from .states import STATES, UNKNOWN
|
||||
|
|
@ -92,6 +93,55 @@ def index(request):
|
|||
)
|
||||
|
||||
|
||||
def find_address(request):
|
||||
"""
|
||||
The running VMs using an IP address or an address in a network
|
||||
(instances/addresses.py), as JSON for the Instances page's dialog.
|
||||
Administrators search every compute, VMs the panel has not registered
|
||||
included, and see the hosts; other users only their own VMs, without
|
||||
hosts or host errors.
|
||||
"""
|
||||
try:
|
||||
network = addresses.parse_query(request.GET.get("q", ""))
|
||||
except ValueError:
|
||||
return JsonResponse({"error": _("Enter an IP address or a network such as 192.0.2.0/24.")}, status=400)
|
||||
show_hosts = request.user.is_superuser or request.user.has_perm("instances.view_instances")
|
||||
# a VM is its compute and UUID: one UUID may be defined on two hosts
|
||||
if show_hosts:
|
||||
known = {(inst.compute_id, inst.uuid): inst for inst in Instance.objects.all()}
|
||||
targets = [addresses.Target(compute, None) for compute in Compute.objects.all()]
|
||||
else:
|
||||
known = {(inst.compute_id, inst.uuid): inst for inst in
|
||||
Instance.objects.filter(userinstance__user=request.user).select_related("compute")}
|
||||
scopes = {}
|
||||
for inst in known.values():
|
||||
scopes.setdefault(inst.compute_id, addresses.Target(inst.compute, set())).uuids.add(inst.uuid)
|
||||
targets = list(scopes.values())
|
||||
try:
|
||||
result = addresses.find(targets, network)
|
||||
except addresses.Busy:
|
||||
return JsonResponse({"error": _("Too many searches are still running; try again shortly.")}, status=409)
|
||||
|
||||
hosts = {target.compute.id: target.compute.name for target in targets}
|
||||
matches = []
|
||||
for match in result["matches"]:
|
||||
inst = known.get((match["compute_id"], match["uuid"]))
|
||||
if inst is None and not show_hosts:
|
||||
continue
|
||||
row = {key: match[key] for key in ("address", "mac", "sources", "conflict")}
|
||||
row.update(vm=inst.name if inst else match["name"], registered=inst is not None,
|
||||
url=reverse("instances:instance", args=[inst.id]) + "#network" if inst else None)
|
||||
if show_hosts:
|
||||
row["host"] = hosts[match["compute_id"]]
|
||||
matches.append(row)
|
||||
coverage = {key: result[key] for key in ("checked", "no_agent", "unread")}
|
||||
if show_hosts:
|
||||
coverage.update(hosts_down=result["hosts_down"], hosts_unfinished=result["hosts_unfinished"])
|
||||
else:
|
||||
coverage["some_unchecked"] = bool(result["hosts_down"] or result["hosts_unfinished"])
|
||||
return JsonResponse({"matches": matches, "coverage": coverage, "read_at": timezone.now().isoformat()})
|
||||
|
||||
|
||||
def instance(request, pk):
|
||||
instance: Instance = get_instance(request.user, pk)
|
||||
compute: Compute = instance.compute
|
||||
|
|
|
|||
109
static/js/find_address.js
Normal file
109
static/js/find_address.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// The Instances page's "Find by address" dialog: the running VMs that use an
|
||||
// IP address or an address in a network (instances.views.find_address), with
|
||||
// where each address came from and what the search could not check.
|
||||
(function () {
|
||||
var dialog = document.getElementById("find-address");
|
||||
var form = document.getElementById("find-address-form");
|
||||
var error = document.getElementById("find-address-error");
|
||||
var table = document.getElementById("find-address-results");
|
||||
var coverage = document.getElementById("find-address-coverage");
|
||||
var button = form.querySelector("button[type=submit]");
|
||||
var text = dialog.dataset;
|
||||
|
||||
function cell(row, content) {
|
||||
var td = document.createElement("td");
|
||||
if (content instanceof Node) {
|
||||
td.appendChild(content);
|
||||
} else {
|
||||
td.textContent = content;
|
||||
}
|
||||
row.appendChild(td);
|
||||
return td;
|
||||
}
|
||||
|
||||
function vmCell(match) {
|
||||
if (match.url) {
|
||||
var link = document.createElement("a");
|
||||
link.href = match.url;
|
||||
link.textContent = match.vm;
|
||||
return link;
|
||||
}
|
||||
return document.createTextNode(match.vm + " " + text.unregistered);
|
||||
}
|
||||
|
||||
function coverageText(c) {
|
||||
var lines = [text.checked.replace("%(n)s", c.checked)];
|
||||
if (c.no_agent) {
|
||||
lines.push(text.noAgent.replace("%(n)s", c.no_agent));
|
||||
}
|
||||
if (c.unread) {
|
||||
lines.push(text.unread.replace("%(n)s", c.unread));
|
||||
}
|
||||
if (c.hosts_down && c.hosts_down.length) {
|
||||
lines.push(text.hostsDown.replace("%(hosts)s", c.hosts_down.join(", ")));
|
||||
}
|
||||
if (c.hosts_unfinished && c.hosts_unfinished.length) {
|
||||
lines.push(text.hostsUnfinished.replace("%(hosts)s", c.hosts_unfinished.join(", ")));
|
||||
}
|
||||
if (c.some_unchecked) {
|
||||
lines.push(text.someUnchecked);
|
||||
}
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
function show(answer) {
|
||||
error.textContent = answer.error || "";
|
||||
error.classList.toggle("d-none", !answer.error);
|
||||
var body = table.tBodies[0];
|
||||
body.replaceChildren();
|
||||
var matches = answer.matches || [];
|
||||
matches.forEach(function (match) {
|
||||
var row = body.insertRow();
|
||||
var address = cell(row, match.address);
|
||||
if (match.conflict) {
|
||||
var badge = document.createElement("span");
|
||||
badge.className = "badge bg-warning-subtle text-warning-emphasis ms-1";
|
||||
badge.textContent = text.conflict;
|
||||
address.appendChild(badge);
|
||||
}
|
||||
cell(row, vmCell(match));
|
||||
cell(row, match.mac);
|
||||
cell(row, match.sources.join(", "));
|
||||
if ("host" in match) {
|
||||
cell(row, match.host);
|
||||
}
|
||||
});
|
||||
table.classList.toggle("d-none", matches.length === 0);
|
||||
if (answer.coverage) {
|
||||
var readAt = text.readAt.replace("%(time)s", new Date(answer.read_at).toLocaleTimeString());
|
||||
coverage.textContent = (matches.length ? "" : text.none + " ") + coverageText(answer.coverage) + " " + readAt;
|
||||
} else {
|
||||
coverage.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// an address typed in the list's search box is what the dialog looks for
|
||||
dialog.addEventListener("show.bs.modal", function () {
|
||||
var typed = document.getElementById("filter").value.trim();
|
||||
if (/^[0-9a-fA-F:.]+(\/[0-9]+)?$/.test(typed) && /[.:]/.test(typed)) {
|
||||
form.elements.q.value = typed;
|
||||
}
|
||||
});
|
||||
dialog.addEventListener("shown.bs.modal", function () {
|
||||
form.elements.q.focus();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
button.disabled = true;
|
||||
var url = text.url + "?" + new URLSearchParams({q: form.elements.q.value});
|
||||
fetch(url, {headers: {"X-Requested-With": "XMLHttpRequest"}}).then(function (response) {
|
||||
return response.json();
|
||||
}).catch(function () {
|
||||
return {error: text.failed};
|
||||
}).then(function (answer) {
|
||||
show(answer);
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -31,6 +31,7 @@ from instances.models import Instance
|
|||
ALLOWED_FOR_ANY_USER = {
|
||||
"index": "landing page",
|
||||
"instances:index": "lists only the user's own instances",
|
||||
"instances:find_address": "searches only the user's own instances",
|
||||
"accounts:login": "login page",
|
||||
"accounts:email_otp": "part of the login flow (its own issues are tracked separately)",
|
||||
"accounts:logout": "logout",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue