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

feat(instances): external snapshots first, internal ones a fallback

The Snapshots tab had three sub-tabs: internal, external and the
lists, with the two kinds side by side as equals. The owner set the
direction: external snapshots are the way; internal ones pause the VM
while its memory is saved and can take long, so they stay only as a
fallback for a VM that cannot take an external one.

The tab now has the external snapshot form on top, its name and
description on one line, with its memory and freeze options; the
internal snapshot under a folded "fallback" line with its warning; and
one list of all snapshots, newest first, each on one line with its
name, description, kind and date, and Revert and Delete.

Codex reviewed snapshots end to end; these are fixed:

- The pause and memory lines follow the VM's state and the kind.
- An internal snapshot is reverted to on a shut-off VM only, on the
  server too; the tab alone held that rule.
- A paused VM's revert says it comes back paused, not that it starts.
- An external snapshot waits, with the reason, while snapshots newer
  than the one reverted to exist; the server refused it unexplained.
- Deleting an external snapshot says its files are merged with the
  disks; "into the next one" was wrong for the last one.
- The description has no 45-character limit only the browser kept.
- Freezing is not shown ticked where it cannot be offered, and
  unticking memory gives back the earlier freeze choice.
- Internal create, revert and delete refuse a saved state or a running
  disk job, as external ones do.
This commit is contained in:
catborise 2026-10-30 21:40:00 +03:00
parent 60f12cefa1
commit 7fc55537e5
7 changed files with 186 additions and 203 deletions

View file

@ -1771,6 +1771,7 @@ class wvmInstance(wvmConnect):
self.instance.snapshotCreateXML(xml, flag)
def create_snapshot(self, name, desc=None):
self._check_snapshot_state()
if self.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
raise util.OperationError("The VM has external snapshots; internal snapshots cannot be mixed with them")
state = "shutoff" if self.get_status() == 5 else "running"
@ -2071,6 +2072,7 @@ class wvmInstance(wvmConnect):
"description": tree.findtext("description") or "",
"memory": tree.find("memory[@snapshot='external']") is not None,
"current": bool(snap.isCurrent(0)),
"children": snap.numChildren(0),
"detaches": [{"dev": dev, "path": path} for dev, path in detaches.items()],
}
)
@ -2222,18 +2224,24 @@ class wvmInstance(wvmConnect):
return self.instance.snapshotLookupByName(name, 0)
def _check_snapshot_state(self):
"""Refusals shared by external snapshot create, revert and delete. The
internal/external mix is checked only on create, so a VM that is
already mixed can still be cleaned up."""
"""Refusals shared by snapshot create, revert and delete, internal and
external. The internal/external mix is checked only on create, so a
VM that is already mixed can still be cleaned up."""
if self.instance.hasManagedSaveImage(0):
raise util.OperationError("The VM has a saved state; start it or remove the saved state first")
if self.get_snapshot_jobs():
raise util.OperationError("A snapshot job is still running on this VM; try again when it ends")
def snapshot_delete(self, snapshot):
self._check_snapshot_state()
self._snapshot(snapshot, external=False).delete(0)
def snapshot_revert(self, snapshot):
"""An internal snapshot is reverted to on a shut-off VM; one of a
running VM starts it again as it was then."""
if self.get_status() != 5:
raise util.OperationError("Shut the VM down to revert to an internal snapshot")
self._check_snapshot_state()
self.instance.revertToSnapshot(self._snapshot(snapshot, external=False), 0)
def get_managed_save_image(self):

View file

