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

feat(instances): a VM's options in one list, each saved when it can be

Settings > Options was four forms under bold sentences, most of them
disabled while the VM ran. The owner chose this layout from three
rendered options: one list, each row a setting with its description on
the left and its control on the right: VM details (title, description),
Template, primary video model, QEMU guest agent. vCPU hot plug moved to
Resize > CPU, next to the vCPU counts.

Codex reviewed the four forms; these are fixed:

- Title, description and the video model are saved while the VM runs:
  they are in the persistent definition, used at the next start.
- The template flag has its own form and view, set_template, for
  superusers and staff, and only for a shut-off VM: a running VM made a
  template left the quota while it kept running. Turning a template
  back into a VM checks the quota there, and each change is audited.
  The title form no longer touches the flag.
- A new video model stays the primary one; the old model's memory and
  head settings, which are its own, go with it.
- The agent and vCPU hot plug forms show the persistent definition; they
  showed the running VM's, so a pending change looked undone.
- Enabling the guest agent adds its channel only where it is missing:
  saving Enabled again failed, and a channel pending the next start kept
  a running VM from getting one.
- The agent line tells a VM that is not running, one without a channel,
  and a connected or not connected agent apart.
This commit is contained in:
catborise 2026-10-30 16:03:43 +03:00
parent 46188d3230
commit a993c12db0
13 changed files with 272 additions and 172 deletions

View file

@ -265,6 +265,16 @@ class Instance(models.Model):
def guest_agent(self): def guest_agent(self):
return False if self.proxy.get_guest_agent() is None else True return False if self.proxy.get_guest_agent() is None else True
@cached_property
def config_guest_agent(self) -> bool:
"""Whether the next start has the agent channel; the agent form shows it."""
return self.proxy.get_guest_agent(config=True) is not None
@cached_property
def config_vcpu_hotplug(self) -> bool:
"""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 @cached_property
def guest_agent_ready(self) -> bool: def guest_agent_ready(self) -> bool:
return self.proxy.is_agent_ready() return self.proxy.is_agent_ready()

View file

@ -84,6 +84,32 @@
<button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button> <button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button>
{% endif %} {% endif %}
<div class="clearfix"></div> <div class="clearfix"></div>
{% if request.user.is_superuser %}
<form class="border-top pt-3 mt-3" action="{% url 'instances:set_vcpu_hotplug' instance.id %}" method="post">{% csrf_token %}
<div class="row g-3 align-items-center">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-0" for="vcpu_hotplug">{% trans "vCPU hot plug" %}</label>
<p class="small text-body-secondary mb-0" id="vcpu-hotplug-help">
{% trans "Lets vCPUs up to the maximum be turned on and off while the VM runs." %}
{% if instance.status != 5 %}{% trans "Shut the VM down to change this." %}{% endif %}
</p>
</div>
<div class="col-12 col-lg-8">
<div class="row g-2">
<div class="col-12 col-sm">
<select id="vcpu_hotplug" class="form-select" name="vcpu_hotplug" aria-describedby="vcpu-hotplug-help">
<option value="True"{% if instance.config_vcpu_hotplug %} selected{% endif %}>{% trans "Enabled" %}</option>
<option value="False"{% if not instance.config_vcpu_hotplug %} selected{% endif %}>{% trans "Disabled" %}</option>
</select>
</div>
<div class="col-12 col-sm-auto d-grid">
<button class="btn btn-secondary" type="submit" name="set_vcpu_hotplug"{% if instance.status != 5 %} disabled{% endif %}>{% trans "Save" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
</div> </div>
<div role="tabpanel" class="tab-pane" id="resizevm_mem"> <div role="tabpanel" class="tab-pane" id="resizevm_mem">
{% if can_change %} {% if can_change %}

View file

@ -809,110 +809,96 @@
{% endif %} {% endif %}
{% if can_change %} {% if can_change %}
<div role="tabpanel" class="tab-pane" id="options"> <div role="tabpanel" class="tab-pane" id="options">
<p>{% trans "To set instance template name description, shutdown the instance." %}</p> <div class="list-group">
<form action="{% url 'instances:change_options' instance.id %}" method="post" role="form" aria-label="Set instance template name description form">{% csrf_token %} <form class="list-group-item p-3 p-md-4" action="{% url 'instances:change_options' instance.id %}" method="post" aria-labelledby="options-details">{% csrf_token %}
<div class="row"> <div class="row g-3">
<label class="col-sm-3 col-form-label">{% trans "Title" %}</label> <div class="col-12 col-lg-4">
<div class="col-sm-6"> <h2 class="h6" id="options-details">{% bs_icon 'pencil-square' %} {% trans "VM details" %}</h2>
<input type="text" name="title" class="form-control" value="{{ instance.config_title }}"> <p class="small text-body-secondary mb-0">{% trans "A title and description to identify the VM." %}</p>
</div> </div>
</div> <div class="col-12 col-lg-8">
<div class="row"> <label class="form-label" for="options-title">{% trans "Title" %}</label>
<label class="col-sm-3 col-form-label">{% trans "Description" %}</label> <input class="form-control mb-3" type="text" id="options-title" name="title" value="{{ instance.config_title }}">
<div class="col-sm-6"> <label class="form-label" for="options-description">{% trans "Description" %}</label>
<textarea name="description" class="form-control">{{ instance.config_description }}</textarea> <textarea class="form-control mb-3" id="options-description" name="description" rows="2">{{ instance.config_description }}</textarea>
</div> <div class="text-end">
</div> <button class="btn btn-primary" type="submit" name="change_options">{% trans "Save" %}</button>
<div class="row"> </div>
<label class="col-sm-3 col-form-label">{% trans "Is template" %}</label>
<div class="col-sm-6">
<input type="checkbox"
name="is_template"
value="True"
id="is_template"
{% if instance.is_template %}checked{% endif %}
{% if not request.user.is_superuser and not request.user.is_staff %}disabled{% endif %}>
</div>
</div>
<div class="row">
<div class="d-grid offset-sm-3 col-sm-6">
{% if instance.status == 5 %}
<button type="submit" class="btn btn-primary" name="change_options">{% trans "Change" %}</button>
{% else %}
<button class="btn btn-primary disabled" disabled name="change_options">{% trans "Change" %}</button>
{% endif %}
</div>
</div>
</form>
{% if request.user.is_superuser %}
<p class="fw-bold">{% trans "To set instance video model, shutdown the instance." %}</p>
<form action="{% url 'instances:set_video_model' instance.id %}" method="post" role="form" aria-label="Set instance video model form">{% csrf_token %}
<div class="row">
<label class="col-sm-3 col-form-label" for="video_model_select">{% trans "Primary Video Model" %}</label>
<div class="col-sm-6">
<div class="input-group">
<select id="video_model_select" class="form-select" name="video_model">
<option class="fw-bold" value="">{% trans "please choose" %}</option>
{% for vmodel in instance.video_models %}
<option value="{{ vmodel }}"{% if vmodel == instance.config_video_model %} selected{% endif %}>{{ vmodel }}</option>
{% endfor %}
</select>
{% if instance.status == 5 %}
<button type="submit" class="btn btn-primary" name="set_video_model">{% trans "Set" %}</button>
{% else %}
<button class="btn btn-primary disabled" disabled name="set_video_model">{% trans "Set" %}</button>
{% endif %}
</div> </div>
</div> </div>
</div> </form>
</form> <form class="list-group-item p-3 p-md-4" action="{% url 'instances:set_template' instance.id %}" method="post">{% csrf_token %}
<div class="row g-3 align-items-center">
<p class="fw-bold">{% trans "To set instance vCPUs hotpluggable" %}</p> <div class="col-12 col-lg-4">
<form action="{% url 'instances:set_vcpu_hotplug' instance.id %}" method="post" role="form" aria-label="Set instance vCPUs hotpluggable form">{% csrf_token %} <label class="form-label fw-semibold mb-0" for="is_template">{% trans "Template" %}</label>
<div class="row"> <p class="small text-body-secondary mb-0" id="template-help">{% trans "A template is a VM to clone: it is not counted in quotas and cannot be started." %}</p>
<label for="vcpu_hotplug" class="col-sm-3 col-form-label">{% trans "vCPU Hot Plug" %}</label> </div>
<div class="col-sm-6"> <div class="col-12 col-lg-8">
<div class="input-group"> <div class="form-check form-switch">
<select id="vcpu_hotplug" class="form-control" name="vcpu_hotplug"> <input class="form-check-input" type="checkbox" role="switch" name="is_template" value="True" id="is_template" aria-describedby="template-help template-state"
<option value="True" {% if instance.vcpus %} selected {% endif %}>{% trans 'Enabled' %}</option> {% if instance.is_template %}checked{% endif %}
<option value="False" {% if not instance.vcpus %} selected {% endif %}>{% trans 'Disabled' %}</option> {% if instance.status != 5 or not request.user.is_superuser and not request.user.is_staff %}disabled{% endif %} onchange="this.form.submit()">
</select> <label class="form-check-label" for="is_template">{% trans "Use as a template" %}</label>
{% if instance.status == 5 %} </div>
<button type="submit" class="btn btn-primary" name="set_vcpu_hotplug">{% trans "Set" %}</button> <p class="small text-body-secondary mt-1 mb-0" id="template-state">
{% else %} {% if not request.user.is_superuser and not request.user.is_staff %}{% trans "Only administrators and staff can change this." %}
<button class="btn btn-primary" name="set_vcpu_hotplug" disabled>{% trans "Set" %}</button> {% elif instance.status != 5 %}{% trans "Shut the VM down to change this." %}{% endif %}
{% endif %} </p>
</div> </div>
</div> </div>
</div> </form>
</form> {% if request.user.is_superuser %}
<form class="list-group-item p-3 p-md-4" action="{% url 'instances:set_video_model' instance.id %}" method="post">{% csrf_token %}
<p class="fw-bold">{% trans "To Enable/Disable Qemu Guest Agent. Status" %}: <div class="row g-3 align-items-center">
{% if instance.status == 1 %} <div class="col-12 col-lg-4">
{% if instance.guest_agent_ready %} <label class="form-label fw-semibold mb-0" for="video_model_select">{% trans "Primary video model" %}</label>
<label class="badge bg-success">{% trans 'Connected' %}</label> <p class="small text-body-secondary mb-0" id="video-help">{% trans "The graphics device the guest sees." %}</p>
{% else %} </div>
<label class="badge bg-danger">{% trans 'Disconnected' %}</label> <div class="col-12 col-lg-8">
{% endif %}</p> <div class="row g-2">
{% else %} <div class="col-12 col-sm">
<label class="badge bg-default">{% trans 'Unknown' %}</label> <select id="video_model_select" class="form-select" name="video_model" aria-describedby="video-help">
{% endif %} {% for vmodel in instance.video_models %}
<form action="{% url 'instances:set_guest_agent' instance.id %}" method="post" role="form" aria-label="Enable/Disable Qemu Guest Agent form">{% csrf_token %} <option value="{{ vmodel }}"{% if vmodel == instance.config_video_model %} selected{% endif %}>{{ vmodel }}</option>
<div class="row"> {% endfor %}
<label class="col-sm-3 col-form-label" for="guest_agent">{% trans "Qemu Guest Agent" %}</label> </select>
<div class="col-sm-6"> </div>
<div class="input-group"> <div class="col-12 col-sm-auto d-grid">
<select id="guest_agent" class="form-select" name="guest_agent"> <button class="btn btn-secondary" type="submit" name="set_video_model">{% trans "Save" %}</button>
<option value="True" {% if instance.guest_agent %} selected {% endif %}>{% trans 'Enabled' %}</option> </div>
<option value="False" {% if not instance.guest_agent %} selected {% endif %}>{% trans 'Disabled' %}</option> </div>
</select>
<button type="submit" class="btn btn-primary" name="set_guest_agent">{% trans "Set" %}</button>
</div> </div>
</div> </div>
</div> </form>
</form> <form class="list-group-item p-3 p-md-4" action="{% url 'instances:set_guest_agent' instance.id %}" method="post">{% csrf_token %}
{% endif %} <div class="row g-3 align-items-center">
<div class="clearfix"></div> <div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-0" for="guest_agent">{% trans "QEMU guest agent" %}</label>
<p class="small text-body-secondary mb-0" id="agent-help">{% trans "The channel to the agent in the guest: a running VM gets it at once." %}</p>
<p class="small mt-2 mb-0">{% trans "Agent" %}:
{% if instance.status != 1 and instance.status != 3 %}<span class="badge bg-secondary">{% trans "VM not running" %}</span>
{% elif not instance.guest_agent %}<span class="badge bg-secondary">{% trans "No channel" %}</span>
{% elif instance.guest_agent_ready %}<span class="badge bg-success">{% trans "Connected" %}</span>
{% else %}<span class="badge bg-danger">{% trans "Not connected" %}</span>{% endif %}
</p>
</div>
<div class="col-12 col-lg-8">
<div class="row g-2">
<div class="col-12 col-sm">
<select id="guest_agent" class="form-select" name="guest_agent" aria-describedby="agent-help">
<option value="True"{% if instance.config_guest_agent %} selected{% endif %}>{% trans "Enabled" %}</option>
<option value="False"{% if not instance.config_guest_agent %} selected{% endif %}>{% trans "Disabled" %}</option>
</select>
</div>
<div class="col-12 col-sm-auto d-grid">
<button class="btn btn-secondary" type="submit" name="set_guest_agent">{% trans "Save" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -544,12 +544,10 @@ class LiveDataLossTestCase(TestCase):
return (f"<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='{path}'/>" return (f"<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='{path}'/>"
"<target dev='sda' bus='scsi'/></disk>") "<target dev='sda' bus='scsi'/></disk>")
agent = "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>"
cases = ( cases = (
("disk", lambda n: disk(livetest.create_volume(self.conn, P + n + "-pending")), ("disk", lambda n: disk(livetest.create_volume(self.conn, P + n + "-pending")),
lambda vm, n: vm.attach_disk("sda", livetest.create_volume(self.conn, P + n + "-new"), lambda vm, n: vm.attach_disk("sda", livetest.create_volume(self.conn, P + n + "-new"),
target_bus="scsi", format_type="qcow2")), target_bus="scsi", format_type="qcow2")),
("agent", lambda n: agent, lambda vm, n: vm.add_guest_agent()),
) )
both = (0, libvirt.VIR_DOMAIN_XML_INACTIVE) both = (0, libvirt.VIR_DOMAIN_XML_INACTIVE)
for paused in (False, True): for paused in (False, True):
@ -566,6 +564,19 @@ class LiveDataLossTestCase(TestCase):
attach(inst.proxy, name) attach(inst.proxy, name)
self.assertEqual([dom.XMLDesc(flags) for flags in both], before) self.assertEqual([dom.XMLDesc(flags) for flags in both], before)
def test_an_agent_channel_pending_the_next_start_is_added_to_the_running_vm_only(self):
from lxml import etree
agent = "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>"
dom, inst = self.vm("agent-pending", [], extra_devices="<controller type='virtio-serial'/>")
dom.create()
dom.attachDeviceFlags(agent, libvirt.VIR_DOMAIN_AFFECT_CONFIG) # pending: persistent only
channels = "devices/channel/target[@name='org.qemu.guest_agent.0']"
for _attempt in range(2): # saving Enabled again changes nothing
inst.proxy.add_guest_agent()
self.assertEqual([len(etree.fromstring(dom.XMLDesc(flags)).xpath(channels))
for flags in (0, libvirt.VIR_DOMAIN_XML_INACTIVE)], [1, 1])
def test_a_nic_without_a_mac_gets_the_same_one_in_both_definitions(self): def test_a_nic_without_a_mac_gets_the_same_one_in_both_definitions(self):
from lxml import etree from lxml import etree

View file

@ -132,16 +132,29 @@ class PendingEditsTestCase(TestCase):
self.assertEqual(doc.xpath("//select[@name='vcpu']/option[@selected]/@value"), ["4"]) self.assertEqual(doc.xpath("//select[@name='vcpu']/option[@selected]/@value"), ["4"])
self.assertEqual(doc.xpath("//select[@name='cur_vcpu']/option[@selected]/@value"), ["3"]) self.assertEqual(doc.xpath("//select[@name='cur_vcpu']/option[@selected]/@value"), ["3"])
def test_the_agent_and_vcpu_hot_plug_forms_show_the_pending_definition(self):
# running without an agent channel and hot plug, both pending for the next start
hotplug = {"0": {"enabled": "yes", "hotpluggable": "no", "order": "1"},
"1": {"enabled": "yes", "hotpluggable": "yes", "order": "2"}}
doc = self.page(get_guest_agent=(None, object()), get_vcpus=({}, hotplug))
self.assertEqual(doc.xpath("//select[@name='guest_agent']/option[@selected]/@value"), ["True"])
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_buttons_shown_as_disabled_cannot_submit(self): def test_buttons_shown_as_disabled_cannot_submit(self):
# running: the CPU, options and video forms wait for a shutdown # 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
doc = self.page(live=[disk("vda", "default", "")], config=[disk("vda", "default", "")]) doc = self.page(live=[disk("vda", "default", "")], config=[disk("vda", "default", "")])
self.assertGreater(len(doc.xpath("//button[@disabled]")), 5) self.assertGreater(len(doc.xpath("//button[@disabled]")), 4)
shown = doc.xpath("//button[contains(concat(' ', @class, ' '), ' disabled ')]") shown = doc.xpath("//button[contains(concat(' ', @class, ' '), ' disabled ')]")
self.assertEqual([b for b in shown if b.get("disabled") is None], []) self.assertEqual([b for b in shown if b.get("disabled") is None], [])
for action in ("resizevm_cpu", "change_options", "set_video_model", "detach_vol", "delete_vol"): for action, waits in (("resizevm_cpu", True), ("set_vcpu_hotplug", True), ("set_template", True),
("detach_vol", True), ("delete_vol", True),
("change_options", False), ("set_video_model", False)):
with self.subTest(action): with self.subTest(action):
(form,) = doc.xpath("//form[@action=$a]", a=reverse(f"instances:{action}", args=[self.instance.id])) (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"))) controls = form.xpath(".//button | .//input[@type='checkbox']")
self.assertEqual([c.get("disabled") is not None for c in controls], [waits] * len(controls))
def test_the_xml_editor_shows_the_definition_for_the_next_start(self): 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 # static/js/xml_editor.js opens the review dialog on Change, also while the VM runs

View file

@ -1,8 +1,6 @@
""" """
Only superusers and staff may mark or unmark a VM as a template. Only superusers and staff may mark or unmark a VM as a template, on its own
The UI only disables the checkbox for other users, and a disabled checkbox is form: the title and description form leaves the flag alone.
not submitted, so the server must not derive is_template from its absence for
them.
""" """
from unittest.mock import patch from unittest.mock import patch
@ -13,6 +11,7 @@ from django.urls import reverse
from accounts.models import UserInstance from accounts.models import UserInstance
from computes.models import Compute from computes.models import Compute
from instances.models import Instance from instances.models import Instance
from logs.models import Logs
class TemplateFlagTestCase(TestCase): class TemplateFlagTestCase(TestCase):
@ -36,16 +35,15 @@ class TemplateFlagTestCase(TestCase):
for user in (self.owner, self.staff_owner): for user in (self.owner, self.staff_owner):
UserInstance.objects.create(instance=self.instance, user=user, is_change=True) UserInstance.objects.create(instance=self.instance, user=user, is_change=True)
def _change_options(self, user, data, status=302): def _post(self, user, action, data, status=302):
self.client.force_login(user) self.client.force_login(user)
with patch("instances.models.wvmInstance") as wvm: with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_status.return_value = 5
# unmarking a template checks the owner's quota with the VM's size # unmarking a template checks the owner's quota with the VM's size
wvm.return_value.get_vcpu.return_value = 1 wvm.return_value.get_vcpu.return_value = 1
wvm.return_value.get_memory.return_value = 1024 wvm.return_value.get_memory.return_value = 1024
wvm.return_value.get_disk_devices.return_value = [] wvm.return_value.get_disk_devices.return_value = []
res = self.client.post( res = self.client.post(reverse(f"instances:{action}", args=[self.instance.id]), data)
reverse("instances:change_options", args=[self.instance.id]), data
)
self.assertEqual(res.status_code, status) self.assertEqual(res.status_code, status)
self.instance.refresh_from_db() self.instance.refresh_from_db()
@ -54,23 +52,30 @@ class TemplateFlagTestCase(TestCase):
self.instance.save(update_fields=["is_template"]) self.instance.save(update_fields=["is_template"])
def test_owner_cannot_mark_vm_as_template(self): def test_owner_cannot_mark_vm_as_template(self):
self._change_options(self.owner, {"title": "t", "is_template": "True"}) self._post(self.owner, "set_template", {"is_template": "True"}, status=403)
self.assertFalse(self.instance.is_template) self.assertFalse(self.instance.is_template)
def test_owner_cannot_change_a_template(self): def test_owner_cannot_change_a_template(self):
# a template is changed only by superusers and staff owners # a template is changed only by superusers and staff owners
self._make_template() self._make_template()
self._change_options(self.owner, {"title": "new title", "description": ""}, status=403) self._post(self.owner, "change_options", {"title": "new title", "description": ""}, status=403)
self.assertTrue(self.instance.is_template) self.assertTrue(self.instance.is_template)
def test_staff_owner_can_mark_and_unmark_template(self): def test_staff_owner_can_mark_and_unmark_template(self):
self._change_options(self.staff_owner, {"title": "t", "is_template": "True"}) self._post(self.staff_owner, "set_template", {"is_template": "True"})
self.assertTrue(self.instance.is_template) self.assertTrue(self.instance.is_template)
# An unchecked checkbox is not submitted: for staff that means "not a template". # an unchecked switch is not submitted: "not a template"
self._change_options(self.staff_owner, {"title": "t"}) self._post(self.staff_owner, "set_template", {})
self.assertFalse(self.instance.is_template) self.assertFalse(self.instance.is_template)
self.assertEqual(list(Logs.objects.filter(message__startswith="VM").order_by("id").values_list("message", "success")),
[("VM tpl-vm: mark as template", True), ("VM tpl-vm: unmark as template", True)])
def test_superuser_can_unmark_template(self): def test_superuser_can_unmark_template(self):
self._make_template() self._make_template()
self._change_options(self.superuser, {"title": "t"}) self._post(self.superuser, "set_template", {})
self.assertFalse(self.instance.is_template) self.assertFalse(self.instance.is_template)
def test_the_title_form_leaves_the_template_alone(self):
self._make_template()
self._post(self.superuser, "change_options", {"title": "t", "description": ""})
self.assertTrue(self.instance.is_template)

View file

@ -1,6 +1,6 @@
"""Templates do not count towards the quota and cannot be started. Turning a """Templates do not count towards the quota and cannot be started. Turning a
template back into a VM adds it to its owner's usage, so for users with a template back into a VM adds it to its owner's usage, so for users with a
quota it is checked like any other increase.""" quota it is checked like any other increase. Only a shut-off VM changes."""
from unittest.mock import patch from unittest.mock import patch
@ -22,18 +22,17 @@ class TemplateFlagQuotaTests(TestCase):
self.vm = Instance.objects.create(compute=compute, name="tq-vm", uuid="12345678-0000-0000-0000-00000000aaaa") self.vm = Instance.objects.create(compute=compute, name="tq-vm", uuid="12345678-0000-0000-0000-00000000aaaa")
UserInstance.objects.create(user=self.staff, instance=self.vm, is_change=True) UserInstance.objects.create(user=self.staff, instance=self.vm, is_change=True)
def post(self, user, is_template, quota=""): def post(self, user, is_template, quota="", status=5):
self.client.force_login(user) self.client.force_login(user)
data = {"title": "", "description": ""} data = {"is_template": "on"} if is_template else {}
if is_template:
data["is_template"] = "on"
with patch("instances.models.wvmInstance") as wvm, patch( with patch("instances.models.wvmInstance") as wvm, patch(
"instances.views.utils.check_user_quota", return_value=quota "instances.views.utils.check_user_quota", return_value=quota
) as check: ) as check:
wvm.return_value.get_status.return_value = status
wvm.return_value.get_vcpu.return_value = 2 wvm.return_value.get_vcpu.return_value = 2
wvm.return_value.get_memory.return_value = 2048 wvm.return_value.get_memory.return_value = 2048
wvm.return_value.get_disk_devices.return_value = [{"size": 10 << 30}] wvm.return_value.get_disk_devices.return_value = [{"size": 10 << 30}]
response = self.client.post(reverse("instances:change_options", args=[self.vm.id]), data) response = self.client.post(reverse("instances:set_template", args=[self.vm.id]), data)
self.vm.refresh_from_db() self.vm.refresh_from_db()
return check, [str(m) for m in get_messages(response.wsgi_request)] return check, [str(m) for m in get_messages(response.wsgi_request)]
@ -60,6 +59,14 @@ class TemplateFlagQuotaTests(TestCase):
self.assertTrue(self.vm.is_template) self.assertTrue(self.vm.is_template)
self.assertTrue(any("cannot be reached" in m for m in messages), messages) self.assertTrue(any("cannot be reached" in m for m in messages), messages)
def test_a_vm_that_is_not_shut_off_stays_as_it_is(self):
# a running VM made a template would leave the quota while it runs
for status in (1, 3):
with self.subTest(status=status):
check, messages = self.post(self.staff, is_template=True, status=status)
self.assertFalse(self.vm.is_template)
self.assertIn("Shut the VM down to change whether it is a template.", messages)
def test_superusers_have_no_quota(self): def test_superusers_have_no_quota(self):
Instance.objects.filter(pk=self.vm.pk).update(is_template=True) Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
admin = get_user_model().objects.create_superuser("tq-admin", "tq@example.com", "pw") admin = get_user_model().objects.create_superuser("tq-admin", "tq@example.com", "pw")

View file

@ -73,6 +73,7 @@ urlpatterns = [
path("<int:pk>/clone/", views.clone, name="clone"), path("<int:pk>/clone/", views.clone, name="clone"),
path("<int:pk>/update_console/", views.update_console, name="update_console"), path("<int:pk>/update_console/", views.update_console, name="update_console"),
path("<int:pk>/change_options/", views.change_options, name="change_options"), path("<int:pk>/change_options/", views.change_options, name="change_options"),
path("<int:pk>/set_template/", views.set_template, name="set_template"),
path( path(
"<int:pk>/getvvfile/", views.getvvfile, name="getvvfile" "<int:pk>/getvvfile/", views.getvvfile, name="getvvfile"
), # no links to this one??? ), # no links to this one???

View file

@ -2165,45 +2165,47 @@ def update_console(request, pk):
@require_POST @require_POST
@serialize_user_quota
@serialize_instance_mutation @serialize_instance_mutation
def change_options(request, pk): def change_options(request, pk):
"""The title and description of the persistent definition: they apply
at the next start, so a running VM's are changed too."""
instance = get_instance(request.user, pk, perm_type="change") instance = get_instance(request.user, pk, perm_type="change")
try: options = {key: request.POST.get(key, "") for key in ("title", "description") if key in request.POST}
userinstance = instance.userinstance_set.get(user=request.user) with audit_vm(request, instance, "edit options"):
except UserInstance.DoesNotExist: instance.proxy.set_options(options)
userinstance = UserInstance(is_change=False)
if request.user.is_superuser or userinstance.is_change:
# Only superusers and staff may (un)mark templates. The
# checkbox is disabled for everyone else, and a disabled checkbox is
# not submitted, so for them the flag must be left alone.
if request.user.is_superuser or request.user.is_staff:
is_template = bool(request.POST.get("is_template", False))
# A template is not counted and cannot be started: making it a
# VM again adds it to the owner's usage, so the quota applies.
if instance.is_template and not is_template and not request.user.is_superuser:
disk_sum = sum(int(disk.get("size") or 0) >> 30 for disk in instance.disks)
quota_msg = utils.check_user_quota(request.user, 1, instance.vcpu, instance.memory, disk_sum)
if quota_msg:
messages.error(request, quota_refused(quota_msg, _(
"User %(quota_msg)s quota reached, '%(instance_name)s' stays a template!"
) % {"quota_msg": quota_msg, "instance_name": instance.name}))
is_template = True
instance.is_template = is_template
instance.save(update_fields=["is_template"])
options = {}
for post in request.POST:
if post in ["title", "description"]:
options[post] = request.POST.get(post, "")
with audit_vm(request, instance, "edit options"):
instance.proxy.set_options(options)
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#options" request, default=reverse("instances:instance", args=[instance.id]) + "#options"
) )
@require_POST
@serialize_user_quota
@serialize_instance_mutation
def set_template(request, pk):
"""Marks a shut-off VM as a template, or a template as a VM again; only
superusers and staff. A template is not counted and cannot be started:
making it a VM again adds it to the owner's usage, so the quota applies."""
instance = get_instance(request.user, pk, perm_type="change")
if not (request.user.is_superuser or request.user.is_staff):
raise PermissionDenied
is_template = bool(request.POST.get("is_template"))
if instance.proxy.get_status() != 5:
messages.error(request, _("Shut the VM down to change whether it is a template."))
return _back(request, pk, "options")
if instance.is_template and not is_template and not request.user.is_superuser:
disk_sum = sum(int(disk.get("size") or 0) >> 30 for disk in instance.disks)
quota_msg = utils.check_user_quota(request.user, 1, instance.vcpu, instance.memory, disk_sum)
if quota_msg:
messages.error(request, quota_refused(quota_msg, _(
"User %(quota_msg)s quota reached, '%(instance_name)s' stays a template!"
) % {"quota_msg": quota_msg, "instance_name": instance.name}))
return _back(request, pk, "options")
with audit_vm(request, instance, "mark as template" if is_template else "unmark as template"):
instance.is_template = is_template
instance.save(update_fields=["is_template"])
return _back(request, pk, "options")
def getvvfile(request, pk): def getvvfile(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
# The .vv file contains the VNC password: same rule as the console. # The .vv file contains the VNC password: same rule as the console.

View file

@ -83,6 +83,7 @@ PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE
USB_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='usb']" USB_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='usb']"
PCI_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='pci']" PCI_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='pci']"
# what of the host a VM holds: host devices, and NICs that are host devices # what of the host a VM holds: host devices, and NICs that are host devices
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']" HOST_DEVICES = "devices/hostdev | devices/interface[@type='hostdev'] | devices/interface/actual[@type='hostdev']"
# External snapshot names become part of overlay and memory file names. # External snapshot names become part of overlay and memory file names.
@ -695,9 +696,9 @@ class wvmInstance(wvmConnect):
readonly = util.get_xml_path(xml, "/domain/os/loader/@readonly") readonly = util.get_xml_path(xml, "/domain/os/loader/@readonly")
return {"loader": loader, "type": loader_type, "readonly": readonly} return {"loader": loader, "type": loader_type, "readonly": readonly}
def get_vcpus(self): def get_vcpus(self, config=False):
vcpus = OrderedDict() vcpus = OrderedDict()
tree = etree.fromstring(self._XMLDesc(0)) tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML if config else 0))
for vcpu in tree.xpath("/domain/vcpus/vcpu"): for vcpu in tree.xpath("/domain/vcpus/vcpu"):
vcpu_id = vcpu.get("id") vcpu_id = vcpu.get("id")
enabled = vcpu.get("enabled") enabled = vcpu.get("enabled")
@ -1597,7 +1598,8 @@ class wvmInstance(wvmConnect):
return model.get("type") return model.get("type")
def set_video_model(self, model): def set_video_model(self, model):
"""Changes only primary video card""" """Changes only the primary video card, which stays the primary one;
the old model's memory and head settings go with it."""
xml = self._XMLDesc(PERSISTENT_XML) xml = self._XMLDesc(PERSISTENT_XML)
tree = etree.fromstring(xml) tree = etree.fromstring(xml)
video_models = tree.xpath("/domain/devices/video/model") video_models = tree.xpath("/domain/devices/video/model")
@ -1605,7 +1607,9 @@ class wvmInstance(wvmConnect):
if video_model.get("primary") == "yes" or len(video_models) == 1: if video_model.get("primary") == "yes" or len(video_models) == 1:
parent = video_model.getparent() parent = video_model.getparent()
parent.remove(video_model) parent.remove(video_model)
etree.SubElement(parent, "model", type=model) new_model = etree.SubElement(parent, "model", type=model)
if video_model.get("primary"):
new_model.set("primary", video_model.get("primary"))
self._defineXML(etree.tostring(tree).decode()) self._defineXML(etree.tostring(tree).decode())
return return
@ -2923,23 +2927,23 @@ class wvmInstance(wvmConnect):
self._defineXML(etree.tostring(tree).decode()) self._defineXML(etree.tostring(tree).decode())
def add_guest_agent(self): def add_guest_agent(self):
channel_xml = """ """Adds the agent channel to the definitions that do not have it."""
<channel type='unix'> flags = 0
<target type='virtio' name='org.qemu.guest_agent.0'/> for xml_flags, affect in self._definitions():
</channel> if not etree.fromstring(self._XMLDesc(xml_flags)).xpath(GUEST_AGENT_CHANNEL):
""" flags |= affect
self._attach_device(channel_xml) if flags:
channel_xml = "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>"
self.instance.attachDeviceFlags(channel_xml, flags)
def remove_guest_agent(self): def remove_guest_agent(self):
for xml_flags, affect in self._definitions(): for xml_flags, affect in self._definitions():
tree = etree.fromstring(self._XMLDesc(xml_flags)) tree = etree.fromstring(self._XMLDesc(xml_flags))
for target in tree.xpath( for target in tree.xpath(GUEST_AGENT_CHANNEL):
"/domain/devices/channel[@type='unix']/target[@name='org.qemu.guest_agent.0']"
):
channel_xml = etree.tostring(target.getparent()).decode() channel_xml = etree.tostring(target.getparent()).decode()
self.instance.detachDeviceFlags(channel_xml, affect) self.instance.detachDeviceFlags(channel_xml, affect)
def get_guest_agent(self): def get_guest_agent(self, config=False):
def _get_agent(doc): def _get_agent(doc):
""" """
Return agent channel object if it is defined. Return agent channel object if it is defined.
@ -2952,7 +2956,7 @@ class wvmInstance(wvmConnect):
return channel return channel
return None return None
return util.get_xml_path(self._XMLDesc(0), func=_get_agent) return util.get_xml_path(self._XMLDesc(PERSISTENT_XML if config else 0), func=_get_agent)
def set_user_password(self, user, crypted): def set_user_password(self, user, crypted):
"""Set a user's password in the running guest through its agent; """Set a user's password in the running guest through its agent;

View file

@ -116,6 +116,24 @@ class DeviceEditsTestCase(unittest.TestCase):
inbound = defined(p).find("devices/interface[@type='network']/bandwidth/inbound") inbound = defined(p).find("devices/interface[@type='network']/bandwidth/inbound")
self.assertEqual(inbound.attrib, {"average": "10", "peak": "20", "burst": "30", "floor": "4"}) self.assertEqual(inbound.attrib, {"average": "10", "peak": "20", "burst": "30", "floor": "4"})
def test_a_new_video_model_stays_the_primary_one(self):
# the old model's memory sizes are its own: they go
p = proxy()
p._XMLDesc = lambda flags: PERSISTENT.replace("<model type='vga'/>", "<model type='vga' vram='16384' primary='yes'/>")
p.set_video_model("virtio")
self.assertEqual(defined(p).find("devices/video/model").attrib, {"type": "virtio", "primary": "yes"})
def test_the_agent_channel_and_vcpu_hot_plug_are_read_from_either_definition(self):
p = proxy()
pending = ("<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>"
"<video>")
vcpus = "<vcpus><vcpu id='0' enabled='yes' hotpluggable='no'/><vcpu id='1' enabled='no' hotpluggable='yes'/></vcpus>"
p._XMLDesc = lambda flags: (PERSISTENT.replace("<video>", pending).replace("<devices>", vcpus + "<devices>")
if flags == PERSISTENT_XML else LIVE)
self.assertEqual((p.get_guest_agent(), p.get_vcpus()), (None, {}))
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_cpu_title_and_description_are_read_from_either_definition(self): def test_cpu_title_and_description_are_read_from_either_definition(self):
p = proxy() p = proxy()
self.assertEqual((p.get_vcpu(), p.get_cur_vcpu(), p.get_title(), p.get_description()), (1, 1, "", "")) self.assertEqual((p.get_vcpu(), p.get_cur_vcpu(), p.get_title(), p.get_description()), (1, 1, "", ""))

View file

@ -94,7 +94,8 @@ class DeviceEditsByStateTestCase(unittest.TestCase):
vm.instance = MagicMock() vm.instance = MagicMock()
vm.instance.isActive.return_value = active vm.instance.isActive.return_value = active
vm.get_status = MagicMock(return_value=state) vm.get_status = MagicMock(return_value=state)
vm._XMLDesc = MagicMock(return_value=AGENT_DOMAIN) # an agent channel is added where there is none, removed where there is one
vm._XMLDesc = MagicMock(return_value=DOMAIN if name == "add_guest_agent" else AGENT_DOMAIN)
edit(vm) edit(vm)
result[name] = [names[c.args[1]] for c in getattr(vm.instance, method).call_args_list] result[name] = [names[c.args[1]] for c in getattr(vm.instance, method).call_args_list]
return result return result
@ -119,11 +120,25 @@ class DeviceEditsByStateTestCase(unittest.TestCase):
vm = wvmInstance.__new__(wvmInstance) vm = wvmInstance.__new__(wvmInstance)
vm.instance = MagicMock() vm.instance = MagicMock()
vm.instance.isActive.return_value = True vm.instance.isActive.return_value = True
vm._XMLDesc = MagicMock(return_value=DOMAIN)
vm.instance.attachDeviceFlags.side_effect = libvirtError("target sda already exists") vm.instance.attachDeviceFlags.side_effect = libvirtError("target sda already exists")
with self.assertRaises(libvirtError): with self.assertRaises(libvirtError):
self.EDITS[name][1](vm) self.EDITS[name][1](vm)
vm.instance.attachDeviceFlags.assert_called_once() vm.instance.attachDeviceFlags.assert_called_once()
def test_an_agent_channel_is_added_only_where_it_is_missing(self):
from libvirt import VIR_DOMAIN_AFFECT_CONFIG, VIR_DOMAIN_AFFECT_LIVE
for live, persistent, affected in ((AGENT_DOMAIN, AGENT_DOMAIN, []),
(DOMAIN, AGENT_DOMAIN, [VIR_DOMAIN_AFFECT_LIVE]),
(AGENT_DOMAIN, DOMAIN, [VIR_DOMAIN_AFFECT_CONFIG])):
with self.subTest(affected=affected):
vm = wvmInstance.__new__(wvmInstance)
vm.instance = MagicMock(**{"isActive.return_value": True})
vm._XMLDesc = lambda flags, live=live, persistent=persistent: persistent if flags == PERSISTENT_XML else live
vm.add_guest_agent()
self.assertEqual([c.args[1] for c in vm.instance.attachDeviceFlags.call_args_list], affected)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -110,6 +110,8 @@ CHANGE_VM = {
# Also needs is_vnc (can_manage_console). Snapshots also need # Also needs is_vnc (can_manage_console). Snapshots also need
# instances.snapshot_instances, which no role here has: refused to all. # instances.snapshot_instances, which no role here has: refused to all.
CONSOLE_SETTINGS = {"instances:update_console"} CONSOLE_SETTINGS = {"instances:update_console"}
# Also needs is_staff: whether the VM is a template.
TEMPLATE_FLAG = {"instances:set_template"}
DELETE_VM = {"instances:destroy"} DELETE_VM = {"instances:destroy"}
ROLE_EXPECTATIONS = { ROLE_EXPECTATIONS = {
@ -123,7 +125,7 @@ ROLE_EXPECTATIONS = {
True, True,
False, False,
{"is_change": True, "is_vnc": True}, {"is_change": True, "is_vnc": True},
VIEW_VM | POWER_AND_CONSOLE | CHANGE_VM | CONSOLE_SETTINGS, VIEW_VM | POWER_AND_CONSOLE | CHANGE_VM | CONSOLE_SETTINGS | TEMPLATE_FLAG,
), ),
} }