1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +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

@ -556,7 +556,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat
- gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3).
- A theme picked on the Settings page rewrites two tracked files; the update and rollback steps put back the shipped theme. Pick yours again afterwards, also after Docker recreates the container.
- With the bundled nginx config, browsers revalidate static files on each load (304 when unchanged), so an update or a theme change shows without a hard reload. Files cached before this update can stay until a hard reload (Ctrl+F5) of each affected page, or until the site's browser cache is cleared once; an external caching proxy may need a purge.
- External snapshots use libvirt's snapshot API and need libvirt 9.9 or newer on the compute hosts. They work on UEFI VMs and running, paused or shut-off VMs, can save memory too, and a revert keeps disks added later (it detaches them). A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. Snapshot names no longer get an `s1.` prefix; existing ones keep theirs and can still be reverted and deleted, except that a snapshot an older version took of a UEFI VM can only be deleted. On NFS, libvirt removes merged snapshot files as the qemu user, who needs write access to the image directory; files it could not remove are named in a warning after the delete or revert.
- External snapshots use libvirt's snapshot API and need libvirt 9.9 or newer on the compute hosts. They work on UEFI VMs and running, paused or shut-off VMs, can save memory too, and a revert keeps disks added later (it detaches them). A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. Snapshot names no longer get an `s1.` prefix; existing ones keep theirs and can still be reverted and deleted, except that a snapshot an older version took of a UEFI VM can only be deleted. On NFS, libvirt removes merged snapshot files as the qemu user, who needs write access to the image directory; files it could not remove are named in a warning after the delete or revert. Deleting a VM with its data removes its snapshot files too, except a template it grew from and files another VM uses.
- Docker no longer publishes port 6080; the console goes through nginx on the panel's port (`WS_PUBLIC_PORT`, default 80).
## Usage

View file

@ -275,6 +275,52 @@ class LiveDataLossTestCase(TestCase):
self.assertFalse(livetest.volume_exists(self.conn, overlay), "the VM's own image is still there")
self.assertTrue(livetest.volume_exists(self.conn, base), "the base image was deleted")
def test_destroy_deletes_the_external_snapshot_chain_but_not_its_template(self):
# The VM grew from a template; its snapshots added overlays above its
# disk and saved memory. Deleting the disks takes all of those, which
# nothing else uses once the VM and its snapshot metadata are gone.
template = livetest.create_volume(self.conn, P + "r14-ext-template")
pool = self.conn.storagePoolLookupByName(livetest.POOL)
disk = pool.createXML(
f"<volume><name>{P}r14-ext.qcow2</name><capacity unit='MiB'>64</capacity>"
f"<target><format type='qcow2'/></target><backingStore><path>{template}</path>"
"<format type='qcow2'/></backingStore></volume>",
0,
).path()
dom, inst = self.vm("r14-ext", [disk])
dom.create()
inst.proxy.create_external_snapshot("a", memory=True)
inst.proxy.create_external_snapshot("b")
memory = os.path.join(livetest.POOL_PATH, f"{dom.UUIDString()}.a.mem")
pool.refresh(0)
made = {name for name in pool.listVolumes() if name.startswith(P + "r14-ext.")} - {P + "r14-ext.qcow2"}
self.assertEqual(len(made), 2, made) # one overlay per snapshot
response = self.post("destroy", inst, {"delete_disk": "1"})
self.assertEqual(response.status_code, 302)
self.assertFalse(self.domain_exists(dom.UUIDString()), "VM is still defined")
pool.refresh(0)
left = {name for name in pool.listVolumes() if name.startswith(P + "r14-ext") and "template" not in name}
self.assertEqual(left, set(), "files of the VM or its snapshots stayed")
self.assertFalse(livetest.volume_exists(self.conn, memory), "the memory file stayed")
self.assertTrue(livetest.volume_exists(self.conn, template), "the template was deleted")
self.assertEqual([str(m) for m in get_messages(response.wsgi_request) if "kept" in str(m)], [])
def test_destroy_after_a_revert_deletes_the_newer_snapshots_files_too(self):
base = livetest.create_volume(self.conn, P + "r14-rev")
dom, inst = self.vm("r14-rev", [base])
inst.proxy.create_external_snapshot("a")
inst.proxy.create_external_snapshot("b")
inst.proxy.revert_external_snapshot("a") # b's overlay is off the disk's chain now
response = self.post("destroy", inst, {"delete_disk": "1"})
self.assertEqual(response.status_code, 302)
pool = self.conn.storagePoolLookupByName(livetest.POOL)
pool.refresh(0)
self.assertEqual({name for name in pool.listVolumes() if name.startswith(P + "r14-rev")}, set())
# Delete a volume only after it is detached
def _delete_attached_volume(self, pause):

