mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
Each stats answer carries a signed sample of the counters, bound to the VM, its compute and UUID. The next poll sends it back and the rates are the averages since then; a sample from another run of the VM (domain ID), another host CPU count or device list, or older than 30 s falls back to one second as before. Windows are measured on the monotonic clock. Now that a poll is cheap, the Stats tab polls every 5 s like the host graph, the first time as soon as it opens (pollWhileVisible takes the delay of the first request). A counter the host reports as unsupported (-1) or that went back gives no value instead of 0 or a jump; a NIC without a target keeps its chart, a pool volume disk is measured, and a disk or NIC attached after the page loaded no longer stops the other charts. Disk rates are MB/s (they were MiB/s labelled Mb/s), network Mbit/s; the CPU tooltip names the window.
194 lines
9.6 KiB
Python
194 lines
9.6 KiB
Python
"""The VM stats: each answer carries a signed usage sample bound to the VM on
|
|
its compute; the next poll sends it back so that the rates are averages since
|
|
then, without waiting."""
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import libvirt
|
|
from computes.models import Compute
|
|
from django.contrib.auth import get_user_model
|
|
from django.core import signing
|
|
from django.test import TestCase
|
|
from django.urls import reverse
|
|
from instances.models import Instance
|
|
from vrtManager.instance import USAGE_MAX_WINDOW
|
|
|
|
SAMPLE = {"key": [7, 4, ["vda"], [["52:54:00:00:00:01", "vnet0"]]], "counters": [1, [[2, 3]], [[4, 5]], 100.0]}
|
|
|
|
|
|
class StatsViewTestCase(TestCase):
|
|
def setUp(self):
|
|
self.compute = Compute.objects.create(name="stats", hostname="192.0.2.1", login="u", password="p", type=1)
|
|
self.other_compute = Compute.objects.create(name="stats2", hostname="192.0.2.2", login="u", password="p", type=1)
|
|
uuid = "5d1b8a3e-0000-4000-8000-000000000001"
|
|
self.vm = Instance.objects.create(compute=self.compute, name="stats-vm", uuid=uuid)
|
|
self.other = Instance.objects.create(compute=self.compute, name="stats-vm2", uuid=uuid.replace("1", "2"))
|
|
self.client.force_login(get_user_model().objects.create_superuser("sv-admin", "sv@example.com", "pw"))
|
|
patcher = patch("instances.models.wvmInstance")
|
|
self.addCleanup(patcher.stop)
|
|
self.proxy = patcher.start().return_value
|
|
self.proxy.mem_usage.return_value = {"used": 1, "total": 2}
|
|
self.proxy.usage.return_value = {
|
|
"cpu": 12.345,
|
|
"disks": [{"dev": "vda", "rd": 2_000_000, "wr": None}],
|
|
"nics": [{"dev": 0, "rx": 8_000_000, "tx": 0}],
|
|
"window": 9.96,
|
|
"sample": SAMPLE,
|
|
}
|
|
|
|
def stats(self, vm=None, **params):
|
|
response = self.client.get(reverse("instances:stats", args=[(vm or self.vm).id]), params)
|
|
self.assertEqual(response.status_code, 200)
|
|
return json.loads(response.content)
|
|
|
|
def previous(self):
|
|
return self.proxy.usage.call_args.args[0]
|
|
|
|
def test_rates_in_mb_and_mbit_per_second_and_the_sample_round_trip(self):
|
|
data = self.stats()
|
|
self.assertIsNone(self.previous())
|
|
self.assertEqual((data["cpudata"], data["window"]), (12.3, 10.0))
|
|
self.assertEqual(data["blkdata"], [{"dev": "vda", "data": [2.0, None]}])
|
|
self.assertEqual(data["netdata"], [{"dev": 0, "data": [8.0, 0.0]}])
|
|
self.stats(sample=data["sample"])
|
|
self.assertEqual(self.previous(), SAMPLE)
|
|
|
|
def test_an_altered_old_or_foreign_sample_is_not_used(self):
|
|
token = self.stats()["sample"]
|
|
foreign = self.stats(self.other)["sample"]
|
|
altered = token[:-2] + ("xx" if not token.endswith("xx") else "yy")
|
|
for name, sample in (("altered", altered), ("garbage", "x"), ("other VM", foreign)):
|
|
with self.subTest(name):
|
|
self.stats(sample=sample)
|
|
self.assertIsNone(self.previous())
|
|
with patch("django.core.signing.time.time", return_value=signing.time.time() + USAGE_MAX_WINDOW + 1):
|
|
self.stats(sample=token)
|
|
self.assertIsNone(self.previous())
|
|
|
|
def test_a_sample_from_before_a_migration_is_not_used(self):
|
|
token = self.stats()["sample"]
|
|
Instance.objects.filter(pk=self.vm.pk).update(compute=self.other_compute)
|
|
self.stats(sample=token)
|
|
self.assertIsNone(self.previous())
|
|
|
|
def test_a_vm_that_is_not_running_gives_no_sample(self):
|
|
self.proxy.usage.return_value = {"cpu": 0, "disks": [], "nics": []}
|
|
data = self.stats()
|
|
self.assertEqual(data["cpudata"], 0)
|
|
self.assertNotIn("sample", data)
|
|
self.assertNotIn("window", data)
|
|
|
|
|
|
# a VM with a disk and a NIC, on libvirt's test driver
|
|
DOMAIN = """<domain type='test'><name>stats-script-vm</name><uuid>3c0e2b7a-91d4-4f5e-8a6b-0d2f4e6a8c10</uuid>
|
|
<memory unit='MiB'>128</memory><vcpu>1</vcpu><os><type arch='i686'>hvm</type></os>
|
|
<devices><disk type='file' device='disk'><source file='/default-pool/stats-script.img'/><target dev='vda' bus='virtio'/></disk>
|
|
<interface type='network'><mac address='52:54:00:00:00:31'/><source network='default'/><model type='virtio'/></interface></devices>
|
|
</domain>"""
|
|
|
|
|
|
class TestDriverVM:
|
|
def setUp(self):
|
|
self.conn = libvirt.open("test:///default")
|
|
self.addCleanup(self.conn.close)
|
|
# the test host lives on while a libvirt object still refers to it
|
|
pool = self.conn.storagePoolLookupByName("default-pool")
|
|
if "stats-script.img" not in pool.listVolumes():
|
|
pool.createXML("<volume><name>stats-script.img</name><capacity>1048576</capacity></volume>", 0)
|
|
self.domain = self.conn.defineXML(DOMAIN)
|
|
compute = Compute.objects.create(name="ss", hostname="localhost", login="", password="", type=4)
|
|
self.vm = Instance.objects.create(compute=compute, name=self.domain.name(), uuid=self.domain.UUIDString())
|
|
self.client.force_login(get_user_model().objects.create_superuser("ss-admin", "ss@example.com", "pw"))
|
|
for target, value in (
|
|
("vrtManager.connection.connection_manager.get_connection", self.conn),
|
|
("vrtManager.connection.wvmConnect.get_machine_types", ["pc"]),
|
|
("vrtManager.connection.wvmConnect.get_nwfilters", []),
|
|
):
|
|
patcher = patch(target, return_value=value)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
|
|
class SampleRoundTripTestCase(TestDriverVM, TestCase):
|
|
def test_a_real_sample_is_used_by_the_next_poll(self):
|
|
self.domain.create()
|
|
self.addCleanup(self.domain.destroy)
|
|
url = reverse("instances:stats", args=[self.vm.id])
|
|
with patch("vrtManager.instance.time.sleep") as sleep:
|
|
first = json.loads(self.client.get(url).content)
|
|
sleep.assert_called_once_with(1)
|
|
sleep.reset_mock()
|
|
second = json.loads(self.client.get(url, {"sample": first["sample"]}).content)
|
|
sleep.assert_not_called()
|
|
self.assertEqual([disk["dev"] for disk in second["blkdata"]], ["vda"])
|
|
self.assertIn("sample", second)
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
|
class StatsScriptTestCase(TestDriverVM, TestCase):
|
|
"""The VM page's script sends each answer's sample with the next poll and
|
|
drops it after a failed one."""
|
|
|
|
def page(self):
|
|
response = self.client.get(reverse("instances:instance", args=[self.vm.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
return response.content.decode()
|
|
|
|
def test_the_next_poll_sends_the_previous_sample(self):
|
|
script = next(s for s in re.findall(r"<script>(.*?)</script>", self.page(), re.S) if "statsSample" in s)
|
|
js = """
|
|
const vm = require("vm");
|
|
const charts = [], requests = [], handlers = {};
|
|
let poll, fail = false;
|
|
const canvas = {get: () => ({getContext: () => ({})})};
|
|
const $ = () => canvas;
|
|
$.getJSON = (url, params) => {
|
|
requests.push(params);
|
|
return {fail: (callback) => { if (fail) callback(); return {}; }};
|
|
};
|
|
const page = {
|
|
$,
|
|
document: {querySelector: () => ({addEventListener: (name, handler) => { handlers[name] = handler; }})},
|
|
// Chart.js adds the lists a config leaves out
|
|
Chart: function (ctx, config) {
|
|
this.data = Object.assign({labels: []}, config.data);
|
|
this.data.datasets.forEach((set) => { set.data = set.data || []; });
|
|
this.options = config.options;
|
|
this.update = () => {};
|
|
charts.push(this);
|
|
},
|
|
pollWhileVisible: (interval, request, onData, first) => { poll = {interval, first, request, onData}; return {}; },
|
|
};
|
|
vm.runInNewContext(SCRIPT, vm.createContext(page));
|
|
handlers["shown.bs.tab"]();
|
|
const answer = (cpu, sample) => ({cpudata: cpu, window: 9.9, sample: sample, timeline: "t",
|
|
memdata: {used: 0, total: 1024},
|
|
// vdb was attached after the page loaded
|
|
blkdata: [{dev: "vdb", data: [9, 9]}, {dev: "vda", data: [1.5, null]}],
|
|
netdata: [{dev: 0, data: [2, 3]}]});
|
|
poll.request();
|
|
poll.onData(answer(3, "token-1"));
|
|
poll.request();
|
|
fail = true;
|
|
poll.request();
|
|
fail = false;
|
|
poll.request();
|
|
const label = charts[0].options.tooltips.callbacks.label({datasetIndex: 0, index: 0, yLabel: 3}, charts[0].data);
|
|
console.log(JSON.stringify({requests, label, every: [poll.interval, poll.first], cpu: charts[0].data.datasets[0].data,
|
|
disk: charts[2].data.datasets[1].data, net: charts[3].data.datasets[0].data}));
|
|
""".replace("SCRIPT", json.dumps(script))
|
|
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(out.returncode, 0, out.stderr)
|
|
result = json.loads(out.stdout)
|
|
# the first poll at once, then every 5 s
|
|
self.assertEqual(result["every"], [5000, 0])
|
|
# the sample goes with the next poll; a failed poll drops it
|
|
self.assertEqual(result["requests"], [{}, {"sample": "token-1"}, {"sample": "token-1"}, {}])
|
|
# a disk without a chart is skipped, the other charts still update
|
|
self.assertEqual((result["cpu"], result["disk"], result["net"]), ([3], [None], [2]))
|
|
self.assertTrue(result["label"].endswith(": 3 % (average over 9.9 s)"), result["label"])
|