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:
parent
60f12cefa1
commit
7fc55537e5
7 changed files with 186 additions and 203 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue