diff --git a/README.md b/README.md
index 9dcae29f..a69ce36f 100644
--- a/README.md
+++ b/README.md
@@ -533,6 +533,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat
- A template VM is changed or deleted only by superusers and staff owners with the matching permission; other owners can still view, open the console and clone it. Power cycle no longer starts a template.
- A VM that vanishes from its compute keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers list and remove these records on the "Removed VMs" admin page.
- The log rotates (10 MB × 5) in `data/webvirtcloud.log`.
+- A disk can be grown while its VM runs or is paused, not only when it is shut off.
- gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3).
- Docker no longer publishes port 6080; the console goes through nginx on the panel's port (`WS_PUBLIC_PORT`, default 80).
diff --git a/instances/templates/instances/resize_tab.html b/instances/templates/instances/resize_tab.html
index 7d389bde..06006be3 100644
--- a/instances/templates/instances/resize_tab.html
+++ b/instances/templates/instances/resize_tab.html
@@ -148,7 +148,7 @@
{% endif %}
{% endfor %}
- {% if instance.status == 5 %}
+ {% if instance.status == 1 or instance.status == 3 or instance.status == 5 %}
{% else %}
diff --git a/instances/test_live_disk_resize.py b/instances/test_live_disk_resize.py
new file mode 100644
index 00000000..3da911e6
--- /dev/null
+++ b/instances/test_live_disk_resize.py
@@ -0,0 +1,86 @@
+"""Live checks that a disk grows while its VM runs or is paused.
+
+QEMU holds a running domain's image, so the size goes through blockResize;
+the guest and the volume must both see it, and the persistent definition
+stays as it was. Runs only with TEST_LIBVIRT_HOST set (see instances/livetest.py).
+"""
+
+import os
+import unittest
+
+import libvirt
+from django.test import SimpleTestCase
+from vrtManager.connection import connection_manager
+from vrtManager.instance import wvmInstance
+
+from . import livetest
+
+P = livetest.PREFIX
+PERSISTENT = libvirt.VIR_DOMAIN_XML_INACTIVE | libvirt.VIR_DOMAIN_XML_SECURE
+
+
+class LiveDiskResizeTestCase(SimpleTestCase):
+ @classmethod
+ def setUpClass(cls):
+ if not livetest.enabled():
+ raise unittest.SkipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
+ cls.args = (
+ os.environ["TEST_LIBVIRT_HOST"],
+ os.environ.get("TEST_LIBVIRT_LOGIN", ""),
+ os.environ.get("TEST_LIBVIRT_PASSWORD", ""),
+ int(os.environ.get("TEST_LIBVIRT_TYPE", 4)),
+ )
+ cls.conn = connection_manager.get_connection(*cls.args)
+ livetest.cleanup(cls.conn)
+ livetest.ensure_pool(cls.conn)
+ cls.inventory = livetest.inventory(cls.conn)
+ super().setUpClass()
+
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ livetest.remove_pool(cls.conn)
+ finally:
+ super().tearDownClass()
+ if livetest.inventory(cls.conn) != cls.inventory:
+ raise AssertionError("Live tests changed objects outside the test namespace")
+
+ def tearDown(self):
+ livetest.cleanup(self.conn)
+
+ def vm(self, name, fmt):
+ pool = self.conn.storagePoolLookupByName(livetest.POOL)
+ vol = pool.createXML(
+ f"{P}{name}.{fmt}64"
+ f"",
+ 0,
+ )
+ dom = self.conn.defineXML(
+ f"{P}{name}1281"
+ "hvm"
+ f""
+ f""
+ )
+ return dom, vol, wvmInstance(*self.args, f"{P}{name}")
+
+ def test_a_disk_grows_while_the_vm_runs_or_is_paused(self):
+ new_size = 96 << 20
+ for state in ("running", "paused"):
+ for fmt in ("qcow2", "raw"):
+ with self.subTest(state=state, fmt=fmt):
+ try:
+ dom, vol, vm = self.vm(f"resize-{state}-{fmt}", fmt)
+ dom.create()
+ if state == "paused":
+ dom.suspend()
+ before = dom.XMLDesc(PERSISTENT)
+
+ disk = vm.get_disk_devices()[0]
+ vm.resize_disk([{"path": disk["path"], "size_new": new_size}])
+
+ self.assertEqual(dom.blockInfo(disk["path"])[0], new_size, "the guest's disk")
+ self.assertEqual(vol.info()[1], new_size, "the volume")
+ self.assertEqual(dom.XMLDesc(PERSISTENT), before)
+ self.assertEqual(dom.state()[0], libvirt.VIR_DOMAIN_PAUSED if state == "paused" else libvirt.VIR_DOMAIN_RUNNING)
+ finally:
+ livetest.cleanup(self.conn)
diff --git a/instances/test_page_controls.py b/instances/test_page_controls.py
index 699043e2..8ff71945 100644
--- a/instances/test_page_controls.py
+++ b/instances/test_page_controls.py
@@ -150,6 +150,18 @@ class PageControlsTestCase(PageControls, TestCase):
owner = self.user("paused_deleter", owner={"is_delete": True})
self.assertIn("destroy", self.controls(owner, status=3))
+ def test_disks_grow_while_running_paused_or_shut_off(self):
+ # QEMU grows a running or paused VM's disk itself (blockResize)
+ changer = self.user("disk_resizer", owner={"is_change": True})
+ for status, enabled in ((1, True), (3, True), (5, True), (7, False)):
+ with self.subTest(status=status):
+ self.controls(changer, status=status)
+ buttons = lxml_html.fromstring(self.last_page).xpath(
+ "//form[@action=$a]//button[@type='submit']",
+ a=reverse("instances:resize_disk", args=[self.instance.id]),
+ )
+ self.assertEqual(bool(buttons), enabled)
+
def test_owner_with_change_and_no_extra_permissions(self):
changer = self.user("ctl_plain_changer", owner={"is_change": True})
self.assertEqual(self.controls(changer), CONSOLE | POWER | CHANGE)