1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00

feat(instances): the XML editor shows a change before saving it

Change used to define the edited XML at once. It now opens a dialog with
the lines the edit changes in the VM's definition, and Save there defines
it. A definition libvirt refuses comes back to the dialog with the reason,
and the edit stays in the editor; before, the page reloaded with the
current XML and the edit was lost. A definition that changed since the
editor was opened (another tab, a disk added) is not overwritten: the
dialog asks to reload. An unchanged XML has nothing to save.

change_xml answers the dialog with JSON; the editor's script moves to
static/js/xml_editor.js.
This commit is contained in:
catborise 2026-10-24 19:32:41 +03:00
parent 7cf12b997b
commit 3f329b50dd
7 changed files with 198 additions and 26 deletions

View file

@ -6,27 +6,80 @@ 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
CURRENT = "<domain>\n <name>xml-vm</name>\n <memory>1024</memory>\n</domain>"
EDITED = "<domain>\n <name>xml-vm</name>\n <memory>2048</memory>\n</domain>"
class ChangeXmlTestCase(TestCase):
"""The XML editor shows what a change does before it is saved, and saves
only over the definition it was opened with."""
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("xml_admin", "x@example.com", "pw"))
compute = Compute.objects.create(name="xml-compute", hostname="127.0.0.1", login="", password="", type=4)
self.instance = Instance.objects.create(compute=compute, name="xml-vm", uuid="11111111-2222-3333-4444-888888888888")
def post(self, status):
def post(self, status=5, xml=EDITED, base=CURRENT, confirm=False, define_error=None):
data = {"inst_xml": xml, "base": base}
if confirm:
data["confirm"] = "1"
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_status.return_value = status
response = self.client.post(reverse("instances:change_xml", args=[self.instance.id]), {"inst_xml": "<domain/>"})
wvm.return_value._XMLDesc.return_value = CURRENT
wvm.return_value._defineXML.side_effect = define_error
response = self.client.post(reverse("instances:change_xml", args=[self.instance.id]), data)
return response, wvm.return_value._defineXML
def test_shut_off_vm_gets_the_new_definition(self):
_, define = self.post(status=5)
define.assert_called_once_with("<domain/>")
def test_without_confirm_the_change_is_shown_not_saved(self):
response, define = self.post()
define.assert_not_called()
diff = response.json()["diff"]
self.assertIn("- <memory>1024</memory>", diff)
self.assertIn("+ <memory>2048</memory>", diff)
def test_an_unchanged_definition_shows_no_difference(self):
response, _ = self.post(xml=CURRENT)
self.assertEqual(response.json()["diff"], [])
def test_a_confirmed_change_is_saved_and_logged(self):
response, define = self.post(confirm=True)
define.assert_called_once_with(EDITED)
self.assertEqual(response.json(), {"saved": True})
self.assertIn("XML", " ".join(str(m) for m in get_messages(response.wsgi_request)))
row = Logs.objects.get(instance_uuid=self.instance.uuid)
self.assertEqual((row.message, row.success), ("VM xml-vm: change XML", True))
def test_a_definition_changed_since_the_editor_opened_is_not_overwritten(self):
for confirm in (False, True):
with self.subTest(confirm=confirm):
response, define = self.post(base="<domain>older</domain>", confirm=confirm)
define.assert_not_called()
self.assertEqual(response.status_code, 409)
self.assertIn("changed since", response.json()["error"])
def test_a_definition_libvirt_refuses_comes_back_with_the_reason(self):
response, define = self.post(confirm=True, define_error=libvirtError("unsupported configuration: bad memory"))
self.assertEqual(response.status_code, 400)
self.assertIn("bad memory", response.json()["error"])
row = Logs.objects.get(instance_uuid=self.instance.uuid)
self.assertEqual((row.message, row.success), ("VM xml-vm: change XML: unsupported configuration: bad memory", False))
def test_running_or_paused_vm_is_refused(self):
for status in (1, 3):
with self.subTest(status=status):
response, define = self.post(status)
response, define = self.post(status, confirm=True)
define.assert_not_called()
self.assertIn("Power off", " ".join(str(m) for m in get_messages(response.wsgi_request)))
self.assertEqual(response.status_code, 409)
self.assertIn("Power off", response.json()["error"])
def test_the_line_breaks_a_form_posts_are_the_same_definition(self):
response, _ = self.post(base=CURRENT.replace("\n", "\r\n"))
self.assertEqual(response.status_code, 200)
def test_an_empty_definition_is_refused(self):
response, define = self.post(xml=" ", confirm=True)
define.assert_not_called()
self.assertEqual(response.status_code, 400)

View file

@ -57,7 +57,7 @@ class ErrorHandlingTestCase(TestCase):
def test_redirects_without_or_with_a_foreign_referer_go_to_the_vm_page(self):
names = back_views()
self.assertGreaterEqual(len(names), 30)
self.assertGreaterEqual(len(names), 29)
for name in names:
for referer in (None, EXTERNAL):
with self.subTest(view=name, referer=referer), patch("instances.models.wvmInstance"), \

View file

@ -106,6 +106,13 @@ class PendingEditsTestCase(TestCase):
(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_xml_editor_reviews_a_change_only_while_the_vm_is_off(self):
# static/js/xml_editor.js opens the review dialog on Change
doc = self.page(status=5)
self.assertEqual(len(doc.xpath("//form[@id='xml-form']//button[@type='submit']")), 1)
self.assertEqual(len(doc.xpath("//div[@id='xml-review']//button[@id='xml-review-save']")), 1)
self.assertEqual(self.page().xpath("//div[@id='xml-review']"), [])
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=[]):