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

feat(instances): snapshot revert runs as a job

This commit is contained in:
catborise 2026-10-15 09:59:29 +03:00
parent 6f81043438
commit 8d2e88f4bb
7 changed files with 102 additions and 82 deletions

View file

@ -337,6 +337,59 @@ class CreateSnapshot(Kind):
return _log(op, state, f"Create {kind}: {op.params['name']}")
def _current(proxy):
dom = proxy.instance
return dom.snapshotCurrent(0).getName() if dom.hasCurrentSnapshot(0) else None
@register
class RevertSnapshot(Kind):
name = "snapshot.revert"
label = gettext_noop("Revert snapshot")
time_limit = 6 * 3600 # a revert cannot be stopped: over time is only noted
def normalize(self, raw):
return {"instance": int(raw["instance"]), "name": str(raw.get("name", "")),
"external": bool(raw.get("external"))}
def validate(self, user, params):
_proxy(_authorized(user, params["instance"]))._snapshot(params["name"], params["external"])
def plan(self, user, params):
return _vm_plan(params)
def precheck(self, op, ctx):
proxy = _checked_proxy(op)
if op.params["external"]:
proxy.check_external_revert(op.params["name"])
else:
proxy._snapshot(op.params["name"], False)
def run(self, op, ctx):
proxy = _proxy(_instance(op.params["instance"]))
ctx.record_intent(was_current=_current(proxy))
ctx.set_phase(gettext_noop("Reverting the VM"))
if op.params["external"]:
return {"left_behind": proxy.revert_external_snapshot(op.params["name"])}
proxy.snapshot_revert(op.params["name"])
return {}
def reconcile(self, op):
proxy = _recovery_proxy(op)
if proxy is None:
return Outcome(Operation.UNCERTAIN, error=GONE)
if _libvirt_busy(proxy):
return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still reverting the VM"))
if _current(proxy) == op.params["name"] != op.intent.get("was_current"):
return Outcome(Operation.SUCCEEDED)
return Outcome(Operation.UNCERTAIN, error="It cannot be told whether the VM was reverted; "
"check it, then revert again or resolve this job.")
def log_entry(self, op, state):
kind = "external snapshot" if op.params["external"] else "snapshot"
return _log(op, state, f"Revert {kind}: {op.params['name']}")
@register
class CloneInstance(Kind):
name = "instance.clone"

View file

@ -102,7 +102,7 @@
<td>{{ snap.description }}</td>
<td style="width:30px;">
<form action="{% url 'instances:revert_snapshot' instance.id %}" method="post" role="form" aria-label="Restore snapshot form">
{% csrf_token %}
{% csrf_token %}{% submission_key %}
<input type="hidden" name="name" value="{{ snap.name }}">
{% if instance.status == 5 %}
<button type="submit" class="btn btn-sm btn-primary" name="revert_snapshot" title="{% trans 'Revert to this Snapshot' %}" onclick="return confirm('{% trans "Are you sure?" %}')" {% if busy or held %}disabled{% endif %}>
@ -152,7 +152,7 @@
<td>{{ ext_snap.description }}</td>
<td style="width:30px;">
<form action="{% url 'instances:revert_external_snapshot' instance.id %}" method="post" role="form" aria-label="Restore external snapshot form">
{% csrf_token %}
{% csrf_token %}{% submission_key %}
<input type="hidden" name="name" value="{{ ext_snap.name }}">
<button type="submit" class="btn btn-sm btn-primary" name="revert_external_snapshot" title="{% trans 'Revert to this Snapshot' %}"
data-confirm="{% trans 'Revert to this snapshot? Changes made since it are lost.' %}{% if not ext_snap.memory %}{% if instance.status == 1 or instance.status == 3 %} {% trans "The VM is powered off and starts again from the snapshot's disks, as after a power cut." %}{% endif %}{% endif %}{% if ext_snap.detaches %} {% trans 'These disks are detached; their writes since the current snapshot are discarded and their files are kept:' %} {% for d in ext_snap.detaches %}{{ d.dev }} ({{ d.path }}){% if not forloop.last %}, {% endif %}{% endfor %}.{% endif %}{% if instance.nvram %} {% trans 'Firmware variables (NVRAM) are not restored.' %}{% endif %}"

View file