View file

@ -1479,8 +1479,9 @@ class wvmInstance(wvmConnect):
iso.extend(name for name in names if name.lower().endswith(".iso"))
return iso
def paths_used_by_other_domains(self):
"""Paths another domain uses as a disk or as a backing file of one."""
def paths_used_by_other_domains(self, own=()):
"""Paths another domain uses as a disk or as a backing file of one.
The volumes in own are this VM's: their backing files do not count."""
uuid = self.instance.UUIDString()
used = set()
for dom in self.wvm.listAllDomains():
@ -1499,19 +1500,73 @@ class wvmInstance(wvmConnect):
pool.refresh(0)
for vol in pool.listAllVolumes():
with contextlib.suppress(libvirtError):
used.update(etree.fromstring(vol.XMLDesc(0)).xpath("./backingStore/path/text()"))
if vol.path() not in own:
used.update(etree.fromstring(vol.XMLDesc(0)).xpath("./backingStore/path/text()"))
return used
def split_disk_paths_by_use(self):
"""This VM's disk paths, split into (only this VM, also used by another domain)."""
"""This VM's disk paths, split into (only this VM, also used by another
domain). With external snapshots, the files below a disk that the
snapshots were taken on, and their memory files, are the VM's too."""
self.refresh_instance_pools()
own = [disk["path"] for disk in self.get_disk_devices() if disk["path"]]
used_elsewhere = self.paths_used_by_other_domains()
own += self._snapshot_chain_files(own)
used_elsewhere = self.paths_used_by_other_domains(own)
# a kept file keeps the files below it
for path in [path for path in own if path in used_elsewhere]:
while (path := self._backing_path(path)) and path not in used_elsewhere:
used_elsewhere.add(path)
return (
[path for path in own if path not in used_elsewhere],
[path for path in own if path in used_elsewhere],
)
def _snapshot_chain_files(self, tops):
"""The files of this VM's external snapshots that these disk images
grow from or that grow from them: the backing files snapshots were
taken on, down the chain, the overlays of snapshots off the chain
(newer ones kept after a revert), and the snapshots' memory files. A
backing file the VM started from (a template) is no snapshot point
and ends the chain; an overlay that does not grow from the chain (a
disk detached since) stays."""
points, overlays, memory = set(), set(), []
for snap in self.instance.listAllSnapshots(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
tree = etree.fromstring(snap.getXMLDesc(0))
memory += tree.xpath("memory/@file")
overlays.update(tree.xpath("disks/disk/source/@file"))
saved = self._saved_definition(tree)
if saved is not None:
points.update(self._disk_sources(saved).values())
files = list(tops)
for path in tops:
while (path := self._backing_path(path)) in points and path not in files:
files.append(path)
grew = True
while grew:
grew = False
for top in overlays.difference(files):
if top in files: # reached by a branch in this pass
continue
branch, path = [top], top
while (path := self._backing_path(path)) in points | overlays and path not in files:
branch.append(path)
if path in files:
files += branch
grew = True
return files[len(tops):] + memory
def _backing_path(self, path):
"""The backing file of a volume; None if it has none or is in no pool.
Any other error is raised: a chain cut short would let a file a kept
one needs count as deletable."""
try:
vol = self.get_volume_by_path(path)
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
return None
return util.get_xml_path(vol.XMLDesc(0), "/volume/backingStore/path")
def _snapshotCreateXML(self, xml, flag):
self.instance.snapshotCreateXML(xml, flag)

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)