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

fix(instances): a snapshot needs free space in the VM's storage pools

A disk-only snapshot was taken without any space check, though its
overlays grow as the guest writes and a later delete merges them down;
a full pool pauses the guest and can leave the snapshot undeletable.
Only a memory snapshot was checked, for the memory alone.

Before a snapshot, each storage pool with a disk of the VM must have
free a share of those disks' virtual size (10%, at least 1 GiB), and
the pool of the memory file the VM's memory on top. Both values are on
the Settings page (Snapshot Free Percent, Snapshot Free Min GiB; 0
turns a rule off, an invalid value keeps the default). The pools are
refreshed first, and a failed refresh or an unreadable volume refuses
the snapshot instead of skipping the check; a disk in no pool is not
checked. Nothing is reserved: later writes can still fill a pool.
This commit is contained in:
catborise 2026-10-13 11:47:40 +03:00
parent f766890825
commit 5f8f7a8643
8 changed files with 227 additions and 30 deletions

View file

@ -176,3 +176,15 @@ class LiveExternalSnapshotTestCase(SimpleTestCase):
with self.assertRaisesRegex(util.OperationError, "guest agent is not connected"):
vm.create_external_snapshot("q", quiesce=True)
self.assertEqual(dom.snapshotNum(0), 0)
def test_a_pool_without_room_for_the_overlays_is_refused(self):
# a sparse disk ten times the pool: a tenth of it is more than the pool has free
pool = self.conn.storagePoolLookupByName(livetest.POOL)
big = livetest.create_volume(self.conn, P + "snap-big", size_mib=pool.info()[1] * 10 // 1024**2)
dom = livetest.define_vm(self.conn, P + "snap-big", [big])
vm = wvmInstance(*self.args, None, uuid=dom.UUIDString())
with self.assertRaisesRegex(util.OperationError, f"Storage pool {livetest.POOL} has .* GiB free; the snapshot needs"):
vm.create_external_snapshot("x")
self.assertEqual(dom.snapshotNum(0), 0)
vm.create_external_snapshot("x", free_percent=0, free_min_gib=0)
vm.delete_external_snapshot("x")

View file

@ -12,6 +12,7 @@ from django.urls import reverse
from lxml import html as lxml_html
from vrtManager import util
from appsettings.models import AppSettings
from computes.models import Compute
from instances.models import Instance
@ -48,11 +49,22 @@ 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_memory_and_quiesce(self):
def test_create_passes_name_description_memory_quiesce_and_free_space(self):
free = {"free_percent": 10.0, "free_min_gib": 1.0} # the Settings page defaults
_, method, _ = self.post("create_external_snapshot", {"name": "a", "description": "d", "memory": "on"})
method.assert_called_once_with("a", "d", True, False)
method.assert_called_once_with("a", "d", True, False, **free)
_, method, _ = self.post("create_external_snapshot", {"name": "a", "description": "", "quiesce": "on"})
method.assert_called_once_with("a", "", False, True)
method.assert_called_once_with("a", "", False, True, **free)
def test_the_free_space_comes_from_the_settings_page(self):
for percent, minimum, expected in (("20", "0.5", {"free_percent": 20.0, "free_min_gib": 0.5}),
("0", "0", {"free_percent": 0.0, "free_min_gib": 0.0}),
("-1", "x", {}), ("nan", "inf", {})): # invalid: the backend default
with self.subTest(percent=percent, minimum=minimum):
AppSettings.objects.filter(key="SNAPSHOT_FREE_SPACE_PERCENT").update(value=percent)
AppSettings.objects.filter(key="SNAPSHOT_FREE_SPACE_MIN_GIB").update(value=minimum)
_, method, _ = self.post("create_external_snapshot", {"name": "a"})
method.assert_called_once_with("a", "", False, False, **expected)
def test_revert_and_delete_call_the_backend_once_and_leave_power_alone(self):
for view in ("revert_external_snapshot", "delete_external_snapshot"):

View file

@ -4,6 +4,7 @@ except ImportError:
import crypt
import functools
import json
import math
import os
import re
import socket
@ -1391,6 +1392,20 @@ def _warn_left_behind(request, files):
)
def _snapshot_free_space():
"""The free space each storage pool keeps for a snapshot, from the
Settings page; an invalid value leaves the backend's default."""
values = {}
for key, argument in (("SNAPSHOT_FREE_SPACE_PERCENT", "free_percent"), ("SNAPSHOT_FREE_SPACE_MIN_GIB", "free_min_gib")):
try:
value = float(getattr(app_settings, key))
except (AttributeError, TypeError, ValueError):
continue
if math.isfinite(value) and value >= 0:
values[argument] = value
return values
@require_POST
@serialize_instance_mutation
def create_external_snapshot(request, pk):
@ -1402,7 +1417,7 @@ def create_external_snapshot(request, pk):
memory = request.POST.get("memory") == "on"
quiesce = request.POST.get("quiesce") == "on"
try:
instance.proxy.create_external_snapshot(name, desc, memory, quiesce)
instance.proxy.create_external_snapshot(name, desc, memory, quiesce, **_snapshot_free_space())
except libvirtError as err:
messages.error(request, _("Snapshot was not created: %(err)s") % {"err": error_text(request, err)})
else: