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