mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
Every VM list load reconciles each compute with libvirt. Even when nothing had changed, that took the compute's claims and a transaction with SELECT FOR UPDATE (and the ownership advisory lock on PostgreSQL): about 11 queries a compute, 4 of them writes. On SQLite each load queued behind every other write of the app. The reconcile now first compares libvirt's VM list with the database, without a claim or a transaction, and stops when they match and no removed record has expired. Otherwise it runs as before, reading libvirt again under its claims, so two reconciles still cannot commit in reverse order. With two matching hosts a list load goes from 38 queries (8 writes) to 18 (none).
436 lines
17 KiB
Python
436 lines
17 KiB
Python
import json
|
|
import os
|
|
from unittest.mock import patch
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.shortcuts import reverse
|
|
from django.test import RequestFactory, TestCase, TransactionTestCase
|
|
|
|
from vrtManager.connection import CONN_SOCKET, connection_manager
|
|
from computes.models import Compute
|
|
from instances.tests import livetest
|
|
|
|
TEST_COMPUTE_HOST = os.environ.get("TEST_LIBVIRT_HOST", "localhost")
|
|
TEST_COMPUTE_LOGIN = os.environ.get("TEST_LIBVIRT_LOGIN", "")
|
|
TEST_COMPUTE_PASSWORD = os.environ.get("TEST_LIBVIRT_PASSWORD", "")
|
|
TEST_COMPUTE_TYPE = int(os.environ.get("TEST_LIBVIRT_TYPE", CONN_SOCKET))
|
|
|
|
|
|
class ComputesTestCase(TestCase):
|
|
def setUp(self):
|
|
self.client.login(request=RequestFactory().get("/"), username="admin", password="admin")
|
|
self.compute, _ = Compute.objects.get_or_create(
|
|
name="computes-test-compute",
|
|
defaults={
|
|
"hostname": TEST_COMPUTE_HOST,
|
|
"login": TEST_COMPUTE_LOGIN,
|
|
"password": TEST_COMPUTE_PASSWORD,
|
|
"details": "test",
|
|
"type": TEST_COMPUTE_TYPE,
|
|
},
|
|
)
|
|
|
|
def tearDown(self):
|
|
Compute.objects.filter(name="computes-test-compute").delete()
|
|
super().tearDown()
|
|
|
|
def _require_live_compute(self):
|
|
try:
|
|
conn = connection_manager.get_connection(
|
|
self.compute.hostname,
|
|
self.compute.login,
|
|
self.compute.password,
|
|
self.compute.type,
|
|
)
|
|
if not (conn and conn.isAlive()):
|
|
self.skipTest("Live libvirt compute host not available")
|
|
except Exception:
|
|
self.skipTest("Live libvirt compute host not available")
|
|
|
|
def test_index(self):
|
|
response = self.client.get(reverse("computes"))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_create_update_delete(self):
|
|
response = self.client.get(reverse("add_socket_host"))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
response = self.client.post(
|
|
reverse("add_socket_host"),
|
|
{
|
|
"name": "l1",
|
|
"details": "Created",
|
|
"hostname": "localhost",
|
|
"type": 4,
|
|
},
|
|
)
|
|
self.assertRedirects(response, reverse("computes"))
|
|
|
|
compute = Compute.objects.get(name="l1")
|
|
self.assertEqual(compute.name, "l1")
|
|
self.assertEqual(compute.details, "Created")
|
|
created_id = compute.id
|
|
|
|
response = self.client.get(reverse("compute_update", args=[created_id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
response = self.client.post(
|
|
reverse("compute_update", args=[created_id]),
|
|
{
|
|
"name": "l2",
|
|
"details": "Updated",
|
|
"hostname": "localhost",
|
|
"type": 4,
|
|
},
|
|
)
|
|
self.assertRedirects(response, reverse("computes"))
|
|
|
|
compute = Compute.objects.get(id=created_id)
|
|
self.assertEqual(compute.name, "l2")
|
|
self.assertEqual(compute.details, "Updated")
|
|
|
|
response = self.client.get(reverse("compute_delete", args=[created_id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
response = self.client.post(reverse("compute_delete", args=[created_id]))
|
|
self.assertRedirects(response, reverse("computes"))
|
|
|
|
with self.assertRaises(ObjectDoesNotExist):
|
|
Compute.objects.get(id=created_id)
|
|
|
|
def test_overview(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("overview", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_graph(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("compute_graph", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_instances(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("instances", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_storages(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("storages", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def _test_pool(self):
|
|
"""The suite's own pool on the named test host: a host need not have one called default."""
|
|
self._require_live_compute()
|
|
if not livetest.enabled():
|
|
self.skipTest("TEST_LIBVIRT_HOST is not set")
|
|
conn = connection_manager.get_connection(
|
|
self.compute.hostname, self.compute.login, self.compute.password, self.compute.type
|
|
)
|
|
livetest.ensure_pool(conn)
|
|
self.addCleanup(livetest.remove_pool, conn)
|
|
return livetest.POOL
|
|
|
|
def test_storage_volumes(self):
|
|
response = self.client.get(reverse("volumes", kwargs={"compute_id": self.compute.id, "pool": self._test_pool()}))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_storage(self):
|
|
response = self.client.get(reverse("storage", kwargs={"compute_id": self.compute.id, "pool": self._test_pool()}))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_networks(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("networks", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_default_network(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(
|
|
reverse("network", kwargs={"compute_id": self.compute.id, "pool": "default"})
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_interfaces(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("interfaces", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_nwfilters(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("nwfilters", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_secrets(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(reverse("virtsecrets", args=[self.compute.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_machines(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(
|
|
reverse("machines", kwargs={"compute_id": self.compute.id, "arch": "x86_64"})
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_compute_disk_buses(self):
|
|
self._require_live_compute()
|
|
response = self.client.get(
|
|
reverse(
|
|
"buses",
|
|
kwargs={
|
|
"compute_id": self.compute.id,
|
|
"arch": "x86_64",
|
|
"machine": "pc",
|
|
"disk": "disk",
|
|
},
|
|
)
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_a_disk_is_offered_the_hosts_disk_buses(self):
|
|
with patch("computes.models.wvmHostDetails") as conn:
|
|
conn.return_value.get_disk_device_types.return_value = ["disk", "cdrom", "lun"]
|
|
conn.return_value.get_disk_bus_types.return_value = ["virtio", "sata", "scsi"]
|
|
response = self.client.get(reverse("buses", args=[self.compute.id, "x86_64", "q35", "disk"]))
|
|
self.assertEqual(json.loads(response.content), {"bus": ["sata", "scsi", "virtio"]})
|
|
|
|
|
|
class ComputeConcurrencyTestCase(TransactionTestCase):
|
|
def test_concurrent_refresh_instance_database(self):
|
|
import threading
|
|
from django.db import connection
|
|
from unittest.mock import MagicMock
|
|
from computes.utils import refresh_instance_database
|
|
from instances.models import Instance
|
|
|
|
compute = Compute.objects.create(
|
|
name="concurrent-test",
|
|
hostname="127.0.0.1",
|
|
login="test",
|
|
password="pwd",
|
|
type=CONN_SOCKET,
|
|
)
|
|
|
|
mock_dom1 = MagicMock()
|
|
mock_dom1.UUIDString.return_value = "11111111-2222-3333-4444-555555555555"
|
|
mock_dom1.name.return_value = "vm-concurrent-1"
|
|
|
|
mock_dom2 = MagicMock()
|
|
mock_dom2.UUIDString.return_value = "22222222-3333-4444-5555-666666666666"
|
|
mock_dom2.name.return_value = "vm-concurrent-2"
|
|
|
|
mock_proxy = MagicMock()
|
|
mock_proxy.wvm.listAllDomains.return_value = [mock_dom1, mock_dom2]
|
|
compute.proxy = mock_proxy
|
|
compute.status = True # reachable
|
|
|
|
exceptions = []
|
|
|
|
def worker():
|
|
try:
|
|
refresh_instance_database(compute)
|
|
except Exception as e:
|
|
exceptions.append(e)
|
|
finally:
|
|
connection.close()
|
|
|
|
connection.close()
|
|
threads = [threading.Thread(target=worker) for _ in range(5)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
self.assertEqual(exceptions, [])
|
|
self.assertEqual(Instance.objects.filter(compute=compute).count(), 2)
|
|
|
|
def test_refresh_skips_a_busy_compute(self):
|
|
from unittest.mock import MagicMock
|
|
from computes.utils import refresh_instance_database
|
|
from instances.models import Instance
|
|
from operations import claims
|
|
from operations.models import Claim
|
|
|
|
compute = Compute.objects.create(
|
|
name="lock-busy-test",
|
|
hostname="192.0.2.10",
|
|
login="test",
|
|
password="pwd",
|
|
type=CONN_SOCKET,
|
|
)
|
|
Instance.objects.create(compute=compute, name="wvc-test-existing", uuid="existing-uuid-1234")
|
|
|
|
# Libvirt returns an empty list: a refresh that ran would delete the VM
|
|
mock_proxy = MagicMock()
|
|
mock_proxy.wvm.listAllDomains.return_value = []
|
|
compute.proxy = mock_proxy
|
|
compute.status = True
|
|
|
|
claims.admit({claims.compute_key(compute.id): Claim.EXCLUSIVE}, holder="other")
|
|
refresh_instance_database(compute)
|
|
self.assertEqual(Instance.objects.filter(compute=compute).count(), 1)
|
|
mock_proxy.wvm.listAllDomains.assert_called_once() # the pre-check's read; no reconcile
|
|
|
|
def _reconcile_compute(self, name, domains):
|
|
"""A compute whose database differs from libvirt (a VM libvirt no
|
|
longer lists), so a refresh reconciles it: the listing that domains
|
|
answers second is the one inside the reconcile, the first is the
|
|
pre-check's."""
|
|
from unittest.mock import MagicMock
|
|
from instances.models import Instance
|
|
|
|
compute = Compute.objects.create(name=name, hostname="192.0.2.10", login="test", password="pwd",
|
|
type=CONN_SOCKET)
|
|
Instance.objects.create(compute=compute, name="wvc-test-gone", uuid="wvc-test-gone-uuid")
|
|
compute.proxy = MagicMock()
|
|
compute.proxy.wvm.listAllDomains.side_effect = domains
|
|
compute.status = True
|
|
return compute
|
|
|
|
def test_a_vm_operation_runs_while_a_reconcile_holds_the_compute(self):
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from django.db import connection
|
|
from computes.utils import libvirt_instance_lock, refresh_instance_database
|
|
|
|
outcome = []
|
|
|
|
def vm_operation():
|
|
try:
|
|
with libvirt_instance_lock(SimpleNamespace(uuid="wvc-test-uuid-1", compute=compute)):
|
|
outcome.append("ran")
|
|
except Exception as err:
|
|
outcome.append(err)
|
|
finally:
|
|
connection.close()
|
|
|
|
calls = []
|
|
|
|
def list_domains():
|
|
calls.append("list")
|
|
if len(calls) == 2: # inside the reconcile
|
|
thread = threading.Thread(target=vm_operation)
|
|
thread.start()
|
|
thread.join(10)
|
|
return []
|
|
|
|
compute = self._reconcile_compute("reconcile-shared-test", list_domains)
|
|
refresh_instance_database(compute)
|
|
self.assertEqual(outcome, ["ran"])
|
|
|
|
def test_a_second_reconcile_of_the_compute_is_skipped_meanwhile(self):
|
|
"""Two reconciles would read libvirt at different times and could
|
|
commit in reverse order, an older listing undoing a newer one."""
|
|
import threading
|
|
from django.db import connection
|
|
from computes.utils import refresh_instance_database
|
|
|
|
calls = []
|
|
|
|
def second_reconcile():
|
|
try:
|
|
refresh_instance_database(compute)
|
|
finally:
|
|
connection.close()
|
|
|
|
def list_domains():
|
|
calls.append("list")
|
|
if len(calls) == 2: # inside the reconcile
|
|
thread = threading.Thread(target=second_reconcile)
|
|
thread.start()
|
|
thread.join(10)
|
|
return []
|
|
|
|
compute = self._reconcile_compute("reconcile-twice-test", list_domains)
|
|
refresh_instance_database(compute)
|
|
# the second one's pre-check, then no listing of its own
|
|
self.assertEqual(calls, ["list", "list", "list"])
|
|
|
|
def test_a_reconcile_runs_while_a_job_holds_the_compute_shared(self):
|
|
from computes.utils import refresh_instance_database
|
|
from instances.models import Instance
|
|
from operations import claims
|
|
from operations.models import Claim, Operation
|
|
|
|
dom = type("Dom", (), {"UUIDString": lambda self: "33333333-4444-5555-6666-777777777777",
|
|
"name": lambda self: "wvc-test-vm"})()
|
|
compute = self._reconcile_compute("reconcile-job-test", lambda: [dom])
|
|
op = Operation.objects.create(kind="test.job", username="wvc-test-user", submission_key="key-00000001",
|
|
state=Operation.RUNNING)
|
|
Claim.objects.create(operation=op, resource=claims.compute_key(compute.id), mode=Claim.SHARED)
|
|
refresh_instance_database(compute)
|
|
self.assertTrue(Instance.objects.filter(compute=compute, name="wvc-test-vm").exists())
|
|
|
|
def test_libvirt_compute_lock_held_by_another_holder_is_busy(self):
|
|
from computes.utils import libvirt_compute_lock
|
|
from operations import claims
|
|
from operations.models import Claim
|
|
|
|
compute = Compute.objects.create(
|
|
name="thread-lock-test",
|
|
hostname="192.0.2.10",
|
|
login="test",
|
|
password="pwd",
|
|
type=CONN_SOCKET,
|
|
)
|
|
claims.admit({claims.compute_key(compute.id): Claim.EXCLUSIVE}, holder="other")
|
|
with self.assertRaises(claims.Busy):
|
|
with libvirt_compute_lock(compute):
|
|
pass
|
|
|
|
def test_libvirt_compute_lock_reentrant(self):
|
|
from computes.utils import libvirt_compute_lock
|
|
compute = Compute.objects.create(
|
|
name="reentrant-lock-test",
|
|
hostname="192.0.2.10",
|
|
login="test",
|
|
password="pwd",
|
|
type=CONN_SOCKET,
|
|
)
|
|
executed = []
|
|
with libvirt_compute_lock(compute):
|
|
executed.append("outer")
|
|
with libvirt_compute_lock(compute):
|
|
executed.append("inner")
|
|
with libvirt_compute_lock(compute):
|
|
executed.append("deep")
|
|
self.assertEqual(executed, ["outer", "inner", "deep"])
|
|
|
|
|
|
class ComputeInputValidationTestCase(TestCase):
|
|
"""hostname and login reach libvirt URIs and ssh unquoted; the form and
|
|
the API reject values that would inject a URI parameter or an ssh option."""
|
|
|
|
def test_form_rejects_injecting_hostname(self):
|
|
from computes.forms import SshComputeForm
|
|
|
|
form = SshComputeForm(
|
|
data={"name": "c", "hostname": "192.0.2.1/system?command=/evil", "login": "root", "type": 2}
|
|
)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn("hostname", form.errors)
|
|
|
|
def test_api_rejects_injecting_hostname_and_login(self):
|
|
from computes.api.serializers import ComputeSerializer
|
|
|
|
for field, data in (
|
|
("hostname", {"name": "c", "hostname": "192.0.2.1/system?command=/evil&a=", "login": "root", "type": 2}),
|
|
("login", {"name": "c", "hostname": "192.0.2.1", "login": "-oProxyCommand=x", "type": 2}),
|
|
):
|
|
with self.subTest(field=field):
|
|
serializer = ComputeSerializer(data=data)
|
|
self.assertFalse(serializer.is_valid())
|
|
self.assertIn(field, serializer.errors)
|
|
|
|
def test_api_accepts_valid_values(self):
|
|
from computes.api.serializers import ComputeSerializer
|
|
|
|
serializer = ComputeSerializer(
|
|
data={"name": "c", "hostname": "host.example.com", "login": "root", "type": 2}
|
|
)
|
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
|
|
|
def test_api_accepts_empty_login_for_tcp(self):
|
|
from computes.api.serializers import ComputeSerializer
|
|
|
|
serializer = ComputeSerializer(data={"name": "c", "hostname": "192.0.2.1", "login": "", "type": 1})
|
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|