1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/instances/tests/test_pending_edits.py
catborise ff1508b3c2 feat(instances): external snapshots use libvirt's snapshot API
Create, revert and delete are one libvirt call each. The pflash-to-rom
loader rewrite, the hand-written block commit and the s1./s2. names are
gone, so UEFI VMs can take external snapshots, a revert keeps disks added
later, and deleting an older snapshot keeps the newer one.

Create refuses what libvirt could not revert or delete later: internal
snapshots on the VM, a saved state, a running snapshot job, disks that
are not files, CD-ROM media, a branch after a revert, and memory without
a running VM or room in the first disk's pool. A disk-only revert keeps
the VM running, paused or shut off; one with memory resumes it as saved.
A revert in the same second as the previous one, which libvirt fails
after stopping the VM, is retried once.
A snapshot the old code took of a UEFI VM saved the loader as rom; its
revert is refused (deleting it still works).

The snapshots tab lists internal and external snapshots separately, can
save memory too, shows running snapshot jobs, and the revert confirmation
names the disks it detaches and that NVRAM is not restored.
2026-10-12 15:25:44 +03:00

137 lines
8.9 KiB
Python

"""On an active VM an edit changes the persistent definition and is pending
until the next start. The edit forms show that definition, so a save does
not post the running VM's value back over a pending change."""
from unittest.mock import PropertyMock, 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 lxml import html
from vrtManager import util
def disk(dev, cache, serial):
return {
"dev": dev, "bus": "virtio", "image": f"{dev}.qcow2", "storage": "default", "path": f"/pool/{dev}.qcow2",
"format": "qcow2", "backing_file": None, "size": 1 << 30, "used": 1 << 20, "cache": cache, "io": "default",
"discard": "default", "detect_zeroes": "default", "readonly": False, "shareable": False, "serial": serial,
}
class PendingEditsTestCase(TestCase):
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("pe-admin", "pe@example.com", "x"))
compute = Compute.objects.create(name="pe", hostname="127.0.0.1:1", login="root", password="", type=1)
self.instance = Instance.objects.create(compute=compute, name="pe-vm", uuid="aaaaaaaa-1111-2222-3333-444444444444")
def page(self, live=(), config=(), qos=({}, {}), status=1, **getters):
"""live and config: disks; qos and getters (get_vcpu=(2, 4)): values of
the live and the persistent definition."""
with patch("instances.models.wvmInstance") as wvm, patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
):
proxy = wvm.return_value
proxy.get_status.return_value = status
proxy.get_memory.return_value = proxy.get_cur_memory.return_value = 1024
proxy.get_max_memory.return_value = 4096
getters = {"get_vcpu": (1, 1), "get_cur_vcpu": (1, 1), "get_title": ("", ""),
"get_description": ("", ""), **getters}
for getter, values in getters.items():
getattr(proxy, getter).side_effect = lambda config=False, values=values: values[config]
proxy.get_max_cpus.return_value = [1, 2, 3, 4]
proxy.get_vcpus.return_value = {}
for getter in ("get_networks", "get_ifaces", "get_storages", "get_media_devices", "get_net_devices",
"get_snapshot", "get_snapshot_jobs"):
getattr(proxy, getter).return_value = []
proxy.get_disk_devices.side_effect = lambda **kw: config if kw.get("config") else live
proxy.get_all_qos.side_effect = lambda **kw: qos[1] if kw.get("config") else qos[0]
proxy.get_console_type.return_value = "vnc"
proxy.get_video_models.return_value = ["vga", "virtio"]
proxy.get_cache_modes.return_value = {"default": "Default", "none": "Disabled"}
response = self.client.get(reverse("instances:instance", args=[self.instance.id]))
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
def test_the_disk_edit_form_shows_the_pending_definition(self):
# running: cache=none and a new disk vdb pending, vda's serial live only
doc = self.page(
live=[disk("vda", "default", "live-serial")],
config=[disk("vda", "none", ""), disk("vdb", "default", "")],
)
forms = doc.xpath("//form[@action=$a]", a=reverse("instances:edit_volume", args=[self.instance.id]))
self.assertEqual([f.xpath("string(.//input[@name='dev']/@value)") for f in forms], ["vda", "vdb"])
vda = forms[0]
self.assertEqual(vda.xpath(".//select[@name='vol_cache']/option[@selected]/@value"), ["none"])
self.assertEqual(vda.xpath("string(.//input[@name='vol_serial']/@value)"), "")
def test_the_qos_form_shows_the_pending_definition(self):
mac = "52:54:10:00:00:01"
inbound = {"direction": "inbound", "average": "500", "peak": "0", "burst": "0", "floor": None}
doc = self.page(qos=({mac: [inbound]}, {mac: [{**inbound, "average": "1000"}, {**inbound, "direction": "outbound"}]}))
self.assertEqual(doc.xpath("//input[@name='qos_average']/@value"), ["1000", "500"])
# the pending outbound has a row
self.assertEqual([" ".join(t.split()) for t in doc.xpath("//label[@class='col-form-label']/text()")],
[f"{mac} Inbound", f"{mac} Outbound"])
def test_a_disk_with_a_pending_source_is_not_deleted(self):
# running: vda is A, its definition for the next start uses B
with patch("instances.models.wvmInstance") as wvm, patch("instances.views.wvmStorage") as storage:
proxy = wvm.return_value
live, pending = disk("vda", "default", ""), {**disk("vda", "default", ""), "path": "/pool/b.qcow2"}
proxy.get_disk_devices.side_effect = lambda **kw: [pending] if kw.get("config") else [live]
self.client.post(reverse("instances:delete_vol", args=[self.instance.id]), {"dev": "vda"})
proxy.detach_disk.assert_not_called()
storage.return_value.del_volume.assert_not_called()
def test_the_options_and_cpu_forms_show_the_pending_definition(self):
doc = self.page(get_vcpu=(2, 4), get_cur_vcpu=(1, 3), get_title=("old", "new"),
get_description=("live text", "pending text"))
self.assertEqual(doc.xpath("//input[@name='title']/@value"), ["new"])
self.assertEqual(doc.xpath("//textarea[@name='description']/text()"), ["pending text"])
self.assertEqual(doc.xpath("//select[@name='vcpu']/option[@selected]/@value"), ["4"])
self.assertEqual(doc.xpath("//select[@name='cur_vcpu']/option[@selected]/@value"), ["3"])
def test_buttons_shown_as_disabled_cannot_submit(self):
# running: the CPU, options, video and XML forms wait for a shutdown
doc = self.page(live=[disk("vda", "default", "")], config=[disk("vda", "default", "")])
shown = doc.xpath("//button[contains(concat(' ', @class, ' '), ' disabled ')]")
self.assertGreater(len(shown), 5)
self.assertEqual([b for b in shown if b.get("disabled") is None], [])
for action in ("resizevm_cpu", "change_options", "set_video_model", "change_xml", "detach_vol", "delete_vol"):
with self.subTest(action):
(form,) = doc.xpath("//form[@action=$a]", a=reverse(f"instances:{action}", args=[self.instance.id]))
self.assertTrue(all(b.get("disabled") is not None for b in form.xpath(".//button")))
def test_the_boot_video_and_console_forms_show_the_pending_definition(self):
cd = {"dev": "sda", "image": "pending.iso", "storage": "default", "path": "/pool/pending.iso", "bus": "sata"}
with patch("instances.models.Instance.config_networks", new_callable=PropertyMock, return_value=[]):
doc = self.page(
live=[disk("vda", "default", "")], config=[disk("vda", "default", ""), disk("vdb", "default", "")],
get_bootmenu=(False, True), get_bootorder=({}, {0: {"type": "file", "dev": "cdrom", "target": "sda"}}),
get_media_devices=([], [cd]), get_video_model=("vga", "virtio"),
get_console_listener_addr=("127.0.0.1", "0.0.0.0"), get_console_keymap=("", "de"),
get_console_passwd=("live-pw", "pending-pw"),
)
(boot,) = doc.xpath("//form[@action=$a]", a=reverse("instances:set_bootorder", args=[self.instance.id]))
self.assertEqual(boot.xpath(".//input[@type='checkbox']/@value"), ["disk:vda", "disk:vdb", "cdrom:sda"])
self.assertIn("1:sda", doc.xpath("string(//div[label[contains(., ':')]])"))
self.assertEqual(doc.xpath("//select[@name='video_model']/option[@selected]/@value"), ["virtio"])
self.assertEqual(doc.xpath("//select[@name='keymap']/option[@selected]/@value"), ["de"])
self.assertEqual(doc.xpath("//select[@name='listen_on']/option[@selected]/@value"), ["0.0.0.0"])
self.assertEqual(doc.xpath("//input[@name='password']/@value"), ["pending-pw"])
def test_a_boot_order_choice_not_in_the_definition_is_refused(self):
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.set_bootorder.side_effect = util.OperationError("Boot device vdz is not in the definition")
response = self.client.post(reverse("instances:set_bootorder", args=[self.instance.id]),
{"bootorder": "disk:vda,disk:vdz"})
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], ["Boot device vdz is not in the definition"])
def test_an_active_vm_says_its_settings_apply_at_the_next_start(self):
for status, shown in ((1, True), (3, True), (5, False)):
with self.subTest(status=status):
self.assertEqual(bool(self.page(status=status).xpath("//p[@id='next-start-note']")), shown)