@ -3,13 +3,14 @@
from types import SimpleNamespace
from unittest.mock import patch
from vrtManager.util import OperationError
from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from django.urls import reverse
from appsettings.settings import get_settings
from computes.models import Compute
from instances.jobs import CreateSnapshot
from instances.jobs import CreateSnapshot, RevertSnapshot
from instances.models import Instance
from operations.jobs import Context, get_kind
from operations.models import Claim, Operation, acquire
@ -36,7 +37,8 @@ class SnapshotJobsTestCase(TransactionTestCase):
op = Operation.objects.get()
self.assertIn((f"instance:{self.instance.uuid}", "exclusive"),
set(Claim.objects.values_list("resource", "mode")))
self.assertFalse(self.proxy.create_snapshot.called or self.proxy.create_external_snapshot.called)
self.assertFalse(self.proxy.create_snapshot.called or self.proxy.create_external_snapshot.called
or self.proxy.snapshot_revert.called or self.proxy.revert_external_snapshot.called)
acquire("wvc-test:1:a", "wvc-test")
Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING, worker="wvc-test:1:a")
op.refresh_from_db()
@ -69,3 +71,28 @@ class SnapshotJobsTestCase(TransactionTestCase):
self.proxy.instance.isActive.return_value = True
self.proxy.instance.jobInfo.return_value = [2] + [0] * 11 # VIR_DOMAIN_JOB_UNBOUNDED
self.assertEqual(CreateSnapshot().reconcile(op).state, Operation.RUNNING)
def test_an_external_revert_is_made_by_the_worker(self):
self.proxy.revert_external_snapshot.return_value = ["/pool/a.one"]
op, result = self.queue_and_run("revert_external_snapshot", {"name": "a"})
self.assertEqual((op.kind, result), ("snapshot.revert", {"left_behind": ["/pool/a.one"]}))
self.proxy.revert_external_snapshot.assert_called_once_with("a")
def test_a_revert_libvirt_cannot_do_is_refused_before_effects(self):
self.proxy.check_external_revert.side_effect = OperationError("taken by an older version")
with self.assertRaises(OperationError):
self.queue_and_run("revert_external_snapshot", {"name": "a"})
self.assertFalse(Operation.objects.get().effects_started)
def test_an_internal_revert_is_made_by_the_worker(self):
self.queue_and_run("revert_snapshot", {"name": "b"})
self.proxy.snapshot_revert.assert_called_once_with("b")
def test_revert_recovery(self):
op = Operation(params={"instance": self.instance.pk, "name": "a", "external": False},
intent={"compute": self.instance.compute_id, "was_current": "z"})
self.proxy.instance.hasCurrentSnapshot.return_value = 1
self.proxy.instance.snapshotCurrent.return_value.getName.return_value = "a"
self.assertEqual(RevertSnapshot().reconcile(op).state, Operation.SUCCEEDED)
op.intent["was_current"] = "a" # it was current before: nothing tells
self.assertEqual(RevertSnapshot().reconcile(op).state, Operation.UNCERTAIN)

View file

@ -30,15 +30,12 @@ class SnapshotViewsTestCase(TestCase):
admin = get_user_model().objects.create_superuser("snap_admin", "s@example.com", "pw")
self.client.force_login(admin)
def post(self, view, data, error=None, left=()):
def post(self, view, data, error=None):
with patch("instances.models.wvmInstance") as mock_wvm:
proxy = mock_wvm.return_value
method = {
"revert_external_snapshot": proxy.revert_external_snapshot,
"revert_snapshot": proxy.snapshot_revert,
"delete_snapshot": proxy.snapshot_delete,
}[view]
method.return_value = list(left) # files revert and delete left behind
if error:
method.side_effect = util.OperationError(error)
self.client.cookies.pop("messages", None) # only this request's messages
@ -60,20 +57,6 @@ class SnapshotViewsTestCase(TestCase):
get_settings()
self.assertEqual(_snapshot_free_space(), expected)
def test_revert_calls_the_backend_once_and_leaves_power_alone(self):
proxy, method, _ = self.post("revert_external_snapshot", {"name": "a"})
method.assert_called_once_with("a")
for power in ("start", "force_shutdown", "shutdown"):
getattr(proxy, power).assert_not_called()
def test_files_left_behind_are_named_in_a_warning(self):
_, _, messages = self.post("revert_external_snapshot", {"name": "a"}, left=["/pool/a.one", "/pool/vm.one.mem"])
warning = [m for m in messages if "/pool/a.one, /pool/vm.one.mem" in m]
self.assertEqual(len(warning), 1, messages)
self.assertIn("qemu user", warning[0])
_, _, messages = self.post("revert_external_snapshot", {"name": "a"})
self.assertFalse([m for m in messages if "not removed" in m], messages)
def delete(self, listed):
with patch("instances.jobs.wvmInstance") as mock_wvm:
proxy = mock_wvm.return_value
@ -101,7 +84,7 @@ class SnapshotViewsTestCase(TestCase):
self.assertFalse(Operation.objects.exists())
def test_refusals_become_messages(self):
for view in ("revert_external_snapshot", "revert_snapshot", "delete_snapshot"):
for view in ("delete_snapshot",):
with self.subTest(view):
_, _, messages = self.post(view, {"name": "a"}, error="refused for a reason")
self.assertTrue(any("refused for a reason" in m for m in messages), messages)

