mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
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.
225 lines
13 KiB
Python
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"])
|