1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

perf(instances): a VM page does not wait for the guest agent's addresses

A running VM's page read its addresses while it rendered: the guest agent,
when its channel is connected but it does not answer, holds the page (and
a gunicorn worker) for its whole 10 s timeout, on every load. The page now
shows them once it has loaded: vm_addresses.js asks the new
instances/<pk>/addresses/ for each NIC's addresses and fills the header's
IPv4 and each network card's IPv4 and IPv6.
This commit is contained in:
catborise 2026-11-07 15:31:05 +03:00
parent bf736942eb
commit aab8dbf1df
18 changed files with 600 additions and 449 deletions

View file

@ -2,6 +2,7 @@ from computes.models import Compute
from django.conf import settings
from instances.models import Flavor, Instance
from instances.utils import nic_macs
from libvirt import libvirtError
from rest_framework import serializers
@ -12,6 +13,17 @@ class InstanceSerializer(serializers.ModelSerializer):
class InstanceDetailsSerializer(serializers.ModelSerializer):
networks = serializers.SerializerMethodField()
def get_networks(self, instance) -> list[dict]:
"""Each NIC with its addresses (None: none known), which the VM page
reads after it loads (views.nic_addresses)."""
try:
found = instance.proxy.get_addresses()
except libvirtError:
found = {}
return [{**nic, **dict(zip(("ipv4", "ipv6"), found.get(nic["mac"], (None, None))))} for nic in instance.networks]
def get_fields(self):
fields = super().get_fields()
# the host's ISO images are a superuser's, as on the VM page

View file

@ -67,7 +67,7 @@
{% if not instance.is_template %}
<div class="col-6 col-lg-3">
<dt class="fw-normal text-body-secondary">{% trans "IPv4" %}</dt>
<dd class="mb-0 font-monospace">{{ ipv4_addresses|join:", "|default:"—" }}</dd>
<dd class="mb-0 font-monospace" data-address-mac="{% for nic in instance.networks %}{{ nic.mac }} {% endfor %}" data-address-family="ipv4" data-empty="—">{% trans "Reading…" %}</dd>
</div>
{% endif %}
</dl>
@ -133,6 +133,7 @@
<script src="{% static 'js/poll.js' %}"></script>
{% if not instance.is_template %}<script src="{% static 'js/instance_stats.js' %}"></script>{% endif %}
<script src="{% static 'js/instance_status.js' %}"></script>
<script src="{% static 'js/vm_addresses.js' %}" data-url="{% url 'instances:nic_addresses' instance.id %}"></script>
{% job_panel_script job_resource %}
<script src="{% static 'js/instance_tabs.js' %}"></script>
<script src="{% static 'js/instance_page.js' %}"></script>

View file

@ -318,7 +318,7 @@
</div>
</div>
<div class="d-flex flex-wrap align-items-center column-gap-4 row-gap-1 mt-2">
<span class="text-break">{% if network.ipv4 %}{{ network.ipv4|join:", " }}{% else %}<span class="text-body-secondary">{% trans "No IPv4 address known" %}</span>{% endif %}</span>
<span class="text-break text-body-secondary" data-address-mac="{{ network.mac }}" data-address-family="ipv4" data-empty="{% trans "No IPv4 address known" %}">{% trans "Reading…" %}</span>
<form action="{% url 'instances:set_link_state' instance.id %}" method="post" aria-label="{% trans "Link state form" %}">{% csrf_token %}
<input type="hidden" name="mac" value="{{ network.mac }}">
<input type="hidden" name="set_link_state" value="{{ network.state }}">
@ -337,7 +337,7 @@
<dl class="row mb-0 mt-2" style="max-width: 32rem">
<dt class="col-4 fw-normal text-body-secondary">{% trans "Type" %}</dt><dd class="col-8 mb-1">{{ network.type }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "Host device" %}</dt><dd class="col-8 mb-1 text-break">{{ network.target|default:_("no target") }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "IPv6" %}</dt><dd class="col-8 mb-1 text-break">{{ network.ipv6|join:", "|default:_("unknown") }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "IPv6" %}</dt><dd class="col-8 mb-1 text-break" data-address-mac="{{ network.mac }}" data-address-family="ipv6" data-empty="{% trans "unknown" %}">{% trans "Reading…" %}</dd>
</dl>
</details>
{% if network.inbound or network.outbound %}

View file

@ -0,0 +1,35 @@
// Behaviour of static/js/vm_addresses.js with a fake document and fetch.
// Run by instances/tests/test_vm_addresses.py:
// node vm_addresses.test.js static/js/vm_addresses.js
const fs = require("fs");
const assert = require("assert");
function place(family, macs, empty) {
const classes = new Set(["text-body-secondary"]);
return { dataset: { addressFamily: family, addressMac: macs, empty }, textContent: "Reading…",
classList: { toggle(name, on) { if (on) classes.add(name); else classes.delete(name); } }, classes };
}
// the header (the VM's NICs), a NIC's IPv4 and IPv6, a NIC the guest has no address on
const header = place("ipv4", "52:54:00:00:00:01 52:54:00:00:00:02 ", "—");
const ipv4 = place("ipv4", "52:54:00:00:00:01", "No IPv4 address known");
const ipv6 = place("ipv6", "52:54:00:00:00:01", "unknown");
const other = place("ipv4", "52:54:00:00:00:02", "No IPv4 address known");
global.document = { querySelectorAll: () => [header, ipv4, ipv6, other], currentScript: { dataset: { url: "/instances/7/addresses/" } } };
let asked = null;
global.fetch = (url) => {
asked = url;
// a guest's own bridge (docker0) is not one of the VM's NICs
return Promise.resolve({ ok: true, json: () => ({
"52:54:00:00:00:01": { ipv4: ["192.0.2.10"], ipv6: ["2001:db8::10/64"] },
"02:42:ac:11:00:01": { ipv4: ["172.17.0.1"], ipv6: [] } }) });
};
eval(fs.readFileSync(process.argv[2], "utf8"));
setTimeout(() => {
assert.strictEqual(asked, "/instances/7/addresses/");
assert.strictEqual(header.textContent, "192.0.2.10", "only the VM's NICs");
assert.strictEqual(ipv4.textContent, "192.0.2.10");
assert.strictEqual(ipv6.textContent, "2001:db8::10/64");
assert.ok(!ipv4.classes.has("text-body-secondary"), "an address is not muted");
assert.strictEqual(other.textContent, "No IPv4 address known");
assert.ok(other.classes.has("text-body-secondary"));
}, 0);

View file

@ -140,7 +140,8 @@ class PageControlsTestCase(PageControls, TestCase):
self.assertEqual([a.text_content().strip() for a in owner_page.xpath(links)], ["Instances"])
self.assertIn("Guest agent not connected", self.last_page)
self.assertIn("vda 2.0\xa0GB · vdb 1.0\xa0GB", self.last_page)
self.assertEqual(owner_page.xpath("normalize-space(//dt[.='IPv4']/following-sibling::dd)"), "—")
# filled by its script once the page has loaded ("—" when it has none)
self.assertEqual(owner_page.xpath("//dt[.='IPv4']/following-sibling::dd/@data-empty"), ["—"])
def test_superuser_sees_every_control(self):
superuser = get_user_model().objects.create_superuser("ctl_super", "ctl_super@example.com", "x")

View file

@ -82,6 +82,16 @@ class PendingEditsTestCase(TestCase):
self.assertEqual(doc.xpath("//form[.//input[@name='qos_average']]//input[@name='qos_direction']/@value"),
["inbound", "outbound"])
def test_the_page_leaves_the_addresses_to_its_script(self):
nic = {"type": "network", "mac": "52:54:10:00:00:01", "nic": "default", "state": "up", "model": "virtio",
"filterref": "", "target": "vnet0", "inbound": [], "outbound": []}
doc = self.page(nics=([nic], [nic]))
self.assertEqual(doc.xpath("//*[@data-address-family]/@data-address-family"), ["ipv4", "ipv4", "ipv6"])
# the header's are those of the VM's NICs, not the guest's own bridges
self.assertEqual(doc.xpath("//dt[.='IPv4']/following-sibling::dd/@data-address-mac"), ["52:54:10:00:00:01 "])
self.assertEqual(doc.xpath("//script[contains(@src, 'vm_addresses')]/@data-url"),
[reverse("instances:nic_addresses", args=[self.instance.id])])
def test_the_nics_are_rows_of_one_list_with_their_dialogs(self):
nic = {"type": "network", "mac": "52:54:10:00:00:01", "nic": "default", "state": "up", "model": "virtio",
"ipv4": None, "ipv6": None, "filterref": "", "target": "", "inbound": [], "outbound": []}

View file

@ -0,0 +1,59 @@
"""A VM page does not wait for the guest agent, which may take its whole
timeout: its script asks for the addresses once the page has loaded."""
import shutil
import subprocess
import unittest
from pathlib import Path
from unittest.mock import PropertyMock, patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from libvirt import libvirtError
from computes.models import Compute
from instances.api.serializers import InstanceDetailsSerializer
from instances.models import Instance
class AddressesTestCase(TestCase):
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("ad-admin", "ad@example.com", "x"))
compute = Compute.objects.create(name="ad", hostname="127.0.0.1:1", login="root", password="", type=1)
self.instance = Instance.objects.create(compute=compute, name="ad-vm", uuid="aaaaaaaa-1111-2222-3333-555555555555")
self.url = reverse("instances:nic_addresses", args=[self.instance.id])
def addresses(self, **proxy):
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.configure_mock(**proxy)
return self.client.get(self.url).json()
def test_each_nic_s_addresses_by_mac(self):
found = {"52:54:10:00:00:01": (["192.0.2.10"], ["2001:db8::10/64"]), "52:54:10:00:00:02": (["192.0.2.20"], None)}
self.assertEqual(self.addresses(**{"get_addresses.return_value": found}), {
"52:54:10:00:00:01": {"ipv4": ["192.0.2.10"], "ipv6": ["2001:db8::10/64"]},
"52:54:10:00:00:02": {"ipv4": ["192.0.2.20"], "ipv6": []}})
def test_a_host_that_cannot_be_read_gives_none(self):
self.assertEqual(self.addresses(**{"get_addresses.side_effect": libvirtError("unable to connect")}), {})
def test_the_api_s_vm_details_keep_each_nic_s_addresses(self):
nics = [{"mac": "52:54:10:00:00:01", "type": "network"}, {"mac": "52:54:10:00:00:02", "type": "network"}]
with patch("instances.models.wvmInstance") as wvm, \
patch.object(Instance, "networks", new_callable=PropertyMock, return_value=nics):
wvm.return_value.get_addresses.return_value = {"52:54:10:00:00:01": (["192.0.2.10"], None)}
networks = InstanceDetailsSerializer(self.instance).get_networks(self.instance)
self.assertEqual(networks, [{"mac": "52:54:10:00:00:01", "type": "network", "ipv4": ["192.0.2.10"], "ipv6": None},
{"mac": "52:54:10:00:00:02", "type": "network", "ipv4": None, "ipv6": None}])
class AddressesScriptTestCase(unittest.TestCase):
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_vm_addresses_js(self):
base = Path(settings.BASE_DIR)
result = subprocess.run(["node", str(base / "instances/tests/js/vm_addresses.test.js"),
str(base / "static/js/vm_addresses.js")], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)

View file

@ -21,6 +21,7 @@ urlpatterns = [
path("<int:pk>/destroy/", views.destroy, name="destroy"),
path("<int:pk>/migrate/", views.migrate, name="migrate"),
path("<int:pk>/status/", views.status, name="status"),
path("<int:pk>/addresses/", views.nic_addresses, name="nic_addresses"),
path("<int:pk>/stats/", views.stats, name="stats"),
path("<int:pk>/osinfo/", views.osinfo, name="osinfo"),
path("<int:pk>/rootpasswd/", views.set_root_pass, name="rootpasswd"),

View file

@ -296,7 +296,6 @@ def _instance_page(request, pk, instance, compute):
if can_manage_console:
console_form = ConsoleForm(instance=instance, is_superuser=request.user.is_superuser)
console_listener_addresses = settings.QEMU_CONSOLE_LISTENER_ADDRESSES
ipv4_addresses = [address for net in instance.networks for address in net["ipv4"] or []]
try:
userinstance = UserInstance.objects.get(instance=instance, user=request.user)
except UserInstance.DoesNotExist:
@ -390,6 +389,18 @@ def status(request, pk):
return JsonResponse({"status": instance.proxy.get_status()})
def nic_addresses(request, pk):
"""The VM's addresses by MAC, which its page shows once it has loaded
(vm_addresses.js): the guest agent may take its whole timeout. A host
that cannot be read gives none."""
instance = get_instance(request.user, pk)
try:
found = instance.proxy.get_addresses()
except libvirtError:
found = {}
return JsonResponse({mac: {"ipv4": ipv4 or [], "ipv6": ipv6 or []} for mac, (ipv4, ipv6) in found.items()})
def _valid_ssh_target(compute):
"""The login and hostname reach ssh as a command-line argument; a compute
whose values would inject an ssh option (an existing or bypassed row that

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

20
static/js/vm_addresses.js Normal file
View file

@ -0,0 +1,20 @@
// The VM's addresses come once the page has loaded: the guest agent may take
// its whole timeout (instances.views.nic_addresses). An element with
// data-address-family shows those of the NICs in data-address-mac (MACs
// separated by spaces: not the guest's own bridges); data-empty when there
// are none.
(function () {
const places = document.querySelectorAll("[data-address-family]");
const show = (found) => {
for (const place of places) {
const nics = place.dataset.addressMac.split(" ").filter(Boolean).map((mac) => found[mac] || {});
const addresses = nics.flatMap((nic) => nic[place.dataset.addressFamily] || []);
place.textContent = addresses.join(", ") || place.dataset.empty;
place.classList.toggle("text-body-secondary", !addresses.length);
}
};
fetch(document.currentScript.dataset.url, {headers: {Accept: "application/json"}})
.then((response) => (response.ok ? response.json() : {}))
.catch(() => ({}))
.then(show);
})();

View file

@ -795,15 +795,11 @@ class wvmInstance(wvmConnect):
pcpus = hostinfo[4] * hostinfo[5] * hostinfo[6] * hostinfo[7]
return range(1, max(int(pcpus), at_least) + 1)
def get_interface_addresses(self, iface_mac):
"""The running VM's addresses of the NIC with iface_mac: (IPv4, IPv6
with prefix), from the guest agent when it has some, else from the
host's ARP table; (None, None) when neither has one. Read once in a
cached_reads block: the agent may take its whole timeout."""
return self._cached("addresses", self._read_addresses).get(iface_mac, (None, None))
def _read_addresses(self):
"""{MAC: (IPv4 list, IPv6 list)}: each MAC's addresses from the first source that has some."""
def get_addresses(self):
"""The running VM's addresses, {MAC: (IPv4 list, IPv6 list with
prefix)}, each MAC's from the first source that has some: the guest
agent, else the host's ARP table. The agent may take its whole
timeout: the VM page reads them after it loads (views.nic_addresses)."""
if self.get_status() != 1:
return {}
sources = []
@ -856,10 +852,6 @@ class wvmInstance(wvmConnect):
out_burst = out_attr.get("burst")
outbound = {"average": out_av, "peak": out_peak, "burst": out_burst}
try:
ipv4, ipv6 = self.get_interface_addresses(mac_inst)
except libvirtError:
ipv4, ipv6 = None, None
result.append(
{
"type": interface_type,
@ -868,8 +860,6 @@ class wvmInstance(wvmConnect):
"target": target_inst,
"state": link_state,
"model": model_type,
"ipv4": ipv4,
"ipv6": ipv6,
"filterref": filterref_inst,
"inbound": inbound,
"outbound": outbound,

View file

@ -26,7 +26,6 @@ XML = """<domain>
def proxy():
p = wvmInstance.__new__(wvmInstance) # no connection: only these calls
p._read_addresses = dict # no addresses
p._XMLDesc = lambda flags: XML
volumes = {"/iso/x.iso": ("x.iso", "iso")}

View file

@ -1,7 +1,7 @@
"""A running VM's addresses come from the guest agent when it answers and
from the host's ARP table, so a VM without an agent shows its IPv4 address.
The agent is asked with a timeout: interfaceAddresses() would wait for a
hung agent indefinitely, and the VM page with it."""
hung agent indefinitely; the VM page asks for them after it loads."""
import json
import unittest
@ -38,32 +38,31 @@ class InterfaceAddressesTestCase(unittest.TestCase):
def test_a_vm_without_an_agent_shows_its_arp_address(self):
inst = vm(agent=False)
self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.10"], []))
self.assertEqual(inst.get_addresses(), {MAC: (["192.0.2.10"], [])})
self.agent.assert_not_called()
def test_the_agents_address_comes_first_and_is_asked_with_a_timeout(self):
inst = vm(agent=True)
self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.20"], ["2001:db8::20/64"]))
self.assertEqual(inst.get_addresses(), {MAC: (["192.0.2.20"], ["2001:db8::20/64"])})
self.agent.assert_called_once_with(inst.instance, '{"execute": "guest-network-get-interfaces"}', 10, 0)
self.assertEqual([c.args for c in inst.instance.interfaceAddresses.call_args_list],
[(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,)])
def test_the_addresses_are_read_once_in_a_page_and_again_after_it(self):
def test_the_vm_s_definition_does_not_read_them(self):
inst = vm(agent=True)
with inst.cached_reads():
inst.get_interface_addresses(MAC)
inst.get_interface_addresses("52:54:00:00:00:02")
self.assertEqual(self.agent.call_count, 1)
# outside a page's block, a new read: a NIC's address can change
inst.get_interface_addresses(MAC)
self.assertEqual(self.agent.call_count, 2)
inst._XMLDesc = lambda flags: f"<domain><devices><interface type='network'><mac address='{MAC}'/></interface></devices></domain>"
self.assertEqual([nic["mac"] for nic in inst.get_net_devices()], [MAC])
self.agent.assert_not_called()
inst.instance.interfaceAddresses.assert_not_called()
def test_a_mac_without_addresses_has_none(self):
self.assertEqual(vm(agent=False).get_interface_addresses("52:54:00:00:00:02"), (None, None))
def test_a_vm_that_does_not_run_has_none(self):
inst = vm(agent=True)
inst.get_status.return_value = 5
self.assertEqual(inst.get_addresses(), {})
def test_an_agent_that_does_not_answer_leaves_the_arp_address(self):
self.agent.side_effect = libvirtError("guest agent didn't respond to command within '10' seconds")
self.assertEqual(vm(agent=True).get_interface_addresses(MAC), (["192.0.2.10"], []))
self.assertEqual(vm(agent=True).get_addresses(), {MAC: (["192.0.2.10"], [])})
class OsInfoTestCase(unittest.TestCase):

View file

@ -83,6 +83,7 @@ DENIED = (403, 404)
VIEW_VM = {
"instances:instance",
"instances:status",
"instances:nic_addresses",
"instances:stats",
"instances:osinfo",
"instances:sshkeys",