@ -236,6 +236,27 @@ class SnapshotStateTestCase(unittest.TestCase):
def test_a_clean_vm_passes(self):
proxy()._check_snapshot_state()
def test_internal_snapshots_are_refused_alike(self):
# a saved state or a running job refuses internal create, revert and delete too
for state in ({"managed_save": True}, {"jobs": {"vda": {"cur": 1, "end": 2}}}):
for name, act in (("create", lambda p: p.create_snapshot("s")),
("revert", lambda p: p.snapshot_revert("i")),
("delete", lambda p: p.snapshot_delete("i"))):
with self.subTest(name, **{key: True for key in state}):
p = proxy(status=5, internal=[fake_snap("i")], **state)
with self.assertRaises(util.OperationError):
act(p)
p.instance.revertToSnapshot.assert_not_called()
p._snapshotCreateXML.assert_not_called()
def test_an_internal_snapshot_is_reverted_to_on_a_shut_off_vm(self):
for status in (1, 3):
with self.subTest(status=status), self.assertRaisesRegex(util.OperationError, "Shut the VM down"):
proxy(status=status, internal=[fake_snap("i")]).snapshot_revert("i")
p = proxy(status=5, internal=[fake_snap("i")])
p.snapshot_revert("i")
p.instance.revertToSnapshot.assert_called_once()
DISK_ONLY_ATOMIC = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY | libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC

View file

@ -57,7 +57,8 @@ class TestXmlSafety(unittest.TestCase):
def test_snapshot_xml_payload_is_escaped(self):
inst = wvmInstance.__new__(wvmInstance)
inst.instance = MagicMock(**{"snapshotListNames.return_value": []}) # no snapshots yet
inst.instance = MagicMock(**{"snapshotListNames.return_value": [], "hasManagedSaveImage.return_value": 0}) # no snapshots yet
inst.get_snapshot_jobs = MagicMock(return_value=[])
inst.get_status = MagicMock(return_value=5) # shutoff
inst._XMLDesc = MagicMock(return_value="<domain></domain>")
inst._snapshotCreateXML = MagicMock(return_value=None)
@ -76,7 +77,8 @@ class TestXmlSafety(unittest.TestCase):
def test_original_rom_loader_preserved(self):
inst = wvmInstance.__new__(wvmInstance)
inst.instance = MagicMock(**{"snapshotListNames.return_value": []}) # no snapshots yet
inst.instance = MagicMock(**{"snapshotListNames.return_value": [], "hasManagedSaveImage.return_value": 0}) # no snapshots yet
inst.get_snapshot_jobs = MagicMock(return_value=[])
inst._XMLDesc = MagicMock(return_value="<domain><os><loader type='rom'>/usr/share/rom.bin</loader></os></domain>")
inst._defineXML = MagicMock()
inst._snapshotCreateXML = MagicMock(return_value=None)
@ -87,7 +89,9 @@ class TestXmlSafety(unittest.TestCase):
def test_snapshot_revert_rom_loader_preserved(self):
inst = wvmInstance.__new__(wvmInstance)
inst.instance = MagicMock(**{"snapshotListNames.return_value": ["snap_rom"]})
inst.instance = MagicMock(**{"snapshotListNames.return_value": ["snap_rom"], "hasManagedSaveImage.return_value": 0})
inst.get_snapshot_jobs = MagicMock(return_value=[])
inst.get_status = MagicMock(return_value=5) # an internal snapshot is reverted to on a shut-off VM
mock_snap = MagicMock()
inst.instance.snapshotLookupByName = MagicMock(return_value=mock_snap)
inst.instance.revertToSnapshot = MagicMock()
@ -102,7 +106,8 @@ class TestXmlSafety(unittest.TestCase):
def test_internal_snapshot_never_rewrites_a_pflash_loader(self):
# libvirt decides; the definition is not switched to rom.
inst = wvmInstance.__new__(wvmInstance)
inst.instance = MagicMock(**{"snapshotListNames.return_value": []}) # no snapshots yet
inst.instance = MagicMock(**{"snapshotListNames.return_value": [], "hasManagedSaveImage.return_value": 0}) # no snapshots yet
inst.get_snapshot_jobs = MagicMock(return_value=[])
inst.get_status = MagicMock(return_value=5)
inst._XMLDesc = MagicMock(
return_value="<domain><os><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF.fd</loader></os></domain>"