mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
The delete ran in the request: undefine, then the VM's record, then its disk files. A request that died in between (a gunicorn worker past its timeout, OOM, a restart), or a database write that failed after the undefine, left a record that later showed as removed outside the panel, and the disk files the user asked to delete were never deleted. Deleting a VM is now an instance.delete job. It holds the compute exclusively, as the request did, so no other VM attaches a file it is about to delete. The job chooses the files before it stops the VM (a host that cannot be checked still stops nothing) and records them first. It then stops and undefines the VM, removes its record, and deletes the files, each with its own Logs row. Recovery decides from libvirt: a VM still on its host means the delete failed; a VM gone means libvirt did it, so recovery removes the record and deletes the recorded files that are still there. The page queues the job and returns to the VM page, where the job panel follows it and opens the VM list when it ends; disks that were kept are the result's warning. The API's delete answers 202 with the job and needs a submission_key. backing_path moves to wvmConnect, as recovery has no domain to ask.
218 lines
12 KiB
Python
218 lines
12 KiB
Python
"""Operations on a VM write one row each: done, or failed with the reason,
|
|
named by the VM and carrying its UUID; refused input writes nothing."""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import libvirt
|
|
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 libvirtError
|
|
from logs.models import Logs
|
|
from vrtManager.util import OperationError
|
|
|
|
UUID = "11111111-2222-3333-4444-555555555555"
|
|
|
|
|
|
class VMRowTests(TestCase):
|
|
def setUp(self):
|
|
self.client.force_login(get_user_model().objects.create_superuser("vm-admin", "vm@example.com", "pw"))
|
|
self.client.raise_request_exception = False
|
|
compute = Compute.objects.create(name="c1", hostname="192.0.2.1", login="", password="", type=4)
|
|
self.vm = Instance.objects.create(compute=compute, name="vm", uuid=UUID)
|
|
Logs.objects.all().delete() # the login's row
|
|
patcher = patch("instances.models.wvmInstance")
|
|
self.proxy = patcher.start().return_value
|
|
self.addCleanup(patcher.stop)
|
|
self.proxy.instance.isActive.return_value = False
|
|
|
|
def post(self, view, data=None, args=None):
|
|
return self.client.post(reverse(f"instances:{view}", args=args or [self.vm.pk]), data or {},
|
|
REMOTE_ADDR="192.0.2.50")
|
|
|
|
def rows(self):
|
|
return list(Logs.objects.order_by("id").values_list("message", "success"))
|
|
|
|
def test_a_done_row_names_the_vm_and_carries_its_uuid(self):
|
|
self.post("poweron")
|
|
row = Logs.objects.get()
|
|
self.assertEqual((row.message, row.success), ("VM vm: power on", True))
|
|
self.assertEqual((row.user, row.host, row.instance, row.instance_uuid, row.ip),
|
|
("vm-admin", "c1", "vm", UUID, "192.0.2.50"))
|
|
|
|
def test_a_raised_failure_writes_a_failed_row(self):
|
|
self.proxy.start.side_effect = libvirtError("Requested operation is not valid")
|
|
self.post("poweron")
|
|
self.assertEqual(self.rows(), [("VM vm: power on: Requested operation is not valid", False)])
|
|
|
|
def test_a_caught_failure_writes_a_failed_row(self):
|
|
self.proxy.snapshot_delete.side_effect = libvirtError("snapshot not found")
|
|
self.post("delete_snapshot", {"name": "snap1"})
|
|
self.proxy.snapshot_delete.side_effect = None
|
|
self.post("delete_snapshot", {"name": "snap1"})
|
|
self.assertEqual(self.rows(), [("VM vm: delete snapshot snap1: snapshot not found", False),
|
|
("VM vm: delete snapshot snap1", True)])
|
|
|
|
def test_refused_input_writes_nothing(self):
|
|
self.post("resizevm_cpu", {"vcpu": "x", "cur_vcpu": "1"})
|
|
self.post("set_qos", {"qos_direction": "sideways", "net-mac-0": "52:54:00:00:00:01"})
|
|
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
|
self.post("poweron")
|
|
self.assertEqual(self.rows(), [])
|
|
|
|
def test_an_empty_root_password_is_refused_with_a_message(self):
|
|
response = self.post("rootpasswd", {"passwd": ""})
|
|
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], ["Enter a password"])
|
|
self.assertEqual(self.rows(), [])
|
|
|
|
def test_qos_is_logged(self):
|
|
self.post("set_qos", {"qos_direction": "inbound", "net-mac-0": "52:54:00:00:00:01", "qos_average": "100"})
|
|
self.post("unset_qos", {"qos_direction": "inbound", "net-mac": "52:54:00:00:00:01"})
|
|
self.assertEqual(self.rows(), [("VM vm: set inbound QoS on interface 52:54:00:00:00:01", True),
|
|
("VM vm: remove inbound QoS from interface 52:54:00:00:00:01", True)])
|
|
|
|
def test_a_gstfsd_error_reply_is_a_failed_row_without_the_password(self):
|
|
self.proxy.get_status.return_value = 5
|
|
with patch("instances.views.gstfsd_request", side_effect=OperationError("gstfsd error: timed out")):
|
|
self.post("rootpasswd", {"passwd": "S3cret-pass"})
|
|
with patch("instances.views.gstfsd_request", return_value={"return": "success"}):
|
|
self.post("rootpasswd", {"passwd": "S3cret-pass"})
|
|
self.assertEqual(self.rows(), [("VM vm: reset root password: gstfsd error: timed out", False),
|
|
("VM vm: reset root password", True)])
|
|
|
|
def test_an_agent_error_is_a_failed_row_without_the_password(self):
|
|
self.proxy.get_status.return_value = 1
|
|
self.proxy.is_agent_ready.return_value = True
|
|
self.proxy.set_user_password.side_effect = libvirtError("guest agent didn't respond to command within '10' seconds")
|
|
self.post("rootpasswd", {"passwd": "S3cret-pass"})
|
|
self.assertEqual(self.rows(), [
|
|
("VM vm: reset root password: guest agent didn't respond to command within '10' seconds", False)])
|
|
self.assertFalse(Logs.objects.filter(message__contains="$6$").exists())
|
|
|
|
def test_console_password_rows_never_hold_the_password(self):
|
|
self.proxy.get_console_type.return_value = "vnc"
|
|
self.proxy.console_listens_locally.return_value = True
|
|
self.proxy.set_console_passwd.return_value = False # no graphics device
|
|
form = {"listen_on": "127.0.0.1", "keymap": "auto", "password_action": "set"}
|
|
self.post("update_console", {**form, "password": "Vnc-pw-1"})
|
|
self.proxy.set_console_passwd.return_value = True
|
|
self.post("update_console", {**form, "password": "Vnc-pw-2"})
|
|
self.assertNotIn("Vnc-pw", str(self.rows()))
|
|
rows = [row for row in self.rows() if "password" in row[0]]
|
|
self.assertEqual(rows, [("VM vm: set console password: The VM has no graphics device", False),
|
|
("VM vm: set console password", True)])
|
|
|
|
def test_a_console_setting_that_changed_nothing_writes_no_row(self):
|
|
self.proxy.get_console_type.return_value = "vnc"
|
|
self.proxy.get_console_listener_addr.return_value = "127.0.0.1"
|
|
self.proxy.get_console_passwd.return_value = ""
|
|
self.proxy.get_console_keymap.return_value = ""
|
|
self.proxy.set_console_keymap.return_value = False # no graphics device
|
|
self.proxy.set_console_listener_addr.return_value = False # a socket, no address
|
|
form = {"listen_on": "0.0.0.0", "keymap": "de", "password_action": "keep"}
|
|
self.post("update_console", form)
|
|
self.proxy.set_console_keymap.return_value = None # the define's result
|
|
self.proxy.set_console_listener_addr.return_value = None
|
|
self.post("update_console", form)
|
|
self.assertEqual(self.rows(), [("VM vm: set console keymap de", True),
|
|
("VM vm: set console listen address 0.0.0.0", True)])
|
|
|
|
def test_auto_removes_the_keymap(self):
|
|
# no "Clear keymap" box: Auto removes it, never writes an empty keymap
|
|
self.proxy.get_console_type.return_value = "vnc"
|
|
self.proxy.get_console_listener_addr.return_value = "127.0.0.1"
|
|
self.proxy.get_console_passwd.return_value = ""
|
|
self.proxy.get_console_keymap.return_value = "de"
|
|
self.proxy.set_console_keymap.return_value = None
|
|
self.post("update_console", {"listen_on": "127.0.0.1", "keymap": "auto", "password_action": "keep"})
|
|
self.proxy.set_console_keymap.assert_called_once_with("auto")
|
|
self.assertEqual(self.rows(), [("VM vm: remove console keymap", True)])
|
|
|
|
def test_a_console_save_logs_only_what_changed(self):
|
|
# an address the form does not list stays: the select offers it
|
|
self.proxy.get_console_type.return_value = "vnc"
|
|
self.proxy.get_console_listener_addr.return_value = "192.0.2.10"
|
|
self.proxy.get_console_keymap.return_value = "de"
|
|
self.proxy.get_console_passwd.return_value = "Vnc-pw-1"
|
|
form = {"listen_on": "192.0.2.10", "keymap": "de"}
|
|
self.post("update_console", {**form, "password_action": "keep"})
|
|
self.post("update_console", {**form, "password_action": "set", "password": "Vnc-pw-2"})
|
|
self.assertEqual(self.rows(), [("VM vm: set console password", True)])
|
|
self.proxy.set_console_listener_addr.assert_not_called()
|
|
|
|
def test_a_disk_delete_logs_the_detach_and_the_volume(self):
|
|
disk = {"dev": "vdb", "path": "/pool/data.qcow2", "storage": "default", "image": "data.qcow2"}
|
|
self.proxy.get_disk_devices.return_value = [disk]
|
|
self.proxy.paths_used_by_other_domains.return_value = set()
|
|
with patch("instances.views.wvmStorage"):
|
|
self.proxy.wait_disk_detached.return_value = False
|
|
self.post("delete_vol", {"dev": "vdb"})
|
|
self.proxy.wait_disk_detached.return_value = True
|
|
self.post("delete_vol", {"dev": "vdb"})
|
|
self.assertEqual(self.rows(), [
|
|
("VM vm: detach disk vdb", True),
|
|
("VM vm: delete volume default/data.qcow2: the guest has not released the disk", False),
|
|
("VM vm: detach disk vdb", True),
|
|
("VM vm: delete volume default/data.qcow2", True),
|
|
])
|
|
|
|
|
|
class DefineFromXmlRowTests(TestCase):
|
|
def post(self, xml):
|
|
conn = libvirt.open("test:///default")
|
|
self.addCleanup(conn.close)
|
|
compute = Compute.objects.create(name="testdrv", hostname="localhost", login="", password="", type=4)
|
|
self.client.force_login(get_user_model().objects.create_superuser("xml", "xml@example.com", "pw"))
|
|
Logs.objects.all().delete()
|
|
with patch("vrtManager.connection.connection_manager.get_connection", return_value=conn):
|
|
self.client.post(reverse("instances:create_instance_select_type", args=[compute.id]),
|
|
{"create_xml": "", "dom_xml": xml})
|
|
return Logs.objects.get()
|
|
|
|
def test_a_defined_vm_has_its_uuid(self):
|
|
row = self.post("<domain type='test'><name>from-xml</name><memory>1024</memory>"
|
|
"<os><type>hvm</type></os></domain>")
|
|
self.assertEqual((row.message, row.success), ("VM from-xml: define from XML", True))
|
|
self.assertEqual(row.instance_uuid, Instance.objects.get(name="from-xml").uuid)
|
|
|
|
def test_a_refused_definition_is_a_failed_row(self):
|
|
row = self.post("<domain type='nope'><name>new</name><memory>1024</memory></domain>")
|
|
self.assertFalse(row.success)
|
|
self.assertTrue(row.message.startswith("VM new: define from XML: "))
|
|
|
|
|
|
class JobRowTests(TestCase):
|
|
def test_vm_jobs_carry_the_uuid_of_their_vm(self):
|
|
from operations.jobs import get_kind
|
|
from operations.models import Operation
|
|
|
|
new = {"compute": 1, "uuid": UUID, "name": "web-2"}
|
|
cases = [
|
|
("instance.create", "c1/web-2", {}, new, ("c1", "web-2", UUID, "VM web-2: create")),
|
|
("instance.clone", "c1/web-1", {}, new, ("c1", "web-2", UUID, "VM web-2: clone from web-1")),
|
|
("snapshot.delete", "c1/web-1", {"name": "s1"}, {"uuid": UUID},
|
|
("c1", "web-1", UUID, "VM web-1: delete external snapshot s1 (failed)")),
|
|
]
|
|
for kind, label, params, intent, entry in cases:
|
|
with self.subTest(kind=kind):
|
|
op = Operation(kind=kind, resource_label=label, params=params, intent=intent)
|
|
state = Operation.FAILED if kind == "snapshot.delete" else Operation.SUCCEEDED
|
|
self.assertEqual(get_kind(kind).log_entry(op, state), entry)
|
|
|
|
|
|
class MigrationResolutionRowTests(TestCase):
|
|
def test_a_resolved_migration_carries_the_vm_uuid(self):
|
|
from operations import jobs
|
|
from operations.models import Operation
|
|
|
|
admin = get_user_model().objects.create_superuser("res-admin", "res@example.com", "pw")
|
|
op = Operation.objects.create(kind="instance.migrate", user=admin, username=admin.username,
|
|
submission_key="key-res-00000001", resource="instance:" + UUID,
|
|
resource_label="c1/vm", state=Operation.UNCERTAIN,
|
|
intent={"compute": 1, "uuid": UUID})
|
|
jobs.resolve(op.pk, admin, Operation.SUCCEEDED, "the VM runs on the target")
|
|
row = Logs.objects.get(message__contains="resolved")
|
|
self.assertEqual((row.instance, row.instance_uuid, row.success), ("c1/vm", UUID, True))
|