diff --git a/computes/templates/computes/instances.html b/computes/templates/computes/instances.html index dfceb80b..e56c32af 100644 --- a/computes/templates/computes/instances.html +++ b/computes/templates/computes/instances.html @@ -88,5 +88,6 @@ {% block script %} {% job_panel_script job_resource %} +{% include "instances/vm_states_poll.html" %} {% endblock %} \ No newline at end of file diff --git a/computes/views.py b/computes/views.py index 3706456e..2e175035 100644 --- a/computes/views.py +++ b/computes/views.py @@ -138,7 +138,8 @@ def instances(request, compute_id): return render( request, "computes/instances.html", - {"compute": compute, "instances": instances, "job_resource": claims.compute_key(compute.id)}, + {"compute": compute, "instances": instances, "job_resource": claims.compute_key(compute.id), + "states_url": reverse("instances:states") + f"?compute={compute.id}"}, ) diff --git a/instances/templates/allinstances.html b/instances/templates/allinstances.html index 4841db37..a3fe344b 100644 --- a/instances/templates/allinstances.html +++ b/instances/templates/allinstances.html @@ -116,6 +116,8 @@ {% endblock content %} {% block script %} + + {% include "instances/vm_states_poll.html" %} {% if request.user.is_superuser %} diff --git a/instances/templates/instances/vm_states_poll.html b/instances/templates/instances/vm_states_poll.html new file mode 100644 index 00000000..68c1b1a6 --- /dev/null +++ b/instances/templates/instances/vm_states_poll.html @@ -0,0 +1,5 @@ +{% load static %} +{# the VM list reloads when its VMs change: vm_states.js #} +{{ states_url|json_script:"vm-states-url" }} + + diff --git a/instances/tests/test_vm_states.py b/instances/tests/test_vm_states.py new file mode 100644 index 00000000..6f54e35f --- /dev/null +++ b/instances/tests/test_vm_states.py @@ -0,0 +1,90 @@ +"""The VM lists follow libvirt: instances:states gives the states of the +listed VMs, read in one call per host; vm_states.js polls it and reloads the +list when an answer differs from the one before. A user who does not see +hosts gets only the hosts and VMs of their own.""" + +import json +from unittest.mock import MagicMock, PropertyMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from libvirt import libvirtError +from lxml import html + +from accounts.models import UserInstance +from computes.models import Compute +from instances.models import Instance + +UP = {"aaaaaaaa-0000-4000-8000-000000000001": ["web", 1], "aaaaaaaa-0000-4000-8000-000000000002": ["db", 5]} + + +class VmStatesTestCase(TestCase): + def setUp(self): + self.up = Compute.objects.create(name="up", hostname="192.0.2.70", login="u", password="p", type=1) + self.down = Compute.objects.create(name="down", hostname="192.0.2.71", login="u", password="p", type=1) + self.web = Instance.objects.create(compute=self.up, name="web", uuid="aaaaaaaa-0000-4000-8000-000000000001") + self.admin = get_user_model().objects.create_superuser("st-admin", "st@example.com", "pw") + + def get(self, user, query="", read=lambda: dict(UP)): + self.client.force_login(user) + proxy = MagicMock() + proxy.get_domain_states.side_effect = read + # self.up answers, self.down cannot be reached + with patch.object(Compute, "status", property(lambda c: c.pk == self.up.pk)), \ + patch.object(Compute, "proxy", new_callable=PropertyMock, return_value=proxy): + return self.client.get(reverse("instances:states") + query) + + def test_an_administrator_gets_every_host_and_a_host_that_cannot_be_read(self): + response = self.get(self.admin) + self.assertEqual(response["Cache-Control"], "no-store") + self.assertEqual(response.json()["computes"], {str(self.up.pk): {"ok": True, "vms": UP}, + str(self.down.pk): {"ok": False}}) + + def test_a_host_whose_listing_fails_cannot_be_read(self): + def fails(): + raise libvirtError("Cannot recv data") + self.assertEqual(self.get(self.admin, read=fails).json()["computes"][str(self.up.pk)], {"ok": False}) + + def test_a_user_gets_the_hosts_and_vms_of_their_own(self): + owner = get_user_model().objects.create_user("st-owner", password="pw") + UserInstance.objects.create(user=owner, instance=self.web) + self.assertEqual(self.get(owner).json()["computes"], + {str(self.up.pk): {"ok": True, "vms": {self.web.uuid: ["web", 1]}}}) + stranger = get_user_model().objects.create_user("st-stranger", password="pw") + self.assertEqual(self.get(stranger).json()["computes"], {}) + + def test_one_host_s_list_is_for_administrators(self): + self.assertEqual(self.get(self.admin, f"?compute={self.up.pk}").json()["computes"], + {str(self.up.pk): {"ok": True, "vms": UP}}) + owner = get_user_model().objects.create_user("st-owner2", password="pw") + self.assertEqual(self.get(owner, f"?compute={self.up.pk}").status_code, 403) + + def test_a_user_s_uuid_on_another_host_is_not_theirs(self): + # one UUID may be defined on two hosts: a VM is its host and UUID + other = Compute.objects.create(name="other", hostname="192.0.2.72", login="u", password="p", type=1) + Instance.objects.create(compute=other, name="mine-too", uuid="aaaaaaaa-0000-4000-8000-000000000009") + owner = get_user_model().objects.create_user("st-owner3", password="pw") + UserInstance.objects.create(user=owner, instance=self.web) + UserInstance.objects.create(user=owner, instance=Instance.objects.get(name="mine-too")) + self.client.force_login(owner) + proxy = MagicMock() + proxy.get_domain_states.return_value = dict(UP) # both hosts list the same UUIDs + with patch.object(Compute, "status", True), \ + patch.object(Compute, "proxy", new_callable=PropertyMock, return_value=proxy): + computes = self.client.get(reverse("instances:states")).json()["computes"] + self.assertEqual(computes, {str(self.up.pk): {"ok": True, "vms": {self.web.uuid: ["web", 1]}}, + str(other.pk): {"ok": True, "vms": {}}}) + + def test_the_lists_poll_it(self): + self.client.force_login(self.admin) + with patch.object(Compute, "status", property(lambda c: c.pk == self.up.pk)), \ + patch.object(Compute, "proxy", new_callable=PropertyMock, return_value=MagicMock()), \ + patch("instances.views.utils.refr"), patch("computes.views.utils.refresh_instance_database"), \ + patch.object(Instance, "proxy", new_callable=PropertyMock, return_value=MagicMock()): + for url, states_url in ((reverse("instances:index"), reverse("instances:states")), + (reverse("instances", args=[self.up.pk]), + reverse("instances:states") + f"?compute={self.up.pk}")): + with self.subTest(url=url): + page = html.fromstring(self.client.get(url).content) + self.assertEqual(json.loads(page.get_element_by_id("vm-states-url").text), states_url) diff --git a/instances/urls.py b/instances/urls.py index c12ba49d..f07fdf72 100755 --- a/instances/urls.py +++ b/instances/urls.py @@ -6,6 +6,7 @@ app_name = "instances" urlpatterns = [ path("", views.index, name="index"), + path("states/", views.states, name="states"), path("find_address/", views.find_address, name="find_address"), path("report.xlsx", views.vm_report, name="vm_report"), path("flavor/create/", views.flavor_create, name="flavor_create"), diff --git a/instances/utils.py b/instances/utils.py index e27334c2..31800dd4 100644 --- a/instances/utils.py +++ b/instances/utils.py @@ -338,6 +338,25 @@ def record_migration(instance, new_compute): instance.save() +def vm_states(user, computes): + """{compute pk: {"ok": True, "vms": {uuid: [name, state]}}, or {"ok": False} + for a host that cannot be read}: what the VM lists poll, read from libvirt; + for a user who does not see hosts only their own VMs (a VM is its host and + UUID: one UUID may be defined on two hosts).""" + show_hosts = user.is_superuser or user.has_perm("instances.view_instances") + own = None if show_hosts else set( + Instance.objects.filter(userinstance__user=user).values_list("compute_id", "uuid")) + result = {} + for compute in computes: + try: + vms = compute.proxy.get_domain_states() if compute.status is True else None + except libvirtError: + vms = None + result[str(compute.pk)] = {"ok": False} if vms is None else { + "ok": True, "vms": {u: s for u, s in vms.items() if own is None or (compute.pk, u) in own}} + return result + + def migration_target(compute): """The destination for the log: its host name, and the migration address when one is set and differs (else the migration goes to the host name).""" diff --git a/instances/views.py b/instances/views.py index 50fbaceb..6ab46fca 100755 --- a/instances/views.py +++ b/instances/views.py @@ -103,10 +103,33 @@ def index(request): {"computes": computes, "instances": instances, "show_hosts": show_hosts, "own_hosts_down": own_hosts_down, "reachable_computes": [c for c in computes if c.status is True], "unreachable_computes": [c for c in computes if c.status is not True], - "states": [label for label, _color in STATES.values()] + [UNKNOWN[0]]}, + "states": [label for label, _color in STATES.values()] + [UNKNOWN[0]], + "states_url": reverse("instances:states")}, ) +def _listed_computes(user, computes): + """The hosts of the VM list: every host for a user who sees hosts, else + only those of the user's own VMs.""" + if user.is_superuser or user.has_perm("instances.view_instances"): + return computes + own = set(Instance.objects.filter(userinstance__user=user).values_list("compute_id", flat=True)) + return [c for c in computes if c.pk in own] + + +def states(request): + """The VM lists' poll: utils.vm_states of the list's hosts; ?compute=id + for one host's list (administrators).""" + computes = list(Compute.objects.all()) + if request.GET.get("compute"): + if not request.user.is_superuser: + raise PermissionDenied + computes = [get_object_or_404(Compute, pk=request.GET["compute"])] + response = JsonResponse({"computes": utils.vm_states(request.user, _listed_computes(request.user, computes))}) + response["Cache-Control"] = "no-store" + return response + + def _address_scope(user): """What the address search and the VM report read for the user: whether the user sees hosts, the registered VMs by (compute id, UUID), and the diff --git a/operations/static/operations/job_panel.js b/operations/static/operations/job_panel.js index c7157a4f..9a13e0b1 100644 --- a/operations/static/operations/job_panel.js +++ b/operations/static/operations/job_panel.js @@ -120,14 +120,6 @@ function jobPanel(element, urls, csrfToken, initialIds) { return job.state === "succeeded" && !result.warning && !(result.left_behind || []).length; } - // a dialog open, a field in use or an XML edit not saved: its outcome waits in the panel, with Refresh - function interacting() { - var active = document.activeElement; - return !!document.querySelector(".modal.show") - || !!(active && active.matches && active.matches("input, select, textarea")) - || !!(window.xmlEditUnsaved && window.xmlEditUnsaved()); - } - function fetchOutcome(id) { if (inflight[id]) { return; } inflight[id] = true; @@ -140,7 +132,8 @@ function jobPanel(element, urls, csrfToken, initialIds) { delete pending[id]; // only when no other job runs, is shown or awaited: a reload would lose its outcome var others = Object.keys(outcomes).length + Object.keys(pending).length + Object.keys(shown).length; - if (quiet(job) && !interacting() && !others) { + // the page in use (poll.js): its outcome waits in the panel, with Refresh + if (quiet(job) && !pageInUse() && !others) { window.location.reload(); // the job is not remembered again: no loop return; } diff --git a/static/js/poll.js b/static/js/poll.js index 113ee05c..04c6dd29 100644 --- a/static/js/poll.js +++ b/static/js/poll.js @@ -49,3 +49,12 @@ function pollWhileVisible(interval, request, onData, firstDelay) { }, }; } + +// The user is in the page: a dialog open, a field in use or an XML edit not +// saved. A poll that would reload the page waits for its next answer. +function pageInUse() { + var active = document.activeElement; + return !!document.querySelector(".modal.show") + || !!(active && active.matches && active.matches("input, select, textarea")) + || !!(window.xmlEditUnsaved && window.xmlEditUnsaved()); +} diff --git a/static/js/vm_states.js b/static/js/vm_states.js new file mode 100644 index 00000000..1cf17548 --- /dev/null +++ b/static/js/vm_states.js @@ -0,0 +1,28 @@ +// A VM list follows libvirt: it polls the states of its VMs (instances:states) +// at once, then every 5 s, as the VM page, and reloads when an answer differs +// from the one before: a VM started or stopped, made, removed or renamed +// outside the page included. The page in use or the job panel showing +// something: it waits for the next answer. poll.js gives the polling. +function vmStatesKey(states) { + return Object.keys(states).sort().map(function (id) { + var host = states[id]; + return id + ":" + (host.ok ? Object.keys(host.vms).sort().map(function (uuid) { + return uuid + "=" + host.vms[uuid].join("/"); + }).join(",") : "unreadable"); + }).join(";"); +} + +function followVmStates(url) { + var last = null; + // the first answer, at once, is what the page was rendered with + return pollWhileVisible(5000, function () { return $.getJSON(url); }, function (data) { + var key = vmStatesKey(data.computes); + if (last !== null && key !== last) { + if (!pageInUse() && !window.backgroundJobRunning) { + window.location.reload(); + } + return; // in use: the change stays against the answer before + } + last = key; + }, 0); +} diff --git a/vrtManager/connection.py b/vrtManager/connection.py index 57bb7feb..d3a7c6b5 100644 --- a/vrtManager/connection.py +++ b/vrtManager/connection.py @@ -1011,6 +1011,11 @@ class wvmConnect(object): instances.append(name) return instances + def get_domain_states(self): + """{uuid: [name, state]} of every domain, in one call.""" + return {dom.UUIDString(): [dom.name(), stats["state.state"]] + for dom, stats in self.wvm.getAllDomainStats(libvirt.VIR_DOMAIN_STATS_STATE)} + def domain_trees(self, running_only=False): """Each domain's name with its definitions, parsed: the live one, and for a persistent domain the one saved for its next boot too, read diff --git a/webvirtcloud/tests/js/vm_states.test.js b/webvirtcloud/tests/js/vm_states.test.js new file mode 100644 index 00000000..5bc6fa2a --- /dev/null +++ b/webvirtcloud/tests/js/vm_states.test.js @@ -0,0 +1,76 @@ +// Behaviour of static/js/vm_states.js (with static/js/poll.js) under a fake +// clock, document and jQuery. Run by webvirtcloud/tests/test_poll_js.py: +// node vm_states.test.js static/js/poll.js static/js/vm_states.js +const fs = require("fs"); +const assert = require("assert"); + +let now = 0, timers = [], nextId = 1; +global.setTimeout = (fn, ms) => { const id = nextId++; timers.push({ id, at: now + ms, fn }); return id; }; +global.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); }; +function advance(ms) { + const end = now + ms; + for (;;) { + timers.sort((a, b) => a.at - b.at); + const t = timers[0]; + if (!t || t.at > end) break; + timers.shift(); now = t.at; t.fn(); + } + now = end; +} +global.document = { hidden: false, addEventListener() {}, activeElement: null, querySelector: () => null }; +global.window = { backgroundJobRunning: false, location: { reload() { window.reloads += 1; } }, reloads: 0 }; + +// each poll answers with what the test puts in `answer` +let answer = null, asked = []; +global.$ = { getJSON(url) { + asked.push(url); + const value = answer; + const r = { always(f) { f(); return r; }, done(f) { f(value); return r; } }; + return r; +} }; +eval(fs.readFileSync(process.argv[2], "utf8")); +eval(fs.readFileSync(process.argv[3], "utf8")); + +const base = { "1": { ok: true, vms: { "u-a": ["web", 1], "u-b": ["db", 5] } }, "2": { ok: false } }; +followVmStates("/instances/states/?compute=1"); + +// the first answer, asked at once, is where it starts; the same VMs in another order change nothing +answer = { computes: base }; +advance(0); +assert.strictEqual(asked.length, 1, "asks at once"); +answer = { computes: { "2": { ok: false }, "1": { ok: true, vms: { "u-b": ["db", 5], "u-a": ["web", 1] } } } }; +advance(5000); +assert.deepStrictEqual(asked, ["/instances/states/?compute=1", "/instances/states/?compute=1"], "asks every 5 s"); +assert.strictEqual(window.reloads, 0, "nothing changed"); + +// a VM stopped, a new VM, a removed one, a renamed one, a host read again: each reloads +for (const host1 of [{ ok: true, vms: { "u-a": ["web", 5], "u-b": ["db", 5] } }, + { ok: true, vms: { "u-a": ["web", 1], "u-b": ["db", 5], "u-c": ["new", 5] } }, + { ok: true, vms: { "u-a": ["web", 1] } }, { ok: true, vms: { "u-a": ["web2", 1], "u-b": ["db", 5] } }]) { + window.reloads = 0; + answer = { computes: { "1": host1, "2": { ok: false } } }; + advance(5000); + assert.strictEqual(window.reloads, 1, JSON.stringify(host1)); + answer = { computes: base }; // the reloaded page starts again from here + advance(5000); + window.reloads = 0; +} + +// not while the page is in use or the job panel shows something: the next answer tells again +for (const [what, set, unset] of [ + ["a dialog", () => { document.querySelector = (s) => s === ".modal.show" ? {} : null; }, () => { document.querySelector = () => null; }], + ["the filter", () => { document.activeElement = { matches: () => true }; }, () => { document.activeElement = null; }], + ["a job outcome", () => { window.backgroundJobRunning = true; }, () => { window.backgroundJobRunning = false; }], +]) { + window.reloads = 0; + answer = { computes: { "1": { ok: true, vms: { "u-a": ["web", 5], "u-b": ["db", 5] } }, "2": { ok: false } } }; + set(); + advance(5000); + assert.strictEqual(window.reloads, 0, "waits for " + what); + unset(); + advance(5000); + assert.strictEqual(window.reloads, 1, "then reloads: " + what); + answer = { computes: base }; + advance(5000); +} +console.log("ok"); diff --git a/webvirtcloud/tests/test_authorization_matrix.py b/webvirtcloud/tests/test_authorization_matrix.py index 2c132ab4..f9aa1f60 100644 --- a/webvirtcloud/tests/test_authorization_matrix.py +++ b/webvirtcloud/tests/test_authorization_matrix.py @@ -32,6 +32,7 @@ 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", + "instances:states": "the VM list's poll: 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", diff --git a/webvirtcloud/tests/test_poll_js.py b/webvirtcloud/tests/test_poll_js.py index 2678c42a..c6791033 100644 --- a/webvirtcloud/tests/test_poll_js.py +++ b/webvirtcloud/tests/test_poll_js.py @@ -25,6 +25,17 @@ class PollTestCase(unittest.TestCase): ) self.assertEqual(result.returncode, 0, result.stderr) + @unittest.skipUnless(shutil.which("node"), "node is not installed") + def test_vm_states_js(self): + result = subprocess.run( + ["node", str(BASE / "webvirtcloud/tests/js/vm_states.test.js"), str(BASE / "static/js/poll.js"), + str(BASE / "static/js/vm_states.js")], + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stderr) + def test_pages_poll_through_poll_js(self): for template in POLLING_TEMPLATES: with self.subTest(template=template):