From 1150b941bec5dde2ce7dedb2a8fa4c4527e5cc51 Mon Sep 17 00:00:00 2001 From: catborise Date: Fri, 30 Oct 2026 16:20:32 +0300 Subject: [PATCH] feat(instances): a VM's RNG device and crash action in its options Two settings the owner chose to add to Settings > Options after the page's review with Codex. Both change the persistent definition, so they are saved while the VM runs and used from its next start. - Random number generator: a switch for a virtio RNG device fed from the host's /dev/urandom, so a new VM does not wait for entropy at boot. Another RNG device, or the panel's with a rate or driver, is shown as the VM's own and left to the XML editor. - When the guest crashes: Restart or Stop (on_crash restart or destroy); libvirt's default, Stop, when the definition has none. Stop keeps a VM that crashes again and again down for a look. Any other action is shown and left to the XML editor. --- instances/models.py | 8 ++++ .../templates/instances/settings_tab.html | 43 +++++++++++++++++++ instances/tests/test_live_xml_effects.py | 10 +++++ instances/tests/test_pending_edits.py | 9 ++++ instances/urls.py | 2 + instances/views.py | 28 ++++++++++++ vrtManager/instance.py | 41 ++++++++++++++++++ vrtManager/tests/test_device_edits.py | 36 ++++++++++++++++ 8 files changed, 177 insertions(+) diff --git a/instances/models.py b/instances/models.py index f262d0d5..2c8ae0ce 100644 --- a/instances/models.py +++ b/instances/models.py @@ -275,6 +275,14 @@ class Instance(models.Model): """Whether the next start has hotpluggable vCPUs; the hot plug form shows it.""" return any(vcpu["hotpluggable"] == "yes" for vcpu in self.proxy.get_vcpus(config=True).values()) + @cached_property + def config_rng(self) -> str | None: + return self.proxy.get_rng() + + @cached_property + def config_on_crash(self) -> str: + return self.proxy.get_on_crash() + @cached_property def guest_agent_ready(self) -> bool: return self.proxy.is_agent_ready() diff --git a/instances/templates/instances/settings_tab.html b/instances/templates/instances/settings_tab.html index 078896a5..c1c72488 100644 --- a/instances/templates/instances/settings_tab.html +++ b/instances/templates/instances/settings_tab.html @@ -870,6 +870,49 @@ +
{% csrf_token %} +
+
+ +

{% trans "A virtio device that feeds the guest's entropy from the host: a new VM does not wait for it at boot. From the next start." %}

+
+
+
+ + +
+ {% if instance.config_rng == "custom" %} +

{% trans "The VM has its own RNG device: change it in the XML editor." %}

+ {% endif %} +
+
+
+
{% csrf_token %} +
+
+ +

{% trans "For a crash the guest reports, not a hang. Stop keeps a VM that crashes again and again down for a look. From the next start." %}

+
+
+ {% if instance.config_on_crash == "restart" or instance.config_on_crash == "destroy" %} +
+
+ +
+
+ +
+
+ {% else %} +

{% blocktrans with action=instance.config_on_crash %}{{ action }}: change it in the XML editor.{% endblocktrans %}

+ {% endif %} +
+
+
{% csrf_token %}
diff --git a/instances/tests/test_live_xml_effects.py b/instances/tests/test_live_xml_effects.py index 834f95df..8d382ff4 100644 --- a/instances/tests/test_live_xml_effects.py +++ b/instances/tests/test_live_xml_effects.py @@ -125,6 +125,16 @@ EDITS = { lambda t: first(t, "devices/video/model/@type") == "virtio", ["devices/video"], True, False, ), + "set_rng": ( + lambda vm: vm.set_rng(False), + lambda t: not t.xpath("devices/rng"), + ["devices/rng"], True, False, + ), + "set_on_crash": ( + lambda vm: vm.set_on_crash("restart"), + lambda t: t.findtext("on_crash") == "restart", + ["on_crash"], True, False, + ), "resize_cpu": ( lambda vm: vm.resize_cpu("1", "2"), lambda t: t.findtext("vcpu") == "2" and first(t, "vcpu/@current") == "1", diff --git a/instances/tests/test_pending_edits.py b/instances/tests/test_pending_edits.py index d14f1c2a..99be4186 100644 --- a/instances/tests/test_pending_edits.py +++ b/instances/tests/test_pending_edits.py @@ -141,6 +141,15 @@ class PendingEditsTestCase(TestCase): self.assertEqual(doc.xpath("//select[@name='vcpu_hotplug']/option[@selected]/@value"), ["True"]) self.assertIn("No channel", doc.xpath("string(//form[.//select[@name='guest_agent']])")) + def test_a_custom_rng_or_crash_action_is_left_to_the_xml_editor(self): + doc = self.page(get_rng=("custom", "custom"), get_on_crash=("preserve", "preserve")) + self.assertIsNotNone(doc.xpath("//input[@name='rng']")[0].get("disabled")) + self.assertEqual(doc.xpath("//select[@name='on_crash']"), []) + self.assertIn("preserve: change it in the XML editor.", doc.xpath("string(//p[@id='on_crash'])")) + doc = self.page(get_rng=("virtio", "virtio"), get_on_crash=("restart", "restart")) # read from the persistent definition + self.assertEqual(doc.xpath("//input[@name='rng']/@checked"), ["checked"]) + self.assertEqual(doc.xpath("//select[@name='on_crash']/option[@selected]/@value"), ["restart"]) + def test_buttons_shown_as_disabled_cannot_submit(self): # running: the CPU resize, vCPU hot plug, template and disk removal forms wait for a shutdown; # the title, description and video model are of the next start, saved at once diff --git a/instances/urls.py b/instances/urls.py index bd4064c3..e7d8e541 100755 --- a/instances/urls.py +++ b/instances/urls.py @@ -54,6 +54,8 @@ urlpatterns = [ path("/change_xml/", views.change_xml, name="change_xml"), path("/set_guest_agent/", views.set_guest_agent, name="set_guest_agent"), path("/set_video_model/", views.set_video_model, name="set_video_model"), + path("/set_rng/", views.set_rng, name="set_rng"), + path("/set_on_crash/", views.set_on_crash, name="set_on_crash"), path("/change_network/", views.change_network, name="change_network"), path("/add_network/", views.add_network, name="add_network"), path("/delete_network/", views.delete_network, name="delete_network"), diff --git a/instances/views.py b/instances/views.py index e23bc85d..46b95b24 100755 --- a/instances/views.py +++ b/instances/views.py @@ -1749,6 +1749,34 @@ def set_guest_agent(request, pk): return _back(request, pk, "options") +@require_POST +@superuser_only +@serialize_instance_mutation +def set_rng(request, pk): + instance = get_instance(request.user, pk) + enabled = bool(request.POST.get("rng")) + try: + with audit_vm(request, instance, "add RNG device" if enabled else "remove RNG device"): + instance.proxy.set_rng(enabled) + except util.OperationError as err: + messages.error(request, err) + return _back(request, pk, "options") + + +@require_POST +@superuser_only +@serialize_instance_mutation +def set_on_crash(request, pk): + instance = get_instance(request.user, pk) + action = request.POST.get("on_crash", "") + try: + with audit_vm(request, instance, f"set crash action {action}"): + instance.proxy.set_on_crash(action) + except util.OperationError as err: + messages.error(request, err) + return _back(request, pk, "options") + + @require_POST @superuser_only @serialize_instance_mutation diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 67c555be..3d07ff53 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -83,6 +83,7 @@ PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE USB_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='usb']" PCI_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='pci']" # what of the host a VM holds: host devices, and NICs that are host devices +RNG_DEVICE = "/dev/urandom" GUEST_AGENT_CHANNEL = "/domain/devices/channel[@type='unix']/target[@name='org.qemu.guest_agent.0']" HOST_DEVICES = "devices/hostdev | devices/interface[@type='hostdev'] | devices/interface/actual[@type='hostdev']" @@ -2866,6 +2867,46 @@ class wvmInstance(wvmConnect): option = etree.SubElement(tree, o) option.text = option_value + def get_rng(self): + """The persistent definition's RNG device: "virtio" for the panel's + own (virtio, from the host's /dev/urandom), "custom" for another one, + None without one.""" + rngs = etree.fromstring(self._XMLDesc(PERSISTENT_XML)).xpath("devices/rng") + if not rngs: + return None + # libvirt adds only its address and alias to the panel's device; a rate or driver makes it another one + own = "@model='virtio' and backend[@model='random']='/dev/urandom' and not(*[not(self::backend or self::address or self::alias)])" + if len(rngs) == 1 and rngs[0].xpath(own): + return "virtio" + return "custom" + + def set_rng(self, enabled): + """Adds the panel's virtio RNG device to the persistent definition, or + removes it; another RNG device is left to the XML editor.""" + if self.get_rng() == "custom": + raise util.OperationError("The VM has its own RNG device: change it in the XML editor") + tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML)) + for rng in tree.xpath("devices/rng"): + rng.getparent().remove(rng) + if enabled: + tree.find("devices").append(etree.fromstring(RNG_DEVICE)) + self._defineXML(etree.tostring(tree).decode()) + + def get_on_crash(self): + """What the next start does when the guest crashes; libvirt's default is destroy.""" + return etree.fromstring(self._XMLDesc(PERSISTENT_XML)).findtext("on_crash") or "destroy" + + def set_on_crash(self, action): + """restart or destroy (stop) the VM when the guest crashes, from the next start.""" + if action not in ("restart", "destroy"): + raise util.OperationError(f"Unknown crash action {action}") + tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML)) + on_crash = tree.find("on_crash") + if on_crash is None: + on_crash = etree.SubElement(tree, "on_crash") + on_crash.text = action + self._defineXML(etree.tostring(tree).decode()) + def set_options(self, options): """ Function change description, title diff --git a/vrtManager/tests/test_device_edits.py b/vrtManager/tests/test_device_edits.py index 1e61c504..a653121b 100644 --- a/vrtManager/tests/test_device_edits.py +++ b/vrtManager/tests/test_device_edits.py @@ -134,6 +134,42 @@ class DeviceEditsTestCase(unittest.TestCase): self.assertIsNotNone(p.get_guest_agent(config=True)) self.assertEqual([v["hotpluggable"] for v in p.get_vcpus(config=True).values()], ["no", "yes"]) + def test_the_rng_device_is_the_panel_s_own_or_left_to_the_xml_editor(self): + p = proxy() + self.assertIsNone(p.get_rng()) + p.set_rng(True) + self.assertEqual(etree.tostring(defined(p).find("devices/rng")).decode(), + "/dev/urandom") + added = etree.tostring(defined(p)).decode() + p._XMLDesc = lambda flags: added + self.assertEqual(p.get_rng(), "virtio") + p.set_rng(False) + self.assertIsNone(defined(p).find("devices/rng")) + # the panel's device with the address libvirt gives it; another RNG device, or the panel's with + # a rate or driver, is shown as custom and changed only in the XML editor + backend = "{}" + for inner, found in ((backend.format("/dev/urandom") + "
", "virtio"), + (backend.format("/dev/hwrng"), "custom"), + (backend.format("/dev/urandom") + "", "custom"), + (backend.format("/dev/urandom") + "", "custom")): + with self.subTest(inner): + p = proxy() + p._XMLDesc = lambda flags, inner=inner: PERSISTENT.replace("