mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
test: each test is in the tests package of the code it tests
Tests that build vrtManager objects and touch no view, form or model of an app lived in that app; vrtManager/tests already holds such tests, so the convention was split. They move to vrtManager/tests: the firmware, CPU mode and PCIe port definitions of a new VM (test_create), USB and PCI host devices, the console listener, host CPU reads, interface reads, deletes and XML, network filters, PCI passthrough rules, node devices, network changes, addresses and leases, pool XML, secrets, and the live tests that use vrtManager only (disk resize, ISO, snapshots, XML, live clone). livetest.py, the live-test helper six apps import, and the network test driver move with them. Others go to their app: the quota with a host down to instances, the 401/403 Logs rows (webvirtcloud's middleware) to webvirtcloud, the VM page's tab script test to instances, novncd's own tests out of the console views tests, the compute lock and input validation tests out of the computes views tests, the interface views out of the forms tests. instances/tests/test_views.py, entirely live, is test_live_views.py. Test modules imported fixtures from each other; shared ones are now in vrtManager/tests/fakes.py (migration fakes, node devices, IOMMU groups, leases, a NAT network, a Ceph secret), interfaces/tests/fakes.py, console/tests/daemons.py (novncd loaded once), livetest.wait_for and instances/tests/create_page.BODY. Every live test checks livetest.enabled(). The suite runs the same 2097 tests.
This commit is contained in:
parent
4e0952aa50
commit
bdc8a6755c
73 changed files with 2231 additions and 2132 deletions
|
|
@ -6,8 +6,7 @@ import json
|
|||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import TestCase as UnitTestCase
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
|
@ -19,7 +18,6 @@ from lxml import html
|
|||
|
||||
from computes.models import Compute
|
||||
from computes.views import CPU_SAMPLE_MAX_AGE, CPU_SAMPLE_SIGNER
|
||||
from vrtManager.hostdetails import cpu_percent, wvmHostDetails
|
||||
from vrtManager.util import ConnectionFailed
|
||||
|
||||
# the page script runs after the shared chart helpers
|
||||
|
|
@ -27,52 +25,6 @@ CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js"
|
|||
TOPOLOGY = {"nodes": 1, "sockets": 1, "cores": 2, "threads": 1, "mhz": 2400, "cpus": 2}
|
||||
|
||||
|
||||
def host(*readings):
|
||||
"""A wvmHostDetails whose getCPUStats returns readings, (idle, busy) each."""
|
||||
h = wvmHostDetails.__new__(wvmHostDetails)
|
||||
h.wvm = MagicMock()
|
||||
h.wvm.getCPUStats.side_effect = [
|
||||
{"kernel": busy // 2, "user": busy - busy // 2, "idle": idle, "iowait": 0, "guest": busy // 3, "utilization": 99}
|
||||
for idle, busy in readings
|
||||
]
|
||||
return h
|
||||
|
||||
|
||||
class CpuPercentTestCase(UnitTestCase):
|
||||
def test_busy_share_between_two_samples(self):
|
||||
self.assertEqual(cpu_percent((100, 200), (130, 300)), 70.0)
|
||||
self.assertEqual(cpu_percent((0, 0), (1, 3)), 66.7)
|
||||
|
||||
def test_samples_that_do_not_follow_on(self):
|
||||
for before, after in (((100, 200), (100, 200)), ((100, 200), (50, 100)), ((100, 200), (200, 250))):
|
||||
with self.subTest(before=before, after=after):
|
||||
self.assertIsNone(cpu_percent(before, after))
|
||||
|
||||
|
||||
@patch("vrtManager.hostdetails.time")
|
||||
class CpuUsageTestCase(UnitTestCase):
|
||||
def test_since_a_previous_sample_without_waiting(self, clock):
|
||||
clock.time.return_value = 105.0
|
||||
h = host((130, 70))
|
||||
usage = h.get_cpu_usage(previous=(100, 100, 100.0))
|
||||
clock.sleep.assert_not_called()
|
||||
# total = kernel + user + idle + iowait; guest (inside user) and utilization are left out
|
||||
self.assertEqual(usage, {"usage": 70.0, "sample": (130, 200, 105.0), "window": 5.0})
|
||||
|
||||
def test_over_one_second_without_a_sample(self, clock):
|
||||
clock.time.side_effect = [10.0, 11.0]
|
||||
h = host((100, 100), (110, 190))
|
||||
self.assertEqual(h.get_cpu_usage(), {"usage": 90.0, "sample": (110, 300, 11.0), "window": 1.0})
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
|
||||
def test_over_one_second_after_the_host_restarted(self, clock):
|
||||
clock.time.side_effect = [10.0, 11.0]
|
||||
h = host((5, 5), (6, 9))
|
||||
usage = h.get_cpu_usage(previous=(1000, 5000, 5.0))
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
self.assertEqual((usage["usage"], usage["window"]), (80.0, 1.0))
|
||||
|
||||
|
||||
class GraphTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.compute = Compute.objects.create(name="graph", hostname="192.0.2.1", login="u", password="p", type=1)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from django.test import TransactionTestCase
|
|||
|
||||
from computes.utils import libvirt_compute_lock, libvirt_instance_lock
|
||||
from operations import claims
|
||||
from vrtManager.connection import CONN_SOCKET
|
||||
from computes.models import Compute
|
||||
|
||||
|
||||
class OperationLocksTestCase(TransactionTestCase):
|
||||
|
|
@ -62,3 +64,205 @@ class OperationLocksTestCase(TransactionTestCase):
|
|||
with libvirt_compute_lock(self.compute):
|
||||
with libvirt_instance_lock(self.first):
|
||||
pass
|
||||
|
||||
|
||||
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"])
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import socket
|
||||
|
||||
from accounts.models import UserAttributes, UserInstance
|
||||
from appsettings.settings import get_settings
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from instances.models import Instance
|
||||
from instances.utils import QUOTA_UNVERIFIED, check_user_quota
|
||||
|
||||
|
||||
class QuotaWithDownHostTestCase(TestCase):
|
||||
def test_vm_on_an_unreachable_host_makes_the_quota_unverified(self):
|
||||
# its usage is unknown, so an increase is refused (fail closed), without an error page
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
user = get_user_model().objects.create_user("quota_down", password="pw")
|
||||
UserAttributes.objects.create(user=user, max_instances=5, max_cpus=8, max_memory=8192, max_disk_size=100)
|
||||
compute = Compute.objects.create(name="down", hostname=f"127.0.0.1:{port}", login="", password="", type=1)
|
||||
vm = Instance.objects.create(compute=compute, name="vm", uuid="55555555-5555-5555-5555-555555555555")
|
||||
UserInstance.objects.create(user=user, instance=vm)
|
||||
get_settings() # app settings are normally loaded by middleware
|
||||
|
||||
self.assertEqual(check_user_quota(user, 1, 1, 128, 1), QUOTA_UNVERIFIED)
|
||||
|
|
@ -7,6 +7,7 @@ import unittest
|
|||
from django.core.exceptions import ValidationError
|
||||
|
||||
from computes.validators import validate_hostname, validate_login
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class HostnameValidatorTestCase(unittest.TestCase):
|
||||
|
|
@ -46,3 +47,41 @@ class LoginValidatorTestCase(unittest.TestCase):
|
|||
validate_login(value)
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ 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 django.test import TestCase
|
||||
|
||||
from vrtManager.connection import CONN_SOCKET, connection_manager
|
||||
from computes.models import Compute
|
||||
from instances.tests import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
TEST_COMPUTE_HOST = os.environ.get("TEST_LIBVIRT_HOST", "localhost")
|
||||
TEST_COMPUTE_LOGIN = os.environ.get("TEST_LIBVIRT_LOGIN", "")
|
||||
|
|
@ -196,245 +196,3 @@ class ComputesTestCase(TestCase):
|
|||
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)
|
||||
|
|
|
|||
10
console/tests/daemons.py
Normal file
10
console/tests/daemons.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""console/novncd, an extensionless script, loaded as a module the tests can patch."""
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
NOVNCD_PATH = str(Path(__file__).resolve().parent.parent / "novncd")
|
||||
_loader = importlib.machinery.SourceFileLoader("novncd_mod", NOVNCD_PATH)
|
||||
novncd_mod = importlib.util.module_from_spec(importlib.util.spec_from_file_location("novncd_mod", NOVNCD_PATH, loader=_loader))
|
||||
_loader.exec_module(novncd_mod)
|
||||
|
|
@ -17,7 +17,7 @@ from computes.models import Compute
|
|||
from console import tokens
|
||||
from console.tests.test_socketiod_child import consoles, console, start_socketiod, socketio
|
||||
from instances.models import Instance
|
||||
from instances.tests import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
|
|
|||
394
console/tests/test_novncd.py
Normal file
394
console/tests/test_novncd.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
"""novncd, the VNC console proxy: what it checks before it connects a browser, and a
|
||||
port it cannot listen on ends it with a failure status, which systemd restarts."""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from console import tokens
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
from instances.models import Instance
|
||||
from console.tests.daemons import novncd_mod
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
class NovncdExitTestCase(SimpleTestCase):
|
||||
def test_a_taken_port_ends_novncd_with_a_failure_status(self):
|
||||
with socket.socket() as taken:
|
||||
taken.bind(("127.0.0.1", 0))
|
||||
taken.listen()
|
||||
port = taken.getsockname()[1]
|
||||
novncd = subprocess.run([sys.executable, str(ROOT / "console" / "novncd"), "-v", "-H", "127.0.0.1",
|
||||
"-p", str(port)], cwd=ROOT, capture_output=True, timeout=60)
|
||||
self.assertNotEqual(novncd.returncode, 0)
|
||||
self.assertIn(b"Openening socket failed", novncd.stderr)
|
||||
|
||||
|
||||
class NovncdDaemonLogicTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.get_parser = novncd_mod.get_parser
|
||||
self.ConsoleClient = novncd_mod.ConsoleClient
|
||||
user = MagicMock(pk=7)
|
||||
user.username = "vnc-user"
|
||||
for name, value in (("authorize", (MagicMock(), user)), ("session_user_id", "7")):
|
||||
patcher = patch.object(novncd_mod, name, return_value=value)
|
||||
setattr(self, name, patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = patch.object(novncd_mod.ConsoleClient, "headers", {}, create=True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_novncd_parser_defaults(self):
|
||||
parser = self.get_parser()
|
||||
opts = parser.parse_args([])
|
||||
self.assertFalse(opts.verbose)
|
||||
self.assertFalse(opts.debug)
|
||||
self.assertEqual(opts.port, 6080)
|
||||
|
||||
def test_novncd_parser_custom_arguments(self):
|
||||
parser = self.get_parser()
|
||||
opts = parser.parse_args(["-v", "-d", "-H", "192.168.1.100", "-p", "7070"])
|
||||
self.assertTrue(opts.verbose)
|
||||
self.assertTrue(opts.debug)
|
||||
self.assertEqual(opts.host, "192.168.1.100")
|
||||
self.assertEqual(opts.port, 7070)
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_a_cookie_does_not_choose_the_console(self, mock_get_info):
|
||||
# another tab's console in a cookie: the URL's own token is used
|
||||
mock_get_info.return_value = ("host1", "root", 1, "192.168.1.50", 5900, None)
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {"cookie": "token=1-uuid-of-another-tab"}
|
||||
handler.path = "/novncd/?token=1-uuid-of-this-tab"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
handler._new_client(socket_factory=MagicMock())
|
||||
self.authorize.assert_called_once_with("1-uuid-of-this-tab", "vnc")
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_compatibility_mixin_token_from_query_params(self, mock_get_info):
|
||||
mock_get_info.return_value = (
|
||||
"host2",
|
||||
"root",
|
||||
1,
|
||||
"192.168.1.50",
|
||||
5901,
|
||||
None,
|
||||
)
|
||||
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {}
|
||||
handler.path = "/novncd/?token=1-uuid-from-query"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
|
||||
self.authorize.assert_called_once_with("1-uuid-from-query", "vnc")
|
||||
# the address the console listens on
|
||||
socket_factory.assert_called_once_with("192.168.1.50", 5901, connect=True)
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_a_local_socket_host_reaches_a_console_on_its_loopback(self, mock_get_info):
|
||||
# libvirt on the panel's host, the VM's console listening on 127.0.0.1
|
||||
mock_get_info.return_value = ("localhost", "", novncd_mod.CONN_SOCKET, "127.0.0.1", 5902, None)
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {}
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
socket_factory.assert_called_once_with("127.0.0.1", 5902, connect=True)
|
||||
|
||||
def test_an_ssh_hosts_console_always_goes_through_the_tunnel(self):
|
||||
# whatever address the console listens on, the VNC stream is not sent unencrypted
|
||||
for console_host, console_socket, target in (
|
||||
("192.0.2.40", None, "192.0.2.40:5900"),
|
||||
("0.0.0.0", None, "127.0.0.1:5900"),
|
||||
("::", None, "[::1]:5900"),
|
||||
("127.0.0.1", None, "127.0.0.1:5900"),
|
||||
(None, "/run/libvirt/qemu/vnc.sock", "/run/libvirt/qemu/vnc.sock"),
|
||||
):
|
||||
with self.subTest(console_host=console_host), patch.object(
|
||||
novncd_mod, "get_connection_infos",
|
||||
return_value=("kvm1.example.com", "root", novncd_mod.CONN_SSH, console_host, 5900, console_socket),
|
||||
), patch.object(novncd_mod, "open_tunnel", return_value=(MagicMock(), MagicMock())) as open_tunnel:
|
||||
handler = self.ConsoleClient()
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
open_tunnel.assert_called_once_with("kvm1.example.com", "root", target)
|
||||
socket_factory.assert_not_called()
|
||||
# the tunnel ends with the session
|
||||
open_tunnel.return_value[1].close.assert_called_once_with()
|
||||
|
||||
def test_a_tcp_hosts_console_is_reached_where_it_listens(self):
|
||||
for console_host, reached in (("0.0.0.0", "kvm1.example.com"), ("::", "kvm1.example.com"),
|
||||
(None, "kvm1.example.com"), ("192.0.2.40", "192.0.2.40")):
|
||||
with self.subTest(console_host=console_host), patch.object(
|
||||
novncd_mod, "get_connection_infos",
|
||||
return_value=("kvm1.example.com", "", 1,
|
||||
console_host, 5900, None),
|
||||
):
|
||||
handler = self.ConsoleClient()
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
socket_factory.assert_called_once_with(reached, 5900, connect=True)
|
||||
|
||||
def test_a_tcp_hosts_loopback_console_is_refused(self):
|
||||
for console_host in ("127.0.0.1", "::1", "localhost"):
|
||||
with self.subTest(console_host=console_host), patch.object(
|
||||
novncd_mod, "get_connection_infos", return_value=("kvm1.example.com", "", 1, console_host, 5900, None),
|
||||
):
|
||||
handler = self.ConsoleClient()
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
with self.assertRaises(Exception):
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
socket_factory.assert_not_called()
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_compatibility_mixin_missing_token_safe_exit(self, mock_get_info):
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {}
|
||||
handler.path = "/novncd/"
|
||||
handler.msg = MagicMock()
|
||||
|
||||
socket_factory = MagicMock()
|
||||
# a refusal is a warning: it shows in the log without -v
|
||||
with self.assertLogs(level="WARNING") as log:
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
|
||||
mock_get_info.assert_not_called()
|
||||
socket_factory.assert_not_called()
|
||||
self.assertIn("without a token", log.output[0])
|
||||
|
||||
@patch("vrtManager.instance.wvmInstance")
|
||||
def test_get_connection_infos_reads_the_vm_it_was_given(self, mock_wvm):
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.compute.hostname = "127.0.0.1"
|
||||
mock_instance.compute.login = "root"
|
||||
mock_instance.compute.password = ""
|
||||
mock_instance.compute.type = 1
|
||||
mock_instance.name = "test-vm"
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.get_console_listener_addr.return_value = "127.0.0.1"
|
||||
mock_conn.get_console_port.return_value = 5900
|
||||
mock_conn.get_console_socket.return_value = None
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
||||
infos = novncd_mod.get_connection_infos(mock_instance)
|
||||
self.assertEqual(infos[0], "127.0.0.1")
|
||||
self.assertEqual(infos[4], 5900)
|
||||
# the VM access was checked for, not whichever VM has its name now
|
||||
self.assertEqual(mock_wvm.call_args.kwargs["uuid"], mock_instance.uuid)
|
||||
|
||||
def test_the_vnc_server_is_waited_for_a_bounded_time(self):
|
||||
with patch.object(novncd_mod.socket, "create_connection") as create_connection:
|
||||
tsock = novncd_mod.bounded_connect("192.0.2.40", 5900, connect=True)
|
||||
create_connection.assert_called_once_with(("192.0.2.40", 5900), timeout=novncd_mod.CONNECT_TIMEOUT)
|
||||
tsock.settimeout.assert_called_once_with(None)
|
||||
|
||||
def test_a_token_without_its_users_session_is_refused_and_logged(self):
|
||||
from logs.models import Logs
|
||||
|
||||
self.session_user_id.return_value = None # read from a log, used in another browser
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {"Cookie": "sessionid=another", "X-Real-IP": "192.0.2.9"}
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
with patch.object(novncd_mod, "get_connection_infos") as get_info, self.assertLogs(level="WARNING"):
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
get_info.assert_not_called()
|
||||
socket_factory.assert_not_called()
|
||||
self.session_user_id.assert_called_once_with("sessionid=another")
|
||||
row = Logs.objects.get()
|
||||
self.assertEqual((row.user, row.ip, row.success), ("vnc-user", "192.0.2.9", False))
|
||||
self.assertEqual(row.message, "VNC console refused: The console page was opened in another session")
|
||||
|
||||
def test_the_session_names_its_user(self):
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
|
||||
user = get_user_model().objects.create_user("vnc-session", password="pw")
|
||||
self.client.force_login(user)
|
||||
cookie = f"{settings.SESSION_COOKIE_NAME}={self.client.session.session_key}; other=1"
|
||||
self.assertEqual(tokens.session_user_id(cookie), str(user.pk))
|
||||
self.assertIsNone(tokens.session_user_id(f"{settings.SESSION_COOKIE_NAME}={SessionStore().session_key}"))
|
||||
self.assertIsNone(tokens.session_user_id(None))
|
||||
|
||||
def test_a_vnc_session_ends_when_its_user_may_no_longer_open_it(self):
|
||||
tsock, ended = MagicMock(), MagicMock()
|
||||
ended.wait.return_value = False # ACCESS_CHECK passed, the session still open
|
||||
vm = MagicMock()
|
||||
with patch.object(novncd_mod, "may_still_open", side_effect=[True, False]) as may_still_open:
|
||||
novncd_mod.end_when_refused(vm, 7, tsock, ended)
|
||||
self.assertEqual(may_still_open.call_count, 2)
|
||||
may_still_open.assert_called_with(7, vm)
|
||||
tsock.shutdown.assert_called_once_with(novncd_mod.socket.SHUT_RDWR)
|
||||
|
||||
def test_a_token_of_a_serial_console_does_not_open_a_vnc_console(self):
|
||||
user = get_user_model().objects.create_user("vnc-owner", password="pw")
|
||||
compute = Compute.objects.create(name="novnc-type-compute", hostname="127.0.0.1", login="root", password="", type=1)
|
||||
instance = Instance.objects.create(compute=compute, name="novnc-type-vm", uuid="77777777-7777-7777-7777-777777777777")
|
||||
UserInstance.objects.create(instance=instance, user=user)
|
||||
self.assertEqual(tokens.authorize(tokens.sign(compute.id, instance.uuid, user.id, "vnc"), "vnc"), (instance, user))
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(tokens.sign(compute.id, instance.uuid, user.id, "pty"), "vnc")
|
||||
|
||||
@patch("sys.argv", ["novncd"])
|
||||
@override_settings(DEBUG=False)
|
||||
def test_vnc_token_invalid_token_in_production(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize("invalid-raw-token", "vnc")
|
||||
|
||||
def test_vnc_token_unauthorized_user(self):
|
||||
User = get_user_model()
|
||||
unauthorized_user = User.objects.create_user(
|
||||
username="unauthorized_console_user",
|
||||
password="password",
|
||||
)
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-unauth-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-unauth-vm",
|
||||
uuid="33333333-3333-3333-3333-333333333333",
|
||||
)
|
||||
|
||||
token = tokens.sign(compute.id, instance.uuid, unauthorized_user.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
def test_vnc_token_rejects_signed_token_without_user(self):
|
||||
# The console view always signs host:uuid:user_id; a token without the
|
||||
# user part would skip the per-user access check entirely.
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-2part-compute", hostname="127.0.0.1", login="root", password="", type=1
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-2part-vm",
|
||||
uuid="66666666-6666-6666-6666-666666666666",
|
||||
)
|
||||
signer = TimestampSigner(salt="console.novnc")
|
||||
token = signer.sign(f"{compute.id}:{instance.uuid}")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
def test_vnc_token_rejects_unsigned_token_even_under_test_runner(self):
|
||||
# sys.argv contains "test" while this suite runs; production code must
|
||||
# not change behaviour based on that.
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-unsigned-compute", hostname="127.0.0.1", login="root", password="", type=1
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-unsigned-vm",
|
||||
uuid="88888888-8888-8888-8888-888888888888",
|
||||
)
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(f"{compute.id}-{instance.uuid}", "vnc")
|
||||
|
||||
def test_vnc_token_view_instances_user_is_not_enough(self):
|
||||
User = get_user_model()
|
||||
viewer = User.objects.create_user(username="novnc_viewer", password="password")
|
||||
viewer.user_permissions.add(Permission.objects.get(codename="view_instances"))
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-viewer-compute", hostname="127.0.0.1", login="root", password="", type=1
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-viewer-vm",
|
||||
uuid="55555555-5555-5555-5555-555555555555",
|
||||
)
|
||||
|
||||
token = tokens.sign(compute.id, instance.uuid, viewer.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
def test_vnc_token_inactive_user(self):
|
||||
User = get_user_model()
|
||||
inactive_user = User.objects.create_user(
|
||||
username="inactive_console_user",
|
||||
password="password",
|
||||
is_active=False,
|
||||
)
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-inactive-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-inactive-vm",
|
||||
uuid="44444444-4444-4444-4444-444444444444",
|
||||
)
|
||||
UserInstance.objects.create(instance=instance, user=inactive_user)
|
||||
|
||||
token = tokens.sign(compute.id, instance.uuid, inactive_user.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
@patch("sys.argv", ["novncd"])
|
||||
@override_settings(DEBUG=True)
|
||||
def test_vnc_token_debug_true_does_not_bypass(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize("invalid-token-in-debug", "vnc")
|
||||
|
||||
@patch("sys.argv", ["novncd"])
|
||||
def test_vnc_token_expired_token(self):
|
||||
User = get_user_model()
|
||||
user = User.objects.filter(is_superuser=True).first()
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-expired-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-expired-vm",
|
||||
uuid="55555555-5555-5555-5555-555555555555",
|
||||
)
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
# Sign token 300 seconds in the past (> 120s TTL)
|
||||
with patch("time.time", return_value=time.time() - 300):
|
||||
expired_token = tokens.sign(compute.id, instance.uuid, user.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(expired_token, "vnc")
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"""novncd that cannot listen ends with a failure status, which systemd restarts."""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
class NovncdExitTestCase(SimpleTestCase):
|
||||
def test_a_taken_port_ends_novncd_with_a_failure_status(self):
|
||||
with socket.socket() as taken:
|
||||
taken.bind(("127.0.0.1", 0))
|
||||
taken.listen()
|
||||
port = taken.getsockname()[1]
|
||||
novncd = subprocess.run([sys.executable, str(ROOT / "console" / "novncd"), "-v", "-H", "127.0.0.1",
|
||||
"-p", str(port)], cwd=ROOT, capture_output=True, timeout=60)
|
||||
self.assertNotEqual(novncd.returncode, 0)
|
||||
self.assertIn(b"Openening socket failed", novncd.stderr)
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
# pylint: disable=no-member
|
||||
import importlib.machinery
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from appsettings.models import AppSettings
|
||||
from computes.models import Compute
|
||||
from console import tokens
|
||||
from console.views import LAYOUTS_DIR
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
|
|
@ -20,17 +17,6 @@ from instances.models import Instance
|
|||
from libvirt import libvirtError
|
||||
from lxml import html
|
||||
|
||||
# Dynamically load console/novncd script for testing
|
||||
NOVNCD_PATH = str(Path(__file__).resolve().parent.parent / "novncd")
|
||||
_loader = importlib.machinery.SourceFileLoader("novncd_mod", NOVNCD_PATH)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"novncd_mod", NOVNCD_PATH, loader=_loader
|
||||
)
|
||||
assert _spec is not None
|
||||
assert _spec.loader is not None
|
||||
novncd_mod = importlib.util.module_from_spec(_spec)
|
||||
_loader.exec_module(novncd_mod)
|
||||
|
||||
|
||||
def vnc_config(response):
|
||||
"""What the VNC page's script starts from."""
|
||||
|
|
@ -400,363 +386,3 @@ class ConsoleViewsTestCase(TestCase):
|
|||
content,
|
||||
f"Expected key element id '{key_id}' in console menu",
|
||||
)
|
||||
|
||||
|
||||
class NovncdDaemonLogicTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.get_parser = novncd_mod.get_parser
|
||||
self.ConsoleClient = novncd_mod.ConsoleClient
|
||||
user = MagicMock(pk=7)
|
||||
user.username = "vnc-user"
|
||||
for name, value in (("authorize", (MagicMock(), user)), ("session_user_id", "7")):
|
||||
patcher = patch.object(novncd_mod, name, return_value=value)
|
||||
setattr(self, name, patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = patch.object(novncd_mod.ConsoleClient, "headers", {}, create=True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_novncd_parser_defaults(self):
|
||||
parser = self.get_parser()
|
||||
opts = parser.parse_args([])
|
||||
self.assertFalse(opts.verbose)
|
||||
self.assertFalse(opts.debug)
|
||||
self.assertEqual(opts.port, 6080)
|
||||
|
||||
def test_novncd_parser_custom_arguments(self):
|
||||
parser = self.get_parser()
|
||||
opts = parser.parse_args(["-v", "-d", "-H", "192.168.1.100", "-p", "7070"])
|
||||
self.assertTrue(opts.verbose)
|
||||
self.assertTrue(opts.debug)
|
||||
self.assertEqual(opts.host, "192.168.1.100")
|
||||
self.assertEqual(opts.port, 7070)
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_a_cookie_does_not_choose_the_console(self, mock_get_info):
|
||||
# another tab's console in a cookie: the URL's own token is used
|
||||
mock_get_info.return_value = ("host1", "root", 1, "192.168.1.50", 5900, None)
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {"cookie": "token=1-uuid-of-another-tab"}
|
||||
handler.path = "/novncd/?token=1-uuid-of-this-tab"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
handler._new_client(socket_factory=MagicMock())
|
||||
self.authorize.assert_called_once_with("1-uuid-of-this-tab", "vnc")
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_compatibility_mixin_token_from_query_params(self, mock_get_info):
|
||||
mock_get_info.return_value = (
|
||||
"host2",
|
||||
"root",
|
||||
1,
|
||||
"192.168.1.50",
|
||||
5901,
|
||||
None,
|
||||
)
|
||||
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {}
|
||||
handler.path = "/novncd/?token=1-uuid-from-query"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
|
||||
self.authorize.assert_called_once_with("1-uuid-from-query", "vnc")
|
||||
# the address the console listens on
|
||||
socket_factory.assert_called_once_with("192.168.1.50", 5901, connect=True)
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_a_local_socket_host_reaches_a_console_on_its_loopback(self, mock_get_info):
|
||||
# libvirt on the panel's host, the VM's console listening on 127.0.0.1
|
||||
mock_get_info.return_value = ("localhost", "", novncd_mod.CONN_SOCKET, "127.0.0.1", 5902, None)
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {}
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
socket_factory.assert_called_once_with("127.0.0.1", 5902, connect=True)
|
||||
|
||||
def test_an_ssh_hosts_console_always_goes_through_the_tunnel(self):
|
||||
# whatever address the console listens on, the VNC stream is not sent unencrypted
|
||||
for console_host, console_socket, target in (
|
||||
("192.0.2.40", None, "192.0.2.40:5900"),
|
||||
("0.0.0.0", None, "127.0.0.1:5900"),
|
||||
("::", None, "[::1]:5900"),
|
||||
("127.0.0.1", None, "127.0.0.1:5900"),
|
||||
(None, "/run/libvirt/qemu/vnc.sock", "/run/libvirt/qemu/vnc.sock"),
|
||||
):
|
||||
with self.subTest(console_host=console_host), patch.object(
|
||||
novncd_mod, "get_connection_infos",
|
||||
return_value=("kvm1.example.com", "root", novncd_mod.CONN_SSH, console_host, 5900, console_socket),
|
||||
), patch.object(novncd_mod, "open_tunnel", return_value=(MagicMock(), MagicMock())) as open_tunnel:
|
||||
handler = self.ConsoleClient()
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
open_tunnel.assert_called_once_with("kvm1.example.com", "root", target)
|
||||
socket_factory.assert_not_called()
|
||||
# the tunnel ends with the session
|
||||
open_tunnel.return_value[1].close.assert_called_once_with()
|
||||
|
||||
def test_a_tcp_hosts_console_is_reached_where_it_listens(self):
|
||||
for console_host, reached in (("0.0.0.0", "kvm1.example.com"), ("::", "kvm1.example.com"),
|
||||
(None, "kvm1.example.com"), ("192.0.2.40", "192.0.2.40")):
|
||||
with self.subTest(console_host=console_host), patch.object(
|
||||
novncd_mod, "get_connection_infos",
|
||||
return_value=("kvm1.example.com", "", 1,
|
||||
console_host, 5900, None),
|
||||
):
|
||||
handler = self.ConsoleClient()
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
handler.do_proxy = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
socket_factory.assert_called_once_with(reached, 5900, connect=True)
|
||||
|
||||
def test_a_tcp_hosts_loopback_console_is_refused(self):
|
||||
for console_host in ("127.0.0.1", "::1", "localhost"):
|
||||
with self.subTest(console_host=console_host), patch.object(
|
||||
novncd_mod, "get_connection_infos", return_value=("kvm1.example.com", "", 1, console_host, 5900, None),
|
||||
):
|
||||
handler = self.ConsoleClient()
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
with self.assertRaises(Exception):
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
socket_factory.assert_not_called()
|
||||
|
||||
@patch.object(novncd_mod, "get_connection_infos")
|
||||
def test_compatibility_mixin_missing_token_safe_exit(self, mock_get_info):
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {}
|
||||
handler.path = "/novncd/"
|
||||
handler.msg = MagicMock()
|
||||
|
||||
socket_factory = MagicMock()
|
||||
# a refusal is a warning: it shows in the log without -v
|
||||
with self.assertLogs(level="WARNING") as log:
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
|
||||
mock_get_info.assert_not_called()
|
||||
socket_factory.assert_not_called()
|
||||
self.assertIn("without a token", log.output[0])
|
||||
|
||||
@patch("vrtManager.instance.wvmInstance")
|
||||
def test_get_connection_infos_reads_the_vm_it_was_given(self, mock_wvm):
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.compute.hostname = "127.0.0.1"
|
||||
mock_instance.compute.login = "root"
|
||||
mock_instance.compute.password = ""
|
||||
mock_instance.compute.type = 1
|
||||
mock_instance.name = "test-vm"
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.get_console_listener_addr.return_value = "127.0.0.1"
|
||||
mock_conn.get_console_port.return_value = 5900
|
||||
mock_conn.get_console_socket.return_value = None
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
||||
infos = novncd_mod.get_connection_infos(mock_instance)
|
||||
self.assertEqual(infos[0], "127.0.0.1")
|
||||
self.assertEqual(infos[4], 5900)
|
||||
# the VM access was checked for, not whichever VM has its name now
|
||||
self.assertEqual(mock_wvm.call_args.kwargs["uuid"], mock_instance.uuid)
|
||||
|
||||
def test_the_vnc_server_is_waited_for_a_bounded_time(self):
|
||||
with patch.object(novncd_mod.socket, "create_connection") as create_connection:
|
||||
tsock = novncd_mod.bounded_connect("192.0.2.40", 5900, connect=True)
|
||||
create_connection.assert_called_once_with(("192.0.2.40", 5900), timeout=novncd_mod.CONNECT_TIMEOUT)
|
||||
tsock.settimeout.assert_called_once_with(None)
|
||||
|
||||
def test_a_token_without_its_users_session_is_refused_and_logged(self):
|
||||
from logs.models import Logs
|
||||
|
||||
self.session_user_id.return_value = None # read from a log, used in another browser
|
||||
handler = self.ConsoleClient()
|
||||
handler.headers = {"Cookie": "sessionid=another", "X-Real-IP": "192.0.2.9"}
|
||||
handler.path = "/novncd/?token=1-uuid"
|
||||
handler.msg = MagicMock()
|
||||
socket_factory = MagicMock()
|
||||
with patch.object(novncd_mod, "get_connection_infos") as get_info, self.assertLogs(level="WARNING"):
|
||||
handler._new_client(socket_factory=socket_factory)
|
||||
get_info.assert_not_called()
|
||||
socket_factory.assert_not_called()
|
||||
self.session_user_id.assert_called_once_with("sessionid=another")
|
||||
row = Logs.objects.get()
|
||||
self.assertEqual((row.user, row.ip, row.success), ("vnc-user", "192.0.2.9", False))
|
||||
self.assertEqual(row.message, "VNC console refused: The console page was opened in another session")
|
||||
|
||||
def test_the_session_names_its_user(self):
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
|
||||
user = get_user_model().objects.create_user("vnc-session", password="pw")
|
||||
self.client.force_login(user)
|
||||
cookie = f"{settings.SESSION_COOKIE_NAME}={self.client.session.session_key}; other=1"
|
||||
self.assertEqual(tokens.session_user_id(cookie), str(user.pk))
|
||||
self.assertIsNone(tokens.session_user_id(f"{settings.SESSION_COOKIE_NAME}={SessionStore().session_key}"))
|
||||
self.assertIsNone(tokens.session_user_id(None))
|
||||
|
||||
def test_a_vnc_session_ends_when_its_user_may_no_longer_open_it(self):
|
||||
tsock, ended = MagicMock(), MagicMock()
|
||||
ended.wait.return_value = False # ACCESS_CHECK passed, the session still open
|
||||
vm = MagicMock()
|
||||
with patch.object(novncd_mod, "may_still_open", side_effect=[True, False]) as may_still_open:
|
||||
novncd_mod.end_when_refused(vm, 7, tsock, ended)
|
||||
self.assertEqual(may_still_open.call_count, 2)
|
||||
may_still_open.assert_called_with(7, vm)
|
||||
tsock.shutdown.assert_called_once_with(novncd_mod.socket.SHUT_RDWR)
|
||||
|
||||
def test_a_token_of_a_serial_console_does_not_open_a_vnc_console(self):
|
||||
user = get_user_model().objects.create_user("vnc-owner", password="pw")
|
||||
compute = Compute.objects.create(name="novnc-type-compute", hostname="127.0.0.1", login="root", password="", type=1)
|
||||
instance = Instance.objects.create(compute=compute, name="novnc-type-vm", uuid="77777777-7777-7777-7777-777777777777")
|
||||
UserInstance.objects.create(instance=instance, user=user)
|
||||
self.assertEqual(tokens.authorize(tokens.sign(compute.id, instance.uuid, user.id, "vnc"), "vnc"), (instance, user))
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(tokens.sign(compute.id, instance.uuid, user.id, "pty"), "vnc")
|
||||
|
||||
@patch("sys.argv", ["novncd"])
|
||||
@override_settings(DEBUG=False)
|
||||
def test_vnc_token_invalid_token_in_production(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize("invalid-raw-token", "vnc")
|
||||
|
||||
def test_vnc_token_unauthorized_user(self):
|
||||
User = get_user_model()
|
||||
unauthorized_user = User.objects.create_user(
|
||||
username="unauthorized_console_user",
|
||||
password="password",
|
||||
)
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-unauth-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-unauth-vm",
|
||||
uuid="33333333-3333-3333-3333-333333333333",
|
||||
)
|
||||
|
||||
token = tokens.sign(compute.id, instance.uuid, unauthorized_user.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
def test_vnc_token_rejects_signed_token_without_user(self):
|
||||
# The console view always signs host:uuid:user_id; a token without the
|
||||
# user part would skip the per-user access check entirely.
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-2part-compute", hostname="127.0.0.1", login="root", password="", type=1
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-2part-vm",
|
||||
uuid="66666666-6666-6666-6666-666666666666",
|
||||
)
|
||||
signer = TimestampSigner(salt="console.novnc")
|
||||
token = signer.sign(f"{compute.id}:{instance.uuid}")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
def test_vnc_token_rejects_unsigned_token_even_under_test_runner(self):
|
||||
# sys.argv contains "test" while this suite runs; production code must
|
||||
# not change behaviour based on that.
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-unsigned-compute", hostname="127.0.0.1", login="root", password="", type=1
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-unsigned-vm",
|
||||
uuid="88888888-8888-8888-8888-888888888888",
|
||||
)
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(f"{compute.id}-{instance.uuid}", "vnc")
|
||||
|
||||
def test_vnc_token_view_instances_user_is_not_enough(self):
|
||||
User = get_user_model()
|
||||
viewer = User.objects.create_user(username="novnc_viewer", password="password")
|
||||
viewer.user_permissions.add(Permission.objects.get(codename="view_instances"))
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-viewer-compute", hostname="127.0.0.1", login="root", password="", type=1
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-viewer-vm",
|
||||
uuid="55555555-5555-5555-5555-555555555555",
|
||||
)
|
||||
|
||||
token = tokens.sign(compute.id, instance.uuid, viewer.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
def test_vnc_token_inactive_user(self):
|
||||
User = get_user_model()
|
||||
inactive_user = User.objects.create_user(
|
||||
username="inactive_console_user",
|
||||
password="password",
|
||||
is_active=False,
|
||||
)
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-inactive-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-inactive-vm",
|
||||
uuid="44444444-4444-4444-4444-444444444444",
|
||||
)
|
||||
UserInstance.objects.create(instance=instance, user=inactive_user)
|
||||
|
||||
token = tokens.sign(compute.id, instance.uuid, inactive_user.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token, "vnc")
|
||||
|
||||
@patch("sys.argv", ["novncd"])
|
||||
@override_settings(DEBUG=True)
|
||||
def test_vnc_token_debug_true_does_not_bypass(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize("invalid-token-in-debug", "vnc")
|
||||
|
||||
@patch("sys.argv", ["novncd"])
|
||||
def test_vnc_token_expired_token(self):
|
||||
User = get_user_model()
|
||||
user = User.objects.filter(is_superuser=True).first()
|
||||
compute = Compute.objects.create(
|
||||
name="novnc-expired-compute",
|
||||
hostname="127.0.0.1",
|
||||
login="root",
|
||||
password="",
|
||||
type=1,
|
||||
)
|
||||
instance = Instance.objects.create(
|
||||
compute=compute,
|
||||
name="novnc-expired-vm",
|
||||
uuid="55555555-5555-5555-5555-555555555555",
|
||||
)
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
# Sign token 300 seconds in the past (> 120s TTL)
|
||||
with patch("time.time", return_value=time.time() - 300):
|
||||
expired_token = tokens.sign(compute.id, instance.uuid, user.id, "vnc")
|
||||
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(expired_token, "vnc")
|
||||
|
|
|
|||
|
|
@ -28,3 +28,9 @@ def render_create_page(testcase, data=None):
|
|||
else:
|
||||
response = testcase.client.post(url, {"create": "", **data})
|
||||
return compute, response
|
||||
|
||||
|
||||
# an API create request (CreateInstanceSerializer) that the job takes
|
||||
BODY = {"name": "api-vm", "vcpu": 1, "vcpu_mode": "host-model", "memory": 512, "networks": "default", "mac": "",
|
||||
"nwfilter": "", "storage": "default", "hdd_size": 1, "cache_mode": "none", "meta_prealloc": False,
|
||||
"virtio": True, "qemu_ga": True, "video": "vga", "listener_addr": "0.0.0.0", "submission_key": "key-api00001"}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from operations.jobs import Context
|
|||
from operations.models import Operation, acquire
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
from instances.tests.test_create_job import BODY
|
||||
from instances.tests.create_page import BODY
|
||||
|
||||
SEED = "/srv/default/cidata-x.iso"
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from django.contrib.messages import get_messages
|
|||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from vrtManager.instance import PERSISTENT_XML, wvmInstance
|
||||
|
||||
|
||||
class ConsoleListenerTests(TestCase):
|
||||
|
|
@ -83,29 +82,3 @@ class ConsoleListenerTests(TestCase):
|
|||
response = self.client.get(reverse("instances:instance", args=[self.vm.id]))
|
||||
form = response.context["console_form"]
|
||||
self.assertTrue(form.fields["listen_on"].disabled)
|
||||
|
||||
|
||||
class ConsoleListensLocallyTests(TestCase):
|
||||
"""Only a binding the persistent XML pins to loopback (or a Unix socket) is local."""
|
||||
|
||||
def listens_locally(self, graphics):
|
||||
proxy = wvmInstance.__new__(wvmInstance) # no connection: only the XML is read
|
||||
xml = f"<domain><devices>{graphics}</devices></domain>"
|
||||
proxy._XMLDesc = lambda flags: xml if flags == PERSISTENT_XML else "<wrong-flags/>"
|
||||
return proxy.console_listens_locally()
|
||||
|
||||
def test_cases(self):
|
||||
cases = {
|
||||
"<graphics type='vnc' listen='127.0.0.1'><listen type='address' address='127.0.0.1'/></graphics>": True,
|
||||
"<graphics type='vnc'><listen type='address' address='::1'/></graphics>": True,
|
||||
"<graphics type='vnc'><listen type='socket' socket='/run/vnc.sock'/></graphics>": True,
|
||||
"": True, # no console
|
||||
"<graphics type='vnc' listen='0.0.0.0'><listen type='address' address='0.0.0.0'/></graphics>": False,
|
||||
"<graphics type='vnc'><listen type='network' network='default'/></graphics>": False,
|
||||
"<graphics type='vnc' autoport='yes'/>": False, # the host default decides
|
||||
"<graphics type='vnc'><listen type='address' address='localhost'/></graphics>": False,
|
||||
"<graphics type='vnc' listen='127.0.0.1'><listen type='address' address='10.0.0.5'/></graphics>": False,
|
||||
}
|
||||
for graphics, expected in cases.items():
|
||||
with self.subTest(graphics=graphics):
|
||||
self.assertIs(self.listens_locally(graphics), expected)
|
||||
|
|
|
|||
|
|
@ -2,43 +2,12 @@
|
|||
model, checked by libvirt before the start. That the host offers it is
|
||||
checked when the job is submitted (test_create_job)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from appsettings.models import AppSettings
|
||||
from appsettings.settings import get_settings
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import TestCase
|
||||
from instances.tests.create_page import render_create_page
|
||||
from lxml import etree, html
|
||||
from vrtManager.create import wvmCreate
|
||||
|
||||
DOM_CAPS = {"domain": "kvm", "os_support": "no", "cpu_modes": ["host-passthrough", "maximum", "host-model", "custom"],
|
||||
"cpu_custom_models": ["Skylake-Server-noTSX-IBRS", "SandyBridge-IBRS"]}
|
||||
|
||||
|
||||
def defined_cpu(vcpu_mode):
|
||||
conn = wvmCreate.__new__(wvmCreate)
|
||||
conn.wvm = MagicMock()
|
||||
with patch.object(wvmCreate, "get_capabilities", return_value={"features": []}), \
|
||||
patch.object(wvmCreate, "get_dom_capabilities", return_value=DOM_CAPS):
|
||||
conn.create_instance(name="vm-1", memory=512, vcpu=1, vcpu_mode=vcpu_mode,
|
||||
uuid="00000000-0000-4000-8000-0000000000c1", arch="x86_64", machine="q35",
|
||||
firmware="BIOS", volumes=[], networks="", nwfilter="", virtio=True, listener_addr="0.0.0.0",
|
||||
add_cdrom="None", add_input="None")
|
||||
return etree.fromstring(conn.wvm.defineXML.call_args.args[0]).find("cpu")
|
||||
|
||||
|
||||
class CpuModeTests(SimpleTestCase):
|
||||
def test_a_mode_is_written_as_the_mode(self):
|
||||
for mode in ("host-model", "host-passthrough", "maximum"):
|
||||
with self.subTest(mode=mode):
|
||||
cpu = defined_cpu(mode)
|
||||
self.assertEqual((dict(cpu.attrib), len(cpu)), ({"mode": mode}, 0))
|
||||
|
||||
def test_a_named_model_is_that_model_checked_by_libvirt(self):
|
||||
# a name longer than 20 characters too
|
||||
cpu = defined_cpu("Skylake-Server-noTSX-IBRS")
|
||||
self.assertEqual(dict(cpu.attrib), {"mode": "custom", "match": "exact"}) # no check='none'
|
||||
self.assertEqual((cpu.findtext("model"), cpu.find("model").get("fallback")), ("Skylake-Server-noTSX-IBRS", "forbid"))
|
||||
from lxml import html
|
||||
|
||||
|
||||
class CpuModeSelectTests(TestCase):
|
||||
|
|
|
|||
|
|
@ -13,13 +13,10 @@ from appsettings.settings import get_settings
|
|||
from computes.models import Compute
|
||||
from instances.jobs import CreateInstance
|
||||
from instances.models import Instance
|
||||
from instances.tests.create_page import BODY
|
||||
from operations.jobs import Context
|
||||
from operations.models import Operation, acquire
|
||||
|
||||
BODY = {"name": "api-vm", "vcpu": 1, "vcpu_mode": "host-model", "memory": 512, "networks": "default", "mac": "",
|
||||
"nwfilter": "", "storage": "default", "hdd_size": 1, "cache_mode": "none", "meta_prealloc": False,
|
||||
"virtio": True, "qemu_ga": True, "video": "vga", "listener_addr": "0.0.0.0", "submission_key": "key-api00001"}
|
||||
|
||||
|
||||
class CreateJobTestCase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
|
|
|
|||
|
|
@ -3,274 +3,22 @@ named once whether the running VM or its next start has it; the checks that
|
|||
keep a device to one VM and away from what the host uses itself, also for a
|
||||
snapshot revert; clones get none, and a VM with any is not migrated."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import libvirt
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import etree, html
|
||||
from nodedevs.tests.test_nodedevs import NODES, node
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
from vrtManager.tests.test_migration import FakeSource, destination, with_disks
|
||||
|
||||
MIGRATABLE = libvirt.VIR_DOMAIN_XML_MIGRATABLE
|
||||
CONTROLLER = "<controller type='usb' model='qemu-xhci'/>"
|
||||
PCI_05 = ("<hostdev mode='subsystem' type='pci' managed='yes'><source>"
|
||||
"<address domain='0x0000' bus='0x05' slot='0x00' function='0x0'/></source></hostdev>")
|
||||
BY_MODEL = "<hostdev mode='subsystem' type='usb' managed='yes'><source{auto}><vendor id='0x0627'/><product id='0x0001'/>{address}</source></hostdev>"
|
||||
|
||||
|
||||
def by_model(address="", auto=""):
|
||||
return BY_MODEL.format(address=address, auto=auto)
|
||||
|
||||
|
||||
def domain(*devices):
|
||||
return f"<domain><name>kiosk-1</name><devices>{''.join(devices)}</devices></domain>"
|
||||
|
||||
|
||||
def vm(running, saved, active=True, migratable=None):
|
||||
"""A wvmInstance whose running VM is running, as XMLDesc gives it (and as
|
||||
the migratable XML marks it), and whose next start is saved."""
|
||||
proxy = wvmInstance.__new__(wvmInstance)
|
||||
proxy._read_cache = None
|
||||
proxy.instance = MagicMock(**{"isActive.return_value": active, "hasManagedSaveImage.return_value": 0,
|
||||
"info.return_value": [libvirt.VIR_DOMAIN_RUNNING if active else libvirt.VIR_DOMAIN_SHUTOFF]})
|
||||
|
||||
def describe(flags=0):
|
||||
if flags & libvirt.VIR_DOMAIN_XML_INACTIVE:
|
||||
return saved
|
||||
return migratable if flags & MIGRATABLE and migratable else running
|
||||
proxy.instance.XMLDesc.side_effect = describe
|
||||
return proxy
|
||||
|
||||
|
||||
class UsbHostdevTests(SimpleTestCase):
|
||||
def test_a_device_chosen_by_model_is_one_row_with_the_device_it_holds(self):
|
||||
# the running VM writes the address libvirt resolved; only its migratable XML marks it
|
||||
proxy = vm(domain(CONTROLLER, by_model("<address bus='1' device='3'/>")), domain(CONTROLLER, by_model()),
|
||||
migratable=domain(CONTROLLER, by_model("<address bus='1' device='3'/>", " autoAddress='yes'")))
|
||||
usb, pci, others = proxy.get_hostdevs()
|
||||
self.assertEqual(usb, [{"vendor": "0x0627", "product": "0x0001", "address": None, "now": (1, 3), "next": True}])
|
||||
self.assertEqual((pci, others), ([], []))
|
||||
|
||||
def test_a_device_is_taken_from_each_definition_as_it_describes_it(self):
|
||||
proxy = vm(domain(CONTROLLER, by_model("<address bus='1' device='3'/>")), domain(CONTROLLER, by_model()),
|
||||
migratable=domain(CONTROLLER, by_model("<address bus='1' device='3'/>", " autoAddress='yes'")))
|
||||
proxy.delete_usb_hostdev(("0x0627", "0x0001", None))
|
||||
live, config = proxy.instance.detachDeviceFlags.call_args_list
|
||||
self.assertEqual(live.args[1], libvirt.VIR_DOMAIN_AFFECT_LIVE)
|
||||
self.assertEqual(etree.fromstring(live.args[0]).xpath("string(source/address/@device)"), "3")
|
||||
self.assertEqual((config.args[1], etree.fromstring(config.args[0]).find("source/address")),
|
||||
(libvirt.VIR_DOMAIN_AFFECT_CONFIG, None))
|
||||
with self.assertRaisesRegex(OperationError, "no such USB device"):
|
||||
proxy.delete_usb_hostdev(("0x0627", "0x0001", (1, 2)))
|
||||
|
||||
def test_a_device_is_given_by_model_or_by_address_with_a_usb_controller(self):
|
||||
proxy = vm(domain(CONTROLLER), domain(CONTROLLER))
|
||||
proxy.add_usb_hostdev("0x0627", "0x0001", (1, 3))
|
||||
added = etree.fromstring(proxy.instance.attachDeviceFlags.call_args.args[0])
|
||||
self.assertEqual((added.get("type"), added.xpath("string(source/vendor/@id)"), added.xpath("string(source/address/@device)")),
|
||||
("usb", "0x0627", "3"))
|
||||
for xml in (domain(), domain("<controller type='usb' model='none'/>")):
|
||||
proxy = vm(xml, xml, active=False)
|
||||
with self.subTest(xml=xml), self.assertRaisesRegex(OperationError, "no USB controller"):
|
||||
proxy.add_usb_hostdev("0x0627", "0x0001")
|
||||
proxy.instance.attachDeviceFlags.assert_not_called()
|
||||
|
||||
def test_a_device_the_host_has_one_of_is_given_by_model(self):
|
||||
proxy = vm(domain(CONTROLLER), domain(CONTROLLER))
|
||||
proxy.add_usb_hostdev("0x0627", "0x0001")
|
||||
self.assertIsNone(etree.fromstring(proxy.instance.attachDeviceFlags.call_args.args[0]).find("source/address"))
|
||||
|
||||
def test_other_host_devices_as_the_running_vm_and_its_next_start_have_them(self):
|
||||
pci = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x0a' function='0x0'/></source></hostdev>"
|
||||
nic = "<interface type='hostdev'><source><address type='pci' domain='0x0000' bus='0x00' slot='0x0b' function='0x0'/></source></interface>"
|
||||
network_nic = ("<interface type='network'><source network='sriov'/><actual type='hostdev'><source>"
|
||||
"<address type='pci' domain='0x0000' bus='0x00' slot='0x0c' function='0x1'/></source></actual></interface>")
|
||||
mdev = "<hostdev mode='subsystem' type='mdev'><source><address uuid='{}'/></source></hostdev>"
|
||||
running = domain(pci, nic, network_nic, mdev.format("00000000-0000-4000-8000-00000000a001"))
|
||||
_, pci_rows, others = vm(running, domain(nic, mdev.format("00000000-0000-4000-8000-00000000a002"))).get_hostdevs()
|
||||
self.assertEqual(pci_rows, [{"address": "0000:00:0a.0", "now": True, "next": False, "managed": False}])
|
||||
self.assertEqual(others, [{"type": "nic", "address": "0000:00:0b.0", "now": True, "next": True},
|
||||
{"type": "nic", "address": "0000:00:0c.1", "now": True, "next": False},
|
||||
{"type": "mdev", "address": "00000000-0000-4000-8000-00000000a001", "now": True, "next": False},
|
||||
{"type": "mdev", "address": "00000000-0000-4000-8000-00000000a002", "now": False, "next": True}])
|
||||
|
||||
def test_a_shut_off_vm_gets_a_pci_device_for_its_next_start(self):
|
||||
proxy = vm("", domain(), active=False)
|
||||
proxy.add_pci_hostdev("0000:05:00.0")
|
||||
xml, flags = proxy.instance.attachDeviceFlags.call_args.args
|
||||
self.assertEqual(flags, libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
self.assertEqual((etree.fromstring(xml).get("managed"), dict(etree.fromstring(xml).find("source/address").attrib)),
|
||||
("yes", {"domain": "0x0000", "bus": "0x05", "slot": "0x00", "function": "0x0"}))
|
||||
proxy = vm("", domain(PCI_05), active=False)
|
||||
self.assertFalse(proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual([c.args[1] for c in proxy.instance.detachDeviceFlags.call_args_list], [libvirt.VIR_DOMAIN_AFFECT_CONFIG])
|
||||
with self.assertRaisesRegex(OperationError, "no PCI device 0000:06:00.0"):
|
||||
proxy.delete_pci_hostdev("0000:06:00.0")
|
||||
|
||||
def test_a_paused_vm_or_one_with_a_saved_state_keeps_its_pci_devices(self):
|
||||
paused = vm(domain(PCI_05), domain(PCI_05))
|
||||
paused.instance.info.return_value = [libvirt.VIR_DOMAIN_PAUSED]
|
||||
saved = vm("", domain(PCI_05), active=False)
|
||||
saved.instance.hasManagedSaveImage.return_value = 1
|
||||
for name, proxy in (("paused", paused), ("saved", saved)):
|
||||
for change in (proxy.add_pci_hostdev, proxy.delete_pci_hostdev):
|
||||
with self.subTest(vm=name, change=change.__name__), self.assertRaisesRegex(OperationError, "runs, or is shut off"):
|
||||
change("0000:05:00.0")
|
||||
proxy.instance.attachDeviceFlags.assert_not_called()
|
||||
proxy.instance.detachDeviceFlags.assert_not_called()
|
||||
|
||||
def test_a_nic_that_is_a_host_device_is_left_to_the_xml_editor(self):
|
||||
nic = "<interface type='hostdev'><mac address='52:54:00:00:00:0a'/><source><address type='pci' bus='0x00' slot='0x0a'/></source></interface>"
|
||||
proxy = vm(domain(nic), domain(nic))
|
||||
with self.assertRaisesRegex(OperationError, "XML editor"):
|
||||
proxy.delete_network("52:54:00:00:00:0a")
|
||||
with self.assertRaisesRegex(OperationError, "XML editor"):
|
||||
proxy.change_network("52:54:00:00:00:0a", "52:54:00:00:00:0b", "default", "net", "virtio", "")
|
||||
proxy.instance.detachDeviceFlags.assert_not_called()
|
||||
|
||||
def test_a_clone_gets_no_host_device_and_its_nics_keep_their_numbers(self):
|
||||
proxy = vm("", domain(by_model(),
|
||||
"<interface type='hostdev'><mac address='52:54:00:00:00:0a'/></interface>",
|
||||
"<interface type='network'><mac address='52:54:00:00:00:0b'/><source network='default'/></interface>"))
|
||||
proxy.get_instances = lambda: []
|
||||
tree, _ = proxy._clone_definition({"name": "kiosk-2", "clone-net-mac-1": "52:54:00:00:00:1b"},
|
||||
"99999999-2222-3333-4444-555555555555")
|
||||
self.assertEqual(tree.xpath("devices/hostdev"), [])
|
||||
self.assertEqual(tree.xpath("devices/interface/mac/@address"), ["52:54:00:00:00:1b"])
|
||||
|
||||
|
||||
class RunningVM:
|
||||
"""A running VM whose definitions change as libvirt changes them: the
|
||||
running one and the next start's, each with the PCI device or not.
|
||||
fail: the affect flag whose change libvirt fails, after doing it or not;
|
||||
keeps: the guest does not let the device go."""
|
||||
|
||||
def __init__(self, now=True, later=True, fail=None, done=False, keeps=False, machine="pc-i440fx-10.1"):
|
||||
self.has = {libvirt.VIR_DOMAIN_AFFECT_LIVE: now, libvirt.VIR_DOMAIN_AFFECT_CONFIG: later}
|
||||
self.fail, self.done, self.keeps, self.machine = fail, done, keeps, machine
|
||||
self.proxy = vm("", "")
|
||||
self.proxy.instance.XMLDesc.side_effect = self.describe
|
||||
self.proxy.instance.attachDeviceFlags.side_effect = lambda xml, flags: self.change(flags, True)
|
||||
self.proxy.instance.detachDeviceFlags.side_effect = lambda xml, flags: self.change(flags, False)
|
||||
|
||||
def describe(self, flags=0):
|
||||
saved = flags & libvirt.VIR_DOMAIN_XML_INACTIVE
|
||||
has = self.has[libvirt.VIR_DOMAIN_AFFECT_CONFIG if saved else libvirt.VIR_DOMAIN_AFFECT_LIVE]
|
||||
return f"<domain><os><type machine='{self.machine}'>hvm</type></os><devices>{PCI_05 if has else ''}</devices></domain>"
|
||||
|
||||
def change(self, flags, adding):
|
||||
for affect in (libvirt.VIR_DOMAIN_AFFECT_LIVE, libvirt.VIR_DOMAIN_AFFECT_CONFIG):
|
||||
if flags & affect:
|
||||
if affect == self.fail:
|
||||
if self.done:
|
||||
self.has[affect] = adding
|
||||
raise libvirt.libvirtError("refused")
|
||||
if adding or not (self.keeps and affect == libvirt.VIR_DOMAIN_AFFECT_LIVE):
|
||||
self.has[affect] = adding
|
||||
|
||||
|
||||
class PciHotplugTests(SimpleTestCase):
|
||||
def test_a_running_vm_gets_it_at_once_and_for_its_next_start(self):
|
||||
running = RunningVM(now=False, later=False)
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
self.assertEqual(running.proxy.instance.attachDeviceFlags.call_args.args[1],
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE | libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
self.assertEqual(list(running.has.values()), [True, True])
|
||||
|
||||
def test_a_hot_plug_that_fails_says_where_the_device_is(self):
|
||||
cases = ((libvirt.VIR_DOMAIN_AFFECT_CONFIG, False, "the running VM has it, its next start does not have it"),
|
||||
(libvirt.VIR_DOMAIN_AFFECT_CONFIG, True, "the running VM has it, its next start has it"),
|
||||
(libvirt.VIR_DOMAIN_AFFECT_LIVE, False, "the running VM does not have it, its next start does not have it"))
|
||||
for fail, done, where in cases:
|
||||
# libvirt plugs into the running VM first, then saves the next start
|
||||
running = RunningVM(now=False, later=False, fail=fail, done=done)
|
||||
if fail == libvirt.VIR_DOMAIN_AFFECT_CONFIG:
|
||||
running.has[libvirt.VIR_DOMAIN_AFFECT_LIVE] = True
|
||||
with self.subTest(where=where), self.assertRaisesRegex(OperationError, f"refused; PCI device 0000:05:00.0: {where}"):
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_hot_plug_whose_outcome_cannot_be_read_says_so(self):
|
||||
running = RunningVM(now=False, later=False)
|
||||
running.proxy.instance.attachDeviceFlags.side_effect = libvirt.libvirtError("connection reset")
|
||||
running.proxy.instance.XMLDesc.side_effect = libvirt.libvirtError("connection reset")
|
||||
with self.assertRaisesRegex(OperationError, "connection reset; whether the VM has PCI device 0000:05:00.0 now is not known"):
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_vm_that_stops_meanwhile_is_said_to_be_not_running(self):
|
||||
running = RunningVM(now=False, later=False, fail=libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
running.proxy.instance.isActive.return_value = False # its live XML is then the inactive one
|
||||
with self.assertRaisesRegex(OperationError, "the VM is not running, its next start does not have it"):
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_running_vm_lets_it_go_as_its_guest_does(self):
|
||||
running = RunningVM()
|
||||
self.assertFalse(running.proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual(list(running.has.values()), [False, False])
|
||||
# not yet: the request stands, the next start no longer has it
|
||||
running = RunningVM(keeps=True)
|
||||
self.assertTrue(running.proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual(list(running.has.values()), [True, False])
|
||||
|
||||
def test_a_guest_that_refuses_keeps_it_for_its_next_start_too(self):
|
||||
running = RunningVM(fail=libvirt.VIR_DOMAIN_AFFECT_LIVE)
|
||||
with self.assertRaisesRegex(OperationError, "refused; .*the running VM has it, its next start has it"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
self.assertEqual(list(running.has.values()), [True, True])
|
||||
# removed from the running VM, then an error: said, and the next start keeps it
|
||||
running = RunningVM(fail=libvirt.VIR_DOMAIN_AFFECT_LIVE, done=True)
|
||||
with self.assertRaisesRegex(OperationError, "the running VM does not have it, its next start has it"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_failed_removal_from_the_next_start_of_a_running_vm_is_said(self):
|
||||
running = RunningVM(now=False, later=True, fail=libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
with self.assertRaisesRegex(OperationError, "the running VM does not have it, its next start has it"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_removal_whose_outcome_cannot_be_read_says_so(self):
|
||||
running = RunningVM()
|
||||
reads = []
|
||||
|
||||
def describe(flags=0): # the reads before the removal work, the one after it does not
|
||||
reads.append(flags)
|
||||
if len(reads) > 2:
|
||||
raise libvirt.libvirtError("connection reset")
|
||||
return running.describe(flags)
|
||||
running.proxy.instance.XMLDesc.side_effect = describe
|
||||
with self.assertRaisesRegex(OperationError, "connection reset; whether the VM has PCI device 0000:05:00.0 now is not known"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_next_start_that_keeps_it_after_the_running_vm_let_it_go_is_said(self):
|
||||
for keeps, where in ((False, "the running VM does not have it, its next start has it"),
|
||||
(True, "the running VM has it, its next start has it")):
|
||||
running = RunningVM(fail=libvirt.VIR_DOMAIN_AFFECT_CONFIG, keeps=keeps)
|
||||
with self.subTest(keeps=keeps), self.assertRaisesRegex(OperationError, where):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_device_in_one_definition_only_is_taken_from_it(self):
|
||||
for now, later in ((True, False), (False, True)):
|
||||
running = RunningVM(now=now, later=later)
|
||||
with self.subTest(now=now, later=later):
|
||||
self.assertFalse(running.proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual(list(running.has.values()), [False, False])
|
||||
self.assertEqual(running.proxy.instance.detachDeviceFlags.call_count, 1)
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_a_vm_with_host_devices_is_not_migrated(self):
|
||||
for devices, state in ((by_model(), 1), (by_model(), 5), ("<hostdev mode='subsystem' type='pci'/>", 1)):
|
||||
source = FakeSource(state, xml=with_disks(devices))
|
||||
with self.subTest(state=state), self.assertRaisesRegex(OperationError, "host devices cannot be migrated"):
|
||||
destination().moveto(source, "vm", live=state == 1, unsafe=False, offline=state == 5)
|
||||
self.assertEqual(source.instance.calls, [])
|
||||
from vrtManager.tests.fakes import by_model, domain
|
||||
from vrtManager.tests.fakes import IOMMU_NODES, NODES, node
|
||||
|
||||
|
||||
class HostUsbViewTests(TestCase):
|
||||
|
|
@ -352,10 +100,9 @@ class HostUsbViewTests(TestCase):
|
|||
"VM kiosk-1: request the removal of PCI device 0000:00:0a.0"])
|
||||
|
||||
def test_a_running_vm_is_given_a_device_alone_a_shut_off_one_with_its_group(self):
|
||||
from nodedevs.tests.test_pci_rules import NODES as GROUPS
|
||||
|
||||
# the next start has the GPU's audio function managed: it goes along when the VM starts, not at a hot plug
|
||||
self.proxy.wvm.listAllDevices.return_value = [node(name, etree.tostring(n)) for name, n in GROUPS.items()]
|
||||
self.proxy.wvm.listAllDevices.return_value = [node(name, etree.tostring(n)) for name, n in IOMMU_NODES.items()]
|
||||
self.proxy.domain_trees.side_effect = lambda: []
|
||||
self.proxy.get_hostdevs.return_value = ([], [{"address": "0000:00:0e.0", "now": False, "next": True, "managed": True}], [])
|
||||
url = reverse("instances:host_devices", args=[self.instance.id])
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class InstanceTabsTestCase(unittest.TestCase):
|
|||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
def test_instance_tabs_js(self):
|
||||
result = subprocess.run(
|
||||
["node", str(BASE / "webvirtcloud/tests/js/instance_tabs.test.js"), str(BASE / "static/js/instance_tabs.js")],
|
||||
["node", str(BASE / "instances/tests/js/instance_tabs.test.js"), str(BASE / "static/js/instance_tabs.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
|
|
@ -4,13 +4,11 @@ import hashlib
|
|||
import os
|
||||
import time
|
||||
import unittest
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import libvirt
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TransactionTestCase
|
||||
from django.test import TransactionTestCase
|
||||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from appsettings.settings import get_settings
|
||||
from computes.models import Compute
|
||||
|
|
@ -20,115 +18,12 @@ from operations.models import Operation
|
|||
from operations.tests import fakes
|
||||
from operations.worker import Worker
|
||||
|
||||
from . import livetest
|
||||
from .test_live_jobs import wait_for
|
||||
from vrtManager.tests import livetest
|
||||
from vrtManager.tests.livetest import wait_for
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
||||
class LiveCloneTestCase(SimpleTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not livetest.enabled():
|
||||
raise unittest.SkipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
cls.args = (
|
||||
os.environ["TEST_LIBVIRT_HOST"],
|
||||
os.environ.get("TEST_LIBVIRT_LOGIN", ""),
|
||||
os.environ.get("TEST_LIBVIRT_PASSWORD", ""),
|
||||
int(os.environ.get("TEST_LIBVIRT_TYPE", 4)),
|
||||
)
|
||||
cls.conn = connection_manager.get_connection(*cls.args)
|
||||
livetest.cleanup(cls.conn)
|
||||
livetest.ensure_pool(cls.conn)
|
||||
cls.inventory = livetest.inventory(cls.conn)
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
try:
|
||||
livetest.remove_pool(cls.conn)
|
||||
finally:
|
||||
super().tearDownClass()
|
||||
if livetest.inventory(cls.conn) != cls.inventory:
|
||||
raise AssertionError("Live tests changed objects outside the test namespace")
|
||||
|
||||
def tearDown(self):
|
||||
livetest.cleanup(self.conn)
|
||||
|
||||
def pool_volumes(self):
|
||||
pool = self.conn.storagePoolLookupByName(livetest.POOL)
|
||||
pool.refresh(0)
|
||||
return set(pool.listVolumes())
|
||||
|
||||
def source(self, name):
|
||||
disks = [livetest.create_volume(self.conn, P + f"{name}-{d}") for d in ("a", "b")]
|
||||
dom = livetest.define_vm(self.conn, P + name, disks)
|
||||
return dom, wvmInstance(*self.args, None, uuid=dom.UUIDString())
|
||||
|
||||
def clone_data(self, name):
|
||||
return {
|
||||
"name": P + name,
|
||||
"disk-vda": P + name + "-a.qcow2",
|
||||
"disk-vdb": P + name + "-b.qcow2",
|
||||
"disk_owner_uid": 0,
|
||||
"disk_owner_gid": 0,
|
||||
}
|
||||
|
||||
def test_failed_clone_removes_only_its_own_copies(self):
|
||||
_, vm = self.source("clone-src")
|
||||
taken = livetest.create_volume(self.conn, P + "clone-dst-b") # destination of vdb exists
|
||||
before = self.pool_volumes()
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
vm.clone_instance(self.clone_data("clone-dst"), "abababab-0000-1111-2222-333333333333")
|
||||
|
||||
self.assertEqual(self.pool_volumes(), before, "copies left behind or a volume deleted")
|
||||
self.assertTrue(livetest.volume_exists(self.conn, taken))
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
self.conn.lookupByName(P + "clone-dst")
|
||||
|
||||
def test_running_vm_is_not_cloned(self):
|
||||
dom, vm = self.source("clone-run")
|
||||
dom.create()
|
||||
before = self.pool_volumes()
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
vm.clone_instance(self.clone_data("clone-run-copy"), "abababab-0000-1111-2222-333333333333")
|
||||
|
||||
self.assertEqual(self.pool_volumes(), before)
|
||||
|
||||
def test_clone_of_a_uefi_vm_has_its_own_copy_of_the_nvram(self):
|
||||
disk = livetest.create_volume(self.conn, P + "clone-uefi-a")
|
||||
dom = livetest.define_vm(self.conn, P + "clone-uefi", [disk], uefi=True)
|
||||
dom.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # libvirt makes the NVRAM file at the first start
|
||||
dom.destroy()
|
||||
vm = wvmInstance(*self.args, None, uuid=dom.UUIDString())
|
||||
source = vm.get_nvram()
|
||||
|
||||
uuid = vm.clone_instance(
|
||||
{"name": P + "clone-uefi-copy", "disk-vda": P + "clone-uefi-copy-a.qcow2", "disk_owner_uid": 0, "disk_owner_gid": 0}, "abababab-0000-1111-2222-333333333333"
|
||||
)
|
||||
|
||||
clone = self.conn.lookupByUUIDString(uuid)
|
||||
path = ElementTree.fromstring(clone.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE)).findtext("os/nvram")
|
||||
self.assertEqual(path, os.path.join(os.path.dirname(source), P + "clone-uefi-copy_VARS.fd"))
|
||||
with vm._nvram_pool() as pool:
|
||||
copy = pool.storageVolLookupByName(os.path.basename(path))
|
||||
self.assertEqual(copy.info()[1], pool.storageVolLookupByName(os.path.basename(source)).info()[1])
|
||||
clone.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # qemu opens the copy
|
||||
self.assertEqual(
|
||||
ElementTree.fromstring(clone.XMLDesc(0)).findtext("os/nvram"), path, "libvirt replaced the copy"
|
||||
)
|
||||
clone.destroy()
|
||||
|
||||
def test_clone_of_a_stopped_vm(self):
|
||||
_, vm = self.source("clone-ok")
|
||||
uuid = vm.clone_instance(self.clone_data("clone-ok-copy"), "abababab-0000-1111-2222-333333333333")
|
||||
clone = self.conn.lookupByUUIDString(uuid)
|
||||
self.assertEqual(clone.name(), P + "clone-ok-copy")
|
||||
self.assertTrue({P + "clone-ok-copy-a.qcow2", P + "clone-ok-copy-b.qcow2"} <= self.pool_volumes())
|
||||
|
||||
|
||||
MIB = 1024 * 1024
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Live checks of VM creation: the queued job defines the VM, and a failed
|
||||
one leaves no volumes behind.
|
||||
|
||||
Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
||||
Runs only with TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -19,8 +19,8 @@ from operations.models import Operation
|
|||
from operations.tests import fakes
|
||||
from operations.worker import Worker
|
||||
|
||||
from . import livetest
|
||||
from .test_live_jobs import wait_for
|
||||
from vrtManager.tests import livetest
|
||||
from vrtManager.tests.livetest import wait_for
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
|
@ -214,4 +214,3 @@ class LiveCreateTestCase(TransactionTestCase):
|
|||
self.work()
|
||||
self.assertEqual(self.volumes(), set(), "a volume was left behind")
|
||||
self.assertFalse(self.domain_exists(P + "cr-api"))
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Each test asserts the safe behavior. Known unfixed cases are marked
|
||||
expectedFailure; a fix turns them into unexpected successes, which fail
|
||||
the run until the marker is removed. Runs only with
|
||||
TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
||||
TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -19,7 +19,7 @@ from django.test import TestCase
|
|||
from django.urls import reverse
|
||||
from vrtManager.connection import connection_manager
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from instances.jobs import DeleteInstance
|
||||
from instances.models import Instance
|
||||
from operations.jobs import Context
|
||||
|
|
@ -671,4 +671,3 @@ class LiveDataLossTestCase(TestCase):
|
|||
inbound = etree.fromstring(dom.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE)).find("devices/interface/bandwidth/inbound")
|
||||
self.assertEqual((inbound.get("average"), inbound.get("peak")), ("1000", "600"))
|
||||
self.assertEqual(dom.XMLDesc(0), live)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ with qemu-guest-agent enabled.
|
|||
The tests boot wvc-test VMs from qcow2 overlays in the test pool, so the
|
||||
image itself is only read. They are skipped while a running VM uses the
|
||||
image, since its content would change under the overlays. Runs only with TEST_LIBVIRT_HOST set as well (see
|
||||
instances/tests/livetest.py).
|
||||
vrtManager/tests/livetest.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -26,7 +26,7 @@ from vrtManager.connection import connection_manager
|
|||
|
||||
from operations.models import Operation
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from .test_create_instance import run
|
||||
from instances.models import Instance
|
||||
from instances.utils import refr
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from django.urls import reverse
|
|||
from lxml import etree
|
||||
from vrtManager.connection import connection_manager
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from instances.models import Instance
|
||||
from instances.utils import refr
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
"""Live checks for the instance jobs: the worker runs them against libvirt
|
||||
and recovery reads the same outcome back. Runs only with TEST_LIBVIRT_HOST
|
||||
set (see instances/tests/livetest.py). The migration needs a second host:
|
||||
set (see vrtManager/tests/livetest.py). The migration needs a second host:
|
||||
TEST_LIBVIRT_SSH_HOST (and _LOGIN), with TEST_LIBVIRT_MIGRATE_ADDRESS as its
|
||||
migration address. The ISO upload runs on both hosts: TCP writes through a
|
||||
libvirt stream, SSH through SFTP."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import libvirt
|
||||
|
|
@ -27,20 +26,12 @@ from operations.models import Operation
|
|||
from operations.tests import fakes
|
||||
from operations.worker import Worker
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from vrtManager.tests.livetest import wait_for
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
||||
def wait_for(predicate, seconds):
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
class LiveJobsTestCase(TransactionTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from django.urls import reverse
|
|||
from lxml import etree
|
||||
from vrtManager.connection import connection_manager
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from instances.models import Instance
|
||||
from instances.utils import refr
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from vrtManager.util import randomUUID
|
|||
|
||||
from operations.models import Operation
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from .test_create_instance import run
|
||||
from instances.models import Flavor, Instance
|
||||
from instances.utils import refr
|
||||
|
|
@ -14,35 +14,10 @@ from instances.models import Instance
|
|||
from logs.models import Logs
|
||||
from libvirt import libvirtError
|
||||
from lxml import etree
|
||||
from vrtManager.create import PCIE_ROOT_PORTS, wvmCreate
|
||||
|
||||
DOM_CAPS = {"domain": "kvm", "os_support": "no", "cpu_modes": ["host-model"], "cpu_custom_models": [],
|
||||
"disk_bus": ["virtio", "sata", "scsi"]}
|
||||
NO_SLOT = libvirtError("internal error: No more available PCI slots")
|
||||
|
||||
|
||||
def defined_controllers(machine):
|
||||
conn = wvmCreate.__new__(wvmCreate)
|
||||
conn.wvm = MagicMock()
|
||||
with patch.object(wvmCreate, "get_capabilities", return_value={"features": []}), \
|
||||
patch.object(wvmCreate, "get_dom_capabilities", return_value=DOM_CAPS):
|
||||
conn.create_instance(name="vm-1", memory=512, vcpu=1, vcpu_mode="host-model",
|
||||
uuid="00000000-0000-4000-8000-0000000000c2", arch="x86_64", machine=machine,
|
||||
firmware="BIOS", volumes=[], networks="default", nwfilter="", virtio=True,
|
||||
listener_addr="0.0.0.0", add_cdrom="None", add_input="None")
|
||||
domain = etree.fromstring(conn.wvm.defineXML.call_args.args[0])
|
||||
return [c.get("model") for c in domain.findall("devices/controller[@type='pci']")]
|
||||
|
||||
|
||||
class NewVmPortsTests(SimpleTestCase):
|
||||
def test_a_new_q35_vm_has_spare_root_ports(self):
|
||||
# libvirt puts the VM's devices into these and adds none: the rest are free
|
||||
self.assertEqual(defined_controllers("pc-q35-rhel9.8.0"), ["pcie-root"] + ["pcie-root-port"] * PCIE_ROOT_PORTS)
|
||||
|
||||
def test_a_pc_vm_has_none_its_pci_bus_has_slots(self):
|
||||
self.assertEqual(defined_controllers("pc-i440fx-rhel7.6.0"), [])
|
||||
|
||||
|
||||
class NoPciSlotTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("ps-admin", "ps@example.com", "x"))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from vrtManager.util import render
|
|||
from libvirt import VIR_DOMAIN_PAUSED, VIR_DOMAIN_RUNNING
|
||||
from operations.models import Operation
|
||||
from vrtManager.util import OperationError
|
||||
import socket
|
||||
|
||||
|
||||
def vm_reader(vcpu=2, memory=1024, disk_gib=10, pending=None):
|
||||
|
|
@ -274,3 +275,20 @@ class DetailPageQuotaTests(TestCase):
|
|||
proxy.get_status.return_value = 5
|
||||
response = self.client.get(reverse("instances:instance", args=[vm.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class QuotaWithDownHostTestCase(TestCase):
|
||||
def test_vm_on_an_unreachable_host_makes_the_quota_unverified(self):
|
||||
# its usage is unknown, so an increase is refused (fail closed), without an error page
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
user = get_user_model().objects.create_user("quota_down", password="pw")
|
||||
UserAttributes.objects.create(user=user, max_instances=5, max_cpus=8, max_memory=8192, max_disk_size=100)
|
||||
compute = Compute.objects.create(name="down", hostname=f"127.0.0.1:{port}", login="", password="", type=1)
|
||||
vm = Instance.objects.create(compute=compute, name="vm", uuid="55555555-5555-5555-5555-555555555555")
|
||||
UserInstance.objects.create(user=user, instance=vm)
|
||||
get_settings() # app settings are normally loaded by middleware
|
||||
|
||||
self.assertEqual(check_user_quota(user, 1, 1, 128, 1), QUOTA_UNVERIFIED)
|
||||
|
|
|
|||
8
interfaces/tests/fakes.py
Normal file
8
interfaces/tests/fakes.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Test data shared by the interface tests: a valid bridge form."""
|
||||
|
||||
|
||||
BASE = {
|
||||
"name": "br1", "itype": "bridge", "start_mode": "onboot", "netdev": "eth1",
|
||||
"ipv4_type": "static", "ipv4_addr": "192.0.2.10/24", "ipv4_gw": "192.0.2.1",
|
||||
"ipv6_type": "static", "stp": "on", "delay": 0,
|
||||
}
|
||||
|
|
@ -1,24 +1,10 @@
|
|||
"""Host interface form: every value is validated before it goes into the interface XML."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import libvirt
|
||||
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.urls import reverse
|
||||
from lxml import etree
|
||||
from vrtManager.interface import wvmInterfaces
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from interfaces.forms import AddInterface
|
||||
|
||||
BASE = {
|
||||
"name": "br1", "itype": "bridge", "start_mode": "onboot", "netdev": "eth1",
|
||||
"ipv4_type": "static", "ipv4_addr": "192.0.2.10/24", "ipv4_gw": "192.0.2.1",
|
||||
"ipv6_type": "static", "stp": "on", "delay": 0,
|
||||
}
|
||||
|
||||
from interfaces.tests.fakes import BASE
|
||||
|
||||
# the host's network devices, as interfaces/views.py passes them
|
||||
NETDEVS = ["eth0", "eth1", "enp129s0f0np0", "eth0.100", "bond-lan"]
|
||||
|
|
@ -71,25 +57,6 @@ class Ipv6FormTestCase(SimpleTestCase):
|
|||
self.assertTrue(form(ipv6_type="dhcp", ipv6_addr="", ipv6_gw="").is_valid())
|
||||
|
||||
|
||||
class Ipv6XmlTestCase(SimpleTestCase):
|
||||
def xml(self, gw):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
conn.get_iface = MagicMock()
|
||||
conn.get_iface.return_value.XMLDesc.return_value = "<interface type='ethernet' name='eth1'/>"
|
||||
conn.create_iface("br1", "bridge", "onboot", "eth1", "none", "", "", "static", "2001:db8::10/64", gw, "on", 0)
|
||||
return etree.fromstring(conn.define_iface.call_args[0][0])
|
||||
|
||||
def test_static_address_with_gateway(self):
|
||||
proto = self.xml("2001:db8::1").find("protocol[@family='ipv6']")
|
||||
self.assertEqual(proto.find("ip").attrib, {"address": "2001:db8::10", "prefix": "64"})
|
||||
self.assertEqual(proto.find("route").get("gateway"), "2001:db8::1")
|
||||
|
||||
def test_no_empty_route_without_gateway(self):
|
||||
self.assertIsNone(self.xml("").find("protocol[@family='ipv6']/route"))
|
||||
|
||||
|
||||
class Ipv4FormTestCase(SimpleTestCase):
|
||||
def test_dhcp_and_none_need_no_address(self):
|
||||
for ipv4_type in ("dhcp", "none"):
|
||||
|
|
@ -156,25 +123,6 @@ class InterfaceNameTestCase(SimpleTestCase):
|
|||
self.assertIn("name", f.errors)
|
||||
|
||||
|
||||
class Ipv4XmlTestCase(SimpleTestCase):
|
||||
def xml(self, addr, gw):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
conn.get_iface = MagicMock()
|
||||
conn.get_iface.return_value.XMLDesc.return_value = "<interface type='ethernet' name='eth1'/>"
|
||||
conn.create_iface("br1", "bridge", "onboot", "eth1", "static", addr, gw, "none", "", "", "on", 0)
|
||||
return etree.fromstring(conn.define_iface.call_args[0][0])
|
||||
|
||||
def test_static_address_with_gateway(self):
|
||||
proto = self.xml("192.0.2.10/24", "192.0.2.1").find("protocol[@family='ipv4']")
|
||||
self.assertEqual(proto.find("ip").attrib, {"address": "192.0.2.10", "prefix": "24"})
|
||||
self.assertEqual(proto.find("route").get("gateway"), "192.0.2.1")
|
||||
|
||||
def test_no_empty_route_without_gateway(self):
|
||||
self.assertIsNone(self.xml("192.0.2.10/24", "").find("protocol[@family='ipv4']/route"))
|
||||
|
||||
|
||||
class HostDeviceTestCase(SimpleTestCase):
|
||||
def test_a_bridge_member_must_be_a_host_device(self):
|
||||
for netdev in ("eth9", "eth0:1"):
|
||||
|
|
@ -202,89 +150,6 @@ class HostDeviceTestCase(SimpleTestCase):
|
|||
self.assertIn("name", f.errors)
|
||||
|
||||
|
||||
def no_interface(name):
|
||||
err = libvirt.libvirtError("Interface not found: no interface with matching name '%s'" % name)
|
||||
err.err = (libvirt.VIR_ERR_NO_INTERFACE, libvirt.VIR_FROM_INTERFACE, str(err), 2, name, None, None, 0, 0)
|
||||
return err
|
||||
|
||||
|
||||
class BridgeMemberXmlTestCase(SimpleTestCase):
|
||||
"""A bridge member is declared as the host defines it: a bond keeps its
|
||||
mode and devices, a vlan its tag and device."""
|
||||
|
||||
def member(self, host_xml):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
|
||||
def get_iface(name):
|
||||
if host_xml is None:
|
||||
raise no_interface(name)
|
||||
iface = MagicMock()
|
||||
iface.XMLDesc.return_value = host_xml
|
||||
return iface
|
||||
|
||||
conn.get_iface = MagicMock(side_effect=get_iface)
|
||||
conn.create_iface("br1", "bridge", "onboot", "bond0", "none", "", "", "none", "", "", "on", 0)
|
||||
self.assertTrue(conn.define_iface.return_value.create.called)
|
||||
members = etree.fromstring(conn.define_iface.call_args[0][0]).findall("bridge/interface")
|
||||
self.assertEqual(len(members), 1)
|
||||
return members[0]
|
||||
|
||||
def test_a_bond_keeps_its_mode_and_devices(self):
|
||||
member = self.member(
|
||||
"<interface type='bond' name='bond0'><start mode='onboot'/><mtu size='9000'/>"
|
||||
"<protocol family='ipv4'><dhcp/></protocol>"
|
||||
"<bond mode='active-backup'><miimon freq='100'/>"
|
||||
"<interface type='ethernet' name='eth0'><mac address='52:54:00:00:00:01'/></interface>"
|
||||
"<interface type='ethernet' name='eth1'/></bond></interface>"
|
||||
)
|
||||
self.assertEqual((member.get("type"), member.get("name")), ("bond", "bond0"))
|
||||
self.assertEqual(member.find("bond").get("mode"), "active-backup")
|
||||
self.assertEqual([i.get("name") for i in member.findall("bond/interface")], ["eth0", "eth1"])
|
||||
self.assertEqual(member.find("bond/miimon").get("freq"), "100")
|
||||
# what only a top-level interface has: the bridge holds the addresses
|
||||
for tag in ("start", "mtu", "protocol"):
|
||||
self.assertIsNone(member.find(tag), tag)
|
||||
|
||||
def test_a_vlan_keeps_its_tag_and_device(self):
|
||||
member = self.member(
|
||||
"<interface type='vlan' name='bond0'><start mode='onboot'/>"
|
||||
"<vlan tag='42'><interface name='eth0'/></vlan></interface>"
|
||||
)
|
||||
self.assertEqual(member.get("type"), "vlan")
|
||||
self.assertEqual(member.find("vlan").get("tag"), "42")
|
||||
self.assertEqual(member.find("vlan/interface").get("name"), "eth0")
|
||||
self.assertIsNone(member.find("start"))
|
||||
|
||||
def test_a_device_the_host_does_not_define_is_ethernet(self):
|
||||
member = self.member(None)
|
||||
self.assertEqual(member.attrib, {"type": "ethernet", "name": "bond0"})
|
||||
|
||||
def test_other_lookup_errors_are_not_hidden(self):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
err = libvirt.libvirtError("cannot recv data")
|
||||
err.err = (libvirt.VIR_ERR_SYSTEM_ERROR, libvirt.VIR_FROM_RPC, str(err), 2, None, None, None, 0, 0)
|
||||
conn.get_iface = MagicMock(side_effect=err)
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.create_iface("br1", "bridge", "onboot", "bond0", "none", "", "", "none", "", "", "on", 0)
|
||||
self.assertFalse(conn.define_iface.called)
|
||||
|
||||
def test_an_interface_that_does_not_start_is_rolled_back(self):
|
||||
# the definition, and the member's own configuration it changed, as they were
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
iface = conn.define_iface.return_value
|
||||
iface.create.side_effect = libvirt.libvirtError("failed to bring up br1")
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.create_iface("br1", "ethernet", "onboot", "", "none", "", "", "none", "", "", "on", 0)
|
||||
conn.wvm.changeRollback.assert_called_once_with(0)
|
||||
conn.wvm.changeCommit.assert_not_called()
|
||||
|
||||
|
||||
class BridgeSettingsTestCase(SimpleTestCase):
|
||||
def test_a_bridge_needs_stp_and_a_delay(self):
|
||||
for field in ("stp", "delay"):
|
||||
|
|
@ -313,152 +178,6 @@ class AddressNotationTestCase(SimpleTestCase):
|
|||
self.assertIn(field, f.errors)
|
||||
|
||||
|
||||
class CreateViewTestCase(TestCase):
|
||||
"""The page validates the device against the host's devices before it
|
||||
defines an interface."""
|
||||
|
||||
def test_only_a_host_device_becomes_a_bridge_member(self):
|
||||
admin = get_user_model().objects.create_superuser("iface_admin", "a@example.com", "x")
|
||||
compute = Compute.objects.create(name="iface-c", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
self.client.force_login(admin)
|
||||
for netdev, created in (("eth9", False), ("eth0", True)):
|
||||
with self.subTest(netdev=netdev), patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = True
|
||||
conn.get_net_devices.return_value = ["eth0", "eth1"]
|
||||
conn.get_member_candidates.return_value = ["eth0", "eth1"]
|
||||
self.client.post(
|
||||
reverse("interfaces", args=[compute.id]),
|
||||
{**BASE, "netdev": netdev, "ipv6_type": "none", "create": ""},
|
||||
)
|
||||
self.assertEqual(conn.create_iface.called, created)
|
||||
|
||||
def test_a_device_taken_meanwhile_is_chosen_again(self):
|
||||
# not swapped for the first device the dialog still offers
|
||||
admin = get_user_model().objects.create_superuser("iface_admin3", "c@example.com", "x")
|
||||
compute = Compute.objects.create(name="iface-c3", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
self.client.force_login(admin)
|
||||
for select, values in (("iface_netdev", {"netdev": "eth1"}), ("iface_device", {"itype": "ethernet", "name": "eth1", "netdev": "eth0"})):
|
||||
with self.subTest(**values), patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = True
|
||||
conn.get_net_devices.return_value = ["eth0", "eth1"]
|
||||
conn.get_member_candidates.return_value = ["eth0"]
|
||||
page = self.client.post(reverse("interfaces", args=[compute.id]),
|
||||
{**BASE, **values, "ipv6_type": "none", "create": ""}).content.decode()
|
||||
self.assertFalse(conn.create_iface.called)
|
||||
chosen = etree.HTML(page).xpath(f"//select[@id='{select}']/option[@selected]/@value")
|
||||
self.assertEqual(chosen, [""])
|
||||
|
||||
def test_unreadable_host_devices_are_reported_not_blamed_on_the_input(self):
|
||||
admin = get_user_model().objects.create_superuser("iface_admin2", "b@example.com", "x")
|
||||
compute = Compute.objects.create(name="iface-c2", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
self.client.force_login(admin)
|
||||
with patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = True
|
||||
conn.get_net_devices.side_effect = RuntimeError("nodedev driver not running")
|
||||
response = self.client.post(
|
||||
reverse("interfaces", args=[compute.id]),
|
||||
{**BASE, "netdev": "eth0", "ipv6_type": "none", "create": ""},
|
||||
)
|
||||
self.assertFalse(conn.create_iface.called)
|
||||
messages = [str(m) for m in response.context["messages"]]
|
||||
self.assertTrue(any("nodedev driver not running" in m for m in messages), messages)
|
||||
self.assertFalse(any("not on this host" in m for m in messages), messages)
|
||||
|
||||
|
||||
class InterfaceListTestCase(TestCase):
|
||||
def test_the_interfaces_are_grouped_by_type(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("iface_list", "l@example.com", "x"))
|
||||
compute = Compute.objects.create(name="iface-list", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
ifaces = [{"name": name, "type": kind, "mac": None, "link": "up", "members": []}
|
||||
for name, kind in (("br0", "bridge"), ("eth0", "ethernet"), ("eth1", "ethernet"))]
|
||||
with patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn_cls.return_value.get_ifaces_info.return_value = ifaces
|
||||
conn_cls.return_value.can_change_interfaces.return_value = False
|
||||
page = etree.HTML(self.client.get(reverse("interfaces", args=[compute.id])).content.decode())
|
||||
# no heading for a type the host has none of
|
||||
self.assertEqual([" ".join(h.itertext()).split() for h in page.xpath("//h2")], [["Bridges", "1"], ["Ethernet", "2"]])
|
||||
|
||||
|
||||
class UnchangeableHostTestCase(TestCase):
|
||||
"""A host whose libvirt cannot change interfaces (udev backend) gets no
|
||||
create form and no start/stop/delete, only a note; posts change nothing."""
|
||||
|
||||
NOTE = "only reads network interfaces"
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("iface_ro", "ro@example.com", "x"))
|
||||
self.compute = Compute.objects.create(name="iface-ro", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
|
||||
def interfaces_page(self, changeable, post=None):
|
||||
with patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = changeable
|
||||
conn.get_net_devices.return_value = ["eth0", "eth1"]
|
||||
url = reverse("interfaces", args=[self.compute.id])
|
||||
response = self.client.post(url, post) if post else self.client.get(url)
|
||||
return conn, response.content.decode()
|
||||
|
||||
def test_the_interfaces_page_offers_create_only_where_it_works(self):
|
||||
for changeable in (True, False):
|
||||
with self.subTest(changeable=changeable):
|
||||
conn, page = self.interfaces_page(changeable)
|
||||
self.assertEqual("#AddInterface" in page, changeable)
|
||||
self.assertEqual(self.NOTE in page, not changeable)
|
||||
# the devices are only the form's choices
|
||||
self.assertEqual(conn.get_net_devices.called, changeable)
|
||||
|
||||
def test_a_create_post_creates_nothing(self):
|
||||
conn, page = self.interfaces_page(False, {**BASE, "netdev": "eth1", "ipv6_type": "none", "create": ""})
|
||||
conn.create_iface.assert_not_called()
|
||||
self.assertIn(self.NOTE, page)
|
||||
|
||||
def interface_page(self, changeable, state, post=None):
|
||||
with patch("interfaces.views.wvmInterface") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.can_change_interfaces.return_value = changeable
|
||||
conn.is_active.return_value = state
|
||||
url = reverse("interface", args=[self.compute.id, "br0"])
|
||||
response = self.client.post(url, post) if post else self.client.get(url)
|
||||
return conn, response
|
||||
|
||||
def test_the_interface_page_shows_the_state_without_actions(self):
|
||||
# libvirt's own state is told where it manages the interface only; the link always
|
||||
for state in (1, 0):
|
||||
with self.subTest(state=state):
|
||||
conn, response = self.interface_page(False, state)
|
||||
page = response.content.decode()
|
||||
self.assertNotIn('name="start"', page)
|
||||
self.assertNotIn('name="stop"', page)
|
||||
self.assertNotIn(">Active<", page)
|
||||
self.assertIn(self.NOTE, page)
|
||||
_conn, response = self.interface_page(True, 0)
|
||||
page = response.content.decode()
|
||||
self.assertIn('name="start"', page)
|
||||
self.assertIn(">Inactive<", page)
|
||||
|
||||
def test_a_host_that_could_not_be_read_offers_nothing(self):
|
||||
# not knowing whether the host can change interfaces is not a yes
|
||||
with patch("interfaces.views.wvmInterface", side_effect=libvirt.libvirtError("unreachable")):
|
||||
page = self.client.get(reverse("interface", args=[self.compute.id, "br0"])).content.decode()
|
||||
self.assertNotIn(self.NOTE, page)
|
||||
self.assertNotIn("Interface start/stop/delete form", page)
|
||||
|
||||
def test_start_stop_and_delete_posts_change_nothing(self):
|
||||
for action in ("start", "stop", "delete"):
|
||||
with self.subTest(action=action):
|
||||
conn, response = self.interface_page(False, 1, {action: ""})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for method in (conn.start_iface, conn.stop_iface, conn.delete_iface):
|
||||
method.assert_not_called()
|
||||
|
||||
|
||||
class EthernetDeviceTestCase(SimpleTestCase):
|
||||
def test_an_ethernet_interface_needs_no_bridge_member(self):
|
||||
self.assertTrue(form(itype="ethernet", name="eth0", netdev="", ipv6_type="none").is_valid())
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ from django.contrib.auth import get_user_model
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_HOST"), "Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
@unittest.skipUnless(livetest.enabled(), "Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
class LiveInterfacesTestCase(TestCase):
|
||||
def hosts(self):
|
||||
yield "tcp", (
|
||||
|
|
|
|||
159
interfaces/tests/test_views.py
Normal file
159
interfaces/tests/test_views.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""The host interface pages: creating an interface, the list, and a host
|
||||
whose libvirt cannot change interfaces."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from lxml import etree
|
||||
|
||||
from computes.models import Compute
|
||||
from interfaces.tests.fakes import BASE
|
||||
|
||||
|
||||
class CreateViewTestCase(TestCase):
|
||||
"""The page validates the device against the host's devices before it
|
||||
defines an interface."""
|
||||
|
||||
def test_only_a_host_device_becomes_a_bridge_member(self):
|
||||
admin = get_user_model().objects.create_superuser("iface_admin", "a@example.com", "x")
|
||||
compute = Compute.objects.create(name="iface-c", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
self.client.force_login(admin)
|
||||
for netdev, created in (("eth9", False), ("eth0", True)):
|
||||
with self.subTest(netdev=netdev), patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = True
|
||||
conn.get_net_devices.return_value = ["eth0", "eth1"]
|
||||
conn.get_member_candidates.return_value = ["eth0", "eth1"]
|
||||
self.client.post(
|
||||
reverse("interfaces", args=[compute.id]),
|
||||
{**BASE, "netdev": netdev, "ipv6_type": "none", "create": ""},
|
||||
)
|
||||
self.assertEqual(conn.create_iface.called, created)
|
||||
|
||||
def test_a_device_taken_meanwhile_is_chosen_again(self):
|
||||
# not swapped for the first device the dialog still offers
|
||||
admin = get_user_model().objects.create_superuser("iface_admin3", "c@example.com", "x")
|
||||
compute = Compute.objects.create(name="iface-c3", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
self.client.force_login(admin)
|
||||
for select, values in (("iface_netdev", {"netdev": "eth1"}), ("iface_device", {"itype": "ethernet", "name": "eth1", "netdev": "eth0"})):
|
||||
with self.subTest(**values), patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = True
|
||||
conn.get_net_devices.return_value = ["eth0", "eth1"]
|
||||
conn.get_member_candidates.return_value = ["eth0"]
|
||||
page = self.client.post(reverse("interfaces", args=[compute.id]),
|
||||
{**BASE, **values, "ipv6_type": "none", "create": ""}).content.decode()
|
||||
self.assertFalse(conn.create_iface.called)
|
||||
chosen = etree.HTML(page).xpath(f"//select[@id='{select}']/option[@selected]/@value")
|
||||
self.assertEqual(chosen, [""])
|
||||
|
||||
def test_unreadable_host_devices_are_reported_not_blamed_on_the_input(self):
|
||||
admin = get_user_model().objects.create_superuser("iface_admin2", "b@example.com", "x")
|
||||
compute = Compute.objects.create(name="iface-c2", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
self.client.force_login(admin)
|
||||
with patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = True
|
||||
conn.get_net_devices.side_effect = RuntimeError("nodedev driver not running")
|
||||
response = self.client.post(
|
||||
reverse("interfaces", args=[compute.id]),
|
||||
{**BASE, "netdev": "eth0", "ipv6_type": "none", "create": ""},
|
||||
)
|
||||
self.assertFalse(conn.create_iface.called)
|
||||
messages = [str(m) for m in response.context["messages"]]
|
||||
self.assertTrue(any("nodedev driver not running" in m for m in messages), messages)
|
||||
self.assertFalse(any("not on this host" in m for m in messages), messages)
|
||||
|
||||
|
||||
class InterfaceListTestCase(TestCase):
|
||||
def test_the_interfaces_are_grouped_by_type(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("iface_list", "l@example.com", "x"))
|
||||
compute = Compute.objects.create(name="iface-list", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
ifaces = [{"name": name, "type": kind, "mac": None, "link": "up", "members": []}
|
||||
for name, kind in (("br0", "bridge"), ("eth0", "ethernet"), ("eth1", "ethernet"))]
|
||||
with patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn_cls.return_value.get_ifaces_info.return_value = ifaces
|
||||
conn_cls.return_value.can_change_interfaces.return_value = False
|
||||
page = etree.HTML(self.client.get(reverse("interfaces", args=[compute.id])).content.decode())
|
||||
# no heading for a type the host has none of
|
||||
self.assertEqual([" ".join(h.itertext()).split() for h in page.xpath("//h2")], [["Bridges", "1"], ["Ethernet", "2"]])
|
||||
|
||||
|
||||
class UnchangeableHostTestCase(TestCase):
|
||||
"""A host whose libvirt cannot change interfaces (udev backend) gets no
|
||||
create form and no start/stop/delete, only a note; posts change nothing."""
|
||||
|
||||
NOTE = "only reads network interfaces"
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("iface_ro", "ro@example.com", "x"))
|
||||
self.compute = Compute.objects.create(name="iface-ro", hostname="127.0.0.1:1", login="root", password="", type=1)
|
||||
|
||||
def interfaces_page(self, changeable, post=None):
|
||||
with patch("interfaces.views.wvmInterfaces") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.get_ifaces_info.return_value = []
|
||||
conn.can_change_interfaces.return_value = changeable
|
||||
conn.get_net_devices.return_value = ["eth0", "eth1"]
|
||||
url = reverse("interfaces", args=[self.compute.id])
|
||||
response = self.client.post(url, post) if post else self.client.get(url)
|
||||
return conn, response.content.decode()
|
||||
|
||||
def test_the_interfaces_page_offers_create_only_where_it_works(self):
|
||||
for changeable in (True, False):
|
||||
with self.subTest(changeable=changeable):
|
||||
conn, page = self.interfaces_page(changeable)
|
||||
self.assertEqual("#AddInterface" in page, changeable)
|
||||
self.assertEqual(self.NOTE in page, not changeable)
|
||||
# the devices are only the form's choices
|
||||
self.assertEqual(conn.get_net_devices.called, changeable)
|
||||
|
||||
def test_a_create_post_creates_nothing(self):
|
||||
conn, page = self.interfaces_page(False, {**BASE, "netdev": "eth1", "ipv6_type": "none", "create": ""})
|
||||
conn.create_iface.assert_not_called()
|
||||
self.assertIn(self.NOTE, page)
|
||||
|
||||
def interface_page(self, changeable, state, post=None):
|
||||
with patch("interfaces.views.wvmInterface") as conn_cls:
|
||||
conn = conn_cls.return_value
|
||||
conn.can_change_interfaces.return_value = changeable
|
||||
conn.is_active.return_value = state
|
||||
url = reverse("interface", args=[self.compute.id, "br0"])
|
||||
response = self.client.post(url, post) if post else self.client.get(url)
|
||||
return conn, response
|
||||
|
||||
def test_the_interface_page_shows_the_state_without_actions(self):
|
||||
# libvirt's own state is told where it manages the interface only; the link always
|
||||
for state in (1, 0):
|
||||
with self.subTest(state=state):
|
||||
conn, response = self.interface_page(False, state)
|
||||
page = response.content.decode()
|
||||
self.assertNotIn('name="start"', page)
|
||||
self.assertNotIn('name="stop"', page)
|
||||
self.assertNotIn(">Active<", page)
|
||||
self.assertIn(self.NOTE, page)
|
||||
_conn, response = self.interface_page(True, 0)
|
||||
page = response.content.decode()
|
||||
self.assertIn('name="start"', page)
|
||||
self.assertIn(">Inactive<", page)
|
||||
|
||||
def test_a_host_that_could_not_be_read_offers_nothing(self):
|
||||
# not knowing whether the host can change interfaces is not a yes
|
||||
with patch("interfaces.views.wvmInterface", side_effect=libvirt.libvirtError("unreachable")):
|
||||
page = self.client.get(reverse("interface", args=[self.compute.id, "br0"])).content.decode()
|
||||
self.assertNotIn(self.NOTE, page)
|
||||
self.assertNotIn("Interface start/stop/delete form", page)
|
||||
|
||||
def test_start_stop_and_delete_posts_change_nothing(self):
|
||||
for action in ("start", "stop", "delete"):
|
||||
with self.subTest(action=action):
|
||||
conn, response = self.interface_page(False, 1, {action: ""})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for method in (conn.start_iface, conn.stop_iface, conn.delete_iface):
|
||||
method.assert_not_called()
|
||||
|
|
@ -8,7 +8,7 @@ from computes.models import Compute
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from django.urls import reverse
|
||||
|
||||
from logs.models import Logs
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from computes.models import Compute
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from django.urls import reverse
|
||||
from lxml import etree
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from django.contrib.auth import get_user_model
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from lxml import html
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
|
||||
NODE = """<node><network><name>lab</name><bridge name='virbr9'/>
|
||||
<ip address='192.0.2.1' netmask='255.255.255.0'><dhcp><range start='192.0.2.10' end='192.0.2.20'/></dhcp></ip>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from computes.models import Compute
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from django.urls import reverse
|
||||
|
||||
NODE = """<node>
|
||||
|
|
|
|||
|
|
@ -5,56 +5,24 @@ that cannot be read leave the rest of the page. Runs on a private libvirt
|
|||
test driver, which has no leases: they are given."""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from networks.tests.test_network_pages import NODE
|
||||
from vrtManager.network import wvmNetwork
|
||||
|
||||
DUID = "00:01:00:01:2e:8b:3c:41:52:54:00:00:00:40"
|
||||
|
||||
|
||||
def lease(ip, mac=None, expiry=1793265489, hostname=None, clientid=None, family=0):
|
||||
return {"iface": "virbr9", "expirytime": expiry, "type": family, "mac": mac, "ipaddr": ip, "prefix": 24,
|
||||
"hostname": hostname, "clientid": clientid, "iaid": None}
|
||||
|
||||
|
||||
class LeasesTests(SimpleTestCase):
|
||||
def network(self, leases, active=True):
|
||||
net = wvmNetwork.__new__(wvmNetwork)
|
||||
net.net = MagicMock(**{"isActive.return_value": active, "DHCPLeases.return_value": leases})
|
||||
return net
|
||||
|
||||
def test_leases_by_address_ipv4_first(self):
|
||||
net = self.network([lease("2001:db8:1::40", family=1, clientid=DUID, expiry=0),
|
||||
lease("192.0.2.100", "52:54:00:AA:00:01", hostname="db"),
|
||||
lease("192.0.2.9", "52:54:00:aa:00:02")])
|
||||
ipv4_9, ipv4_100, ipv6 = net.get_leases()
|
||||
self.assertEqual([str(ipv4_9["ip"]), str(ipv4_100["ip"]), str(ipv6["ip"])], ["192.0.2.9", "192.0.2.100", "2001:db8:1::40"])
|
||||
self.assertEqual((ipv4_100["mac"], ipv4_100["hostname"], ipv4_100["expires"]),
|
||||
("52:54:00:aa:00:01", "db", datetime.fromtimestamp(1793265489, timezone.utc)))
|
||||
# an IPv6 lease: no MAC, its client's DUID; one that never ends
|
||||
self.assertEqual((ipv6["mac"], ipv6["client_id"], ipv6["expires"]), ("", DUID, None))
|
||||
|
||||
def test_a_network_that_does_not_run_has_none(self):
|
||||
net = self.network([lease("192.0.2.9")], active=False)
|
||||
self.assertEqual(net.get_leases(), [])
|
||||
net.net.DHCPLeases.assert_not_called()
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.fakes import DUID, NAT_NETWORK_NODE, lease
|
||||
|
||||
|
||||
class LeasesCardTests(TestCase):
|
||||
def setUp(self):
|
||||
node = tempfile.NamedTemporaryFile("w", suffix=".xml")
|
||||
self.addCleanup(node.close)
|
||||
node.write(NODE)
|
||||
node.write(NAT_NETWORK_NODE)
|
||||
node.flush()
|
||||
self.conn = libvirt.open(f"test://{node.name}")
|
||||
self.addCleanup(self.conn.close)
|
||||
|
|
|
|||
|
|
@ -14,26 +14,15 @@ from django.test import TestCase
|
|||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
|
||||
NODE = """<node>
|
||||
<network><name>lab</name><forward mode='nat'/><bridge name='virbr9'/>
|
||||
<ip address='192.0.2.1' netmask='255.255.255.0'><dhcp><range start='192.0.2.10' end='192.0.2.20'/>
|
||||
<host mac='52:54:00:00:00:31' name='web' ip='192.0.2.31'/></dhcp></ip>
|
||||
<ip family='ipv6' address='2001:db8:1::1' prefix='64'><dhcp><host id='0:1:0:1' ip='2001:db8:1::31'/></dhcp></ip>
|
||||
</network>
|
||||
<network><name>lan</name><forward mode='bridge'/><bridge name='br0'/></network>
|
||||
<network><name>direct</name><forward mode='bridge'><interface dev='eth0'/></forward></network>
|
||||
<domain type='test'><name>web</name><memory>1024</memory><os><type>hvm</type></os>
|
||||
<devices><interface type='network'><source network='lab'/></interface></devices></domain>
|
||||
</node>"""
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.fakes import NAT_NETWORK_NODE
|
||||
|
||||
|
||||
class NetworkPageTests(TestCase):
|
||||
def setUp(self):
|
||||
node = tempfile.NamedTemporaryFile("w", suffix=".xml")
|
||||
self.addCleanup(node.close)
|
||||
node.write(NODE)
|
||||
node.write(NAT_NETWORK_NODE)
|
||||
node.flush()
|
||||
self.conn = libvirt.open(f"test://{node.name}")
|
||||
self.addCleanup(self.conn.close)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model
|
|||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
|
||||
|
||||
class NetworkQosTests(TestCase):
|
||||
|
|
|
|||
|
|
@ -4,169 +4,17 @@ and the VMs that name it, by PCI address, by USB address or by USB model
|
|||
only. Node device XML as libvirt 11.10 writes it; the connection is a mock,
|
||||
libvirt's test driver has no node devices."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager import util
|
||||
from vrtManager.nodedevs import hostdev_problems, node_devices, pci_address
|
||||
|
||||
|
||||
def pci(slot, product, code, driver=None, group=None):
|
||||
iommu = f"<iommuGroup number='{group}'><address domain='0x0000' bus='0x00' slot='{slot}' function='0x0'/></iommuGroup>" \
|
||||
if group is not None else ""
|
||||
driver = f"<driver><name>{driver}</name></driver>" if driver else ""
|
||||
return (f"pci_0000_00_{int(slot, 16):02x}_0", f"""<device><name>pci_0000_00_{int(slot, 16):02x}_0</name><parent>computer</parent>{driver}
|
||||
<capability type='pci'><class>{code}</class><domain>0</domain><bus>0</bus><slot>{int(slot, 16)}</slot><function>0</function>
|
||||
<product id='0x1000'>{product}</product><vendor id='0x1af4'>Red Hat, Inc.</vendor>{iommu}</capability></device>""")
|
||||
|
||||
|
||||
def child(name, parent, capability):
|
||||
return name, f"<device><name>{name}</name><parent>{parent}</parent>{capability}</device>"
|
||||
|
||||
|
||||
def usb(name, parent, bus, device, product="QEMU Tablet", vendor_id="0x0627", product_id="0x0001"):
|
||||
return name, f"""<device><name>{name}</name><parent>{parent}</parent><capability type='usb_device'><bus>{bus}</bus>
|
||||
<device>{device}</device><product id='{product_id}'>{product}</product><vendor id='{vendor_id}'>Adomax</vendor>
|
||||
</capability></device>"""
|
||||
|
||||
|
||||
def interface(name, parent, code):
|
||||
return child(name, parent, f"<capability type='usb'><number>0</number><class>{code}</class></capability>")
|
||||
|
||||
|
||||
NODES = [
|
||||
pci("0x06", "Virtio network device", "0x020000", "virtio-pci", group=3),
|
||||
child("net_eth0", "pci_0000_00_06_0", "<capability type='net'><interface>eth0</interface></capability>"),
|
||||
pci("0x03", "Virtio block device", "0x010000", "virtio-pci", group=2),
|
||||
child("virtio1", "pci_0000_00_03_0", "<capability type='system'/>"), # a level between, not a resource
|
||||
child("block_vda", "virtio1", "<capability type='storage'><block>/dev/vda</block></capability>"),
|
||||
pci("0x02", "Virtio 1.0 GPU", "0x030000", "virtio-pci", group=2),
|
||||
child("drm_card0", "pci_0000_00_02_0", "<devnode type='dev'>/dev/dri/card0</devnode><capability type='drm'><type>primary</type></capability>"),
|
||||
child("drm_render", "pci_0000_00_02_0", "<devnode type='dev'>/dev/dri/renderD128</devnode><capability type='drm'><type>render</type></capability>"),
|
||||
pci("0x0a", "Ethernet controller", "0x020000", "vfio-pci", group=4),
|
||||
pci("0x01", "PIIX3 USB", "0x0c0300", "uhci_hcd"),
|
||||
usb("usb_usb1", "pci_0000_00_01_0", 1, 1, "1.1 root hub", "0x1d6b", "0x0001"),
|
||||
interface("usb_1_0_1_0", "usb_usb1", 9),
|
||||
usb("usb_1_1", "usb_usb1", 1, 2),
|
||||
interface("usb_1_1_1_0", "usb_1_1", 3),
|
||||
usb("usb_1_2", "usb_usb1", 1, 3),
|
||||
interface("usb_1_2_1_0", "usb_1_2", 3),
|
||||
]
|
||||
|
||||
|
||||
def node(name, xml):
|
||||
return MagicMock(**{"name.return_value": name, "XMLDesc.return_value": xml})
|
||||
|
||||
|
||||
def vm(name, hostdevs, saved=None):
|
||||
"""A VM with hostdevs as it runs and, given saved, persistent with those for its next start."""
|
||||
def describe(flags=0):
|
||||
return f"<domain><devices>{saved if flags else hostdevs}</devices></domain>"
|
||||
return MagicMock(**{"name.return_value": name, "XMLDesc.side_effect": describe, "isPersistent.return_value": saved is not None})
|
||||
|
||||
|
||||
PCI_01 = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x01' function='0x0'/></source></hostdev>"
|
||||
PCI_0A = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x0a' function='0x0'/></source></hostdev>"
|
||||
USB_1_3 = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><product id='0x0001'/><address bus='1' device='3'/></source></hostdev>"
|
||||
USB_MODEL = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><product id='0x0001'/></source></hostdev>"
|
||||
|
||||
|
||||
def devices(nodes=NODES, domains=()):
|
||||
conn = wvmConnect.__new__(wvmConnect)
|
||||
conn.wvm = MagicMock(**{"listAllDevices.return_value": [node(*n) for n in nodes],
|
||||
"listAllDomains.return_value": list(domains)})
|
||||
return node_devices(conn)
|
||||
|
||||
|
||||
class NodeDevicesTests(SimpleTestCase):
|
||||
def test_pci_devices_by_group_with_what_the_host_has_on_them(self):
|
||||
pci_devices, _ = devices()
|
||||
self.assertEqual([(d["address"], d["group"]) for d in pci_devices],
|
||||
[("0000:00:02.0", 2), ("0000:00:03.0", 2), ("0000:00:06.0", 3), ("0000:00:0a.0", 4), ("0000:00:01.0", None)])
|
||||
gpu, disk, nic, spare, _ = pci_devices
|
||||
# a graphics card's primary node is the host's; its render node is not a resource of its own
|
||||
self.assertEqual((gpu["resources"], disk["resources"], nic["resources"], spare["resources"]),
|
||||
(["/dev/dri/card0"], ["/dev/vda"], ["eth0"], []))
|
||||
self.assertEqual((nic["kind"], nic["driver"], nic["ids"]), ("Network controller", "virtio-pci", "1af4:1000"))
|
||||
|
||||
def test_usb_devices_with_their_classes_hubs_and_same_models(self):
|
||||
_, (hub, tablet_2, tablet_3) = devices()
|
||||
self.assertEqual((hub["kinds"], hub["same_model"]), (["Hub"], False))
|
||||
self.assertEqual((tablet_2["kinds"], tablet_2["same_model"], tablet_3["same_model"]), (["Human interface"], True, True))
|
||||
|
||||
def test_the_vms_that_name_a_device(self):
|
||||
pci_devices, (_, tablet_2, tablet_3) = devices(domains=[vm("db-1", PCI_0A + USB_1_3), vm("kiosk-1", USB_MODEL)])
|
||||
self.assertEqual([d["vms"] for d in pci_devices], [[], [], [], ["db-1"], []])
|
||||
# by address: that device; by model only: any device of the model
|
||||
self.assertEqual((tablet_3["vms"], tablet_3["by_model"]), (["db-1"], ["kiosk-1"]))
|
||||
self.assertEqual((tablet_2["vms"], tablet_2["by_model"]), ([], ["kiosk-1"]))
|
||||
|
||||
def test_a_usb_controller_holds_the_usb_devices_below_it(self):
|
||||
pci_devices, (hub, tablet_2, _) = devices(domains=[vm("kiosk-1", USB_1_3)])
|
||||
controller = [d for d in pci_devices if d["address"] == "0000:00:01.0"][0]
|
||||
# its root hub is no device of the host's; the tablets are, one given to a VM
|
||||
self.assertEqual(controller["resources"], ["USB device 1/2 (QEMU Tablet)", "USB device 1/3 (QEMU Tablet)"])
|
||||
self.assertEqual((hub["controller"], tablet_2["controller"]), ("0000:00:01.0", "0000:00:01.0"))
|
||||
|
||||
def test_a_usb_device_on_a_controller_given_to_a_vm_is_not_given_to_another(self):
|
||||
pci_devices, usb_devices = devices(domains=[vm("db-1", PCI_01)])
|
||||
reasons = [util.render(m["text"], m["values"], str) for m in hostdev_problems(
|
||||
"kiosk-1", pci_devices, usb_devices, set(), [("0x0627", "0x0001", (1, 2))])]
|
||||
self.assertIn("USB device 1/2 (QEMU Tablet) is on the PCI controller 0000:00:01.0, given to db-1", reasons)
|
||||
|
||||
def test_a_usb_device_is_named_as_libvirt_picks_one(self):
|
||||
def named(hostdev):
|
||||
_, (_, tablet_2, tablet_3) = devices(domains=[vm("v", hostdev)])
|
||||
return [(d["vms"], d["by_model"]) for d in (tablet_2, tablet_3)]
|
||||
# an address with other IDs: another device now at that address
|
||||
other = USB_1_3.replace("0x0627", "0x046d")
|
||||
address_only = "<hostdev mode='subsystem' type='usb'><source><address bus='1' device='2'/></source></hostdev>"
|
||||
self.assertEqual(named(other), [([], []), ([], [])])
|
||||
self.assertEqual(named(address_only), [(["v"], []), ([], [])])
|
||||
self.assertEqual(named(USB_MODEL), [([], ["v"]), ([], ["v"])])
|
||||
|
||||
def test_a_pci_device_named_as_a_nic_or_only_for_the_next_start(self):
|
||||
nic = ("<interface type='hostdev'><source><address type='pci' domain='0x0000' bus='0x00' slot='0x06' function='0x0'/>"
|
||||
"</source></interface>")
|
||||
pci_devices, _ = devices(domains=[vm("nic-1", nic), vm("next-1", "", saved=PCI_0A)])
|
||||
self.assertEqual({d["address"]: d["vms"] for d in pci_devices if d["vms"]},
|
||||
{"0000:00:06.0": ["nic-1"], "0000:00:0a.0": ["next-1"]})
|
||||
|
||||
def test_sr_iov_functions_and_unnamed_hardware(self):
|
||||
vf = pci("0x0b", "", "0x020000", group=5)[1].replace(
|
||||
"<product id='0x1000'></product>", "<product id='0x10ed'/>").replace(
|
||||
"</capability></device>", "<capability type='phys_function'><address domain='0x0000' bus='0x00' slot='0x0a'"
|
||||
" function='0x0'/></capability></capability></device>")
|
||||
pf = NODES[8][1].replace("</capability></device>", "<capability type='virt_functions' maxCount='7'><address domain='0x0000'"
|
||||
" bus='0x00' slot='0x0b' function='0x0'/></capability></capability></device>")
|
||||
pci_devices, _ = devices(nodes=[("pci_0000_00_0a_0", pf), ("pci_0000_00_0b_0", vf)])
|
||||
self.assertEqual([(d["physfn"], d["vfs"], d["product"]) for d in pci_devices],
|
||||
[("", 1, "Ethernet controller"), ("0000:00:0a.0", 0, "0x10ed")])
|
||||
|
||||
def test_a_device_gone_meanwhile_is_left_out_other_errors_are_raised(self):
|
||||
for code, raised in ((libvirt.VIR_ERR_NO_NODE_DEVICE, False), (libvirt.VIR_ERR_OPERATION_FAILED, True)):
|
||||
err = libvirt.libvirtError("gone")
|
||||
err.get_error_code = lambda code=code: code
|
||||
gone = node("pci_0000_00_0b_0", "")
|
||||
gone.XMLDesc.side_effect = err
|
||||
conn = wvmConnect.__new__(wvmConnect)
|
||||
conn.wvm = MagicMock(**{"listAllDevices.return_value": [gone, node(*NODES[0])], "listAllDomains.return_value": []})
|
||||
with self.subTest(code=code):
|
||||
if raised:
|
||||
self.assertRaises(libvirt.libvirtError, node_devices, conn)
|
||||
else:
|
||||
self.assertEqual([d["address"] for d in node_devices(conn)[0]], ["0000:00:06.0"])
|
||||
|
||||
def test_a_pci_address_from_libvirts_hex_or_numbers(self):
|
||||
self.assertEqual(pci_address("0x0000", "0x00", "0x1f", "0x3"), pci_address(0, 0, 31, 3))
|
||||
self.assertEqual(pci_address(0, 0, 31, 3), "0000:00:1f.3")
|
||||
from vrtManager.tests.fakes import NODES, node
|
||||
from vrtManager.tests.fakes import PCI_0A, USB_1_3, vm
|
||||
|
||||
|
||||
class NodeDevicesPageTests(TestCase):
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from computes.models import Compute
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.tests import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from vrtManager.connection import connection_manager
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
|
|
|||
|
|
@ -5,79 +5,12 @@ value refused leaves no secret behind."""
|
|||
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.urls import reverse
|
||||
from lxml import etree, html
|
||||
from lxml import html
|
||||
from virtsecrets.forms import AddSecret
|
||||
from vrtManager.util import OperationError
|
||||
from vrtManager.virtsecrets import wvmSecrets
|
||||
|
||||
|
||||
def connection():
|
||||
with patch("vrtManager.connection.connection_manager.get_connection") as get:
|
||||
conn = wvmSecrets("host", "login", "pw", 1)
|
||||
return conn, get.return_value
|
||||
|
||||
|
||||
class CreateSecretTests(SimpleTestCase):
|
||||
def test_the_usage_is_text_of_its_element(self):
|
||||
for usage_type, element, usage in (
|
||||
("volume", "volume", "/var/lib/libvirt/images/vm-1.qcow2"),
|
||||
("iscsi", "target", "iqn.2026-10.test.wvc:lun"),
|
||||
("ceph", "name", "client.libvirt secret"),
|
||||
("tls", "name", "migration"),
|
||||
("vtpm", "name", "VM web-1's vTPM"),
|
||||
("volume", "volume", "/a'b<c>&d\""),
|
||||
):
|
||||
with self.subTest(usage_type=usage_type, usage=usage):
|
||||
conn, wvm = connection()
|
||||
conn.create_secret(usage_type, usage, private=True)
|
||||
secret = etree.fromstring(wvm.secretDefineXML.call_args.args[0])
|
||||
self.assertEqual((secret.get("ephemeral"), secret.get("private")), ("no", "yes"))
|
||||
self.assertEqual(secret.find("usage").get("type"), usage_type)
|
||||
self.assertEqual(secret.findtext(f"usage/{element}"), usage)
|
||||
wvm.secretDefineXML.return_value.setValue.assert_not_called()
|
||||
|
||||
def test_a_new_secret_takes_its_value_at_once(self):
|
||||
for usage_type, value, stored in (("ceph", "cHcA/w==", b"pw\x00\xff"), ("iscsi", " pass word ", b" pass word ")):
|
||||
with self.subTest(usage_type=usage_type):
|
||||
conn, wvm = connection()
|
||||
conn.create_secret(usage_type, "lun", value)
|
||||
wvm.secretDefineXML.return_value.setValue.assert_called_once_with(stored)
|
||||
|
||||
def test_a_value_refused_leaves_no_secret(self):
|
||||
conn, wvm = connection()
|
||||
with self.assertRaisesRegex(OperationError, "base64"):
|
||||
conn.create_secret("ceph", "client.libvirt", "not base64!")
|
||||
wvm.secretDefineXML.assert_not_called()
|
||||
wvm.secretDefineXML.return_value.setValue.side_effect = libvirt.libvirtError("refused")
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.create_secret("iscsi", "lun", "pw")
|
||||
wvm.secretDefineXML.return_value.undefine.assert_called_once_with()
|
||||
|
||||
|
||||
class SecretValueTests(SimpleTestCase):
|
||||
def set_value(self, usage_type, value):
|
||||
conn, wvm = connection()
|
||||
secret = wvm.secretLookupByUUIDString.return_value
|
||||
secret.XMLDesc.return_value = f"<secret><usage type='{usage_type}'><name>x</name></usage></secret>"
|
||||
conn.set_secret_value("uuid", value)
|
||||
return secret.setValue
|
||||
|
||||
def test_a_ceph_key_is_stored_as_its_bytes_a_password_as_typed(self):
|
||||
self.set_value("ceph", "cHcA/w==").assert_called_once_with(b"pw\x00\xff")
|
||||
# a password that happens to be base64 is not decoded
|
||||
self.set_value("iscsi", "cHcA/w==").assert_called_once_with(b"cHcA/w==")
|
||||
self.set_value("vtpm", " pässword ").assert_called_once_with(" pässword ".encode())
|
||||
|
||||
def test_a_ceph_key_that_is_not_base64_is_refused_without_the_value(self):
|
||||
for value in ("b'cHc='", "not base64!", "kéy"):
|
||||
with self.subTest(value=value), self.assertRaises(OperationError) as refused:
|
||||
self.set_value("ceph", value)
|
||||
self.assertNotIn(value, str(refused.exception))
|
||||
|
||||
|
||||
class CreateSecretFormTests(SimpleTestCase):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from computes.models import Compute
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.tests import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from vrtManager.connection import connection_manager
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
|
|
|||
|
|
@ -4,125 +4,16 @@ a pool in its source, by UUID or by usage within the usage type; as they run
|
|||
and as saved. libvirt's test driver has no secrets, so the connection is a
|
||||
mock."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from vrtManager.util import OperationError
|
||||
from vrtManager.virtsecrets import wvmSecrets
|
||||
|
||||
CEPH = "8f2c0000-0000-4000-8000-000000000001"
|
||||
ISCSI = "8f2c0000-0000-4000-8000-000000000002"
|
||||
VTPM = "8f2c0000-0000-4000-8000-000000000003"
|
||||
|
||||
|
||||
def secret(uuid, usage_type, usage):
|
||||
return MagicMock(**{"UUIDString.return_value": uuid,
|
||||
"XMLDesc.return_value": f"<secret><usage type='{usage_type}'><name>{usage}</name></usage></secret>"})
|
||||
|
||||
|
||||
def owner(name, xml, saved=None, active=False, persistent=True):
|
||||
"""A domain or a pool: xml as it runs, saved as defined for its next start."""
|
||||
return MagicMock(**{"name.return_value": name, "XMLDesc.side_effect": lambda flags=0: (saved or xml) if flags else xml,
|
||||
"isActive.return_value": active, "isPersistent.return_value": persistent})
|
||||
|
||||
|
||||
def vm(name, devices, **kwargs):
|
||||
return owner(name, f"<domain><devices>{devices}</devices></domain>", **kwargs)
|
||||
|
||||
|
||||
def pool(name, auth, **kwargs):
|
||||
return owner(name, f"<pool type='rbd'><source>{auth}</source></pool>", **kwargs)
|
||||
|
||||
|
||||
def gone(code):
|
||||
err = libvirt.libvirtError("gone")
|
||||
err.get_error_code = lambda: code
|
||||
return MagicMock(**{"name.return_value": "gone", "XMLDesc.side_effect": err})
|
||||
|
||||
|
||||
# the same usage, of two types: a reference by usage names the one of its type
|
||||
SECRETS = [secret(CEPH, "ceph", "client.libvirt"), secret(ISCSI, "iscsi", "client.libvirt"), secret(VTPM, "vtpm", "tpm")]
|
||||
|
||||
|
||||
def connection(domains=(), pools=(), secrets=SECRETS):
|
||||
with patch("vrtManager.connection.connection_manager.get_connection") as get:
|
||||
conn = wvmSecrets("host", "login", "pw", 1)
|
||||
get.return_value.configure_mock(**{"listAllDomains.return_value": list(domains),
|
||||
"listAllStoragePools.return_value": list(pools),
|
||||
"listAllSecrets.return_value": list(secrets)})
|
||||
return conn
|
||||
|
||||
|
||||
def users(**kwargs):
|
||||
conn = connection(**kwargs)
|
||||
return conn.get_users(conn.get_secrets_info())
|
||||
|
||||
|
||||
class UsersTests(SimpleTestCase):
|
||||
def test_vms_name_secrets_in_disks_host_devices_and_vtpm(self):
|
||||
found = users(domains=[
|
||||
vm("rbd-1", f"<disk type='network'><auth username='libvirt'><secret type='ceph' uuid='{CEPH}'/></auth></disk>"),
|
||||
vm("lun-1", "<hostdev mode='subsystem' type='scsi'><source protocol='iscsi'><auth username='u'>"
|
||||
"<secret type='iscsi' usage='client.libvirt'/></auth></source></hostdev>"),
|
||||
vm("tpm-1", f"<tpm model='tpm-crb'><backend type='emulator'><encryption secret='{VTPM}'/></backend></tpm>"),
|
||||
])
|
||||
self.assertEqual(found, {CEPH: {"vms": ["rbd-1"], "pools": []}, ISCSI: {"vms": ["lun-1"], "pools": []},
|
||||
VTPM: {"vms": ["tpm-1"], "pools": []}})
|
||||
|
||||
def test_a_pool_names_its_secret_by_the_auth_type_and_as_saved(self):
|
||||
found = users(pools=[
|
||||
pool("ceph-rbd", "<auth type='ceph' username='libvirt'><secret usage='client.libvirt'/></auth>"),
|
||||
# chap is iSCSI; named only for its next start
|
||||
pool("lun", "", saved="<pool><source><auth type='chap' username='u'><secret usage='client.libvirt'/>"
|
||||
"</auth></source></pool>", active=False),
|
||||
])
|
||||
self.assertEqual(found, {CEPH: {"vms": [], "pools": ["ceph-rbd"]}, ISCSI: {"vms": [], "pools": ["lun"]}})
|
||||
|
||||
def test_a_vm_uses_a_secret_as_it_runs_and_as_saved(self):
|
||||
disk = "<disk><auth username='libvirt'><secret type='ceph' usage='client.libvirt'/></auth></disk>"
|
||||
# saved only: read whether it runs or not, as it may stop between the reads
|
||||
for kwargs in ({"active": True, "saved": f"<domain><devices>{disk}</devices></domain>"},
|
||||
{"active": False, "saved": f"<domain><devices>{disk}</devices></domain>"},
|
||||
{"active": False}, {"active": True, "persistent": False}):
|
||||
devices = "" if "saved" in kwargs else disk
|
||||
with self.subTest(**kwargs):
|
||||
self.assertEqual(users(domains=[vm("db-1", devices, **kwargs)]), {CEPH: {"vms": ["db-1"], "pools": []}})
|
||||
|
||||
def test_what_is_gone_meanwhile_is_left_out_other_errors_are_raised(self):
|
||||
found = users(domains=[gone(libvirt.VIR_ERR_NO_DOMAIN)], pools=[gone(libvirt.VIR_ERR_NO_STORAGE_POOL)],
|
||||
secrets=[gone(libvirt.VIR_ERR_NO_SECRET)])
|
||||
self.assertEqual(found, {})
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
users(domains=[gone(libvirt.VIR_ERR_OPERATION_FAILED)])
|
||||
|
||||
|
||||
class NoValueReadTests(SimpleTestCase):
|
||||
def test_the_list_and_its_users_read_no_value(self):
|
||||
for listed in SECRETS:
|
||||
listed.value.side_effect = AssertionError("a secret's value was read")
|
||||
conn = connection(domains=[vm("db-1", f"<disk><auth><secret type='ceph' uuid='{CEPH}'/></auth></disk>")])
|
||||
conn.get_users(conn.get_secrets_info())
|
||||
conn.wvm.secretLookupByUUIDString.assert_not_called()
|
||||
|
||||
|
||||
class DeleteTests(SimpleTestCase):
|
||||
def test_a_secret_a_vm_or_a_pool_names_is_not_deleted(self):
|
||||
conn = connection(domains=[vm("db-2", f"<disk><auth><secret type='ceph' uuid='{CEPH}'/></auth></disk>")],
|
||||
pools=[pool("ceph-rbd", "<auth type='ceph'><secret usage='client.libvirt'/></auth>")])
|
||||
with self.assertRaisesRegex(OperationError, f"Secret {CEPH} is used by VMs db-2; pools ceph-rbd"):
|
||||
conn.delete_secret(CEPH)
|
||||
conn.wvm.secretLookupByUUIDString.return_value.undefine.assert_not_called()
|
||||
|
||||
def test_an_unused_secret_is_deleted(self):
|
||||
conn = connection(domains=[vm("web-1", f"<disk><auth><secret type='ceph' uuid='{CEPH}'/></auth></disk>")])
|
||||
conn.delete_secret(ISCSI)
|
||||
conn.wvm.secretLookupByUUIDString.assert_called_once_with(ISCSI)
|
||||
conn.wvm.secretLookupByUUIDString.return_value.undefine.assert_called_once_with()
|
||||
from vrtManager.tests.fakes import CEPH
|
||||
|
||||
|
||||
class SecretInUsePageTests(TestCase):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""libvirt's test driver for the network tests."""
|
||||
"""libvirt's test driver as the network tests need it."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
318
vrtManager/tests/fakes.py
Normal file
318
vrtManager/tests/fakes.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
"""Fake libvirt hosts, domains and node devices that tests of several
|
||||
modules share: the migration source and destination, the host devices of
|
||||
a q35 host, and the IOMMU groups of the PCI passthrough rules."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
from vrtManager.instance import wvmInstances
|
||||
|
||||
|
||||
def no_domain():
|
||||
error = libvirt.libvirtError("Domain not found")
|
||||
error.err = (libvirt.VIR_ERR_NO_DOMAIN,)
|
||||
return error
|
||||
|
||||
|
||||
class FakeDomain:
|
||||
def __init__(self, xml="<domain/>", snapshots=0, saved=False, undefine_fails=False, undefine_lost=False,
|
||||
started=False):
|
||||
self.calls = []
|
||||
self.xml = xml
|
||||
self.snapshots = snapshots
|
||||
self.saved = saved
|
||||
self.undefine_fails = undefine_fails
|
||||
self.undefine_lost = undefine_lost # undefined, but the reply is lost
|
||||
self.started = started # started by another client during the move
|
||||
self.undefined = None
|
||||
self.redefined = []
|
||||
self.restored_autostart = None
|
||||
|
||||
def migrate(self, dconn, flags, dname, uri, bandwidth):
|
||||
self.calls.append((flags, uri))
|
||||
|
||||
def name(self):
|
||||
return "vm"
|
||||
|
||||
def UUIDString(self):
|
||||
return "u-1"
|
||||
|
||||
inactive_xml = None # the persistent definition, when it differs
|
||||
|
||||
def XMLDesc(self, flags):
|
||||
if flags & libvirt.VIR_DOMAIN_XML_INACTIVE and self.inactive_xml:
|
||||
return self.inactive_xml
|
||||
return self.xml
|
||||
|
||||
def snapshotNum(self, flags=0):
|
||||
return self.snapshots
|
||||
|
||||
def hasManagedSaveImage(self, flags=0):
|
||||
return self.saved
|
||||
|
||||
def undefineFlags(self, flags):
|
||||
if self.undefine_fails:
|
||||
raise libvirt.libvirtError("undefine failed")
|
||||
self.undefined = flags
|
||||
if self.undefine_lost:
|
||||
raise libvirt.libvirtError("Cannot recv data")
|
||||
|
||||
def isPersistent(self):
|
||||
return self.undefined is None or bool(self.redefined)
|
||||
|
||||
def isActive(self):
|
||||
if not self.started and self.undefined is not None:
|
||||
raise no_domain() # libvirt: an undefined shut-off VM is gone
|
||||
return self.started
|
||||
|
||||
def autostart(self):
|
||||
return 1
|
||||
|
||||
def setAutostart(self, value):
|
||||
self.restored_autostart = value
|
||||
|
||||
def connect(self):
|
||||
return self
|
||||
|
||||
def defineXML(self, xml):
|
||||
self.redefined.append(xml)
|
||||
return self
|
||||
|
||||
|
||||
def no_volume():
|
||||
error = libvirt.libvirtError("Storage volume not found")
|
||||
error.err = (libvirt.VIR_ERR_NO_STORAGE_VOL,)
|
||||
return error
|
||||
|
||||
|
||||
class FakeVolume:
|
||||
def __init__(self, backing):
|
||||
self.backing = backing
|
||||
|
||||
def XMLDesc(self, flags=0):
|
||||
if self.backing is None:
|
||||
return "<volume><target><path>x</path></target></volume>"
|
||||
return f"<volume><backingStore><path>{self.backing}</path></backingStore></volume>"
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, host, active=True):
|
||||
self.host = host
|
||||
self.active = active
|
||||
|
||||
def isActive(self):
|
||||
return self.active
|
||||
|
||||
def XMLDesc(self, flags=0):
|
||||
return "<pool><target><path>/elsewhere</path></target></pool>"
|
||||
|
||||
def refresh(self, flags=0):
|
||||
self.host.refreshes += 1
|
||||
self.host.volumes |= self.host.unlisted
|
||||
self.host.unlisted = set()
|
||||
|
||||
def storageVolLookupByName(self, name):
|
||||
return self.host.storageVolLookupByPath(name)
|
||||
|
||||
|
||||
class FakeHost:
|
||||
"""The destination's libvirt connection. volumes: the paths its pools
|
||||
list; unlisted: files its pools show after a refresh."""
|
||||
|
||||
def __init__(self, existing=(), volumes=(), unlisted=(), backing=None, inactive_pools=()):
|
||||
self.existing = set(existing)
|
||||
self.defined = []
|
||||
self.new = []
|
||||
self.volumes = set(volumes)
|
||||
self.unlisted = set(unlisted)
|
||||
self.backing = backing or {} # path: the path of its backing file
|
||||
self.inactive_pools = set(inactive_pools)
|
||||
self.refreshes = 0
|
||||
|
||||
def storageVolLookupByPath(self, path):
|
||||
if path not in self.volumes:
|
||||
raise no_volume()
|
||||
return FakeVolume(self.backing.get(path))
|
||||
|
||||
def storagePoolLookupByName(self, name):
|
||||
return FakePool(self, active=name not in self.inactive_pools)
|
||||
|
||||
def listAllStoragePools(self, flags=0):
|
||||
return [FakePool(self)]
|
||||
|
||||
def lookup(self, key):
|
||||
if key in self.existing:
|
||||
return FakeDomain()
|
||||
raise no_domain()
|
||||
|
||||
lookupByName = lookupByUUIDString = lookup
|
||||
|
||||
def defineXML(self, xml):
|
||||
self.defined.append(xml)
|
||||
self.new.append(FakeDomain())
|
||||
return self.new[-1]
|
||||
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, state, nvram=None, **domain):
|
||||
self.state = state
|
||||
self.nvram = nvram
|
||||
self.instance = FakeDomain(**domain)
|
||||
|
||||
def get_nvram(self):
|
||||
return self.nvram
|
||||
|
||||
reads = 0
|
||||
|
||||
def get_status(self):
|
||||
self.reads += 1
|
||||
return self.state
|
||||
|
||||
def get_arch(self):
|
||||
return "x86_64"
|
||||
|
||||
def get_dom_emulator(self):
|
||||
return "/usr/bin/qemu-system-x86_64"
|
||||
|
||||
|
||||
def destination(host=None):
|
||||
dest = wvmInstances.__new__(wvmInstances)
|
||||
dest.wvm = host or FakeHost()
|
||||
dest.get_emulator = lambda arch: "/usr/bin/qemu-system-x86_64"
|
||||
return dest
|
||||
|
||||
|
||||
def with_disks(*disks):
|
||||
return "<domain><devices>%s</devices></domain>" % "".join(disks)
|
||||
|
||||
|
||||
def pci(slot, product, code, driver=None, group=None):
|
||||
iommu = f"<iommuGroup number='{group}'><address domain='0x0000' bus='0x00' slot='{slot}' function='0x0'/></iommuGroup>" \
|
||||
if group is not None else ""
|
||||
driver = f"<driver><name>{driver}</name></driver>" if driver else ""
|
||||
return (f"pci_0000_00_{int(slot, 16):02x}_0", f"""<device><name>pci_0000_00_{int(slot, 16):02x}_0</name><parent>computer</parent>{driver}
|
||||
<capability type='pci'><class>{code}</class><domain>0</domain><bus>0</bus><slot>{int(slot, 16)}</slot><function>0</function>
|
||||
<product id='0x1000'>{product}</product><vendor id='0x1af4'>Red Hat, Inc.</vendor>{iommu}</capability></device>""")
|
||||
|
||||
|
||||
def child(name, parent, capability):
|
||||
return name, f"<device><name>{name}</name><parent>{parent}</parent>{capability}</device>"
|
||||
|
||||
|
||||
def usb(name, parent, bus, device, product="QEMU Tablet", vendor_id="0x0627", product_id="0x0001"):
|
||||
return name, f"""<device><name>{name}</name><parent>{parent}</parent><capability type='usb_device'><bus>{bus}</bus>
|
||||
<device>{device}</device><product id='{product_id}'>{product}</product><vendor id='{vendor_id}'>Adomax</vendor>
|
||||
</capability></device>"""
|
||||
|
||||
|
||||
def interface(name, parent, code):
|
||||
return child(name, parent, f"<capability type='usb'><number>0</number><class>{code}</class></capability>")
|
||||
|
||||
|
||||
NODES = [
|
||||
pci("0x06", "Virtio network device", "0x020000", "virtio-pci", group=3),
|
||||
child("net_eth0", "pci_0000_00_06_0", "<capability type='net'><interface>eth0</interface></capability>"),
|
||||
pci("0x03", "Virtio block device", "0x010000", "virtio-pci", group=2),
|
||||
child("virtio1", "pci_0000_00_03_0", "<capability type='system'/>"), # a level between, not a resource
|
||||
child("block_vda", "virtio1", "<capability type='storage'><block>/dev/vda</block></capability>"),
|
||||
pci("0x02", "Virtio 1.0 GPU", "0x030000", "virtio-pci", group=2),
|
||||
child("drm_card0", "pci_0000_00_02_0", "<devnode type='dev'>/dev/dri/card0</devnode><capability type='drm'><type>primary</type></capability>"),
|
||||
child("drm_render", "pci_0000_00_02_0", "<devnode type='dev'>/dev/dri/renderD128</devnode><capability type='drm'><type>render</type></capability>"),
|
||||
pci("0x0a", "Ethernet controller", "0x020000", "vfio-pci", group=4),
|
||||
pci("0x01", "PIIX3 USB", "0x0c0300", "uhci_hcd"),
|
||||
usb("usb_usb1", "pci_0000_00_01_0", 1, 1, "1.1 root hub", "0x1d6b", "0x0001"),
|
||||
interface("usb_1_0_1_0", "usb_usb1", 9),
|
||||
usb("usb_1_1", "usb_usb1", 1, 2),
|
||||
interface("usb_1_1_1_0", "usb_1_1", 3),
|
||||
usb("usb_1_2", "usb_usb1", 1, 3),
|
||||
interface("usb_1_2_1_0", "usb_1_2", 3),
|
||||
]
|
||||
|
||||
|
||||
def node(name, xml):
|
||||
return MagicMock(**{"name.return_value": name, "XMLDesc.return_value": xml})
|
||||
|
||||
|
||||
BY_MODEL = "<hostdev mode='subsystem' type='usb' managed='yes'><source{auto}><vendor id='0x0627'/><product id='0x0001'/>{address}</source></hostdev>"
|
||||
|
||||
|
||||
def by_model(address="", auto=""):
|
||||
return BY_MODEL.format(address=address, auto=auto)
|
||||
|
||||
|
||||
def domain(*devices):
|
||||
return f"<domain><name>kiosk-1</name><devices>{''.join(devices)}</devices></domain>"
|
||||
|
||||
|
||||
def device(slot, product, code, driver, group, members=None, bridge=False):
|
||||
"""A PCI node of an IOMMU test host: a device on bus 0 at slot, in IOMMU group with the slots members (its own by default)."""
|
||||
members = members or [slot]
|
||||
addresses = "".join(f"<address domain='0x0000' bus='0x00' slot='0x{m:02x}' function='0x0'/>" for m in members)
|
||||
driver_xml = f"<driver><name>{driver}</name></driver>" if driver else ""
|
||||
bridge_xml = "<capability type='pci-bridge'/>" if bridge else ""
|
||||
xml = (f"<device><name>pci_0000_00_{slot:02x}_0</name><parent>computer</parent>{driver_xml}"
|
||||
f"<capability type='pci'><class>{code}</class><domain>0</domain><bus>0</bus><slot>{slot}</slot><function>0</function>"
|
||||
f"<product id='0x0001'>{product}</product><vendor id='0x8086'>Intel</vendor>{bridge_xml}"
|
||||
f"<iommuGroup number='{group}'>{addresses}</iommuGroup></capability></device>")
|
||||
return f"pci_0000_00_{slot:02x}_0", etree.fromstring(xml)
|
||||
|
||||
|
||||
IOMMU_NODES = dict([
|
||||
device(0x02, "PCIe Root port", "0x060400", "pcieport", 2, bridge=True),
|
||||
device(0x03, "SATA controller", "0x010601", "ahci", 7, [0x03, 0x04, 0x05]),
|
||||
device(0x04, "SMBus controller", "0x0c0500", "i801_smbus", 7, [0x03, 0x04, 0x05]),
|
||||
device(0x05, "LPC controller", "0x060100", "lpc_ich", 7, [0x03, 0x04, 0x05]), # class 0x06, not a bridge
|
||||
device(0x06, "Gigabit NIC", "0x020000", "e1000e", 12),
|
||||
device(0x07, "Spare NIC", "0x020000", "vfio-pci", 13),
|
||||
device(0x08, "NIC port 0", "0x020000", "vfio-pci", 14, [0x08, 0x09, 0x0a]),
|
||||
device(0x09, "NIC port 1", "0x020000", None, 14, [0x08, 0x09, 0x0a]),
|
||||
device(0x0a, "Downstream port", "0x060400", "pcieport", 14, [0x08, 0x09, 0x0a], bridge=True),
|
||||
device(0x0b, "Accelerator", "0x120000", "vfio-pci", 15, [0x0b, 0x0c]), # 0x0c cannot be read
|
||||
device(0x0d, "GPU", "0x030000", "nouveau", 16, [0x0d, 0x0e]),
|
||||
device(0x0e, "GPU audio", "0x040300", "snd_hda_intel", 16, [0x0d, 0x0e]),
|
||||
])
|
||||
# what the host has below: a disk on the SATA controller, its interface on the e1000e
|
||||
IOMMU_NODES["block_sda"] = etree.fromstring("<device><name>block_sda</name><parent>pci_0000_00_03_0</parent>"
|
||||
"<capability type='storage'><block>/dev/sda</block></capability></device>")
|
||||
IOMMU_NODES["net_eth1"] = etree.fromstring("<device><name>net_eth1</name><parent>pci_0000_00_06_0</parent>"
|
||||
"<capability type='net'><interface>eth1</interface></capability></device>")
|
||||
|
||||
|
||||
def vm(name, hostdevs, saved=None):
|
||||
"""A VM with hostdevs as it runs and, given saved, persistent with those for its next start."""
|
||||
def describe(flags=0):
|
||||
return f"<domain><devices>{saved if flags else hostdevs}</devices></domain>"
|
||||
return MagicMock(**{"name.return_value": name, "XMLDesc.side_effect": describe, "isPersistent.return_value": saved is not None})
|
||||
|
||||
|
||||
PCI_0A = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x0a' function='0x0'/></source></hostdev>"
|
||||
|
||||
|
||||
USB_1_3 = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><product id='0x0001'/><address bus='1' device='3'/></source></hostdev>"
|
||||
|
||||
|
||||
DUID = "00:01:00:01:2e:8b:3c:41:52:54:00:00:00:40"
|
||||
|
||||
|
||||
def lease(ip, mac=None, expiry=1793265489, hostname=None, clientid=None, family=0):
|
||||
return {"iface": "virbr9", "expirytime": expiry, "type": family, "mac": mac, "ipaddr": ip, "prefix": 24,
|
||||
"hostname": hostname, "clientid": clientid, "iaid": None}
|
||||
|
||||
|
||||
# a host with a NAT network that hands out addresses (libvirt's test driver node file)
|
||||
NAT_NETWORK_NODE = """<node>
|
||||
<network><name>lab</name><forward mode='nat'/><bridge name='virbr9'/>
|
||||
<ip address='192.0.2.1' netmask='255.255.255.0'><dhcp><range start='192.0.2.10' end='192.0.2.20'/>
|
||||
<host mac='52:54:00:00:00:31' name='web' ip='192.0.2.31'/></dhcp></ip>
|
||||
<ip family='ipv6' address='2001:db8:1::1' prefix='64'><dhcp><host id='0:1:0:1' ip='2001:db8:1::31'/></dhcp></ip>
|
||||
</network>
|
||||
<network><name>lan</name><forward mode='bridge'/><bridge name='br0'/></network>
|
||||
<network><name>direct</name><forward mode='bridge'><interface dev='eth0'/></forward></network>
|
||||
<domain type='test'><name>web</name><memory>1024</memory><os><type>hvm</type></os>
|
||||
<devices><interface type='network'><source network='lab'/></interface></devices></domain>
|
||||
</node>"""
|
||||
|
||||
|
||||
CEPH = "8f2c0000-0000-4000-8000-000000000001"
|
||||
|
|
@ -10,6 +10,7 @@ changed.
|
|||
import os
|
||||
|
||||
import libvirt
|
||||
import time
|
||||
|
||||
PREFIX = "wvc-test-"
|
||||
POOL = "wvc-test"
|
||||
|
|
@ -125,3 +126,12 @@ def volume_exists(conn, path):
|
|||
pool = conn.storagePoolLookupByName(POOL)
|
||||
pool.refresh(0)
|
||||
return os.path.basename(path) in pool.listVolumes()
|
||||
|
||||
|
||||
def wait_for(predicate, seconds):
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
|
@ -3,6 +3,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
from lxml import etree
|
||||
from vrtManager.instance import PERSISTENT_XML, wvmInstance
|
||||
from django.test import TestCase
|
||||
|
||||
LIVE = "<domain><devices><graphics type='vnc' passwd='live' keymap='en-us'><listen type='address'/></graphics></devices></domain>"
|
||||
PERSISTENT = "<domain><devices><graphics type='vnc' passwd='old'><listen type='address'/></graphics></devices></domain>"
|
||||
|
|
@ -39,3 +40,29 @@ class ConsoleSettersTestCase(unittest.TestCase):
|
|||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ConsoleListensLocallyTests(TestCase):
|
||||
"""Only a binding the persistent XML pins to loopback (or a Unix socket) is local."""
|
||||
|
||||
def listens_locally(self, graphics):
|
||||
proxy = wvmInstance.__new__(wvmInstance) # no connection: only the XML is read
|
||||
xml = f"<domain><devices>{graphics}</devices></domain>"
|
||||
proxy._XMLDesc = lambda flags: xml if flags == PERSISTENT_XML else "<wrong-flags/>"
|
||||
return proxy.console_listens_locally()
|
||||
|
||||
def test_cases(self):
|
||||
cases = {
|
||||
"<graphics type='vnc' listen='127.0.0.1'><listen type='address' address='127.0.0.1'/></graphics>": True,
|
||||
"<graphics type='vnc'><listen type='address' address='::1'/></graphics>": True,
|
||||
"<graphics type='vnc'><listen type='socket' socket='/run/vnc.sock'/></graphics>": True,
|
||||
"": True, # no console
|
||||
"<graphics type='vnc' listen='0.0.0.0'><listen type='address' address='0.0.0.0'/></graphics>": False,
|
||||
"<graphics type='vnc'><listen type='network' network='default'/></graphics>": False,
|
||||
"<graphics type='vnc' autoport='yes'/>": False, # the host default decides
|
||||
"<graphics type='vnc'><listen type='address' address='localhost'/></graphics>": False,
|
||||
"<graphics type='vnc' listen='127.0.0.1'><listen type='address' address='10.0.0.5'/></graphics>": False,
|
||||
}
|
||||
for graphics, expected in cases.items():
|
||||
with self.subTest(graphics=graphics):
|
||||
self.assertIs(self.listens_locally(graphics), expected)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from django.test import SimpleTestCase
|
|||
from lxml import etree
|
||||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager.create import wvmCreate
|
||||
from vrtManager.create import PCIE_ROOT_PORTS
|
||||
|
||||
FULL = """<domainCapabilities><os supported='yes'>
|
||||
<enum name='firmware'><value>efi</value></enum>
|
||||
|
|
@ -124,3 +125,59 @@ class DefinitionTests(SimpleTestCase):
|
|||
tpm = defined("UEFI Secure Boot", tpm_model="tpm-crb").find("devices/tpm")
|
||||
self.assertEqual((tpm.get("model"), tpm.find("backend").get("type"), tpm.find("backend").get("version")),
|
||||
("tpm-crb", "emulator", "2.0"))
|
||||
|
||||
|
||||
CPU_DOM_CAPS = {"domain": "kvm", "os_support": "no", "cpu_modes": ["host-passthrough", "maximum", "host-model", "custom"],
|
||||
"cpu_custom_models": ["Skylake-Server-noTSX-IBRS", "SandyBridge-IBRS"]}
|
||||
|
||||
|
||||
def defined_cpu(vcpu_mode):
|
||||
conn = wvmCreate.__new__(wvmCreate)
|
||||
conn.wvm = MagicMock()
|
||||
with patch.object(wvmCreate, "get_capabilities", return_value={"features": []}), \
|
||||
patch.object(wvmCreate, "get_dom_capabilities", return_value=CPU_DOM_CAPS):
|
||||
conn.create_instance(name="vm-1", memory=512, vcpu=1, vcpu_mode=vcpu_mode,
|
||||
uuid="00000000-0000-4000-8000-0000000000c1", arch="x86_64", machine="q35",
|
||||
firmware="BIOS", volumes=[], networks="", nwfilter="", virtio=True, listener_addr="0.0.0.0",
|
||||
add_cdrom="None", add_input="None")
|
||||
return etree.fromstring(conn.wvm.defineXML.call_args.args[0]).find("cpu")
|
||||
|
||||
|
||||
class CpuModeTests(SimpleTestCase):
|
||||
def test_a_mode_is_written_as_the_mode(self):
|
||||
for mode in ("host-model", "host-passthrough", "maximum"):
|
||||
with self.subTest(mode=mode):
|
||||
cpu = defined_cpu(mode)
|
||||
self.assertEqual((dict(cpu.attrib), len(cpu)), ({"mode": mode}, 0))
|
||||
|
||||
def test_a_named_model_is_that_model_checked_by_libvirt(self):
|
||||
# a name longer than 20 characters too
|
||||
cpu = defined_cpu("Skylake-Server-noTSX-IBRS")
|
||||
self.assertEqual(dict(cpu.attrib), {"mode": "custom", "match": "exact"}) # no check='none'
|
||||
self.assertEqual((cpu.findtext("model"), cpu.find("model").get("fallback")), ("Skylake-Server-noTSX-IBRS", "forbid"))
|
||||
|
||||
|
||||
PORTS_DOM_CAPS = {"domain": "kvm", "os_support": "no", "cpu_modes": ["host-model"], "cpu_custom_models": [],
|
||||
"disk_bus": ["virtio", "sata", "scsi"]}
|
||||
|
||||
|
||||
def defined_controllers(machine):
|
||||
conn = wvmCreate.__new__(wvmCreate)
|
||||
conn.wvm = MagicMock()
|
||||
with patch.object(wvmCreate, "get_capabilities", return_value={"features": []}), \
|
||||
patch.object(wvmCreate, "get_dom_capabilities", return_value=PORTS_DOM_CAPS):
|
||||
conn.create_instance(name="vm-1", memory=512, vcpu=1, vcpu_mode="host-model",
|
||||
uuid="00000000-0000-4000-8000-0000000000c2", arch="x86_64", machine=machine,
|
||||
firmware="BIOS", volumes=[], networks="default", nwfilter="", virtio=True,
|
||||
listener_addr="0.0.0.0", add_cdrom="None", add_input="None")
|
||||
domain = etree.fromstring(conn.wvm.defineXML.call_args.args[0])
|
||||
return [c.get("model") for c in domain.findall("devices/controller[@type='pci']")]
|
||||
|
||||
|
||||
class NewVmPortsTests(SimpleTestCase):
|
||||
def test_a_new_q35_vm_has_spare_root_ports(self):
|
||||
# libvirt puts the VM's devices into these and adds none: the rest are free
|
||||
self.assertEqual(defined_controllers("pc-q35-rhel9.8.0"), ["pcie-root"] + ["pcie-root-port"] * PCIE_ROOT_PORTS)
|
||||
|
||||
def test_a_pc_vm_has_none_its_pci_bus_has_slots(self):
|
||||
self.assertEqual(defined_controllers("pc-i440fx-rhel7.6.0"), [])
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""External snapshots go through libvirt's snapshot API; WebVirtCloud only
|
||||
checks preconditions and picks the flags. libvirt is mocked here; the live
|
||||
cases are in instances/tests/test_live_snapshots.py."""
|
||||
cases are in vrtManager/tests/test_live_snapshots.py."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
|
@ -714,6 +714,7 @@ class PoolRefreshTestCase(unittest.TestCase):
|
|||
p.get_volume_by_path = MagicMock()
|
||||
self.assertEqual(getattr(p, action)("one"), [])
|
||||
|
||||
|
||||
class KindTestCase(unittest.TestCase):
|
||||
def test_internal_endpoints_refuse_external_names(self):
|
||||
snap = fake_snap("e")
|
||||
|
|
|
|||
261
vrtManager/tests/test_host_devices.py
Normal file
261
vrtManager/tests/test_host_devices.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""A VM's host devices in vrtManager: USB devices given and taken back,
|
||||
each named once whether the running VM or its next start has it, PCI
|
||||
devices plugged into a running VM, and a VM with any is not migrated."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import libvirt
|
||||
from django.test import SimpleTestCase
|
||||
from lxml import etree
|
||||
|
||||
from vrtManager.instance import wvmInstance
|
||||
from vrtManager.tests.fakes import FakeSource, by_model, destination, domain, with_disks
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
MIGRATABLE = libvirt.VIR_DOMAIN_XML_MIGRATABLE
|
||||
|
||||
|
||||
CONTROLLER = "<controller type='usb' model='qemu-xhci'/>"
|
||||
|
||||
|
||||
PCI_05 = ("<hostdev mode='subsystem' type='pci' managed='yes'><source>"
|
||||
"<address domain='0x0000' bus='0x05' slot='0x00' function='0x0'/></source></hostdev>")
|
||||
|
||||
|
||||
def vm(running, saved, active=True, migratable=None):
|
||||
"""A wvmInstance whose running VM is running, as XMLDesc gives it (and as
|
||||
the migratable XML marks it), and whose next start is saved."""
|
||||
proxy = wvmInstance.__new__(wvmInstance)
|
||||
proxy._read_cache = None
|
||||
proxy.instance = MagicMock(**{"isActive.return_value": active, "hasManagedSaveImage.return_value": 0,
|
||||
"info.return_value": [libvirt.VIR_DOMAIN_RUNNING if active else libvirt.VIR_DOMAIN_SHUTOFF]})
|
||||
|
||||
def describe(flags=0):
|
||||
if flags & libvirt.VIR_DOMAIN_XML_INACTIVE:
|
||||
return saved
|
||||
return migratable if flags & MIGRATABLE and migratable else running
|
||||
proxy.instance.XMLDesc.side_effect = describe
|
||||
return proxy
|
||||
|
||||
|
||||
class UsbHostdevTests(SimpleTestCase):
|
||||
def test_a_device_chosen_by_model_is_one_row_with_the_device_it_holds(self):
|
||||
# the running VM writes the address libvirt resolved; only its migratable XML marks it
|
||||
proxy = vm(domain(CONTROLLER, by_model("<address bus='1' device='3'/>")), domain(CONTROLLER, by_model()),
|
||||
migratable=domain(CONTROLLER, by_model("<address bus='1' device='3'/>", " autoAddress='yes'")))
|
||||
usb, pci, others = proxy.get_hostdevs()
|
||||
self.assertEqual(usb, [{"vendor": "0x0627", "product": "0x0001", "address": None, "now": (1, 3), "next": True}])
|
||||
self.assertEqual((pci, others), ([], []))
|
||||
|
||||
def test_a_device_is_taken_from_each_definition_as_it_describes_it(self):
|
||||
proxy = vm(domain(CONTROLLER, by_model("<address bus='1' device='3'/>")), domain(CONTROLLER, by_model()),
|
||||
migratable=domain(CONTROLLER, by_model("<address bus='1' device='3'/>", " autoAddress='yes'")))
|
||||
proxy.delete_usb_hostdev(("0x0627", "0x0001", None))
|
||||
live, config = proxy.instance.detachDeviceFlags.call_args_list
|
||||
self.assertEqual(live.args[1], libvirt.VIR_DOMAIN_AFFECT_LIVE)
|
||||
self.assertEqual(etree.fromstring(live.args[0]).xpath("string(source/address/@device)"), "3")
|
||||
self.assertEqual((config.args[1], etree.fromstring(config.args[0]).find("source/address")),
|
||||
(libvirt.VIR_DOMAIN_AFFECT_CONFIG, None))
|
||||
with self.assertRaisesRegex(OperationError, "no such USB device"):
|
||||
proxy.delete_usb_hostdev(("0x0627", "0x0001", (1, 2)))
|
||||
|
||||
def test_a_device_is_given_by_model_or_by_address_with_a_usb_controller(self):
|
||||
proxy = vm(domain(CONTROLLER), domain(CONTROLLER))
|
||||
proxy.add_usb_hostdev("0x0627", "0x0001", (1, 3))
|
||||
added = etree.fromstring(proxy.instance.attachDeviceFlags.call_args.args[0])
|
||||
self.assertEqual((added.get("type"), added.xpath("string(source/vendor/@id)"), added.xpath("string(source/address/@device)")),
|
||||
("usb", "0x0627", "3"))
|
||||
for xml in (domain(), domain("<controller type='usb' model='none'/>")):
|
||||
proxy = vm(xml, xml, active=False)
|
||||
with self.subTest(xml=xml), self.assertRaisesRegex(OperationError, "no USB controller"):
|
||||
proxy.add_usb_hostdev("0x0627", "0x0001")
|
||||
proxy.instance.attachDeviceFlags.assert_not_called()
|
||||
|
||||
def test_a_device_the_host_has_one_of_is_given_by_model(self):
|
||||
proxy = vm(domain(CONTROLLER), domain(CONTROLLER))
|
||||
proxy.add_usb_hostdev("0x0627", "0x0001")
|
||||
self.assertIsNone(etree.fromstring(proxy.instance.attachDeviceFlags.call_args.args[0]).find("source/address"))
|
||||
|
||||
def test_other_host_devices_as_the_running_vm_and_its_next_start_have_them(self):
|
||||
pci = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x0a' function='0x0'/></source></hostdev>"
|
||||
nic = "<interface type='hostdev'><source><address type='pci' domain='0x0000' bus='0x00' slot='0x0b' function='0x0'/></source></interface>"
|
||||
network_nic = ("<interface type='network'><source network='sriov'/><actual type='hostdev'><source>"
|
||||
"<address type='pci' domain='0x0000' bus='0x00' slot='0x0c' function='0x1'/></source></actual></interface>")
|
||||
mdev = "<hostdev mode='subsystem' type='mdev'><source><address uuid='{}'/></source></hostdev>"
|
||||
running = domain(pci, nic, network_nic, mdev.format("00000000-0000-4000-8000-00000000a001"))
|
||||
_, pci_rows, others = vm(running, domain(nic, mdev.format("00000000-0000-4000-8000-00000000a002"))).get_hostdevs()
|
||||
self.assertEqual(pci_rows, [{"address": "0000:00:0a.0", "now": True, "next": False, "managed": False}])
|
||||
self.assertEqual(others, [{"type": "nic", "address": "0000:00:0b.0", "now": True, "next": True},
|
||||
{"type": "nic", "address": "0000:00:0c.1", "now": True, "next": False},
|
||||
{"type": "mdev", "address": "00000000-0000-4000-8000-00000000a001", "now": True, "next": False},
|
||||
{"type": "mdev", "address": "00000000-0000-4000-8000-00000000a002", "now": False, "next": True}])
|
||||
|
||||
def test_a_shut_off_vm_gets_a_pci_device_for_its_next_start(self):
|
||||
proxy = vm("", domain(), active=False)
|
||||
proxy.add_pci_hostdev("0000:05:00.0")
|
||||
xml, flags = proxy.instance.attachDeviceFlags.call_args.args
|
||||
self.assertEqual(flags, libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
self.assertEqual((etree.fromstring(xml).get("managed"), dict(etree.fromstring(xml).find("source/address").attrib)),
|
||||
("yes", {"domain": "0x0000", "bus": "0x05", "slot": "0x00", "function": "0x0"}))
|
||||
proxy = vm("", domain(PCI_05), active=False)
|
||||
self.assertFalse(proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual([c.args[1] for c in proxy.instance.detachDeviceFlags.call_args_list], [libvirt.VIR_DOMAIN_AFFECT_CONFIG])
|
||||
with self.assertRaisesRegex(OperationError, "no PCI device 0000:06:00.0"):
|
||||
proxy.delete_pci_hostdev("0000:06:00.0")
|
||||
|
||||
def test_a_paused_vm_or_one_with_a_saved_state_keeps_its_pci_devices(self):
|
||||
paused = vm(domain(PCI_05), domain(PCI_05))
|
||||
paused.instance.info.return_value = [libvirt.VIR_DOMAIN_PAUSED]
|
||||
saved = vm("", domain(PCI_05), active=False)
|
||||
saved.instance.hasManagedSaveImage.return_value = 1
|
||||
for name, proxy in (("paused", paused), ("saved", saved)):
|
||||
for change in (proxy.add_pci_hostdev, proxy.delete_pci_hostdev):
|
||||
with self.subTest(vm=name, change=change.__name__), self.assertRaisesRegex(OperationError, "runs, or is shut off"):
|
||||
change("0000:05:00.0")
|
||||
proxy.instance.attachDeviceFlags.assert_not_called()
|
||||
proxy.instance.detachDeviceFlags.assert_not_called()
|
||||
|
||||
def test_a_nic_that_is_a_host_device_is_left_to_the_xml_editor(self):
|
||||
nic = "<interface type='hostdev'><mac address='52:54:00:00:00:0a'/><source><address type='pci' bus='0x00' slot='0x0a'/></source></interface>"
|
||||
proxy = vm(domain(nic), domain(nic))
|
||||
with self.assertRaisesRegex(OperationError, "XML editor"):
|
||||
proxy.delete_network("52:54:00:00:00:0a")
|
||||
with self.assertRaisesRegex(OperationError, "XML editor"):
|
||||
proxy.change_network("52:54:00:00:00:0a", "52:54:00:00:00:0b", "default", "net", "virtio", "")
|
||||
proxy.instance.detachDeviceFlags.assert_not_called()
|
||||
|
||||
def test_a_clone_gets_no_host_device_and_its_nics_keep_their_numbers(self):
|
||||
proxy = vm("", domain(by_model(),
|
||||
"<interface type='hostdev'><mac address='52:54:00:00:00:0a'/></interface>",
|
||||
"<interface type='network'><mac address='52:54:00:00:00:0b'/><source network='default'/></interface>"))
|
||||
proxy.get_instances = lambda: []
|
||||
tree, _ = proxy._clone_definition({"name": "kiosk-2", "clone-net-mac-1": "52:54:00:00:00:1b"},
|
||||
"99999999-2222-3333-4444-555555555555")
|
||||
self.assertEqual(tree.xpath("devices/hostdev"), [])
|
||||
self.assertEqual(tree.xpath("devices/interface/mac/@address"), ["52:54:00:00:00:1b"])
|
||||
|
||||
|
||||
class RunningVM:
|
||||
"""A running VM whose definitions change as libvirt changes them: the
|
||||
running one and the next start's, each with the PCI device or not.
|
||||
fail: the affect flag whose change libvirt fails, after doing it or not;
|
||||
keeps: the guest does not let the device go."""
|
||||
|
||||
def __init__(self, now=True, later=True, fail=None, done=False, keeps=False, machine="pc-i440fx-10.1"):
|
||||
self.has = {libvirt.VIR_DOMAIN_AFFECT_LIVE: now, libvirt.VIR_DOMAIN_AFFECT_CONFIG: later}
|
||||
self.fail, self.done, self.keeps, self.machine = fail, done, keeps, machine
|
||||
self.proxy = vm("", "")
|
||||
self.proxy.instance.XMLDesc.side_effect = self.describe
|
||||
self.proxy.instance.attachDeviceFlags.side_effect = lambda xml, flags: self.change(flags, True)
|
||||
self.proxy.instance.detachDeviceFlags.side_effect = lambda xml, flags: self.change(flags, False)
|
||||
|
||||
def describe(self, flags=0):
|
||||
saved = flags & libvirt.VIR_DOMAIN_XML_INACTIVE
|
||||
has = self.has[libvirt.VIR_DOMAIN_AFFECT_CONFIG if saved else libvirt.VIR_DOMAIN_AFFECT_LIVE]
|
||||
return f"<domain><os><type machine='{self.machine}'>hvm</type></os><devices>{PCI_05 if has else ''}</devices></domain>"
|
||||
|
||||
def change(self, flags, adding):
|
||||
for affect in (libvirt.VIR_DOMAIN_AFFECT_LIVE, libvirt.VIR_DOMAIN_AFFECT_CONFIG):
|
||||
if flags & affect:
|
||||
if affect == self.fail:
|
||||
if self.done:
|
||||
self.has[affect] = adding
|
||||
raise libvirt.libvirtError("refused")
|
||||
if adding or not (self.keeps and affect == libvirt.VIR_DOMAIN_AFFECT_LIVE):
|
||||
self.has[affect] = adding
|
||||
|
||||
|
||||
class PciHotplugTests(SimpleTestCase):
|
||||
def test_a_running_vm_gets_it_at_once_and_for_its_next_start(self):
|
||||
running = RunningVM(now=False, later=False)
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
self.assertEqual(running.proxy.instance.attachDeviceFlags.call_args.args[1],
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE | libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
self.assertEqual(list(running.has.values()), [True, True])
|
||||
|
||||
def test_a_hot_plug_that_fails_says_where_the_device_is(self):
|
||||
cases = ((libvirt.VIR_DOMAIN_AFFECT_CONFIG, False, "the running VM has it, its next start does not have it"),
|
||||
(libvirt.VIR_DOMAIN_AFFECT_CONFIG, True, "the running VM has it, its next start has it"),
|
||||
(libvirt.VIR_DOMAIN_AFFECT_LIVE, False, "the running VM does not have it, its next start does not have it"))
|
||||
for fail, done, where in cases:
|
||||
# libvirt plugs into the running VM first, then saves the next start
|
||||
running = RunningVM(now=False, later=False, fail=fail, done=done)
|
||||
if fail == libvirt.VIR_DOMAIN_AFFECT_CONFIG:
|
||||
running.has[libvirt.VIR_DOMAIN_AFFECT_LIVE] = True
|
||||
with self.subTest(where=where), self.assertRaisesRegex(OperationError, f"refused; PCI device 0000:05:00.0: {where}"):
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_hot_plug_whose_outcome_cannot_be_read_says_so(self):
|
||||
running = RunningVM(now=False, later=False)
|
||||
running.proxy.instance.attachDeviceFlags.side_effect = libvirt.libvirtError("connection reset")
|
||||
running.proxy.instance.XMLDesc.side_effect = libvirt.libvirtError("connection reset")
|
||||
with self.assertRaisesRegex(OperationError, "connection reset; whether the VM has PCI device 0000:05:00.0 now is not known"):
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_vm_that_stops_meanwhile_is_said_to_be_not_running(self):
|
||||
running = RunningVM(now=False, later=False, fail=libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
running.proxy.instance.isActive.return_value = False # its live XML is then the inactive one
|
||||
with self.assertRaisesRegex(OperationError, "the VM is not running, its next start does not have it"):
|
||||
running.proxy.add_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_running_vm_lets_it_go_as_its_guest_does(self):
|
||||
running = RunningVM()
|
||||
self.assertFalse(running.proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual(list(running.has.values()), [False, False])
|
||||
# not yet: the request stands, the next start no longer has it
|
||||
running = RunningVM(keeps=True)
|
||||
self.assertTrue(running.proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual(list(running.has.values()), [True, False])
|
||||
|
||||
def test_a_guest_that_refuses_keeps_it_for_its_next_start_too(self):
|
||||
running = RunningVM(fail=libvirt.VIR_DOMAIN_AFFECT_LIVE)
|
||||
with self.assertRaisesRegex(OperationError, "refused; .*the running VM has it, its next start has it"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
self.assertEqual(list(running.has.values()), [True, True])
|
||||
# removed from the running VM, then an error: said, and the next start keeps it
|
||||
running = RunningVM(fail=libvirt.VIR_DOMAIN_AFFECT_LIVE, done=True)
|
||||
with self.assertRaisesRegex(OperationError, "the running VM does not have it, its next start has it"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_failed_removal_from_the_next_start_of_a_running_vm_is_said(self):
|
||||
running = RunningVM(now=False, later=True, fail=libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
with self.assertRaisesRegex(OperationError, "the running VM does not have it, its next start has it"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_removal_whose_outcome_cannot_be_read_says_so(self):
|
||||
running = RunningVM()
|
||||
reads = []
|
||||
|
||||
def describe(flags=0): # the reads before the removal work, the one after it does not
|
||||
reads.append(flags)
|
||||
if len(reads) > 2:
|
||||
raise libvirt.libvirtError("connection reset")
|
||||
return running.describe(flags)
|
||||
running.proxy.instance.XMLDesc.side_effect = describe
|
||||
with self.assertRaisesRegex(OperationError, "connection reset; whether the VM has PCI device 0000:05:00.0 now is not known"):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_next_start_that_keeps_it_after_the_running_vm_let_it_go_is_said(self):
|
||||
for keeps, where in ((False, "the running VM does not have it, its next start has it"),
|
||||
(True, "the running VM has it, its next start has it")):
|
||||
running = RunningVM(fail=libvirt.VIR_DOMAIN_AFFECT_CONFIG, keeps=keeps)
|
||||
with self.subTest(keeps=keeps), self.assertRaisesRegex(OperationError, where):
|
||||
running.proxy.delete_pci_hostdev("0000:05:00.0")
|
||||
|
||||
def test_a_device_in_one_definition_only_is_taken_from_it(self):
|
||||
for now, later in ((True, False), (False, True)):
|
||||
running = RunningVM(now=now, later=later)
|
||||
with self.subTest(now=now, later=later):
|
||||
self.assertFalse(running.proxy.delete_pci_hostdev("0000:05:00.0"))
|
||||
self.assertEqual(list(running.has.values()), [False, False])
|
||||
self.assertEqual(running.proxy.instance.detachDeviceFlags.call_count, 1)
|
||||
|
||||
|
||||
class MigrationTests(unittest.TestCase):
|
||||
def test_a_vm_with_host_devices_is_not_migrated(self):
|
||||
for devices, state in ((by_model(), 1), (by_model(), 5), ("<hostdev mode='subsystem' type='pci'/>", 1)):
|
||||
source = FakeSource(state, xml=with_disks(devices))
|
||||
with self.subTest(state=state), self.assertRaisesRegex(OperationError, "host devices cannot be migrated"):
|
||||
destination().moveto(source, "vm", live=state == 1, unsafe=False, offline=state == 5)
|
||||
self.assertEqual(source.instance.calls, [])
|
||||
|
|
@ -23,6 +23,9 @@ from libvirt import (
|
|||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager.hostdetails import wvmHostDetails
|
||||
from vrtManager.storage import wvmStorage, wvmStorages
|
||||
from unittest import TestCase as UnitTestCase
|
||||
from unittest.mock import patch
|
||||
from vrtManager.hostdetails import cpu_percent
|
||||
|
||||
CAPABILITIES = """<capabilities><host><cpu><arch>x86_64</arch></cpu></host>
|
||||
<guest><os_type>hvm</os_type><arch name='x86_64'><emulator>/usr/bin/qemu-kvm</emulator>
|
||||
|
|
@ -270,3 +273,49 @@ class VolumesTestCase(unittest.TestCase):
|
|||
stg = self.storage([])
|
||||
stg.pool.storageVolLookupByName.return_value = volume("a.img", 4096, 0, "raw")
|
||||
self.assertEqual(stg.get_volume_details("a.img"), {"name": "a.img", "size": 4096, "allocation": 0, "type": "raw"})
|
||||
|
||||
|
||||
def host(*readings):
|
||||
"""A wvmHostDetails whose getCPUStats returns readings, (idle, busy) each."""
|
||||
h = wvmHostDetails.__new__(wvmHostDetails)
|
||||
h.wvm = MagicMock()
|
||||
h.wvm.getCPUStats.side_effect = [
|
||||
{"kernel": busy // 2, "user": busy - busy // 2, "idle": idle, "iowait": 0, "guest": busy // 3, "utilization": 99}
|
||||
for idle, busy in readings
|
||||
]
|
||||
return h
|
||||
|
||||
|
||||
class CpuPercentTestCase(UnitTestCase):
|
||||
def test_busy_share_between_two_samples(self):
|
||||
self.assertEqual(cpu_percent((100, 200), (130, 300)), 70.0)
|
||||
self.assertEqual(cpu_percent((0, 0), (1, 3)), 66.7)
|
||||
|
||||
def test_samples_that_do_not_follow_on(self):
|
||||
for before, after in (((100, 200), (100, 200)), ((100, 200), (50, 100)), ((100, 200), (200, 250))):
|
||||
with self.subTest(before=before, after=after):
|
||||
self.assertIsNone(cpu_percent(before, after))
|
||||
|
||||
|
||||
@patch("vrtManager.hostdetails.time")
|
||||
class CpuUsageTestCase(UnitTestCase):
|
||||
def test_since_a_previous_sample_without_waiting(self, clock):
|
||||
clock.time.return_value = 105.0
|
||||
h = host((130, 70))
|
||||
usage = h.get_cpu_usage(previous=(100, 100, 100.0))
|
||||
clock.sleep.assert_not_called()
|
||||
# total = kernel + user + idle + iowait; guest (inside user) and utilization are left out
|
||||
self.assertEqual(usage, {"usage": 70.0, "sample": (130, 200, 105.0), "window": 5.0})
|
||||
|
||||
def test_over_one_second_without_a_sample(self, clock):
|
||||
clock.time.side_effect = [10.0, 11.0]
|
||||
h = host((100, 100), (110, 190))
|
||||
self.assertEqual(h.get_cpu_usage(), {"usage": 90.0, "sample": (110, 300, 11.0), "window": 1.0})
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
|
||||
def test_over_one_second_after_the_host_restarted(self, clock):
|
||||
clock.time.side_effect = [10.0, 11.0]
|
||||
h = host((5, 5), (6, 9))
|
||||
usage = h.get_cpu_usage(previous=(1000, 5000, 5.0))
|
||||
clock.sleep.assert_called_once_with(1)
|
||||
self.assertEqual((usage["usage"], usage["window"]), (80.0, 1.0))
|
||||
|
|
|
|||
131
vrtManager/tests/test_interface_xml.py
Normal file
131
vrtManager/tests/test_interface_xml.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""The interface XML wvmInterfaces writes from the form's values: IPv4,
|
||||
IPv6 and a bridge's member."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import libvirt
|
||||
from django.test import SimpleTestCase
|
||||
from lxml import etree
|
||||
|
||||
from vrtManager.interface import wvmInterfaces
|
||||
|
||||
|
||||
class Ipv6XmlTestCase(SimpleTestCase):
|
||||
def xml(self, gw):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
conn.get_iface = MagicMock()
|
||||
conn.get_iface.return_value.XMLDesc.return_value = "<interface type='ethernet' name='eth1'/>"
|
||||
conn.create_iface("br1", "bridge", "onboot", "eth1", "none", "", "", "static", "2001:db8::10/64", gw, "on", 0)
|
||||
return etree.fromstring(conn.define_iface.call_args[0][0])
|
||||
|
||||
def test_static_address_with_gateway(self):
|
||||
proto = self.xml("2001:db8::1").find("protocol[@family='ipv6']")
|
||||
self.assertEqual(proto.find("ip").attrib, {"address": "2001:db8::10", "prefix": "64"})
|
||||
self.assertEqual(proto.find("route").get("gateway"), "2001:db8::1")
|
||||
|
||||
def test_no_empty_route_without_gateway(self):
|
||||
self.assertIsNone(self.xml("").find("protocol[@family='ipv6']/route"))
|
||||
|
||||
|
||||
class Ipv4XmlTestCase(SimpleTestCase):
|
||||
def xml(self, addr, gw):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
conn.get_iface = MagicMock()
|
||||
conn.get_iface.return_value.XMLDesc.return_value = "<interface type='ethernet' name='eth1'/>"
|
||||
conn.create_iface("br1", "bridge", "onboot", "eth1", "static", addr, gw, "none", "", "", "on", 0)
|
||||
return etree.fromstring(conn.define_iface.call_args[0][0])
|
||||
|
||||
def test_static_address_with_gateway(self):
|
||||
proto = self.xml("192.0.2.10/24", "192.0.2.1").find("protocol[@family='ipv4']")
|
||||
self.assertEqual(proto.find("ip").attrib, {"address": "192.0.2.10", "prefix": "24"})
|
||||
self.assertEqual(proto.find("route").get("gateway"), "192.0.2.1")
|
||||
|
||||
def test_no_empty_route_without_gateway(self):
|
||||
self.assertIsNone(self.xml("192.0.2.10/24", "").find("protocol[@family='ipv4']/route"))
|
||||
|
||||
|
||||
def no_interface(name):
|
||||
err = libvirt.libvirtError("Interface not found: no interface with matching name '%s'" % name)
|
||||
err.err = (libvirt.VIR_ERR_NO_INTERFACE, libvirt.VIR_FROM_INTERFACE, str(err), 2, name, None, None, 0, 0)
|
||||
return err
|
||||
|
||||
|
||||
class BridgeMemberXmlTestCase(SimpleTestCase):
|
||||
"""A bridge member is declared as the host defines it: a bond keeps its
|
||||
mode and devices, a vlan its tag and device."""
|
||||
|
||||
def member(self, host_xml):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
|
||||
def get_iface(name):
|
||||
if host_xml is None:
|
||||
raise no_interface(name)
|
||||
iface = MagicMock()
|
||||
iface.XMLDesc.return_value = host_xml
|
||||
return iface
|
||||
|
||||
conn.get_iface = MagicMock(side_effect=get_iface)
|
||||
conn.create_iface("br1", "bridge", "onboot", "bond0", "none", "", "", "none", "", "", "on", 0)
|
||||
self.assertTrue(conn.define_iface.return_value.create.called)
|
||||
members = etree.fromstring(conn.define_iface.call_args[0][0]).findall("bridge/interface")
|
||||
self.assertEqual(len(members), 1)
|
||||
return members[0]
|
||||
|
||||
def test_a_bond_keeps_its_mode_and_devices(self):
|
||||
member = self.member(
|
||||
"<interface type='bond' name='bond0'><start mode='onboot'/><mtu size='9000'/>"
|
||||
"<protocol family='ipv4'><dhcp/></protocol>"
|
||||
"<bond mode='active-backup'><miimon freq='100'/>"
|
||||
"<interface type='ethernet' name='eth0'><mac address='52:54:00:00:00:01'/></interface>"
|
||||
"<interface type='ethernet' name='eth1'/></bond></interface>"
|
||||
)
|
||||
self.assertEqual((member.get("type"), member.get("name")), ("bond", "bond0"))
|
||||
self.assertEqual(member.find("bond").get("mode"), "active-backup")
|
||||
self.assertEqual([i.get("name") for i in member.findall("bond/interface")], ["eth0", "eth1"])
|
||||
self.assertEqual(member.find("bond/miimon").get("freq"), "100")
|
||||
# what only a top-level interface has: the bridge holds the addresses
|
||||
for tag in ("start", "mtu", "protocol"):
|
||||
self.assertIsNone(member.find(tag), tag)
|
||||
|
||||
def test_a_vlan_keeps_its_tag_and_device(self):
|
||||
member = self.member(
|
||||
"<interface type='vlan' name='bond0'><start mode='onboot'/>"
|
||||
"<vlan tag='42'><interface name='eth0'/></vlan></interface>"
|
||||
)
|
||||
self.assertEqual(member.get("type"), "vlan")
|
||||
self.assertEqual(member.find("vlan").get("tag"), "42")
|
||||
self.assertEqual(member.find("vlan/interface").get("name"), "eth0")
|
||||
self.assertIsNone(member.find("start"))
|
||||
|
||||
def test_a_device_the_host_does_not_define_is_ethernet(self):
|
||||
member = self.member(None)
|
||||
self.assertEqual(member.attrib, {"type": "ethernet", "name": "bond0"})
|
||||
|
||||
def test_other_lookup_errors_are_not_hidden(self):
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
err = libvirt.libvirtError("cannot recv data")
|
||||
err.err = (libvirt.VIR_ERR_SYSTEM_ERROR, libvirt.VIR_FROM_RPC, str(err), 2, None, None, None, 0, 0)
|
||||
conn.get_iface = MagicMock(side_effect=err)
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.create_iface("br1", "bridge", "onboot", "bond0", "none", "", "", "none", "", "", "on", 0)
|
||||
self.assertFalse(conn.define_iface.called)
|
||||
|
||||
def test_an_interface_that_does_not_start_is_rolled_back(self):
|
||||
# the definition, and the member's own configuration it changed, as they were
|
||||
conn = wvmInterfaces.__new__(wvmInterfaces)
|
||||
conn.wvm = MagicMock()
|
||||
conn.define_iface = MagicMock()
|
||||
iface = conn.define_iface.return_value
|
||||
iface.create.side_effect = libvirt.libvirtError("failed to bring up br1")
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.create_iface("br1", "ethernet", "onboot", "", "none", "", "", "none", "", "", "on", 0)
|
||||
conn.wvm.changeRollback.assert_called_once_with(0)
|
||||
conn.wvm.changeCommit.assert_not_called()
|
||||
|
|
@ -15,6 +15,14 @@ from vrtManager.instance import (
|
|||
VIR_DOMAIN_BACKUP_BEGIN_PRESERVE_SHUTDOWN_DOMAIN,
|
||||
wvmInstance,
|
||||
)
|
||||
import os
|
||||
from xml.etree import ElementTree
|
||||
from django.test import SimpleTestCase
|
||||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
||||
GIB = 1024**3
|
||||
UUID = "abababab-0000-1111-2222-333333333333"
|
||||
|
|
@ -295,3 +303,106 @@ class DefineCloneTestCase(unittest.TestCase):
|
|||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class LiveCloneTestCase(SimpleTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not livetest.enabled():
|
||||
raise unittest.SkipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
cls.args = (
|
||||
os.environ["TEST_LIBVIRT_HOST"],
|
||||
os.environ.get("TEST_LIBVIRT_LOGIN", ""),
|
||||
os.environ.get("TEST_LIBVIRT_PASSWORD", ""),
|
||||
int(os.environ.get("TEST_LIBVIRT_TYPE", 4)),
|
||||
)
|
||||
cls.conn = connection_manager.get_connection(*cls.args)
|
||||
livetest.cleanup(cls.conn)
|
||||
livetest.ensure_pool(cls.conn)
|
||||
cls.inventory = livetest.inventory(cls.conn)
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
try:
|
||||
livetest.remove_pool(cls.conn)
|
||||
finally:
|
||||
super().tearDownClass()
|
||||
if livetest.inventory(cls.conn) != cls.inventory:
|
||||
raise AssertionError("Live tests changed objects outside the test namespace")
|
||||
|
||||
def tearDown(self):
|
||||
livetest.cleanup(self.conn)
|
||||
|
||||
def pool_volumes(self):
|
||||
pool = self.conn.storagePoolLookupByName(livetest.POOL)
|
||||
pool.refresh(0)
|
||||
return set(pool.listVolumes())
|
||||
|
||||
def source(self, name):
|
||||
disks = [livetest.create_volume(self.conn, P + f"{name}-{d}") for d in ("a", "b")]
|
||||
dom = livetest.define_vm(self.conn, P + name, disks)
|
||||
return dom, wvmInstance(*self.args, None, uuid=dom.UUIDString())
|
||||
|
||||
def clone_data(self, name):
|
||||
return {
|
||||
"name": P + name,
|
||||
"disk-vda": P + name + "-a.qcow2",
|
||||
"disk-vdb": P + name + "-b.qcow2",
|
||||
"disk_owner_uid": 0,
|
||||
"disk_owner_gid": 0,
|
||||
}
|
||||
|
||||
def test_failed_clone_removes_only_its_own_copies(self):
|
||||
_, vm = self.source("clone-src")
|
||||
taken = livetest.create_volume(self.conn, P + "clone-dst-b") # destination of vdb exists
|
||||
before = self.pool_volumes()
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
vm.clone_instance(self.clone_data("clone-dst"), "abababab-0000-1111-2222-333333333333")
|
||||
|
||||
self.assertEqual(self.pool_volumes(), before, "copies left behind or a volume deleted")
|
||||
self.assertTrue(livetest.volume_exists(self.conn, taken))
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
self.conn.lookupByName(P + "clone-dst")
|
||||
|
||||
def test_running_vm_is_not_cloned(self):
|
||||
dom, vm = self.source("clone-run")
|
||||
dom.create()
|
||||
before = self.pool_volumes()
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
vm.clone_instance(self.clone_data("clone-run-copy"), "abababab-0000-1111-2222-333333333333")
|
||||
|
||||
self.assertEqual(self.pool_volumes(), before)
|
||||
|
||||
def test_clone_of_a_uefi_vm_has_its_own_copy_of_the_nvram(self):
|
||||
disk = livetest.create_volume(self.conn, P + "clone-uefi-a")
|
||||
dom = livetest.define_vm(self.conn, P + "clone-uefi", [disk], uefi=True)
|
||||
dom.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # libvirt makes the NVRAM file at the first start
|
||||
dom.destroy()
|
||||
vm = wvmInstance(*self.args, None, uuid=dom.UUIDString())
|
||||
source = vm.get_nvram()
|
||||
|
||||
uuid = vm.clone_instance(
|
||||
{"name": P + "clone-uefi-copy", "disk-vda": P + "clone-uefi-copy-a.qcow2", "disk_owner_uid": 0, "disk_owner_gid": 0}, "abababab-0000-1111-2222-333333333333"
|
||||
)
|
||||
|
||||
clone = self.conn.lookupByUUIDString(uuid)
|
||||
path = ElementTree.fromstring(clone.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE)).findtext("os/nvram")
|
||||
self.assertEqual(path, os.path.join(os.path.dirname(source), P + "clone-uefi-copy_VARS.fd"))
|
||||
with vm._nvram_pool() as pool:
|
||||
copy = pool.storageVolLookupByName(os.path.basename(path))
|
||||
self.assertEqual(copy.info()[1], pool.storageVolLookupByName(os.path.basename(source)).info()[1])
|
||||
clone.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # qemu opens the copy
|
||||
self.assertEqual(
|
||||
ElementTree.fromstring(clone.XMLDesc(0)).findtext("os/nvram"), path, "libvirt replaced the copy"
|
||||
)
|
||||
clone.destroy()
|
||||
|
||||
def test_clone_of_a_stopped_vm(self):
|
||||
_, vm = self.source("clone-ok")
|
||||
uuid = vm.clone_instance(self.clone_data("clone-ok-copy"), "abababab-0000-1111-2222-333333333333")
|
||||
clone = self.conn.lookupByUUIDString(uuid)
|
||||
self.assertEqual(clone.name(), P + "clone-ok-copy")
|
||||
self.assertTrue({P + "clone-ok-copy-a.qcow2", P + "clone-ok-copy-b.qcow2"} <= self.pool_volumes())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Live checks of bounded connection opens against real libvirt.
|
||||
|
||||
Runs only with TEST_LIBVIRT_HOST set (a TCP compute, see instances/tests/livetest.py);
|
||||
Runs only with TEST_LIBVIRT_HOST set (a TCP compute, see vrtManager/tests/livetest.py);
|
||||
TEST_LIBVIRT_SSH_HOST/_LOGIN add a reachable ssh compute. 192.0.2.1 is
|
||||
TEST-NET-1: SYN packets to it are dropped, as for a host that went away
|
||||
without a trace.
|
||||
|
|
@ -34,6 +34,7 @@ import unittest
|
|||
|
||||
import libvirt
|
||||
from vrtManager.connection import CONN_SSH, CONN_TCP, CONNECT_TIMEOUT, connection_manager
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
UNREACHABLE = "192.0.2.1"
|
||||
KEEPALIVE_DEATH = 45 # keepalive (5, 5) closes a silent connection after ~30 s
|
||||
|
|
@ -118,7 +119,7 @@ class Relay:
|
|||
sock.close()
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_HOST"), "Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
@unittest.skipUnless(livetest.enabled(), "Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
class LiveBoundedOpenTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.host = os.environ["TEST_LIBVIRT_HOST"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
QEMU holds a running domain's image, so the size goes through blockResize;
|
||||
the guest and the volume must both see it, and the persistent definition
|
||||
stays as it was. Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
||||
stays as it was. Runs only with TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -13,7 +13,7 @@ from django.test import SimpleTestCase
|
|||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
PERSISTENT = libvirt.VIR_DOMAIN_XML_INACTIVE | libvirt.VIR_DOMAIN_XML_SECURE
|
||||
|
|
@ -9,7 +9,7 @@ from lxml import etree
|
|||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
CDROMS = (
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Live check that the NVRAM file a migrated VM leaves behind is removed,
|
||||
and kept when the destination sees the same directory.
|
||||
|
||||
Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py). The
|
||||
Runs only with TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py). The
|
||||
deletion needs a second host: TEST_LIBVIRT_SSH_HOST (and _LOGIN).
|
||||
"""
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ import libvirt
|
|||
from django.test import SimpleTestCase
|
||||
from lxml import etree
|
||||
|
||||
from instances.tests import livetest
|
||||
from vrtManager.tests import livetest
|
||||
from vrtManager.connection import CONN_SSH, NVRAM_DIR, connection_manager, wvmConnect
|
||||
|
||||
NAME = livetest.PREFIX + "nvram"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""Live checks for external snapshots on libvirt's own API. Runs only with
|
||||
TEST_LIBVIRT_HOST set (see instances/tests/livetest.py)."""
|
||||
TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py)."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
|
@ -11,7 +11,7 @@ from vrtManager import util
|
|||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Every edit that redefines the domain must start from the persistent
|
||||
definition with secrets (VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE).
|
||||
Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
||||
Runs only with TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -14,7 +14,7 @@ from lxml import etree
|
|||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
MAC = "52:54:00:aa:01:01"
|
||||
|
|
@ -5,7 +5,7 @@ before and after. The edit must show its effect, and once the nodes it is
|
|||
allowed to touch are removed from both sides, the rest of the definition
|
||||
must be identical: unknown elements, secrets and pending changes survive.
|
||||
On a running VM, edits of the persistent definition must leave the live
|
||||
one alone. Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
||||
one alone. Runs only with TEST_LIBVIRT_HOST set (see vrtManager/tests/livetest.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -17,7 +17,7 @@ from lxml import etree
|
|||
from vrtManager.connection import connection_manager
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
from . import livetest
|
||||
from vrtManager.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
MAC = "52:54:00:aa:02:01"
|
||||
|
|
@ -9,180 +9,7 @@ import libvirt
|
|||
|
||||
from vrtManager import util
|
||||
from vrtManager.instance import wvmInstances
|
||||
|
||||
|
||||
def no_domain():
|
||||
error = libvirt.libvirtError("Domain not found")
|
||||
error.err = (libvirt.VIR_ERR_NO_DOMAIN,)
|
||||
return error
|
||||
|
||||
|
||||
class FakeDomain:
|
||||
def __init__(self, xml="<domain/>", snapshots=0, saved=False, undefine_fails=False, undefine_lost=False,
|
||||
started=False):
|
||||
self.calls = []
|
||||
self.xml = xml
|
||||
self.snapshots = snapshots
|
||||
self.saved = saved
|
||||
self.undefine_fails = undefine_fails
|
||||
self.undefine_lost = undefine_lost # undefined, but the reply is lost
|
||||
self.started = started # started by another client during the move
|
||||
self.undefined = None
|
||||
self.redefined = []
|
||||
self.restored_autostart = None
|
||||
|
||||
def migrate(self, dconn, flags, dname, uri, bandwidth):
|
||||
self.calls.append((flags, uri))
|
||||
|
||||
def name(self):
|
||||
return "vm"
|
||||
|
||||
def UUIDString(self):
|
||||
return "u-1"
|
||||
|
||||
inactive_xml = None # the persistent definition, when it differs
|
||||
|
||||
def XMLDesc(self, flags):
|
||||
if flags & libvirt.VIR_DOMAIN_XML_INACTIVE and self.inactive_xml:
|
||||
return self.inactive_xml
|
||||
return self.xml
|
||||
|
||||
def snapshotNum(self, flags=0):
|
||||
return self.snapshots
|
||||
|
||||
def hasManagedSaveImage(self, flags=0):
|
||||
return self.saved
|
||||
|
||||
def undefineFlags(self, flags):
|
||||
if self.undefine_fails:
|
||||
raise libvirt.libvirtError("undefine failed")
|
||||
self.undefined = flags
|
||||
if self.undefine_lost:
|
||||
raise libvirt.libvirtError("Cannot recv data")
|
||||
|
||||
def isPersistent(self):
|
||||
return self.undefined is None or bool(self.redefined)
|
||||
|
||||
def isActive(self):
|
||||
if not self.started and self.undefined is not None:
|
||||
raise no_domain() # libvirt: an undefined shut-off VM is gone
|
||||
return self.started
|
||||
|
||||
def autostart(self):
|
||||
return 1
|
||||
|
||||
def setAutostart(self, value):
|
||||
self.restored_autostart = value
|
||||
|
||||
def connect(self):
|
||||
return self
|
||||
|
||||
def defineXML(self, xml):
|
||||
self.redefined.append(xml)
|
||||
return self
|
||||
|
||||
|
||||
def no_volume():
|
||||
error = libvirt.libvirtError("Storage volume not found")
|
||||
error.err = (libvirt.VIR_ERR_NO_STORAGE_VOL,)
|
||||
return error
|
||||
|
||||
|
||||
class FakeVolume:
|
||||
def __init__(self, backing):
|
||||
self.backing = backing
|
||||
|
||||
def XMLDesc(self, flags=0):
|
||||
if self.backing is None:
|
||||
return "<volume><target><path>x</path></target></volume>"
|
||||
return f"<volume><backingStore><path>{self.backing}</path></backingStore></volume>"
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, host, active=True):
|
||||
self.host = host
|
||||
self.active = active
|
||||
|
||||
def isActive(self):
|
||||
return self.active
|
||||
|
||||
def XMLDesc(self, flags=0):
|
||||
return "<pool><target><path>/elsewhere</path></target></pool>"
|
||||
|
||||
def refresh(self, flags=0):
|
||||
self.host.refreshes += 1
|
||||
self.host.volumes |= self.host.unlisted
|
||||
self.host.unlisted = set()
|
||||
|
||||
def storageVolLookupByName(self, name):
|
||||
return self.host.storageVolLookupByPath(name)
|
||||
|
||||
|
||||
class FakeHost:
|
||||
"""The destination's libvirt connection. volumes: the paths its pools
|
||||
list; unlisted: files its pools show after a refresh."""
|
||||
|
||||
def __init__(self, existing=(), volumes=(), unlisted=(), backing=None, inactive_pools=()):
|
||||
self.existing = set(existing)
|
||||
self.defined = []
|
||||
self.new = []
|
||||
self.volumes = set(volumes)
|
||||
self.unlisted = set(unlisted)
|
||||
self.backing = backing or {} # path: the path of its backing file
|
||||
self.inactive_pools = set(inactive_pools)
|
||||
self.refreshes = 0
|
||||
|
||||
def storageVolLookupByPath(self, path):
|
||||
if path not in self.volumes:
|
||||
raise no_volume()
|
||||
return FakeVolume(self.backing.get(path))
|
||||
|
||||
def storagePoolLookupByName(self, name):
|
||||
return FakePool(self, active=name not in self.inactive_pools)
|
||||
|
||||
def listAllStoragePools(self, flags=0):
|
||||
return [FakePool(self)]
|
||||
|
||||
def lookup(self, key):
|
||||
if key in self.existing:
|
||||
return FakeDomain()
|
||||
raise no_domain()
|
||||
|
||||
lookupByName = lookupByUUIDString = lookup
|
||||
|
||||
def defineXML(self, xml):
|
||||
self.defined.append(xml)
|
||||
self.new.append(FakeDomain())
|
||||
return self.new[-1]
|
||||
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, state, nvram=None, **domain):
|
||||
self.state = state
|
||||
self.nvram = nvram
|
||||
self.instance = FakeDomain(**domain)
|
||||
|
||||
def get_nvram(self):
|
||||
return self.nvram
|
||||
|
||||
reads = 0
|
||||
|
||||
def get_status(self):
|
||||
self.reads += 1
|
||||
return self.state
|
||||
|
||||
def get_arch(self):
|
||||
return "x86_64"
|
||||
|
||||
def get_dom_emulator(self):
|
||||
return "/usr/bin/qemu-system-x86_64"
|
||||
|
||||
|
||||
def destination(host=None):
|
||||
dest = wvmInstances.__new__(wvmInstances)
|
||||
dest.wvm = host or FakeHost()
|
||||
dest.get_emulator = lambda arch: "/usr/bin/qemu-system-x86_64"
|
||||
return dest
|
||||
from vrtManager.tests.fakes import FakeDomain, FakeHost, FakeSource, destination, with_disks
|
||||
|
||||
|
||||
class MigrationFlagsTestCase(unittest.TestCase):
|
||||
|
|
@ -325,10 +152,6 @@ class MigrationModeTestCase(unittest.TestCase):
|
|||
self.assertEqual(self.migrate(5, live=True, autoconverge=True, compress=True), ("offline", None))
|
||||
|
||||
|
||||
def with_disks(*disks):
|
||||
return "<domain><devices>%s</devices></domain>" % "".join(disks)
|
||||
|
||||
|
||||
FILE_DISK = "<disk type='file' device='disk'><source file='/pool/vm.qcow2'/><target dev='vda'/></disk>"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
"""A network's addresses are read from its XML however libvirt writes it, each
|
||||
<ip> by its own index; a page reads each definition once; a change starts
|
||||
from the saved definition, so two in a row keep each other; a fixed address
|
||||
is found by the address it had and checked against its subnet; the XML
|
||||
editor defines only this network; a network that does not start is not left
|
||||
defined. Runs on a private libvirt test driver: "test:///default" is shared
|
||||
by the whole process."""
|
||||
"""wvmNetwork: a change starts from the saved definition, so two in a row
|
||||
keep each other; a fixed address is found by the address it had; the XML
|
||||
editor defines only this network; a network that does not start is not
|
||||
left defined; the DHCP leases are read. Runs on a private libvirt test
|
||||
driver: "test:///default" is shared by the whole process."""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import libvirt
|
||||
from django.test import SimpleTestCase
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from lxml import etree
|
||||
from vrtManager.network import ip_networks, network_size, wvmNetwork, wvmNetworks
|
||||
|
||||
from vrtManager.network import network_size, wvmNetwork, wvmNetworks
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.fakes import DUID, lease
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
NODE = """<node>
|
||||
|
|
@ -25,23 +26,6 @@ NODE = """<node>
|
|||
</node>"""
|
||||
|
||||
|
||||
class AddressTests(SimpleTestCase):
|
||||
def test_an_address_without_a_prefix_takes_libvirts(self):
|
||||
tree = etree.fromstring("<network><ip address='192.0.2.1'/><ip address='10.1.2.3'/><ip family='ipv6' address='2001:db8::1'/></network>")
|
||||
self.assertEqual([str(ip["subnet"]) for ip in ip_networks(tree)], ["192.0.2.0/24", "10.0.0.0/8", "2001:db8::/64"])
|
||||
|
||||
def test_every_way_libvirt_writes_an_address(self):
|
||||
compact = "<network><ip address='192.0.2.1' netmask='255.255.255.0'/></network>"
|
||||
static = ("<network><ip address='192.0.2.1' prefix='24'><dhcp><host mac='52:54:00:00:00:01' ip='192.0.2.5'/>"
|
||||
"</dhcp></ip><ip family='ipv6' address='2001:db8::1' prefix='64'/></network>")
|
||||
(ip,) = ip_networks(etree.fromstring(compact))
|
||||
self.assertEqual((ip["index"], ip["family"], str(ip["subnet"]), ip["dhcp"]), (0, "ipv4", "192.0.2.0/24", False))
|
||||
v4, v6 = ip_networks(etree.fromstring(static))
|
||||
# DHCP with fixed addresses only is DHCP too
|
||||
self.assertEqual((v4["dhcp"], v4["range"], v4["hosts"][0]["ip"]), (True, None, "192.0.2.5"))
|
||||
self.assertEqual((v6["index"], v6["family"], str(v6["subnet"])), (1, "ipv6", "2001:db8::/64"))
|
||||
|
||||
|
||||
class NetworkChangeTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
node = tempfile.NamedTemporaryFile("w", suffix=".xml")
|
||||
|
|
@ -182,3 +166,26 @@ class NetworkChangeTests(SimpleTestCase):
|
|||
wvmNetworks("localhost", "", "", 4).create_network(
|
||||
"n1", "nat", True, gateway, mask, dhcp, False, None, None, None, "", False)
|
||||
self.assertNotIn("n1", self.conn.listDefinedNetworks() + self.conn.listNetworks())
|
||||
|
||||
|
||||
class LeasesTests(SimpleTestCase):
|
||||
def network(self, leases, active=True):
|
||||
net = wvmNetwork.__new__(wvmNetwork)
|
||||
net.net = MagicMock(**{"isActive.return_value": active, "DHCPLeases.return_value": leases})
|
||||
return net
|
||||
|
||||
def test_leases_by_address_ipv4_first(self):
|
||||
net = self.network([lease("2001:db8:1::40", family=1, clientid=DUID, expiry=0),
|
||||
lease("192.0.2.100", "52:54:00:AA:00:01", hostname="db"),
|
||||
lease("192.0.2.9", "52:54:00:aa:00:02")])
|
||||
ipv4_9, ipv4_100, ipv6 = net.get_leases()
|
||||
self.assertEqual([str(ipv4_9["ip"]), str(ipv4_100["ip"]), str(ipv6["ip"])], ["192.0.2.9", "192.0.2.100", "2001:db8:1::40"])
|
||||
self.assertEqual((ipv4_100["mac"], ipv4_100["hostname"], ipv4_100["expires"]),
|
||||
("52:54:00:aa:00:01", "db", datetime.fromtimestamp(1793265489, timezone.utc)))
|
||||
# an IPv6 lease: no MAC, its client's DUID; one that never ends
|
||||
self.assertEqual((ipv6["mac"], ipv6["client_id"], ipv6["expires"]), ("", DUID, None))
|
||||
|
||||
def test_a_network_that_does_not_run_has_none(self):
|
||||
net = self.network([lease("192.0.2.9")], active=False)
|
||||
self.assertEqual(net.get_leases(), [])
|
||||
net.net.DHCPLeases.assert_not_called()
|
||||
|
|
@ -2,6 +2,7 @@ import unittest
|
|||
|
||||
from lxml import etree
|
||||
from vrtManager.network import ip_networks, network_size
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
class TestNetworkSize(unittest.TestCase):
|
||||
|
|
@ -96,3 +97,20 @@ class TestIpNetworks(unittest.TestCase):
|
|||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class AddressTests(SimpleTestCase):
|
||||
def test_an_address_without_a_prefix_takes_libvirts(self):
|
||||
tree = etree.fromstring("<network><ip address='192.0.2.1'/><ip address='10.1.2.3'/><ip family='ipv6' address='2001:db8::1'/></network>")
|
||||
self.assertEqual([str(ip["subnet"]) for ip in ip_networks(tree)], ["192.0.2.0/24", "10.0.0.0/8", "2001:db8::/64"])
|
||||
|
||||
def test_every_way_libvirt_writes_an_address(self):
|
||||
compact = "<network><ip address='192.0.2.1' netmask='255.255.255.0'/></network>"
|
||||
static = ("<network><ip address='192.0.2.1' prefix='24'><dhcp><host mac='52:54:00:00:00:01' ip='192.0.2.5'/>"
|
||||
"</dhcp></ip><ip family='ipv6' address='2001:db8::1' prefix='64'/></network>")
|
||||
(ip,) = ip_networks(etree.fromstring(compact))
|
||||
self.assertEqual((ip["index"], ip["family"], str(ip["subnet"]), ip["dhcp"]), (0, "ipv4", "192.0.2.0/24", False))
|
||||
v4, v6 = ip_networks(etree.fromstring(static))
|
||||
# DHCP with fixed addresses only is DHCP too
|
||||
self.assertEqual((v4["dhcp"], v4["range"], v4["hosts"][0]["ip"]), (True, None, "192.0.2.5"))
|
||||
self.assertEqual((v6["index"], v6["family"], str(v6["subnet"])), (1, "ipv6", "2001:db8::/64"))
|
||||
|
|
|
|||
110
vrtManager/tests/test_nodedevs.py
Normal file
110
vrtManager/tests/test_nodedevs.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""The host's node devices as vrtManager lists them: PCI devices by IOMMU
|
||||
group and USB devices, each with what the host has on it and the VMs
|
||||
that name it. Node device XML as libvirt 11.10 writes it; the connection
|
||||
is a mock, libvirt's test driver has no node devices."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import libvirt
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from vrtManager import util
|
||||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager.nodedevs import hostdev_problems, node_devices, pci_address
|
||||
from vrtManager.tests.fakes import NODES, PCI_0A, USB_1_3, node, pci, vm
|
||||
|
||||
PCI_01 = "<hostdev mode='subsystem' type='pci'><source><address domain='0x0000' bus='0x00' slot='0x01' function='0x0'/></source></hostdev>"
|
||||
|
||||
|
||||
USB_MODEL = "<hostdev mode='subsystem' type='usb'><source><vendor id='0x0627'/><product id='0x0001'/></source></hostdev>"
|
||||
|
||||
|
||||
def devices(nodes=NODES, domains=()):
|
||||
conn = wvmConnect.__new__(wvmConnect)
|
||||
conn.wvm = MagicMock(**{"listAllDevices.return_value": [node(*n) for n in nodes],
|
||||
"listAllDomains.return_value": list(domains)})
|
||||
return node_devices(conn)
|
||||
|
||||
|
||||
class NodeDevicesTests(SimpleTestCase):
|
||||
def test_pci_devices_by_group_with_what_the_host_has_on_them(self):
|
||||
pci_devices, _ = devices()
|
||||
self.assertEqual([(d["address"], d["group"]) for d in pci_devices],
|
||||
[("0000:00:02.0", 2), ("0000:00:03.0", 2), ("0000:00:06.0", 3), ("0000:00:0a.0", 4), ("0000:00:01.0", None)])
|
||||
gpu, disk, nic, spare, _ = pci_devices
|
||||
# a graphics card's primary node is the host's; its render node is not a resource of its own
|
||||
self.assertEqual((gpu["resources"], disk["resources"], nic["resources"], spare["resources"]),
|
||||
(["/dev/dri/card0"], ["/dev/vda"], ["eth0"], []))
|
||||
self.assertEqual((nic["kind"], nic["driver"], nic["ids"]), ("Network controller", "virtio-pci", "1af4:1000"))
|
||||
|
||||
def test_usb_devices_with_their_classes_hubs_and_same_models(self):
|
||||
_, (hub, tablet_2, tablet_3) = devices()
|
||||
self.assertEqual((hub["kinds"], hub["same_model"]), (["Hub"], False))
|
||||
self.assertEqual((tablet_2["kinds"], tablet_2["same_model"], tablet_3["same_model"]), (["Human interface"], True, True))
|
||||
|
||||
def test_the_vms_that_name_a_device(self):
|
||||
pci_devices, (_, tablet_2, tablet_3) = devices(domains=[vm("db-1", PCI_0A + USB_1_3), vm("kiosk-1", USB_MODEL)])
|
||||
self.assertEqual([d["vms"] for d in pci_devices], [[], [], [], ["db-1"], []])
|
||||
# by address: that device; by model only: any device of the model
|
||||
self.assertEqual((tablet_3["vms"], tablet_3["by_model"]), (["db-1"], ["kiosk-1"]))
|
||||
self.assertEqual((tablet_2["vms"], tablet_2["by_model"]), ([], ["kiosk-1"]))
|
||||
|
||||
def test_a_usb_controller_holds_the_usb_devices_below_it(self):
|
||||
pci_devices, (hub, tablet_2, _) = devices(domains=[vm("kiosk-1", USB_1_3)])
|
||||
controller = [d for d in pci_devices if d["address"] == "0000:00:01.0"][0]
|
||||
# its root hub is no device of the host's; the tablets are, one given to a VM
|
||||
self.assertEqual(controller["resources"], ["USB device 1/2 (QEMU Tablet)", "USB device 1/3 (QEMU Tablet)"])
|
||||
self.assertEqual((hub["controller"], tablet_2["controller"]), ("0000:00:01.0", "0000:00:01.0"))
|
||||
|
||||
def test_a_usb_device_on_a_controller_given_to_a_vm_is_not_given_to_another(self):
|
||||
pci_devices, usb_devices = devices(domains=[vm("db-1", PCI_01)])
|
||||
reasons = [util.render(m["text"], m["values"], str) for m in hostdev_problems(
|
||||
"kiosk-1", pci_devices, usb_devices, set(), [("0x0627", "0x0001", (1, 2))])]
|
||||
self.assertIn("USB device 1/2 (QEMU Tablet) is on the PCI controller 0000:00:01.0, given to db-1", reasons)
|
||||
|
||||
def test_a_usb_device_is_named_as_libvirt_picks_one(self):
|
||||
def named(hostdev):
|
||||
_, (_, tablet_2, tablet_3) = devices(domains=[vm("v", hostdev)])
|
||||
return [(d["vms"], d["by_model"]) for d in (tablet_2, tablet_3)]
|
||||
# an address with other IDs: another device now at that address
|
||||
other = USB_1_3.replace("0x0627", "0x046d")
|
||||
address_only = "<hostdev mode='subsystem' type='usb'><source><address bus='1' device='2'/></source></hostdev>"
|
||||
self.assertEqual(named(other), [([], []), ([], [])])
|
||||
self.assertEqual(named(address_only), [(["v"], []), ([], [])])
|
||||
self.assertEqual(named(USB_MODEL), [([], ["v"]), ([], ["v"])])
|
||||
|
||||
def test_a_pci_device_named_as_a_nic_or_only_for_the_next_start(self):
|
||||
nic = ("<interface type='hostdev'><source><address type='pci' domain='0x0000' bus='0x00' slot='0x06' function='0x0'/>"
|
||||
"</source></interface>")
|
||||
pci_devices, _ = devices(domains=[vm("nic-1", nic), vm("next-1", "", saved=PCI_0A)])
|
||||
self.assertEqual({d["address"]: d["vms"] for d in pci_devices if d["vms"]},
|
||||
{"0000:00:06.0": ["nic-1"], "0000:00:0a.0": ["next-1"]})
|
||||
|
||||
def test_sr_iov_functions_and_unnamed_hardware(self):
|
||||
vf = pci("0x0b", "", "0x020000", group=5)[1].replace(
|
||||
"<product id='0x1000'></product>", "<product id='0x10ed'/>").replace(
|
||||
"</capability></device>", "<capability type='phys_function'><address domain='0x0000' bus='0x00' slot='0x0a'"
|
||||
" function='0x0'/></capability></capability></device>")
|
||||
pf = NODES[8][1].replace("</capability></device>", "<capability type='virt_functions' maxCount='7'><address domain='0x0000'"
|
||||
" bus='0x00' slot='0x0b' function='0x0'/></capability></capability></device>")
|
||||
pci_devices, _ = devices(nodes=[("pci_0000_00_0a_0", pf), ("pci_0000_00_0b_0", vf)])
|
||||
self.assertEqual([(d["physfn"], d["vfs"], d["product"]) for d in pci_devices],
|
||||
[("", 1, "Ethernet controller"), ("0000:00:0a.0", 0, "0x10ed")])
|
||||
|
||||
def test_a_device_gone_meanwhile_is_left_out_other_errors_are_raised(self):
|
||||
for code, raised in ((libvirt.VIR_ERR_NO_NODE_DEVICE, False), (libvirt.VIR_ERR_OPERATION_FAILED, True)):
|
||||
err = libvirt.libvirtError("gone")
|
||||
err.get_error_code = lambda code=code: code
|
||||
gone = node("pci_0000_00_0b_0", "")
|
||||
gone.XMLDesc.side_effect = err
|
||||
conn = wvmConnect.__new__(wvmConnect)
|
||||
conn.wvm = MagicMock(**{"listAllDevices.return_value": [gone, node(*NODES[0])], "listAllDomains.return_value": []})
|
||||
with self.subTest(code=code):
|
||||
if raised:
|
||||
self.assertRaises(libvirt.libvirtError, node_devices, conn)
|
||||
else:
|
||||
self.assertEqual([d["address"] for d in node_devices(conn)[0]], ["0000:00:06.0"])
|
||||
|
||||
def test_a_pci_address_from_libvirts_hex_or_numbers(self):
|
||||
self.assertEqual(pci_address("0x0000", "0x00", "0x1f", "0x3"), pci_address(0, 0, 31, 3))
|
||||
self.assertEqual(pci_address(0, 0, 31, 3), "0000:00:1f.3")
|
||||
|
|
@ -12,50 +12,17 @@ from django.test import SimpleTestCase
|
|||
from lxml import etree
|
||||
from vrtManager import util
|
||||
from vrtManager.nodedevs import _pci, check_hostdevs, hostdev_problems, pci_problems
|
||||
from vrtManager.tests.fakes import IOMMU_NODES
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
|
||||
def device(slot, product, code, driver, group, members=None, bridge=False):
|
||||
"""A PCI device on bus 0 at slot, in IOMMU group with the slots members (its own by default)."""
|
||||
members = members or [slot]
|
||||
addresses = "".join(f"<address domain='0x0000' bus='0x00' slot='0x{m:02x}' function='0x0'/>" for m in members)
|
||||
driver_xml = f"<driver><name>{driver}</name></driver>" if driver else ""
|
||||
bridge_xml = "<capability type='pci-bridge'/>" if bridge else ""
|
||||
xml = (f"<device><name>pci_0000_00_{slot:02x}_0</name><parent>computer</parent>{driver_xml}"
|
||||
f"<capability type='pci'><class>{code}</class><domain>0</domain><bus>0</bus><slot>{slot}</slot><function>0</function>"
|
||||
f"<product id='0x0001'>{product}</product><vendor id='0x8086'>Intel</vendor>{bridge_xml}"
|
||||
f"<iommuGroup number='{group}'>{addresses}</iommuGroup></capability></device>")
|
||||
return f"pci_0000_00_{slot:02x}_0", etree.fromstring(xml)
|
||||
|
||||
|
||||
NODES = dict([
|
||||
device(0x02, "PCIe Root port", "0x060400", "pcieport", 2, bridge=True),
|
||||
device(0x03, "SATA controller", "0x010601", "ahci", 7, [0x03, 0x04, 0x05]),
|
||||
device(0x04, "SMBus controller", "0x0c0500", "i801_smbus", 7, [0x03, 0x04, 0x05]),
|
||||
device(0x05, "LPC controller", "0x060100", "lpc_ich", 7, [0x03, 0x04, 0x05]), # class 0x06, not a bridge
|
||||
device(0x06, "Gigabit NIC", "0x020000", "e1000e", 12),
|
||||
device(0x07, "Spare NIC", "0x020000", "vfio-pci", 13),
|
||||
device(0x08, "NIC port 0", "0x020000", "vfio-pci", 14, [0x08, 0x09, 0x0a]),
|
||||
device(0x09, "NIC port 1", "0x020000", None, 14, [0x08, 0x09, 0x0a]),
|
||||
device(0x0a, "Downstream port", "0x060400", "pcieport", 14, [0x08, 0x09, 0x0a], bridge=True),
|
||||
device(0x0b, "Accelerator", "0x120000", "vfio-pci", 15, [0x0b, 0x0c]), # 0x0c cannot be read
|
||||
device(0x0d, "GPU", "0x030000", "nouveau", 16, [0x0d, 0x0e]),
|
||||
device(0x0e, "GPU audio", "0x040300", "snd_hda_intel", 16, [0x0d, 0x0e]),
|
||||
])
|
||||
# what the host has below: a disk on the SATA controller, its interface on the e1000e
|
||||
NODES["block_sda"] = etree.fromstring("<device><name>block_sda</name><parent>pci_0000_00_03_0</parent>"
|
||||
"<capability type='storage'><block>/dev/sda</block></capability></device>")
|
||||
NODES["net_eth1"] = etree.fromstring("<device><name>net_eth1</name><parent>pci_0000_00_06_0</parent>"
|
||||
"<capability type='net'><interface>eth1</interface></capability></device>")
|
||||
|
||||
|
||||
def inventory(vms=()):
|
||||
"""The host's PCI devices, as a list; vms: (VM, its PCI addresses)."""
|
||||
children = {}
|
||||
for name, node in NODES.items():
|
||||
for name, node in IOMMU_NODES.items():
|
||||
children.setdefault(node.findtext("parent"), []).append(name)
|
||||
users = [(vm, set(addresses), []) for vm, addresses in vms]
|
||||
return [_pci(name, node, NODES, children, users) for name, node in NODES.items()
|
||||
return [_pci(name, node, IOMMU_NODES, children, users) for name, node in IOMMU_NODES.items()
|
||||
if node.find("capability").get("type") == "pci"]
|
||||
|
||||
|
||||
|
|
@ -121,7 +88,7 @@ class DefinitionsTests(SimpleTestCase):
|
|||
conn = MagicMock()
|
||||
conn.wvm.listAllDevices.return_value = [MagicMock(**{"name.return_value": name,
|
||||
"XMLDesc.return_value": etree.tostring(node)})
|
||||
for name, node in NODES.items()]
|
||||
for name, node in IOMMU_NODES.items()]
|
||||
conn.domain_trees.return_value = []
|
||||
trees = [etree.fromstring(f"<domain><devices>{''.join(devices)}</devices></domain>") for devices in devices_per_definition]
|
||||
check_hostdevs(conn, "web-1", trees)
|
||||
188
vrtManager/tests/test_secrets.py
Normal file
188
vrtManager/tests/test_secrets.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""wvmSecrets: a secret's usage goes into its XML as text; its value is a
|
||||
Ceph key in base64 or a password as typed, and a value refused leaves no
|
||||
secret behind; what uses a secret, and deleting one."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import libvirt
|
||||
from django.test import SimpleTestCase
|
||||
from lxml import etree
|
||||
|
||||
from vrtManager.tests.fakes import CEPH
|
||||
from vrtManager.util import OperationError
|
||||
from vrtManager.virtsecrets import wvmSecrets
|
||||
|
||||
|
||||
def connection_and_libvirt():
|
||||
with patch("vrtManager.connection.connection_manager.get_connection") as get:
|
||||
conn = wvmSecrets("host", "login", "pw", 1)
|
||||
return conn, get.return_value
|
||||
|
||||
|
||||
class CreateSecretTests(SimpleTestCase):
|
||||
def test_the_usage_is_text_of_its_element(self):
|
||||
for usage_type, element, usage in (
|
||||
("volume", "volume", "/var/lib/libvirt/images/vm-1.qcow2"),
|
||||
("iscsi", "target", "iqn.2026-10.test.wvc:lun"),
|
||||
("ceph", "name", "client.libvirt secret"),
|
||||
("tls", "name", "migration"),
|
||||
("vtpm", "name", "VM web-1's vTPM"),
|
||||
("volume", "volume", "/a'b<c>&d\""),
|
||||
):
|
||||
with self.subTest(usage_type=usage_type, usage=usage):
|
||||
conn, wvm = connection_and_libvirt()
|
||||
conn.create_secret(usage_type, usage, private=True)
|
||||
secret = etree.fromstring(wvm.secretDefineXML.call_args.args[0])
|
||||
self.assertEqual((secret.get("ephemeral"), secret.get("private")), ("no", "yes"))
|
||||
self.assertEqual(secret.find("usage").get("type"), usage_type)
|
||||
self.assertEqual(secret.findtext(f"usage/{element}"), usage)
|
||||
wvm.secretDefineXML.return_value.setValue.assert_not_called()
|
||||
|
||||
def test_a_new_secret_takes_its_value_at_once(self):
|
||||
for usage_type, value, stored in (("ceph", "cHcA/w==", b"pw\x00\xff"), ("iscsi", " pass word ", b" pass word ")):
|
||||
with self.subTest(usage_type=usage_type):
|
||||
conn, wvm = connection_and_libvirt()
|
||||
conn.create_secret(usage_type, "lun", value)
|
||||
wvm.secretDefineXML.return_value.setValue.assert_called_once_with(stored)
|
||||
|
||||
def test_a_value_refused_leaves_no_secret(self):
|
||||
conn, wvm = connection_and_libvirt()
|
||||
with self.assertRaisesRegex(OperationError, "base64"):
|
||||
conn.create_secret("ceph", "client.libvirt", "not base64!")
|
||||
wvm.secretDefineXML.assert_not_called()
|
||||
wvm.secretDefineXML.return_value.setValue.side_effect = libvirt.libvirtError("refused")
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.create_secret("iscsi", "lun", "pw")
|
||||
wvm.secretDefineXML.return_value.undefine.assert_called_once_with()
|
||||
|
||||
|
||||
class SecretValueTests(SimpleTestCase):
|
||||
def set_value(self, usage_type, value):
|
||||
conn, wvm = connection_and_libvirt()
|
||||
secret = wvm.secretLookupByUUIDString.return_value
|
||||
secret.XMLDesc.return_value = f"<secret><usage type='{usage_type}'><name>x</name></usage></secret>"
|
||||
conn.set_secret_value("uuid", value)
|
||||
return secret.setValue
|
||||
|
||||
def test_a_ceph_key_is_stored_as_its_bytes_a_password_as_typed(self):
|
||||
self.set_value("ceph", "cHcA/w==").assert_called_once_with(b"pw\x00\xff")
|
||||
# a password that happens to be base64 is not decoded
|
||||
self.set_value("iscsi", "cHcA/w==").assert_called_once_with(b"cHcA/w==")
|
||||
self.set_value("vtpm", " pässword ").assert_called_once_with(" pässword ".encode())
|
||||
|
||||
def test_a_ceph_key_that_is_not_base64_is_refused_without_the_value(self):
|
||||
for value in ("b'cHc='", "not base64!", "kéy"):
|
||||
with self.subTest(value=value), self.assertRaises(OperationError) as refused:
|
||||
self.set_value("ceph", value)
|
||||
self.assertNotIn(value, str(refused.exception))
|
||||
|
||||
|
||||
ISCSI = "8f2c0000-0000-4000-8000-000000000002"
|
||||
|
||||
|
||||
VTPM = "8f2c0000-0000-4000-8000-000000000003"
|
||||
|
||||
|
||||
def secret(uuid, usage_type, usage):
|
||||
return MagicMock(**{"UUIDString.return_value": uuid,
|
||||
"XMLDesc.return_value": f"<secret><usage type='{usage_type}'><name>{usage}</name></usage></secret>"})
|
||||
|
||||
|
||||
def owner(name, xml, saved=None, active=False, persistent=True):
|
||||
"""A domain or a pool: xml as it runs, saved as defined for its next start."""
|
||||
return MagicMock(**{"name.return_value": name, "XMLDesc.side_effect": lambda flags=0: (saved or xml) if flags else xml,
|
||||
"isActive.return_value": active, "isPersistent.return_value": persistent})
|
||||
|
||||
|
||||
def vm(name, devices, **kwargs):
|
||||
return owner(name, f"<domain><devices>{devices}</devices></domain>", **kwargs)
|
||||
|
||||
|
||||
def pool(name, auth, **kwargs):
|
||||
return owner(name, f"<pool type='rbd'><source>{auth}</source></pool>", **kwargs)
|
||||
|
||||
|
||||
def gone(code):
|
||||
err = libvirt.libvirtError("gone")
|
||||
err.get_error_code = lambda: code
|
||||
return MagicMock(**{"name.return_value": "gone", "XMLDesc.side_effect": err})
|
||||
|
||||
|
||||
# the same usage, of two types: a reference by usage names the one of its type
|
||||
SECRETS = [secret(CEPH, "ceph", "client.libvirt"), secret(ISCSI, "iscsi", "client.libvirt"), secret(VTPM, "vtpm", "tpm")]
|
||||
|
||||
|
||||
def connection(domains=(), pools=(), secrets=SECRETS):
|
||||
with patch("vrtManager.connection.connection_manager.get_connection") as get:
|
||||
conn = wvmSecrets("host", "login", "pw", 1)
|
||||
get.return_value.configure_mock(**{"listAllDomains.return_value": list(domains),
|
||||
"listAllStoragePools.return_value": list(pools),
|
||||
"listAllSecrets.return_value": list(secrets)})
|
||||
return conn
|
||||
|
||||
|
||||
def users(**kwargs):
|
||||
conn = connection(**kwargs)
|
||||
return conn.get_users(conn.get_secrets_info())
|
||||
|
||||
|
||||
class UsersTests(SimpleTestCase):
|
||||
def test_vms_name_secrets_in_disks_host_devices_and_vtpm(self):
|
||||
found = users(domains=[
|
||||
vm("rbd-1", f"<disk type='network'><auth username='libvirt'><secret type='ceph' uuid='{CEPH}'/></auth></disk>"),
|
||||
vm("lun-1", "<hostdev mode='subsystem' type='scsi'><source protocol='iscsi'><auth username='u'>"
|
||||
"<secret type='iscsi' usage='client.libvirt'/></auth></source></hostdev>"),
|
||||
vm("tpm-1", f"<tpm model='tpm-crb'><backend type='emulator'><encryption secret='{VTPM}'/></backend></tpm>"),
|
||||
])
|
||||
self.assertEqual(found, {CEPH: {"vms": ["rbd-1"], "pools": []}, ISCSI: {"vms": ["lun-1"], "pools": []},
|
||||
VTPM: {"vms": ["tpm-1"], "pools": []}})
|
||||
|
||||
def test_a_pool_names_its_secret_by_the_auth_type_and_as_saved(self):
|
||||
found = users(pools=[
|
||||
pool("ceph-rbd", "<auth type='ceph' username='libvirt'><secret usage='client.libvirt'/></auth>"),
|
||||
# chap is iSCSI; named only for its next start
|
||||
pool("lun", "", saved="<pool><source><auth type='chap' username='u'><secret usage='client.libvirt'/>"
|
||||
"</auth></source></pool>", active=False),
|
||||
])
|
||||
self.assertEqual(found, {CEPH: {"vms": [], "pools": ["ceph-rbd"]}, ISCSI: {"vms": [], "pools": ["lun"]}})
|
||||
|
||||
def test_a_vm_uses_a_secret_as_it_runs_and_as_saved(self):
|
||||
disk = "<disk><auth username='libvirt'><secret type='ceph' usage='client.libvirt'/></auth></disk>"
|
||||
# saved only: read whether it runs or not, as it may stop between the reads
|
||||
for kwargs in ({"active": True, "saved": f"<domain><devices>{disk}</devices></domain>"},
|
||||
{"active": False, "saved": f"<domain><devices>{disk}</devices></domain>"},
|
||||
{"active": False}, {"active": True, "persistent": False}):
|
||||
devices = "" if "saved" in kwargs else disk
|
||||
with self.subTest(**kwargs):
|
||||
self.assertEqual(users(domains=[vm("db-1", devices, **kwargs)]), {CEPH: {"vms": ["db-1"], "pools": []}})
|
||||
|
||||
def test_what_is_gone_meanwhile_is_left_out_other_errors_are_raised(self):
|
||||
found = users(domains=[gone(libvirt.VIR_ERR_NO_DOMAIN)], pools=[gone(libvirt.VIR_ERR_NO_STORAGE_POOL)],
|
||||
secrets=[gone(libvirt.VIR_ERR_NO_SECRET)])
|
||||
self.assertEqual(found, {})
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
users(domains=[gone(libvirt.VIR_ERR_OPERATION_FAILED)])
|
||||
|
||||
|
||||
class NoValueReadTests(SimpleTestCase):
|
||||
def test_the_list_and_its_users_read_no_value(self):
|
||||
for listed in SECRETS:
|
||||
listed.value.side_effect = AssertionError("a secret's value was read")
|
||||
conn = connection(domains=[vm("db-1", f"<disk><auth><secret type='ceph' uuid='{CEPH}'/></auth></disk>")])
|
||||
conn.get_users(conn.get_secrets_info())
|
||||
conn.wvm.secretLookupByUUIDString.assert_not_called()
|
||||
|
||||
|
||||
class DeleteTests(SimpleTestCase):
|
||||
def test_a_secret_a_vm_or_a_pool_names_is_not_deleted(self):
|
||||
conn = connection(domains=[vm("db-2", f"<disk><auth><secret type='ceph' uuid='{CEPH}'/></auth></disk>")],
|
||||
pools=[pool("ceph-rbd", "<auth type='ceph'><secret usage='client.libvirt'/></auth>")])
|
||||
with self.assertRaisesRegex(OperationError, f"Secret {CEPH} is used by VMs db-2; pools ceph-rbd"):
|
||||
conn.delete_secret(CEPH)
|
||||
conn.wvm.secretLookupByUUIDString.return_value.undefine.assert_not_called()
|
||||
|
||||
def test_an_unused_secret_is_deleted(self):
|
||||
conn = connection(domains=[vm("web-1", f"<disk><auth><secret type='ceph' uuid='{CEPH}'/></auth></disk>")])
|
||||
conn.delete_secret(ISCSI)
|
||||
conn.wvm.secretLookupByUUIDString.assert_called_once_with(ISCSI)
|
||||
conn.wvm.secretLookupByUUIDString.return_value.undefine.assert_called_once_with()
|
||||
|
|
@ -8,7 +8,7 @@ import libvirt
|
|||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from networks.tests.driver import without_inactive_flag
|
||||
from vrtManager.tests.driver import without_inactive_flag
|
||||
from django.urls import NoReverseMatch, URLPattern, URLResolver, get_resolver, reverse
|
||||
from instances.models import Flavor, Instance
|
||||
from lxml import html
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue