1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 12:16:49 +00:00

fix(instances): deleting a VM's data takes its snapshot files too

Deleting a VM with its data deleted only the images its disks pointed
at. With external snapshots that is the newest overlay: the image the
snapshots were taken on, the older overlays and the memory files stayed
on disk, with nothing referring to them once the VM and its snapshot
metadata were gone.

The files the VM's snapshots were taken on, down each disk's backing
chain, the overlays of snapshots kept off the chain after a revert, and
the snapshots' memory files are now the VM's own too. A template the
VM grew from ends the chain and stays, as does an overlay of a disk
detached since. The backing references of the VM's own volumes no
longer count as another domain's use, and a file another VM uses keeps
the files below it.

A backing file that cannot be read (other than one in no pool) stops the
split before anything is deleted, instead of ending the chain there.
This commit is contained in:
catborise 2026-10-12 22:56:20 +03:00
parent ff4ffab382
commit 08cad04468
4 changed files with 186 additions and 6 deletions

View file

@ -551,3 +551,82 @@ class KindTestCase(unittest.TestCase):
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)