From c970f8e8531b717144838484af15a53dd67a0de5 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 12 Oct 2026 23:16:24 +0300 Subject: [PATCH] fix(storages): keep volumes a VM still needs libvirt deletes any volume it is asked to. The storage page and the API deleted a VM's disk, the base image of another volume or the file a snapshot was taken on without a check, breaking the VM or its snapshots. wvmStorage.del_volume now refuses a volume a domain on the compute uses as a disk or a backing file of one, a file of a domain's snapshots, or the backing file of another volume. The check is the one destroy already used, moved to wvmConnect and extended to snapshot files, so destroy also keeps the files of another VM's snapshots. The storage page shows the refusal; the API answers 400 with it, as for other refusals. Deleting a disk from a VM refuses after the detach when the VM's own snapshots need the volume, and says the volume was kept. Network disks count by their source name, pool volume disks by their volume's path. A volume whose metadata cannot be read stops the check instead of counting as unused; only one removed meanwhile is skipped. The check and the delete hold the compute lock, so no VM operation attaches the volume in between; a busy compute is a message on the page and 409 in the API. --- README.md | 1 + instances/tests/test_destroy.py | 16 +++ instances/tests/test_live_dataloss.py | 27 +++++ instances/views.py | 8 +- storages/api/viewsets.py | 5 +- storages/tests/test_views.py | 78 ++++++++++++++ storages/views.py | 19 +++- vrtManager/connection.py | 54 ++++++++++ vrtManager/instance.py | 26 +---- vrtManager/storage.py | 3 + vrtManager/tests/test_volume_in_use.py | 138 +++++++++++++++++++++++++ 11 files changed, 345 insertions(+), 30 deletions(-) create mode 100644 vrtManager/tests/test_volume_in_use.py diff --git a/README.md b/README.md index f365c109..b566e685 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat - 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. Deleting a VM with its data removes its snapshot files too, except a template it grew from and files another VM uses. +- A storage volume a VM still needs (a disk, a backing file or a snapshot's file) is no longer deleted from the storage page or the API; the delete is refused with the reason. Only VMs on the same compute are checked. - Docker no longer publishes port 6080; the console goes through nginx on the panel's port (`WS_PUBLIC_PORT`, default 80). ## Usage diff --git a/instances/tests/test_destroy.py b/instances/tests/test_destroy.py index f42dd89e..1afd3505 100644 --- a/instances/tests/test_destroy.py +++ b/instances/tests/test_destroy.py @@ -3,10 +3,12 @@ from unittest.mock import patch from computes.models import Compute from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages from django.test import TestCase from django.urls import reverse from instances.models import Instance from libvirt import VIR_DOMAIN_UNDEFINE_KEEP_NVRAM, VIR_DOMAIN_UNDEFINE_NVRAM, libvirtError +from vrtManager.util import OperationError class DestroyOrderTestCase(TestCase): @@ -66,6 +68,20 @@ class DestroyOrderTestCase(TestCase): self.destroy() self.assertEqual(calls, [False, False]) + def test_a_volume_the_vms_snapshots_need_is_kept_after_the_detach(self): + disk = {"dev": "vdb", "path": "/pool/data.qcow2", "storage": "default", "image": "data.qcow2"} + with patch("instances.models.wvmInstance") as wvm, patch("instances.views.wvmStorage") as storage: + proxy = wvm.return_value + proxy.get_disk_devices.return_value = [disk] + proxy.paths_used_by_other_domains.return_value = set() + proxy.wait_disk_detached.return_value = True + storage.return_value.del_volume.side_effect = OperationError("Volume data.qcow2 is in use") + response = self.client.post(reverse("instances:delete_vol", args=[self.instance.id]), {"dev": "vdb"}) + self.assertEqual(response.status_code, 302) + proxy.detach_disk.assert_called_once_with("vdb") + messages = [str(m) for m in get_messages(response.wsgi_request)] + self.assertTrue(any("data.qcow2 is in use" in m and "detached" in m for m in messages), messages) + def test_confirmation_page_takes_no_lock(self): calls = [] diff --git a/instances/tests/test_live_dataloss.py b/instances/tests/test_live_dataloss.py index 0f3485c6..33963b40 100644 --- a/instances/tests/test_live_dataloss.py +++ b/instances/tests/test_live_dataloss.py @@ -321,6 +321,33 @@ class LiveDataLossTestCase(TestCase): pool.refresh(0) self.assertEqual({name for name in pool.listVolumes() if name.startswith(P + "r14-rev")}, set()) + # Delete a volume from the storage page + + def test_the_storage_page_keeps_volumes_a_vm_needs(self): + disk = livetest.create_volume(self.conn, P + "vol-disk") + dom, inst = self.vm("vol-disk", [disk]) + inst.proxy.create_external_snapshot("a") # disk is now the file snapshot a was taken on + template = livetest.create_volume(self.conn, P + "vol-template") + pool = self.conn.storagePoolLookupByName(livetest.POOL) + pool.createXML( + f"{P}vol-clone.qcow264" + f"{template}" + "", + 0, + ) + unused = livetest.create_volume(self.conn, P + "vol-unused") + top = self.disk_sources(dom)["vda"] + url = reverse("storage", args=[self.compute.id, livetest.POOL]) + + for path in (disk, template, top): + with self.subTest(path): + response = self.client.post(url, {"del_volume": "1", "volname": os.path.basename(path)}) + self.assertEqual(response.status_code, 302) + self.assertTrue(livetest.volume_exists(self.conn, path), "a volume a VM needs was deleted") + self.assertTrue(any("is in use" in str(m) for m in get_messages(response.wsgi_request))) + self.client.post(url, {"del_volume": "1", "volname": os.path.basename(unused)}) + self.assertFalse(livetest.volume_exists(self.conn, unused), "an unused volume was kept") + # Delete a volume only after it is detached def _delete_attached_volume(self, pause): diff --git a/instances/views.py b/instances/views.py index e9564dff..2bbd84b9 100755 --- a/instances/views.py +++ b/instances/views.py @@ -1223,7 +1223,13 @@ def delete_vol(request, pk): % {"dev": dev, "vol": disk["image"]}, ) return _back(request, pk, "disks") - conn_delete.del_volume(disk["image"]) + try: + conn_delete.del_volume(disk["image"]) + except util.OperationError as err: # a file of the VM's own snapshots + messages.warning( + request, + _("Disk %(dev)s was detached, but its volume was kept: %(err)s") % {"dev": dev, "err": err}, + ) addlogmsg(request.user.username, instance.compute.name, instance.name, msg) return _back(request, pk, "disks") diff --git a/storages/api/viewsets.py b/storages/api/viewsets.py index 48c407ea..714f0b28 100644 --- a/storages/api/viewsets.py +++ b/storages/api/viewsets.py @@ -1,6 +1,7 @@ from admin.permissions import IsSuperUser from appsettings.settings import app_settings from computes.models import Compute +from computes.utils import libvirt_compute_lock from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework import serializers, status, viewsets @@ -196,7 +197,9 @@ class VolumeViewSet(viewsets.ViewSet): ) if conn.is_active(): - conn.del_volume(pk) + # no VM operation attaches it between the check and the delete + with libvirt_compute_lock(compute): + conn.del_volume(pk) conn.close() return Response({"status": "Volume: " + pk + " is deleted"}) else: diff --git a/storages/tests/test_views.py b/storages/tests/test_views.py index b4981023..e21226d6 100644 --- a/storages/tests/test_views.py +++ b/storages/tests/test_views.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from django.core.files.uploadedfile import SimpleUploadedFile from django.test import RequestFactory, TestCase +from django.contrib.messages import get_messages from django.urls import reverse from django.contrib.auth import get_user_model @@ -894,3 +895,80 @@ class GetVolumesViewTests(TestCase): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["vols"], ["a.qcow2", "b.qcow2"]) + + +class VolumeDeleteTests(TestCase): + """A volume a VM still needs is refused with its reason, on the page and in the API.""" + + def setUp(self): + User.objects.create_superuser(username="vol_admin", password="password", email="vol_admin@example.com") + self.client.login(request=RequestFactory().get("/"), username="vol_admin", password="password") + self.compute = Compute.objects.create(name="VolCompute", hostname="localhost", type=1, login="u", password="p") + + def refused(self, mock_wvm): + from vrtManager import util + + conn = mock_wvm.return_value + conn.is_active.return_value = False + conn.get_size.return_value = (1, 1) + conn.del_volume.side_effect = util.OperationError("Volume disk.qcow2 is in use") + return conn + + @patch("storages.views.wvmStorage") + def test_the_page_shows_the_refusal(self, mock_wvm): + conn = self.refused(mock_wvm) + url = reverse("storage", args=[self.compute.id, "default"]) + response = self.client.post(url, {"del_volume": "1", "volname": "disk.qcow2"}) + self.assertRedirects(response, url, fetch_redirect_response=False) + conn.del_volume.assert_called_once_with("disk.qcow2") + messages = [str(m) for m in get_messages(response.wsgi_request)] + self.assertTrue(any("disk.qcow2 is in use" in m for m in messages), messages) + self.assertFalse(any("is deleted" in m for m in messages), messages) + + def test_the_check_and_the_delete_hold_the_compute_lock(self): + """No VM operation (they share the compute lock) attaches the volume + between the check and the delete.""" + from contextlib import contextmanager + + held = [] + + @contextmanager + def lock(compute, *args, **kwargs): + held.append((compute.pk, kwargs.get("shared", False))) + yield + held.pop() + + for module, request in ( + ("storages.views", lambda: self.client.post(reverse("storage", args=[self.compute.id, "default"]), + {"del_volume": "1", "volname": "disk.qcow2"})), + ("storages.api.viewsets", lambda: self.client.delete( + f"/api/v1/computes/{self.compute.id}/storages/default/volumes/disk.qcow2/")), + ): + with self.subTest(module), patch(f"{module}.wvmStorage") as mock_wvm, \ + patch(f"{module}.libvirt_compute_lock", lock): + conn = mock_wvm.return_value + conn.is_active.return_value = True + conn.get_size.return_value = (1, 1) + inside = [] + conn.del_volume.side_effect = lambda name: inside.append(list(held)) + request() + self.assertEqual(inside, [[(self.compute.pk, False)]]) + + @patch("storages.views.wvmStorage") + def test_a_busy_compute_is_a_message(self, mock_wvm): + conn = self.refused(mock_wvm) + url = reverse("storage", args=[self.compute.id, "default"]) + with patch("storages.views.libvirt_compute_lock", side_effect=TimeoutError): + response = self.client.post(url, {"del_volume": "1", "volname": "disk.qcow2"}) + self.assertRedirects(response, url, fetch_redirect_response=False) + conn.del_volume.assert_not_called() + messages = [str(m) for m in get_messages(response.wsgi_request)] + self.assertTrue(any("Please retry" in m for m in messages), messages) + + @patch("storages.api.viewsets.wvmStorage") + def test_the_api_refuses_with_the_reason(self, mock_wvm): + conn = self.refused(mock_wvm) + conn.is_active.return_value = True + response = self.client.delete(f"/api/v1/computes/{self.compute.id}/storages/default/volumes/disk.qcow2/") + self.assertEqual(response.status_code, 400, response.content) + self.assertIn("disk.qcow2 is in use", response.json()["detail"]) diff --git a/storages/views.py b/storages/views.py index 8f8dcd73..e7085c16 100644 --- a/storages/views.py +++ b/storages/views.py @@ -4,6 +4,7 @@ import re from admin.decorators import superuser_only from appsettings.settings import app_settings from computes.models import Compute +from computes.utils import libvirt_compute_lock from django.contrib import messages from django.http import HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -12,6 +13,7 @@ from django.utils.translation import gettext_lazy as _ from libvirt import libvirtError from vrtManager.connection import CONN_TCP, CONN_TLS +from vrtManager.util import OperationError from vrtManager.storage import wvmStorage, wvmStorages from storages.forms import AddStgPool, CloneImage, CreateVolumeForm @@ -232,11 +234,18 @@ def storage(request, compute_id, pool): return HttpResponseRedirect(request.get_full_path()) if "del_volume" in request.POST: volname = request.POST.get("volname", "") - vol = conn.get_volume(volname) - vol.delete(0) - messages.success( - request, _("Volume: %(vol)s is deleted.") % {"vol": volname} - ) + try: + # no VM operation attaches it between the check and the delete + with libvirt_compute_lock(compute): + conn.del_volume(volname) + except OperationError as err: + messages.error(request, str(err)) + except TimeoutError: + messages.error(request, _("A VM operation on this host is running. Please retry.")) + else: + messages.success( + request, _("Volume: %(vol)s is deleted.") % {"vol": volname} + ) return redirect(reverse("storage", args=[compute.id, pool])) # return HttpResponseRedirect(request.get_full_path()) if "cln_volume" in request.POST: diff --git a/vrtManager/connection.py b/vrtManager/connection.py index e4b9c35b..7fec5de0 100644 --- a/vrtManager/connection.py +++ b/vrtManager/connection.py @@ -11,6 +11,7 @@ import libvirt from django.conf import settings from django.utils.functional import cached_property from libvirt import libvirtError +from lxml import etree from vrtManager import util from vrtManager.rwlock import ReadWriteLock @@ -946,6 +947,59 @@ class wvmConnect(object): instances.append(name) return instances + def paths_in_use(self, skip_uuid=None, own=()): + """Paths (names for network disks) a domain uses as a disk or a + backing file of one, or as a file of its snapshots (overlays, memory, + the disks they were taken on); and the backing files of volumes. + skip_uuid leaves out one domain, own the volumes whose backing files + do not count.""" + used = set() + for dom in self.wvm.listAllDomains(): + if dom.UUIDString() == skip_uuid: + continue + for flags in (0, libvirt.VIR_DOMAIN_XML_INACTIVE): + used.update(self._source_paths(etree.fromstring(dom.XMLDesc(flags)))) + for snap in dom.listAllSnapshots(0): + tree = etree.fromstring(snap.getXMLDesc(0)) + used.update(tree.xpath("disks/disk/source/@file|memory/@file")) + for saved in tree.xpath("domain|inactiveDomain"): + used.update(self._source_paths(saved)) + # A stopped domain's XML has no backing chain; the volumes have it. + # Any volume backed by a path keeps that path, even an unattached one. + for pool in self.wvm.listAllStoragePools(): + if not pool.isActive(): + continue + with contextlib.suppress(libvirtError): + pool.refresh(0) + for vol in pool.listAllVolumes(): + # only a volume removed meanwhile is skipped: one that cannot + # be read may still keep a backing file + try: + if vol.path() not in own: + used.update(etree.fromstring(vol.XMLDesc(0)).xpath("./backingStore/path/text()")) + except libvirtError as err: + if err.get_error_code() != libvirt.VIR_ERR_NO_STORAGE_VOL: + raise + return used + + def _source_paths(self, domain): + """The files, devices or network names of a domain definition's + disks and of their backing chains ("//" reaches a running domain's + backingStore); a pool volume source as its volume's path.""" + paths = [] + for source in domain.xpath("devices/disk//source"): + if source.get("pool") and source.get("volume"): + try: + pool = self.wvm.storagePoolLookupByName(source.get("pool")) + paths.append(pool.storageVolLookupByName(source.get("volume")).path()) + except libvirtError as err: + # a volume that is not there cannot be the one deleted + if err.get_error_code() not in (libvirt.VIR_ERR_NO_STORAGE_VOL, libvirt.VIR_ERR_NO_STORAGE_POOL): + raise + else: + paths += [v for v in (source.get("file"), source.get("dev"), source.get("name")) if v] + return paths + def get_snapshots(self): instance = [] for snap_id in self.wvm.listDomainsID(): diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 5a4cc822..c5ce2614 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -1480,29 +1480,9 @@ class wvmInstance(wvmConnect): return iso 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(): - if dom.UUIDString() == uuid: - continue - for flags in (0, VIR_DOMAIN_XML_INACTIVE): - tree = etree.fromstring(dom.XMLDesc(flags)) - # "//" also reaches backingStore chains in a running domain's XML - used.update(tree.xpath("./devices/disk//source/@file|./devices/disk//source/@dev")) - # A stopped domain's XML has no backing chain; the volumes have it. - # Any volume backed by a path keeps that path, even an unattached one. - for pool in self.wvm.listAllStoragePools(): - if not pool.isActive(): - continue - with contextlib.suppress(libvirtError): - pool.refresh(0) - for vol in pool.listAllVolumes(): - with contextlib.suppress(libvirtError): - if vol.path() not in own: - used.update(etree.fromstring(vol.XMLDesc(0)).xpath("./backingStore/path/text()")) - return used + """Paths another domain uses (see paths_in_use). The volumes in own + are this VM's: their backing files do not count.""" + return self.paths_in_use(self.instance.UUIDString(), own) def split_disk_paths_by_use(self): """This VM's disk paths, split into (only this VM, also used by another diff --git a/vrtManager/storage.py b/vrtManager/storage.py index 0037de22..60a8bf8d 100644 --- a/vrtManager/storage.py +++ b/vrtManager/storage.py @@ -238,7 +238,10 @@ class wvmStorage(wvmConnect): return vol.XMLDesc(0) def del_volume(self, name): + """libvirt deletes any volume; one a VM still needs is refused.""" vol = self.pool.storageVolLookupByName(name) + if vol.path() in self.paths_in_use(): + raise util.OperationError(f"Volume {name} is in use: a VM's disk, a backing file or a snapshot's file") vol.delete(0) def get_volume_format_type(self, name): diff --git a/vrtManager/tests/test_volume_in_use.py b/vrtManager/tests/test_volume_in_use.py new file mode 100644 index 00000000..5c2fb738 --- /dev/null +++ b/vrtManager/tests/test_volume_in_use.py @@ -0,0 +1,138 @@ +"""A volume a VM still needs is never deleted from a storage pool: not a +disk, not a backing file, not a file of a snapshot. libvirt deletes any +volume it is asked to; the live cases are in instances/tests/test_live_dataloss.py.""" + +import unittest +from unittest.mock import MagicMock + +import libvirt +from django.conf import settings +from libvirt import libvirtError + +if not settings.configured: + settings.configure(MAC_OUI="52:54:10") + +from vrtManager import util +from vrtManager.storage import wvmStorage + + +def domain(uuid, disks=(), snapshots=()): + dom = MagicMock() + dom.UUIDString.return_value = uuid + devices = "".join(f"" for d in disks) + dom.XMLDesc.return_value = f"{devices}" + snaps = [] + for xml in snapshots: + snap = MagicMock() + snap.getXMLDesc.return_value = xml + snaps.append(snap) + dom.listAllSnapshots.return_value = snaps + return dom + + +def volume(path, backing=None): + vol = MagicMock() + vol.path.return_value = path + parent = f"{backing}" if backing else "" + vol.XMLDesc.return_value = f"{parent}" + return vol + + +def storage(domains=(), volumes=()): + conn = wvmStorage.__new__(wvmStorage) + conn.wvm = MagicMock() + conn.wvm.listAllDomains.return_value = list(domains) + pool = MagicMock(**{"isActive.return_value": True}) + pool.listAllVolumes.return_value = list(volumes) + by_name = {v.path().rsplit("/", 1)[1]: v for v in volumes} + pool.storageVolLookupByName.side_effect = lambda name: by_name[name] + conn.wvm.listAllStoragePools.return_value = [pool] + conn.pool = pool + return conn, by_name + + +SNAPSHOT = ( + "s" + "" + "" + "" +) + + +class PathsInUseTestCase(unittest.TestCase): + def test_disks_backing_files_and_snapshot_files_are_in_use(self): + conn, _ = storage( + domains=[domain("u1", ["/pool/vm.now"], [SNAPSHOT])], + volumes=[volume("/pool/clone.qcow2", backing="/pool/template.qcow2")], + ) + self.assertEqual( + conn.paths_in_use(), + {"/pool/vm.now", "/pool/vm.s", "/pool/vm.s.mem", "/pool/vm.qcow2", "/pool/template.qcow2"}, + ) + + def test_one_domain_and_the_backing_files_of_own_volumes_can_be_left_out(self): + conn, _ = storage( + domains=[domain("u1", ["/pool/a.qcow2"]), domain("u2", ["/pool/b.qcow2"])], + volumes=[volume("/pool/a.qcow2", backing="/pool/base.qcow2")], + ) + self.assertEqual(conn.paths_in_use(skip_uuid="u1", own=["/pool/a.qcow2"]), {"/pool/b.qcow2"}) + + + def test_a_network_disk_is_in_use_by_its_name(self): + dom = domain("u1") + dom.XMLDesc.return_value = ( + "" + "" + ) + conn, _ = storage(domains=[dom]) + self.assertIn("rbdpool/disk", conn.paths_in_use()) + + def test_a_pool_volume_disk_is_in_use_by_its_path(self): + for xml in ( + "{}", + "", # in a snapshot's saved definition + ): + with self.subTest(xml): + disk = "" + dom = domain("u1") + if "{}" in xml: + dom.XMLDesc.return_value = xml.format(disk) + else: + snap = MagicMock() + snap.getXMLDesc.return_value = f"{disk}" + dom.listAllSnapshots.return_value = [snap] + conn, _ = storage(domains=[dom], volumes=[volume("/pool/disk.qcow2")]) + conn.wvm.storagePoolLookupByName.return_value = conn.pool + self.assertIn("/pool/disk.qcow2", conn.paths_in_use()) + + def test_a_volume_that_cannot_be_read_is_not_taken_as_unused(self): + removed, unreadable = volume("/pool/gone.qcow2"), volume("/pool/top.qcow2") + error = libvirtError("removed") + error.err = (libvirt.VIR_ERR_NO_STORAGE_VOL,) + removed.XMLDesc.side_effect = error + conn, _ = storage(volumes=[removed]) + self.assertEqual(conn.paths_in_use(), set()) + unreadable.XMLDesc.side_effect = libvirtError("permission denied") + conn, _ = storage(volumes=[unreadable]) + with self.assertRaises(libvirtError): + conn.paths_in_use() + + +class DeleteVolumeTestCase(unittest.TestCase): + def test_a_volume_in_use_is_refused(self): + for case, domains, volumes in ( + ("disk", [domain("u1", ["/pool/disk.qcow2"])], [volume("/pool/disk.qcow2")]), + ("backing file", [], [volume("/pool/disk.qcow2"), volume("/pool/top.qcow2", backing="/pool/disk.qcow2")]), + ("snapshot point", [domain("u1", ["/pool/vm.s"], [SNAPSHOT.replace("vm.qcow2", "disk.qcow2")])], + [volume("/pool/disk.qcow2")]), + ): + with self.subTest(case): + conn, vols = storage(domains, volumes) + with self.assertRaisesRegex(util.OperationError, "disk.qcow2 is in use"): + conn.del_volume("disk.qcow2") + vols["disk.qcow2"].delete.assert_not_called() + + def test_an_unused_volume_is_deleted(self): + conn, vols = storage([domain("u1", ["/pool/other.qcow2"])], [volume("/pool/disk.qcow2")]) + conn.del_volume("disk.qcow2") + vols["disk.qcow2"].delete.assert_called_once_with(0)