mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
- change_network detached the NIC and attached one built from scratch, dropping its QoS, link state and other settings. It now edits the NIC of the persistent definition in place and defines once; a running VM gets the change at its next start, as the UI already says. - add_network, delete_network and set_link_state did nothing on a paused VM, and set_link_state rewrote the persistent NIC from the live XML. They now update the running definition when the VM is active and the persistent one always, each from its own XML. - Boot and console edits used xml.etree, which renamed the namespace prefixes of other tools' metadata (<app:info> became <ns0:info>); they use lxml now. - The NIC table and the clone form are filled from the persistent definition (Instance.config_networks, config_disks), so a pending NIC change is shown and can be edited again. A live test runs 18 domain edits on stopped, running and paused VMs whose definition has elements WebVirtCloud does not manage, and checks for each the effect, that nothing else in the persistent definition changed, and that persistent-only edits leave the running VM alone.
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""Live reproductions of the Wave 1a data-loss findings.
|
|
|
|
Each test asserts the safe behavior and is marked expectedFailure until
|
|
its finding is fixed; a fix turns it into an unexpected success, which
|
|
fails the run until the marker is removed. Runs only with
|
|
TEST_LIBVIRT_HOST set (see instances/livetest.py).
|
|
"""
|
|
|
|
import os
|
|
import unittest
|
|
|
|
import libvirt
|
|
from computes.models import Compute
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.messages import get_messages
|
|
from django.test import TestCase
|
|
from django.urls import reverse
|
|
from vrtManager.connection import connection_manager
|
|
|
|
from . import livetest
|
|
from .models import Instance
|
|
from .utils import refr
|
|
|
|
P = livetest.PREFIX
|
|
|
|
|
|
class LiveDataLossTestCase(TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
if not livetest.enabled():
|
|
raise unittest.SkipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
|
cls.compute_args = dict(
|
|
hostname=os.environ["TEST_LIBVIRT_HOST"],
|
|
login=os.environ.get("TEST_LIBVIRT_LOGIN", ""),
|
|
password=os.environ.get("TEST_LIBVIRT_PASSWORD", ""),
|
|
type=int(os.environ.get("TEST_LIBVIRT_TYPE", 4)),
|
|
)
|
|
cls.conn = connection_manager.get_connection(*cls.compute_args.values())
|
|
livetest.cleanup(cls.conn)
|
|
livetest.ensure_pool(cls.conn)
|
|
cls.inventory = livetest.inventory(cls.conn)
|
|
# Last: a failure above must not leave the class transaction open,
|
|
# which would lock the shared test database for later test classes.
|
|
super().setUpClass()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
try:
|
|
livetest.remove_pool(cls.conn)
|
|
finally:
|
|
super().tearDownClass()
|
|
if livetest.inventory(cls.conn) != cls.inventory:
|
|
raise AssertionError("Live tests changed objects outside the test namespace")
|
|
|
|
def setUp(self):
|
|
self.compute = Compute.objects.create(name="live-dataloss", details="test", **self.compute_args)
|
|
admin = get_user_model().objects.create_superuser("live_admin", "l@example.com", "pw")
|
|
self.client.force_login(admin)
|
|
self.client.raise_request_exception = False
|
|
|
|
def tearDown(self):
|
|
livetest.cleanup(self.conn)
|
|
|
|
# helpers
|
|
|
|
def vm(self, name, disks, uefi=False, extra_devices=""):
|
|
dom = livetest.define_vm(self.conn, P + name, disks, uefi=uefi, extra_devices=extra_devices)
|
|
refr(self.compute)
|
|
return dom, Instance.objects.get(compute=self.compute, uuid=dom.UUIDString())
|
|
|
|
def post(self, view, instance, data=None):
|
|
return self.client.post(
|
|
reverse(f"instances:{view}", args=[instance.id]),
|
|
data or {},
|
|
HTTP_REFERER=reverse("instances:instance", args=[instance.id]),
|
|
)
|
|
|
|
def disk_sources(self, dom, flags=0):
|
|
from lxml import etree
|
|
|
|
return {
|
|
d.find("target").get("dev"): d.find("source").get("file")
|
|
for d in etree.fromstring(dom.XMLDesc(flags)).findall("devices/disk")
|
|
}
|
|
|
|
def domain_exists(self, uuid):
|
|
try:
|
|
self.conn.lookupByUUIDString(uuid)
|
|
return True
|
|
except libvirt.libvirtError:
|
|
return False
|
|
|
|
# R-03: external snapshots
|
|
|
|
@unittest.expectedFailure
|
|
def test_r03_revert_external_snapshot_keeps_disk_added_later(self):
|
|
base = livetest.create_volume(self.conn, P + "r03-a")
|
|
dom, inst = self.vm("r03-a", [base])
|
|
self.post("create_external_snapshot", inst, {"name": "snap"})
|
|
|
|
later = livetest.create_volume(self.conn, P + "r03-later")
|
|
dom.attachDeviceFlags(
|
|
f"<disk type='file' device='disk'><driver name='qemu' type='qcow2'/>"
|
|
f"<source file='{later}'/><target dev='vdb' bus='virtio'/></disk>",
|
|
libvirt.VIR_DOMAIN_AFFECT_CONFIG,
|
|
)
|
|
self.post("revert_external_snapshot", inst, {"name": "s1.snap", "date": "", "desc": ""})
|
|
|
|
self.assertTrue(livetest.volume_exists(self.conn, later), "revert deleted a disk the snapshot never had")
|
|
|
|
@unittest.expectedFailure
|
|
def test_r03_delete_older_external_snapshot_keeps_newer_snapshot_files(self):
|
|
base = livetest.create_volume(self.conn, P + "r03-b")
|
|
dom, inst = self.vm("r03-b", [base])
|
|
self.post("create_external_snapshot", inst, {"name": "older"})
|
|
self.post("create_external_snapshot", inst, {"name": "newer"})
|
|
sources = set(self.disk_sources(dom).values())
|
|
|
|
self.post("delete_external_snapshot", inst, {"name": "s1.older"})
|
|
|
|
for path in sources:
|
|
self.assertTrue(livetest.volume_exists(self.conn, path), f"{path} was deleted")
|
|
self.assertIn("s1.newer", dom.snapshotListNames(0))
|
|
|
|
# R-13: internal snapshots of UEFI VMs
|
|
|
|
def _uefi_internal_snapshot(self, running):
|
|
base = livetest.create_volume(self.conn, P + "r13")
|
|
dom, inst = self.vm("r13", [base], uefi=True)
|
|
if running:
|
|
dom.create()
|
|
os_before = self.os_xml(dom)
|
|
|
|
response = self.post("snapshot", inst, {"name": "snap"})
|
|
|
|
# Taken where libvirt supports it, otherwise refused with a message;
|
|
# never a server error and never a rewritten loader or NVRAM.
|
|
self.assertEqual(response.status_code, 302)
|
|
if dom.snapshotNum(0) == 0:
|
|
self.assertTrue(list(get_messages(response.wsgi_request)))
|
|
self.assertEqual(self.os_xml(dom), os_before)
|
|
|
|
def os_xml(self, dom):
|
|
from lxml import etree
|
|
|
|
return etree.tostring(etree.fromstring(dom.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE)).find("os"))
|
|
|
|
def test_r13_uefi_internal_snapshot_of_a_stopped_vm(self):
|
|
self._uefi_internal_snapshot(running=False)
|
|
|
|
def test_r13_uefi_internal_snapshot_of_a_running_vm(self):
|
|
self._uefi_internal_snapshot(running=True)
|
|
|
|
# R-14: destroy
|
|
|
|
def test_r14_destroy_keeps_a_volume_another_vm_uses(self):
|
|
shared = livetest.create_volume(self.conn, P + "r14-shared")
|
|
_, inst = self.vm("r14-a", [shared])
|
|
self.vm("r14-b", [shared])
|
|
|
|
response = self.post("destroy", inst, {"delete_disk": "1"})
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(livetest.volume_exists(self.conn, shared), "destroy deleted another VM's disk")
|
|
|
|
def _destroy_base_of_another_vms_overlay(self, running):
|
|
base = livetest.create_volume(self.conn, P + "r14-base")
|
|
pool = self.conn.storagePoolLookupByName(livetest.POOL)
|
|
overlay = pool.createXML(
|
|
f"<volume><name>{P}r14-overlay.qcow2</name><capacity unit='MiB'>64</capacity>"
|
|
f"<target><format type='qcow2'/></target><backingStore><path>{base}</path>"
|
|
"<format type='qcow2'/></backingStore></volume>",
|
|
0,
|
|
).path()
|
|
_, inst = self.vm("r14-base", [base])
|
|
dom_b, _ = self.vm("r14-overlay", [overlay])
|
|
if running:
|
|
dom_b.create()
|
|
|
|
response = self.post("destroy", inst, {"delete_disk": "1"})
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(livetest.volume_exists(self.conn, base), "deleted the backing file of another VM's disk")
|
|
|
|
def test_r14_destroy_keeps_the_backing_file_of_a_stopped_vm(self):
|
|
self._destroy_base_of_another_vms_overlay(running=False)
|
|
|
|
def test_r14_destroy_keeps_the_backing_file_of_a_running_vm(self):
|
|
self._destroy_base_of_another_vms_overlay(running=True)
|
|
|
|
def test_r14_destroy_with_managed_save_removes_the_vm(self):
|
|
base = livetest.create_volume(self.conn, P + "r14-save")
|
|
dom, inst = self.vm("r14-save", [base])
|
|
dom.create()
|
|
dom.managedSave(0)
|
|
|
|
self.post("destroy", inst, {"delete_disk": "1"})
|
|
|
|
uuid = dom.UUIDString()
|
|
self.assertFalse(self.domain_exists(uuid), "VM is still defined")
|
|
self.assertFalse(Instance.objects.filter(uuid=uuid).exists())
|
|
|
|
def test_r14_destroy_of_a_paused_vm_removes_it(self):
|
|
base = livetest.create_volume(self.conn, P + "r14-paused")
|
|
dom, inst = self.vm("r14-paused", [base])
|
|
dom.create()
|
|
dom.suspend()
|
|
|
|
self.post("destroy", inst)
|
|
|
|
self.assertFalse(self.domain_exists(dom.UUIDString()), "paused VM is still running")
|
|
|
|
# S-02: delete a volume only after it is detached
|
|
|
|
def _delete_attached_volume(self, pause):
|
|
base = livetest.create_volume(self.conn, P + "s02")
|
|
data = livetest.create_volume(self.conn, P + "s02-data")
|
|
dom, inst = self.vm("s02", [base, data])
|
|
dom.create()
|
|
if pause:
|
|
dom.suspend()
|
|
|
|
response = self.post("delete_vol", inst, {"dev": "vdb"})
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
# The guest has no OS, so it never releases the disk: the live
|
|
# definition keeps vdb, and the volume must be kept with it.
|
|
self.assertIn("vdb", self.disk_sources(dom))
|
|
self.assertTrue(livetest.volume_exists(self.conn, data), "volume deleted while the VM still uses it")
|
|
|
|
def test_s02_delete_volume_of_a_paused_vm(self):
|
|
self._delete_attached_volume(pause=True)
|
|
|
|
def test_s02_delete_volume_of_a_running_vm_without_guest_ack(self):
|
|
self._delete_attached_volume(pause=False)
|
|
|
|
def test_s02_delete_volume_another_vm_uses_is_refused(self):
|
|
base = livetest.create_volume(self.conn, P + "s02-own")
|
|
shared = livetest.create_volume(self.conn, P + "s02-shared")
|
|
_, inst = self.vm("s02-a", [base, shared])
|
|
self.vm("s02-b", [shared])
|
|
|
|
response = self.post("delete_vol", inst, {"dev": "vdb"})
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(livetest.volume_exists(self.conn, shared), "deleted another VM's disk")
|
|
|
|
# R-06: operations follow the VM's UUID, not its name
|
|
|
|
def test_r06_mutation_follows_uuid_after_names_are_swapped(self):
|
|
dom_a, inst_a = self.vm("r06-a", [])
|
|
dom_b, _ = self.vm("r06-b", [])
|
|
dom_a.rename(P + "r06-tmp", 0)
|
|
dom_b.rename(P + "r06-a", 0)
|
|
dom_a.rename(P + "r06-b", 0)
|
|
|
|
self.post("change_options", inst_a, {"title": "for-a", "description": ""})
|
|
|
|
self.assertIn("<title>for-a</title>", dom_a.XMLDesc(0))
|
|
self.assertNotIn("<title>for-a</title>", dom_b.XMLDesc(0))
|
|
|
|
def test_r06_vm_replaced_under_the_same_name_is_not_touched(self):
|
|
dom_old, inst = self.vm("r06-c", [])
|
|
dom_old.undefine()
|
|
dom_new = livetest.define_vm(self.conn, P + "r06-c", [])
|
|
|
|
self.post("change_options", inst, {"title": "for-old", "description": ""})
|
|
|
|
self.assertNotIn("<title>for-old</title>", dom_new.XMLDesc(0))
|
|
|
|
# NIC edits of a running VM are pending until restart; the form must show them
|
|
|
|
def test_pending_nic_change_is_shown_and_can_be_edited_again(self):
|
|
nic = "<interface type='network'><mac address='52:54:00:aa:05:01'/><source network='default'/></interface>"
|
|
dom, inst = self.vm("nic-pending", [], extra_devices=nic)
|
|
dom.create()
|
|
|
|
def change(old, new):
|
|
return self.post("change_network", inst, {
|
|
"net-old-mac-0": old, "net-mac-0": new, "net-source-0": "net:default", "net-model-0": "virtio",
|
|
})
|
|
|
|
change("52:54:00:aa:05:01", "52:54:00:aa:05:02")
|
|
page = self.client.get(reverse("instances:instance", args=[inst.id])).content.decode()
|
|
self.assertIn('name="net-old-mac-0" value="52:54:00:aa:05:02"', page)
|
|
|
|
change("52:54:00:aa:05:02", "52:54:00:aa:05:03")
|
|
self.assertIn("52:54:00:aa:05:03", dom.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE))
|
|
|