From f766890825ab702c32f20909fbbc39724bb4015f Mon Sep 17 00:00:00 2001 From: catborise Date: Tue, 13 Oct 2026 10:33:17 +0300 Subject: [PATCH] feat(instances): external snapshots can freeze the guest file systems A disk-only snapshot of a running VM is crash-consistent: data the guest has not written yet is missing from it. With a connected QEMU guest agent, the snapshot form now offers to freeze the guest's file systems while the disks are snapshotted (libvirt's QUIESCE flag); the box is checked by default and can be cleared. It is offered only for a running VM whose agent is connected, and not together with memory: libvirt accepts quiesce only for disk-only snapshots, and a memory snapshot keeps the whole guest state anyway. A request that does not fit is refused before libvirt is called. --- README.md | 2 +- .../templates/instances/snapshots_tab.html | 11 ++++++++++- instances/tests/test_live_snapshots.py | 10 ++++++++++ instances/tests/test_snapshot_views.py | 18 +++++++++++++----- instances/views.py | 3 ++- vrtManager/instance.py | 16 ++++++++++++++-- vrtManager/tests/test_external_snapshots.py | 18 ++++++++++++++++++ 7 files changed, 68 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b566e685..558e9103 100644 --- a/README.md +++ b/README.md @@ -556,7 +556,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat - 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). - A theme picked on the Settings page rewrites two tracked files; the update and rollback steps put back the shipped theme. Pick yours again afterwards, also after Docker recreates the container. - With the bundled nginx config, browsers revalidate static files on each load (304 when unchanged), so an update or a theme change shows without a hard reload. Files cached before this update can stay until a hard reload (Ctrl+F5) of each affected page, or until the site's browser cache is cleared once; an external caching proxy may need a purge. -- External snapshots use libvirt's snapshot API and need libvirt 9.9 or newer on the compute hosts. They work on UEFI VMs and running, paused or shut-off VMs, can save memory too, and a revert keeps disks added later (it detaches them). A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. Snapshot names no longer get an `s1.` prefix; existing ones keep theirs and can still be reverted and deleted, except that a snapshot an older version took of a UEFI VM can only be deleted. On NFS, libvirt removes merged snapshot files as the qemu user, who needs write access to the image directory; files it could not remove are named in a warning after the delete or revert. Deleting a VM with its data removes its snapshot files too, except a template it grew from and files another VM uses. +- External snapshots use libvirt's snapshot API and need libvirt 9.9 or newer on the compute hosts. They work on UEFI VMs and running, paused or shut-off VMs, can save memory too or, on a running VM with a connected QEMU guest agent, freeze the guest's file systems (the default there), and a revert keeps disks added later (it detaches them). A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. Snapshot names no longer get an `s1.` prefix; existing ones keep theirs and can still be reverted and deleted, except that a snapshot an older version took of a UEFI VM can only be deleted. On NFS, libvirt removes merged snapshot files as the qemu user, who needs write access to the image directory; files it could not remove are named in a warning after the delete or revert. Deleting a VM with its data removes its snapshot files too, except a template it grew from and files another VM uses. - A storage volume a VM still needs (a disk, a backing file or a snapshot's file) is no longer deleted from the storage page or the API; the delete is refused with the reason. Only VMs on the same compute are checked. - 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/snapshots_tab.html b/instances/templates/instances/snapshots_tab.html index 2060653c..1f6f994b 100755 --- a/instances/templates/instances/snapshots_tab.html +++ b/instances/templates/instances/snapshots_tab.html @@ -66,9 +66,18 @@ {% endif %}
- +
+
+ {% if instance.status == 1 and instance.guest_agent_ready %} + + {% else %} + + {% endif %} + +

{% trans "A VM with snapshots cannot be migrated." %}

diff --git a/instances/tests/test_live_snapshots.py b/instances/tests/test_live_snapshots.py index d1b17c28..471f83fb 100644 --- a/instances/tests/test_live_snapshots.py +++ b/instances/tests/test_live_snapshots.py @@ -166,3 +166,13 @@ class LiveExternalSnapshotTestCase(SimpleTestCase): dom.snapshotCreateXML("i", 0) with self.assertRaisesRegex(util.OperationError, "internal snapshots"): vm.create_external_snapshot("x") + + def test_quiesce_without_a_connected_agent_is_refused(self): + # no guest OS runs an agent here: the channel exists but is not connected + channel = "" + for name, extra in (("snap-noagent", ""), ("snap-agentchan", channel)): + with self.subTest(name): + dom, vm = self.vm(name, "running", extra_devices=extra) + with self.assertRaisesRegex(util.OperationError, "guest agent is not connected"): + vm.create_external_snapshot("q", quiesce=True) + self.assertEqual(dom.snapshotNum(0), 0) diff --git a/instances/tests/test_snapshot_views.py b/instances/tests/test_snapshot_views.py index 8e3521da..724f8038 100644 --- a/instances/tests/test_snapshot_views.py +++ b/instances/tests/test_snapshot_views.py @@ -48,11 +48,11 @@ class SnapshotViewsTestCase(TestCase): self.assertEqual(response.status_code, 302) return proxy, method, [str(m) for m in get_messages(response.wsgi_request)] - def test_create_passes_name_description_and_memory(self): + def test_create_passes_name_description_memory_and_quiesce(self): _, method, _ = self.post("create_external_snapshot", {"name": "a", "description": "d", "memory": "on"}) - method.assert_called_once_with("a", "d", True) - _, method, _ = self.post("create_external_snapshot", {"name": "a", "description": ""}) - method.assert_called_once_with("a", "", False) + method.assert_called_once_with("a", "d", True, False) + _, method, _ = self.post("create_external_snapshot", {"name": "a", "description": "", "quiesce": "on"}) + method.assert_called_once_with("a", "", False, True) def test_revert_and_delete_call_the_backend_once_and_leave_power_alone(self): for view in ("revert_external_snapshot", "delete_external_snapshot"): @@ -83,7 +83,7 @@ class SnapshotViewsTestCase(TestCase): class SnapshotTabTestCase(TestCase): setUp = SnapshotViewsTestCase.setUp # same VM and superuser, without rerunning the view tests - def page(self, external=(), internal=(), jobs=(), status=1, nvram=None): + def page(self, external=(), internal=(), jobs=(), status=1, nvram=None, agent=False): with patch("instances.models.wvmInstance") as mock_wvm, patch.object( Compute, "status", new_callable=PropertyMock, return_value=True ): @@ -101,6 +101,7 @@ class SnapshotTabTestCase(TestCase): proxy.get_snapshot_jobs.return_value = list(jobs) proxy.get_status.return_value = status proxy.get_nvram.return_value = nvram + proxy.is_agent_ready.return_value = agent proxy.get_memory.return_value = proxy.get_cur_memory.return_value = 1024 proxy.get_max_memory.return_value = 4096 proxy.get_vcpu.return_value = proxy.get_cur_vcpu.return_value = 1 @@ -144,6 +145,13 @@ class SnapshotTabTestCase(TestCase): [button] = doc.xpath("//button[@name='revert_external_snapshot']") self.assertEqual("powered off" in button.get("data-confirm"), restarts) + def test_quiesce_is_checked_and_offered_with_a_running_vm_and_its_agent(self): + for status, agent, offered in ((1, True, True), (1, False, False), (3, True, False), (5, True, False)): + with self.subTest(status=status, agent=agent): + [box] = self.page(status=status, agent=agent).xpath("//input[@name='quiesce']") + self.assertIsNotNone(box.get("checked")) + 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")) diff --git a/instances/views.py b/instances/views.py index 13976c01..60f3be28 100755 --- a/instances/views.py +++ b/instances/views.py @@ -1400,8 +1400,9 @@ def create_external_snapshot(request, pk): name = request.POST.get("name", "") desc = request.POST.get("description", "") memory = request.POST.get("memory") == "on" + quiesce = request.POST.get("quiesce") == "on" try: - instance.proxy.create_external_snapshot(name, desc, memory) + instance.proxy.create_external_snapshot(name, desc, memory, quiesce) except libvirtError as err: messages.error(request, _("Snapshot was not created: %(err)s") % {"err": error_text(request, err)}) else: diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 39803ae5..ba0e8646 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -34,6 +34,7 @@ try: VIR_MIGRATE_UNSAFE, VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC, VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY, + VIR_DOMAIN_SNAPSHOT_CREATE_QUIESCE, VIR_DOMAIN_SNAPSHOT_REVERT_PAUSED, VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING, VIR_ERR_CONFIG_UNSUPPORTED, @@ -1575,14 +1576,23 @@ class wvmInstance(wvmConnect): """ self._snapshotCreateXML(xml, 0) - def create_external_snapshot(self, name, desc="", memory=False): + def create_external_snapshot(self, name, desc="", memory=False, quiesce=False): """A disk-only snapshot, or with memory too (running VMs only). Every disk gets a libvirt-named overlay next to it; the memory file goes - next to the first disk.""" + next to the first disk. quiesce: the guest agent freezes the guest's + file systems for a disk-only snapshot of a running VM (libvirt + accepts it only without memory, which keeps the whole state anyway).""" if not SNAPSHOT_NAME.fullmatch(name): raise util.OperationError( "Snapshot names are 1-64 letters, digits, '.', '_' or '-' and do not start with '.'" ) + if quiesce: + if memory: + raise util.OperationError("A snapshot with memory is not quiesced; it keeps the whole guest state") + if self.get_status() != 1: + raise util.OperationError("Only a running VM's file systems can be frozen") + if not self.is_agent_ready(): + raise util.OperationError("The QEMU guest agent is not connected; it freezes the file systems") self._check_snapshot_state() if self.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL): raise util.OperationError("The VM has internal snapshots; external snapshots cannot be mixed with them") @@ -1610,6 +1620,8 @@ class wvmInstance(wvmConnect): flags = VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC if not memory: flags |= VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY + if quiesce: + flags |= VIR_DOMAIN_SNAPSHOT_CREATE_QUIESCE self._snapshotCreateXML(xml, flags) self._refresh_pools_after_snapshot() diff --git a/vrtManager/tests/test_external_snapshots.py b/vrtManager/tests/test_external_snapshots.py index b778424b..f95327b3 100644 --- a/vrtManager/tests/test_external_snapshots.py +++ b/vrtManager/tests/test_external_snapshots.py @@ -212,6 +212,24 @@ class CreateTestCase(unittest.TestCase): "", xml ) + def test_a_quiesced_snapshot_freezes_the_guest_file_systems(self): + p = proxy() + p.is_agent_ready = lambda: True + p.create_external_snapshot("q", quiesce=True) + _, flags = created(p) + self.assertEqual(flags, DISK_ONLY_ATOMIC | libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_QUIESCE) + + def test_quiesce_needs_a_running_vm_with_its_agent_and_no_memory(self): + # libvirt accepts quiesce only for disk-only snapshots, through the agent + for status, ready, memory, pattern in ( + (5, True, False, "running VM"), (3, True, False, "running VM"), + (1, False, False, "guest agent is not connected"), (1, True, True, "memory"), + ): + with self.subTest(status=status, ready=ready, memory=memory): + p = proxy(status=status) + p.is_agent_ready = lambda ready=ready: ready + self.refused(p, pattern, quiesce=True, memory=memory) + def refused(self, p, pattern, **kwargs): with self.assertRaisesRegex(util.OperationError, pattern): p.create_external_snapshot(kwargs.pop("name", "s"), **kwargs)