1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 04:06:47 +00:00

fix(instances): name snapshot files libvirt could not remove

When libvirt cannot remove a snapshot file it merged or discarded, it
only logs a warning and the delete or revert still succeeds. On NFS it
removes them as the qemu user, so an image directory the qemu user may
not write to left every merged overlay on disk without a word.

Revert and delete now compare the files in use before and after and
show a warning naming the ones still on disk. Only a snapshot's own
files (overlays, memory) and overlays on a snapshot point count, and a
disk the revert detached is never named, so a kept disk is not offered
for removal.
This commit is contained in:
catborise 2026-10-12 16:32:51 +03:00
parent ff1508b3c2
commit 3fd422db7e
6 changed files with 194 additions and 11 deletions

View file

@ -1598,7 +1598,8 @@ class wvmInstance(wvmConnect):
def revert_external_snapshot(self, name):
"""libvirt returns the disks and the definition to the snapshot's. A
disk-only snapshot keeps the VM running, paused or shut off as it is;
a snapshot with memory resumes the VM as it was saved."""
a snapshot with memory resumes the VM as it was saved. Returns the
files libvirt left behind (see _left_behind)."""
snap = self._snapshot(name, external=True)
self._check_snapshot_state()
tree = etree.fromstring(snap.getXMLDesc(0))
@ -1618,6 +1619,7 @@ class wvmInstance(wvmConnect):
flags = {1: VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING, 3: VIR_DOMAIN_SNAPSHOT_REVERT_PAUSED}.get(
self.get_status(), 0
)
before = self._snapshot_files()
# libvirt names the new overlays after the second, so a revert within
# the second of the previous one finds them taken, after it has
# already stopped a running VM; one retry a second later finishes it.
@ -1634,14 +1636,55 @@ class wvmInstance(wvmConnect):
raise util.OperationError(
f"The revert failed twice; try again in a moment ({retry_err}). The VM is {state}."
) from None
self.refresh_instance_pools()
return self._left_behind(before)
def delete_external_snapshot(self, name):
"""libvirt merges the snapshot's overlays and removes its files."""
"""libvirt merges the snapshot's overlays and removes its files.
Returns the files it left behind (see _left_behind)."""
snap = self._snapshot(name, external=True)
self._check_snapshot_state()
before = self._snapshot_files()
snap.delete(0)
return self._left_behind(before)
def _snapshot_files(self):
"""(disks, snapshot files, snapshot points): the files each disk uses
by target, the external snapshots' own files (overlays, memory), and
the files snapshots were taken on, which libvirt's overlays sit on."""
disks = {
disk.xpath("string(target/@dev)"): set(disk.xpath(".//source/@file"))
for disk in etree.fromstring(self.instance.XMLDesc(0)).xpath("devices/disk[@device='disk']")
}
own, points = set(), set()
for snap in self.instance.listAllSnapshots(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
tree = etree.fromstring(snap.getXMLDesc(0))
own.update(tree.xpath("disks/disk/source/@file|memory/@file"))
saved = self._saved_definition(tree)
if saved is not None:
points.update(self._disk_sources(saved).values())
return disks, own, points | own
def _left_behind(self, before):
"""Files libvirt merged or discarded but could not remove: it only
logs a warning then (on NFS it removes them as the qemu user, who
needs write access to their directory). Only a snapshot's own file or
an overlay on a snapshot point counts; a disk a revert detached is
kept on purpose. A file outside every storage pool is not checked."""
disks, own, points = before
self.refresh_instance_pools()
now_disks, now_own, _ = self._snapshot_files()
# only disks still attached: one a revert detached keeps its files,
# whatever they sit on
attached = set().union(*(files for dev, files in disks.items() if dev in now_disks))
left = []
for path in sorted((attached | own) - set().union(now_own, *now_disks.values())):
try:
vol = self.get_volume_by_path(path)
except libvirtError:
continue
if path in own or util.get_xml_path(vol.XMLDesc(0), "/volume/backingStore/path") in points:
left.append(path)
return left
@staticmethod
def _disk_sources(xml):

View file

@ -78,6 +78,7 @@ def proxy(status=1, internal=(), external=(), xml=DOMAIN, jobs=None, managed_sav
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
return p
@ -365,6 +366,110 @@ class DeleteTestCase(unittest.TestCase):
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 run_delete(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.delete_external_snapshot("one")
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_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")
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, [])
class KindTestCase(unittest.TestCase):
def test_internal_endpoints_refuse_external_names(self):
snap = fake_snap("e")