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

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.
This commit is contained in:
catborise 2026-10-13 10:33:17 +03:00
parent 64ea12b0ce
commit f766890825
7 changed files with 68 additions and 10 deletions

View file

@ -66,9 +66,18 @@
{% endif %}
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="memory" id="snapshot-memory" {% if instance.status != 1 %}disabled{% endif %}>
<input class="form-check-input" type="checkbox" name="memory" id="snapshot-memory" {% if instance.status != 1 %}disabled{% endif %}
onchange="var q = document.getElementById('snapshot-quiesce'); if (q.dataset.offered) { q.disabled = this.checked; q.checked = !this.checked; }">
<label class="form-check-label" for="snapshot-memory">{% trans "Save memory too (running VMs only); the VM resumes as it was when reverted" %}</label>
</div>
<div class="form-check mb-3">
{% if instance.status == 1 and instance.guest_agent_ready %}
<input class="form-check-input" type="checkbox" name="quiesce" id="snapshot-quiesce" data-offered="1" checked>
{% else %}
<input class="form-check-input" type="checkbox" name="quiesce" id="snapshot-quiesce" checked disabled>
{% endif %}
<label class="form-check-label" for="snapshot-quiesce">{% trans "Freeze the guest's file systems while the disks are snapshotted (running VMs with a connected QEMU guest agent; not with memory)" %}</label>
</div>
<p class="small">{% trans "A VM with snapshots cannot be migrated." %}</p>
</form>
<div class="clearfix"></div>

View file

@ -166,3 +166,13 @@ class LiveExternalSnapshotTestCase(SimpleTestCase):
dom.snapshotCreateXML("<domainsnapshot><name>i</name></domainsnapshot>", 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 = "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></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)

View file

@ -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"))

View file

@ -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: