mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
- create_admin had a branch for `"test" in sys.argv` that gave the admin the password "admin"; 17 places signed in with it, and failed silently with ADMIN_PASSWORD set in the environment. The tests now make their own superuser and force_login it, and the shipped code has no test branch: the test runner gives migrate a password (also when ADMIN_PASSWORD is set but empty), so no data/admin_password is written into the checkout. - AccountsTestCase builds its rows in setUpTestData instead of setUpClass and deleting them by hand. - accounts tests set OTP_ENABLED with override_settings instead of assigning settings (one assignment was never undone, so later tests in the process ran with OTP on), and undo their app_settings change. The OTP login class reloads the URLs again only after the setting is off. - computes/tests/test_views.py reached the machine's own libvirt (localhost over the socket) when TEST_LIBVIRT_HOST was unset; its live tests now run only with TEST_LIBVIRT_HOST, like the other live tests, as the README says.
440 lines
17 KiB
Python
440 lines
17 KiB
Python
import json
|
|
import os
|
|
from unittest.mock import patch
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.shortcuts import reverse
|
|
from django.test import 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.admin = get_user_model().objects.create_superuser("tests-admin", "admin@example.com", "pw")
|
|
self.client.force_login(self.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):
|
|
if not livetest.enabled(): # never the machine's own libvirt by default
|
|
self.skipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
|
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)
|