1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +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

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

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:

View file

@ -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):
</domainsnapshot>"""
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()

View file

@ -212,6 +212,24 @@ class CreateTestCase(unittest.TestCase):
"<memory snapshot='external' file='/pool/11111111-2222-3333-4444-555555555555.m.mem'/>", 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)