mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(networks): a network's DHCP leases on its page
- A DHCP leases card on the page of a network that serves DHCP: each lease's address, MAC or an IPv6 client's DUID, the host name the client sent, the VM whose card has that MAC, linked, and when it ends. - A lease that is a fixed address is marked; any other in one of the network's DHCP subnets has a Make fixed button that fills in the fixed address dialog with its address, MAC or DUID and name. - Leases that cannot be read are said on the card; the rest of the page stays. A network that does not run has none. - The VMs on a network are read with the MACs of their cards on it, from the definitions already read.
This commit is contained in:
parent
2e1f29961c
commit
f745033d29
5 changed files with 245 additions and 9 deletions
|
|
@ -189,6 +189,69 @@
|
|||
<div class="card-body small text-body-secondary">{% trans "The network serves no DHCP, so it has no fixed addresses." %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if dhcp_networks %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{% trans "DHCP leases" %} {% if leases %}<span class="badge text-bg-light border">{{ leases|length }}</span>{% endif %}</div>
|
||||
{% if leases_error %}
|
||||
<div class="card-body small text-danger">{% blocktrans with error=leases_error %}The leases could not be read: {{ error }}{% endblocktrans %}</div>
|
||||
{% elif leases %}
|
||||
<div class="table-responsive position-relative">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Address" %}</th>
|
||||
<th scope="col">{% trans "MAC / DUID" %}</th>
|
||||
<th scope="col">{% trans "Host name" %}</th>
|
||||
<th scope="col">{% trans "VM" %}</th>
|
||||
<th scope="col">{% trans "Ends" %}</th>
|
||||
<th scope="col"><span class="visually-hidden">{% trans "Actions" %}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lease in leases %}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<span class="font-monospace">{{ lease.ip }}</span>
|
||||
{% if lease.fixed %}<span class="badge text-bg-light border">{% trans "Fixed" %}</span>{% endif %}
|
||||
</td>
|
||||
<td class="font-monospace text-nowrap">{{ lease.key|default:"–" }}</td>
|
||||
<td class="text-nowrap">{{ lease.hostname|default:"–" }}</td>
|
||||
<td>
|
||||
{% if lease.vm.id %}
|
||||
<a href="{% url 'instances:instance' lease.vm.id %}">{{ lease.vm.name }}</a>
|
||||
{% else %}
|
||||
{{ lease.vm.name|default:"–" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if lease.expires %}
|
||||
<div>{% blocktrans with time=lease.expires|timeuntil %}in {{ time }}{% endblocktrans %}</div>
|
||||
<div class="small text-body-secondary">{{ lease.expires|date:"Y-m-d H:i" }}</div>
|
||||
{% else %}
|
||||
{% trans "Never" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{# a lease kept as a fixed address: the dialog of a new one, filled in #}
|
||||
{% if not lease.fixed and lease.key and lease.ip_index is not None %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" data-bs-toggle="modal" data-bs-target="#FixedAddress"
|
||||
data-ip-index="{{ lease.ip_index }}" data-address="{{ lease.ip }}" data-mac="{{ lease.key }}"
|
||||
data-name="{{ lease.name }}">{% bs_icon 'pin-angle' %} {% trans "Make fixed" %}</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif state %}
|
||||
<div class="card-body small text-body-secondary">{% trans "No current DHCP leases." %}</div>
|
||||
{% else %}
|
||||
<div class="card-body small text-body-secondary">{% trans "The network is not running, so it gives no leases." %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-3">
|
||||
|
|
@ -413,7 +476,8 @@
|
|||
form.elements.address.value = data.address || "";
|
||||
form.elements.mac.value = data.mac || "";
|
||||
form.elements.name.value = data.name || "";
|
||||
if (data.ipIndex) form.elements.ip_index.value = data.ipIndex;
|
||||
// Add starts from the first subnet, not the one a row chose before
|
||||
form.elements.ip_index.value = data.ipIndex || form.elements.ip_index.options[0].value;
|
||||
document.getElementById("fixed_subnet_row").hidden = Boolean(data.original);
|
||||
});
|
||||
fillFrom("DhcpRange", function (form, data) {
|
||||
|
|
|
|||
118
networks/tests/test_network_leases.py
Normal file
118
networks/tests/test_network_leases.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""A network's DHCP leases on its page: each with the VM whose card has its
|
||||
MAC, a mark when it is a fixed address, and a "Make fixed" button that fills
|
||||
in the fixed address dialog, by MAC for IPv4 and by DUID for IPv6. Leases
|
||||
that cannot be read leave the rest of the page. Runs on a private libvirt
|
||||
test driver, which has no leases: they are given."""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import libvirt
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from networks.tests.test_network_pages import NODE
|
||||
from vrtManager.network import wvmNetwork
|
||||
|
||||
DUID = "00:01:00:01:2e:8b:3c:41:52:54:00:00:00:40"
|
||||
|
||||
|
||||
def lease(ip, mac=None, expiry=1793265489, hostname=None, clientid=None, family=0):
|
||||
return {"iface": "virbr9", "expirytime": expiry, "type": family, "mac": mac, "ipaddr": ip, "prefix": 24,
|
||||
"hostname": hostname, "clientid": clientid, "iaid": None}
|
||||
|
||||
|
||||
class LeasesTests(SimpleTestCase):
|
||||
def network(self, leases, active=True):
|
||||
net = wvmNetwork.__new__(wvmNetwork)
|
||||
net.net = MagicMock(**{"isActive.return_value": active, "DHCPLeases.return_value": leases})
|
||||
return net
|
||||
|
||||
def test_leases_by_address_ipv4_first(self):
|
||||
net = self.network([lease("2001:db8:1::40", family=1, clientid=DUID, expiry=0),
|
||||
lease("192.0.2.100", "52:54:00:AA:00:01", hostname="db"),
|
||||
lease("192.0.2.9", "52:54:00:aa:00:02")])
|
||||
ipv4_9, ipv4_100, ipv6 = net.get_leases()
|
||||
self.assertEqual([str(ipv4_9["ip"]), str(ipv4_100["ip"]), str(ipv6["ip"])], ["192.0.2.9", "192.0.2.100", "2001:db8:1::40"])
|
||||
self.assertEqual((ipv4_100["mac"], ipv4_100["hostname"], ipv4_100["expires"]),
|
||||
("52:54:00:aa:00:01", "db", datetime.fromtimestamp(1793265489, timezone.utc)))
|
||||
# an IPv6 lease: no MAC, its client's DUID; one that never ends
|
||||
self.assertEqual((ipv6["mac"], ipv6["client_id"], ipv6["expires"]), ("", DUID, None))
|
||||
|
||||
def test_a_network_that_does_not_run_has_none(self):
|
||||
net = self.network([lease("192.0.2.9")], active=False)
|
||||
self.assertEqual(net.get_leases(), [])
|
||||
net.net.DHCPLeases.assert_not_called()
|
||||
|
||||
|
||||
class LeasesCardTests(TestCase):
|
||||
def setUp(self):
|
||||
node = tempfile.NamedTemporaryFile("w", suffix=".xml")
|
||||
self.addCleanup(node.close)
|
||||
node.write(NODE)
|
||||
node.flush()
|
||||
self.conn = libvirt.open(f"test://{node.name}")
|
||||
self.addCleanup(self.conn.close)
|
||||
patcher = patch("vrtManager.connection.connection_manager.get_connection", return_value=self.conn)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
without_inactive_flag(self)
|
||||
self.compute = Compute.objects.create(name="nl", hostname="localhost", login="", password="", type=4)
|
||||
self.client.force_login(get_user_model().objects.create_superuser("nl", "nl@example.com", "pw"))
|
||||
web = self.conn.lookupByName("web")
|
||||
self.vm = Instance.objects.create(compute=self.compute, name="web", uuid=web.UUIDString())
|
||||
self.mac = html.fromstring(web.XMLDesc(0)).xpath("string(//interface/mac/@address)")
|
||||
|
||||
def rows(self, leases):
|
||||
with patch.object(libvirt.virNetwork, "DHCPLeases", return_value=leases, create=True):
|
||||
response = self.client.get(reverse("network", args=[self.compute.id, "lab"]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
page = html.fromstring(response.content)
|
||||
card = page.xpath("//div[@class='card mb-3'][div[@class='card-header'][starts-with(normalize-space(), 'DHCP leases')]]")[0]
|
||||
return card, card.xpath(".//tbody/tr")
|
||||
|
||||
def test_each_lease_with_its_vm_and_whether_it_is_fixed(self):
|
||||
_, (by_vm, fixed, stale, ipv6) = self.rows([
|
||||
lease("192.0.2.15", self.mac.upper(), hostname="web-guest"),
|
||||
lease("192.0.2.31", "52:54:00:00:00:31"),
|
||||
lease("198.51.100.5", "52:54:00:00:00:99"), # a removed VM's, outside the network's subnets
|
||||
lease("2001:db8:1::40", family=1, clientid=DUID),
|
||||
])
|
||||
self.assertEqual(by_vm.xpath("td[4]/a/@href"), [reverse("instances:instance", args=[self.vm.id])])
|
||||
button = by_vm.xpath(".//button[@data-bs-target='#FixedAddress']")[0]
|
||||
self.assertEqual((button.get("data-ip-index"), button.get("data-address"), button.get("data-mac"), button.get("data-name")),
|
||||
("0", "192.0.2.15", self.mac.lower(), "web-guest"))
|
||||
self.assertIn("Fixed", fixed.xpath("string(td[1])"))
|
||||
self.assertFalse(fixed.xpath(".//button"))
|
||||
self.assertEqual((stale.xpath("normalize-space(td[4])"), stale.xpath(".//button")), ("–", []))
|
||||
# an IPv6 fixed address is known by the client's DUID, not a MAC
|
||||
button = ipv6.xpath(".//button")[0]
|
||||
self.assertEqual((button.get("data-ip-index"), button.get("data-mac"), button.get("data-name")), ("1", DUID, ""))
|
||||
|
||||
def test_an_ipv6_lease_is_known_by_its_duid_and_its_address_however_written(self):
|
||||
_, (fixed, other) = self.rows([
|
||||
lease("2001:db8:1:0:0:0:0:31", family=1, clientid="0:1:0:1"), # the fixed 2001:db8:1::31
|
||||
lease("2001:db8:1::41", "52:54:00:00:00:41", family=1, clientid=DUID),
|
||||
])
|
||||
self.assertEqual((fixed.xpath("normalize-space(td[1])"), fixed.xpath(".//button")), ("2001:db8:1::31 Fixed", []))
|
||||
self.assertEqual(other.xpath(".//button/@data-mac"), [DUID])
|
||||
|
||||
def test_a_vm_the_panel_does_not_know_is_named_not_linked(self):
|
||||
self.vm.delete()
|
||||
_, (row,) = self.rows([lease("192.0.2.15", self.mac)])
|
||||
self.assertEqual((row.xpath("normalize-space(td[4])"), row.xpath("td[4]/a")), ("web", []))
|
||||
# without a host name, the fixed address takes the VM's name
|
||||
self.assertEqual(row.xpath(".//button/@data-name"), ["web"])
|
||||
|
||||
def test_leases_that_cannot_be_read_leave_the_page(self):
|
||||
with patch.object(libvirt.virNetwork, "DHCPLeases", side_effect=libvirt.libvirtError("status file unreadable"),
|
||||
create=True):
|
||||
response = self.client.get(reverse("network", args=[self.compute.id, "lab"]))
|
||||
page = html.fromstring(response.content)
|
||||
self.assertIn("The leases could not be read: status file unreadable", page.text_content())
|
||||
self.assertTrue(page.xpath("//tbody[@class='searchable']/tr"), "the fixed addresses are still shown")
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import ipaddress
|
||||
|
||||
from admin.decorators import superuser_only
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
|
|
@ -279,6 +281,24 @@ def network(request, compute_id, pool):
|
|||
except libvirtError as lib_err:
|
||||
messages.error(request, lib_err)
|
||||
return HttpResponseRedirect(reverse("networks", args=[compute_id]))
|
||||
|
||||
# leases that cannot be read leave the rest of the page
|
||||
leases, leases_error = [], None
|
||||
if dhcp_networks:
|
||||
try:
|
||||
leases = conn.get_leases()
|
||||
except libvirtError as lib_err:
|
||||
leases_error = lib_err
|
||||
vm_by_mac = {mac: vm for vm in vms for mac in vm["macs"]}
|
||||
fixed_ips = {ipaddress.ip_address(host["ip"]) for host in fixed_addresses if host["ip"]}
|
||||
for lease in leases:
|
||||
lease["vm"] = vm_by_mac.get(lease["mac"])
|
||||
lease["name"] = lease["hostname"] or (lease["vm"]["name"] if lease["vm"] else "") # for a fixed address
|
||||
lease["fixed"] = lease["ip"] in fixed_ips
|
||||
# what a fixed address for it is known by: its MAC, or an IPv6 client's DUID
|
||||
lease["key"] = lease["mac"] if lease["ip"].version == 4 else lease["client_id"]
|
||||
lease["ip_index"] = next((ip["index"] for ip in dhcp_networks
|
||||
if ip["subnet"] and lease["ip"] in ip["subnet"]), None)
|
||||
conn.close()
|
||||
|
||||
return render(request, "network.html", locals())
|
||||
|
|
|
|||
8
static/icon_cache/pin-angle_None_None_None.svg
Normal file
8
static/icon_cache/pin-angle_None_None_None.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pin-angle" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M9.828.722a.5.5 0 0 1 .354.146l4.95 4.95a.5.5 0 0 1 0 .707c-.48.48-1.072.588-1.503.588-.177 0-.335-.018-.46-.039l-3.134 3.134a5.927 5.927 0 0 1 .16 1.013c.046.702-.032 1.687-.72 2.375a.5.5 0 0 1-.707 0l-2.829-2.828-3.182 3.182c-.195.195-1.219.902-1.414.707-.195-.195.512-1.22.707-1.414l3.182-3.182-2.828-2.829a.5.5 0 0 1 0-.707c.688-.688 1.673-.767 2.375-.72a5.922 5.922 0 0 1 1.013.16l3.134-3.133a2.772 2.772 0 0 1-.04-.461c0-.43.108-1.022.589-1.503a.5.5 0 0 1 .353-.146zm.122 2.112v-.002zm0-.002v.002a.5.5 0 0 1-.122.51L6.293 6.878a.5.5 0 0 1-.511.12H5.78l-.014-.004a4.507 4.507 0 0 0-.288-.076 4.922 4.922 0 0 0-.765-.116c-.422-.028-.836.008-1.175.15l5.51 5.509c.141-.34.177-.753.149-1.175a4.924 4.924 0 0 0-.192-1.054l-.004-.013v-.001a.5.5 0 0 1 .12-.512l3.536-3.535a.5.5 0 0 1 .532-.115l.096.022c.087.017.208.034.344.034.114 0 .23-.011.343-.04L9.927 2.028c-.029.113-.04.23-.04.343a1.779 1.779 0 0 0 .062.46z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,5 +1,6 @@
|
|||
import copy
|
||||
import ipaddress
|
||||
from datetime import datetime, timezone
|
||||
from functools import cached_property
|
||||
|
||||
from libvirt import (
|
||||
|
|
@ -240,25 +241,50 @@ class wvmNetwork(wvmConnect):
|
|||
return network_mode(self.tree)
|
||||
|
||||
def get_vms(self):
|
||||
"""The VMs whose definition names this network, by name, and whether
|
||||
each is running on it now: one listing, and one XML read per stopped
|
||||
VM, two per running one (its running and its saved definition)."""
|
||||
"""The VMs whose definition names this network, by name, whether each
|
||||
is running on it now, and the MACs of its cards on it (as it runs, or
|
||||
as saved when it does not): one listing, and one XML read per stopped
|
||||
VM, two per running one that is not on it now."""
|
||||
name = self.net.name()
|
||||
|
||||
def on_it(xml):
|
||||
return etree.fromstring(xml).xpath("devices/interface[@type='network']/source[@network=$name]", name=name)
|
||||
def cards(xml):
|
||||
return etree.fromstring(xml).xpath("devices/interface[@type='network'][source/@network=$name]", name=name)
|
||||
|
||||
vms = []
|
||||
for dom in self.wvm.listAllDomains():
|
||||
try:
|
||||
connected = dom.isActive() and bool(on_it(dom.XMLDesc(0)))
|
||||
if connected or on_it(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE)):
|
||||
vms.append({"name": dom.name(), "connected": connected})
|
||||
on_it = cards(dom.XMLDesc(0)) if dom.isActive() else []
|
||||
connected = bool(on_it)
|
||||
if not connected:
|
||||
on_it = cards(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
|
||||
except libvirtError as err: # a VM undefined meanwhile is not on it
|
||||
if err.get_error_code() != VIR_ERR_NO_DOMAIN:
|
||||
raise
|
||||
continue
|
||||
if on_it:
|
||||
macs = [card.xpath("string(mac/@address)").lower() for card in on_it]
|
||||
vms.append({"name": dom.name(), "connected": connected, "macs": [mac for mac in macs if mac]})
|
||||
return sorted(vms, key=lambda vm: vm["name"])
|
||||
|
||||
def get_leases(self):
|
||||
"""The network's DHCP leases as its dnsmasq keeps them, by address: IP,
|
||||
MAC (an IPv6 lease may have none), the host name and client ID (an
|
||||
IPv6 lease's DUID) the client sent, and when it ends (None: never).
|
||||
A network that does not run has none."""
|
||||
if not self.is_active():
|
||||
return []
|
||||
leases = []
|
||||
for lease in self.net.DHCPLeases():
|
||||
expiry = lease["expirytime"]
|
||||
leases.append({
|
||||
"ip": ipaddress.ip_address(lease["ipaddr"]),
|
||||
"mac": (lease.get("mac") or "").lower(),
|
||||
"hostname": lease.get("hostname") or "",
|
||||
"client_id": lease.get("clientid") or "",
|
||||
"expires": datetime.fromtimestamp(expiry, timezone.utc) if expiry else None,
|
||||
})
|
||||
return sorted(leases, key=lambda lease: (lease["ip"].version, int(lease["ip"])))
|
||||
|
||||
def _vms_on_it(self, running_only):
|
||||
name = self.net.name()
|
||||
return self.domains_using(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue