1
0
Fork 0
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:
catborise 2026-10-24 21:07:12 +03:00
parent 3730a95c02
commit 52b721e6df
8 changed files with 765 additions and 8 deletions

196
instances/addresses.py Normal file
View 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)}

View file

@ -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,

View 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)])

View 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)

View file

@ -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"),

View file

@ -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
View 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;
});
});
})();

View file

@ -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",