1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +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):
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
def guest_agent_ready(self) -> bool:
return self.proxy.is_agent_ready()

View file

@ -84,6 +84,32 @@
<button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button>
{% endif %}
<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 role="tabpanel" class="tab-pane" id="resizevm_mem">
{% if can_change %}

View file

@ -809,110 +809,96 @@
{% endif %}
{% if can_change %}
<div role="tabpanel" class="tab-pane" id="options">
<p>{% trans "To set instance template name description, shutdown the instance." %}</p>
<form action="{% url 'instances:change_options' instance.id %}" method="post" role="form" aria-label="Set instance template name description form">{% csrf_token %}
<div class="row">
<label class="col-sm-3 col-form-label">{% trans "Title" %}</label>
<div class="col-sm-6">
<input type="text" name="title" class="form-control" value="{{ instance.config_title }}">
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label">{% trans "Description" %}</label>
<div class="col-sm-6">
<textarea name="description" class="form-control">{{ instance.config_description }}</textarea>
</div>
</div>
<div class="row">
<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 class="list-group">
<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 g-3">
<div class="col-12 col-lg-4">
<h2 class="h6" id="options-details">{% bs_icon 'pencil-square' %} {% trans "VM details" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "A title and description to identify the VM." %}</p>
</div>
<div class="col-12 col-lg-8">
<label class="form-label" for="options-title">{% trans "Title" %}</label>
<input class="form-control mb-3" type="text" id="options-title" name="title" value="{{ instance.config_title }}">
<label class="form-label" for="options-description">{% trans "Description" %}</label>
<textarea class="form-control mb-3" id="options-description" name="description" rows="2">{{ instance.config_description }}</textarea>
<div class="text-end">
<button class="btn btn-primary" type="submit" name="change_options">{% trans "Save" %}</button>
</div>
</div>
</div>
</div>
</form>
<p class="fw-bold">{% trans "To set instance vCPUs hotpluggable" %}</p>
<form action="{% url 'instances:set_vcpu_hotplug' instance.id %}" method="post" role="form" aria-label="Set instance vCPUs hotpluggable form">{% csrf_token %}
<div class="row">
<label for="vcpu_hotplug" class="col-sm-3 col-form-label">{% trans "vCPU Hot Plug" %}</label>
<div class="col-sm-6">
<div class="input-group">
<select id="vcpu_hotplug" class="form-control" name="vcpu_hotplug">
<option value="True" {% if instance.vcpus %} selected {% endif %}>{% trans 'Enabled' %}</option>
<option value="False" {% if not instance.vcpus %} selected {% endif %}>{% trans 'Disabled' %}</option>
</select>
{% if instance.status == 5 %}
<button type="submit" class="btn btn-primary" name="set_vcpu_hotplug">{% trans "Set" %}</button>
{% else %}
<button class="btn btn-primary" name="set_vcpu_hotplug" disabled>{% trans "Set" %}</button>
{% endif %}
</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">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-0" for="is_template">{% trans "Template" %}</label>
<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>
</div>
<div class="col-12 col-lg-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="is_template" value="True" id="is_template" aria-describedby="template-help template-state"
{% if instance.is_template %}checked{% endif %}
{% if instance.status != 5 or not request.user.is_superuser and not request.user.is_staff %}disabled{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="is_template">{% trans "Use as a template" %}</label>
</div>
<p class="small text-body-secondary mt-1 mb-0" id="template-state">
{% if not request.user.is_superuser and not request.user.is_staff %}{% trans "Only administrators and staff can change this." %}
{% elif instance.status != 5 %}{% trans "Shut the VM down to change this." %}{% endif %}
</p>
</div>
</div>
</div>
</form>
<p class="fw-bold">{% trans "To Enable/Disable Qemu Guest Agent. Status" %}:
{% if instance.status == 1 %}
{% if instance.guest_agent_ready %}
<label class="badge bg-success">{% trans 'Connected' %}</label>
{% else %}
<label class="badge bg-danger">{% trans 'Disconnected' %}</label>
{% endif %}</p>
{% else %}
<label class="badge bg-default">{% trans 'Unknown' %}</label>
{% endif %}
<form action="{% url 'instances:set_guest_agent' instance.id %}" method="post" role="form" aria-label="Enable/Disable Qemu Guest Agent form">{% csrf_token %}
<div class="row">
<label class="col-sm-3 col-form-label" for="guest_agent">{% trans "Qemu Guest Agent" %}</label>
<div class="col-sm-6">
<div class="input-group">
<select id="guest_agent" class="form-select" name="guest_agent">
<option value="True" {% if instance.guest_agent %} selected {% endif %}>{% trans 'Enabled' %}</option>
<option value="False" {% if not instance.guest_agent %} selected {% endif %}>{% trans 'Disabled' %}</option>
</select>
<button type="submit" class="btn btn-primary" name="set_guest_agent">{% trans "Set" %}</button>
</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 %}
<div class="row g-3 align-items-center">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-0" for="video_model_select">{% trans "Primary video model" %}</label>
<p class="small text-body-secondary mb-0" id="video-help">{% trans "The graphics device the guest sees." %}</p>
</div>
<div class="col-12 col-lg-8">
<div class="row g-2">
<div class="col-12 col-sm">
<select id="video_model_select" class="form-select" name="video_model" aria-describedby="video-help">
{% for vmodel in instance.video_models %}
<option value="{{ vmodel }}"{% if vmodel == instance.config_video_model %} selected{% endif %}>{{ vmodel }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-auto d-grid">
<button class="btn btn-secondary" type="submit" name="set_video_model">{% trans "Save" %}</button>
</div>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="clearfix"></div>
</form>
<form class="list-group-item p-3 p-md-4" action="{% url 'instances:set_guest_agent' 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="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>
{% endif %}
</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}'/>"
"<target dev='sda' bus='scsi'/></disk>")
agent = "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>"
cases = (
("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"),
target_bus="scsi", format_type="qcow2")),
("agent", lambda n: agent, lambda vm, n: vm.add_guest_agent()),
)
both = (0, libvirt.VIR_DOMAIN_XML_INACTIVE)
for paused in (False, True):
@ -566,6 +564,19 @@ class LiveDataLossTestCase(TestCase):
attach(inst.proxy, name)
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):
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='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):
# 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", "")])
self.assertGreater(len(doc.xpath("//button[@disabled]")), 5)
self.assertGreater(len(doc.xpath("//button[@disabled]")), 4)
shown = doc.xpath("//button[contains(concat(' ', @class, ' '), ' disabled ')]")
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):
(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):
# 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.
The UI only disables the checkbox for other users, and a disabled checkbox is
not submitted, so the server must not derive is_template from its absence for
them.
Only superusers and staff may mark or unmark a VM as a template, on its own
form: the title and description form leaves the flag alone.
"""
from unittest.mock import patch
@ -13,6 +11,7 @@ from django.urls import reverse
from accounts.models import UserInstance
from computes.models import Compute
from instances.models import Instance
from logs.models import Logs
class TemplateFlagTestCase(TestCase):
@ -36,16 +35,15 @@ class TemplateFlagTestCase(TestCase):
for user in (self.owner, self.staff_owner):
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)
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
wvm.return_value.get_vcpu.return_value = 1
wvm.return_value.get_memory.return_value = 1024
wvm.return_value.get_disk_devices.return_value = []
res = self.client.post(
reverse("instances:change_options", args=[self.instance.id]), data
)
res = self.client.post(reverse(f"instances:{action}", args=[self.instance.id]), data)
self.assertEqual(res.status_code, status)
self.instance.refresh_from_db()
@ -54,23 +52,30 @@ class TemplateFlagTestCase(TestCase):
self.instance.save(update_fields=["is_template"])
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)
def test_owner_cannot_change_a_template(self):
# a template is changed only by superusers and staff owners
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)
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)
# An unchecked checkbox is not submitted: for staff that means "not a template".
self._change_options(self.staff_owner, {"title": "t"})
# an unchecked switch is not submitted: "not a template"
self._post(self.staff_owner, "set_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):
self._make_template()
self._change_options(self.superuser, {"title": "t"})
self._post(self.superuser, "set_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
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
@ -22,18 +22,17 @@ class TemplateFlagQuotaTests(TestCase):
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)
def post(self, user, is_template, quota=""):
def post(self, user, is_template, quota="", status=5):
self.client.force_login(user)
data = {"title": "", "description": ""}
if is_template:
data["is_template"] = "on"
data = {"is_template": "on"} if is_template else {}
with patch("instances.models.wvmInstance") as wvm, patch(
"instances.views.utils.check_user_quota", return_value=quota
) as check:
wvm.return_value.get_status.return_value = status
wvm.return_value.get_vcpu.return_value = 2
wvm.return_value.get_memory.return_value = 2048
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()
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(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):
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
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>/update_console/", views.update_console, name="update_console"),
path("<int:pk>/change_options/", views.change_options, name="change_options"),
path("<int:pk>/set_template/", views.set_template, name="set_template"),
path(
"<int:pk>/getvvfile/", views.getvvfile, name="getvvfile"
), # no links to this one???

View file

@ -2165,45 +2165,47 @@ def update_console(request, pk):
@require_POST
@serialize_user_quota
@serialize_instance_mutation
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")
try:
userinstance = instance.userinstance_set.get(user=request.user)
except UserInstance.DoesNotExist:
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)
options = {key: request.POST.get(key, "") for key in ("title", "description") if key in request.POST}
with audit_vm(request, instance, "edit options"):
instance.proxy.set_options(options)
return get_safe_redirect(
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):
instance = get_instance(request.user, pk)
# 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']"
PCI_HOSTDEVS = "devices/hostdev[@mode='subsystem'][@type='pci']"
# 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']"
# 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")
return {"loader": loader, "type": loader_type, "readonly": readonly}
def get_vcpus(self):
def get_vcpus(self, config=False):
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"):
vcpu_id = vcpu.get("id")
enabled = vcpu.get("enabled")
@ -1597,7 +1598,8 @@ class wvmInstance(wvmConnect):
return model.get("type")
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)
tree = etree.fromstring(xml)
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:
parent = video_model.getparent()
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())
return
@ -2923,23 +2927,23 @@ class wvmInstance(wvmConnect):
self._defineXML(etree.tostring(tree).decode())
def add_guest_agent(self):
channel_xml = """
<channel type='unix'>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
"""
self._attach_device(channel_xml)
"""Adds the agent channel to the definitions that do not have it."""
flags = 0
for xml_flags, affect in self._definitions():
if not etree.fromstring(self._XMLDesc(xml_flags)).xpath(GUEST_AGENT_CHANNEL):
flags |= affect
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):
for xml_flags, affect in self._definitions():
tree = etree.fromstring(self._XMLDesc(xml_flags))
for target in tree.xpath(
"/domain/devices/channel[@type='unix']/target[@name='org.qemu.guest_agent.0']"
):
for target in tree.xpath(GUEST_AGENT_CHANNEL):
channel_xml = etree.tostring(target.getparent()).decode()
self.instance.detachDeviceFlags(channel_xml, affect)
def get_guest_agent(self):
def get_guest_agent(self, config=False):
def _get_agent(doc):
"""
Return agent channel object if it is defined.
@ -2952,7 +2956,7 @@ class wvmInstance(wvmConnect):
return channel
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):
"""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")
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):
p = proxy()
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.isActive.return_value = active
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)
result[name] = [names[c.args[1]] for c in getattr(vm.instance, method).call_args_list]
return result
@ -119,11 +120,25 @@ class DeviceEditsByStateTestCase(unittest.TestCase):
vm = wvmInstance.__new__(wvmInstance)
vm.instance = MagicMock()
vm.instance.isActive.return_value = True
vm._XMLDesc = MagicMock(return_value=DOMAIN)
vm.instance.attachDeviceFlags.side_effect = libvirtError("target sda already exists")
with self.assertRaises(libvirtError):
self.EDITS[name][1](vm)
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__":
unittest.main()

View file

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