1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/instances/tests/test_destroy.py
catborise 54a1943e26 fix(instances): a VM's destruction checks its files on every host first
Destroying a VM with its disk files forced it off first and then
worked out which files to delete, so a check that failed left the VM
stopped, and only this host's VMs and volumes were checked: a file on
shared storage that a VM on another host uses was deleted.

The files are now worked out before the VM is stopped: the disks it
runs with and those of its next start, as before with their snapshot
files and seed. A file a VM or volume of another of the panel's hosts
uses at the same path (shared storage is mounted at one path on every
host, as migration needs) is kept like one used here; another record
of the VM's own host is skipped, as there the VM itself uses them. A
host that cannot be read stops the destruction with its name, before
anything is stopped or deleted.
2026-10-31 01:08:42 +03:00

225 lines
13 KiB
Python

from contextlib import contextmanager
from unittest.mock import MagicMock, 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 instances.utils import paths_used_on_other_computes
from libvirt import VIR_DOMAIN_UNDEFINE_KEEP_NVRAM, VIR_DOMAIN_UNDEFINE_NVRAM, libvirtError
from vrtManager.util import OperationError
class DestroyOrderTestCase(TestCase):
def setUp(self):
admin = get_user_model().objects.create_superuser("destroy_admin", "d@example.com", "pw")
self.client.force_login(admin)
self.client.raise_request_exception = False
compute = Compute.objects.create(name="destroy-compute", hostname="127.0.0.1", login="", password="", type=4)
self.instance = Instance.objects.create(
compute=compute, name="destroy-vm", uuid="11111111-2222-3333-4444-555555555555"
)
def destroy(self, undefine_error=None, data=None):
with patch("instances.models.wvmInstance") as wvm:
proxy = wvm.return_value
proxy.instance.isActive.return_value = False
proxy.split_disk_paths_by_use.return_value = (["/pool/own.qcow2"], ["/pool/shared.qcow2"])
proxy.delete.side_effect = undefine_error
self.client.post(reverse("instances:destroy", args=[self.instance.id]), data or {"delete_disk": "1"})
return proxy
def test_the_nvram_goes_only_when_asked(self):
for data, wanted, unwanted in (
({"delete_nvram": "1"}, VIR_DOMAIN_UNDEFINE_NVRAM, VIR_DOMAIN_UNDEFINE_KEEP_NVRAM),
({}, VIR_DOMAIN_UNDEFINE_KEEP_NVRAM, VIR_DOMAIN_UNDEFINE_NVRAM),
):
with self.subTest(data=data):
Instance.objects.get_or_create(
compute=self.instance.compute, name="destroy-vm", uuid="11111111-2222-3333-4444-555555555555"
)
self.instance = Instance.objects.get(uuid="11111111-2222-3333-4444-555555555555")
flags = self.destroy(data=data).delete.call_args[0][0]
self.assertTrue(flags & wanted)
self.assertFalse(flags & unwanted)
def test_the_api_takes_its_options_as_booleans(self):
url = f"/api/v1/computes/{self.instance.compute_id}/instances/{self.instance.id}/"
for body, content_type, deleted in (
({"delete_disk": True}, "application/json", True),
("delete_disk=false", "application/x-www-form-urlencoded", False),
):
with self.subTest(body=body):
Instance.objects.get_or_create(
compute=self.instance.compute, name="destroy-vm", uuid="11111111-2222-3333-4444-555555555555"
)
self.instance = Instance.objects.get(uuid="11111111-2222-3333-4444-555555555555")
with patch("instances.models.wvmInstance") as wvm:
proxy = wvm.return_value
proxy.instance.isActive.return_value = False
proxy.split_disk_paths_by_use.return_value = (["/pool/own.qcow2"], [])
url = f"/api/v1/computes/{self.instance.compute_id}/instances/{self.instance.id}/"
response = self.client.delete(url, body, content_type=content_type)
self.assertEqual(response.status_code, 200)
self.assertEqual(proxy.get_volume_by_path.called, deleted)
def test_a_disk_file_that_stays_keeps_the_files_it_needs(self):
# the overlay is not deleted: the file below it would leave it broken
volumes = {}
def volume(path):
vol = volumes.setdefault(path, MagicMock())
if path == "/pool/vm.snap1":
vol.delete.side_effect = libvirtError("Permission denied")
return vol
with patch("instances.models.wvmInstance") as wvm:
proxy = wvm.return_value
proxy.instance.isActive.return_value = False
proxy.split_disk_paths_by_use.return_value = (["/pool/vm.snap1", "/pool/vm.qcow2", "/pool/vm-data.qcow2"], [])
proxy.get_volume_by_path.side_effect = volume
proxy.backing_path.side_effect = {"/pool/vm.snap1": "/pool/vm.qcow2"}.get
response = self.client.post(reverse("instances:destroy", args=[self.instance.id]), {"delete_disk": "true"})
self.assertNotIn("/pool/vm.qcow2", volumes)
volumes["/pool/vm-data.qcow2"].delete.assert_called_once_with(0)
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)][-1],
"Disk /pool/vm.qcow2 was kept: a disk file that stays needs it")
def test_when_the_files_a_kept_one_needs_are_unknown_none_of_the_rest_goes(self):
with patch("instances.models.wvmInstance") as wvm:
proxy = wvm.return_value
proxy.instance.isActive.return_value = False
proxy.split_disk_paths_by_use.return_value = (["/pool/a.qcow2", "/pool/b.qcow2"], [])
proxy.get_volume_by_path.return_value.delete.side_effect = libvirtError("Permission denied")
proxy.backing_path.side_effect = libvirtError("pool unreachable")
response = self.client.post(reverse("instances:destroy", args=[self.instance.id]), {"delete_disk": "true"})
self.assertEqual(response.status_code, 302)
proxy.get_volume_by_path.assert_called_once_with("/pool/a.qcow2")
def test_the_files_are_checked_before_the_vm_is_stopped(self):
with patch("instances.models.wvmInstance") as wvm:
proxy = wvm.return_value
proxy.instance.isActive.return_value = True
proxy.split_disk_paths_by_use.return_value = (["/pool/own.qcow2"], [])
self.client.post(reverse("instances:destroy", args=[self.instance.id]), {"delete_disk": "true"})
calls = [name for name, *_ in proxy.mock_calls if name in ("split_disk_paths_by_use", "force_shutdown", "delete")]
self.assertEqual(calls, ["split_disk_paths_by_use", "force_shutdown", "delete"])
def test_a_host_that_cannot_be_checked_stops_nothing(self):
Compute.objects.create(name="other-host", hostname="192.0.2.50", login="u", password="p", type=1)
def connect(host, *args):
if host == "192.0.2.50":
raise libvirtError("unreachable")
return MagicMock()
with patch("instances.models.wvmInstance") as wvm, patch("instances.utils.wvmConnect", side_effect=connect):
proxy = wvm.return_value
proxy.instance.isActive.return_value = True
proxy.split_disk_paths_by_use.side_effect = lambda elsewhere: elsewhere(["/pool/own.qcow2"])
response = self.client.post(reverse("instances:destroy", args=[self.instance.id]), {"delete_disk": "true"})
proxy.force_shutdown.assert_not_called()
proxy.delete.assert_not_called()
self.assertTrue(Instance.objects.filter(pk=self.instance.pk).exists())
self.assertIn("The disk files cannot be checked on other-host", str(list(get_messages(response.wsgi_request))[-1]))
def test_disks_are_deleted_after_the_undefine(self):
proxy = self.destroy()
proxy.get_volume_by_path.assert_called_once_with("/pool/own.qcow2")
self.assertFalse(Instance.objects.filter(pk=self.instance.pk).exists())
def test_failed_undefine_deletes_no_disk(self):
proxy = self.destroy(undefine_error=libvirtError("refused"))
proxy.get_volume_by_path.assert_not_called()
self.assertTrue(Instance.objects.filter(pk=self.instance.pk).exists())
def test_destroy_and_delete_vol_hold_the_compute_exclusively(self):
calls = []
@contextmanager
def record(compute, *, shared=False):
calls.append(shared)
yield
with patch("instances.views.libvirt_compute_lock", record):
with patch("instances.models.wvmInstance"):
self.client.post(reverse("instances:delete_vol", args=[self.instance.id]), {"dev": "vda"})
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 = []
@contextmanager
def record(compute, *, shared=False):
calls.append(shared)
yield
with patch("instances.views.libvirt_compute_lock", record), patch("instances.models.wvmInstance"):
response = self.client.get(reverse("instances:destroy", args=[self.instance.id]))
self.assertEqual(response.status_code, 200)
self.assertEqual(calls, [])
def test_the_confirmation_page_of_a_paused_vm_offers_the_form(self):
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_status.return_value = 3 # paused
response = self.client.get(reverse("instances:destroy", args=[self.instance.id]))
self.assertIn('id="delete_form"', response.content.decode())
class OtherComputesTestCase(TestCase):
"""The files of a VM another host's VM or volume uses: shared storage
holds them at the same path on every host that mounts it."""
def setUp(self):
self.source = Compute.objects.create(name="source", hostname="192.0.2.60", login="u", password="p", type=1)
Compute.objects.create(name="host-b", hostname="192.0.2.61", login="u", password="p", type=1)
Compute.objects.create(name="host-c", hostname="192.0.2.62", login="u", password="p", type=1)
def test_the_paths_another_host_uses(self):
hosts = self.hosts({"192.0.2.61": {"/nfs/a.qcow2", "/nfs/unrelated.qcow2"}, "192.0.2.62": {"/nfs/b.qcow2"}})
own = ["/nfs/a.qcow2", "/nfs/b.qcow2", "/local/c.qcow2"]
with patch("instances.utils.wvmConnect", side_effect=lambda host, *args: hosts[host]):
self.assertEqual(paths_used_on_other_computes(self.source, own), {"/nfs/a.qcow2", "/nfs/b.qcow2"})
# the VM's own host is checked by split_disk_paths_by_use itself; on the
# others too, the backing files of the VM's own volumes do not count
hosts["192.0.2.60"].paths_in_use.assert_not_called()
hosts["192.0.2.61"].paths_in_use.assert_called_once_with(own=own)
hosts["192.0.2.62"].paths_in_use.assert_called_once_with(own=own)
def test_another_record_of_the_vm_s_own_host_is_skipped(self):
# there the VM itself uses its files: they would all be kept
hosts = self.hosts({"192.0.2.61": {"/pool/a.qcow2"}, "192.0.2.62": set()}, same_host="192.0.2.61")
with patch("instances.utils.wvmConnect", side_effect=lambda host, *args: hosts[host]):
self.assertEqual(paths_used_on_other_computes(self.source, ["/pool/a.qcow2"]), set())
hosts["192.0.2.61"].paths_in_use.assert_not_called()
def hosts(self, used, same_host=None):
"""Connections by host name; same_host is the source host under another record."""
hosts = {"192.0.2.60": MagicMock(**{"host_uuid.return_value": "aaaaaaaa-0000-0000-0000-000000000060"})}
for n, (host, paths) in enumerate(used.items()):
uuid = "aaaaaaaa-0000-0000-0000-000000000060" if host == same_host else f"bbbbbbbb-0000-0000-0000-00000000000{n}"
hosts[host] = MagicMock(**{"host_uuid.return_value": uuid, "paths_in_use.return_value": paths})
return hosts
def test_a_host_that_cannot_be_read_is_an_error(self):
hosts = self.hosts({"192.0.2.61": set(), "192.0.2.62": set()})
hosts["192.0.2.61"].paths_in_use.side_effect = libvirtError("connection refused")
with patch("instances.utils.wvmConnect", side_effect=lambda host, *args: hosts[host]):
with self.assertRaisesMessage(OperationError, "The disk files cannot be checked on host-b"):
paths_used_on_other_computes(self.source, ["/nfs/a.qcow2"])