mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +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>
|
<div class="card-body small text-body-secondary">{% trans "The network serves no DHCP, so it has no fixed addresses." %}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
|
|
@ -413,7 +476,8 @@
|
||||||
form.elements.address.value = data.address || "";
|
form.elements.address.value = data.address || "";
|
||||||
form.elements.mac.value = data.mac || "";
|
form.elements.mac.value = data.mac || "";
|
||||||
form.elements.name.value = data.name || "";
|
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);
|
document.getElementById("fixed_subnet_row").hidden = Boolean(data.original);
|
||||||
});
|
});
|
||||||
fillFrom("DhcpRange", function (form, data) {
|
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 admin.decorators import superuser_only
|
||||||
from computes.models import Compute
|
from computes.models import Compute
|
||||||
from instances.models import Instance
|
from instances.models import Instance
|
||||||
|
|
@ -279,6 +281,24 @@ def network(request, compute_id, pool):
|
||||||
except libvirtError as lib_err:
|
except libvirtError as lib_err:
|
||||||
messages.error(request, lib_err)
|
messages.error(request, lib_err)
|
||||||
return HttpResponseRedirect(reverse("networks", args=[compute_id]))
|
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()
|
conn.close()
|
||||||
|
|
||||||
return render(request, "network.html", locals())
|
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 copy
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
from datetime import datetime, timezone
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from libvirt import (
|
from libvirt import (
|
||||||
|
|
@ -240,25 +241,50 @@ class wvmNetwork(wvmConnect):
|
||||||
return network_mode(self.tree)
|
return network_mode(self.tree)
|
||||||
|
|
||||||
def get_vms(self):
|
def get_vms(self):
|
||||||
"""The VMs whose definition names this network, by name, and whether
|
"""The VMs whose definition names this network, by name, whether each
|
||||||
each is running on it now: one listing, and one XML read per stopped
|
is running on it now, and the MACs of its cards on it (as it runs, or
|
||||||
VM, two per running one (its running and its saved definition)."""
|
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()
|
name = self.net.name()
|
||||||
|
|
||||||
def on_it(xml):
|
def cards(xml):
|
||||||
return etree.fromstring(xml).xpath("devices/interface[@type='network']/source[@network=$name]", name=name)
|
return etree.fromstring(xml).xpath("devices/interface[@type='network'][source/@network=$name]", name=name)
|
||||||
|
|
||||||
vms = []
|
vms = []
|
||||||
for dom in self.wvm.listAllDomains():
|
for dom in self.wvm.listAllDomains():
|
||||||
try:
|
try:
|
||||||
connected = dom.isActive() and bool(on_it(dom.XMLDesc(0)))
|
on_it = cards(dom.XMLDesc(0)) if dom.isActive() else []
|
||||||
if connected or on_it(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE)):
|
connected = bool(on_it)
|
||||||
vms.append({"name": dom.name(), "connected": connected})
|
if not connected:
|
||||||
|
on_it = cards(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
|
||||||
except libvirtError as err: # a VM undefined meanwhile is not on it
|
except libvirtError as err: # a VM undefined meanwhile is not on it
|
||||||
if err.get_error_code() != VIR_ERR_NO_DOMAIN:
|
if err.get_error_code() != VIR_ERR_NO_DOMAIN:
|
||||||
raise
|
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"])
|
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):
|
def _vms_on_it(self, running_only):
|
||||||
name = self.net.name()
|
name = self.net.name()
|
||||||
return self.domains_using(
|
return self.domains_using(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue