1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/vrtManager/tests/test_external_snapshots.py

792 lines
37 KiB
Python

"""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 json
import unittest
from datetime import datetime
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 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 = """<domain>
<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/later.qcow2'/><target dev='vdb' bus='virtio'/></disk>
<disk type='file' device='cdrom'><target dev='sda' bus='sata'/></disk>
</devices>
</domain>"""
def snapshot_xml(name, memory=False, disks=("vda",)):
devices = "".join(
f"<disk type='file' device='disk'><source file='/pool/{d}.qcow2'/><target dev='{d}'/></disk>" for d in disks
)
mem = "<memory snapshot='external' file='/pool/x.mem'/>" if memory else "<memory snapshot='no'/>"
return (
f"<domainsnapshot><name>{name}</name><description>d &amp; e</description>"
f"<creationTime>1700000000</creationTime>{mem}"
f"<domain><devices>{devices}</devices></domain></domainsnapshot>"
)
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
GiB = 1024**3
def pools(p, disks, free):
"""disks: path -> (pool name, virtual size); free: pool name -> free
bytes. A path not in disks is in no pool."""
by_name = {}
for name, available in free.items():
by_name[name] = MagicMock(**{"name.return_value": name, "info.return_value": [2, 0, 0, available]})
def lookup(path):
if path not in disks:
error = libvirtError("no volume")
error.err = (libvirt.VIR_ERR_NO_STORAGE_VOL,)
raise error
name, size = disks[path]
return MagicMock(**{"info.return_value": [0, size, 0],
"storagePoolLookupByVolume.return_value": by_name[name]})
p.get_volume_by_path = MagicMock(side_effect=lookup)
return by_name
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, {})
dom.XMLDesc.side_effect = lambda flags: xml
p.instance = dom
pools(p, {"/pool/a.qcow2": ("default", 20 * GiB), "/pool/later.qcow2": ("default", 20 * GiB)},
{"default": 100 * GiB})
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_covers_the_persistent_and_the_live_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"}, {"dev": "vdc", "path": "/pool/live-only.qcow2"}],
)
def test_the_live_definition_is_compared_with_the_snapshots_live_one(self):
# taken while vdc was hot-plugged: <domain> is what ran, <inactiveDomain> what was defined
snap = fake_snap("one")
snap.getXMLDesc.return_value = snapshot_xml("one", disks=("vda", "vdc")).replace(
"</domainsnapshot>",
"<inactiveDomain><devices><disk type='file' device='disk'><source file='/pool/vda.qcow2'/>"
"<target dev='vda'/></disk></devices></inactiveDomain></domainsnapshot>",
)
persistent = DOMAIN.replace("<disk type='file' device='disk'><source file='/pool/later.qcow2'/><target dev='vdb' bus='virtio'/></disk>", "")
live = DOMAIN.replace("/pool/later.qcow2", "/pool/live-only.qcow2").replace("dev='vdb'", "dev='vdc'")
p = proxy(external=[snap])
p._XMLDesc = lambda flags: persistent if flags == PERSISTENT_XML else live
[entry] = p.get_snapshot(EXTERNAL)
self.assertEqual(entry["detaches"], [])
def test_a_shut_off_vm_is_compared_with_the_persistent_definition_only(self):
p = proxy(status=5, external=[fake_snap("one", disks=("vda", "vdb"))])
p._XMLDesc = MagicMock(return_value=DOMAIN)
[entry] = p.get_snapshot(EXTERNAL)
self.assertEqual(entry["detaches"], [])
p._XMLDesc.assert_called_once_with(PERSISTENT_XML)
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_without_a_job_has_none(self):
self.assertEqual(proxy(status=5).get_snapshot_jobs(), [])
def test_an_offline_merge_of_a_stopped_vm_is_reported(self):
# libvirt deletes an external snapshot of a shut-off VM as an offline block job
p = proxy(status=5, jobs={"vdb": {"cur": 3, "end": 8, "type": 4, "bandwidth": 0}})
p._XMLDesc = lambda flags: DOMAIN if flags == PERSISTENT_XML else "<domain><devices/></domain>"
self.assertEqual(p.get_snapshot_jobs(), [{"dev": "vdb", "cur": 3, "end": 8}])
def test_a_libvirt_without_offline_jobs_reports_none(self):
err = libvirtError("domain is not running")
err.err = (libvirt.VIR_ERR_OPERATION_INVALID,)
p = proxy(status=5)
p.instance.blockJobInfo.side_effect = err
self.assertEqual(p.get_snapshot_jobs(), [])
def test_another_error_of_a_stopped_vm_propagates(self):
err = libvirtError("cannot reach the host")
err.err = (libvirt.VIR_ERR_SYSTEM_ERROR,)
p = proxy(status=5)
p.instance.blockJobInfo.side_effect = err
with self.assertRaises(libvirtError):
p.get_snapshot_jobs()
def test_a_running_vm_does_not_swallow_operation_invalid(self):
err = libvirtError("operation invalid")
err.err = (libvirt.VIR_ERR_OPERATION_INVALID,)
p = proxy()
p.instance.blockJobInfo.side_effect = err
with self.assertRaises(libvirtError):
p.get_snapshot_jobs()
def test_a_running_vm_reads_the_live_disks(self):
p = proxy(jobs={"vdb": {"cur": 1, "end": 2}})
p._XMLDesc = lambda flags: DOMAIN if flags == 0 else "<domain><devices/></domain>"
self.assertEqual(p.get_snapshot_jobs(), [{"dev": "vdb", "cur": 1, "end": 2}])
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_an_offline_merge_of_a_stopped_vm_is_refused(self):
with self.assertRaisesRegex(util.OperationError, "still running"):
proxy(status=5, 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("<name>snap-1</name>", xml)
self.assertNotIn("<memory", xml)
# before, for the free space; after, for the new overlays
self.assertEqual(p.refresh_instance_pools.call_count, 2)
def test_description_is_escaped(self):
p = proxy()
p.create_external_snapshot("s", "<x> & 'y'")
xml, _ = created(p)
self.assertIn("&lt;x&gt; &amp;", xml)
self.assertNotIn("<x>", xml)
def test_memory_snapshot_goes_into_the_first_disks_pool(self):
p = proxy()
p.create_external_snapshot("m", memory=True)
xml, flags = created(p)
self.assertEqual(flags, libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC)
self.assertIn(
"<memory snapshot='external' file='/pool/11111111-2222-3333-4444-555555555555.m.mem'/>", xml
)
def test_a_quiesced_snapshot_freezes_the_guest_file_systems(self):
p = proxy()
p.is_agent_ready = lambda: True
p.create_external_snapshot("q", quiesce=True)
_, flags = created(p)
self.assertEqual(flags, DISK_ONLY_ATOMIC | libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_QUIESCE)
def test_quiesce_needs_a_running_vm_with_its_agent_and_no_memory(self):
# libvirt accepts quiesce only for disk-only snapshots, through the agent
for status, ready, memory, pattern in (
(5, True, False, "running VM"), (3, True, False, "running VM"),
(1, False, False, "guest agent is not connected"), (1, True, True, "memory"),
):
with self.subTest(status=status, ready=ready, memory=memory):
p = proxy(status=status)
p.is_agent_ready = lambda ready=ready: ready
self.refused(p, pattern, quiesce=True, memory=memory)
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, "<x>"):
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.<name> 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 = "<domain><devices><disk type='block' device='disk'><source dev='/dev/vg/x'/><target dev='vda'/></disk></devices></domain>"
self.refused(proxy(xml=xml), "vda is not a file")
def test_cdrom_with_media_is_refused(self):
xml = DOMAIN.replace("<target dev='sda'", "<source file='/iso/x.iso'/><target dev='sda'")
self.refused(proxy(xml=xml), "Eject")
def test_memory_needs_a_running_vm(self):
self.refused(proxy(status=3), "running VM", memory=True)
self.refused(proxy(status=5), "running VM", memory=True)
def test_memory_needs_a_pool(self):
p = proxy()
p.get_volume_by_path = MagicMock(side_effect=libvirtError("no volume"))
self.refused(p, "not in a storage pool", memory=True)
def space(self, disks, free, memory=False):
"""Creates a snapshot of a VM with these disks (path -> (pool, size)) and free space per pool."""
xml = "<domain><devices>%s</devices></domain>" % "".join(
f"<disk type='file' device='disk'><source file='{path}'/><target dev='vd{chr(97 + i)}'/></disk>"
for i, path in enumerate(disks)
)
p = proxy(xml=xml)
pools(p, {path: disk for path, disk in disks.items() if disk}, free)
p.create_external_snapshot("s", memory=memory)
return p
def test_each_pool_keeps_a_tenth_of_its_disks_free(self):
# a disk-only overlay grows as the guest writes, and a delete merges it down
disks = {"/fast/a.qcow2": ("fast", 20 * GiB), "/slow/b.qcow2": ("slow", 50 * GiB)}
self.space(disks, {"fast": 2 * GiB, "slow": 5 * GiB})
with self.assertRaisesRegex(util.OperationError, r"Storage pool slow has 4\.9 GiB free; the snapshot needs 5\.0 GiB"):
self.space(disks, {"fast": 2 * GiB, "slow": int(4.9 * GiB)})
def test_at_least_one_gib_is_kept_free(self):
self.space({"/pool/small.qcow2": ("default", 2 * GiB)}, {"default": GiB})
with self.assertRaisesRegex(util.OperationError, "needs 1.0 GiB"):
self.space({"/pool/small.qcow2": ("default", 2 * GiB)}, {"default": GiB - 1})
def test_the_memory_pool_also_needs_room_for_the_memory(self):
# get_cur_memory: 1024 MiB
disks = {"/pool/a.qcow2": ("default", 20 * GiB)}
self.space(disks, {"default": 3 * GiB}, memory=True)
with self.assertRaisesRegex(util.OperationError, "needs 3.0 GiB"):
self.space(disks, {"default": 3 * GiB - 1}, memory=True)
def test_the_share_and_the_minimum_can_be_changed(self):
disks = {"/pool/a.qcow2": ("default", 20 * GiB)}
p = proxy()
pools(p, disks, {"default": 4 * GiB - 1})
with self.assertRaisesRegex(util.OperationError, r"needs 4\.0 GiB \(20% of the VM's disks in it, at least 2 GiB"):
p.create_external_snapshot("s", free_percent=20, free_min_gib=2)
p.create_external_snapshot("s", free_percent=0, free_min_gib=0) # no check
p._snapshotCreateXML.assert_called_once()
def test_a_volume_that_cannot_be_read_is_refused_not_skipped(self):
p = proxy()
p.get_volume_by_path = MagicMock(side_effect=libvirtError("permission denied"))
with self.assertRaises(libvirtError):
p.create_external_snapshot("s")
p._snapshotCreateXML.assert_not_called()
def test_a_disk_in_no_pool_is_not_checked(self):
p = self.space({"/pool/a.qcow2": ("default", 20 * GiB), "/elsewhere/b.qcow2": None}, {"default": 2 * GiB})
p._snapshotCreateXML.assert_called_once()
REVERT_RUNNING = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING
REVERT_PAUSED = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_PAUSED
class RevertTestCase(unittest.TestCase):
def reverted_with(self, status, memory=False):
snap = fake_snap("s", memory=memory, current=True)
p = proxy(status=status, external=[snap])
p.revert_external_snapshot("s")
p.instance.revertToSnapshot.assert_called_once_with(snap, unittest.mock.ANY)
p.refresh_instance_pools.assert_called_once()
return p.instance.revertToSnapshot.call_args[0][1]
def test_disk_only_flags_keep_the_vm_state(self):
self.assertEqual(self.reverted_with(1), REVERT_RUNNING)
self.assertEqual(self.reverted_with(3), REVERT_PAUSED)
self.assertEqual(self.reverted_with(5), 0)
def test_memory_snapshot_resumes_as_saved(self):
self.assertEqual(self.reverted_with(1, memory=True), 0)
self.assertEqual(self.reverted_with(5, memory=True), 0)
def test_shared_state_checks_apply(self):
p = proxy(external=[fake_snap("s")], managed_save=True)
with self.assertRaisesRegex(util.OperationError, "saved state"):
p.revert_external_snapshot("s")
p.instance.revertToSnapshot.assert_not_called()
def collision(self):
err = libvirtError("external snapshot file for disk vda already exists and is not a block device")
err.get_error_code = lambda: libvirt.VIR_ERR_CONFIG_UNSUPPORTED
return err
def test_same_second_revert_collision_is_retried_once(self):
# libvirt has already stopped a running VM when the overlay names
# collide; the retry a second later finishes the revert.
snap = fake_snap("s")
p = proxy(external=[snap])
p.instance.revertToSnapshot.side_effect = [self.collision(), None]
with unittest.mock.patch("vrtManager.instance.time.sleep") as sleep:
p.revert_external_snapshot("s")
sleep.assert_called_once_with(1)
self.assertEqual(
p.instance.revertToSnapshot.call_args_list,
[unittest.mock.call(snap, REVERT_RUNNING)] * 2,
)
def test_a_second_collision_is_reported_with_the_vm_state(self):
p = proxy(external=[fake_snap("s")])
p.instance.revertToSnapshot.side_effect = [self.collision(), self.collision()]
p.instance.isActive.return_value = False
with unittest.mock.patch("vrtManager.instance.time.sleep"):
with self.assertRaisesRegex(util.OperationError, "try again in a moment.*shut off"):
p.revert_external_snapshot("s")
def test_other_libvirt_errors_pass_through(self):
p = proxy(external=[fake_snap("s")])
p.instance.revertToSnapshot.side_effect = libvirtError("boom")
with self.assertRaises(libvirtError) as ctx:
p.revert_external_snapshot("s")
self.assertNotIsInstance(ctx.exception, util.OperationError)
def test_an_old_uefi_snapshot_saved_with_a_rom_loader_is_refused(self):
# The old code switched pflash to rom while taking the snapshot, so
# the saved definition has rom; reverting would leave the VM unbootable.
uefi = DOMAIN.replace("<devices>", "<os><loader readonly='yes' type='pflash'>/ovmf/CODE.fd</loader></os><devices>")
snap = fake_snap("s1.old", current=True)
snap.getXMLDesc.return_value = snapshot_xml("s1.old").replace(
"<domain>", "<domain><os><loader readonly='yes' type='rom'>/ovmf/CODE.fd</loader></os>"
)
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("<devices>", "<os><loader readonly='yes' type='pflash'>/ovmf/CODE.fd</loader></os><devices>")
snap = fake_snap("s1.old", current=True)
snap.getXMLDesc.return_value = snapshot_xml("s1.old").replace(
"<domain>", "<domain><os><loader readonly='yes' type='pflash'>/ovmf/CODE.fd</loader></os>"
).replace(
"</domainsnapshot>",
"<inactiveDomain><os><loader readonly='yes' type='rom'>/ovmf/CODE.fd</loader></os>"
"<devices/></inactiveDomain></domainsnapshot>",
)
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()
CHAIN = """<domain><devices>
<disk type='file' device='disk'><source file='/pool/a.two'/><target dev='vda'/>
<backingStore type='file'><source file='/pool/a.one'/>
<backingStore type='file'><source file='/pool/a.qcow2'/><backingStore/></backingStore>
</backingStore>
</disk>
</devices></domain>"""
def chain_snap(name, overlay, memory=None):
snap = fake_snap(name)
mem = f"<memory snapshot='external' file='{memory}'/>" if memory else "<memory snapshot='no'/>"
snap.getXMLDesc.return_value = (
f"<domainsnapshot><name>{name}</name><creationTime>1700000000</creationTime>{mem}"
f"<disks><disk name='vda' snapshot='external'><source file='{overlay}'/></disk></disks>"
"<domain><devices/></domain></domainsnapshot>"
)
return snap
class LeftBehindTestCase(unittest.TestCase):
"""libvirt only logs a warning when it cannot remove a merged or
discarded file (on NFS it removes them as the qemu user), so the delete
and revert report the files nothing uses any more that still exist."""
def delete_fixture(self, still_there):
one = chain_snap("one", "/pool/a.one", memory="/pool/vm.one.mem")
two = chain_snap("two", "/pool/a.two")
p = proxy(external=[one, two])
after = CHAIN.replace("<source file='/pool/a.one'/>", "<source file='/pool/a.qcow2'/>").replace(
"<backingStore type='file'><source file='/pool/a.qcow2'/><backingStore/></backingStore>", "<backingStore/>"
)
p.instance.XMLDesc.side_effect = [CHAIN, after]
p.instance.listAllSnapshots.side_effect = [[one, two], [two]]
def volume(path):
if path not in still_there:
raise libvirtError("no volume")
return MagicMock()
p.get_volume_by_path = volume
return p
def run_delete(self, still_there):
return self.delete_fixture(still_there).delete_external_snapshot("one")
def test_a_recorded_state_gives_the_same_files(self):
# the delete job records the state before the merge and compares later
still_there = {"/pool/a.one", "/pool/vm.one.mem", "/pool/a.two", "/pool/a.qcow2"}
p = self.delete_fixture(still_there)
direct = p._left_behind(p._snapshot_files())
p = self.delete_fixture(still_there)
state = json.loads(json.dumps(p.snapshot_files_state())) # as stored in the job
self.assertEqual(p.left_behind_since(state), direct)
self.assertEqual(direct, ["/pool/a.one", "/pool/vm.one.mem"])
def test_files_libvirt_could_not_remove_are_reported(self):
self.assertEqual(
self.run_delete({"/pool/a.one", "/pool/vm.one.mem", "/pool/a.two", "/pool/a.qcow2"}),
["/pool/a.one", "/pool/vm.one.mem"],
)
def test_a_file_another_vm_uses_is_not_reported(self):
# on NFS libvirt could not remove it, but another VM uses it now
with patch.object(wvmInstance, "paths_in_use", return_value={"/pool/a.one"}) as in_use, \
patch.object(wvmInstance, "_backing_path", return_value=None):
left = self.run_delete({"/pool/a.one", "/pool/vm.one.mem", "/pool/a.two", "/pool/a.qcow2"})
self.assertEqual(left, ["/pool/vm.one.mem"])
# their own backing files do not keep each other
in_use.assert_called_once_with(own=["/pool/a.one", "/pool/vm.one.mem"])
def test_a_file_below_one_another_vm_uses_is_not_reported(self):
# the other VM is shut off: its definition does not list the backing
# file, and the in-use top's own backing reference does not count
backing = {"/pool/a.one": "/pool/vm.one.mem"}
with patch.object(wvmInstance, "paths_in_use", return_value={"/pool/a.one"}), \
patch.object(wvmInstance, "_backing_path", side_effect=backing.get):
left = self.run_delete({"/pool/a.one", "/pool/vm.one.mem", "/pool/a.two", "/pool/a.qcow2"})
self.assertEqual(left, [])
def test_nothing_is_reported_when_libvirt_removed_them(self):
# files still in use (base, the newer overlay) are never reported
self.assertEqual(self.run_delete({"/pool/a.two", "/pool/a.qcow2"}), [])
def revert(self, before, after, backing):
"""Revert to one; backing maps each file still on disk to its backing file."""
one = chain_snap("one", "/pool/a.one")
p = proxy(external=[one])
p.instance.XMLDesc.side_effect = [before, after]
p.instance.listAllSnapshots.side_effect = [[one], [one]]
def volume(path):
if path not in backing:
raise libvirtError("no volume")
if isinstance(backing[path], Exception): # removed between the lookup and the read
return MagicMock(**{"XMLDesc.side_effect": backing[path]})
parent = f"<backingStore><path>{backing[path]}</path></backingStore>" if backing[path] else ""
return MagicMock(**{"XMLDesc.return_value": f"<volume><target><path>{path}</path></target>{parent}</volume>"})
p.get_volume_by_path = volume
return p.revert_external_snapshot("one")
def test_revert_reports_a_discarded_overlay_left_on_disk(self):
before = CHAIN.replace("/pool/a.two", "/pool/a.after")
after = CHAIN.replace("/pool/a.two", "/pool/a.1700000001")
left = self.revert(before, after, {"/pool/a.after": "/pool/a.one", "/pool/a.1700000001": "/pool/a.one"})
self.assertEqual(left, ["/pool/a.after"])
def test_a_shut_off_vm_overlay_is_found_through_the_storage(self):
# a shut-off VM's definition does not show the backing chain
def plain(top):
return f"<domain><devices><disk type='file' device='disk'><source file='{top}'/><target dev='vda'/></disk></devices></domain>"
left = self.revert(plain("/pool/a.after"), plain("/pool/a.1700000001"), {"/pool/a.after": "/pool/a.one"})
self.assertEqual(left, ["/pool/a.after"])
def test_revert_never_reports_a_detached_disk(self):
# vdb and vdc were added after the snapshot: the revert detaches them
# and keeps their files on purpose, also when vdc is itself an overlay
later = (
"<disk type='file' device='disk'><source file='/pool/later.qcow2'/><target dev='vdb'/></disk>"
"<disk type='file' device='disk'><source file='/pool/clone.qcow2'/><target dev='vdc'/>"
"<backingStore type='file'><source file='/templates/base.qcow2'/><backingStore/></backingStore></disk>"
)
before = CHAIN.replace("</devices>", later + "</devices>")
left = self.revert(before, CHAIN, {"/pool/later.qcow2": None, "/pool/clone.qcow2": "/templates/base.qcow2"})
self.assertEqual(left, [])
def test_revert_never_reports_a_detached_overlay_of_a_snapshot_file(self):
linked = (
"<disk type='file' device='disk'><source file='/pool/linked.qcow2'/><target dev='vdb'/>"
"<backingStore type='file'><source file='/pool/a.one'/><backingStore/></backingStore></disk>"
)
before = CHAIN.replace("</devices>", linked + "</devices>")
left = self.revert(before, CHAIN, {"/pool/linked.qcow2": "/pool/a.one"})
self.assertEqual(left, [])
def test_a_volume_gone_while_checking_is_not_reported(self):
before = CHAIN.replace("/pool/a.two", "/pool/a.after")
after = CHAIN.replace("/pool/a.two", "/pool/a.1700000001")
left = self.revert(before, after, {"/pool/a.after": libvirtError("no volume")})
self.assertEqual(left, [])
class PoolRefreshTestCase(unittest.TestCase):
"""The pools are refreshed after libvirt made, reverted or deleted the
snapshot; a refresh error does not turn that into a failure. A create
also refreshes them before, to read their free space."""
def test_create_succeeds_when_the_refresh_after_it_fails(self):
p = proxy()
p.refresh_instance_pools.side_effect = [None, libvirtError("pool busy")]
p.create_external_snapshot("one")
p._snapshotCreateXML.assert_called_once()
def test_create_is_refused_when_the_refresh_before_it_fails(self):
# the free space is not known then
p = proxy()
p.refresh_instance_pools.side_effect = libvirtError("pool busy")
with self.assertRaisesRegex(util.OperationError, "free space is unknown: pool busy"):
p.create_external_snapshot("one")
p._snapshotCreateXML.assert_not_called()
def test_delete_and_revert_succeed_and_report_nothing_when_the_refresh_fails(self):
for action in ("delete_external_snapshot", "revert_external_snapshot"):
with self.subTest(action):
snap = fake_snap("one", current=True)
p = proxy(external=[snap])
p.refresh_instance_pools.side_effect = libvirtError("pool busy")
# a stale pool still lists every file
p.get_volume_by_path = MagicMock()
self.assertEqual(getattr(p, action)("one"), [])
class KindTestCase(unittest.TestCase):
def test_internal_endpoints_refuse_external_names(self):
snap = fake_snap("e")
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()
class DestroyFilesTestCase(unittest.TestCase):
"""Deleting a VM with its disks takes the files its external snapshots
made or were taken on, not a template below them or a file another VM
still grows from."""
TOP = "<domain><devices><disk type='file' device='disk'><source file='/pool/a.two'/><target dev='vda'/></disk></devices></domain>"
def split(self, other_overlay_on=None, top="/pool/a.two", extra=None):
one = chain_snap("one", "/pool/a.one", memory="/pool/vm.one.mem")
two = chain_snap("two", "/pool/a.two")
snaps = [(one, "/pool/a.qcow2"), (two, "/pool/a.one")] # each with the file it was taken on
if extra:
snaps.append((chain_snap("old", extra[0]), extra[1]))
for snap, point in snaps:
snap.getXMLDesc.return_value = snap.getXMLDesc.return_value.replace(
"<devices/>", f"<devices><disk type='file' device='disk'><source file='{point}'/><target dev='vda'/></disk></devices>"
)
p = proxy(external=[snap for snap, _ in snaps], xml=self.TOP)
p.get_disk_devices = lambda: [{"path": top}]
backing = {
"/pool/a.two": "/pool/a.one", "/pool/a.one": "/pool/a.qcow2", "/pool/a.qcow2": "/templates/base.qcow2",
}
if top == "/pool/a.reverted":
backing[top] = "/pool/a.one"
if extra:
backing[extra[0]] = extra[1]
if other_overlay_on:
backing["/pool/other.qcow2"] = other_overlay_on
def volume(path):
parent = f"<backingStore><path>{backing[path]}</path></backingStore>" if path in backing else ""
vol = MagicMock(**{"XMLDesc.return_value": f"<volume>{parent}</volume>"})
vol.path.return_value = path
return vol
p.get_volume_by_path = volume
pool = MagicMock(**{"isActive.return_value": True})
pool.listAllVolumes.return_value = [volume(path) for path in backing]
p.wvm.listAllStoragePools.return_value = [pool]
p.wvm.listAllDomains.return_value = []
return p.split_disk_paths_by_use()
def test_a_backing_file_that_cannot_be_read_is_an_error_not_the_chain_end(self):
p = proxy()
missing = libvirtError("no volume")
missing.err = (libvirt.VIR_ERR_NO_STORAGE_VOL,)
p.get_volume_by_path = MagicMock(side_effect=missing)
self.assertIsNone(p._backing_path("/elsewhere/base.qcow2")) # in no pool
p.get_volume_by_path = MagicMock(side_effect=libvirtError("connection lost"))
with self.assertRaises(libvirtError):
p._backing_path("/pool/a.one")
def test_the_snapshot_chain_and_memory_go_but_not_the_template(self):
delete, keep = self.split()
self.assertEqual(sorted(delete), ["/pool/a.one", "/pool/a.qcow2", "/pool/a.two", "/pool/vm.one.mem"])
self.assertEqual(keep, [])
def test_a_file_another_vm_grows_from_keeps_the_files_below_it(self):
delete, keep = self.split(other_overlay_on="/pool/a.one")
self.assertEqual(sorted(delete), ["/pool/a.two", "/pool/vm.one.mem"])
self.assertEqual(sorted(keep), ["/pool/a.one", "/pool/a.qcow2"])
def test_newer_snapshots_kept_after_a_revert_go_too(self):
# reverted to one: the disk is a new overlay on a.one, and a.two of
# snapshot two branches off it
delete, keep = self.split(top="/pool/a.reverted")
self.assertEqual(
sorted(delete), ["/pool/a.one", "/pool/a.qcow2", "/pool/a.reverted", "/pool/a.two", "/pool/vm.one.mem"]
)
self.assertEqual(keep, [])
def test_a_snapshot_file_off_the_disks_chain_stays(self):
# a disk detached since: its snapshot overlay grows from a file the
# VM's disks do not
delete, _ = self.split(extra=("/pool/gone.old", "/pool/gone.qcow2"))
self.assertNotIn("/pool/gone.old", delete)
self.assertNotIn("/pool/gone.qcow2", delete)