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

feat(instances): a VM's resize tab as a ledger, its vCPU changes kept

The Resize tab's CPU, Memory and Disk panes were horizontal forms with
bold host figures. The owner chose the ledger layout from three
rendered options: each section with its heading, the VM's figures and
the host's on the left, its fields on the right, and one line on what
applies now and what at the next start.

Codex reviewed resizing end to end; these are fixed:

- A vCPU turned on or off while the VM runs is kept at its next start:
  libvirt was asked without flags, which changes the running VM only.
- A paused VM offers to turn vCPUs on only: turning one off needs the
  guest, which does not run.
- A smaller disk size is refused, and so is the rest of the form,
  before anything grows; it was skipped without a word.
- vCPUs and memory below 1 are refused.
- The memory fields are numbers filled with the VM's values: the
  "Custom value" box, its script and the preset list are gone; an empty
  custom field silently used the preset.
- Units and words: MiB, not MB; a disk's field is its new size, with
  its size now above it; the messages say what the VM gets.
This commit is contained in:
catborise 2026-10-30 18:16:11 +03:00
parent 3d632f1592
commit f8e30d7668
11 changed files with 214 additions and 193 deletions

View file

@ -258,17 +258,6 @@
});
});
</script>
<script>
$(function () {
$('.js-custom__checkbox').change(function () {
var container = $(this).closest('.js-custom__container');
var toggles = container.find('.js-custom__toggle');
toggles.toggleClass("d-none");
// a custom value is sent only while its box is ticked
container.find('input.js-custom__toggle').prop('disabled', !this.checked);
});
});
</script>
<script src="{% static 'js/chart.umd.min.js' %}"></script>
<script src="{% static 'js/charts.js' %}"></script>
<script src="{% static 'js/poll.js' %}"></script>

View file

@ -1,4 +1,5 @@
{% load i18n %}
{% load bootstrap_icons %}
<div class="tab-pane" id="resize" role="tabpanel">
<!-- Nav tabs -->
<div class="card">
@ -25,172 +26,180 @@
<div class="tab-content card-body">
<div role="tabpanel" class="tab-pane active" id="resizevm_cpu">
{% if can_change %}
{% if instance.status == 5 or not instance.vcpus %}
<form action="{% url 'instances:resizevm_cpu' instance.id %}" method="post" role="form" aria-label="Resize instance cpu form">{% csrf_token %}
<p class="fw-bold">{% trans "Logical host CPUs" %} : {{ vcpu_host }}</p>
<div class="row">
<label class="col-sm-4 col-form-label"> {% trans "Current Allocation" %}</label>
<div class="col-sm-4">
<select name="cur_vcpu" class="form-select">
{% for cpu in instance.vcpu_range %}
<option value="{{ cpu }}" {% if cpu == instance.config_cur_vcpu %}selected{% endif %}>{{ cpu }}</option>
{% endfor %}
</select>
<div class="list-group">
<form class="list-group-item p-3 p-md-4" action="{% url 'instances:resizevm_cpu' instance.id %}" method="post" aria-labelledby="cpu-heading">{% csrf_token %}
<div class="row g-3">
<div class="col-12 col-lg-4">
<h2 class="h6" id="cpu-heading">{% bs_icon 'cpu' %} {% trans "CPU allocation" %}</h2>
<p class="small text-body-secondary mb-2">{% blocktrans with cur=instance.cur_vcpu max=instance.vcpu %}Now {{ cur }} of {{ max }} vCPUs.{% endblocktrans %}</p>
<p class="small mb-0">{% trans "Host" %}: <strong>{% blocktrans with count=vcpu_host %}{{ count }} logical CPUs{% endblocktrans %}</strong></p>
</div>
<div class="col-12 col-lg-8">
<p class="small text-body-secondary" id="cpu-help">
{% if instance.status == 5 %}{% trans "The VM gets these at its next start." %}
{% else %}{% trans "Shut the VM down to change its vCPUs." %}{% if instance.vcpus %} {% trans "While it runs, turn vCPUs on and off below." %}{% endif %}{% endif %}
</p>
<div class="row g-3">
<div class="col-12 col-sm-6">
<label class="form-label" for="cur_vcpu">{% trans "Current vCPUs" %}</label>
<select class="form-select" id="cur_vcpu" name="cur_vcpu" aria-describedby="cpu-help">
{% for cpu in instance.vcpu_range %}
<option value="{{ cpu }}"{% if cpu == instance.config_cur_vcpu %} selected{% endif %}>{{ cpu }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6">
<label class="form-label" for="max_vcpu">{% trans "Maximum vCPUs" %}</label>
<select class="form-select" id="max_vcpu" name="vcpu" aria-describedby="cpu-help">
{% for cpu in instance.vcpu_range %}
<option value="{{ cpu }}"{% if cpu == instance.config_vcpu %} selected{% endif %}>{{ cpu }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="text-end mt-3">
<button type="submit" class="btn btn-primary" name="resizevm_cpu"{% if instance.status != 5 %} disabled{% endif %}>{% trans "Resize CPU" %}</button>
</div>
</div>
</div>
<div class="row">
<label class="col-sm-4 col-form-label">{% trans "Maximum Allocation" %}</label>
<div class="col-sm-4">
<select name="vcpu" class="form-select">
{% for cpu in instance.vcpu_range %}
<option value="{{ cpu }}" {% if cpu == instance.config_vcpu %}selected{% endif %}>{{ cpu }}</option>
{% endfor %}
</select>
</div>
</div>
{% if instance.status == 5 %}
<button type="submit" class="btn btn-primary float-end" name="resizevm_cpu">{% trans "Resize" %}</button>
{% else %}
<button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button>
{% endif %}
</form>
<div class="clearfix"></div>
{% else %}
<p class="fw-bold">{% trans "Logical Instance Active/Maximum CPUs" %} : {{ instance.cur_vcpu }} / {{ instance.vcpu }} </p>
<div class="col-sm-3"></div>
<div class="col-sm-6">
{% if request.user.is_superuser %}
{% for id, vcpu in instance.vcpus.items %}
<form action="{% url 'instances:set_vcpu' instance.id %}" method="post" role="form" aria-label="Resize instance cpu form">{% csrf_token %}
<div class="d-grid col-sm-3">
<input name="id" value="{{ id }}" hidden/>
{% if vcpu.enabled == 'yes' and vcpu.hotpluggable == "yes" %}
<button type="submit" class="btn btn-primary" value="False" name="set_vcpu" title="{% trans "Disable" %}">{{ id }}</button>
{% elif vcpu.enabled == 'yes' and vcpu.hotpluggable == "no" %}
<button type="button" class="btn btn-info" title="{% trans "Constant" %}">{{ id }}</button>
{% else %}
<button type="submit" class="btn btn-secondary" value="True" name="set_vcpu" title="{% trans "Enable" %}">{{ id }}</button>
{% endif %}
{% if request.user.is_superuser and instance.vcpus and instance.status != 5 %}
<section class="list-group-item p-3 p-md-4" aria-labelledby="vcpus-heading">
<div class="row g-3">
<div class="col-12 col-lg-4">
<h3 class="h6" id="vcpus-heading">{% trans "Live vCPUs" %}</h3>
<p class="small text-body-secondary mb-0">
{% trans "Turn a vCPU on or off now; the next start keeps it so. vCPU 0 stays on." %}
{% if instance.status == 3 %}{% trans "A paused VM cannot let one go: resume it to turn vCPUs off." %}{% endif %}
</p>
</div>
<div class="col-12 col-lg-8">
<div class="d-flex flex-wrap gap-2">
{% for id, vcpu in instance.vcpus.items %}
<form action="{% url 'instances:set_vcpu' instance.id %}" method="post">{% csrf_token %}
<input type="hidden" name="id" value="{{ id }}">
{% if vcpu.hotpluggable != "yes" %}
<button type="button" class="btn btn-secondary" disabled>{% blocktrans %}{{ id }} · Fixed{% endblocktrans %}</button>
{% elif vcpu.enabled == "yes" %}
<button type="submit" class="btn btn-primary" name="set_vcpu" value="False"{% if instance.status == 3 %} disabled{% endif %}>{% blocktrans %}{{ id }} · On{% endblocktrans %}<span class="visually-hidden"> {% trans "turn off" %}</span></button>
{% else %}
<button type="submit" class="btn btn-secondary" name="set_vcpu" value="True">{% blocktrans %}{{ id }} · Off{% endblocktrans %}<span class="visually-hidden"> {% trans "turn on" %}</span></button>
{% endif %}
</form>
{% endfor %}
</div>
</form>
{% endfor %}
{% endif %}
</div>
</div>
<div class="col-sm-3"></div>
</section>
{% endif %}
{% else %}
{% trans "You don't have permission for resizing instance" %}
<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>
{% if request.user.is_superuser %}
<form class="list-group-item p-3 p-md-4" 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 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>
</form>
{% endif %}
</div>
{% else %}
<p class="mb-0">{% trans "You don't have permission for resizing instance" %}</p>
{% endif %}
</div>
<div role="tabpanel" class="tab-pane" id="resizevm_mem">
{% if can_change %}
<form class="list-group" action="{% url 'instances:resize_memory' instance.id %}" method="post" aria-labelledby="memory-heading">{% csrf_token %}
<div class="list-group-item p-3 p-md-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<h2 class="h6" id="memory-heading">{% bs_icon 'memory' %} {% trans "Memory allocation" %}</h2>
<p class="small text-body-secondary mb-2">{% blocktrans with cur=instance.cur_memory max=instance.memory %}Now {{ cur }} MiB of maximum {{ max }} MiB.{% endblocktrans %}</p>
<p class="small mb-0">{% trans "Host" %}: <strong>{% blocktrans with total=memory_host|filesizeformat %}{{ total }} memory{% endblocktrans %}</strong></p>
</div>
<div class="col-12 col-lg-8">
<p class="small text-body-secondary" id="memory-help">
{% if instance.status == 5 %}{% trans "The VM gets these at its next start." %}
{% else %}{% trans "The current memory changes now, up to the maximum; shut the VM down to change the maximum." %}{% endif %}
</p>
<div class="row g-3">
<div class="col-12 col-sm-6">
<label class="form-label" for="cur_memory">{% trans "Current memory (MiB)" %}</label>
<input class="form-control" type="number" id="cur_memory" name="cur_memory" min="1" step="1" value="{{ instance.cur_memory }}" aria-describedby="memory-help">
</div>
<div class="col-12 col-sm-6">
<label class="form-label" for="max_memory">{% trans "Maximum memory (MiB)" %}</label>
{# a running VM keeps its maximum: only the current memory changes #}
<input class="form-control" type="number" id="max_memory" name="memory" min="1" step="1" value="{{ instance.memory }}" aria-describedby="memory-help"{% if instance.status != 5 %} disabled{% endif %}>
</div>
</div>
<div class="text-end mt-3">
<button type="submit" class="btn btn-primary" name="resizevm_mem">{% trans "Resize memory" %}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
</div>
<div role="tabpanel" class="tab-pane" id="resizevm_mem">
{% if can_change %}
<form action="{% url 'instances:resize_memory' instance.id %}" method="post" role="form" aria-label="Resize instance memory form">
{% csrf_token %}
<p class="fw-bold">{% trans "Total host memory" %}: {{ memory_host|filesizeformat }}</p>
<div class="row">
<label class="col-sm-4 col-form-label">
{% trans "Current Allocation" %} ({% trans "MB" %})
</label>
<div class="col-sm-4 js-custom__container">
<select name="cur_memory" class="form-select js-custom__toggle">
{% for mem in memory_range %}
<option value="{{ mem }}" {% if mem == instance.cur_memory %}selected{% endif %}>{{ mem }}</option>
{% endfor %}
</select>
<input type="text" name="cur_memory_custom" class="form-select js-custom__toggle d-none" disabled />
<small><input type="checkbox" class="js-custom__checkbox" /> {% trans "Custom value" %}</small>
</div>
</div>
<div class="row">
<label class="col-sm-4 col-form-label">
{% trans "Maximum Allocation" %} ({% trans "MB" %})
</label>
<div class="col-sm-4 js-custom__container">
{# a running VM keeps its maximum: only the current memory changes #}
<select name="memory" class="form-select js-custom__toggle"{% if instance.status != 5 %} disabled{% endif %}>
{% for mem in memory_range %}
<option value="{{ mem }}" {% if mem == instance.memory %}selected{% endif %}>{{ mem }}</option>
{% endfor %}
</select>
{% if instance.status == 5 %}
<input type="text" name="memory_custom" class="form-select js-custom__toggle d-none" disabled />
<small><input type="checkbox" class="js-custom__checkbox" /> {% trans "Custom value" %}</small>
{% else %}
<small class="text-body-secondary">{% trans "Shut off the VM to change the maximum" %}</small>
{% endif %}
</div>
</div>
<button type="submit" class="btn btn-primary float-end" name="resizevm_mem">{% trans "Resize" %}</button>
</form>
{% else %}
{% trans "You don't have permission for resizing instance" %}
<button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button>
<p class="mb-0">{% trans "You don't have permission for resizing instance" %}</p>
{% endif %}
<div class="clearfix"></div>
</div>
<div role="tabpanel" class="tab-pane" id="resizevm_disk">
{% if can_change %}
<form action="{% url 'instances:resize_disk' instance.id %}" method="post" role="form" aria-label="Resize instance disk form">
{% csrf_token %}
<p class="fw-bold">{% trans "New disk size (GiB); leave empty to keep a disk as it is" %}:</p>
{% for disk in instance.disks %}
<div class="row">
<label class="col-sm-4 col-form-label">{% trans "Current Allocation" %} ({{ disk.dev }})</label>
{% if disk.storage is None %}
<div class="col-sm-4 js-custom__container">
<div class="alert alert-danger">
{% trans "Error getting disk info" %}
<form class="list-group" action="{% url 'instances:resize_disk' instance.id %}" method="post" aria-labelledby="disks-grow-heading">{% csrf_token %}
<div class="list-group-item p-3 p-md-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<h2 class="h6" id="disks-grow-heading">{% bs_icon 'device-hdd' %} {% trans "Grow disks" %}</h2>
<p class="small text-body-secondary mb-0" id="disks-grow-help">{% trans "A new size in GiB, larger than the disk's; empty keeps a disk as it is. Disks grow while the VM runs; the guest then grows its partition and file system." %}</p>
</div>
<div class="col-12 col-lg-8">
<div class="row g-3">
{% for disk in instance.disks %}
<div class="col-12 col-sm-6">
<label class="form-label" for="disk_size_{{ disk.dev }}">{{ disk.dev }}{% if disk.format %} <span class="small text-body-secondary">· {{ disk.format }}</span>{% endif %}</label>
{% if disk.storage is None %}
<p class="small text-danger mb-0">{% trans "Error getting disk info" %}</p>
{% else %}
<p class="small text-body-secondary mb-2">{% blocktrans with size=disk.size|filesizeformat %}Now {{ size }}{% endblocktrans %}</p>
<div class="input-group">
<input class="form-control" type="number" id="disk_size_{{ disk.dev }}" name="disk_size_{{ disk.dev }}" min="1" step="1" aria-describedby="disks-grow-help">
<span class="input-group-text">GiB</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="text-end mt-3">
{% if instance.status == 1 or instance.status == 3 or instance.status == 5 %}
<button type="submit" class="btn btn-primary" name="resizevm_disk">{% trans "Grow disks" %}</button>
{% else %}
<button type="button" class="btn btn-primary" disabled>{% trans "Grow disks" %}</button>
{% endif %}
</div>
</div>
{% else %}
<div class="col-sm-4 js-custom__container">
{# empty: the disk stays as it is #}
<input type="number" name="disk_size_{{ disk.dev }}" class="form-control" min="1" step="1"
placeholder="{{ disk.size|filesizeformat }}" aria-label="{% trans "New size (GiB)" %} {{ disk.dev }}" />
</div>
{% endif %}
</div>
{% endfor %}
{% if instance.status == 1 or instance.status == 3 or instance.status == 5 %}
<button type="submit" class="btn btn-primary float-end" name="resizevm_disk">{% trans "Resize" %}</button>
{% else %}
<button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button>
{% endif %}
</form>
</div>
</form>
{% else %}
{% trans "You don't have permission for resizing instance" %}
<button class="btn btn-primary float-end disabled" disabled>{% trans "Resize" %}</button>
<p class="mb-0">{% trans "You don't have permission for resizing instance" %}</p>
{% endif %}
<div class="clearfix"></div>
</div>
</div>
</div>

View file

@ -252,6 +252,9 @@ class LiveGuestTestCase(TestCase):
self.assertEqual(online(), 1)
self.post("set_vcpu", inst, {"id": "1", "set_vcpu": "True"})
self.assertTrue(wait_for(lambda: online() == 2, 60), "guest did not bring the new vCPU online")
# the next start keeps it
persistent = etree.fromstring(dom.XMLDesc(libvirt.VIR_DOMAIN_XML_INACTIVE))
self.assertEqual(persistent.xpath("vcpus/vcpu[@id='1']/@enabled"), ["yes"])
def test_agent_reports_the_guest_os_and_nic(self):
dom, inst = self.boot("gagent")

View file

@ -65,7 +65,7 @@ class MissingFieldTestCase(TestCase):
self.assert_reported(self.post("resizevm_cpu", {"vcpu": "²", "cur_vcpu": "1"}))
def test_resize_memory_with_a_unicode_digit(self):
self.assert_reported(self.post("resize_memory", {"memory": "512", "cur_memory_custom": "²"}))
self.assert_reported(self.post("resize_memory", {"memory": "512", "cur_memory": "²"}))
def test_set_qos_with_non_numeric_rate(self):
self.assert_reported(self.post("set_qos", {"qos_direction": "inbound", "net-mac-0": "52:54:00:00:00:01", "qos_average": "fast"}))

View file

@ -168,6 +168,19 @@ class PendingEditsTestCase(TestCase):
# not an owner yet and not an administrator, who can use every VM anyway
self.assertEqual(doc.xpath("//select[@id='add-owner-user']/option/text()"), ["pe-new"])
def test_a_paused_vm_offers_to_turn_vcpus_on_only(self):
# unplugging a vCPU needs the guest, which a paused VM does not run
vcpus = {"0": {"enabled": "yes", "hotpluggable": "no", "order": "1"},
"1": {"enabled": "yes", "hotpluggable": "yes", "order": "2"},
"2": {"enabled": "no", "hotpluggable": "yes", "order": "3"}}
for status, off_disabled in ((1, False), (3, True)):
with self.subTest(status=status):
doc = self.page(status=status, get_vcpus=(vcpus, vcpus))
buttons = {b.get("value"): b.get("disabled") is not None
for b in doc.xpath("//form[@action=$a]//button[@name='set_vcpu']",
a=reverse("instances:set_vcpu", args=[self.instance.id]))}
self.assertEqual(buttons, {"False": off_disabled, "True": False})
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

View file

@ -48,6 +48,26 @@ class ResizeInputTests(TestCase):
self.assertEqual([(d["dev"], d["size_new"]) for d in proxy.resize_disk.call_args.args[0]], [("vdb", 30 * GIB)])
self.assertEqual(messages, ["Disk is resized: vdb"])
def test_a_smaller_disk_size_refuses_every_change(self):
# a disk does not shrink: one smaller size leaves the others as they are too
disks = [{"dev": "vda", "size": 10 * GIB}, {"dev": "vdb", "size": 20 * GIB}]
messages, proxy = self.post("instances:resize_disk", {"disk_size_vda": "5", "disk_size_vdb": "30"}, disks=disks)
proxy.resize_disk.assert_not_called()
self.assertEqual(messages, ["vda is 10.0\xa0GB: a disk cannot shrink"])
def test_vcpus_and_memory_must_be_at_least_one(self):
for view, data, setter in (("instances:resizevm_cpu", {"vcpu": "2", "cur_vcpu": "0"}, "resize_cpu"),
("instances:resize_memory", {"memory": "2048", "cur_memory": "-1"}, "resize_mem")):
with self.subTest(view):
messages, proxy = self.post(view, data)
getattr(proxy, setter).assert_not_called()
self.assertTrue(messages[-1].startswith("Enter a whole number"), messages)
def test_a_cpu_resize_says_what_the_vm_gets(self):
messages, proxy = self.post("instances:resizevm_cpu", {"vcpu": "4", "cur_vcpu": "2"})
proxy.resize_cpu.assert_called_once_with("2", "4")
self.assertEqual(messages, ["vCPUs set to 2 (maximum 4)"])
def test_a_disk_size_that_is_not_a_whole_number_is_refused(self):
messages, proxy = self.post("instances:resize_disk", {"disk_size_vda": "4.5"}, disks=[{"dev": "vda", "size": GIB}])
proxy.resize_disk.assert_not_called()
@ -72,7 +92,5 @@ class ResizeInputTests(TestCase):
with patch("instances.models.wvmInstance"):
self.vm.__dict__["status"] = VIR_DOMAIN_RUNNING
from django.template.loader import render_to_string
html = render_to_string("instances/resize_tab.html", {"instance": self.vm, "can_change": True,
"memory_range": [1024, 2048]})
self.assertRegex(html, r'<select name="memory" class="form-select js-custom__toggle" disabled>')
self.assertNotIn('name="memory_custom"', html)
html = render_to_string("instances/resize_tab.html", {"instance": self.vm, "can_change": True})
self.assertRegex(html, r'<input [^>]*name="memory"[^>]*disabled>')

View file

@ -165,8 +165,9 @@ class SnapshotTabTestCase(TestCase):
self.assertEqual(box.get("disabled") is None, offered)
def test_memory_checkbox_only_for_a_running_vm(self):
self.assertFalse(self.page(status=1).xpath("//input[@name='memory']")[0].get("disabled"))
self.assertIsNotNone(self.page(status=5).xpath("//input[@name='memory']")[0].get("disabled"))
checkbox = "//div[@id='snapshots']//input[@name='memory']" # the Resize tab has a memory field too
self.assertFalse(self.page(status=1).xpath(checkbox)[0].get("disabled"))
self.assertIsNotNone(self.page(status=5).xpath(checkbox)[0].get("disabled"))
def test_a_running_job_disables_actions(self):
# its progress shows in the job panel, not in the tab; the tab says why the actions are off

View file

@ -321,7 +321,7 @@ class InstancesTestCase(TestCase):
response = self.client.post(
reverse("instances:resize_memory", args=[self.instance.id]),
{"memory_custom": 500, "cur_memory_custom": 200},
{"memory": 500, "cur_memory": 200},
)
self.assertRedirects(
response, reverse("instances:instance", args=[self.instance.id]) + "#resize"

View file

@ -10,7 +10,6 @@ import re
import socket
import subprocess
import time
from bisect import insort
from ipaddress import ip_address
from accounts.models import UserInstance, UserSSHKey
@ -30,6 +29,7 @@ from django.http import Http404, HttpResponse, JsonResponse
from django.db.models import Prefetch, prefetch_related_objects
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.template.defaultfilters import filesizeformat
from django.utils import timezone
from django.utils.translation import gettext_noop as _
from django.views.decorators.http import require_POST
@ -278,11 +278,6 @@ def _instance_page(request, pk, instance, compute):
request.user.is_superuser or can_manage_console or can_clone or can_change or instance.guest_agent_ready
)
memory_range = [256, 512, 768, 1024, 2048, 3072, 4096, 6144, 8192, 16384]
if instance.memory not in memory_range:
insort(memory_range, instance.memory)
if instance.cur_memory not in memory_range:
insort(memory_range, instance.cur_memory)
clone_free_names = utils.get_clone_free_names()
default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS
@ -1050,7 +1045,7 @@ def resizevm_cpu(request, pk):
new_vcpu = request.POST.get("vcpu", "")
new_cur_vcpu = request.POST.get("cur_vcpu", "")
if not _is_int(new_vcpu) or not _is_int(new_cur_vcpu):
if not _is_int(new_vcpu) or not _is_int(new_cur_vcpu) or int(new_cur_vcpu) < 1:
messages.error(request, _("Enter a whole number of vCPUs"))
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
# a running VM would get them only at its next start, past the quota counted now
@ -1077,11 +1072,7 @@ def resizevm_cpu(request, pk):
vcpu = new_vcpu
with audit_vm(request, instance, f"set vCPUs to {cur_vcpu} (maximum {vcpu})"):
instance.proxy.resize_cpu(cur_vcpu, vcpu)
msg = _("CPU is resized: %(old)s to %(new)s") % {
"old": cur_vcpu,
"new": vcpu,
}
messages.success(request, msg)
messages.success(request, _("vCPUs set to %(cur)s (maximum %(max)s)") % {"cur": cur_vcpu, "max": vcpu})
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -1091,22 +1082,15 @@ def resizevm_cpu(request, pk):
def resize_memory(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
memory = instance.proxy.get_memory()
cur_memory = instance.proxy.get_cur_memory()
new_memory = request.POST.get("memory", "")
new_memory_custom = request.POST.get("memory_custom", "")
if new_memory_custom:
new_memory = new_memory_custom
new_cur_memory = request.POST.get("cur_memory", "")
new_cur_memory_custom = request.POST.get("cur_memory_custom", "")
if new_cur_memory_custom:
new_cur_memory = new_cur_memory_custom
# The quota charges the maximum. An active VM keeps it (resize_mem changes
# only the current memory there); pass the current maximum, so a VM that
# shuts down meanwhile does not get an unchecked one.
if instance.proxy.instance.isActive():
new_memory = memory
if not _is_int(new_memory) or not _is_int(new_cur_memory):
if not _is_int(new_memory) or not _is_int(new_cur_memory) or int(new_cur_memory) < 1:
messages.error(request, _("Enter a whole number for memory"))
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
if int(new_cur_memory) > int(new_memory):
@ -1124,15 +1108,8 @@ def resize_memory(request, pk):
else:
with audit_vm(request, instance, f"set memory to {new_cur_memory} MiB (maximum {new_memory} MiB)"):
instance.proxy.resize_mem(new_cur_memory, new_memory)
msg = _(
"Memory is resized: current/max: %(old_cur)s/%(old_max)s to %(new_cur)s/%(new_max)s"
) % {
"old_cur": cur_memory,
"old_max": memory,
"new_cur": new_cur_memory,
"new_max": new_memory,
}
messages.success(request, msg)
messages.success(request, _("Memory set to %(cur)s MiB (maximum %(max)s MiB)") % {
"cur": new_cur_memory, "max": new_memory})
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -1153,6 +1130,11 @@ def resize_disk(request, pk):
if not _is_int(size):
messages.error(request, _("Enter disk sizes as whole GiB"))
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
# a disk does not shrink: one smaller size refuses them all
if int(size) << 30 < disk["size"]:
messages.error(request, _("%(dev)s is %(size)s: a disk cannot shrink") % {
"dev": disk["dev"], "size": filesizeformat(disk["size"])})
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
if int(size) << 30 > disk["size"]:
disk["size_new"] = int(size) << 30
disks_new.append(disk)

View file

@ -1307,7 +1307,8 @@ class wvmInstance(wvmConnect):
self._defineXML(etree.tostring(tree).decode())
def set_vcpu(self, cpu_id, enabled):
self.instance.setVcpu(str(cpu_id), enabled)
"""Turns a hotpluggable vCPU of the active VM on or off, now and for its next start."""
self.instance.setVcpu(str(cpu_id), enabled, VIR_DOMAIN_AFFECT_LIVE | VIR_DOMAIN_AFFECT_CONFIG)
def set_vcpu_hotplug(self, status, vcpus_hotplug=0):
"""vcpus_hotplug: the vCPUs enabled at boot, 0 keeps the current ones;

View file

@ -70,6 +70,11 @@ class DetachDiskTestCase(unittest.TestCase):
class VcpuHotplugTestCase(unittest.TestCase):
def test_a_vcpu_turned_on_or_off_stays_so_at_the_next_start(self):
p = proxy()
p.set_vcpu("1", 1)
p.instance.setVcpu.assert_called_once_with("1", 1, VIR_DOMAIN_AFFECT_LIVE | VIR_DOMAIN_AFFECT_CONFIG)
def test_enabling_hotplug_keeps_the_current_vcpus_enabled(self):
p = proxy(active=False)
p.instance.info.return_value = [5]