View file

@ -521,15 +521,6 @@ class InstancesTestCase(TestCase):
self.instance.__dict__.pop(cached, None)
self.assertEqual(len(self.instance.snapshots), 1)
response = self.client.post(
reverse("instances:revert_snapshot", args=[self.instance.id]),
{
"name": "test-snapshot",
},
HTTP_REFERER=reverse("index"),
)
self.assertEqual(response.status_code, 302)
response = self.client.post(
reverse("instances:delete_snapshot", args=[self.instance.id]),
{

View file

@ -1360,37 +1360,9 @@ def delete_snapshot(request, pk):
@require_POST
@serialize_instance_mutation
def revert_snapshot(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if not request.user.has_perm("instances.snapshot_instances"):
raise PermissionDenied
snap_name = request.POST.get("name", "")
try:
instance.proxy.snapshot_revert(snap_name)
except libvirtError as err:
messages.error(request, _("Snapshot was not reverted: %(err)s") % {"err": error_text(request, err)})
return _back(request, pk, "managesnapshot")
msg = _("Successful revert snapshot: ")
msg += snap_name
messages.success(request, msg)
msg = _("Revert snapshot: %(snap)s") % {"snap": snap_name}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "managesnapshot")
def _warn_left_behind(request, files):
"""libvirt only logs it when it cannot remove a snapshot file."""
if files:
messages.warning(
request,
_(
"These snapshot files were not removed, although no VM on this host uses them any more: %(files)s. "
"On NFS libvirt removes them as the qemu user, who needs write access to their directory. "
"Remove them by hand."
)
% {"files": ", ".join(files)},
)
return _snapshot_job(request, pk, "snapshot.revert", {"name": request.POST.get("name", "")},
_("The VM is being reverted; follow it in the job panel."))
@require_POST
@ -1403,22 +1375,9 @@ def create_external_snapshot(request, pk):
@require_POST
@serialize_instance_mutation
def revert_external_snapshot(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if not request.user.has_perm("instances.snapshot_instances"):
raise PermissionDenied
name = request.POST.get("name", "")
try:
left = instance.proxy.revert_external_snapshot(name)
except libvirtError as err:
messages.error(request, _("Snapshot was not reverted: %(err)s") % {"err": error_text(request, err)})
else:
messages.success(request, _("Successful revert snapshot: ") + name)
_warn_left_behind(request, left)
msg = _("Revert external snapshot: %(snap)s") % {"snap": name}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "managesnapshot")
return _snapshot_job(request, pk, "snapshot.revert", {"name": request.POST.get("name", ""), "external": True},
_("The VM is being reverted; follow it in the job panel."))
def _snapshot_job(request, pk, kind, params, queued):

View file

@ -1683,11 +1683,9 @@ class wvmInstance(wvmConnect):
"and the memory if it is saved)"
)
def revert_external_snapshot(self, name):
"""libvirt returns the disks and the definition to the snapshot's. A
disk-only snapshot keeps the VM running, paused or shut off as it is;
a snapshot with memory resumes the VM as it was saved. Returns the
files libvirt left behind (see _left_behind)."""
def check_external_revert(self, name):
"""Refuses a revert that cannot work, before anything changes; returns
the snapshot."""
snap = self._snapshot(name, external=True)
self._check_snapshot_state()
tree = etree.fromstring(snap.getXMLDesc(0))
@ -1701,6 +1699,15 @@ class wvmInstance(wvmConnect):
raise util.OperationError(
"This snapshot was taken by an older version on a UEFI VM and cannot be reverted; delete it instead"
)
return snap
def revert_external_snapshot(self, name):
"""libvirt returns the disks and the definition to the snapshot's. A
disk-only snapshot keeps the VM running, paused or shut off as it is;
a snapshot with memory resumes the VM as it was saved. Returns the
files libvirt left behind (see _left_behind)."""
snap = self.check_external_revert(name)
tree = etree.fromstring(snap.getXMLDesc(0))
if tree.find("memory[@snapshot='external']") is not None:
flags = 0
else: