"""External snapshots go through libvirt's snapshot API; WebVirtCloud only
checks preconditions and picks the flags. libvirt is mocked here; the live
cases are in instances/tests/test_live_snapshots.py."""
import unittest
from datetime import datetime
from unittest.mock import MagicMock
from django.conf import settings
if not settings.configured:
settings.configure(MAC_OUI="52:54:10")
import libvirt
from libvirt import libvirtError
from vrtManager import util
from vrtManager.instance import PERSISTENT_XML, wvmInstance
INTERNAL = libvirt.VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL
EXTERNAL = libvirt.VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL
DOMAIN = """
"""
def snapshot_xml(name, memory=False, disks=("vda",)):
devices = "".join(
f"" for d in disks
)
mem = "" if memory else ""
return (
f"{name}d & e"
f"1700000000{mem}"
f"{devices}"
)
def fake_snap(name, memory=False, disks=("vda",), current=False, children=0):
snap = MagicMock()
snap.getName.return_value = name
snap.getXMLDesc.return_value = snapshot_xml(name, memory, disks)
snap.isCurrent.return_value = current
snap.numChildren.return_value = children
return snap
def proxy(status=1, internal=(), external=(), xml=DOMAIN, jobs=None, managed_save=False):
"""A wvmInstance without a connection. internal/external are fake snapshots."""
p = wvmInstance.__new__(wvmInstance)
p._read_cache = None
p._XMLDesc = lambda flags: xml
p.get_status = lambda: status
p.get_uuid = lambda: "11111111-2222-3333-4444-555555555555"
p.get_cur_memory = lambda: 1024
p.refresh_instance_pools = MagicMock()
p._snapshotCreateXML = MagicMock()
p.wvm = MagicMock()
dom = MagicMock()
snaps = {s.getName(): s for s in (*internal, *external)}
def list_names(flag):
return [s.getName() for s in {INTERNAL: internal, EXTERNAL: external}.get(flag, (*internal, *external))]
def list_all(flag):
return list({INTERNAL: internal, EXTERNAL: external}[flag])
dom.snapshotListNames.side_effect = list_names
dom.listAllSnapshots.side_effect = list_all
dom.snapshotLookupByName.side_effect = lambda name, flags: snaps[name]
current = [s for s in snaps.values() if s.isCurrent()]
dom.hasCurrentSnapshot.return_value = bool(current)
dom.snapshotCurrent.return_value = current[0] if current else None
dom.hasManagedSaveImage.return_value = managed_save
dom.isActive.return_value = status in (1, 3)
dom.blockJobInfo.side_effect = lambda dev, flags: (jobs or {}).get(dev, {})
p.instance = dom
return p
class SnapshotListTestCase(unittest.TestCase):
def test_entries_carry_memory_current_and_description(self):
p = proxy(external=[fake_snap("one", memory=True, current=True)])
[entry] = p.get_snapshot(EXTERNAL)
self.assertEqual(entry["name"], "one")
self.assertEqual(entry["description"], "d & e")
self.assertEqual(entry["date"], datetime.fromtimestamp(1700000000))
self.assertTrue(entry["memory"])
self.assertTrue(entry["current"])
def test_detaches_lists_disks_missing_from_the_snapshot(self):
p = proxy(external=[fake_snap("one", disks=("vda",))])
[entry] = p.get_snapshot(EXTERNAL)
self.assertEqual(entry["detaches"], [{"dev": "vdb", "path": "/pool/later.qcow2"}])
def test_detaches_comes_from_the_persistent_definition(self):
# vdc was hot-plugged into the running VM only; vdb waits for the next start
live = DOMAIN.replace("/pool/later.qcow2", "/pool/live-only.qcow2").replace("dev='vdb'", "dev='vdc'")
p = proxy(external=[fake_snap("one")])
p._XMLDesc = lambda flags: DOMAIN if flags == PERSISTENT_XML else live
[entry] = p.get_snapshot(EXTERNAL)
self.assertEqual(entry["detaches"], [{"dev": "vdb", "path": "/pool/later.qcow2"}])
def test_lists_are_by_kind(self):
p = proxy(internal=[fake_snap("i")], external=[fake_snap("e")])
self.assertEqual([s["name"] for s in p.get_snapshot(INTERNAL)], ["i"])
self.assertEqual([s["name"] for s in p.get_snapshot(EXTERNAL)], ["e"])
class SnapshotJobsTestCase(unittest.TestCase):
def test_running_jobs_are_reported_per_disk(self):
p = proxy(jobs={"vda": {"cur": 5, "end": 10, "type": 3, "bandwidth": 0}})
self.assertEqual(p.get_snapshot_jobs(), [{"dev": "vda", "cur": 5, "end": 10}])
def test_a_stopped_vm_has_no_jobs(self):
p = proxy(status=5)
self.assertEqual(p.get_snapshot_jobs(), [])
p.instance.blockJobInfo.assert_not_called()
class SnapshotLookupTestCase(unittest.TestCase):
def test_a_name_of_the_other_kind_is_refused(self):
p = proxy(internal=[fake_snap("i")], external=[fake_snap("e")])
with self.assertRaises(util.OperationError):
p._snapshot("i", external=True)
with self.assertRaises(util.OperationError):
p._snapshot("e", external=False)
self.assertEqual(p._snapshot("e", external=True).getName(), "e")
class SnapshotStateTestCase(unittest.TestCase):
def test_managed_save_is_refused(self):
with self.assertRaisesRegex(util.OperationError, "saved state"):
proxy(managed_save=True)._check_snapshot_state()
def test_running_job_is_refused(self):
with self.assertRaisesRegex(util.OperationError, "still running"):
proxy(jobs={"vda": {"cur": 1, "end": 2}})._check_snapshot_state()
def test_a_mixed_vm_passes_so_it_can_be_cleaned_up(self):
proxy(internal=[fake_snap("i")], external=[fake_snap("e")])._check_snapshot_state()
def test_a_clean_vm_passes(self):
proxy()._check_snapshot_state()
DISK_ONLY_ATOMIC = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY | libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
def created(p):
xml, flags = p._snapshotCreateXML.call_args[0]
return xml, flags
class CreateTestCase(unittest.TestCase):
def test_disk_only_snapshot(self):
p = proxy()
p.create_external_snapshot("snap-1", "desc")
xml, flags = created(p)
self.assertEqual(flags, DISK_ONLY_ATOMIC)
self.assertIn("snap-1", xml)
self.assertNotIn(" & 'y'")
xml, _ = created(p)
self.assertIn("<x> &", xml)
self.assertNotIn("", xml)
def test_memory_snapshot_goes_into_the_first_disks_pool(self):
p = proxy()
p.get_volume_by_path = MagicMock()
pool = p.get_volume_by_path.return_value.storagePoolLookupByVolume.return_value
pool.info.return_value = [2, 0, 0, 2 * 1024**3]
p.create_external_snapshot("m", memory=True)
xml, flags = created(p)
p.get_volume_by_path.assert_called_once_with("/pool/a.qcow2")
self.assertEqual(flags, libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC)
self.assertIn(
"", xml
)
def refused(self, p, pattern, **kwargs):
with self.assertRaisesRegex(util.OperationError, pattern):
p.create_external_snapshot(kwargs.pop("name", "s"), **kwargs)
p._snapshotCreateXML.assert_not_called()
def test_invalid_names_are_refused(self):
for name in ("", ".hidden", "a/b", "a b", "x" * 65, ""):
with self.subTest(name=name):
self.refused(proxy(), "Snapshot names", name=name)
def test_names_are_checked_only_on_create(self):
# snapshots made by the old code are named s1. and stay usable
p = proxy(external=[fake_snap("s1.old", current=True)])
p.instance.revertToSnapshot = MagicMock()
p.revert_external_snapshot("s1.old")
p.instance.revertToSnapshot.assert_called_once()
def test_a_taken_name_is_refused(self):
self.refused(proxy(external=[fake_snap("s", current=True)]), "already exists")
def test_shared_state_checks_apply(self):
self.refused(proxy(managed_save=True), "saved state")
def test_internal_snapshots_are_refused(self):
self.refused(proxy(internal=[fake_snap("i")]), "internal snapshots")
def test_newer_snapshots_after_a_revert_are_refused(self):
self.refused(proxy(external=[fake_snap("old", current=True, children=1)]), "Newer snapshots")
def test_snapshots_without_a_current_one_are_refused(self):
# a create would start a second root
self.refused(proxy(external=[fake_snap("old")]), "revert to a snapshot first")
def test_a_disk_that_is_not_a_file_is_refused(self):
xml = ""
self.refused(proxy(xml=xml), "vda is not a file")
def test_cdrom_with_media_is_refused(self):
xml = DOMAIN.replace("", "/ovmf/CODE.fd")
snap = fake_snap("s1.old", current=True)
snap.getXMLDesc.return_value = snapshot_xml("s1.old").replace(
"", "/ovmf/CODE.fd"
)
p = proxy(external=[snap], xml=uefi)
with self.assertRaisesRegex(util.OperationError, "older version"):
p.revert_external_snapshot("s1.old")
p.instance.revertToSnapshot.assert_not_called()
def test_an_old_running_uefi_snapshot_is_judged_by_its_inactive_definition(self):
# a running VM's snapshot keeps the live definition (pflash) and the
# persistent one (rom); the revert restores the persistent one
uefi = DOMAIN.replace("", "/ovmf/CODE.fd")
snap = fake_snap("s1.old", current=True)
snap.getXMLDesc.return_value = snapshot_xml("s1.old").replace(
"", "/ovmf/CODE.fd"
).replace(
"",
"/ovmf/CODE.fd"
"",
)
p = proxy(external=[snap], xml=uefi)
with self.assertRaisesRegex(util.OperationError, "older version"):
p.revert_external_snapshot("s1.old")
p.instance.revertToSnapshot.assert_not_called()
def test_a_failed_retry_still_reports_the_vm_state(self):
# the collision already stopped the VM; another error on the retry
# must not hide that
p = proxy(external=[fake_snap("s")])
p.instance.revertToSnapshot.side_effect = [self.collision(), libvirtError("disk busy")]
p.instance.isActive.return_value = False
with unittest.mock.patch("vrtManager.instance.time.sleep"):
with self.assertRaisesRegex(util.OperationError, "disk busy.*The VM is shut off"):
p.revert_external_snapshot("s")
class DeleteTestCase(unittest.TestCase):
def test_delete_is_one_libvirt_call(self):
snap = fake_snap("s", current=True)
p = proxy(external=[snap])
p.delete_external_snapshot("s")
snap.delete.assert_called_once_with(0)
p.refresh_instance_pools.assert_called_once()
def test_running_job_is_refused(self):
snap = fake_snap("s")
p = proxy(external=[snap], jobs={"vda": {"cur": 1, "end": 2}})
with self.assertRaisesRegex(util.OperationError, "still running"):
p.delete_external_snapshot("s")
snap.delete.assert_not_called()
class KindTestCase(unittest.TestCase):
def test_internal_endpoints_refuse_external_names(self):
snap = fake_snap("e")
p = proxy(external=[snap])
for call in (p.snapshot_revert, p.snapshot_delete):
with self.subTest(call.__name__), self.assertRaises(util.OperationError):
call("e")
snap.delete.assert_not_called()
p.instance.revertToSnapshot.assert_not_called()
def test_external_endpoints_refuse_internal_names(self):
snap = fake_snap("i")
p = proxy(internal=[snap])
for call in (p.revert_external_snapshot, p.delete_external_snapshot):
with self.subTest(call.__name__), self.assertRaises(util.OperationError):
call("i")
snap.delete.assert_not_called()
def test_internal_create_refuses_a_vm_with_external_snapshots(self):
p = proxy(external=[fake_snap("e")])
with self.assertRaisesRegex(util.OperationError, "external snapshots"):
p.create_snapshot("i")
p._snapshotCreateXML.assert_not_called()
if __name__ == "__main__":
unittest.main()