mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-10 12:16:49 +00:00
test: each app keeps its tests in a tests package
131 test modules sat next to the code they test; instances alone had 51 of them beside views.py and utils.py. They move into a tests/ package per app, and the JavaScript checks into webvirtcloud/tests/js. Each non-empty tests.py becomes tests/test_views.py; the empty generated stubs are removed. Imports of the app's own modules become absolute, paths built from __file__ go one level up, the live helper is instances.tests.livetest, and coverage omits */tests/*. The suite discovers the same 960 tests as before.
This commit is contained in:
parent
0906838193
commit
c55f52f025
154 changed files with 27 additions and 39 deletions
0
vrtManager/tests/__init__.py
Normal file
0
vrtManager/tests/__init__.py
Normal file
80
vrtManager/tests/test_change_network.py
Normal file
80
vrtManager/tests/test_change_network.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import libvirtError
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
DOMAIN_XML = """<domain><devices>
|
||||
<interface type='network'><mac address='52:54:00:00:00:01'/><source network='default'/><model type='virtio'/></interface>
|
||||
<interface type='network'><mac address='52:54:00:00:00:02'/><source network='default'/><model type='virtio'/>
|
||||
<bandwidth><outbound average='1000'/></bandwidth><link state='down'/>
|
||||
<address type='pci' domain='0x0000' bus='0x02' slot='0x00' function='0x0'/></interface>
|
||||
</devices></domain>"""
|
||||
|
||||
|
||||
def stopped_instance():
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.instance = MagicMock()
|
||||
inst.wvm = MagicMock()
|
||||
inst._XMLDesc = MagicMock(return_value=DOMAIN_XML)
|
||||
inst.get_status = MagicMock(return_value=5)
|
||||
return inst
|
||||
|
||||
|
||||
class TestChangeNetwork(unittest.TestCase):
|
||||
def test_edits_the_nic_in_place_and_keeps_its_other_settings(self):
|
||||
from lxml import etree
|
||||
|
||||
inst = stopped_instance()
|
||||
inst.change_network("52:54:00:00:00:02", "52:54:00:00:00:03", "br0", "bridge", "e1000", "clean-traffic")
|
||||
|
||||
defined = etree.fromstring(inst.wvm.defineXML.call_args[0][0])
|
||||
first, second = defined.findall("devices/interface")
|
||||
self.assertEqual(first.find("mac").get("address"), "52:54:00:00:00:01")
|
||||
self.assertEqual(second.get("type"), "bridge")
|
||||
self.assertEqual(second.find("mac").get("address"), "52:54:00:00:00:03")
|
||||
self.assertEqual(second.find("source").attrib, {"bridge": "br0"})
|
||||
self.assertEqual(second.find("model").get("type"), "e1000")
|
||||
self.assertEqual(second.find("filterref").get("filter"), "clean-traffic")
|
||||
# QoS, link state and the PCI address survive
|
||||
self.assertEqual(second.find("bandwidth/outbound").get("average"), "1000")
|
||||
self.assertEqual(second.find("link").get("state"), "down")
|
||||
self.assertEqual(second.find("address").get("bus"), "0x02")
|
||||
|
||||
def test_unknown_old_mac_changes_nothing(self):
|
||||
inst = stopped_instance()
|
||||
with self.assertRaises(libvirtError):
|
||||
inst.change_network("52:54:00:00:00:09", "52:54:00:00:00:03", "default", "net", "virtio", "")
|
||||
inst.wvm.defineXML.assert_not_called()
|
||||
|
||||
|
||||
class TestAddNetworkXml(unittest.TestCase):
|
||||
def attached(self, **kwargs):
|
||||
from lxml import etree
|
||||
|
||||
inst = stopped_instance()
|
||||
inst.add_network(**kwargs)
|
||||
return etree.fromstring(inst.instance.attachDeviceFlags.call_args[0][0])
|
||||
|
||||
def test_posted_values_cannot_inject_elements(self):
|
||||
iface = self.attached(
|
||||
mac_address="52:54:00:00:00:05",
|
||||
source="default",
|
||||
model="virtio'/><filesystem type='mount'/><model type='x",
|
||||
nwfilter="f'/><evil/>",
|
||||
)
|
||||
self.assertEqual([el.tag for el in iface], ["mac", "source", "model", "filterref"])
|
||||
self.assertEqual(iface.find("model").get("type"), "virtio'/><filesystem type='mount'/><model type='x")
|
||||
|
||||
def test_missing_mac_is_left_to_libvirt(self):
|
||||
iface = self.attached(mac_address="", source="default")
|
||||
self.assertIsNone(iface.find("mac"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
68
vrtManager/tests/test_clone_nvram.py
Normal file
68
vrtManager/tests/test_clone_nvram.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""A clone of a UEFI VM gets its own copy of the VM's NVRAM, with the
|
||||
original's format, owner and mode, wherever the definition names the file."""
|
||||
|
||||
import contextlib
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from vrtManager import util
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
ORIGINAL = """<volume><name>vm_VARS.qcow2</name><key>k</key><capacity>540672</capacity>
|
||||
<target><path>/var/lib/libvirt/qemu/nvram/vm_VARS.qcow2</path><format type='qcow2'/>
|
||||
<permissions><mode>0600</mode><owner>107</owner><group>107</group></permissions></target></volume>"""
|
||||
|
||||
|
||||
def domain(nvram):
|
||||
return f"""<domain><name>vm</name><uuid>u</uuid><os><type>hvm</type>
|
||||
<loader readonly='yes' type='pflash'>/usr/share/edk2/ovmf/OVMF_CODE.fd</loader>{nvram}</os><devices/></domain>"""
|
||||
|
||||
|
||||
def proxy(nvram):
|
||||
p = wvmInstance.__new__(wvmInstance) # no connection: only these calls
|
||||
p.get_status = lambda: 5
|
||||
p.get_instances = lambda: []
|
||||
p._XMLDesc = lambda flags: domain(nvram)
|
||||
p._defineXML = MagicMock()
|
||||
p.pool = MagicMock()
|
||||
p.pool.storageVolLookupByName.return_value.XMLDesc.return_value = ORIGINAL
|
||||
p.pool.createXMLFrom.return_value.path.return_value = "/var/lib/libvirt/qemu/nvram/copy_VARS.qcow2"
|
||||
p._nvram_pool = lambda: contextlib.nullcontext(p.pool)
|
||||
return p
|
||||
|
||||
|
||||
class CloneNvramTestCase(unittest.TestCase):
|
||||
def test_the_copy_keeps_the_format_owner_and_mode(self):
|
||||
p = proxy("<nvram format='qcow2'>/var/lib/libvirt/qemu/nvram/vm_VARS.qcow2</nvram>")
|
||||
p.clone_instance({"name": "copy"})
|
||||
xml, original, _ = p.pool.createXMLFrom.call_args.args
|
||||
volume = etree.fromstring(xml)
|
||||
self.assertEqual(volume.findtext("name"), "copy_VARS.qcow2")
|
||||
self.assertEqual(volume.find("target/format").get("type"), "qcow2")
|
||||
self.assertEqual(volume.findtext("target/permissions/owner"), "107")
|
||||
self.assertIsNone(volume.find("target/path"))
|
||||
self.assertIs(original, p.pool.storageVolLookupByName.return_value)
|
||||
(defined,), _ = p._defineXML.call_args
|
||||
self.assertEqual(etree.fromstring(defined).findtext("os/nvram"), "/var/lib/libvirt/qemu/nvram/copy_VARS.qcow2")
|
||||
|
||||
def test_an_nvram_source_element_points_at_the_copy(self):
|
||||
p = proxy("<nvram type='file'>\n <source file='/var/lib/libvirt/qemu/nvram/vm_VARS.qcow2'/>\n</nvram>")
|
||||
p.clone_instance({"name": "copy"})
|
||||
p.pool.storageVolLookupByName.assert_called_once_with("vm_VARS.qcow2")
|
||||
(defined,), _ = p._defineXML.call_args
|
||||
self.assertEqual(
|
||||
etree.fromstring(defined).find("os/nvram/source").get("file"), "/var/lib/libvirt/qemu/nvram/copy_VARS.qcow2"
|
||||
)
|
||||
|
||||
def test_an_nvram_that_is_not_a_file_is_refused_before_any_copy(self):
|
||||
p = proxy("<nvram type='network'><source protocol='iscsi' name='x'/></nvram>")
|
||||
with self.assertRaises(util.OperationError):
|
||||
p.clone_instance({"name": "copy"})
|
||||
p.pool.createXMLFrom.assert_not_called()
|
||||
276
vrtManager/tests/test_connection.py
Normal file
276
vrtManager/tests/test_connection.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Reachability probe and connections that do not block each other."""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
import libvirt
|
||||
from vrtManager import connection
|
||||
from vrtManager.connection import CONN_SSH, CONN_TCP, SSH_COMMAND, wvmConnectionManager
|
||||
|
||||
|
||||
def closed_port():
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
class HostIsUpTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.manager = wvmConnectionManager.__new__(wvmConnectionManager)
|
||||
|
||||
def test_reachable_host_is_true(self):
|
||||
server = socket.socket()
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
self.addCleanup(server.close)
|
||||
self.assertIs(self.manager.host_is_up(CONN_TCP, f"127.0.0.1:{server.getsockname()[1]}"), True)
|
||||
|
||||
def test_unreachable_host_is_false_not_an_exception(self):
|
||||
self.assertIs(self.manager.host_is_up(CONN_TCP, f"127.0.0.1:{closed_port()}"), False)
|
||||
|
||||
|
||||
class ConnectionManagerTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patcher = patch("vrtManager.connection.wvmEventLoop")
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.manager = wvmConnectionManager()
|
||||
|
||||
def test_a_slow_host_does_not_block_other_hosts(self):
|
||||
def open_auth(uri, auth, flags):
|
||||
if "slow" in uri:
|
||||
time.sleep(1.5)
|
||||
raise __import__("libvirt").libvirtError("refused")
|
||||
|
||||
with patch("vrtManager.connection.libvirt.openAuth", side_effect=open_auth):
|
||||
slow = threading.Thread(target=lambda: self.assertRaises(Exception, self.manager.get_connection, "slow", "u", "p", CONN_TCP))
|
||||
slow.start()
|
||||
time.sleep(0.2)
|
||||
started = time.monotonic()
|
||||
with self.assertRaises(Exception):
|
||||
self.manager.get_connection("fast", "u", "p", CONN_TCP)
|
||||
self.assertLess(time.monotonic() - started, 1.0)
|
||||
slow.join()
|
||||
|
||||
|
||||
|
||||
def alive_connection():
|
||||
conn = MagicMock()
|
||||
conn.isAlive.return_value = 1
|
||||
return conn
|
||||
|
||||
|
||||
class BoundedOpenTestCase(unittest.TestCase):
|
||||
"""Opening waits at most CONNECT_TIMEOUT; a failed host is not retried
|
||||
for RETRY_AFTER; one open per connection at a time."""
|
||||
|
||||
def setUp(self):
|
||||
for name, value in (("wvmEventLoop", MagicMock()), ("CONNECT_TIMEOUT", 0.3), ("RETRY_AFTER", 1.0)):
|
||||
patcher = patch(f"vrtManager.connection.{name}", value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.manager = wvmConnectionManager()
|
||||
self.release = threading.Event()
|
||||
self.addCleanup(self.release.set)
|
||||
self.calls = 0
|
||||
|
||||
def open_auth(self, result):
|
||||
def open_auth(uri, auth, flags):
|
||||
self.calls += 1
|
||||
self.release.wait(5)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
return patch("vrtManager.connection.libvirt.openAuth", side_effect=open_auth)
|
||||
|
||||
def get(self, host="h"):
|
||||
started = time.monotonic()
|
||||
try:
|
||||
return self.manager.get_connection(host, "u", "p", CONN_TCP), time.monotonic() - started
|
||||
except libvirt.libvirtError as err:
|
||||
return err, time.monotonic() - started
|
||||
|
||||
def wait_for_the_open(self, host="h"):
|
||||
"""Wait until the released open has published its connection."""
|
||||
entry = self.manager._search_connection(host, "u", "p", CONN_TCP)
|
||||
deadline = time.monotonic() + 5
|
||||
while entry.connection is None and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
def test_a_hanging_open_is_given_up_after_the_timeout(self):
|
||||
with self.open_auth(alive_connection()):
|
||||
result, took = self.get()
|
||||
self.assertIsInstance(result, libvirt.libvirtError)
|
||||
self.assertIn("did not answer within", str(result))
|
||||
self.assertLess(took, 1.0)
|
||||
|
||||
def test_within_retry_after_the_last_error_comes_at_once(self):
|
||||
with self.open_auth(alive_connection()):
|
||||
self.get()
|
||||
result, took = self.get()
|
||||
self.assertIn("did not answer within", str(result))
|
||||
self.assertLess(took, 0.1)
|
||||
self.assertEqual(self.calls, 1)
|
||||
|
||||
def test_a_late_success_is_used(self):
|
||||
conn = alive_connection()
|
||||
with self.open_auth(conn):
|
||||
self.get()
|
||||
self.release.set()
|
||||
self.wait_for_the_open()
|
||||
result, took = self.get()
|
||||
self.assertIs(result, conn)
|
||||
self.assertEqual(self.calls, 1)
|
||||
|
||||
def test_a_failed_host_is_tried_again_after_retry_after(self):
|
||||
self.release.set()
|
||||
with self.open_auth(libvirt.libvirtError("refused")):
|
||||
self.get()
|
||||
self.get()
|
||||
self.assertEqual(self.calls, 1)
|
||||
time.sleep(1.1)
|
||||
self.get()
|
||||
self.assertEqual(self.calls, 2)
|
||||
|
||||
def test_concurrent_requests_share_one_open(self):
|
||||
with self.open_auth(alive_connection()):
|
||||
threads = [threading.Thread(target=self.get) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
self.assertEqual(self.calls, 1)
|
||||
|
||||
def test_a_stuck_host_does_not_keep_other_hosts_from_connecting(self):
|
||||
healthy = alive_connection()
|
||||
|
||||
def open_auth(uri, auth, flags):
|
||||
if "stuck" in uri:
|
||||
self.release.wait(5) # an open that never returns
|
||||
return healthy
|
||||
|
||||
with patch("vrtManager.connection.libvirt.openAuth", side_effect=open_auth):
|
||||
for i in range(12):
|
||||
self.get(f"stuck{i}")
|
||||
result, took = self.get("healthy")
|
||||
self.assertIs(result, healthy)
|
||||
|
||||
def test_an_abandoned_open_is_not_waited_for_again_nor_duplicated(self):
|
||||
conn = alive_connection()
|
||||
with self.open_auth(conn):
|
||||
self.get()
|
||||
time.sleep(1.1) # past RETRY_AFTER, the open still runs
|
||||
result, took = self.get()
|
||||
self.assertIsInstance(result, libvirt.libvirtError)
|
||||
self.assertLess(took, 0.1)
|
||||
self.release.set() # the host answers at last
|
||||
self.wait_for_the_open()
|
||||
result, _ = self.get()
|
||||
self.assertIs(result, conn)
|
||||
self.assertEqual(self.calls, 1)
|
||||
|
||||
def test_a_late_close_callback_does_not_drop_the_new_connection(self):
|
||||
self.release.set()
|
||||
old, new = alive_connection(), alive_connection()
|
||||
with self.open_auth(old):
|
||||
self.get()
|
||||
entry = self.manager._search_connection("h", "u", "p", CONN_TCP)
|
||||
old.isAlive.return_value = 0 # seen dead, reconnected
|
||||
with self.open_auth(new):
|
||||
self.assertIs(self.get()[0], new)
|
||||
entry._wvmConnection__connection_close_callback(old, 1)
|
||||
self.assertIs(self.get()[0], new)
|
||||
entry._wvmConnection__connection_close_callback(new, 2)
|
||||
self.assertIsNone(entry.connection)
|
||||
self.assertEqual(entry.last_error, "Connection closed (keepalive timeout)")
|
||||
|
||||
def test_a_connection_closed_before_it_is_published_is_an_error(self):
|
||||
self.release.set()
|
||||
dead = alive_connection()
|
||||
dead.isAlive.return_value = 0
|
||||
with self.open_auth(dead):
|
||||
result, _ = self.get()
|
||||
self.assertIn("closed right after opening", str(result))
|
||||
|
||||
def test_ssh_runs_ssh_with_a_connect_timeout(self):
|
||||
self.release.set()
|
||||
with patch("vrtManager.connection.libvirt.open", return_value=alive_connection()) as libvirt_open:
|
||||
self.manager.get_connection("host", "root", "", CONN_SSH)
|
||||
uri = libvirt_open.call_args[0][0]
|
||||
self.assertTrue(uri.startswith("qemu+ssh://root@host/system?command="))
|
||||
self.assertTrue(os.access(SSH_COMMAND, os.X_OK))
|
||||
with open(SSH_COMMAND) as script:
|
||||
self.assertIn("ConnectTimeout", script.read())
|
||||
|
||||
|
||||
class EventLoopTestCase(unittest.TestCase):
|
||||
"""The libvirt event loop thread starts with the first connection of a
|
||||
process, not at import: novncd forks a child per console after importing
|
||||
this module, and libvirt is not safe to use after a fork of a process
|
||||
with running libvirt threads. A child starts its own loop, so its
|
||||
keepalive works."""
|
||||
|
||||
def setUp(self):
|
||||
for name in ("wvmEventLoop", "libvirt.virEventRegisterDefaultImpl", "libvirt.openAuth"):
|
||||
patcher = patch(f"vrtManager.connection.{name}")
|
||||
setattr(self, name.split(".")[-1], patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
self.openAuth.side_effect = lambda *args: alive_connection()
|
||||
state = patch.dict(connection._event_loop, {"lock": threading.Lock(), "running": False, "registered": False})
|
||||
state.start()
|
||||
self.addCleanup(state.stop)
|
||||
|
||||
def test_no_thread_until_the_first_connection(self):
|
||||
manager = wvmConnectionManager()
|
||||
self.wvmEventLoop.assert_not_called()
|
||||
self.virEventRegisterDefaultImpl.assert_not_called()
|
||||
manager.get_connection("h", "u", "p", CONN_TCP)
|
||||
wvmConnectionManager().get_connection("h2", "u", "p", CONN_TCP) # one loop per process
|
||||
self.wvmEventLoop.return_value.start.assert_called_once()
|
||||
self.virEventRegisterDefaultImpl.assert_called_once()
|
||||
|
||||
def test_a_forked_child_starts_its_own_loop_with_its_own_connections(self):
|
||||
manager = connection.connection_manager
|
||||
parent_conn = manager.get_connection("fork-host", "u", "p", CONN_TCP)
|
||||
read_end, write_end = os.pipe()
|
||||
pid = os.fork()
|
||||
if pid == 0: # child: report and leave without running the test runner
|
||||
try:
|
||||
child_conn = manager.get_connection("fork-host", "u", "p", CONN_TCP)
|
||||
kept = any(
|
||||
entry.connection is parent_conn
|
||||
for connections in manager._inherited
|
||||
for entries in connections.values()
|
||||
for entry in entries
|
||||
)
|
||||
report = (
|
||||
f"{self.wvmEventLoop.return_value.start.call_count} "
|
||||
f"{self.virEventRegisterDefaultImpl.call_count} {child_conn is parent_conn} {kept}"
|
||||
)
|
||||
except BaseException as e:
|
||||
report = repr(e)
|
||||
os.write(write_end, report.encode())
|
||||
os._exit(0)
|
||||
os.close(write_end)
|
||||
os.waitpid(pid, 0)
|
||||
report = os.read(read_end, 200).decode()
|
||||
os.close(read_end)
|
||||
# a second loop started in the child, on the inherited registration;
|
||||
# the child opened its own connection instead of the parent's socket,
|
||||
# and kept the parent's (releasing it would run libvirt destructors)
|
||||
self.assertEqual(report, "2 1 False True")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
46
vrtManager/tests/test_console_setters.py
Normal file
46
vrtManager/tests/test_console_setters.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from lxml import etree
|
||||
from vrtManager.instance import PERSISTENT_XML, wvmInstance
|
||||
|
||||
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>"
|
||||
|
||||
|
||||
def running_vm_with_pending_changes():
|
||||
vm = wvmInstance.__new__(wvmInstance)
|
||||
vm.wvm = MagicMock()
|
||||
vm._XMLDesc = MagicMock(side_effect=lambda flags: PERSISTENT if flags == PERSISTENT_XML else LIVE)
|
||||
return vm
|
||||
|
||||
|
||||
class ConsoleSettersTestCase(unittest.TestCase):
|
||||
"""The graphics device to edit comes from the persistent definition."""
|
||||
|
||||
def defined_graphics(self, vm):
|
||||
return etree.fromstring(vm.wvm.defineXML.call_args[0][0]).find("devices/graphics")
|
||||
|
||||
def test_setters_edit_the_persistent_graphics_device(self):
|
||||
edits = {
|
||||
"set_console_passwd": (lambda vm: vm.set_console_passwd("new"), "passwd", "new"),
|
||||
"set_console_keymap": (lambda vm: vm.set_console_keymap("de"), "keymap", "de"),
|
||||
"set_console_listener_addr": (lambda vm: vm.set_console_listener_addr("127.0.0.1"), "listen", "127.0.0.1"),
|
||||
}
|
||||
for name, (edit, attr, value) in edits.items():
|
||||
with self.subTest(name):
|
||||
vm = running_vm_with_pending_changes()
|
||||
edit(vm)
|
||||
graphics = self.defined_graphics(vm)
|
||||
self.assertEqual(graphics.get(attr), value)
|
||||
# Built from the persistent device, not the live one
|
||||
self.assertNotEqual(graphics.get("passwd"), "live")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
176
vrtManager/tests/test_device_edits.py
Normal file
176
vrtManager/tests/test_device_edits.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Device edits of a VM: each definition is changed as it describes the
|
||||
device, every NIC type can be a boot device, posted values stay values, and
|
||||
definition changes go through _defineXML, a QoS edit keeps the floor, and what the edit forms show is read from either definition. A boot order with a
|
||||
missing device changes nothing. An active VM's memory is resized
|
||||
by balloon, a stopped one's in its definition. Enabling vCPU hot plug keeps
|
||||
the vCPUs enabled at boot."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from vrtManager import util
|
||||
from vrtManager.instance import PERSISTENT_XML, wvmInstance
|
||||
from libvirt import VIR_DOMAIN_AFFECT_CONFIG, VIR_DOMAIN_AFFECT_LIVE
|
||||
|
||||
PERSISTENT = """<domain><title>pending</title><vcpu current='2'>4</vcpu><os><type>hvm</type><bootmenu enable='yes'/></os><devices>
|
||||
<disk type='file' device='disk'><source file='/pool/a.qcow2'/><target dev='vda' bus='virtio'/></disk>
|
||||
<disk type='file' device='disk'><source file='/pool/b.qcow2'/><target dev='vdb' bus='virtio'/></disk>
|
||||
<interface type='bridge'><mac address='52:54:10:00:00:02'/><source bridge='br0'/></interface>
|
||||
<interface type='network'><mac address='52:54:10:00:00:01'/><source network='default'/>
|
||||
<bandwidth><inbound average='1' peak='2' burst='3' floor='4'/></bandwidth></interface>
|
||||
<disk type='file' device='cdrom'><target dev='sda' bus='sata'/><boot order='1'/></disk>
|
||||
<video><model type='vga'/></video>
|
||||
<graphics type='vnc' passwd='pending' keymap='de'><listen type='address' address='0.0.0.0'/></graphics>
|
||||
</devices></domain>"""
|
||||
LIVE = """<domain><vcpu>1</vcpu><os><type>hvm</type></os><devices>
|
||||
<disk type='file' device='disk'><source file='/pool/a.qcow2'/><target dev='vda' bus='virtio'/><alias name='virtio-disk0'/></disk>
|
||||
</devices></domain>"""
|
||||
|
||||
|
||||
def proxy(active=True):
|
||||
p = wvmInstance.__new__(wvmInstance) # no connection: only these calls
|
||||
p.instance = MagicMock(**{"isActive.return_value": active})
|
||||
p._XMLDesc = lambda flags: PERSISTENT if flags == PERSISTENT_XML else LIVE
|
||||
p._defineXML = MagicMock()
|
||||
p.wvm = MagicMock()
|
||||
return p
|
||||
|
||||
|
||||
def defined(p):
|
||||
(xml,), _ = p._defineXML.call_args
|
||||
return etree.fromstring(xml)
|
||||
|
||||
|
||||
class DetachDiskTestCase(unittest.TestCase):
|
||||
def test_each_definition_is_detached_as_it_describes_the_disk(self):
|
||||
p = proxy()
|
||||
p.detach_disk("vda")
|
||||
(live, live_flags), (config, config_flags) = (c.args for c in p.instance.detachDeviceFlags.call_args_list)
|
||||
self.assertEqual((live_flags, config_flags), (VIR_DOMAIN_AFFECT_LIVE, VIR_DOMAIN_AFFECT_CONFIG))
|
||||
self.assertIn("virtio-disk0", live)
|
||||
self.assertNotIn("alias", config)
|
||||
|
||||
def test_a_disk_only_in_the_persistent_definition_is_detached_there(self):
|
||||
p = proxy()
|
||||
p.detach_disk("vdb")
|
||||
self.assertEqual([c.args[1] for c in p.instance.detachDeviceFlags.call_args_list], [VIR_DOMAIN_AFFECT_CONFIG])
|
||||
|
||||
def test_an_unknown_disk_is_refused(self):
|
||||
p = proxy()
|
||||
with self.assertRaises(util.OperationError):
|
||||
p.detach_disk("vdz")
|
||||
p.instance.detachDeviceFlags.assert_not_called()
|
||||
|
||||
|
||||
class VcpuHotplugTestCase(unittest.TestCase):
|
||||
def test_enabling_hotplug_keeps_the_current_vcpus_enabled(self):
|
||||
p = proxy(active=False)
|
||||
p.instance.info.return_value = [5]
|
||||
p._XMLDesc = lambda flags: "<domain><vcpu current='2'>4</vcpu></domain>"
|
||||
p.set_vcpu_hotplug(True)
|
||||
self.assertEqual(defined(p).xpath("vcpus/vcpu/@enabled"), ["yes", "yes"])
|
||||
|
||||
|
||||
class DeviceEditsTestCase(unittest.TestCase):
|
||||
def test_a_block_volume_is_attached_by_its_device_path(self):
|
||||
p = proxy(active=False)
|
||||
p._definitions = lambda: [(PERSISTENT_XML, VIR_DOMAIN_AFFECT_CONFIG)]
|
||||
p.attach_disk("vdc", "/dev/vg/c", disk_type="block", target_bus="virtio")
|
||||
xml, _ = p.instance.attachDeviceFlags.call_args.args
|
||||
self.assertEqual(etree.fromstring(xml).find("source").attrib, {"dev": "/dev/vg/c"})
|
||||
|
||||
def test_a_bridge_nic_can_be_a_boot_device(self):
|
||||
p = proxy()
|
||||
p.set_bootorder({0: {"type": "network", "dev": "52:54:10:00:00:02"}, 1: {"type": "disk", "dev": "vdb"}})
|
||||
tree = defined(p)
|
||||
self.assertEqual(tree.xpath("devices/interface[@type='bridge']/boot/@order"), ["1"])
|
||||
self.assertEqual(tree.xpath("devices/disk[target/@dev='vdb']/boot/@order"), ["2"])
|
||||
|
||||
def test_the_video_model_is_a_value(self):
|
||||
p = proxy()
|
||||
p.set_video_model("vga' heads='9")
|
||||
model = defined(p).find("devices/video/model")
|
||||
self.assertEqual(model.attrib, {"type": "vga' heads='9"})
|
||||
p._defineXML.assert_called_once()
|
||||
|
||||
def test_qos_changes_go_through_define_xml(self):
|
||||
for change in (
|
||||
lambda p: p.set_qos("52:54:10:00:00:01", "outbound", 1, 2, 3),
|
||||
lambda p: p.unset_qos("52:54:10:00:00:01", "inbound"),
|
||||
):
|
||||
p = proxy()
|
||||
change(p)
|
||||
p._defineXML.assert_called_once()
|
||||
p.wvm.defineXML.assert_not_called()
|
||||
|
||||
def test_qos_is_read_from_either_definition_and_an_edit_keeps_the_floor(self):
|
||||
p = proxy()
|
||||
self.assertEqual(p.get_all_qos(config=True)["52:54:10:00:00:01"][0]["floor"], "4")
|
||||
self.assertEqual(p.get_all_qos(), {}) # the running VM has none
|
||||
p.set_qos("52:54:10:00:00:01", "inbound", 10, 20, 30)
|
||||
inbound = defined(p).find("devices/interface[@type='network']/bandwidth/inbound")
|
||||
self.assertEqual(inbound.attrib, {"average": "10", "peak": "20", "burst": "30", "floor": "4"})
|
||||
|
||||
def test_cpu_title_and_description_are_read_from_either_definition(self):
|
||||
p = proxy()
|
||||
self.assertEqual((p.get_vcpu(), p.get_cur_vcpu(), p.get_title(), p.get_description()), (1, 1, "", ""))
|
||||
self.assertEqual((p.get_vcpu(config=True), p.get_cur_vcpu(config=True), p.get_title(config=True)), (4, 2, "pending"))
|
||||
|
||||
def test_boot_video_and_console_are_read_from_either_definition(self):
|
||||
p = proxy()
|
||||
live = (p.get_bootmenu(), p.get_bootorder(), p.get_media_devices(), p.get_video_model(),
|
||||
p.get_console_listener_addr(), p.get_console_passwd(), p.get_console_keymap())
|
||||
self.assertEqual(live, (False, {}, [], None, "127.0.0.1", None, ""))
|
||||
self.assertEqual((p.get_bootmenu(config=True), p.get_bootorder(config=True), p.get_video_model(config=True)),
|
||||
(True, {0: {"type": "file", "dev": "cdrom", "target": "sda"}}, "vga"))
|
||||
self.assertEqual([m["dev"] for m in p.get_media_devices(config=True)], ["sda"])
|
||||
self.assertEqual((p.get_console_listener_addr(config=True), p.get_console_passwd(config=True),
|
||||
p.get_console_keymap(config=True)), ("0.0.0.0", "pending", "de"))
|
||||
|
||||
def test_a_boot_order_with_a_device_not_in_the_definition_changes_nothing(self):
|
||||
p = proxy()
|
||||
for choice in ({"type": "disk", "dev": "vdz"}, {"type": "network", "dev": "52:54:10:00:00:99"},
|
||||
{"type": "floppy", "dev": "fda"}):
|
||||
with self.subTest(choice), self.assertRaises(util.OperationError):
|
||||
p.set_bootorder({0: {"type": "disk", "dev": "vda"}, 1: choice})
|
||||
p._defineXML.assert_not_called()
|
||||
|
||||
def test_the_default_nic_model_leaves_the_choice_to_libvirt(self):
|
||||
p = proxy()
|
||||
p.change_network("52:54:10:00:00:01", "52:54:10:00:00:01", "default", "net", "default", "")
|
||||
self.assertIsNone(defined(p).find("devices/interface[@type='network']/model"))
|
||||
p = proxy(active=False)
|
||||
p.add_network("52:54:10:00:00:09", "default", model="default")
|
||||
xml, _ = p.instance.attachDeviceFlags.call_args.args
|
||||
self.assertIsNone(etree.fromstring(xml).find("model"))
|
||||
|
||||
|
||||
class ResizeMemTestCase(unittest.TestCase):
|
||||
def test_an_active_vm_changes_only_its_current_memory(self):
|
||||
# a paused VM is active too: its guest applies the balloon once resumed
|
||||
p = proxy(active=True)
|
||||
p.resize_mem(96, 160)
|
||||
self.assertEqual(
|
||||
[c.args for c in p.instance.setMemoryFlags.call_args_list],
|
||||
[(96 * 1024, VIR_DOMAIN_AFFECT_LIVE), (96 * 1024, VIR_DOMAIN_AFFECT_CONFIG)],
|
||||
)
|
||||
p._defineXML.assert_not_called()
|
||||
|
||||
def test_a_stopped_vm_gets_both_values_in_its_definition(self):
|
||||
p = proxy(active=False)
|
||||
p._XMLDesc = lambda flags: "<domain><memory>131072</memory><currentMemory>131072</currentMemory></domain>"
|
||||
p.resize_mem(96, 160)
|
||||
tree = defined(p)
|
||||
self.assertEqual((tree.findtext("memory"), tree.findtext("currentMemory")), (str(160 * 1024), str(96 * 1024)))
|
||||
p.instance.setMemoryFlags.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
36
vrtManager/tests/test_dhcp_leases.py
Normal file
36
vrtManager/tests/test_dhcp_leases.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""refresh_dhcp_leases must raise a real exception (handled as a libvirt error),
|
||||
not a bare string (which Python turns into TypeError, masking the cause)."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from vrtManager import util
|
||||
from vrtManager.network import wvmNetwork
|
||||
|
||||
|
||||
def network_with_leases(side_effect):
|
||||
net = wvmNetwork.__new__(wvmNetwork)
|
||||
net.leases = None
|
||||
net.net = Mock()
|
||||
net.net.DHCPLeases.side_effect = side_effect
|
||||
return net
|
||||
|
||||
|
||||
class RefreshDhcpLeasesTestCase(unittest.TestCase):
|
||||
def test_a_lease_error_raises_an_operation_error(self):
|
||||
net = network_with_leases(Exception("no DHCP on this network"))
|
||||
with self.assertRaises(util.OperationError):
|
||||
net.refresh_dhcp_leases()
|
||||
|
||||
def test_the_error_keeps_the_cause_and_clears_leases(self):
|
||||
cause = Exception("boom")
|
||||
net = network_with_leases(cause)
|
||||
with self.assertRaises(util.OperationError) as caught:
|
||||
net.refresh_dhcp_leases()
|
||||
self.assertIs(caught.exception.__cause__, cause)
|
||||
self.assertEqual(net.leases, [])
|
||||
134
vrtManager/tests/test_host_reads.py
Normal file
134
vrtManager/tests/test_host_reads.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""A wvmConnect reads the host's info and capabilities once, and its domain
|
||||
capabilities once per emulator, arch, machine and domain type: the create
|
||||
page's getters parse the capabilities some 60 times, the overview reads the
|
||||
info four times. An object lives for one request. The interfaces page lists
|
||||
only the host's network devices, not all of them, and knows whether the host
|
||||
can change interfaces at all."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import VIR_CONNECT_LIST_NODE_DEVICES_CAP_NET, VIR_ERR_NO_SUPPORT, VIR_ERR_OPERATION_DENIED, VIR_ERR_XML_ERROR, libvirtError
|
||||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager.hostdetails import wvmHostDetails
|
||||
|
||||
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>
|
||||
<machine>pc-q35-9.0</machine><machine canonical='pc-q35-9.0'>q35</machine><machine canonical='pc-i440fx-9.0'>pc</machine>
|
||||
<domain type='qemu'/><domain type='kvm'/></arch></guest></capabilities>"""
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.reads = []
|
||||
|
||||
def getCapabilities(self):
|
||||
self.reads.append("capabilities")
|
||||
return CAPABILITIES
|
||||
|
||||
def getInfo(self):
|
||||
self.reads.append("info")
|
||||
return ["x86_64", 2048, 4, 0, 1, 2, 2, 1]
|
||||
|
||||
def getMemoryStats(self, cell, flags):
|
||||
self.reads.append("memory")
|
||||
return {"buffers": 0, "free": 1048576, "cached": 0}
|
||||
|
||||
def getHostname(self):
|
||||
return "host"
|
||||
|
||||
def getSysinfo(self, flags):
|
||||
return "<sysinfo/>"
|
||||
|
||||
def getURI(self):
|
||||
return "qemu:///system"
|
||||
|
||||
def getDomainCapabilities(self, emulator, arch, machine, virttype):
|
||||
self.reads.append((emulator, arch, machine, virttype))
|
||||
return "<domainCapabilities/>"
|
||||
|
||||
|
||||
def connection(cls=wvmConnect):
|
||||
conn = cls.__new__(cls) # no libvirt connection: only these calls
|
||||
conn.wvm = FakeConn()
|
||||
return conn
|
||||
|
||||
|
||||
class CapabilitiesOnceTestCase(unittest.TestCase):
|
||||
def test_the_capabilities_are_read_once_for_all_getters(self):
|
||||
conn = connection()
|
||||
for _ in range(2):
|
||||
conn.get_emulator("x86_64")
|
||||
conn.get_machine_types("x86_64")
|
||||
conn.get_hypervisors_domain_types()
|
||||
conn.get_cap_xml()
|
||||
self.assertEqual(conn.wvm.reads, ["capabilities"])
|
||||
|
||||
def test_domain_capabilities_are_read_once_per_machine(self):
|
||||
conn = connection()
|
||||
for _ in range(2):
|
||||
conn.get_dom_cap_xml("x86_64", "q35")
|
||||
conn.get_dom_cap_xml("x86_64", "pc")
|
||||
conn.get_dom_cap_xml("x86_64", "unknown") # falls back to pc
|
||||
self.assertEqual(
|
||||
conn.wvm.reads,
|
||||
["capabilities", ("/usr/bin/qemu-kvm", "x86_64", "q35", "kvm"), ("/usr/bin/qemu-kvm", "x86_64", "pc", "kvm")],
|
||||
)
|
||||
|
||||
def test_the_overview_reads_the_info_once_and_the_free_memory_each_time(self):
|
||||
conn = connection(wvmHostDetails)
|
||||
conn.get_node_info()
|
||||
conn.get_memory_usage()
|
||||
self.assertEqual(conn.get_memory_usage()["total"], 2048 * 1048576)
|
||||
self.assertEqual(conn.wvm.reads, ["info", "memory", "memory"])
|
||||
|
||||
def test_two_objects_do_not_share_their_reads(self):
|
||||
a, b = connection(), connection()
|
||||
a.get_cap_xml()
|
||||
b.get_cap_xml()
|
||||
self.assertEqual((a.wvm.reads, b.wvm.reads), (["capabilities"], ["capabilities"]))
|
||||
|
||||
def test_a_failed_read_is_not_kept(self):
|
||||
conn = connection()
|
||||
conn.wvm.getCapabilities = lambda: (_ for _ in ()).throw(RuntimeError("libvirt error"))
|
||||
with self.assertRaises(RuntimeError):
|
||||
conn.get_cap_xml()
|
||||
conn.wvm = FakeConn()
|
||||
conn.get_cap_xml()
|
||||
self.assertEqual(conn.wvm.reads, ["capabilities"])
|
||||
|
||||
|
||||
class NetDevicesTestCase(unittest.TestCase):
|
||||
def test_only_the_network_devices_are_listed_and_read(self):
|
||||
conn = connection()
|
||||
devices = {
|
||||
VIR_CONNECT_LIST_NODE_DEVICES_CAP_NET: [
|
||||
MagicMock(**{"XMLDesc.return_value": f"<device><capability type='net'><interface>{name}</interface>"
|
||||
"<capability type='80203'/></capability></device>"})
|
||||
for name in ("eth0", "br0")
|
||||
]
|
||||
}
|
||||
conn.wvm.listAllDevices = lambda flags: devices.get(flags, [])
|
||||
self.assertEqual(conn.get_net_devices(), ["eth0", "br0"])
|
||||
|
||||
|
||||
class ChangeInterfacesTestCase(unittest.TestCase):
|
||||
def error(self, code):
|
||||
err = libvirtError("refused")
|
||||
err.get_error_code = lambda: code
|
||||
return err
|
||||
|
||||
def test_a_backend_without_define_cannot_change_interfaces(self):
|
||||
conn = connection()
|
||||
# any other refusal (access denied, ...) leaves the page as it was
|
||||
for code, changeable in ((VIR_ERR_NO_SUPPORT, False), (VIR_ERR_XML_ERROR, True), (VIR_ERR_OPERATION_DENIED, True)):
|
||||
with self.subTest(code=code):
|
||||
conn.wvm.interfaceDefineXML = MagicMock(side_effect=self.error(code))
|
||||
self.assertIs(conn.can_change_interfaces(), changeable)
|
||||
# an invalid definition: nothing can be defined by asking
|
||||
conn.wvm.interfaceDefineXML.assert_called_once_with("<interface/>", 0)
|
||||
82
vrtManager/tests/test_instance_getters.py
Normal file
82
vrtManager/tests/test_instance_getters.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""The VM page's device getters read each device on its own: a value of one
|
||||
NIC, CD-ROM or boot device does not carry over to the next."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import libvirtError
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
XML = """<domain>
|
||||
<os><type arch='x86_64'>hvm</type></os>
|
||||
<devices>
|
||||
<disk type='file' device='disk'><source file='/pool/a.qcow2'/><target dev='vda' bus='virtio'/><boot order='3'/></disk>
|
||||
<disk type='block' device='disk'><source dev='/dev/vg/b'/><target dev='vdb' bus='virtio'/><boot order='4'/></disk>
|
||||
<disk type='file' device='cdrom'><source file='/iso/x.iso'/><target dev='sda' bus='sata'/></disk>
|
||||
<disk type='file' device='cdrom'><target dev='sdb' bus='sata'/></disk>
|
||||
<interface type='network'>
|
||||
<mac address='52:54:10:00:00:01'/><source network='default'/><model type='virtio'/>
|
||||
<bandwidth><inbound average='100' peak='200' burst='300'/><outbound average='10' peak='20' burst='30'/></bandwidth>
|
||||
</interface>
|
||||
<interface type='bridge'><mac address='52:54:10:00:00:02'/><source bridge='br0'/><boot order='1'/></interface>
|
||||
<interface type='direct'><mac address='52:54:10:00:00:03'/><boot order='2'/></interface>
|
||||
</devices>
|
||||
</domain>"""
|
||||
|
||||
|
||||
def proxy():
|
||||
p = wvmInstance.__new__(wvmInstance) # no connection: only these calls
|
||||
p._ip_cache = {"qemuga": {}, "arp": {}}
|
||||
p._XMLDesc = lambda flags: XML
|
||||
volumes = {"/iso/x.iso": ("x.iso", "iso")}
|
||||
|
||||
def volume(path):
|
||||
if path not in volumes:
|
||||
raise libvirtError("no volume")
|
||||
name, pool = volumes[path]
|
||||
return MagicMock(**{"name.return_value": name, "info.return_value": [0, 10, 4],
|
||||
"storagePoolLookupByVolume.return_value.name.return_value": pool})
|
||||
|
||||
p.get_volume_by_path = volume
|
||||
return p
|
||||
|
||||
|
||||
class DeviceGettersTestCase(unittest.TestCase):
|
||||
def test_a_nic_without_qos_source_or_model_has_none(self):
|
||||
nics = proxy().get_net_devices()
|
||||
self.assertEqual(nics[0]["inbound"], {"average": "100", "peak": "200", "burst": "300"})
|
||||
self.assertEqual(nics[0]["outbound"], {"average": "10", "peak": "20", "burst": "30"})
|
||||
self.assertEqual([(n["inbound"], n["outbound"]) for n in nics[1:]], [([], [])] * 2)
|
||||
self.assertEqual([(n["nic"], n["model"]) for n in nics], [("default", "virtio"), ("br0", ""), ("", "")])
|
||||
|
||||
def test_an_empty_cdrom_shows_no_pool(self):
|
||||
media = proxy().get_media_devices()
|
||||
self.assertEqual(
|
||||
media,
|
||||
[
|
||||
{"dev": "sda", "image": "x.iso", "storage": "iso", "path": "/iso/x.iso", "bus": "sata"},
|
||||
{"dev": "sdb", "image": None, "storage": None, "path": None, "bus": "sata"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_every_boot_device_has_its_target(self):
|
||||
order = proxy().get_bootorder()
|
||||
self.assertEqual(
|
||||
[order[i]["target"] for i in range(4)],
|
||||
["nic-00:00:02", "nic-00:00:03", "vda", "vdb"],
|
||||
)
|
||||
|
||||
|
||||
class RefreshInstancePoolsTestCase(unittest.TestCase):
|
||||
def test_only_pools_holding_a_disk_are_refreshed(self):
|
||||
p = proxy()
|
||||
p.get_disk_devices = lambda: [{"path": "/pool/a.qcow2"}, {"path": "/elsewhere/b.img"}, {"path": None}]
|
||||
pool = MagicMock()
|
||||
p.get_wvmStorages = lambda: MagicMock(get_pool_by_target=lambda target: pool if target == "/pool" else None)
|
||||
p.refresh_instance_pools()
|
||||
pool.refresh.assert_called_once_with(0)
|
||||
54
vrtManager/tests/test_interface_addresses.py
Normal file
54
vrtManager/tests/test_interface_addresses.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""A running VM's addresses come from the guest agent when it answers and
|
||||
from the host's ARP table, so a VM without an agent shows its IPv4 address."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
MAC = "52:54:00:00:00:01"
|
||||
ARP = {"vnet0": {"hwaddr": MAC, "addrs": [{"type": 0, "addr": "192.0.2.10", "prefix": 0}]}}
|
||||
AGENT = {"eth0": {"hwaddr": MAC, "addrs": [{"type": 0, "addr": "192.0.2.20", "prefix": 24}]}}
|
||||
|
||||
|
||||
def vm(agent):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst._ip_cache = None
|
||||
inst.instance = MagicMock()
|
||||
inst.instance.interfaceAddresses.side_effect = lambda source: {
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT: AGENT,
|
||||
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP: ARP,
|
||||
}[source]
|
||||
patch.object(inst, "get_status", return_value=1).start()
|
||||
patch.object(inst, "is_agent_ready", return_value=agent).start()
|
||||
return inst
|
||||
|
||||
|
||||
class InterfaceAddressesTestCase(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def test_a_vm_without_an_agent_shows_its_arp_address(self):
|
||||
inst = vm(agent=False)
|
||||
self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.10"], []))
|
||||
self.assertEqual(
|
||||
[c.args for c in inst.instance.interfaceAddresses.call_args_list], [(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,)]
|
||||
)
|
||||
|
||||
def test_the_agents_address_comes_first(self):
|
||||
inst = vm(agent=True)
|
||||
self.assertEqual(inst.get_interface_addresses(MAC), (["192.0.2.20"], []))
|
||||
self.assertEqual(
|
||||
[c.args for c in inst.instance.interfaceAddresses.call_args_list],
|
||||
[(VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT,), (VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
99
vrtManager/tests/test_iso_media.py
Normal file
99
vrtManager/tests/test_iso_media.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import (
|
||||
VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE,
|
||||
VIR_DOMAIN_AFFECT_CONFIG,
|
||||
VIR_DOMAIN_AFFECT_LIVE,
|
||||
libvirtError,
|
||||
)
|
||||
from lxml import etree
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
DOMAIN = """<domain><devices>
|
||||
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><target dev='sda' bus='sata'/></disk>
|
||||
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><target dev='sdb' bus='sata'/></disk>
|
||||
</devices></domain>"""
|
||||
|
||||
|
||||
def vm(active):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.instance = MagicMock()
|
||||
inst.instance.isActive.return_value = active
|
||||
inst._XMLDesc = MagicMock(return_value=DOMAIN)
|
||||
return inst
|
||||
|
||||
|
||||
class IsoMediaTestCase(unittest.TestCase):
|
||||
"""Media change goes through updateDeviceFlags for the right CD-ROM."""
|
||||
|
||||
def updates(self, inst):
|
||||
return [
|
||||
(etree.fromstring(c.args[0]).find("target").get("dev"), etree.fromstring(c.args[0]).xpath("string(source/@file)"), c.args[1])
|
||||
for c in inst.instance.updateDeviceFlags.call_args_list
|
||||
]
|
||||
|
||||
def test_stopped_vm_changes_only_the_persistent_definition(self):
|
||||
inst = vm(active=False)
|
||||
inst._set_cdrom_media("sdb", "/iso/a.iso")
|
||||
self.assertEqual(self.updates(inst), [("sdb", "/iso/a.iso", VIR_DOMAIN_AFFECT_CONFIG)])
|
||||
|
||||
def test_running_vm_changes_live_then_persistent(self):
|
||||
inst = vm(active=True)
|
||||
inst._set_cdrom_media("sdb", None)
|
||||
self.assertEqual(
|
||||
self.updates(inst), [("sdb", "", VIR_DOMAIN_AFFECT_LIVE), ("sdb", "", VIR_DOMAIN_AFFECT_CONFIG)]
|
||||
)
|
||||
|
||||
def test_unknown_cdrom_changes_nothing(self):
|
||||
inst = vm(active=True)
|
||||
with self.assertRaises(libvirtError):
|
||||
inst._set_cdrom_media("sdz", "/iso/a.iso")
|
||||
inst.instance.updateDeviceFlags.assert_not_called()
|
||||
|
||||
|
||||
def pool(volumes, refresh_error=False, list_error=False):
|
||||
p = MagicMock()
|
||||
if refresh_error:
|
||||
p.refresh.side_effect = libvirtError("refresh failed")
|
||||
if list_error:
|
||||
p.listVolumes.side_effect = libvirtError("pool is not active")
|
||||
else:
|
||||
p.listVolumes.return_value = volumes
|
||||
return p
|
||||
|
||||
|
||||
class IsoListTestCase(unittest.TestCase):
|
||||
"""The CD-ROM choices are the .iso volumes of the active pools, refreshed
|
||||
so that an image copied in by hand shows up."""
|
||||
|
||||
def iso(self, *pools):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.wvm = MagicMock()
|
||||
inst.wvm.listAllStoragePools.return_value = list(pools)
|
||||
return inst, inst.get_iso_media()
|
||||
|
||||
def test_iso_volumes_of_the_active_pools(self):
|
||||
a, b = pool(["a.iso", "disk.qcow2"]), pool(["B.ISO"])
|
||||
inst, iso = self.iso(a, b)
|
||||
self.assertEqual(iso, ["a.iso", "B.ISO"])
|
||||
inst.wvm.listAllStoragePools.assert_called_once_with(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE)
|
||||
a.refresh.assert_called_once_with(0)
|
||||
b.refresh.assert_called_once_with(0)
|
||||
|
||||
def test_a_failed_refresh_still_lists_the_pool(self):
|
||||
_, iso = self.iso(pool(["a.iso"], refresh_error=True))
|
||||
self.assertEqual(iso, ["a.iso"])
|
||||
|
||||
def test_a_pool_stopped_meanwhile_is_skipped(self):
|
||||
_, iso = self.iso(pool([], list_error=True), pool(["b.iso"]))
|
||||
self.assertEqual(iso, ["b.iso"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
249
vrtManager/tests/test_live_connection.py
Normal file
249
vrtManager/tests/test_live_connection.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""Live checks of bounded connection opens against real libvirt.
|
||||
|
||||
Runs only with TEST_LIBVIRT_HOST set (a TCP compute, see instances/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.
|
||||
|
||||
TEST_LIBVIRT_SSH_ALIASES=1 adds the ssh tests that need these entries in the
|
||||
ssh configuration of the user running the tests (SSH_HOST is the ssh compute):
|
||||
|
||||
Host wvc-alias
|
||||
HostName SSH_HOST
|
||||
Host wvc-relay
|
||||
HostName 127.0.0.1
|
||||
Port 2222
|
||||
HostKeyAlias SSH_HOST
|
||||
Host wvc-silent
|
||||
HostName 127.0.0.1
|
||||
Port 2223
|
||||
|
||||
TEST_LIBVIRT_SSH_JUMP=1 adds a ProxyJump test through
|
||||
Host wvc-jump
|
||||
HostName SSH_HOST
|
||||
ProxyJump user@another-host
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import libvirt
|
||||
from vrtManager.connection import CONN_SSH, CONN_TCP, CONNECT_TIMEOUT, connection_manager
|
||||
|
||||
UNREACHABLE = "192.0.2.1"
|
||||
KEEPALIVE_DEATH = 45 # keepalive (5, 5) closes a silent connection after ~30 s
|
||||
|
||||
|
||||
def timed(*args):
|
||||
started = time.monotonic()
|
||||
try:
|
||||
return connection_manager.get_connection(*args), time.monotonic() - started
|
||||
except libvirt.libvirtError as err:
|
||||
return err, time.monotonic() - started
|
||||
|
||||
|
||||
def ssh_processes(host):
|
||||
"""ssh client processes (exact name) whose arguments mention host."""
|
||||
out = subprocess.run(["pgrep", "-a", "-x", "ssh"], capture_output=True, text=True).stdout
|
||||
return [line for line in out.splitlines() if host in line]
|
||||
|
||||
|
||||
class Relay:
|
||||
"""TCP relay that can hold all traffic (frozen) and pass it again (thawed);
|
||||
a frozen relay looks like a host that went silent."""
|
||||
|
||||
def __init__(self, target, port=0, frozen=False):
|
||||
self.forwarding = threading.Event()
|
||||
if not frozen:
|
||||
self.forwarding.set()
|
||||
self.server = socket.socket()
|
||||
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.server.bind(("127.0.0.1", port))
|
||||
self.server.listen(5)
|
||||
self.port = self.server.getsockname()[1]
|
||||
self.target = target
|
||||
self.closed = False
|
||||
self.sockets = []
|
||||
threading.Thread(target=self.accept, daemon=True).start()
|
||||
|
||||
def accept(self):
|
||||
while True:
|
||||
try:
|
||||
client, _ = self.server.accept()
|
||||
except OSError:
|
||||
return
|
||||
upstream = socket.create_connection(self.target)
|
||||
self.sockets += [client, upstream]
|
||||
for a, b in ((client, upstream), (upstream, client)):
|
||||
threading.Thread(target=self.pump, args=(a, b), daemon=True).start()
|
||||
|
||||
def pump(self, src, dst):
|
||||
try:
|
||||
while not self.closed:
|
||||
if not self.forwarding.wait(0.2):
|
||||
continue
|
||||
src.settimeout(0.2)
|
||||
try:
|
||||
data = src.recv(65536)
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not data:
|
||||
break
|
||||
dst.sendall(data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def freeze(self):
|
||||
self.forwarding.clear()
|
||||
time.sleep(0.5) # let a recv that was already waiting run out
|
||||
|
||||
def thaw(self):
|
||||
self.forwarding.set()
|
||||
|
||||
def close(self):
|
||||
"""Stop, and end the relayed connections, as a host that went away would."""
|
||||
self.closed = True
|
||||
self.forwarding.set()
|
||||
try:
|
||||
self.server.shutdown(socket.SHUT_RDWR) # wakes the accept; close alone does not
|
||||
except OSError:
|
||||
pass
|
||||
self.server.close()
|
||||
for sock in self.sockets:
|
||||
sock.close()
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_HOST"), "Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
class LiveBoundedOpenTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.host = os.environ["TEST_LIBVIRT_HOST"]
|
||||
self.login = os.environ.get("TEST_LIBVIRT_LOGIN", "")
|
||||
self.password = os.environ.get("TEST_LIBVIRT_PASSWORD", "")
|
||||
self.ssh_host = os.environ.get("TEST_LIBVIRT_SSH_HOST")
|
||||
self.ssh_login = os.environ.get("TEST_LIBVIRT_SSH_LOGIN", "root")
|
||||
|
||||
# Opening
|
||||
|
||||
def test_unreachable_tcp_host_fails_within_the_timeout_then_at_once(self):
|
||||
args = (UNREACHABLE, "u", "p", CONN_TCP)
|
||||
first, took = timed(*args)
|
||||
self.assertIsInstance(first, libvirt.libvirtError)
|
||||
self.assertLess(took, CONNECT_TIMEOUT + 1)
|
||||
second, took = timed(*args)
|
||||
self.assertIsInstance(second, libvirt.libvirtError)
|
||||
self.assertLess(took, 0.5)
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh"), "no ssh client")
|
||||
def test_unreachable_ssh_host_fails_within_the_timeout_and_leaves_no_ssh(self):
|
||||
result, took = timed(UNREACHABLE, "root", "", CONN_SSH)
|
||||
self.assertIsInstance(result, libvirt.libvirtError)
|
||||
self.assertLess(took, CONNECT_TIMEOUT + 1)
|
||||
time.sleep(12) # the wrapper's ConnectTimeout is 10 s
|
||||
self.assertEqual(ssh_processes(UNREACHABLE), [], "an ssh process outlived its connect timeout")
|
||||
|
||||
def test_a_daemon_that_never_answers_fails_within_the_timeout(self):
|
||||
silent = socket.socket()
|
||||
silent.bind(("127.0.0.1", 0))
|
||||
silent.listen(5) # accepts (kernel backlog), never answers
|
||||
self.addCleanup(silent.close)
|
||||
result, took = timed(f"127.0.0.1:{silent.getsockname()[1]}", "u", "p", CONN_TCP)
|
||||
self.assertIsInstance(result, libvirt.libvirtError)
|
||||
self.assertLess(took, CONNECT_TIMEOUT + 1)
|
||||
|
||||
def check_slow_open_completes_later(self, args, relay):
|
||||
first, _ = timed(*args)
|
||||
self.assertIsInstance(first, libvirt.libvirtError)
|
||||
relay.thaw()
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
conn, _ = timed(*args)
|
||||
if not isinstance(conn, libvirt.libvirtError):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
self.assertNotIsInstance(conn, libvirt.libvirtError)
|
||||
self.assertTrue(conn.getVersion())
|
||||
|
||||
def test_a_slow_tcp_open_that_completes_later_is_used(self):
|
||||
relay = Relay((self.host, 16509), frozen=True)
|
||||
self.addCleanup(relay.close)
|
||||
self.check_slow_open_completes_later((f"127.0.0.1:{relay.port}", self.login, self.password, CONN_TCP), relay)
|
||||
|
||||
# A connection whose host goes silent
|
||||
|
||||
def check_silent_host_is_noticed_and_replaced(self, args, relay):
|
||||
old = connection_manager.get_connection(*args)
|
||||
relay.freeze()
|
||||
outcome = []
|
||||
|
||||
def call():
|
||||
try:
|
||||
outcome.append(old.getVersion())
|
||||
except libvirt.libvirtError as err:
|
||||
outcome.append(err)
|
||||
|
||||
caller = threading.Thread(target=call, daemon=True)
|
||||
caller.start()
|
||||
caller.join(KEEPALIVE_DEATH) # keepalive must end the call by then
|
||||
if caller.is_alive():
|
||||
relay.close() # cut the connection so the stuck call returns
|
||||
self.fail(f"a call on a silent connection still waits after {KEEPALIVE_DEATH} s")
|
||||
self.assertIsInstance(outcome[0], libvirt.libvirtError)
|
||||
relay.thaw()
|
||||
new, _ = timed(*args)
|
||||
self.assertNotIsInstance(new, libvirt.libvirtError)
|
||||
self.assertIsNot(new, old)
|
||||
self.assertTrue(new.getVersion())
|
||||
|
||||
def test_a_silent_tcp_host_is_noticed_and_reconnected(self):
|
||||
relay = Relay((self.host, 16509))
|
||||
self.addCleanup(relay.close)
|
||||
self.check_silent_host_is_noticed_and_replaced((f"127.0.0.1:{relay.port}", self.login, self.password, CONN_TCP), relay)
|
||||
|
||||
# ssh
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_SSH_HOST"), "Set TEST_LIBVIRT_SSH_HOST (and _LOGIN) for an ssh compute")
|
||||
def test_a_reachable_ssh_host_connects_through_the_wrapper(self):
|
||||
conn, _ = timed(self.ssh_host, self.ssh_login, "", CONN_SSH)
|
||||
self.assertNotIsInstance(conn, libvirt.libvirtError)
|
||||
self.assertIn("command=", conn.getURI())
|
||||
self.assertTrue(conn.getVersion())
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_SSH_ALIASES"), "Set TEST_LIBVIRT_SSH_ALIASES=1 (see the module docstring)")
|
||||
def test_an_ssh_config_alias_is_used(self):
|
||||
conn, _ = timed("wvc-alias", self.ssh_login, "", CONN_SSH)
|
||||
self.assertNotIsInstance(conn, libvirt.libvirtError)
|
||||
self.assertTrue(conn.getVersion())
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_SSH_ALIASES"), "Set TEST_LIBVIRT_SSH_ALIASES=1 (see the module docstring)")
|
||||
def test_an_ssh_port_that_never_answers_fails_within_the_timeout(self):
|
||||
silent = socket.socket()
|
||||
silent.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
silent.bind(("127.0.0.1", 2223))
|
||||
silent.listen(5) # no ssh banner ever comes
|
||||
self.addCleanup(silent.close)
|
||||
result, took = timed("wvc-silent", self.ssh_login, "", CONN_SSH)
|
||||
self.assertIsInstance(result, libvirt.libvirtError)
|
||||
self.assertLess(took, CONNECT_TIMEOUT + 1)
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_SSH_ALIASES"), "Set TEST_LIBVIRT_SSH_ALIASES=1 (see the module docstring)")
|
||||
def test_a_slow_ssh_open_that_completes_later_is_used(self):
|
||||
relay = Relay((self.ssh_host, 22), port=2222, frozen=True)
|
||||
self.addCleanup(relay.close)
|
||||
self.check_slow_open_completes_later(("wvc-relay", self.ssh_login, "", CONN_SSH), relay)
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_SSH_ALIASES"), "Set TEST_LIBVIRT_SSH_ALIASES=1 (see the module docstring)")
|
||||
def test_a_silent_ssh_host_is_noticed_and_reconnected(self):
|
||||
relay = Relay((self.ssh_host, 22), port=2222)
|
||||
self.addCleanup(relay.close)
|
||||
self.check_silent_host_is_noticed_and_replaced(("wvc-relay", self.ssh_login, "", CONN_SSH), relay)
|
||||
|
||||
@unittest.skipUnless(os.environ.get("TEST_LIBVIRT_SSH_JUMP"), "Set TEST_LIBVIRT_SSH_JUMP=1 (see the module docstring)")
|
||||
def test_ssh_through_a_jump_host(self):
|
||||
conn, _ = timed("wvc-jump", self.ssh_login, "", CONN_SSH)
|
||||
self.assertNotIsInstance(conn, libvirt.libvirtError)
|
||||
self.assertTrue(conn.getVersion())
|
||||
88
vrtManager/tests/test_live_nvram.py
Normal file
88
vrtManager/tests/test_live_nvram.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""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
|
||||
deletion needs a second host: TEST_LIBVIRT_SSH_HOST (and _LOGIN).
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import libvirt
|
||||
from django.test import SimpleTestCase
|
||||
from lxml import etree
|
||||
|
||||
from instances.tests import livetest
|
||||
from vrtManager.connection import CONN_SSH, NVRAM_DIR, connection_manager, wvmConnect
|
||||
|
||||
NAME = livetest.PREFIX + "nvram"
|
||||
PATH = f"{NVRAM_DIR}/{NAME}_VARS.fd"
|
||||
|
||||
|
||||
class LiveRemoveNvramTestCase(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)
|
||||
cls.inventory = livetest.inventory(cls.conn)
|
||||
cls.pools = {p.name() for p in cls.conn.listAllStoragePools()}
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
livetest.cleanup(cls.conn)
|
||||
super().tearDownClass()
|
||||
if livetest.inventory(cls.conn) != cls.inventory:
|
||||
raise AssertionError("Live tests changed objects outside the test namespace")
|
||||
if {p.name() for p in cls.conn.listAllStoragePools()} != cls.pools:
|
||||
raise AssertionError("A storage pool was left behind")
|
||||
|
||||
def leave_nvram(self):
|
||||
"""What a migration with VIR_MIGRATE_UNDEFINE_SOURCE leaves: the file
|
||||
without a domain."""
|
||||
dom = self.conn.defineXML(
|
||||
f"<domain type='kvm'><name>{NAME}</name><memory unit='MiB'>128</memory><vcpu>1</vcpu>"
|
||||
"<os firmware='efi'><type arch='x86_64' machine='q35'>hvm</type></os>"
|
||||
"<features><acpi/></features><devices/></domain>"
|
||||
)
|
||||
dom.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) # creates the file
|
||||
self.assertEqual(etree.fromstring(dom.XMLDesc(0)).findtext("os/nvram"), PATH)
|
||||
dom.destroy()
|
||||
dom.undefineFlags(libvirt.VIR_DOMAIN_UNDEFINE_KEEP_NVRAM)
|
||||
|
||||
def tearDown(self):
|
||||
livetest.cleanup(self.conn)
|
||||
host = wvmConnect(*self.args)
|
||||
with host._nvram_pool() as pool:
|
||||
vol = host._find_volume(pool, os.path.basename(PATH))
|
||||
if vol is not None:
|
||||
vol.delete(0)
|
||||
|
||||
def exists(self):
|
||||
host = wvmConnect(*self.args)
|
||||
with host._nvram_pool() as pool:
|
||||
return host._find_volume(pool, os.path.basename(PATH)) is not None
|
||||
|
||||
def test_a_directory_both_connections_see_is_kept(self):
|
||||
self.leave_nvram()
|
||||
self.assertFalse(wvmConnect(*self.args).remove_nvram(PATH, wvmConnect(*self.args)))
|
||||
self.assertTrue(self.exists())
|
||||
|
||||
def test_the_file_is_deleted_when_the_destination_is_another_host(self):
|
||||
other_host = os.environ.get("TEST_LIBVIRT_SSH_HOST")
|
||||
if not other_host or other_host == self.args[0]:
|
||||
self.skipTest("Set TEST_LIBVIRT_SSH_HOST to a second host")
|
||||
other = wvmConnect(other_host, os.environ.get("TEST_LIBVIRT_SSH_LOGIN", "root"), "", CONN_SSH)
|
||||
other_pools = {p.name() for p in other.wvm.listAllStoragePools()}
|
||||
self.leave_nvram()
|
||||
self.assertTrue(wvmConnect(*self.args).remove_nvram(PATH, other))
|
||||
self.assertFalse(self.exists())
|
||||
self.assertEqual({p.name() for p in other.wvm.listAllStoragePools()}, other_pools)
|
||||
479
vrtManager/tests/test_migration.py
Normal file
479
vrtManager/tests/test_migration.py
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
"""Migration: the source definition never stays behind; a shut-off VM's
|
||||
definition is moved by the app, not by libvirt's offline migration."""
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
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 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, **domain):
|
||||
self.state = state
|
||||
self.instance = FakeDomain(**domain)
|
||||
|
||||
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
|
||||
|
||||
|
||||
class MigrationFlagsTestCase(unittest.TestCase):
|
||||
def test_the_source_definition_is_always_removed(self):
|
||||
for state, live, offline in ((1, True, False), (3, False, False)):
|
||||
with self.subTest(state=state):
|
||||
source = FakeSource(state)
|
||||
destination().moveto(source, "vm", live=live, unsafe=False, offline=offline)
|
||||
flags = source.instance.calls[0][0]
|
||||
self.assertTrue(flags & libvirt.VIR_MIGRATE_UNDEFINE_SOURCE)
|
||||
self.assertTrue(flags & libvirt.VIR_MIGRATE_PERSIST_DEST)
|
||||
|
||||
def test_the_migration_uri_is_passed_on(self):
|
||||
source = FakeSource(1)
|
||||
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, uri="tcp://192.0.2.12")
|
||||
self.assertEqual(source.instance.calls[0][1], "tcp://192.0.2.12")
|
||||
source = FakeSource(1)
|
||||
destination().moveto(source, "vm", live=True, unsafe=False, offline=False)
|
||||
self.assertIsNone(source.instance.calls[0][1])
|
||||
|
||||
|
||||
class ShutOffMigrationTestCase(unittest.TestCase):
|
||||
"""libvirt's offline migration fails on some builds (RHEL-156800); a
|
||||
shut-off VM is only its definition, so the app moves that itself."""
|
||||
|
||||
def test_the_definition_moves_to_the_destination(self):
|
||||
host = FakeHost()
|
||||
source = FakeSource(5, xml="<domain><name>vm</name></domain>")
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertEqual(host.defined, ["<domain><name>vm</name></domain>"])
|
||||
self.assertEqual(source.instance.calls, [])
|
||||
self.assertEqual(source.instance.undefined, 0)
|
||||
|
||||
def test_a_vm_with_nvram_or_tpm_state_is_refused(self):
|
||||
"""That state stays on the source host: the VM would start on the
|
||||
destination with new UEFI variables and a new TPM."""
|
||||
cases = {
|
||||
"nvram": "<os><nvram>/var/lib/libvirt/qemu/nvram/vm_VARS.fd</nvram></os>",
|
||||
"tpm": "<devices><tpm model='tpm-crb'><backend type='emulator' version='2.0'/></tpm></devices>",
|
||||
}
|
||||
for case, xml in cases.items():
|
||||
with self.subTest(case=case):
|
||||
host = FakeHost()
|
||||
source = FakeSource(5, xml=f"<domain>{xml}</domain>")
|
||||
with self.assertRaisesRegex(util.OperationError, "migrate it live"):
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertEqual(host.defined, [])
|
||||
self.assertIsNone(source.instance.undefined)
|
||||
|
||||
def test_a_tpm_without_local_state_moves(self):
|
||||
xml = "<domain><devices><tpm model='tpm-tis'><backend type='passthrough'/></tpm></devices></domain>"
|
||||
host = FakeHost()
|
||||
destination(host).moveto(FakeSource(5, xml=xml), "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertEqual(host.defined, [xml])
|
||||
|
||||
def test_refused_before_anything_changes(self):
|
||||
cases = {
|
||||
"snapshots": (FakeHost(), {"snapshots": 2}),
|
||||
"saved state": (FakeHost(), {"saved": True}),
|
||||
"same uuid on the destination": (FakeHost(existing={"u-1"}), {}),
|
||||
"same name on the destination": (FakeHost(existing={"vm"}), {}),
|
||||
}
|
||||
for case, (host, domain) in cases.items():
|
||||
with self.subTest(case=case):
|
||||
source = FakeSource(5, **domain)
|
||||
with self.assertRaises(util.OperationError):
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertEqual(host.defined, [])
|
||||
self.assertIsNone(source.instance.undefined)
|
||||
|
||||
def test_a_failed_source_undefine_removes_the_new_definition(self):
|
||||
host = FakeHost()
|
||||
source = FakeSource(5, undefine_fails=True)
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertIsNotNone(host.new[0].undefined)
|
||||
|
||||
def test_the_destination_definition_stays_when_the_source_has_none(self):
|
||||
host = FakeHost()
|
||||
source = FakeSource(5, undefine_lost=True)
|
||||
with self.assertRaisesRegex(util.OperationError, "only on the destination"):
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertIsNone(host.new[0].undefined)
|
||||
|
||||
def test_a_failed_rollback_is_reported(self):
|
||||
host = FakeHost()
|
||||
source = FakeSource(5, undefine_fails=True)
|
||||
host.defineXML = lambda xml: host.new.append(FakeDomain(undefine_fails=True)) or host.new[-1]
|
||||
with self.assertRaisesRegex(util.OperationError, "undefine failed; the VM is now defined on both hosts"):
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
|
||||
def test_a_vm_started_meanwhile_stays_on_the_source(self):
|
||||
host = FakeHost()
|
||||
source = FakeSource(5, xml="<domain><name>vm</name></domain>", started=True)
|
||||
with self.assertRaisesRegex(util.OperationError, "started on the source"):
|
||||
destination(host).moveto(source, "vm", live=False, unsafe=False, offline=True)
|
||||
self.assertEqual(source.instance.redefined, ["<domain><name>vm</name></domain>"])
|
||||
self.assertEqual(source.instance.restored_autostart, 1)
|
||||
self.assertIsNotNone(host.new[0].undefined)
|
||||
|
||||
|
||||
class MigrationModeTestCase(unittest.TestCase):
|
||||
"""The state is read once; a request that does not fit it is refused
|
||||
before anything changes, and moveto names the mode it used."""
|
||||
|
||||
def migrate(self, state, **request):
|
||||
source = FakeSource(state)
|
||||
options = {"live": False, "unsafe": False, "offline": False, **request}
|
||||
mode = destination().moveto(source, "vm", **options)
|
||||
return mode, source.instance.calls[0][0] if source.instance.calls else None
|
||||
|
||||
def test_refused_requests(self):
|
||||
for state, request in ((5, {"live": True}), (1, {"offline": True}), (3, {"offline": True}),
|
||||
(1, {"live": True, "offline": True}), (6, {}), (4, {}), (7, {}), (0, {})):
|
||||
with self.subTest(state=state, request=request):
|
||||
source = FakeSource(state)
|
||||
with self.assertRaises(util.OperationError):
|
||||
destination().moveto(source, "vm", unsafe=False, **{"live": False, "offline": False, **request})
|
||||
self.assertEqual(source.instance.calls, [])
|
||||
|
||||
def test_modes(self):
|
||||
live, unsafe = libvirt.VIR_MIGRATE_LIVE, libvirt.VIR_MIGRATE_UNSAFE
|
||||
converge, compressed = libvirt.VIR_MIGRATE_AUTO_CONVERGE, libvirt.VIR_MIGRATE_COMPRESSED
|
||||
mode, flags = self.migrate(1, live=True, autoconverge=True, compress=True, unsafe=True)
|
||||
self.assertEqual(mode, "live, unsafe, auto converge, compressed")
|
||||
self.assertEqual(flags & (live | unsafe | converge | compressed), live | unsafe | converge | compressed)
|
||||
source = FakeSource(1)
|
||||
destination().moveto(source, "vm", live=True, unsafe=False, offline=False)
|
||||
self.assertEqual(source.reads, 1)
|
||||
mode, flags = self.migrate(1, compress=True, autoconverge=True)
|
||||
self.assertEqual(mode, "non-live, compressed")
|
||||
self.assertEqual(flags & (live | converge | compressed), compressed)
|
||||
# a paused VM stays paused; the tuning options are for running VMs
|
||||
mode, flags = self.migrate(3, live=True, autoconverge=True, compress=True, unsafe=True)
|
||||
self.assertEqual(mode, "live")
|
||||
self.assertEqual(flags & (live | unsafe | converge | compressed), live)
|
||||
self.assertEqual(self.migrate(5), ("offline", None))
|
||||
self.assertEqual(self.migrate(5, offline=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>"
|
||||
|
||||
|
||||
class DestinationDisksTestCase(unittest.TestCase):
|
||||
"""Every disk of the VM must be a volume the destination knows; the
|
||||
VM is refused before anything changes otherwise, naming the disks."""
|
||||
|
||||
def migrate(self, host, xml, state=1, inactive_xml=None):
|
||||
source = FakeSource(state, xml=xml)
|
||||
source.instance.inactive_xml = inactive_xml
|
||||
live = state != 5
|
||||
destination(host).moveto(source, "vm", live=live, unsafe=False, offline=not live)
|
||||
return source
|
||||
|
||||
def test_known_volumes_migrate(self):
|
||||
host = FakeHost(volumes={"/pool/vm.qcow2", "/pool/base.qcow2", "/iso/os.iso"})
|
||||
xml = with_disks(
|
||||
"<disk type='file' device='disk'><source file='/pool/vm.qcow2'/><target dev='vda'/>"
|
||||
"<backingStore type='file'><source file='/pool/base.qcow2'/><backingStore/></backingStore></disk>",
|
||||
"<disk type='file' device='cdrom'><source file='/iso/os.iso'/><target dev='sda'/></disk>",
|
||||
"<disk type='file' device='cdrom'><target dev='sdb'/></disk>", # empty
|
||||
"<disk type='network' device='disk'><source protocol='rbd' name='rbd/vm'/><target dev='vdb'/></disk>",
|
||||
"<disk type='volume' device='disk'><source pool='p' volume='/pool/vm.qcow2'/><target dev='vdc'/></disk>",
|
||||
)
|
||||
self.assertEqual(len(self.migrate(host, xml).instance.calls), 1)
|
||||
self.assertEqual(host.defined, [])
|
||||
self.migrate(host, xml, state=5)
|
||||
self.assertEqual(len(host.defined), 1)
|
||||
|
||||
def test_missing_disks_are_refused_and_named(self):
|
||||
cases = {
|
||||
"vda \\(/pool/vm.qcow2\\)": with_disks(FILE_DISK),
|
||||
"sda \\(/iso/os.iso\\)": with_disks(
|
||||
"<disk type='file' device='cdrom'><source file='/iso/os.iso'/><target dev='sda'/></disk>"),
|
||||
"vda \\(/pool/base.qcow2\\)": with_disks(
|
||||
"<disk type='file' device='disk'><source file='/pool/vm.qcow2'/><target dev='vda'/>"
|
||||
"<backingStore type='file'><source file='/pool/base.qcow2'/></backingStore></disk>"),
|
||||
"vdb \\(/dev/sdz\\)": with_disks(
|
||||
"<disk type='block' device='disk'><source dev='/dev/sdz'/><target dev='vdb'/></disk>"),
|
||||
"vdc \\(p/v\\)": with_disks(
|
||||
"<disk type='volume' device='disk'><source pool='p' volume='v'/><target dev='vdc'/></disk>"),
|
||||
}
|
||||
for missing, xml in cases.items():
|
||||
for state in (1, 5):
|
||||
with self.subTest(missing=missing, state=state):
|
||||
host = FakeHost(volumes=set() if "vm.qcow2" in missing else {"/pool/vm.qcow2"})
|
||||
source = FakeSource(state, xml=xml)
|
||||
with self.assertRaisesRegex(util.OperationError, missing):
|
||||
destination(host).moveto(source, "vm", live=state == 1, unsafe=False, offline=state == 5)
|
||||
self.assertEqual((source.instance.calls, host.defined), ([], []))
|
||||
|
||||
def test_a_disk_of_the_persistent_definition_is_checked_too(self):
|
||||
host = FakeHost(volumes={"/pool/vm.qcow2"})
|
||||
inactive = with_disks(FILE_DISK, "<disk type='file' device='disk'><source file='/pool/data.img'/>"
|
||||
"<target dev='vdb'/></disk>")
|
||||
with self.assertRaisesRegex(util.OperationError, "vdb \\(/pool/data.img\\)"):
|
||||
self.migrate(host, with_disks(FILE_DISK), inactive_xml=inactive)
|
||||
|
||||
def test_pools_are_refreshed_before_a_disk_counts_as_missing(self):
|
||||
# a file another host made on shared storage is listed after a refresh
|
||||
host = FakeHost(unlisted={"/pool/vm.qcow2"})
|
||||
self.assertEqual(len(self.migrate(host, with_disks(FILE_DISK)).instance.calls), 1)
|
||||
self.assertEqual(host.refreshes, 1)
|
||||
host = FakeHost(volumes={"/pool/vm.qcow2"})
|
||||
self.migrate(host, with_disks(FILE_DISK))
|
||||
self.assertEqual(host.refreshes, 0)
|
||||
|
||||
def test_backing_files_the_definition_does_not_list_are_checked(self):
|
||||
# an inactive definition has no <backingStore>: the volume tells
|
||||
for state in (1, 5):
|
||||
with self.subTest(state=state):
|
||||
host = FakeHost(volumes={"/pool/vm.qcow2", "/pool/mid.qcow2"},
|
||||
backing={"/pool/vm.qcow2": "/pool/mid.qcow2", "/pool/mid.qcow2": "/local/base.qcow2"})
|
||||
with self.assertRaisesRegex(util.OperationError, "vda \\(/local/base.qcow2\\)"):
|
||||
self.migrate(host, with_disks(FILE_DISK), state=state)
|
||||
host.volumes.add("/local/base.qcow2")
|
||||
self.migrate(host, with_disks(FILE_DISK), state=state)
|
||||
|
||||
def test_a_network_backing_image_is_not_checked(self):
|
||||
host = FakeHost(volumes={"/pool/vm.qcow2"}, backing={"/pool/vm.qcow2": "nbd://storage.example.org:10809/base"})
|
||||
self.assertEqual(len(self.migrate(host, with_disks(FILE_DISK)).instance.calls), 1)
|
||||
|
||||
def test_disks_that_cannot_be_checked_are_refused(self):
|
||||
xml = with_disks("<disk type='dir' device='disk'><source dir='/srv/vm'/><target dev='vdb'/></disk>")
|
||||
with self.assertRaisesRegex(util.OperationError, "vdb \\(dir disk, cannot be checked\\)"):
|
||||
self.migrate(FakeHost(), xml, state=5)
|
||||
|
||||
def test_a_volume_of_an_inactive_pool_is_missing(self):
|
||||
host = FakeHost(volumes={"v"}, inactive_pools={"p"})
|
||||
xml = with_disks("<disk type='volume' device='disk'><source pool='p' volume='v'/><target dev='vdc'/></disk>")
|
||||
with self.assertRaisesRegex(util.OperationError, "vdc \\(p/v\\)"):
|
||||
self.migrate(host, xml)
|
||||
|
||||
|
||||
class SlowDomain(FakeDomain):
|
||||
"""A migration that does not finish until its job is aborted."""
|
||||
|
||||
def __init__(self, **kw):
|
||||
super().__init__(**kw)
|
||||
self.aborted = threading.Event()
|
||||
|
||||
def migrate(self, dconn, flags, dname, uri, bandwidth):
|
||||
if not self.aborted.wait(5):
|
||||
raise AssertionError("the migration was not aborted")
|
||||
raise libvirt.libvirtError("operation aborted: job 'migration out' canceled by client")
|
||||
|
||||
def abortJob(self):
|
||||
self.aborted.set()
|
||||
|
||||
|
||||
class MigrationTimeLimitTestCase(unittest.TestCase):
|
||||
"""A migration that does not converge is cancelled after the time limit,
|
||||
before the web server ends the request; the VM stays on the source."""
|
||||
|
||||
def test_a_migration_over_the_limit_is_cancelled(self):
|
||||
source = FakeSource(1)
|
||||
source.instance = SlowDomain()
|
||||
with self.assertRaisesRegex(util.OperationError, "did not finish within 0.05 s and was cancelled.*auto converge"):
|
||||
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, timeout=0.05)
|
||||
self.assertTrue(source.instance.aborted.is_set())
|
||||
|
||||
def test_a_migration_within_the_limit_stops_its_timer(self):
|
||||
timers = []
|
||||
|
||||
class Timer(threading.Timer):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self.joined = False
|
||||
timers.append(self)
|
||||
|
||||
def join(self, timeout=None):
|
||||
self.joined = True
|
||||
super().join(timeout)
|
||||
|
||||
source = FakeSource(1)
|
||||
with patch("vrtManager.instance.threading.Timer", Timer):
|
||||
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, timeout=5)
|
||||
self.assertEqual(len(source.instance.calls), 1)
|
||||
self.assertTrue(timers[0].finished.is_set() and timers[0].joined)
|
||||
self.assertFalse(timers[0].is_alive())
|
||||
|
||||
def test_a_failure_the_cancel_did_not_cause_keeps_its_error(self):
|
||||
source = FakeSource(1)
|
||||
source.instance = SlowDomain()
|
||||
|
||||
def refused():
|
||||
source.instance.aborted.set() # migrate() gives up, but the abort itself failed
|
||||
raise libvirt.libvirtError("cannot abort migration in confirm phase")
|
||||
|
||||
source.instance.abortJob = refused
|
||||
with self.assertRaisesRegex(libvirt.libvirtError, "operation aborted") as caught:
|
||||
destination().moveto(source, "vm", live=True, unsafe=False, offline=False, timeout=0.05)
|
||||
self.assertNotIsInstance(caught.exception, util.OperationError)
|
||||
169
vrtManager/tests/test_network_ip.py
Normal file
169
vrtManager/tests/test_network_ip.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
try:
|
||||
from django.conf import settings
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
except ImportError:
|
||||
django_mock = MagicMock()
|
||||
django_mock.conf.settings.MAC_OUI = "52:54:10"
|
||||
sys.modules["django"] = django_mock
|
||||
sys.modules["django.conf"] = django_mock.conf
|
||||
|
||||
from vrtManager.network import network_size, wvmNetwork
|
||||
|
||||
|
||||
class TestNetworkSize(unittest.TestCase):
|
||||
"""Unit tests for the network_size function."""
|
||||
|
||||
def test_ipv4_slash_24_with_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("192.168.100.0/24", dhcp=True)
|
||||
self.assertEqual(gateway, "192.168.100.1")
|
||||
self.assertEqual(mask, "255.255.255.0")
|
||||
self.assertIsNotNone(dhcp_pool)
|
||||
self.assertEqual(str(dhcp_pool[0]), "192.168.100.2")
|
||||
self.assertEqual(str(dhcp_pool[1]), "192.168.100.254")
|
||||
|
||||
def test_ipv4_slash_24_without_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("192.168.100.0/24", dhcp=False)
|
||||
self.assertEqual(gateway, "192.168.100.1")
|
||||
self.assertEqual(mask, "255.255.255.0")
|
||||
self.assertIsNone(dhcp_pool)
|
||||
|
||||
def test_ipv4_slash_16_with_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("172.16.0.0/16", dhcp=True)
|
||||
self.assertEqual(gateway, "172.16.0.1")
|
||||
self.assertEqual(mask, "255.255.0.0")
|
||||
self.assertIsNotNone(dhcp_pool)
|
||||
self.assertEqual(str(dhcp_pool[0]), "172.16.0.2")
|
||||
self.assertEqual(str(dhcp_pool[1]), "172.16.255.254")
|
||||
|
||||
def test_ipv4_slash_28_with_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("10.0.0.0/28", dhcp=True)
|
||||
self.assertEqual(gateway, "10.0.0.1")
|
||||
self.assertEqual(mask, "255.255.255.240")
|
||||
self.assertIsNotNone(dhcp_pool)
|
||||
self.assertEqual(str(dhcp_pool[0]), "10.0.0.2")
|
||||
self.assertEqual(str(dhcp_pool[1]), "10.0.0.14")
|
||||
|
||||
def test_ipv4_slash_30_with_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("10.0.0.0/30", dhcp=True)
|
||||
self.assertEqual(gateway, "10.0.0.1")
|
||||
self.assertEqual(mask, "255.255.255.252")
|
||||
self.assertIsNotNone(dhcp_pool)
|
||||
self.assertEqual(str(dhcp_pool[0]), "10.0.0.2")
|
||||
self.assertEqual(str(dhcp_pool[1]), "10.0.0.2")
|
||||
|
||||
def test_ipv6_slash_64_with_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("2001:db8:ca2:2::/64", dhcp=True)
|
||||
self.assertEqual(gateway, "2001:db8:ca2:2::1")
|
||||
self.assertEqual(mask, "64")
|
||||
self.assertIsNotNone(dhcp_pool)
|
||||
self.assertEqual(str(dhcp_pool[0]), "2001:db8:ca2:2::100")
|
||||
self.assertEqual(str(dhcp_pool[1]), "2001:db8:ca2:2::1ff")
|
||||
|
||||
def test_ipv6_slash_64_without_dhcp(self):
|
||||
gateway, mask, dhcp_pool = network_size("2001:db8:ca2:2::/64", dhcp=False)
|
||||
self.assertEqual(gateway, "2001:db8:ca2:2::1")
|
||||
self.assertEqual(mask, "64")
|
||||
self.assertIsNone(dhcp_pool)
|
||||
|
||||
def test_invalid_subnet(self):
|
||||
with self.assertRaises(ValueError):
|
||||
network_size("invalid.subnet/99")
|
||||
|
||||
|
||||
class DummyNetwork(wvmNetwork):
|
||||
"""Subclass of wvmNetwork with mock XML for isolated IP method testing."""
|
||||
|
||||
def __init__(self, xml_content):
|
||||
self._xml = xml_content
|
||||
self.parent_count = 2
|
||||
self.updates = []
|
||||
|
||||
def _XMLDesc(self, flags):
|
||||
return self._xml
|
||||
|
||||
def update(self, command, section, xml, parent_index, flags):
|
||||
self.updates.append((command, section, xml, parent_index, flags))
|
||||
|
||||
|
||||
class TestWvmNetworkIPOperations(unittest.TestCase):
|
||||
"""Unit tests for IP parsing, DHCP ranges, and host addresses in wvmNetwork."""
|
||||
|
||||
DUAL_STACK_XML = """<network>
|
||||
<name>default</name>
|
||||
<ip address="192.168.100.1" netmask="255.255.255.0">
|
||||
<dhcp>
|
||||
<range start="192.168.100.2" end="192.168.100.254"/>
|
||||
<host mac="52:54:00:12:34:56" name="vm1" ip="192.168.100.50"/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
<ip family="ipv6" address="2001:db8:ca2:2::1" prefix="64">
|
||||
<dhcp>
|
||||
<range start="2001:db8:ca2:2::100" end="2001:db8:ca2:2::1ff"/>
|
||||
<host id="0:1:0:1" name="vm2" ip="2001:db8:ca2:2::50"/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>"""
|
||||
|
||||
NO_IP_XML = """<network>
|
||||
<name>isolated</name>
|
||||
<bridge name="virbr1"/>
|
||||
</network>"""
|
||||
|
||||
def test_get_ip_networks_dual_stack(self):
|
||||
net = DummyNetwork(self.DUAL_STACK_XML)
|
||||
ip_networks = net.get_ip_networks()
|
||||
self.assertIn("ipv4", ip_networks)
|
||||
self.assertIn("ipv6", ip_networks)
|
||||
self.assertEqual(str(ip_networks["ipv4"]), "192.168.100.0/24")
|
||||
self.assertEqual(str(ip_networks["ipv6"]), "2001:db8:ca2:2::/64")
|
||||
|
||||
def test_get_ip_networks_no_ip(self):
|
||||
net = DummyNetwork(self.NO_IP_XML)
|
||||
ip_networks = net.get_ip_networks()
|
||||
self.assertEqual(ip_networks, {})
|
||||
|
||||
def test_get_dhcp_range_ipv4(self):
|
||||
net = DummyNetwork(self.DUAL_STACK_XML)
|
||||
dhcp = net.get_dhcp_range("ipv4")
|
||||
self.assertIsNotNone(dhcp)
|
||||
self.assertEqual(len(dhcp), 2)
|
||||
self.assertEqual(str(dhcp[0]), "192.168.100.2")
|
||||
self.assertEqual(str(dhcp[1]), "192.168.100.254")
|
||||
|
||||
def test_get_dhcp_range_ipv6(self):
|
||||
net = DummyNetwork(self.DUAL_STACK_XML)
|
||||
dhcp = net.get_dhcp_range("ipv6")
|
||||
self.assertIsNotNone(dhcp)
|
||||
self.assertEqual(len(dhcp), 2)
|
||||
self.assertEqual(str(dhcp[0]), "2001:db8:ca2:2::100")
|
||||
self.assertEqual(str(dhcp[1]), "2001:db8:ca2:2::1ff")
|
||||
|
||||
def test_get_dhcp_range_empty(self):
|
||||
net = DummyNetwork(self.NO_IP_XML)
|
||||
self.assertIsNone(net.get_dhcp_range("ipv4"))
|
||||
self.assertIsNone(net.get_dhcp_range("ipv6"))
|
||||
|
||||
def test_get_dhcp_range_start_and_end(self):
|
||||
net = DummyNetwork(self.DUAL_STACK_XML)
|
||||
self.assertEqual(str(net.get_dhcp_range_start("ipv4")), "192.168.100.2")
|
||||
self.assertEqual(str(net.get_dhcp_range_end("ipv4")), "192.168.100.254")
|
||||
self.assertEqual(str(net.get_dhcp_range_start("ipv6")), "2001:db8:ca2:2::100")
|
||||
self.assertEqual(str(net.get_dhcp_range_end("ipv6")), "2001:db8:ca2:2::1ff")
|
||||
|
||||
def test_modify_fixed_address_validation(self):
|
||||
net = DummyNetwork(self.DUAL_STACK_XML)
|
||||
# Invalid IP address must raise ValueError
|
||||
with self.assertRaises(ValueError):
|
||||
net.modify_fixed_address("bad_host", "999.999.999.999", "52:54:00:11:22:33", family="ipv4")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
net.modify_fixed_address("bad_host", "not-an-ip", "0:1:0:2", family="ipv6")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
129
vrtManager/tests/test_persistent_edits.py
Normal file
129
vrtManager/tests/test_persistent_edits.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from vrtManager.instance import PERSISTENT_XML, wvmInstance
|
||||
|
||||
DOMAIN = """<domain><name>vm</name><memory>131072</memory><currentMemory>131072</currentMemory>
|
||||
<vcpu>1</vcpu><os><type>hvm</type></os><devices>
|
||||
<interface type='network'><mac address='52:54:00:00:00:01'/><source network='default'/></interface>
|
||||
<graphics type='vnc' passwd='secret'><listen type='address' address='0.0.0.0'/></graphics>
|
||||
<video><model type='vga' primary='yes'/></video></devices></domain>"""
|
||||
|
||||
|
||||
class PersistentEditsTestCase(unittest.TestCase):
|
||||
"""Edits read the persistent definition with secrets."""
|
||||
|
||||
def test_edits_read_the_persistent_secure_definition(self):
|
||||
edits = {
|
||||
"set_bootmenu": lambda vm: vm.set_bootmenu(1),
|
||||
"set_console_keymap": lambda vm: vm.set_console_keymap("de"),
|
||||
"set_video_model": lambda vm: vm.set_video_model("virtio"),
|
||||
"resize_cpu": lambda vm: vm.resize_cpu("1", "2"),
|
||||
"set_options": lambda vm: vm.set_options({"title": "t"}),
|
||||
"set_qos": lambda vm: vm.set_qos("52:54:00:00:00:01", "inbound", 1, 2, 3),
|
||||
}
|
||||
for name, edit in edits.items():
|
||||
with self.subTest(name):
|
||||
vm = wvmInstance.__new__(wvmInstance)
|
||||
vm.instance = MagicMock()
|
||||
vm.wvm = MagicMock()
|
||||
vm._XMLDesc = MagicMock(return_value=DOMAIN)
|
||||
vm.get_vcpus = MagicMock(return_value=None)
|
||||
vm.get_status = MagicMock(return_value=5)
|
||||
edit(vm)
|
||||
flags = {call.args[0] for call in vm._XMLDesc.call_args_list}
|
||||
self.assertIn(PERSISTENT_XML, flags)
|
||||
defined = (vm.wvm.defineXML.call_args or vm._XMLDesc.call_args)[0][0]
|
||||
self.assertIn("secret", str(defined))
|
||||
|
||||
|
||||
class XmlFidelityTestCase(unittest.TestCase):
|
||||
def test_edits_keep_metadata_namespace_prefixes(self):
|
||||
xml = (
|
||||
"<domain><metadata><app:info xmlns:app='http://example.com/x'>v</app:info></metadata>"
|
||||
"<os><type>hvm</type></os><devices><graphics type='vnc'><listen type='address'/></graphics>"
|
||||
"<disk type='file' device='disk'><target dev='vda'/></disk></devices></domain>"
|
||||
)
|
||||
edits = {
|
||||
"set_bootmenu": lambda vm: vm.set_bootmenu(1),
|
||||
"set_bootorder": lambda vm: vm.set_bootorder({0: {"type": "disk", "dev": "vda"}}),
|
||||
"set_console_keymap": lambda vm: vm.set_console_keymap("de"),
|
||||
"set_console_passwd": lambda vm: vm.set_console_passwd("x"),
|
||||
"set_console_listener_addr": lambda vm: vm.set_console_listener_addr("127.0.0.1"),
|
||||
}
|
||||
for name, edit in edits.items():
|
||||
with self.subTest(name):
|
||||
vm = wvmInstance.__new__(wvmInstance)
|
||||
vm.wvm = MagicMock()
|
||||
vm._XMLDesc = MagicMock(return_value=xml)
|
||||
edit(vm)
|
||||
self.assertIn("<app:info", vm.wvm.defineXML.call_args[0][0])
|
||||
|
||||
|
||||
AGENT_DOMAIN = """<domain><name>vm</name><devices><channel type='unix'>
|
||||
<target type='virtio' name='org.qemu.guest_agent.0'/></channel></devices></domain>"""
|
||||
|
||||
|
||||
class DeviceEditsByStateTestCase(unittest.TestCase):
|
||||
"""Disk, NIC and agent channel changes reach the running definition
|
||||
whenever the VM is active, paused included, and the persistent one
|
||||
always. An attach is one call for both; a removal is one per definition,
|
||||
from that definition's own XML."""
|
||||
|
||||
EDITS = {
|
||||
"attach_disk": ("attachDeviceFlags", lambda vm: vm.attach_disk("vdb", "/var/lib/libvirt/images/d.qcow2")),
|
||||
"add_network": ("attachDeviceFlags", lambda vm: vm.add_network("52:54:00:00:00:09", "default")),
|
||||
"add_guest_agent": ("attachDeviceFlags", lambda vm: vm.add_guest_agent()),
|
||||
"remove_guest_agent": ("detachDeviceFlags", lambda vm: vm.remove_guest_agent()),
|
||||
}
|
||||
ATTACHES = ("attach_disk", "add_network", "add_guest_agent")
|
||||
|
||||
def affected(self, state, active):
|
||||
from libvirt import VIR_DOMAIN_AFFECT_CONFIG, VIR_DOMAIN_AFFECT_LIVE
|
||||
|
||||
names = {VIR_DOMAIN_AFFECT_LIVE: "live", VIR_DOMAIN_AFFECT_CONFIG: "config",
|
||||
VIR_DOMAIN_AFFECT_LIVE | VIR_DOMAIN_AFFECT_CONFIG: "live+config"}
|
||||
result = {}
|
||||
for name, (method, edit) in self.EDITS.items():
|
||||
vm = wvmInstance.__new__(wvmInstance)
|
||||
vm.instance = MagicMock()
|
||||
vm.instance.isActive.return_value = active
|
||||
vm.get_status = MagicMock(return_value=state)
|
||||
vm._XMLDesc = MagicMock(return_value=AGENT_DOMAIN)
|
||||
edit(vm)
|
||||
result[name] = [names[c.args[1]] for c in getattr(vm.instance, method).call_args_list]
|
||||
return result
|
||||
|
||||
def test_paused_vm(self):
|
||||
for name, affected in self.affected(state=3, active=True).items():
|
||||
self.assertEqual(affected, ["live+config"] if name in self.ATTACHES else ["live", "config"], name)
|
||||
|
||||
def test_running_vm(self):
|
||||
for name, affected in self.affected(state=1, active=True).items():
|
||||
self.assertEqual(affected, ["live+config"] if name in self.ATTACHES else ["live", "config"], name)
|
||||
|
||||
def test_stopped_vm(self):
|
||||
for name, affected in self.affected(state=5, active=False).items():
|
||||
self.assertEqual(affected, ["config"], name)
|
||||
|
||||
def test_a_refused_attach_is_not_tried_again(self):
|
||||
from libvirt import libvirtError
|
||||
|
||||
for name in self.ATTACHES:
|
||||
with self.subTest(name):
|
||||
vm = wvmInstance.__new__(wvmInstance)
|
||||
vm.instance = MagicMock()
|
||||
vm.instance.isActive.return_value = True
|
||||
vm.instance.attachDeviceFlags.side_effect = libvirtError("target sda already exists")
|
||||
with self.assertRaises(libvirtError):
|
||||
self.EDITS[name][1](vm)
|
||||
vm.instance.attachDeviceFlags.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
207
vrtManager/tests/test_read_cache.py
Normal file
207
vrtManager/tests/test_read_cache.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""wvmInstance.cached_reads(): inside the block the domain XML (per flags),
|
||||
the VM's state and each disk's volume are read from libvirt once; outside it
|
||||
every read goes to libvirt, as code that changes the VM needs. The host's
|
||||
info and capabilities are read once per object."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import libvirtError
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
|
||||
class FakeDomain:
|
||||
def __init__(self):
|
||||
self.reads = []
|
||||
self.fail = False
|
||||
|
||||
def info(self):
|
||||
self.reads.append("info")
|
||||
return [1, 0, 0, 0, 0]
|
||||
|
||||
def XMLDesc(self, flags):
|
||||
if self.fail:
|
||||
raise RuntimeError("libvirt error")
|
||||
self.reads.append(flags)
|
||||
return f"<domain flags='{flags}' read='{len(self.reads)}'/>"
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.caps = 0
|
||||
self.defined = []
|
||||
|
||||
def getInfo(self):
|
||||
self.caps += 1
|
||||
return ["x86_64", 2048, 4, 0, 1, 2, 2, 1]
|
||||
|
||||
def getCapabilities(self):
|
||||
self.caps += 1
|
||||
return "<capabilities/>"
|
||||
|
||||
def defineXML(self, xml):
|
||||
self.defined.append(xml)
|
||||
|
||||
|
||||
def proxy():
|
||||
p = wvmInstance.__new__(wvmInstance) # no connection: only these calls
|
||||
p.instance = FakeDomain()
|
||||
p.wvm = FakeConn()
|
||||
return p
|
||||
|
||||
|
||||
class CachedReadsTestCase(unittest.TestCase):
|
||||
def test_outside_the_block_every_read_goes_to_libvirt(self):
|
||||
p = proxy()
|
||||
p._XMLDesc(0)
|
||||
p._XMLDesc(0)
|
||||
p.get_status()
|
||||
p.get_status()
|
||||
self.assertEqual(p.instance.reads, [0, 0, "info", "info"])
|
||||
|
||||
def test_inside_the_block_each_flags_value_is_read_once(self):
|
||||
p = proxy()
|
||||
with p.cached_reads():
|
||||
first = [p._XMLDesc(0), p._XMLDesc(1), p._XMLDesc(3)]
|
||||
again = [p._XMLDesc(0), p._XMLDesc(1), p._XMLDesc(3)]
|
||||
self.assertEqual(first, again)
|
||||
self.assertEqual(p.instance.reads, [0, 1, 3])
|
||||
|
||||
def test_the_block_ends_with_its_cache(self):
|
||||
p = proxy()
|
||||
with p.cached_reads():
|
||||
p._XMLDesc(0)
|
||||
with p.cached_reads():
|
||||
p._XMLDesc(0)
|
||||
p._XMLDesc(0)
|
||||
self.assertEqual(p.instance.reads, [0, 0, 0])
|
||||
|
||||
def test_an_error_inside_the_block_still_ends_it(self):
|
||||
p = proxy()
|
||||
with self.assertRaises(ValueError), p.cached_reads():
|
||||
p._XMLDesc(0)
|
||||
raise ValueError
|
||||
p._XMLDesc(0)
|
||||
self.assertEqual(p.instance.reads, [0, 0])
|
||||
|
||||
def test_a_failed_read_is_not_cached(self):
|
||||
p = proxy()
|
||||
with p.cached_reads():
|
||||
p.instance.fail = True
|
||||
with self.assertRaises(RuntimeError):
|
||||
p._XMLDesc(0)
|
||||
p.instance.fail = False
|
||||
p._XMLDesc(0)
|
||||
self.assertEqual(p.instance.reads, [0])
|
||||
|
||||
def test_a_definition_change_drops_the_cache(self):
|
||||
p = proxy()
|
||||
with p.cached_reads():
|
||||
p._XMLDesc(3)
|
||||
p._defineXML("<domain/>")
|
||||
p._XMLDesc(3)
|
||||
self.assertEqual(p.instance.reads, [3, 3])
|
||||
|
||||
def test_blocks_do_not_nest(self):
|
||||
p = proxy()
|
||||
with p.cached_reads(), self.assertRaises(RuntimeError), p.cached_reads():
|
||||
pass
|
||||
|
||||
def test_two_proxies_do_not_share_a_cache(self):
|
||||
a, b = proxy(), proxy()
|
||||
with a.cached_reads():
|
||||
a._XMLDesc(0)
|
||||
b._XMLDesc(0)
|
||||
b._XMLDesc(0)
|
||||
self.assertEqual((a.instance.reads, b.instance.reads), ([0], [0, 0]))
|
||||
|
||||
def test_the_state_and_host_info_are_read_once(self):
|
||||
p = proxy()
|
||||
with p.cached_reads():
|
||||
for _ in range(2):
|
||||
p.get_status()
|
||||
p.get_max_memory()
|
||||
p.get_max_cpus()
|
||||
self.assertEqual((p.instance.reads, p.wvm.caps), (["info"], 1))
|
||||
p.get_status()
|
||||
self.assertEqual(p.instance.reads, ["info", "info"])
|
||||
|
||||
def volume_proxy(paths):
|
||||
"""A proxy whose host has volumes at paths (pool "pool", size 10, used 4)."""
|
||||
p = proxy()
|
||||
p.lookups = []
|
||||
|
||||
def lookup(path):
|
||||
p.lookups.append(path)
|
||||
if path not in paths:
|
||||
raise libvirtError("no storage vol with matching path")
|
||||
vol = MagicMock()
|
||||
vol.name.return_value = path.rsplit("/", 1)[-1]
|
||||
vol.info.return_value = [0, 10, 4]
|
||||
vol.storagePoolLookupByVolume.return_value.name.return_value = "pool"
|
||||
return vol
|
||||
|
||||
p.get_volume_by_path = lookup
|
||||
return p
|
||||
|
||||
|
||||
class VolumeDetailsTestCase(unittest.TestCase):
|
||||
def test_inside_the_block_each_path_is_looked_up_once(self):
|
||||
p = volume_proxy({"/p/a.qcow2", "/p/b.raw"})
|
||||
with p.cached_reads():
|
||||
for _ in range(2):
|
||||
self.assertEqual(p._volume_details("/p/a.qcow2"), ("a.qcow2", 10, 4, "pool"))
|
||||
p._volume_details("/p/b.raw")
|
||||
self.assertIsNone(p._volume_details("/dev/sdx"))
|
||||
self.assertEqual(p.lookups, ["/p/a.qcow2", "/p/b.raw", "/dev/sdx"])
|
||||
|
||||
def test_outside_the_block_and_after_a_change_the_volume_is_read_again(self):
|
||||
p = volume_proxy({"/p/a.qcow2"})
|
||||
p._volume_details("/p/a.qcow2")
|
||||
with p.cached_reads():
|
||||
p._volume_details("/p/a.qcow2")
|
||||
p._defineXML("<domain/>")
|
||||
p._volume_details("/p/a.qcow2")
|
||||
p._volume_details("/p/a.qcow2")
|
||||
self.assertEqual(len(p.lookups), 4)
|
||||
|
||||
|
||||
def disk(path, cache):
|
||||
return (
|
||||
f"<disk type='file' device='disk'><driver name='qemu' type='qcow2' cache='{cache}'/>"
|
||||
f"<source file='{path}'/><target dev='vda' bus='virtio'/></disk>"
|
||||
)
|
||||
|
||||
|
||||
class DiskListsTestCase(unittest.TestCase):
|
||||
"""The page lists the running and the persistent disks; both share a
|
||||
path's volume, each keeps its own settings."""
|
||||
|
||||
def lists(self, live, config, volumes):
|
||||
p = volume_proxy(volumes)
|
||||
xml = {0: f"<domain><devices>{live}</devices></domain>", 3: f"<domain><devices>{config}</devices></domain>"}
|
||||
p.instance.XMLDesc = lambda flags: xml[flags]
|
||||
with p.cached_reads():
|
||||
return p, p.get_disk_devices(), p.get_disk_devices(config=True)
|
||||
|
||||
def test_one_path_in_both_lists_is_looked_up_once(self):
|
||||
p, live, config = self.lists(disk("/p/a.qcow2", "none"), disk("/p/a.qcow2", "writeback"), {"/p/a.qcow2"})
|
||||
self.assertEqual(p.lookups, ["/p/a.qcow2"])
|
||||
self.assertEqual([(d["image"], d["storage"], d["size"], d["cache"]) for d in live], [("a.qcow2", "pool", 10, "none")])
|
||||
self.assertEqual([d["cache"] for d in config], ["writeback"])
|
||||
self.assertIsNot(live[0], config[0])
|
||||
|
||||
def test_a_pending_disk_change_is_looked_up_on_its_own(self):
|
||||
p, live, config = self.lists(disk("/p/a.qcow2", "none"), disk("/p/b.qcow2", "none"), {"/p/a.qcow2", "/p/b.qcow2"})
|
||||
self.assertEqual(p.lookups, ["/p/a.qcow2", "/p/b.qcow2"])
|
||||
self.assertEqual((live[0]["image"], config[0]["image"]), ("a.qcow2", "b.qcow2"))
|
||||
|
||||
def test_a_disk_outside_any_pool_shows_its_path(self):
|
||||
p, live, _ = self.lists(disk("/srv/x.img", "none"), disk("/srv/x.img", "none"), set())
|
||||
self.assertEqual(p.lookups, ["/srv/x.img"])
|
||||
self.assertEqual((live[0]["image"], live[0]["storage"], live[0]["size"]), ("/srv/x.img", None, None))
|
||||
126
vrtManager/tests/test_remove_nvram.py
Normal file
126
vrtManager/tests/test_remove_nvram.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""After a migration took a VM away, its NVRAM file is removed from the
|
||||
source when it is in libvirt's per-host NVRAM directory and the destination
|
||||
does not share that directory; a VM later created on the source with the same
|
||||
name would otherwise start with the old UEFI variables."""
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import libvirt
|
||||
|
||||
from vrtManager.connection import NVRAM_DIR, wvmConnect
|
||||
|
||||
PATH = NVRAM_DIR + "/vm_VARS.fd"
|
||||
|
||||
|
||||
def error(code):
|
||||
err = libvirt.libvirtError("error %d" % code)
|
||||
err.err = (code, libvirt.VIR_FROM_STORAGE, str(err), 2, None, None, None, 0, 0)
|
||||
return err
|
||||
|
||||
|
||||
def pool(path, volumes=(), create_error=None):
|
||||
p = MagicMock()
|
||||
p.XMLDesc.return_value = f"<pool type='dir'><name>p</name><target><path>{path}</path></target></pool>"
|
||||
p.vols = {name: MagicMock() for name in volumes}
|
||||
p.created = []
|
||||
|
||||
def lookup(name):
|
||||
if name not in p.vols:
|
||||
raise error(libvirt.VIR_ERR_NO_STORAGE_VOL)
|
||||
return p.vols[name]
|
||||
|
||||
def create(xml, flags):
|
||||
if create_error is not None:
|
||||
raise create_error
|
||||
vol = MagicMock()
|
||||
p.created.append(vol)
|
||||
return vol
|
||||
|
||||
p.storageVolLookupByName.side_effect = lookup
|
||||
p.createXML.side_effect = create
|
||||
return p
|
||||
|
||||
|
||||
def connect(pools=(), transient=None):
|
||||
conn = wvmConnect.__new__(wvmConnect)
|
||||
conn.wvm = MagicMock()
|
||||
conn.wvm.listAllStoragePools.return_value = list(pools)
|
||||
conn.wvm.storagePoolCreateXML.return_value = transient
|
||||
return conn
|
||||
|
||||
|
||||
def local_destination():
|
||||
return connect([pool(NVRAM_DIR)])
|
||||
|
||||
|
||||
class RemoveNvramTestCase(TestCase):
|
||||
def test_a_file_outside_the_nvram_directory_is_kept(self):
|
||||
conn = connect()
|
||||
for path in ("/srv/shared/nvram/vm_VARS.fd", NVRAM_DIR + "/sub/vm_VARS.fd", "relative_VARS.fd"):
|
||||
with self.subTest(path=path):
|
||||
self.assertFalse(conn.remove_nvram(path, local_destination()))
|
||||
self.assertFalse(conn.wvm.method_calls)
|
||||
|
||||
def test_a_pool_on_the_directory_is_used(self):
|
||||
existing = pool(NVRAM_DIR + "/", ["vm_VARS.fd"])
|
||||
conn = connect([pool("/var/lib/libvirt/images"), existing])
|
||||
self.assertTrue(conn.remove_nvram(PATH, local_destination()))
|
||||
existing.vols["vm_VARS.fd"].delete.assert_called_once_with(0)
|
||||
self.assertFalse(conn.wvm.storagePoolCreateXML.called)
|
||||
self.assertFalse(existing.destroy.called)
|
||||
|
||||
def test_pools_without_a_target_path_are_skipped(self):
|
||||
rbd = MagicMock()
|
||||
rbd.XMLDesc.return_value = "<pool type='rbd'><name>ceph</name><source><name>rbd</name></source></pool>"
|
||||
transient = pool(NVRAM_DIR, ["vm_VARS.fd"])
|
||||
conn = connect([rbd], transient)
|
||||
self.assertTrue(conn.remove_nvram(PATH, local_destination()))
|
||||
transient.vols["vm_VARS.fd"].delete.assert_called_once_with(0)
|
||||
|
||||
def test_without_a_pool_a_transient_one_is_used_and_stopped(self):
|
||||
transient = pool(NVRAM_DIR, ["vm_VARS.fd"])
|
||||
conn = connect([pool("/var/lib/libvirt/images")], transient)
|
||||
self.assertTrue(conn.remove_nvram(PATH, local_destination()))
|
||||
self.assertIn(f"<path>{NVRAM_DIR}</path>", conn.wvm.storagePoolCreateXML.call_args[0][0])
|
||||
transient.vols["vm_VARS.fd"].delete.assert_called_once_with(0)
|
||||
transient.destroy.assert_called_once_with()
|
||||
|
||||
def test_a_directory_the_destination_shares_is_left_alone(self):
|
||||
# the destination cannot create the marker the source just created
|
||||
source = pool(NVRAM_DIR, ["vm_VARS.fd"])
|
||||
destination = connect([pool(NVRAM_DIR, create_error=error(libvirt.VIR_ERR_STORAGE_VOL_EXIST))])
|
||||
self.assertFalse(connect([source]).remove_nvram(PATH, destination))
|
||||
self.assertFalse(source.vols["vm_VARS.fd"].delete.called)
|
||||
source.created[0].delete.assert_called_once_with(0) # the marker is gone
|
||||
|
||||
def test_both_markers_are_removed(self):
|
||||
source = pool(NVRAM_DIR, ["vm_VARS.fd"])
|
||||
other = pool(NVRAM_DIR)
|
||||
self.assertTrue(connect([source]).remove_nvram(PATH, connect([other])))
|
||||
self.assertEqual(source.createXML.call_args, other.createXML.call_args) # one name
|
||||
source.created[0].delete.assert_called_once_with(0)
|
||||
other.created[0].delete.assert_called_once_with(0)
|
||||
|
||||
def test_when_the_destination_cannot_be_checked_nothing_is_deleted(self):
|
||||
source = pool(NVRAM_DIR, ["vm_VARS.fd"])
|
||||
destination = connect([], None)
|
||||
destination.wvm.storagePoolCreateXML.side_effect = error(libvirt.VIR_ERR_INTERNAL_ERROR)
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
connect([source]).remove_nvram(PATH, destination)
|
||||
self.assertFalse(source.vols["vm_VARS.fd"].delete.called)
|
||||
source.created[0].delete.assert_called_once_with(0)
|
||||
|
||||
def test_the_transient_pool_is_stopped_when_the_delete_fails(self):
|
||||
transient = pool(NVRAM_DIR, ["vm_VARS.fd"])
|
||||
transient.vols["vm_VARS.fd"].delete.side_effect = error(libvirt.VIR_ERR_INTERNAL_ERROR)
|
||||
conn = connect([], transient)
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.remove_nvram(PATH, local_destination())
|
||||
transient.destroy.assert_called_once_with()
|
||||
|
||||
def test_a_missing_file_is_nothing_to_remove(self):
|
||||
transient = pool(NVRAM_DIR)
|
||||
conn = connect([], transient)
|
||||
self.assertFalse(conn.remove_nvram(PATH, local_destination()))
|
||||
transient.destroy.assert_called_once_with()
|
||||
45
vrtManager/tests/test_resize_disk.py
Normal file
45
vrtManager/tests/test_resize_disk.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from libvirt import VIR_DOMAIN_BLOCK_RESIZE_BYTES, VIR_STORAGE_VOL_BLOCK, VIR_STORAGE_VOL_FILE
|
||||
from vrtManager.instance import wvmInstance
|
||||
|
||||
SIZE = 10 << 30
|
||||
|
||||
|
||||
def vm(active, vol_type):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.instance = MagicMock()
|
||||
inst.instance.isActive.return_value = active
|
||||
vol = MagicMock()
|
||||
vol.info.return_value = [vol_type, 0, 0]
|
||||
inst.get_volume_by_path = MagicMock(return_value=vol)
|
||||
inst.resize_disk([{"path": "/dev/vg/disk", "size_new": SIZE}])
|
||||
return inst, vol
|
||||
|
||||
|
||||
class ResizeDiskTestCase(unittest.TestCase):
|
||||
def test_running_block_volume_grows_in_the_pool_then_in_qemu(self):
|
||||
inst, vol = vm(active=True, vol_type=VIR_STORAGE_VOL_BLOCK)
|
||||
vol.resize.assert_called_once_with(SIZE)
|
||||
inst.instance.blockResize.assert_called_once_with("/dev/vg/disk", SIZE, VIR_DOMAIN_BLOCK_RESIZE_BYTES)
|
||||
|
||||
def test_running_image_file_grows_through_qemu_only(self):
|
||||
# qemu-img cannot take the image lock QEMU holds
|
||||
inst, vol = vm(active=True, vol_type=VIR_STORAGE_VOL_FILE)
|
||||
vol.resize.assert_not_called()
|
||||
inst.instance.blockResize.assert_called_once()
|
||||
|
||||
def test_stopped_vm_grows_the_volume(self):
|
||||
inst, vol = vm(active=False, vol_type=VIR_STORAGE_VOL_FILE)
|
||||
vol.resize.assert_called_once_with(SIZE)
|
||||
inst.instance.blockResize.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
198
vrtManager/tests/test_rwlock.py
Normal file
198
vrtManager/tests/test_rwlock.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import warnings
|
||||
|
||||
from vrtManager.rwlock import ReadWriteLock
|
||||
|
||||
|
||||
class TestReadWriteLock(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.lock = ReadWriteLock()
|
||||
|
||||
def test_single_thread_read_lock(self):
|
||||
"""Test basic read lock acquire and release in a single thread."""
|
||||
self.lock.acquireRead()
|
||||
self.lock.release()
|
||||
|
||||
def test_single_thread_write_lock(self):
|
||||
"""Test basic write lock acquire and release in a single thread."""
|
||||
self.lock.acquireWrite()
|
||||
self.lock.release()
|
||||
|
||||
def test_nested_read_locks(self):
|
||||
"""Test reentrant read locks by the same thread."""
|
||||
self.lock.acquireRead()
|
||||
self.lock.acquireRead()
|
||||
self.lock.release()
|
||||
self.lock.release()
|
||||
|
||||
def test_nested_write_locks(self):
|
||||
"""Test reentrant write locks by the same thread."""
|
||||
self.lock.acquireWrite()
|
||||
self.lock.acquireWrite()
|
||||
self.lock.release()
|
||||
self.lock.release()
|
||||
|
||||
def test_release_unheld_lock_raises_value_error(self):
|
||||
"""Releasing a lock when none is held should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.lock.release()
|
||||
|
||||
def test_read_to_write_upgrade(self):
|
||||
"""A thread holding a read lock can upgrade to a write lock."""
|
||||
self.lock.acquireRead()
|
||||
self.lock.acquireWrite(timeout=1.0)
|
||||
# Should now hold write lock with nested count
|
||||
self.lock.release()
|
||||
self.lock.release()
|
||||
|
||||
def test_multiple_concurrent_readers(self):
|
||||
"""Multiple threads should be able to acquire read locks simultaneously."""
|
||||
active_readers = 0
|
||||
max_concurrent_readers = 0
|
||||
state_lock = threading.Lock()
|
||||
barrier = threading.Barrier(5)
|
||||
|
||||
def reader_task():
|
||||
nonlocal active_readers, max_concurrent_readers
|
||||
barrier.wait()
|
||||
self.lock.acquireRead(timeout=2.0)
|
||||
try:
|
||||
with state_lock:
|
||||
active_readers += 1
|
||||
if active_readers > max_concurrent_readers:
|
||||
max_concurrent_readers = active_readers
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
with state_lock:
|
||||
active_readers -= 1
|
||||
self.lock.release()
|
||||
|
||||
threads = [threading.Thread(target=reader_task) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(max_concurrent_readers, 5)
|
||||
|
||||
def test_writer_excludes_readers(self):
|
||||
"""While a writer holds the lock, no reader should be able to acquire it."""
|
||||
reader_acquired = False
|
||||
reader_timed_out = False
|
||||
|
||||
self.lock.acquireWrite()
|
||||
|
||||
def reader_task():
|
||||
nonlocal reader_acquired, reader_timed_out
|
||||
try:
|
||||
self.lock.acquireRead(timeout=0.1)
|
||||
reader_acquired = True
|
||||
self.lock.release()
|
||||
except RuntimeError:
|
||||
reader_timed_out = True
|
||||
|
||||
t = threading.Thread(target=reader_task)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
self.lock.release()
|
||||
self.assertFalse(reader_acquired)
|
||||
self.assertTrue(reader_timed_out)
|
||||
|
||||
def test_readers_exclude_writer(self):
|
||||
"""While readers hold the lock, a writer should be blocked until released."""
|
||||
writer_acquired = False
|
||||
writer_timed_out = False
|
||||
|
||||
self.lock.acquireRead()
|
||||
|
||||
def writer_task():
|
||||
nonlocal writer_acquired, writer_timed_out
|
||||
try:
|
||||
self.lock.acquireWrite(timeout=0.1)
|
||||
writer_acquired = True
|
||||
self.lock.release()
|
||||
except RuntimeError:
|
||||
writer_timed_out = True
|
||||
|
||||
t = threading.Thread(target=writer_task)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
self.lock.release()
|
||||
self.assertFalse(writer_acquired)
|
||||
self.assertTrue(writer_timed_out)
|
||||
|
||||
def test_writer_blocks_subsequent_readers(self):
|
||||
"""Pending writers take priority and should block subsequent new readers."""
|
||||
order = []
|
||||
writer_ready_event = threading.Event()
|
||||
|
||||
# Step 1: Initial reader holds read lock
|
||||
self.lock.acquireRead()
|
||||
|
||||
# Step 2: Writer attempts to acquire write lock and waits
|
||||
def writer_task():
|
||||
writer_ready_event.set()
|
||||
self.lock.acquireWrite(timeout=2.0)
|
||||
try:
|
||||
order.append("writer")
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
# Step 3: New reader attempts to acquire read lock after writer is pending
|
||||
def new_reader_task():
|
||||
writer_ready_event.wait()
|
||||
# small delay to ensure writer is queued in pendingwriters
|
||||
time.sleep(0.05)
|
||||
self.lock.acquireRead(timeout=2.0)
|
||||
try:
|
||||
order.append("new_reader")
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
w_thread = threading.Thread(target=writer_task)
|
||||
r_thread = threading.Thread(target=new_reader_task)
|
||||
|
||||
w_thread.start()
|
||||
r_thread.start()
|
||||
|
||||
writer_ready_event.wait()
|
||||
time.sleep(0.1)
|
||||
|
||||
# Release initial reader: writer should acquire and complete before new reader
|
||||
self.lock.release()
|
||||
|
||||
w_thread.join()
|
||||
r_thread.join()
|
||||
|
||||
self.assertEqual(order, ["writer", "new_reader"])
|
||||
|
||||
def test_no_deprecation_warnings(self):
|
||||
"""Verify that lock operations do not emit DeprecationWarnings (e.g. currentThread, notifyAll)."""
|
||||
with warnings.catch_warnings(record=True) as caught_warnings:
|
||||
warnings.simplefilter("always", DeprecationWarning)
|
||||
lock = ReadWriteLock()
|
||||
lock.acquireRead()
|
||||
lock.release()
|
||||
lock.acquireWrite()
|
||||
lock.release()
|
||||
|
||||
# Check for currentThread or notifyAll warnings
|
||||
deprecations = [
|
||||
str(w.message)
|
||||
for w in caught_warnings
|
||||
if issubclass(w.category, DeprecationWarning)
|
||||
and ("currentThread" in str(w.message) or "notifyAll" in str(w.message))
|
||||
]
|
||||
self.assertEqual(
|
||||
deprecations,
|
||||
[],
|
||||
f"Found deprecation warnings: {deprecations}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
178
vrtManager/tests/test_usage.py
Normal file
178
vrtManager/tests/test_usage.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""VM statistics: rates are averages since the sample of the previous answer,
|
||||
without waiting, when it is from the same run of the VM on the same host and
|
||||
devices; otherwise over one second. The stats view runs in a sync worker,
|
||||
polled every 5 s by each open VM page."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from vrtManager.instance import USAGE_MAX_WINDOW, wvmInstance
|
||||
|
||||
XML = """<domain><devices>
|
||||
<disk type='file' device='disk'><source file='/var/lib/libvirt/images/a.qcow2'/><target dev='vda'/></disk>
|
||||
<disk type='volume' device='disk'><source pool='default' volume='b.qcow2'/><target dev='vdb'/></disk>
|
||||
<disk type='file' device='cdrom'><target dev='sda'/></disk>
|
||||
<interface type='hostdev'><mac address='52:54:00:00:00:01'/></interface>
|
||||
<interface type='network'><mac address='52:54:00:00:00:02'/><target dev='vnet0'/></interface>
|
||||
</devices></domain>"""
|
||||
|
||||
|
||||
class FakeDomain:
|
||||
"""Counters grow by a fixed amount per second of the clock."""
|
||||
|
||||
def __init__(self, state, clock):
|
||||
self.state = state
|
||||
self.clock = clock
|
||||
self.domain_id = 7
|
||||
self.unsupported = False
|
||||
|
||||
def now(self):
|
||||
return self.clock[0]
|
||||
|
||||
def ID(self):
|
||||
return self.domain_id
|
||||
|
||||
def info(self):
|
||||
return [self.state, 0, 0, 2, int(self.now() * 500_000_000)] # 0.5 s of CPU time per second
|
||||
|
||||
def blockStats(self, dev):
|
||||
n = {"vda": 1, "vdb": 2}[dev] # disks are read by target name
|
||||
written = -1 if self.unsupported and dev == "vdb" else int(self.now() * n * 3000)
|
||||
return (0, int(self.now() * n * 1000), 0, written, 0)
|
||||
|
||||
def interfaceStats(self, dev):
|
||||
assert dev == "vnet0"
|
||||
return (int(self.now() * 100), 0, 0, 0, int(self.now() * 50), 0, 0, 0)
|
||||
|
||||
|
||||
def instance(state, clock):
|
||||
proxy = wvmInstance.__new__(wvmInstance) # no connection: only these calls
|
||||
proxy._read_cache = None
|
||||
proxy.instance = FakeDomain(state, clock)
|
||||
proxy._XMLDesc = lambda flags: XML
|
||||
proxy.host_info = ["x86_64", 0, 4] # 4 host CPUs
|
||||
proxy.get_status = lambda: state
|
||||
return proxy
|
||||
|
||||
|
||||
class UsageTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.clock = [100.0]
|
||||
patcher = patch("vrtManager.instance.time")
|
||||
self.addCleanup(patcher.stop)
|
||||
self.time = patcher.start()
|
||||
self.time.monotonic.side_effect = lambda: self.clock[0]
|
||||
self.time.sleep.side_effect = self.advance
|
||||
|
||||
def advance(self, seconds):
|
||||
self.clock[0] += seconds
|
||||
|
||||
def usage(self, proxy, previous=None):
|
||||
result = proxy.usage(previous)
|
||||
self.time.sleep.reset_mock()
|
||||
return result
|
||||
|
||||
def test_without_a_sample_over_one_second(self):
|
||||
proxy = instance(1, self.clock)
|
||||
result = proxy.usage()
|
||||
self.time.sleep.assert_called_once_with(1)
|
||||
self.assertEqual((result["cpu"], result["window"]), (12.5, 1.0)) # 0.5 s of 4 host CPUs
|
||||
# a pool volume is measured like a file
|
||||
self.assertEqual(
|
||||
result["disks"], [{"dev": "vda", "rd": 1000, "wr": 3000}, {"dev": "vdb", "rd": 2000, "wr": 6000}]
|
||||
)
|
||||
# bits; the NIC without a target is not measured and keeps its place
|
||||
self.assertEqual(result["nics"], [{"dev": 0, "rx": None, "tx": None}, {"dev": 1, "rx": 800, "tx": 400}])
|
||||
self.assertEqual(result["sample"]["counters"][3], 101.0)
|
||||
|
||||
def test_since_the_previous_sample_without_waiting(self):
|
||||
proxy = instance(1, self.clock)
|
||||
sample = self.usage(proxy)["sample"]
|
||||
self.advance(9.5)
|
||||
result = proxy.usage(sample)
|
||||
self.time.sleep.assert_not_called()
|
||||
self.assertEqual((result["cpu"], result["window"]), (12.5, 9.5))
|
||||
self.assertEqual(result["disks"][1], {"dev": "vdb", "rd": 2000, "wr": 6000}) # per second
|
||||
self.assertEqual(result["nics"][1], {"dev": 1, "rx": 800, "tx": 400})
|
||||
self.assertEqual(result["sample"]["counters"][3], 110.5)
|
||||
|
||||
def test_a_sample_of_another_run_host_or_devices_is_not_used(self):
|
||||
def restarted(proxy):
|
||||
proxy.instance.domain_id = 8
|
||||
|
||||
def other_host_cpus(proxy):
|
||||
proxy.host_info = ["x86_64", 0, 8]
|
||||
|
||||
def nic_renamed(proxy):
|
||||
proxy._XMLDesc = lambda flags: XML.replace("vnet0", "vnet1")
|
||||
|
||||
for change in (restarted, other_host_cpus, nic_renamed):
|
||||
with self.subTest(change.__name__):
|
||||
proxy = instance(1, self.clock)
|
||||
sample = self.usage(proxy)["sample"]
|
||||
change(proxy)
|
||||
self.advance(5)
|
||||
proxy.instance.interfaceStats = lambda dev: (0,) * 8
|
||||
self.assertEqual(proxy.usage(sample)["window"], 1.0)
|
||||
self.time.sleep.assert_called_once_with(1)
|
||||
self.time.sleep.reset_mock()
|
||||
|
||||
def test_a_sample_too_old_from_the_future_or_counting_back_is_not_used(self):
|
||||
for name, seconds in (("too old", USAGE_MAX_WINDOW + 1), ("future", -5), ("same time", 0)):
|
||||
with self.subTest(name):
|
||||
proxy = instance(1, self.clock)
|
||||
sample = self.usage(proxy)["sample"]
|
||||
self.advance(seconds)
|
||||
self.assertEqual(proxy.usage(sample)["window"], 1.0)
|
||||
self.time.sleep.assert_called_once_with(1)
|
||||
self.time.sleep.reset_mock()
|
||||
proxy = instance(1, self.clock)
|
||||
sample = self.usage(proxy)["sample"]
|
||||
sample["counters"][0] += 10**12 # CPU time went back: a restart with the same domain ID
|
||||
self.advance(5)
|
||||
self.assertEqual(proxy.usage(sample)["cpu"], 12.5)
|
||||
self.time.sleep.assert_called_once_with(1)
|
||||
|
||||
def test_a_counter_the_host_does_not_report_has_no_value(self):
|
||||
proxy = instance(1, self.clock)
|
||||
proxy.instance.unsupported = True
|
||||
disks = proxy.usage()["disks"]
|
||||
self.assertEqual(disks[1], {"dev": "vdb", "rd": 2000, "wr": None})
|
||||
|
||||
def test_a_vm_restarted_during_the_second_has_no_values(self):
|
||||
proxy = instance(1, self.clock)
|
||||
domain = proxy.instance
|
||||
real_sleep = self.time.sleep.side_effect
|
||||
|
||||
def restart(seconds):
|
||||
real_sleep(seconds)
|
||||
domain.info = lambda: [1, 0, 0, 2, 0] # CPU time starts over
|
||||
|
||||
self.time.sleep.side_effect = restart
|
||||
result = proxy.usage()
|
||||
self.assertEqual((result["cpu"], result["window"]), (None, None))
|
||||
self.assertEqual(result["disks"][0], {"dev": "vda", "rd": None, "wr": None})
|
||||
self.assertIn("sample", result)
|
||||
|
||||
def test_a_paused_or_shut_off_vm_is_not_sampled(self):
|
||||
for state in (3, 5):
|
||||
with self.subTest(state=state):
|
||||
result = instance(state, self.clock).usage()
|
||||
self.time.sleep.assert_not_called()
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"cpu": 0,
|
||||
"disks": [{"dev": "vda", "rd": 0, "wr": 0}, {"dev": "vdb", "rd": 0, "wr": 0}],
|
||||
"nics": [{"dev": 0, "rx": 0, "tx": 0}, {"dev": 1, "rx": 0, "tx": 0}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
157
vrtManager/tests/test_util.py
Normal file
157
vrtManager/tests/test_util.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from vrtManager.util import (
|
||||
compareMAC,
|
||||
get_xml_path,
|
||||
is_kvm_available,
|
||||
pretty_bytes,
|
||||
pretty_mem,
|
||||
randomMAC,
|
||||
randomPasswd,
|
||||
randomUUID,
|
||||
validate_macaddr,
|
||||
validate_uuid,
|
||||
vol_dev_type,
|
||||
vol_driver_type,
|
||||
xml_escape,
|
||||
)
|
||||
|
||||
|
||||
class TestVrtManagerUtil(unittest.TestCase):
|
||||
def test_random_uuid_format(self):
|
||||
uuid_str = randomUUID()
|
||||
# UUIDv4 format check
|
||||
pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
self.assertRegex(uuid_str, pattern)
|
||||
# Ensure successive UUIDs are distinct
|
||||
self.assertNotEqual(uuid_str, randomUUID())
|
||||
|
||||
def test_validate_uuid(self):
|
||||
valid_dashed = "c9a646d3-9c61-4cd8-8024-f6b72a6642d9"
|
||||
self.assertEqual(validate_uuid(valid_dashed), valid_dashed)
|
||||
|
||||
valid_no_dash = "c9a646d39c614cd88024f6b72a6642d9"
|
||||
self.assertEqual(validate_uuid(valid_no_dash), valid_dashed)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_uuid("invalid-uuid-too-short")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_uuid(12345)
|
||||
|
||||
def test_random_mac(self):
|
||||
mac = randomMAC()
|
||||
self.assertTrue(mac.startswith("52:54:10:"))
|
||||
parts = mac.split(":")
|
||||
self.assertEqual(len(parts), 6)
|
||||
for part in parts:
|
||||
self.assertEqual(len(part), 2)
|
||||
int(part, 16) # valid hex
|
||||
|
||||
def test_compare_mac(self):
|
||||
mac1 = "52:54:10:00:00:01"
|
||||
mac2 = "52:54:10:00:00:02"
|
||||
self.assertEqual(compareMAC(mac1, mac1), 0)
|
||||
self.assertEqual(compareMAC(mac1, mac2), -1)
|
||||
self.assertEqual(compareMAC(mac2, mac1), 1)
|
||||
# Different lengths
|
||||
self.assertEqual(compareMAC("52:54", "52:54:10"), -1)
|
||||
self.assertEqual(compareMAC("52:54:10", "52:54"), 1)
|
||||
|
||||
def test_validate_macaddr(self):
|
||||
self.assertIsNone(validate_macaddr(None))
|
||||
self.assertIsNone(validate_macaddr("52:54:10:aa:bb:cc"))
|
||||
self.assertIsNone(validate_macaddr("0:1:2:3:4:5"))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_macaddr("invalid_mac")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
validate_macaddr(12345)
|
||||
|
||||
def test_random_passwd(self):
|
||||
pwd = randomPasswd(16)
|
||||
self.assertEqual(len(pwd), 16)
|
||||
# Custom alphabet
|
||||
numeric_pwd = randomPasswd(10, alphabet="0123456789")
|
||||
self.assertEqual(len(numeric_pwd), 10)
|
||||
self.assertTrue(numeric_pwd.isdigit())
|
||||
|
||||
def test_xml_escape(self):
|
||||
self.assertIsNone(xml_escape(None))
|
||||
escaped = xml_escape('<foo name="bar" test=\'baz\' &/>')
|
||||
self.assertEqual(escaped, "<foo name="bar" test='baz' &/>")
|
||||
|
||||
def test_get_xml_path(self):
|
||||
xml = "<domain type='kvm'><name>testvm</name><memory unit='KiB'>1048576</memory></domain>"
|
||||
self.assertEqual(get_xml_path(xml, "/domain/name"), "testvm")
|
||||
self.assertEqual(get_xml_path(xml, "/domain/memory"), "1048576")
|
||||
self.assertEqual(get_xml_path(xml, "/domain/@type"), "kvm")
|
||||
|
||||
# Function callback mode
|
||||
def extract_name(doc):
|
||||
return doc.xpath("/domain/name")[0].text
|
||||
|
||||
self.assertEqual(get_xml_path(xml, func=extract_name), "testvm")
|
||||
|
||||
# Missing both path and func
|
||||
with self.assertRaises(ValueError):
|
||||
get_xml_path(xml)
|
||||
|
||||
def test_is_kvm_available(self):
|
||||
kvm_xml = "<capabilities><guest><arch name='x86_64'><domain type='kvm'/></arch></guest></capabilities>"
|
||||
qemu_xml = "<capabilities><guest><arch name='x86_64'><domain type='qemu'/></arch></guest></capabilities>"
|
||||
self.assertTrue(is_kvm_available(kvm_xml))
|
||||
self.assertFalse(is_kvm_available(qemu_xml))
|
||||
|
||||
def test_pretty_mem(self):
|
||||
# Value in KB
|
||||
self.assertEqual(pretty_mem(1024), " 1 MB")
|
||||
self.assertEqual(pretty_mem(1024 * 1024), "1024 MB")
|
||||
self.assertEqual(pretty_mem(16 * 1024 * 1024), "16.00 GB")
|
||||
|
||||
def test_pretty_bytes(self):
|
||||
# Value in Bytes
|
||||
self.assertEqual(pretty_bytes(10 * 1024 * 1024), "10.00 MB")
|
||||
self.assertEqual(pretty_bytes(2 * 1024 * 1024 * 1024), "2.00 GB")
|
||||
|
||||
def test_vol_dev_type(self):
|
||||
self.assertEqual(vol_dev_type("ide"), "hd")
|
||||
self.assertEqual(vol_dev_type("fdc"), "fd")
|
||||
self.assertEqual(vol_dev_type("virtio"), "vd")
|
||||
self.assertEqual(vol_dev_type("usb"), "sd")
|
||||
self.assertEqual(vol_dev_type("scsi"), "sd")
|
||||
self.assertEqual(vol_dev_type("sata"), "sd")
|
||||
self.assertIsNone(vol_dev_type("unknown_bus"))
|
||||
|
||||
def test_vol_driver_type(self):
|
||||
for vol_format in ("qcow2", "qcow", "raw"):
|
||||
self.assertEqual(vol_driver_type(vol_format), vol_format)
|
||||
for vol_format in (None, "", "none", "unknown", "iso"):
|
||||
self.assertEqual(vol_driver_type(vol_format), "raw")
|
||||
self.assertEqual(vol_driver_type("linux-lvm", "disk"), "raw") # a partition
|
||||
|
||||
|
||||
|
||||
class OperationErrorTestCase(unittest.TestCase):
|
||||
def test_keeps_its_own_message_after_a_libvirt_failure(self):
|
||||
import libvirt
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
conn = libvirt.open("test:///default")
|
||||
self.addCleanup(conn.close)
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
conn.lookupByName("no-such-domain") # leaves a thread-local last error
|
||||
self.assertNotEqual(str(libvirt.libvirtError("own message")), "own message")
|
||||
error = OperationError("own message")
|
||||
self.assertEqual(str(error), "own message")
|
||||
self.assertIsInstance(error, libvirt.libvirtError)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
445
vrtManager/tests/test_xml_safety.py
Normal file
445
vrtManager/tests/test_xml_safety.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from vrtManager import util
|
||||
from vrtManager.instance import wvmInstance
|
||||
from vrtManager.network import wvmNetworks, wvmNetwork
|
||||
|
||||
|
||||
class TestXmlSafety(unittest.TestCase):
|
||||
def test_create_network_escaping(self):
|
||||
wn = wvmNetworks.__new__(wvmNetworks)
|
||||
wn.wvm = MagicMock()
|
||||
mock_net = MagicMock()
|
||||
wn.get_network = MagicMock(return_value=mock_net)
|
||||
|
||||
malicious_name = "net<script>alert(1)</script>&name"
|
||||
malicious_bridge = "br0' onfocus='alert(2)"
|
||||
|
||||
wn.create_network(
|
||||
name=malicious_name,
|
||||
forward="bridge",
|
||||
ipv4=False,
|
||||
gateway=None,
|
||||
mask=None,
|
||||
dhcp4=None,
|
||||
ipv6=False,
|
||||
gateway6=None,
|
||||
prefix6=None,
|
||||
dhcp6=None,
|
||||
bridge=malicious_bridge,
|
||||
openvswitch=False,
|
||||
)
|
||||
|
||||
wn.wvm.networkDefineXML.assert_called_once()
|
||||
defined_xml = wn.wvm.networkDefineXML.call_args[0][0]
|
||||
self.assertNotIn("<script>", defined_xml)
|
||||
self.assertIn("<script>alert(1)</script>&name", defined_xml)
|
||||
self.assertIn("name='br0' onfocus='alert(2)'", defined_xml)
|
||||
|
||||
def test_network_set_qos_validation(self):
|
||||
wn = wvmNetwork.__new__(wvmNetwork)
|
||||
wn._XMLDesc = MagicMock(return_value="<network><name>testnet</name></network>")
|
||||
wn.wvm = MagicMock()
|
||||
|
||||
# Invalid direction
|
||||
with self.assertRaises(ValueError):
|
||||
wn.set_qos("lateral", 100, 200, 300)
|
||||
|
||||
# Invalid numeric strings
|
||||
with self.assertRaises(ValueError):
|
||||
wn.set_qos("inbound", "not_a_number", 200, 300)
|
||||
|
||||
def test_snapshot_escaping_and_recovery_on_failure(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.get_status = MagicMock(return_value=5) # shutoff
|
||||
inst.change_snapshot_xml = MagicMock()
|
||||
inst.recover_snapshot_xml = MagicMock()
|
||||
inst._XMLDesc = MagicMock(return_value="<domain></domain>")
|
||||
inst._snapshotCreateXML = MagicMock(side_effect=RuntimeError("libvirt exploded"))
|
||||
|
||||
bad_name = "snap<inject>""
|
||||
bad_desc = "desc<tag>&"
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
inst.create_snapshot(bad_name, bad_desc)
|
||||
|
||||
inst.change_snapshot_xml.assert_not_called()
|
||||
inst.recover_snapshot_xml.assert_not_called()
|
||||
|
||||
def test_snapshot_xml_payload_is_escaped(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.get_status = MagicMock(return_value=5) # shutoff
|
||||
inst.change_snapshot_xml = MagicMock()
|
||||
inst.recover_snapshot_xml = MagicMock()
|
||||
inst._XMLDesc = MagicMock(return_value="<domain></domain>")
|
||||
inst._snapshotCreateXML = MagicMock(return_value=None)
|
||||
|
||||
bad_name = "snap<inject>"es"
|
||||
bad_desc = "desc<tag>&and"
|
||||
|
||||
inst.create_snapshot(bad_name, bad_desc)
|
||||
|
||||
inst._snapshotCreateXML.assert_called_once()
|
||||
created_xml = inst._snapshotCreateXML.call_args[0][0]
|
||||
self.assertNotIn("<inject>", created_xml)
|
||||
self.assertNotIn("<tag>", created_xml)
|
||||
self.assertIn("<inject>&quotes", created_xml)
|
||||
self.assertIn("<tag>&and", created_xml)
|
||||
|
||||
def test_external_snapshot_recovery(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.get_status = MagicMock(return_value=1)
|
||||
inst.change_snapshot_xml = MagicMock()
|
||||
inst.recover_snapshot_xml = MagicMock()
|
||||
inst._XMLDesc = MagicMock(return_value="<domain></domain>")
|
||||
inst._snapshotCreateXML = MagicMock(return_value=None)
|
||||
inst.refresh_instance_pools = MagicMock()
|
||||
|
||||
inst.create_external_snapshot("ext_snap_1", desc="normal snapshot")
|
||||
|
||||
inst.change_snapshot_xml.assert_called_once()
|
||||
inst.recover_snapshot_xml.assert_called_once()
|
||||
inst.refresh_instance_pools.assert_called_once()
|
||||
|
||||
def test_secure_boot_loader_change_and_recovery(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.wvm = MagicMock()
|
||||
uefi_xml = "<domain><os><loader readonly='yes' secure='yes' type='pflash'>/usr/share/OVMF/OVMF.fd</loader></os></domain>"
|
||||
inst._XMLDesc = MagicMock(return_value=uefi_xml)
|
||||
|
||||
inst.change_snapshot_xml()
|
||||
inst.wvm.defineXML.assert_called_once()
|
||||
changed_xml = inst.wvm.defineXML.call_args[0][0]
|
||||
self.assertIn("type=\"rom\"", changed_xml)
|
||||
self.assertIn("secure=\"yes\"", changed_xml)
|
||||
|
||||
# Test recovery from rom back to pflash
|
||||
inst._XMLDesc = MagicMock(return_value=changed_xml)
|
||||
inst.wvm.defineXML.reset_mock()
|
||||
inst.recover_snapshot_xml()
|
||||
inst.wvm.defineXML.assert_called_once()
|
||||
recovered_xml = inst.wvm.defineXML.call_args[0][0]
|
||||
self.assertIn("type=\"pflash\"", recovered_xml)
|
||||
self.assertIn("secure=\"yes\"", recovered_xml)
|
||||
|
||||
def test_original_rom_loader_preserved(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst._XMLDesc = MagicMock(return_value="<domain><os><loader type='rom'>/usr/share/rom.bin</loader></os></domain>")
|
||||
inst._defineXML = MagicMock()
|
||||
inst._snapshotCreateXML = MagicMock(return_value=None)
|
||||
inst.get_status = MagicMock(return_value=5)
|
||||
|
||||
# change_snapshot_xml should return False and not define XML
|
||||
self.assertFalse(inst.change_snapshot_xml())
|
||||
inst._defineXML.assert_not_called()
|
||||
|
||||
inst.create_snapshot("snap_rom", "testing rom preservation")
|
||||
inst._defineXML.assert_not_called()
|
||||
|
||||
def test_malformed_xml_raises_in_change(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst._XMLDesc = MagicMock(return_value="<malformed << xml")
|
||||
with self.assertRaises(Exception):
|
||||
inst.change_snapshot_xml()
|
||||
|
||||
def test_recovery_failure_raises(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst._XMLDesc = MagicMock(return_value="<domain><os><loader type='rom'>/path</loader></os></domain>")
|
||||
inst._defineXML = MagicMock(side_effect=RuntimeError("libvirt defineXML failed"))
|
||||
with self.assertRaises(RuntimeError):
|
||||
inst.recover_snapshot_xml()
|
||||
|
||||
|
||||
def test_snapshot_revert_rom_loader_preserved(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.instance = MagicMock()
|
||||
mock_snap = MagicMock()
|
||||
inst.instance.snapshotLookupByName = MagicMock(return_value=mock_snap)
|
||||
inst.instance.revertToSnapshot = MagicMock()
|
||||
inst._defineXML = MagicMock()
|
||||
|
||||
inst.snapshot_revert("snap_rom")
|
||||
|
||||
inst.instance.snapshotLookupByName.assert_called_once_with("snap_rom", 0)
|
||||
inst.instance.revertToSnapshot.assert_called_once_with(mock_snap, 0)
|
||||
inst._defineXML.assert_not_called()
|
||||
|
||||
def test_internal_snapshot_never_rewrites_a_pflash_loader(self):
|
||||
# libvirt decides; the definition is not switched to rom.
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.get_status = MagicMock(return_value=5)
|
||||
inst._XMLDesc = MagicMock(
|
||||
return_value="<domain><os><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF.fd</loader></os></domain>"
|
||||
)
|
||||
inst._defineXML = MagicMock()
|
||||
inst._snapshotCreateXML = MagicMock()
|
||||
|
||||
inst.create_snapshot("snap", "uefi")
|
||||
|
||||
inst._defineXML.assert_not_called()
|
||||
self.assertIn("type='pflash'", inst._snapshotCreateXML.call_args[0][0])
|
||||
|
||||
|
||||
|
||||
class TestCloneRollback(unittest.TestCase):
|
||||
"""A failed clone removes only the volumes it created."""
|
||||
|
||||
def test_copies_are_removed_when_the_definition_fails(self):
|
||||
import libvirt
|
||||
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.wvm = MagicMock()
|
||||
inst.get_status = MagicMock(return_value=5)
|
||||
inst.get_instances = MagicMock(return_value=[])
|
||||
inst.get_nvram = MagicMock(return_value=None)
|
||||
inst._XMLDesc = MagicMock(return_value=(
|
||||
"<domain><name>src</name><devices>"
|
||||
"<disk type='file' device='disk'><source file='/pool/src.qcow2'/><target dev='vda'/></disk>"
|
||||
"</devices></domain>"
|
||||
))
|
||||
source_vol = MagicMock()
|
||||
source_vol.XMLDesc.return_value = "<volume><target><format type='qcow2'/></target></volume>"
|
||||
copy = MagicMock()
|
||||
copy.path.return_value = "/pool/copy.qcow2"
|
||||
source_vol.storagePoolLookupByVolume.return_value.createXMLFrom.return_value = copy
|
||||
inst.get_volume_by_path = MagicMock(side_effect=lambda path: copy if path == "/pool/copy.qcow2" else source_vol)
|
||||
inst._defineXML = MagicMock(side_effect=libvirt.libvirtError("bad definition"))
|
||||
|
||||
with self.assertRaises(libvirt.libvirtError):
|
||||
inst.clone_instance({"name": "copy", "disk-vda": "copy.qcow2", "disk_owner_uid": 0, "disk_owner_gid": 0})
|
||||
copy.delete.assert_called_once_with(0)
|
||||
source_vol.delete.assert_not_called()
|
||||
|
||||
def test_running_vm_is_refused_before_anything_is_read(self):
|
||||
import libvirt
|
||||
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.get_status = MagicMock(return_value=1)
|
||||
inst._XMLDesc = MagicMock()
|
||||
with self.assertRaisesRegex(libvirt.libvirtError, "Shut the VM down"):
|
||||
inst.clone_instance({"name": "copy"})
|
||||
inst._XMLDesc.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestDiskXmlEscaping(unittest.TestCase):
|
||||
def _instance(self):
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.get_status = MagicMock(return_value=5) # shutoff
|
||||
inst.instance = MagicMock()
|
||||
inst._defineXML = MagicMock()
|
||||
return inst
|
||||
|
||||
def test_attach_disk_escapes_source_and_serial(self):
|
||||
inst = self._instance()
|
||||
inst.attach_disk(
|
||||
"vdb",
|
||||
"/images/a'/><disk type='file",
|
||||
target_bus="virtio",
|
||||
disk_type="file",
|
||||
serial="S1</serial><evil/>",
|
||||
)
|
||||
xml = inst.instance.attachDeviceFlags.call_args[0][0]
|
||||
self.assertIn("file='/images/a'/><disk type='file'", xml)
|
||||
self.assertIn("<serial>S1</serial><evil/></serial>", xml)
|
||||
self.assertNotIn("<evil/>", xml)
|
||||
|
||||
def test_edit_disk_escapes_source_and_serial(self):
|
||||
inst = self._instance()
|
||||
inst._XMLDesc = MagicMock(
|
||||
return_value=(
|
||||
"<domain><devices><disk type='file' device='disk'>"
|
||||
"<driver name='qemu' type='qcow2'/>"
|
||||
"<source file='/images/vm.qcow2'/>"
|
||||
"<target dev='vda' bus='virtio'/>"
|
||||
"</disk></devices></domain>"
|
||||
)
|
||||
)
|
||||
inst.edit_disk(
|
||||
"vda",
|
||||
"/images/b'/><x y='",
|
||||
False,
|
||||
False,
|
||||
"virtio",
|
||||
"S2<evil/>",
|
||||
"qcow2",
|
||||
"default",
|
||||
"default",
|
||||
"default",
|
||||
"default",
|
||||
)
|
||||
xml = inst._defineXML.call_args.args[0]
|
||||
from lxml import etree
|
||||
self.assertEqual(etree.fromstring(xml).find("devices/disk/source").get("file"), "/images/b'/><x y='")
|
||||
self.assertNotIn("<evil/>", xml)
|
||||
|
||||
def test_detach_disk_does_not_evaluate_dev_as_xpath(self):
|
||||
inst = self._instance()
|
||||
inst._XMLDesc = MagicMock(
|
||||
return_value=(
|
||||
"<domain><devices><disk type='file' device='disk'>"
|
||||
"<source file='/images/vm.qcow2'/>"
|
||||
"<target dev='vda' bus='virtio'/>"
|
||||
"</disk></devices></domain>"
|
||||
)
|
||||
)
|
||||
with self.assertRaises(util.OperationError):
|
||||
inst.detach_disk("x' or '1'='1")
|
||||
inst.instance.detachDeviceFlags.assert_not_called()
|
||||
|
||||
|
||||
class TestPersistentDiskEditing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
self.conn = libvirt.open("test:///default")
|
||||
self.addCleanup(self.conn.close)
|
||||
self.domain = self.conn.lookupByName("test")
|
||||
original = self.domain.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE | libvirt.VIR_DOMAIN_XML_SECURE)
|
||||
self.addCleanup(self.conn.defineXML, original)
|
||||
config = etree.fromstring(original)
|
||||
disk = config.find("devices/disk")
|
||||
disk.set("type", "file")
|
||||
driver = disk.find("driver")
|
||||
if driver is None:
|
||||
driver = etree.SubElement(disk, "driver")
|
||||
driver.set("name", "qemu")
|
||||
driver.set("type", "qcow2")
|
||||
driver.set("queues", "4")
|
||||
etree.SubElement(disk, "address", type="pci", slot="0x05")
|
||||
encryption = etree.SubElement(disk, "encryption", format="luks")
|
||||
etree.SubElement(encryption, "secret", type="passphrase", uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
etree.SubElement(etree.SubElement(disk, "iotune"), "total_bytes_sec").text = "1024"
|
||||
# A pending change and a VNC password outside the edited disk must survive.
|
||||
config.find("memory").text = "123456"
|
||||
etree.SubElement(config.find("devices"), "graphics", type="vnc", passwd="keep-secret")
|
||||
self.conn.defineXML(etree.tostring(config).decode())
|
||||
self.inst = wvmInstance.__new__(wvmInstance)
|
||||
self.inst.wvm = self.conn
|
||||
self.inst.instance = self.domain
|
||||
self.target = disk.find("target").get("dev")
|
||||
|
||||
def config(self):
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
return etree.fromstring(self.domain.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE | libvirt.VIR_DOMAIN_XML_SECURE))
|
||||
|
||||
def edit(self, bus="virtio", format="qcow2"):
|
||||
self.inst.edit_disk(
|
||||
self.target, "/images/a'<new>&.qcow2", False, False, bus,
|
||||
"serial<&", format, "none", "native", "unmap", "on",
|
||||
)
|
||||
|
||||
def test_running_bus_change_preserves_disk_and_pending_domain_configuration(self):
|
||||
self.assertTrue(self.domain.isActive())
|
||||
live_before = self.domain.XMLDesc(0)
|
||||
self.edit(bus="sata")
|
||||
config = self.config()
|
||||
disk = config.find("devices/disk")
|
||||
self.assertEqual(disk.find("target").get("bus"), "sata")
|
||||
self.assertTrue(disk.find("target").get("dev").startswith("sd"))
|
||||
self.assertNotEqual(disk.find("address").get("type"), "pci")
|
||||
self.assertIsNone(disk.find("driver").get("queues"))
|
||||
self.assertEqual(disk.find("driver").get("cache"), "none")
|
||||
self.assertEqual(disk.findtext("iotune/total_bytes_sec"), "1024")
|
||||
self.assertEqual(disk.find("encryption/secret").get("uuid"), "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
self.assertEqual(disk.find("source").get("file"), "/images/a'<new>&.qcow2")
|
||||
self.assertEqual(disk.findtext("serial"), "serial<&")
|
||||
self.assertEqual(config.findtext("memory"), "123456")
|
||||
self.assertEqual(config.find("devices/graphics").get("passwd"), "keep-secret")
|
||||
self.assertEqual(self.domain.XMLDesc(0), live_before)
|
||||
# The resulting disk can be edited again before the VM restarts.
|
||||
self.target = disk.find("target").get("dev")
|
||||
self.edit(bus="sata")
|
||||
self.assertEqual(self.config().find("devices/disk/target").get("dev"), self.target)
|
||||
|
||||
def test_failed_definition_leaves_the_original_configuration_intact(self):
|
||||
from libvirt import libvirtError
|
||||
from unittest.mock import patch
|
||||
before = self.domain.XMLDesc(2)
|
||||
with patch.object(self.inst, "_defineXML", side_effect=libvirtError("definition failed")):
|
||||
with self.assertRaises(libvirtError):
|
||||
self.edit(bus="sata")
|
||||
self.assertEqual(self.domain.XMLDesc(2), before)
|
||||
|
||||
def test_network_disk_keeps_auth_source_and_encryption(self):
|
||||
from lxml import etree
|
||||
config = self.config()
|
||||
disk = config.find("devices/disk")
|
||||
disk.set("type", "network")
|
||||
disk.remove(disk.find("source"))
|
||||
source = etree.SubElement(disk, "source", protocol="rbd", name="pool/vm-disk")
|
||||
etree.SubElement(source, "host", name="mon1", port="6789")
|
||||
auth = etree.SubElement(disk, "auth", username="libvirt")
|
||||
etree.SubElement(auth, "secret", type="ceph", uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
self.conn.defineXML(etree.tostring(config).decode())
|
||||
self.edit(bus="sata")
|
||||
result = self.config().find("devices/disk")
|
||||
self.assertEqual(result.find("source").get("name"), "pool/vm-disk")
|
||||
self.assertEqual(result.find("source/host").get("name"), "mon1")
|
||||
self.assertEqual(result.find("auth").get("username"), "libvirt")
|
||||
self.assertIsNotNone(result.find("encryption"))
|
||||
|
||||
def test_empty_format_omits_driver_type(self):
|
||||
self.edit(format="")
|
||||
self.assertIsNone(self.config().find("devices/disk/driver").get("type"))
|
||||
|
||||
def test_virtio_options_survive_editing_on_the_same_bus(self):
|
||||
self.edit()
|
||||
self.assertEqual(self.config().find("devices/disk/driver").get("queues"), "4")
|
||||
|
||||
|
||||
class TestEmptyDiskClone(unittest.TestCase):
|
||||
def test_clone_omits_empty_disks_before_real_libvirt_definition(self):
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
conn = libvirt.open("test:///default")
|
||||
self.addCleanup(conn.close)
|
||||
domain = conn.lookupByName("test")
|
||||
config = etree.fromstring(domain.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE))
|
||||
devices = config.find("devices")
|
||||
for disk in devices.findall("disk"):
|
||||
source = disk.find("source")
|
||||
if source is not None:
|
||||
disk.remove(source)
|
||||
for interface in devices.findall("interface"):
|
||||
devices.remove(interface)
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.wvm = conn
|
||||
inst.instance = domain
|
||||
# Exercise defensive handling of incomplete disk metadata, while the
|
||||
# clone is still defined and looked up through the real test driver.
|
||||
inst._XMLDesc = MagicMock(return_value=etree.tostring(config).decode())
|
||||
inst.get_status = MagicMock(return_value=5) # only shut-off VMs are cloned
|
||||
clone_uuid = inst.clone_instance({"name": "empty-disk-clone"})
|
||||
clone = conn.lookupByUUIDString(clone_uuid)
|
||||
self.addCleanup(clone.undefine)
|
||||
clone_config = etree.fromstring(clone.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE))
|
||||
self.assertEqual(clone_config.findtext("name"), "empty-disk-clone")
|
||||
self.assertEqual(clone_config.findall("devices/disk"), [])
|
||||
self.assertTrue(config.findall("devices/disk"))
|
||||
|
||||
def test_clone_rejects_existing_name_before_allocating_storage(self):
|
||||
import libvirt
|
||||
|
||||
conn = libvirt.open("test:///default")
|
||||
self.addCleanup(conn.close)
|
||||
inst = wvmInstance.__new__(wvmInstance)
|
||||
inst.wvm = conn
|
||||
inst.instance = conn.lookupByName("test")
|
||||
inst._XMLDesc = MagicMock()
|
||||
inst.get_status = MagicMock(return_value=5)
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
inst.clone_instance({"name": "test"})
|
||||
inst._XMLDesc.assert_not_called()
|
||||
65
vrtManager/tests/test_xpath_values.py
Normal file
65
vrtManager/tests/test_xpath_values.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Values from requests do not change the XPath queries they are used in."""
|
||||
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(MAC_OUI="52:54:10")
|
||||
|
||||
from vrtManager.connection import wvmConnect
|
||||
from vrtManager.network import wvmNetwork
|
||||
from vrtManager.nwfilters import wvmNWFilter
|
||||
|
||||
CAPS = """<capabilities><guest><arch name='x86_64'><wordsize>64</wordsize><emulator>/usr/bin/qemu-x86</emulator>
|
||||
<machine canonical='pc-q35'>q35</machine></arch>
|
||||
<arch name='aarch64'><emulator>/usr/bin/qemu-arm</emulator><machine>virt</machine></arch></guest></capabilities>"""
|
||||
|
||||
FILTER = """<filter name='f'><rule action='drop' direction='in' priority='1'><tcp/></rule>
|
||||
<rule action='drop' direction='in' priority='2'><udp/></rule></filter>"""
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.defined = None
|
||||
|
||||
def networkDefineXML(self, xml):
|
||||
self.defined = xml
|
||||
|
||||
|
||||
class XPathValueTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conn = wvmConnect.__new__(wvmConnect)
|
||||
self.conn.get_cap_xml = lambda: CAPS
|
||||
|
||||
def test_arch_lookups(self):
|
||||
self.assertEqual(self.conn.get_emulator("x86_64"), "/usr/bin/qemu-x86")
|
||||
self.assertEqual(self.conn.get_machine_types("aarch64"), ["virt"])
|
||||
injected = "nope' or '1'='1"
|
||||
self.assertIsNone(self.conn.get_emulator(injected))
|
||||
self.assertEqual(self.conn.get_machine_types(injected), [])
|
||||
self.assertEqual(self.conn.get_capabilities(injected), {})
|
||||
|
||||
def test_nwfilter_rule_is_matched_by_its_attributes(self):
|
||||
nwf = wvmNWFilter.__new__(wvmNWFilter)
|
||||
nwf._XMLDesc = lambda flags: FILTER
|
||||
# values with quotes or brackets are compared, not parsed as XPath
|
||||
self.assertEqual(nwf.delete_rule("drop'][@priority='2", "in", "1").count("<rule "), 2)
|
||||
self.assertEqual(nwf.delete_rule("it's", "in", "1").count("<rule "), 2)
|
||||
kept = nwf.delete_rule("drop", "in", "2")
|
||||
self.assertEqual(kept.count("<rule "), 1)
|
||||
self.assertIn("priority=\"1\"", kept)
|
||||
# add_rule appends to the matching rule only
|
||||
added = nwf.add_rule("<rule action='drop' direction='in' priority='2'><icmp/></rule>")
|
||||
self.assertEqual(added.count("<rule "), 2)
|
||||
self.assertIn("<icmp", added.split('priority="2"')[1])
|
||||
|
||||
def test_qos_direction_must_be_known(self):
|
||||
net = wvmNetwork.__new__(wvmNetwork)
|
||||
net.wvm = FakeConn()
|
||||
net._XMLDesc = lambda flags: "<network><bandwidth><inbound average='1'/><outbound average='2'/></bandwidth></network>"
|
||||
with self.assertRaises(ValueError):
|
||||
net.unset_qos("*")
|
||||
net.unset_qos("inbound")
|
||||
self.assertNotIn("inbound", net.wvm.defined)
|
||||
self.assertIn("outbound", net.wvm.defined)
|
||||
Loading…
Add table
Add a link
Reference in a new issue