1
0
Fork 0
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:
catborise 2026-10-30 14:29:08 +03:00
parent 400ba1614f
commit 46188d3230
6 changed files with 157 additions and 120 deletions

View file

@ -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 }}";

View file

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

View file

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

View file

@ -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()

View file

@ -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):

View file

@ -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()