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

@ -296,12 +296,20 @@ class Instance(models.Model):
return self.proxy.get_video_models(self.arch, self.machine)
def _snapshots(self, flag):
return sorted(self.proxy.get_snapshot(flag), reverse=True, key=lambda k: k["date"])
external = flag == VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL
return sorted(({**snap, "external": external} for snap in self.proxy.get_snapshot(flag)),
reverse=True, key=lambda k: k["date"])
@cached_property
def snapshots(self) -> list[dict]:
"""Both kinds, the newest first; "external" tells them apart."""
return sorted(self.internal_snapshots + self.external_snapshots, reverse=True, key=lambda k: k["date"])
@cached_property
def newer_than_current_snapshot(self) -> bool:
"""Snapshots newer than the one reverted to: an external snapshot waits until they are deleted."""
return any(snap["current"] and snap["children"] for snap in self.external_snapshots)
@cached_property
def internal_snapshots(self) -> list[dict]:
return self._snapshots(VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL)

View file

@ -1,196 +1,124 @@
{% load i18n %}
{% load tz %}
{% get_current_timezone as TIME_ZONE %}
{% load bootstrap_icons %}
{% load operations %}
{% load vm_states %}
<div class="tab-pane" id="snapshots" role="tabpanel">
{% with busy=instance.snapshot_jobs held=instance.held_by_job %}
<!-- Nav tabs -->
{% vm_state instance.status as state %}
<div class="card">
<div class="card-header bg-transparent pb-0">
<ul class="nav nav-underline small" role="tablist" aria-label="Instance snapshot menu">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#takesnapshot" type="button" role="tab" aria-controls="takesnapshot" aria-selected="true">
{% trans "Take Snapshot - Internal" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#takeextsnapshot" type="button" role="tab" aria-controls="externalSnapshot" aria-selected="false">
{% trans "Take Snapshot - External" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#managesnapshot" type="button" role="tab" aria-controls="managesnapshot" aria-selected="false">
{% trans "Manage Snapshots" %}
</button>
</li>
</ul>
</div>
<!-- Tab panes -->
<div class="tab-content card-body">
{% if busy %}
<div class="alert alert-warning mt-3" role="status">
{% trans "A disk job is running on this VM; snapshot actions are unavailable until it ends." %}
</div>
{% endif %}
<div role="tabpanel" class="tab-pane active" id="takesnapshot">
{% if instance.status != 5 %}
<p>{% trans "With running machine, internal snapshots may take more than an hour, depending on how much memory has on your instance and how large the disk is." %} </p>
<p class="text-danger">{% trans "The VM is paused while its memory is saved." %}</p>
{% else %}
<p>{% trans "Create an internal snapshot" %}</p>
<div class="card-body">
<p class="small text-body-secondary mb-2">{% trans "Snapshots" %} · {{ state.0 }}</p>
{% if busy %}
<div class="alert alert-warning" role="status">{% trans "A disk job is running on this VM; snapshot actions are unavailable until it ends." %}</div>
{% endif %}
<form action="{% url 'instances:snapshot' instance.id %}" method="post" role="form" aria-label="Create snapshot form">
{% csrf_token %}{% submission_key %}
<div class="input-group mb-3">
<input type="text" class="form-control form-control-lg" name="name" placeholder="{% trans "Snapshot Name" %}" maxlength="64" pattern="[A-Za-z0-9_\-][A-Za-z0-9._\-]{0,63}" title="{% trans "Letters, digits, '.', '_' or '-'; not starting with '.'" %}" required>
<span class="input-group-text">|</span>
<input type="text" class="form-control form-control-lg" name="description" placeholder="{% trans "Snapshot Description" %}" maxlength="45">
{% if instance.external_snapshots or busy or held %}
<button type="submit" class="btn btn-primary float-end" name="snapshot" disabled>{% trans "Take Snapshot" %}</button>
{% else %}
<button type="submit" class="btn btn-primary float-end" name="snapshot">{% trans "Take Snapshot" %}</button>
{% endif %}
{# an external snapshot is the way: each disk goes on in a new file, the VM keeps running #}
<form action="{% url 'instances:create_external_snapshot' instance.id %}" method="post" aria-label="{% trans "Take a snapshot" %}">{% csrf_token %}{% submission_key %}
<div class="row g-2">
<div class="col-12 col-md">
<label class="visually-hidden" for="snapshot-name">{% trans "Snapshot name" %}</label>
<input type="text" class="form-control" id="snapshot-name" name="name" placeholder="{% trans "Snapshot name" %}" maxlength="64" pattern="[A-Za-z0-9_\-][A-Za-z0-9._\-]{0,63}" title="{% trans "Letters, digits, '.', '_' or '-'; not starting with '.'" %}" required>
</div>
<div class="col-12 col-md">
<label class="visually-hidden" for="snapshot-description">{% trans "Description" %}</label>
<input type="text" class="form-control" id="snapshot-description" name="description" placeholder="{% trans "Description (optional)" %}">
</div>
<div class="col-12 col-md-auto d-grid">
<button type="submit" class="btn btn-primary" name="snapshot"{% if instance.internal_snapshots or instance.newer_than_current_snapshot or busy or held %} disabled{% endif %}>{% trans "Take a snapshot" %}</button>
</div>
</form>
<div class="clearfix"></div>
</div> <!-- Tab pane - takesnapshot -->
<div role="tabpanel" class="tab-pane" id="takeextsnapshot">
<p>{% trans "Create an external snapshot. Each disk keeps its state in a new file next to it." %}</p>
<form action="{% url 'instances:create_external_snapshot' instance.id %}" method="post" role="form" aria-label="Create external snapshot form">
{% csrf_token %}{% submission_key %}
<div class="input-group mb-3">
<input type="text" class="form-control form-control-lg" name="name" placeholder="{% trans "Snapshot Name" %}" maxlength="64" pattern="[A-Za-z0-9_\-][A-Za-z0-9._\-]{0,63}" title="{% trans "Letters, digits, '.', '_' or '-'; not starting with '.'" %}" required>
<span class="input-group-text">|</span>
<input type="text" class="form-control form-control-lg" name="description" placeholder="{% trans "Snapshot Description" %}" maxlength="45">
{% if instance.internal_snapshots or busy or held %}
<button type="submit" class="btn btn-primary float-end" name="snapshot" disabled>{% trans "Take Snapshot" %}</button>
{% else %}
<button type="submit" class="btn btn-primary float-end" name="snapshot">{% trans "Take Snapshot" %}</button>
{% 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 %}
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 %}
<div class="d-flex flex-wrap column-gap-4 mt-2">
<div class="form-check">
<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) { if (this.checked) q.dataset.was = q.checked; q.disabled = this.checked; q.checked = !this.checked && q.dataset.was !== 'false'; }">
<label class="form-check-label" for="snapshot-memory">{% trans "Save memory too" %}</label>
</div>
<div class="form-check">
{% 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>
{% else %}
<input class="form-check-input" type="checkbox" name="quiesce" id="snapshot-quiesce" disabled>
{% endif %}
<label class="form-check-label" for="snapshot-quiesce">{% trans "Freeze the guest's file systems" %}</label>
</div>
</div>
<p class="small">{% trans "A VM with snapshots cannot be migrated." %}</p>
<p class="small text-body-secondary mt-1 mb-0">
{% if instance.internal_snapshots %}{% trans "The VM has internal snapshots: delete them before taking an external one." %}
{% elif instance.newer_than_current_snapshot %}{% trans "Snapshots newer than the one reverted to exist: delete them before taking a new one." %}
{% else %}{% trans "Each disk goes on in a new file next to it; the VM keeps running." %}
{% if instance.status == 1 %}{% trans "With memory, the VM pauses while its memory is saved." %}{% endif %}
{% if instance.status != 1 %}{% trans "Memory and frozen file systems are for a running VM." %}{% elif not instance.guest_agent_ready %}{% trans "Freezing needs the guest agent, which is not connected." %}{% endif %}{% endif %}
</p>
</form>
<div class="clearfix"></div>
</div> <!--tab pane takeextsnapshot-->
<div role="tabpanel" class="tab-pane" id="managesnapshot">
{% if instance.internal_snapshots or instance.external_snapshots %}
<p>{% trans "Choose a snapshot for restore/delete" %}</p>
{% if instance.internal_snapshots %}
<h6>{% trans "Internal" %}</h6>
<div class="table-responsive">
<table align-middle class="table" id="internal-snapshots">
<thead>
<tr>
<th scope="col">{% blocktrans %}Date ({{ TIME_ZONE }}){% endblocktrans %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Description" %}</th>
<th scope="colgroup" colspan="2">{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for snap in instance.internal_snapshots %}
<tr>
<td>{{ snap.date|date:"Y-m-d H:i:s" }}</td>
<td><strong>{{ snap.name }}</strong></td>
<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 %}{% 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' %}" data-confirm="{% blocktrans with name=snap.name %}Revert to snapshot {{ name }}? Disk changes made since it are lost.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)" {% if busy or held %}disabled{% endif %}>
<span>{% bs_icon 'download' %} </span>
</button>
{% else %}
<button type="button" class="btn btn-sm btn-primary disabled"
title="{% trans "To restore snapshots you need Power Off the instance." %}">
<span>{% bs_icon 'download' %} </span>
</button>
{% endif %}
</form>
</td>
<td style="width:30px;">
<form action="{% url 'instances:delete_snapshot' instance.id %}" method="post" role="form" aria-label="Delete snapshot form">{% csrf_token %}
<input type="hidden" name="name" value="{{ snap.name }}">
<button type="submit" class="btn btn-sm btn-danger" title="{% trans 'Delete Snapshot' %}" data-confirm="{% blocktrans with name=snap.name %}Delete snapshot {{ name }}? The VM's current disks are not changed.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)" {% if busy or held %}disabled{% endif %}>
{% bs_icon 'trash' %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if instance.external_snapshots %}
<h6>{% trans "External" %}</h6>
<div class="table-responsive">
<table align-middle class="table" id="external-snapshots">
<thead>
<tr>
<th scope="col">{% blocktrans %}Date ({{ TIME_ZONE }}){% endblocktrans %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Description" %}</th>
<th scope="colgroup" colspan="2">{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for ext_snap in instance.external_snapshots %}
<tr>
<td>{{ ext_snap.date|date:"Y-m-d H:i:s" }}</td>
<td>
<strong>{{ ext_snap.name }}</strong>
{% if ext_snap.current %}<span class="badge text-bg-secondary">{% trans "current" %}</span>{% endif %}
{% if ext_snap.memory %}<span class="badge text-bg-info">{% trans "memory" %}</span>{% endif %}
</td>
<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 %}{% 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 %}"
onclick="return confirm(this.dataset.confirm)" {% if busy or held %}disabled{% endif %}>
<span>{% bs_icon 'download' %} </span>
</button>
</form>
</td>
<td style="width:30px;">
<form action="{% url 'instances:delete_external_snapshot' instance.id %}" method="post" role="form" aria-label="Delete external snapshot form">{% csrf_token %}{% submission_key %}
<input type="hidden" name="name" value="{{ ext_snap.name }}">
<button type="submit" class="btn btn-sm btn-danger" name="delete_external_snapshot" title="{% trans 'Delete Snapshot' %}"
data-confirm="{% trans 'Delete this snapshot? Its changes are merged into the next one; nothing is lost.' %}"
onclick="return confirm(this.dataset.confirm)" {% if busy or held %}disabled{% endif %}>
{% bs_icon 'trash' %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<details class="mt-3">
<summary class="small text-body-secondary">{% trans "Internal snapshot: a fallback for a VM that cannot take an external one" %}</summary>
<form class="mt-2" action="{% url 'instances:snapshot' instance.id %}" method="post" aria-label="{% trans "Take an internal snapshot" %}">{% csrf_token %}{% submission_key %}
<p class="small mb-2">
{% if instance.status == 1 %}{% trans "The disks and memory go into the disk files. The VM does not respond while its memory is saved, which can take long: avoid it in production." %}
{% elif instance.status == 3 %}{% trans "The disks and memory go into the disk files; the paused VM stays paused." %}
{% else %}{% trans "The disks go into their files." %}{% endif %}
</p>
<div class="row g-2">
<div class="col-12 col-md">
<label class="visually-hidden" for="internal-snapshot-name">{% trans "Snapshot name" %}</label>
<input type="text" class="form-control" id="internal-snapshot-name" name="name" placeholder="{% trans "Snapshot name" %}" maxlength="64" pattern="[A-Za-z0-9_\-][A-Za-z0-9._\-]{0,63}" title="{% trans "Letters, digits, '.', '_' or '-'; not starting with '.'" %}" required>
</div>
<div class="col-12 col-md">
<label class="visually-hidden" for="internal-snapshot-description">{% trans "Description" %}</label>
<input type="text" class="form-control" id="internal-snapshot-description" name="description" placeholder="{% trans "Description (optional)" %}">
</div>
<div class="col-12 col-md-auto d-grid">
<button type="submit" class="btn btn-secondary" name="snapshot"{% if instance.external_snapshots or busy or held %} disabled{% endif %}>{% trans "Take an internal snapshot" %}</button>
</div>
</div>
{% if instance.external_snapshots %}<p class="small text-body-secondary mt-1 mb-0">{% trans "The VM has external snapshots: the two kinds do not mix." %}</p>{% endif %}
</form>
</details>
{% if instance.snapshots %}
<ul class="list-group mt-3" id="snapshot-list">
{% for snap in instance.snapshots %}
<li class="list-group-item d-flex flex-wrap align-items-center gap-2">
<div class="me-auto text-break">
<strong>{{ snap.name }}</strong>{% if snap.description %} <span>· {{ snap.description }}</span>{% endif %}
<span class="small text-body-secondary">· {% if snap.external %}{% trans "external" %}{% if snap.memory %}, {% trans "with memory" %}{% endif %}{% else %}{% trans "internal" %}{% endif %}{% if snap.current %}, {% trans "current" %}{% endif %} · {{ snap.date|date:"Y-m-d H:i" }}</span>
</div>
{% if snap.external %}
<form action="{% url 'instances:revert_external_snapshot' instance.id %}" method="post">{% csrf_token %}{% submission_key %}
<input type="hidden" name="name" value="{{ snap.name }}">
<button type="submit" class="btn btn-sm btn-secondary" name="revert_external_snapshot"
data-confirm="{% blocktrans with name=snap.name %}Revert to snapshot {{ name }}? Changes made since it are lost.{% endblocktrans %}{% if not snap.memory %}{% if instance.status == 1 %} {% trans "The VM is powered off and starts again from the snapshot's disks, as after a power cut." %}{% elif instance.status == 3 %} {% trans "The VM is powered off and comes back paused on the snapshot's disks." %}{% endif %}{% endif %}{% if snap.detaches %} {% trans 'These disks are detached; their writes since the current snapshot are discarded and their files are kept:' %} {% for d in snap.detaches %}{{ d.dev }} ({{ d.path }}){% if not forloop.last %}, {% endif %}{% endfor %}.{% endif %}{% if instance.nvram %} {% trans 'Firmware variables (NVRAM) are not restored.' %}{% endif %}"
onclick="return confirm(this.dataset.confirm)"{% if busy or held %} disabled{% endif %}>{% trans "Revert" %}</button>
</form>
<form action="{% url 'instances:delete_external_snapshot' instance.id %}" method="post">{% csrf_token %}{% submission_key %}
<input type="hidden" name="name" value="{{ snap.name }}">
<button type="submit" class="btn btn-sm btn-danger" name="delete_external_snapshot"
data-confirm="{% blocktrans with name=snap.name %}Delete snapshot {{ name }}? Its files are merged with the disks; the VM's data is kept.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)"{% if busy or held %} disabled{% endif %}>{% trans "Delete" %}</button>
</form>
{% else %}
<form action="{% url 'instances:revert_snapshot' instance.id %}" method="post">{% csrf_token %}{% submission_key %}
<input type="hidden" name="name" value="{{ snap.name }}">
<button type="submit" class="btn btn-sm btn-secondary" name="revert_snapshot"
data-confirm="{% blocktrans with name=snap.name %}Revert to snapshot {{ name }}? Disk changes made since it are lost; a snapshot of a running VM starts it again as it was then.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)"{% if instance.status != 5 or busy or held %} disabled{% endif %}
{% if instance.status != 5 %}title="{% trans "Shut the VM down to revert to an internal snapshot." %}"{% endif %}>{% trans "Revert" %}</button>
</form>
<form action="{% url 'instances:delete_snapshot' instance.id %}" method="post">{% csrf_token %}
<input type="hidden" name="name" value="{{ snap.name }}">
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="{% blocktrans with name=snap.name %}Delete snapshot {{ name }}? The VM's current disks are not changed.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)"{% if busy or held %} disabled{% endif %}>{% trans "Delete" %}</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% if instance.internal_snapshots and instance.status != 5 %}<p class="small text-body-secondary mt-1 mb-0">{% trans "Shut the VM down to revert to an internal snapshot." %}</p>{% endif %}
{% else %}
<p>{% trans "You do not have any snapshots" %}</p>
<p class="small text-body-secondary mt-3 mb-0">{% trans "The VM has no snapshot." %}</p>
{% endif %}
</div> <!--tab pane managesnapshot-->
</div> <!-- Tab content-->
<p class="small text-body-secondary mt-2 mb-0">{% blocktrans %}Times in {{ TIME_ZONE }}. A VM with snapshots cannot be migrated.{% endblocktrans %}</p>
</div>
</div>
{% endwith %}
</div> <!-- Tab panel-->
</div>

View file

@ -126,7 +126,7 @@ class SnapshotTabTestCase(TestCase):
def snap(self, name, **kw):
return {"name": name, "date": datetime(2026, 1, 1), "description": "", "memory": False,
"current": False, "detaches": [], **kw}
"current": False, "children": 0, "detaches": [], **kw}
def test_revert_confirm_names_detached_disks_and_nvram(self):
doc = self.page(external=[self.snap("e", detaches=[{"dev": "vdb", "path": "/pool/later.qcow2"}])],
@ -158,11 +158,11 @@ class SnapshotTabTestCase(TestCase):
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):
# where it cannot be offered it is not shown ticked either
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)
self.assertEqual((box.get("checked") is not None, box.get("disabled") is None), (offered, offered))
def test_memory_checkbox_only_for_a_running_vm(self):
checkbox = "//div[@id='snapshots']//input[@name='memory']" # the Resize tab has a memory field too
@ -186,15 +186,19 @@ class SnapshotTabTestCase(TestCase):
return Claim.objects.create(operation=op, resource=key, mode=Claim.EXCLUSIVE)
def test_an_active_job_disables_actions_before_libvirt_has_a_block_job(self):
# the button each case leaves enabled, so only the job can disable it
cases = (("none", {}, ("takesnapshot", "takeextsnapshot")),
("internal only", {"internal": [self.snap("i")]}, ("takesnapshot", "managesnapshot")),
("external only", {"external": [self.snap("e")]}, ("takeextsnapshot", "managesnapshot")))
# the buttons each case leaves enabled, so only the job can disable them
def form(name):
return reverse(f"instances:{name}", args=[self.instance.id])
cases = (("none", {}, ("create_external_snapshot", "snapshot")),
("internal only", {"internal": [self.snap("i")]}, ("snapshot", "revert_snapshot", "delete_snapshot")),
("external only", {"external": [self.snap("e")]},
("create_external_snapshot", "revert_external_snapshot", "delete_external_snapshot")))
buttons = "//div[@id='snapshots']//button[@type='submit']"
def enabled(doc, panes):
return [bool(doc.xpath(f"//div[@id='{pane}']//button[@type='submit' and not(@disabled)]"))
for pane in panes]
def enabled(doc, forms):
return [bool(doc.xpath("//form[@action=$a]//button[@type='submit' and not(@disabled)]", a=form(name)))
for name in forms]
for name, lists, panes in cases:
with self.subTest(name):
@ -213,7 +217,16 @@ class SnapshotTabTestCase(TestCase):
self.assertTrue(doc.xpath("//button[@name='revert_snapshot']"))
self.assertFalse(doc.xpath("//div[@id='snapshots']//button[@type='submit'][not(@disabled)]"))
def test_lists_are_separate(self):
doc = self.page(internal=[self.snap("i")], external=[self.snap("e")])
self.assertEqual(doc.xpath("//table[@id='internal-snapshots']//strong/text()"), ["i"])
self.assertEqual(doc.xpath("//table[@id='external-snapshots']//strong/text()"), ["e"])
def test_one_list_newest_first_each_with_its_kind(self):
doc = self.page(internal=[self.snap("i")], external=[self.snap("e", date=datetime(2026, 2, 1), memory=True)])
rows = [" ".join(li.text_content().split()) for li in doc.xpath("//ul[@id='snapshot-list']/li")]
self.assertEqual([row.split(" ")[0] for row in rows], ["e", "i"])
self.assertIn("external, with memory", rows[0])
self.assertIn("internal", rows[1])
def test_an_external_snapshot_waits_for_newer_ones_after_a_revert(self):
create = "//form[@action=$a]//button[@type='submit']"
url = reverse("instances:create_external_snapshot", args=[self.instance.id])
doc = self.page(external=[self.snap("old", current=True, children=1), self.snap("new")])
self.assertIsNotNone(doc.xpath(create, a=url)[0].get("disabled"))
self.assertIn("Snapshots newer than the one reverted to exist", doc.text_content())

View file

@ -1545,7 +1545,7 @@ def delete_snapshot(request, pk):
instance.proxy.snapshot_delete(snap_name)
except libvirtError as err:
messages.error(request, _("Snapshot was not deleted: %(err)s") % {"err": error_text(request, err)})
return _back(request, pk, "managesnapshot")
return _back(request, pk, "snapshots")
@require_POST
@ -1581,7 +1581,7 @@ def _snapshot_job(request, pk, kind, params):
messages.error(request, error_text(request, err))
else:
jobs.remember_job(request, op)
return _back(request, pk, "managesnapshot")
return _back(request, pk, "snapshots")
@require_POST

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