mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
feat(instances): a VM's migration in a route, a disk plan and its transfer settings
Settings > Migrate was a horizontal form of six switches without explanations. The owner chose this layout from three rendered options: the route on top as "From" (the source host) and "To" (the destination select) side by side, a "Storage" card with where each disk goes, a "Transfer settings" card with the switches in two columns, each with a line saying what it does, and a footer. Codex reviewed migration end to end; these are fixed: - No Offline switch: the mode follows the VM's state, and one line says which. A shut-off VM moved offline whether the switch was on or off. - Live migration shows for a running or paused VM; Auto converge, Compressed and Unsafe only for a running VM, the only state the migration applies them to. Unsafe was offered for a paused or shut-off VM and ignored. - Unsafe says it skips libvirt's safety checks; Delete source files says it deletes only the files of copied disks. - A VM that cannot migrate anywhere (no other host, a state that does not migrate, snapshots, host devices) gets the reason and a disabled button instead of the disk plan and the switches; it learned that after submitting. - Migrate stays disabled while the destination did not answer or a disk cannot go there; it was enabled after any plan.
This commit is contained in:
parent
400ba1614f
commit
46188d3230
6 changed files with 157 additions and 120 deletions
|
|
@ -409,7 +409,8 @@
|
|||
return response.text();
|
||||
}).then(function (html) {
|
||||
migratePlan.innerHTML = html;
|
||||
submit.disabled = false;
|
||||
// a destination that did not answer, or a disk that cannot go there
|
||||
submit.disabled = migratePlan.querySelector("[data-refused]") !== null;
|
||||
}).catch(function () {
|
||||
if (loading.signal.aborted) return; // another destination was chosen
|
||||
migratePlan.textContent = "{{ _("The disks could not be checked.")|escapejs }}";
|
||||
|
|
|
|||
|
|
@ -1,42 +1,32 @@
|
|||
{% load i18n %}
|
||||
{# the disks of the Migrate tab for the chosen destination: where each goes #}
|
||||
{# the disks of the Migrate tab for the chosen destination: where each goes; data-refused keeps the form from being sent #}
|
||||
{% if error %}
|
||||
<div class="alert alert-warning mb-0">{% blocktrans %}The disks could not be checked: {{ error }}{% endblocktrans %}</div>
|
||||
<div class="alert alert-warning mb-0" data-refused>{% blocktrans %}The disks could not be checked: {{ error }}{% endblocktrans %}</div>
|
||||
{% elif not disks %}
|
||||
<p class="small text-body-secondary mb-0">{% trans "The VM has no disk files: its disks stay where they are." %}</p>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-1">
|
||||
<thead>
|
||||
<tr><th scope="col">{% trans "Disk" %}</th><th scope="col">{% trans "Size" %}</th><th scope="col">{% trans "On the destination" %}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for disk in disks %}
|
||||
<tr>
|
||||
<td><span class="fw-semibold">{{ disk.target }}</span><div class="small text-body-secondary text-break">{{ disk.path }}</div></td>
|
||||
<td class="text-nowrap">{{ disk.capacity|filesizeformat }}</td>
|
||||
<td>
|
||||
{% if disk.shared or not disk.refusal and pools %}
|
||||
<select name="pool-{{ disk.target }}" class="form-select form-select-sm" aria-label="{% blocktrans with target=disk.target %}Where {{ target }} goes{% endblocktrans %}">
|
||||
{% if disk.shared %}<option value="" selected>{% trans "Stays on shared storage" %}</option>{% endif %}
|
||||
{% if not disk.refusal %}
|
||||
{% for pool in pools %}
|
||||
<option value="{{ pool.name }}"{% if not disk.shared and pool.name == disk.default %} selected{% endif %}>{% blocktrans with name=pool.name free=pool.free|filesizeformat %}Copy to {{ name }} ({{ free }} free){% endblocktrans %}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
{% elif disk.refusal %}
|
||||
<span class="small text-danger">{% blocktrans with reason=disk.refusal %}Cannot be copied: {{ reason }}{% endblocktrans %}</span>
|
||||
{% else %}
|
||||
<span class="small text-danger">{% trans "No pool of the destination host can take it" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% for disk in disks %}
|
||||
<div{% if not forloop.first %} class="border-top pt-3 mt-3"{% endif %}>
|
||||
<p class="mb-0"><strong>{{ disk.target }}</strong> <span class="text-body-secondary">· {{ disk.capacity|filesizeformat }}</span></p>
|
||||
<p class="small text-body-secondary text-break mb-2">{{ disk.path }}</p>
|
||||
{% if disk.shared or not disk.refusal and pools %}
|
||||
<label class="form-label small" for="pool-{{ disk.target }}">{% trans "On the destination" %}</label>
|
||||
<select name="pool-{{ disk.target }}" id="pool-{{ disk.target }}" class="form-select">
|
||||
{% if disk.shared %}<option value="" selected>{% trans "Stays on shared storage" %}</option>{% endif %}
|
||||
{% if not disk.refusal %}
|
||||
{% for pool in pools %}
|
||||
<option value="{{ pool.name }}"{% if not disk.shared and pool.name == disk.default %} selected{% endif %}>{% blocktrans with name=pool.name free=pool.free|filesizeformat %}Copy to {{ name }} ({{ free }} free){% endblocktrans %}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
{% elif disk.refusal %}
|
||||
<p class="small text-danger mb-0" data-refused>{% blocktrans with reason=disk.refusal %}Cannot be copied: {{ reason }}{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p class="small text-danger mb-0" data-refused>{% trans "No pool of the destination host can take it" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="form-text mb-0">
|
||||
{% endfor %}
|
||||
<p class="small text-body-secondary mt-3 mb-0">
|
||||
{% trans "A copy is made during the migration and takes as long as the disk's data needs. Disks on network storage stay where they are." %}
|
||||
{% if shut_off %}{% trans "A shut-off VM is started paused for a copy, its CPUs never run, and is shut off on the destination." %}{% endif %}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -484,86 +484,98 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
<div role="tabpanel" class="tab-pane" id="migrate">
|
||||
<p>{% trans "For migration both host servers must have equal settings and OS type" %}</p>
|
||||
<form action="{% url 'instances:migrate' instance.id %}" class="ms-3 form" method="post" role="form" aria-label="Migrate instance form">
|
||||
{% csrf_token %}{% submission_key %}
|
||||
<div class="row">
|
||||
<label class="col-sm-3 col-form-label">{% trans "Original host" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-control" readonly="readonly">{{ compute.name }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="col-sm-3 col-form-label">{% trans "Host migration" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<select name="compute_id" class="form-select" id="migrate_compute">
|
||||
{% if computes_count != 1 %}
|
||||
<form action="{% url 'instances:migrate' instance.id %}" method="post" aria-labelledby="migrate-route">{% csrf_token %}{% submission_key %}
|
||||
<header class="border-bottom pb-3 mb-4">
|
||||
<h2 class="h6" id="migrate-route">{% trans "Migration route" %}</h2>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col">
|
||||
<label class="form-label small" for="migrate-from">{% trans "From" %}</label>
|
||||
<input class="form-control" id="migrate-from" value="{{ compute.name }}" readonly>
|
||||
</div>
|
||||
<div class="col-auto pb-2 text-body-secondary" aria-hidden="true">→</div>
|
||||
<div class="col">
|
||||
<label class="form-label small" for="migrate_compute">{% trans "To" %}</label>
|
||||
<select class="form-select" name="compute_id" id="migrate_compute">
|
||||
{% for comp in computes %}
|
||||
{% if comp.id != compute.id %}
|
||||
<option value="{{ comp.id }}">{{ comp.name }}</option>
|
||||
{% endif %}
|
||||
{% if comp.id != compute.id %}<option value="{{ comp.id }}">{{ comp.name }}</option>{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small text-body-secondary mt-3 mb-0">
|
||||
{% if instance.status == 1 %}{% trans "The VM is running: it migrates live, or paused for the transfer when Live migration is off." %}
|
||||
{% elif instance.status == 3 %}{% trans "The VM is paused: it stays paused on the destination." %}
|
||||
{% elif instance.status == 5 %}{% trans "The VM is shut off: it moves offline." %}{% endif %}
|
||||
{% trans "Both hosts need the same emulator; the VM's definition leaves the source host." %}
|
||||
</p>
|
||||
</header>
|
||||
{# a VM that cannot go anywhere gets the reason, not the disks and settings #}
|
||||
{% if not migrate_refusal %}
|
||||
<section class="card mb-3" aria-labelledby="migrate-storage">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3" id="migrate-storage">{% bs_icon 'device-hdd' %} {% trans "Storage" %}</h2>
|
||||
<div id="migrate-plan" data-url="{% url 'instances:migrate_plan' instance.id %}" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card mb-4" aria-labelledby="migrate-transfer">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3" id="migrate-transfer">{% bs_icon 'arrow-left-right' %} {% trans "Transfer settings" %}</h2>
|
||||
<div class="row g-3">
|
||||
{% if instance.status == 1 or instance.status == 3 %}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="live_migrate" value="true" id="vm_live_migrate" aria-describedby="live-help" checked>
|
||||
<label class="form-check-label fw-semibold" for="vm_live_migrate">{% trans "Live migration" %}</label>
|
||||
</div>
|
||||
<p class="small text-body-secondary mt-1 mb-0" id="live-help">{% trans "The VM keeps running while its memory is copied. Off, it is paused for the transfer." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="col-sm-3 col-form-label">{% trans "Disks" %}</label>
|
||||
<div class="col-sm-9 py-2" id="migrate-plan" data-url="{% url 'instances:migrate_plan' instance.id %}" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 offset-sm-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="live_migrate" value="true" id="vm_live_migrate" {% if instance.status != 5 %}checked{% else %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="vm_live_migrate">{% trans "Live migration" %}</label>
|
||||
{% if instance.status == 1 %}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="autoconverge" value="true" id="autoconverge" aria-describedby="autoconverge-help" checked>
|
||||
<label class="form-check-label fw-semibold" for="autoconverge">{% trans "Auto converge" %}</label>
|
||||
</div>
|
||||
<p class="small text-body-secondary mt-1 mb-0" id="autoconverge-help">{% trans "Slows the guest's CPUs so that a busy VM's live migration can finish. Only with Live migration." %}</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="compress" value="true" id="compress" aria-describedby="compress-help">
|
||||
<label class="form-check-label fw-semibold" for="compress">{% trans "Compressed" %}</label>
|
||||
</div>
|
||||
<p class="small text-body-secondary mt-1 mb-0" id="compress-help">{% trans "Compresses the memory sent: less network, more CPU on both hosts." %}</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="unsafe_migrate" value="true" id="vm_unsafe_migrate" aria-describedby="unsafe-help">
|
||||
<label class="form-check-label fw-semibold" for="vm_unsafe_migrate">{% trans "Unsafe migration" %}</label>
|
||||
</div>
|
||||
<p class="small text-body-secondary mt-1 mb-0" id="unsafe-help">{% trans "Skips libvirt's safety checks, such as a disk cache mode that can corrupt data in a migration." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" name="delete_source" value="true" id="delete_source" aria-describedby="delete-source-help">
|
||||
<label class="form-check-label fw-semibold" for="delete_source">{% trans "Delete the source files of copied disks" %}</label>
|
||||
</div>
|
||||
<p class="small text-body-secondary mt-1 mb-0" id="delete-source-help">{% trans "After a migration that copied disks, their files on the source host go. Disks on shared storage stay." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 offset-sm-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="unsafe_migrate" value="true" id="vm_unsafe_migrate">
|
||||
<label class="form-check-label" for="vm_unsafe_migrate">{% trans "Unsafe migration" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 offset-sm-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="offline_migrate" value="true" id="offline_migrate" {% if instance.status == 5 %}checked{% else %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="offline_migrate">{% trans "Offline migration" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 offset-sm-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="autoconverge" value="true" id="autoconverge" {% if instance.status != 1 %}disabled{% else %}checked{% endif %}>
|
||||
<label class="form-check-label" for="autoconverge" title="{% trans 'Forces CPU convergence during live migration' %}">{% trans "Auto converge" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 offset-sm-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="compress" value="true" id="compress" {% if instance.status != 1 %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="compress" title="{% trans 'Compress instance memory for fast migration' %}">{% trans "Compressed" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 offset-sm-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="delete_source" value="true" id="delete_source">
|
||||
<label class="form-check-label" for="delete_source">{% trans "Delete the source files of copied disks" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if computes_count != 1 %}
|
||||
<button type="submit" class="btn btn-primary float-end" name="migrate" >{% trans "Migrate" %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-primary float-end disabled" disabled>{% trans "Migrate" %}</button>
|
||||
</section>
|
||||
{% endif %}
|
||||
<footer class="border-top pt-3">
|
||||
{% if migrate_refusal %}<p class="small text-body-secondary mb-2">{{ migrate_refusal }}</p>{% endif %}
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<p class="small text-body-secondary mb-0">{% trans "Migration runs as a background job." %}</p>
|
||||
{% if migrate_refusal %}
|
||||
<button type="button" class="btn btn-primary ms-auto" disabled>{% trans "Migrate" %}</button>
|
||||
{% else %}
|
||||
{# enabled once the disks of the chosen destination are shown #}
|
||||
<button type="submit" class="btn btn-primary ms-auto" name="migrate" disabled>{% trans "Migrate" %}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""The migrate form ticks auto converge for a running VM, and the API uses
|
||||
the same default: it only slows a VM that the migration cannot keep up with."""
|
||||
"""The migrate form shows the switches the VM's state uses and ticks auto
|
||||
converge for a running VM, and the API uses the same default: it only slows
|
||||
a VM that the migration cannot keep up with. A VM that cannot migrate gets
|
||||
the reason instead."""
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import libvirt
|
||||
|
|
@ -10,6 +11,7 @@ from django.contrib.auth import get_user_model
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from lxml import html
|
||||
from operations.models import Operation
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
|
@ -37,20 +39,34 @@ class MigrateFormTests(TestCase):
|
|||
self.domain.destroy()
|
||||
self.domain.undefine()
|
||||
|
||||
def autoconverge_input(self):
|
||||
def migrate_form(self):
|
||||
self.client.force_login(self.admin)
|
||||
with patch("vrtManager.connection.connection_manager.get_connection", return_value=self.conn):
|
||||
page = self.client.get(reverse("instances:instance", args=[self.vm.id])).content.decode()
|
||||
return re.search(r'<input[^>]*id="autoconverge"[^>]*>', page).group(0)
|
||||
page = self.client.get(reverse("instances:instance", args=[self.vm.id])).content
|
||||
(form,) = html.fromstring(page).xpath("//form[@action=$a]", a=reverse("instances:migrate", args=[self.vm.id]))
|
||||
return form
|
||||
|
||||
def test_auto_converge_is_ticked_for_a_running_vm(self):
|
||||
self.domain.create()
|
||||
self.assertIn("checked", self.autoconverge_input())
|
||||
self.assertEqual(self.migrate_form().xpath(".//input[@name='autoconverge']/@checked"), ["checked"])
|
||||
|
||||
def test_a_shut_off_vm_has_no_auto_converge(self):
|
||||
field = self.autoconverge_input()
|
||||
self.assertIn("disabled", field)
|
||||
self.assertNotIn("checked", field)
|
||||
def test_each_state_shows_the_switches_it_uses(self):
|
||||
# the mode follows the state: no Offline switch; unsafe, auto converge and compressed apply to a running VM
|
||||
for state, switches in (("shut off", ["delete_source"]),
|
||||
("running", ["live_migrate", "autoconverge", "compress", "unsafe_migrate", "delete_source"]),
|
||||
("paused", ["live_migrate", "delete_source"])):
|
||||
with self.subTest(state):
|
||||
if state == "running":
|
||||
self.domain.create()
|
||||
elif state == "paused":
|
||||
self.domain.suspend()
|
||||
self.assertEqual(self.migrate_form().xpath(".//input[@type='checkbox']/@name"), switches)
|
||||
|
||||
def test_a_vm_with_no_other_host_gets_the_reason(self):
|
||||
self.other.delete()
|
||||
form = self.migrate_form()
|
||||
self.assertIn("There is no other host to migrate to.", form.text_content())
|
||||
self.assertEqual(form.xpath(".//button[@name='migrate']"), [])
|
||||
|
||||
def test_the_api_uses_auto_converge_unless_told_otherwise(self):
|
||||
api = APIClient()
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ class MigrateJobTestCase(TransactionTestCase):
|
|||
self.assertIn('<option value="fast">Copy to fast (8.0\xa0GB free)</option>', page)
|
||||
self.assertIn("Cannot be copied: an image is not copied; eject it first", page)
|
||||
self.assertIn("A shut-off VM is started paused for a copy", page)
|
||||
# a disk that cannot go keeps the form from being sent, as does a destination that does not answer
|
||||
self.assertEqual(page.count("data-refused"), 1)
|
||||
with patch("instances.views.wvmInstances", side_effect=libvirtError("no answer")):
|
||||
page = self.client.get(reverse("instances:migrate_plan", args=[self.vm.pk]),
|
||||
{"compute_id": self.dest.pk}).content.decode()
|
||||
self.assertIn("data-refused", page)
|
||||
self.assertEqual(self.client.get(reverse("instances:migrate_plan", args=[self.vm.pk])).status_code, 404)
|
||||
|
||||
def test_a_cancelled_migration_ends_cancelled_and_the_vm_stays(self):
|
||||
|
|
|
|||
|
|
@ -322,6 +322,18 @@ def _instance_page(request, pk, instance, compute):
|
|||
show_hostdevs = request.user.is_superuser or bool(usb_hostdevs or pci_hostdevs or other_hostdevs)
|
||||
show_settings = show_settings or show_hostdevs
|
||||
|
||||
# what keeps the Migrate tab from migrating the VM to any destination
|
||||
if computes_count == 1:
|
||||
migrate_refusal = _("There is no other host to migrate to.")
|
||||
elif instance.status not in (1, 3, 5):
|
||||
migrate_refusal = _("A VM migrates when it is running, paused or shut off.")
|
||||
elif instance.snapshots:
|
||||
migrate_refusal = _("A VM with snapshots cannot be migrated: its snapshots stay on this host. Delete them first.")
|
||||
elif usb_hostdevs or pci_hostdevs or other_hostdevs:
|
||||
migrate_refusal = _("A VM with host devices cannot be migrated: the destination may not have them. Remove them first.")
|
||||
else:
|
||||
migrate_refusal = None
|
||||
|
||||
job_resource = claims.instance_key(instance.uuid)
|
||||
return render(request, "instance.html", locals(),)
|
||||
|
||||
|
|
@ -849,7 +861,7 @@ def migrate(request, pk):
|
|||
raise Http404
|
||||
target_host = get_object_or_404(Compute, pk=compute_id)
|
||||
raw = {"instance": pk, "target": target_host.pk, "live": request.POST.get("live_migrate"),
|
||||
"unsafe": request.POST.get("unsafe_migrate"), "offline": request.POST.get("offline_migrate"),
|
||||
"unsafe": request.POST.get("unsafe_migrate"),
|
||||
"autoconverge": request.POST.get("autoconverge"), "compress": request.POST.get("compress"),
|
||||
"delete_source": request.POST.get("delete_source"),
|
||||
"disk_pools": {key.removeprefix("pool-"): value for key, value in request.POST.items()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue