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 dbc7e688ac feat(instances): a shut-off VM with a TPM migrates with its TPM state
A shut-off VM with TPM state, as every Windows 11 VM, was refused for
migration: the state is a file of the source host, and an offline move
would start it there with a new TPM. Such a VM now migrates as a shut-off
VM with disks to copy does: its UEFI variables are copied, it is started
paused, its CPUs never run, migrates live, which carries the TPM state,
and is shut off on the destination. A shut-off VM with a saved state is
refused before any of that, for every way it moves: started paused, it
would resume and then lose the saved memory.

The clone tab says that a clone gets a new, empty TPM, and the destroy
page that the TPM state goes with the VM.
2026-11-01 18:29:53 +03:00

233 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_says_the_tpm_state_goes(self):
for tpm, shown in (("emulator", True), (None, False), ("custom", False)):
with self.subTest(tpm=tpm), patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_status.return_value = 5
wvm.return_value.get_tpm.return_value = tpm
response = self.client.get(reverse("instances:destroy", args=[self.instance.id]))
self.assertEqual(b"Its TPM state is deleted" in response.content, shown)
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"])