From 42a1783178089ab0fd6d6fb4bf0d13540b252850 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 26 Oct 2026 12:06:11 +0300 Subject: [PATCH] fix(instances): the Resize tab changes only what was asked Disk sizes were pre-filled rounded to whole GiB, so a 3.5 GiB disk showed 4 and was grown, for good, whenever another disk was resized; an empty field or a disk outside a pool was a 500. A disk's field is now empty with its size as the placeholder: an empty field leaves the disk alone, a larger whole number grows it, anything else is refused with a message. vCPUs change only on a shut-off VM, also on a direct request: a running VM got them at its next start, past the quota counted against its live count. Current vCPUs or memory above the maximum are refused instead of failing (and dropping vCPU hot-plug) or being capped by libvirt. A running VM's maximum memory select is disabled, since its maximum is kept, and a custom memory value is sent only while its box is ticked. --- instances/templates/instance.html | 2 + instances/templates/instances/resize_tab.html | 17 ++-- instances/tests/test_quota.py | 3 +- instances/tests/test_quota_lock.py | 1 + instances/tests/test_resize_input.py | 78 +++++++++++++++++++ instances/views.py | 24 ++++-- 6 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 instances/tests/test_resize_input.py diff --git a/instances/templates/instance.html b/instances/templates/instance.html index 9f87dc3c..98fa3e07 100644 --- a/instances/templates/instance.html +++ b/instances/templates/instance.html @@ -320,6 +320,8 @@ var container = $(this).closest('.js-custom__container'); var toggles = container.find('.js-custom__toggle'); toggles.toggle(); + // a custom value is sent only while its box is ticked + container.find('input.js-custom__toggle').prop('disabled', !this.checked); }); }); diff --git a/instances/templates/instances/resize_tab.html b/instances/templates/instances/resize_tab.html index cf0321e8..b95b293a 100644 --- a/instances/templates/instances/resize_tab.html +++ b/instances/templates/instances/resize_tab.html @@ -97,7 +97,7 @@ {% endfor %} - + {% trans "Custom value" %} @@ -106,13 +106,18 @@ {% trans "Maximum Allocation" %} ({% trans "MB" %})
- {% for mem in memory_range %} {% endfor %} - + {% if instance.status == 5 %} + {% trans "Custom value" %} + {% else %} + {% trans "Shut off the VM to change the maximum" %} + {% endif %}
@@ -127,7 +132,7 @@ {% if can_change %}
{% csrf_token %} -

{% trans "Disk allocation (GB)" %}:

+

{% trans "New disk size (GiB); leave empty to keep a disk as it is" %}:

