mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-10 12:16:49 +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:
parent
bf736942eb
commit
aab8dbf1df
18 changed files with 600 additions and 449 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
35
instances/tests/js/vm_addresses.test.js
Normal file
35
instances/tests/js/vm_addresses.test.js
Normal 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);
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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": []}
|
||||
|
|
|
|||
59
instances/tests/test_vm_addresses.py
Normal file
59
instances/tests/test_vm_addresses.py
Normal 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)
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue