1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

feat(instances): a running VM's XML can be edited for its next start

The XML editor of a running or paused VM only showed its XML. It now
shows the persistent definition, the one the VM starts with next (with
secrets, like the other edit forms), and Change saves it through the same
review dialog; the running VM keeps its live configuration until it
restarts. The tab and the saved message say so. The check against a
definition changed since the editor was opened covers this case too.
This commit is contained in:
catborise 2026-10-24 19:44:10 +03:00
parent 3f329b50dd
commit ec679d4d7a
6 changed files with 49 additions and 55 deletions

View file

@ -6,12 +6,11 @@ from django.utils.translation import gettext_lazy as _
from libvirt import (
VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL,
VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL,
VIR_DOMAIN_XML_SECURE,
libvirtError,
)
from operations import claims
from operations.models import Claim
from vrtManager.instance import wvmInstance
from vrtManager.instance import PERSISTENT_XML, wvmInstance
from webvirtcloud.settings import QEMU_CONSOLE_LISTENER_ADDRESSES
@ -291,7 +290,8 @@ class Instance(models.Model):
@cached_property
def inst_xml(self):
return self.proxy._XMLDesc(VIR_DOMAIN_XML_SECURE)
# the definition for the next start, which is the current one while the VM is off
return self.proxy._XMLDesc(PERSISTENT_XML)
@cached_property
def has_managed_save_image(self):

View file

@ -656,46 +656,38 @@
</div>
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="xmledit">
{% if instance.status != 5 %}
<p>{% trans "If you need to edit XML please Power Off the instance" %}</p>
<p id="xml-next-start">{% trans "This is the definition for the VM's next start; a saved change applies then." %}</p>
{% endif %}
<form action="{% url 'instances:change_xml' instance.id %}" method="post" role="form" aria-label="Edit instance XML form" id="xml-form">
{% csrf_token %}
<div class="col-sm-12 xmlheight">
<textarea id="editor">{{ instance.inst_xml }}</textarea>
</div>
{% if instance.status == 5 %}
<button type="submit" class="btn btn-lg btn-success float-end" name="change_xml">
{% trans "Change" %}
</button>
{% else %}
<button class="btn btn-lg btn-success float-end disabled" disabled>
{% trans "Change" %}
</button>
{% endif %}
<button type="submit" class="btn btn-lg btn-success float-end" name="change_xml">
{% trans "Change" %}
</button>
</form>
<div class="clearfix"></div>
{% if instance.status == 5 %}
<div class="modal fade" id="xml-review" tabindex="-1" aria-labelledby="xml-review-title" aria-hidden="true"
data-failed="{% trans "The XML could not be checked. Try again." %}">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="xml-review-title">{% trans "Review the XML change" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger d-none" role="alert" id="xml-review-error"></div>
<p class="d-none" id="xml-review-same">{% trans "The XML is the same as the VM's definition; there is nothing to save." %}</p>
<pre class="d-none mb-0" id="xml-review-diff"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="button" class="btn btn-success" id="xml-review-save">{% trans "Save" %}</button>
</div>
<div class="modal fade" id="xml-review" tabindex="-1" aria-labelledby="xml-review-title" aria-hidden="true"
data-failed="{% trans "The XML could not be checked. Try again." %}">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="xml-review-title">{% trans "Review the XML change" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger d-none" role="alert" id="xml-review-error"></div>
<p class="d-none" id="xml-review-same">{% trans "The XML is the same as the VM's definition; there is nothing to save." %}</p>
<pre class="d-none mb-0" id="xml-review-diff"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="button" class="btn btn-success" id="xml-review-save">{% trans "Save" %}</button>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="users">
<div>

View file

@ -7,15 +7,18 @@ from django.test import TestCase
from django.urls import reverse
from instances.models import Instance
from libvirt import libvirtError
from vrtManager.instance import PERSISTENT_XML
from logs.models import Logs
CURRENT = "<domain>\n <name>xml-vm</name>\n <memory>1024</memory>\n</domain>"
LIVE = "<domain id='3'>\n <name>xml-vm</name>\n <memory>512</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."""
only over the definition it was opened with: the persistent one, which a
running VM uses at its next start."""
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("xml_admin", "x@example.com", "pw"))
@ -28,7 +31,7 @@ class ChangeXmlTestCase(TestCase):
data["confirm"] = "1"
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_status.return_value = status
wvm.return_value._XMLDesc.return_value = CURRENT
wvm.return_value._XMLDesc.side_effect = lambda flags: CURRENT if flags == PERSISTENT_XML else LIVE
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
@ -67,13 +70,12 @@ class ChangeXmlTestCase(TestCase):
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):
def test_a_running_or_paused_vm_gets_the_definition_for_its_next_start(self):
for status in (1, 3):
with self.subTest(status=status):
response, define = self.post(status, confirm=True)
define.assert_not_called()
self.assertEqual(response.status_code, 409)
self.assertIn("Power off", response.json()["error"])
define.assert_called_once_with(EDITED)
self.assertIn("next start", " ".join(str(m) for m in get_messages(response.wsgi_request)))
def test_the_line_breaks_a_form_posts_are_the_same_definition(self):
response, _ = self.post(base=CURRENT.replace("\n", "\r\n"))

View file

@ -96,22 +96,25 @@ class PendingEditsTestCase(TestCase):
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
# running: the CPU, options and video 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"):
for action in ("resizevm_cpu", "change_options", "set_video_model", "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_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_xml_editor_shows_the_definition_for_the_next_start(self):
# static/js/xml_editor.js opens the review dialog on Change, also while the VM runs
for status in (5, 1):
with self.subTest(status=status):
doc = self.page(status=status)
(button,) = doc.xpath("//form[@id='xml-form']//button[@type='submit']")
self.assertIsNone(button.get("disabled"))
self.assertEqual(len(doc.xpath("//div[@id='xml-review']//button[@id='xml-review-save']")), 1)
self.assertEqual(bool(doc.xpath("//p[@id='xml-next-start']")), status != 5)
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"}

View file

@ -1457,17 +1457,14 @@ def set_bootorder(request, pk):
def change_xml(request, pk):
"""
The XML editor's dialog: without confirm, the lines the edited XML changes
in the definition, to review; with confirm, the edited XML is defined.
in the persistent definition, to review; with confirm, the edited XML is
defined. A running VM uses it at its next start.
Only over the definition the editor was opened with (base), so a change
made since is not overwritten; a form posts its line breaks as CRLF. A refusal comes back to the dialog and the
edited XML stays in the editor.
"""
instance = get_instance(request.user, pk)
new_xml = request.POST.get("inst_xml", "")
# The editor is filled while the VM is shut off; once it runs, the posted
# XML would overwrite changes made since.
if instance.proxy.get_status() != 5:
return JsonResponse({"error": _("Power off the instance to change its XML")}, status=409)
if not new_xml.strip():
return JsonResponse({"error": _("The XML is empty")}, status=400)
current = instance.inst_xml
@ -1482,7 +1479,10 @@ def change_xml(request, pk):
instance.proxy._defineXML(new_xml)
except libvirtError as err:
return JsonResponse({"error": error_text(request, err)}, status=400)
messages.success(request, _("The XML definition was saved."))
if instance.proxy.get_status() == 5:
messages.success(request, _("The XML definition was saved."))
else:
messages.success(request, _("The XML definition was saved; the VM uses it from its next start."))
return JsonResponse({"saved": True})

View file

@ -10,9 +10,6 @@
editor.getSession().setMode("ace/mode/xml");
var base = editor.getValue(); // the definition the editor opened with
var dialog = document.getElementById("xml-review");
if (!dialog) {
return; // a running VM's editor only shows its XML
}
var modal = bootstrap.Modal.getOrCreateInstance(dialog);
var error = document.getElementById("xml-review-error");
var same = document.getElementById("xml-review-same");