{% for disk in instance.disks %}
@@ -139,7 +144,9 @@
{% else %}
- + {# empty: the disk stays as it is #} +
{% endif %} diff --git a/instances/tests/test_quota.py b/instances/tests/test_quota.py index cd8cebff..c7322526 100644 --- a/instances/tests/test_quota.py +++ b/instances/tests/test_quota.py @@ -163,7 +163,8 @@ class QuotaViewTests(TestCase): return messages, check, wvm.return_value def test_unverified_quota_has_its_own_message(self): - messages, _, proxy = self.post("instances:resizevm_cpu", {"vcpu": 2, "cur_vcpu": 2}, quota=QUOTA_UNVERIFIED) + messages, _, proxy = self.post("instances:resizevm_cpu", {"vcpu": 2, "cur_vcpu": 2}, quota=QUOTA_UNVERIFIED, + status=5) self.assertEqual(len(messages), 1) self.assertIn("a host of your virtual machines cannot be reached", messages[0]) self.assertNotIn("quota reached", messages[0]) diff --git a/instances/tests/test_quota_lock.py b/instances/tests/test_quota_lock.py index 214a718d..5ac608f5 100644 --- a/instances/tests/test_quota_lock.py +++ b/instances/tests/test_quota_lock.py @@ -52,6 +52,7 @@ class QuotaRaceTestCase(TransactionTestCase): "instances.models.wvmInstance" ) as wvm: wvm.return_value.get_vcpu.return_value = 1 + wvm.return_value.instance.isActive.return_value = False # vCPUs change on a shut-off VM wvm.return_value.resize_cpu.side_effect = self.fake_resize threads = [threading.Thread(target=self.resize, args=(vm,)) for vm in self.vms] for t in threads: diff --git a/instances/tests/test_resize_input.py b/instances/tests/test_resize_input.py new file mode 100644 index 00000000..adc7bf07 --- /dev/null +++ b/instances/tests/test_resize_input.py @@ -0,0 +1,78 @@ +"""The Resize tab changes only what was asked, and refuses what libvirt would +not apply as asked.""" + +from unittest.mock import patch + +from accounts.models import UserInstance +from appsettings.settings import get_settings +from computes.models import Compute +from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages +from django.test import TestCase +from django.urls import reverse +from instances.models import Instance +from libvirt import VIR_DOMAIN_RUNNING + +GIB = 1 << 30 + + +class ResizeInputTests(TestCase): + def setUp(self): + get_settings() + self.user = get_user_model().objects.create_user("resize-user", password="pw") + self.client.force_login(self.user) + compute = Compute.objects.create(name="rs", hostname="192.0.2.1", login="u", password="p", type=1) + self.vm = Instance.objects.create(compute=compute, name="rs-vm", uuid="13131313-0000-0000-0000-000000000000") + UserInstance.objects.create(user=self.user, instance=self.vm, is_change=True) + + def post(self, view, data, status=5, disks=()): + with patch("instances.views.utils.check_user_quota", return_value=""), patch( + "instances.models.wvmInstance" + ) as wvm: + proxy = wvm.return_value + proxy.get_vcpu.return_value = 2 + proxy.get_memory.return_value = 2048 + proxy.get_cur_memory.return_value = 2048 + proxy.get_status.return_value = status + proxy.instance.isActive.return_value = status != 5 + proxy.get_disk_devices.return_value = [dict(disk) for disk in disks] + response = self.client.post(reverse(view, args=[self.vm.id]), data) + self.assertEqual(response.status_code, 302) + return [str(m) for m in get_messages(response.wsgi_request)], proxy + + def test_only_a_disk_given_a_larger_size_grows(self): + # a 3.5 GiB disk left as it was is not grown; a disk outside a pool has no size + disks = [{"dev": "vda", "size": 3 * GIB + GIB // 2}, {"dev": "vdb", "size": 20 * GIB}, {"dev": "vdc", "size": None}] + messages, proxy = self.post("instances:resize_disk", {"disk_size_vda": "", "disk_size_vdb": "30"}, disks=disks) + proxy.resize_disk.assert_called_once() + 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_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() + self.assertEqual(messages, ["Enter disk sizes as whole GiB"]) + + def test_vcpus_change_only_on_a_shut_off_vm(self): + messages, proxy = self.post("instances:resizevm_cpu", {"vcpu": "4", "cur_vcpu": "4"}, status=VIR_DOMAIN_RUNNING) + proxy.resize_cpu.assert_not_called() + self.assertEqual(messages, ["Shut off the VM to change its vCPUs"]) + + def test_current_vcpus_above_the_maximum_are_refused(self): + messages, proxy = self.post("instances:resizevm_cpu", {"vcpu": "2", "cur_vcpu": "4"}) + proxy.resize_cpu.assert_not_called() + self.assertEqual(messages, ["The current vCPUs cannot be more than the maximum"]) + + def test_current_memory_above_the_maximum_is_refused(self): + messages, proxy = self.post("instances:resize_memory", {"memory": "4096", "cur_memory": "8192"}) + proxy.resize_mem.assert_not_called() + self.assertEqual(messages, ["The current memory cannot be more than the maximum"]) + + def test_a_running_vm_offers_no_maximum_memory(self): + 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'