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

feat(instances): migrate a VM with its disks copied into pools of the destination

A migration needed every disk on storage both hosts share: it was refused
when the destination lacked a disk file, and a file of the same name
there was taken for the VM's own disk.

The Migrate tab now lists the VM's disk files for the chosen destination.
For each disk it offers the destination's pools that keep disk files
(directory, filesystem and NFS pools). A disk on storage both hosts share
can also stay where it is.

A directory counts as shared when both hosts have it as a storage pool
and a marker file created on the source exists on the destination. A
directory the probe cannot tell is never taken as shared.

libvirt copies the chosen disks during the migration: migrate3 with
NON_SHARED_DISK for those targets, and destination and persistent
definitions naming the new paths, where libvirt creates the files.

- A shut-off VM is started paused for the copy and shut off on the
  destination; its CPUs never run. Its UEFI variables are copied first,
  as the destination would start from the firmware's template. If the
  migration fails, the VM is shut off on the source again.
- Refused before anything changes, each disk with its reason:
  - a disk neither shared nor given a pool, also one only the next
    start's definition has;
  - CD-ROM images, read-only or encrypted disks, formats other than raw
    and qcow2, and disks with a backing file;
  - a pool that is not of disk files, or that has a file of that name,
    which libvirt would reuse and overwrite;
  - two disks copied to one file.
- The job claims each copy's name in its pool, and the source volume.
- A failed or cancelled migration deletes the copies libvirt made, and
  the UEFI variables copy, unless a VM on the destination has them.
- The source files stay and are named in the log. With "Delete the
  source files of copied disks" they go, except one another VM or volume
  uses; a failed delete is logged and the migration stands.

The NVRAM sharing check uses the same probe: a shut-off move stops when
it cannot be told, and the source file stays.

The API's migrate call takes disk_pools ({target: pool}) and
delete_source.
This commit is contained in:
catborise 2026-10-29 18:20:50 +03:00
parent 10eb0d1c2e
commit 51d24fce30
21 changed files with 828 additions and 42 deletions

View file

@ -276,7 +276,7 @@ A compute node is a KVM host the panel manages over libvirt.
Firewall: the compute's VNC ports (`5900`–`65535`) must accept connections **only** from the panel's IP, never from public networks.
For VM migration between computes, define shared storage as a storage pool on every host (a VM migrates only when its disk, backing and ISO files and block devices are volumes of the destination's pools), and where the hosts cannot resolve each other's names, set each compute's **Migration address** on its edit page.
For VM migration between computes, define shared storage as a storage pool on every host (a VM migrates only when its disk, backing and ISO files and block devices are volumes of the destination's pools), and where the hosts cannot resolve each other's names, set each compute's **Migration address** on its edit page. A VM's disks can instead be copied into storage pools of the destination during the migration: after choosing the destination, the **Disks** list shows each disk file and the destination pools that can take it (directory, filesystem and NFS pools; one with a file of the same name refuses the copy), and a disk on storage both hosts share can also stay there. Plain raw or qcow2 disks without a backing file are copied; a shut-off VM is started paused for the copy, its CPUs never run, and is shut off on the destination. With **Delete the source files of copied disks** the source files go after the migration, unless another VM or volume uses them. The copy goes through the destination's migration port range (49152-49215 by default), like the memory.
If libvirt warns `Host SMBIOS information is not available`, install `dmidecode` (`apt-get`, `dnf` or `zypper install -y dmidecode`) and restart `libvirtd`.

View file

@ -53,5 +53,6 @@ class MigrationAddressTests(TestCase):
patch("instances.utils.wvmInstances") as instances, \
patch("instances.utils.wvmInstance"), \
patch.object(Instance, "proxy", MagicMock()):
instances.return_value.moveto.return_value = ("live", [])
migrate_instance(dest, vm, user, live=True)
self.assertEqual(instances.return_value.moveto.call_args.kwargs["uri"], "tcp://198.51.100.9")

View file

@ -117,6 +117,9 @@ class MigrateSerializer(serializers.ModelSerializer):
autoconverge = serializers.BooleanField(default=True)
compress = serializers.BooleanField(default=False)
unsafe = serializers.BooleanField(default=False)
# {target: pool of the destination} of the disks copied there; delete_source deletes their source files
disk_pools = serializers.DictField(child=serializers.CharField(allow_blank=True), default=dict)
delete_source = serializers.BooleanField(default=False)
submission_key = serializers.CharField(write_only=True)
class Meta:
@ -129,5 +132,7 @@ class MigrateSerializer(serializers.ModelSerializer):
"autoconverge",
"compress",
"unsafe",
"disk_pools",
"delete_source",
"submission_key",
]

View file

@ -4,6 +4,7 @@ from computes.models import Compute
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.urls import reverse
from instances.jobs import MIGRATE_OPTIONS
from instances.models import Flavor, Instance
from instances.views import destroy as instance_destroy
from instances.views import (
@ -195,8 +196,8 @@ class MigrateViewSet(viewsets.ViewSet):
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
raw = {"instance": data["instance"].pk, "target": data["target_compute"].pk,
**{name: data.get(name, False) for name in ("live", "unsafe", "offline", "autoconverge", "compress")}}
raw = {"instance": data["instance"].pk, "target": data["target_compute"].pk, "disk_pools": data["disk_pools"],
**{name: data.get(name, False) for name in MIGRATE_OPTIONS}}
op = jobs.submit("instance.migrate", request.user, raw, data["submission_key"])
return Response(OperationSerializer(op, context={"request": request}).data, status=status.HTTP_202_ACCEPTED,
headers={"Location": reverse("operation-detail", args=[op.pk])})

View file

@ -1,6 +1,7 @@
"""Jobs of the instances app (operations framework, spec section 5)."""
import math
import os
import re
import time
@ -809,7 +810,7 @@ class CreateInstance(Kind):
return _log(op, state, "create")
MIGRATE_OPTIONS = ("live", "unsafe", "offline", "autoconverge", "compress")
MIGRATE_OPTIONS = ("live", "unsafe", "offline", "autoconverge", "compress", "delete_source")
def _target(instance, target_pk):
@ -841,7 +842,9 @@ class MigrateInstance(Kind):
progress_bytes = True # libvirt job data processed/total
def normalize(self, raw):
# disk_pools: {target: pool of the destination} of the disks to copy there
return {"instance": int(raw["instance"]), "target": int(raw["target"]),
"disk_pools": {str(k): str(v) for k, v in (raw.get("disk_pools") or {}).items() if v},
**{k: bool(raw.get(k)) for k in MIGRATE_OPTIONS}}
def validate(self, user, params):
@ -855,6 +858,13 @@ class MigrateInstance(Kind):
keys.update({claims.compute_key(instance.compute_id): Claim.SHARED,
claims.reconcile_key(instance.compute_id): Claim.EXCLUSIVE,
claims.instance_key(instance.uuid): Claim.EXCLUSIVE})
if params["disk_pools"]:
# a copy takes its file's name in the chosen pool; the source file may go
for disk in instance.proxy.get_disk_devices():
if pool := params["disk_pools"].get(disk["dev"]):
keys[claims.volume_key(params["target"], pool, os.path.basename(disk["path"]))] = Claim.EXCLUSIVE
if disk["storage"]:
keys[claims.volume_key(instance.compute_id, disk["storage"], disk["image"])] = Claim.EXCLUSIVE
return Plan(claims=keys, resource=claims.instance_key(instance.uuid),
label=f"{instance.compute.name}/{instance.name}",
intent={"compute": instance.compute_id, "uuid": instance.uuid})
@ -875,6 +885,7 @@ class MigrateInstance(Kind):
try:
# migrate_instance logs the outcome
utils.migrate_instance(Compute.objects.get(pk=p["target"]), instance, op.user,
disk_pools=p["disk_pools"],
**{k: p[k] for k in MIGRATE_OPTIONS})
except libvirtError:
op.refresh_from_db(fields=["cancel_requested"])

View file

@ -444,6 +444,40 @@
});
});
}
// the Migrate tab's disks: loaded for the chosen destination when the tab opens or the choice changes
// the form is not sent until the disks of the chosen destination are shown
var migratePlan = document.getElementById("migrate-plan");
var migrateCompute = document.getElementById("migrate_compute");
var migrateLoading = null;
function loadMigratePlan() {
if (!migrateCompute.value) return;
if (migrateLoading) migrateLoading.abort();
var loading = migrateLoading = new AbortController();
var submit = migratePlan.closest("form").querySelector('button[name="migrate"]');
submit.disabled = true;
migratePlan.textContent = "{{ _("Checking the disks…")|escapejs }}";
fetch(migratePlan.dataset.url + "?compute_id=" + encodeURIComponent(migrateCompute.value),
{signal: loading.signal}).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
migratePlan.innerHTML = html;
submit.disabled = false;
}).catch(function () {
if (loading.signal.aborted) return; // another destination was chosen
migratePlan.textContent = "{{ _("The disks could not be checked.")|escapejs }}";
submit.disabled = false; // the migration checks them again
});
}
if (migratePlan && migrateCompute) {
document.querySelector('[data-bs-target="#migrate"]').addEventListener("shown.bs.tab", function () {
if (!migratePlan.dataset.loaded) {
migratePlan.dataset.loaded = "true";
loadMigratePlan();
}
});
migrateCompute.addEventListener("change", loadMigratePlan);
}
function get_osinfo() {
show_tab('#settings');
show_tab('#osinfo');

View file

@ -0,0 +1,43 @@
{% load i18n %}
{# the disks of the Migrate tab for the chosen destination: where each goes #}
{% if error %}
<div class="alert alert-warning mb-0">{% 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>
</div>
<p class="form-text 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>
{% endif %}

View file

@ -697,7 +697,7 @@
<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">
<select name="compute_id" class="form-select" id="migrate_compute">
{% if computes_count != 1 %}
{% for comp in computes %}
{% if comp.id != compute.id %}
@ -708,6 +708,10 @@
</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">
@ -748,6 +752,14 @@
</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-lg btn-success float-end" name="migrate" >{% trans "Migrate" %}</button>
{% else %}

View file

@ -172,6 +172,31 @@ class LiveJobsTestCase(TransactionTestCase):
self.assertFalse(self.conn.lookupByUUIDString(uuid).isActive()) # defined on the source again
self.assertEqual(Instance.objects.get(uuid=uuid).compute, self.compute)
def test_a_disk_neither_host_shares_is_copied_there_and_back(self):
"""The test pool is a directory of each host's own: the disk is
copied into it, running there with its source file deleted on
request, shut off back (started paused for the copy) with it kept."""
dest, dest_conn = self.second_host()
self.addCleanup(livetest.remove_pool, dest_conn)
livetest.ensure_pool(dest_conn)
path = livetest.create_volume(self.conn, P + "copy")
dom = livetest.define_vm(self.conn, P + "copy", [path])
dom.create()
uuid = dom.UUIDString()
instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=uuid)
pools = {"vda": livetest.POOL}
there = {"instance": instance.pk, "target": dest.pk, "live": True, "disk_pools": pools, "delete_source": True}
self.run_job(jobs.submit("instance.migrate", self.admin, there, "key-copy0001"))
self.assertTrue(dest_conn.lookupByUUIDString(uuid).isActive())
self.assertEqual((livetest.volume_exists(self.conn, path), livetest.volume_exists(dest_conn, path)),
(False, True))
dest_conn.lookupByUUIDString(uuid).destroy()
back = {"instance": instance.pk, "target": self.compute.pk, "offline": True, "disk_pools": pools}
self.run_job(jobs.submit("instance.migrate", self.admin, back, "key-copy0002"))
self.assertFalse(self.conn.lookupByUUIDString(uuid).isActive())
self.assertEqual((livetest.volume_exists(self.conn, path), livetest.volume_exists(dest_conn, path)),
(True, True))
def test_a_shut_off_uefi_vm_migrates_with_its_nvram_there_and_back(self):
dest, _dest_conn = self.second_host()
hosts = {self.compute.pk: wvmConnect(*self.args), dest.pk: wvmConnect(dest.hostname, dest.login, "", 2)}

View file

@ -31,7 +31,7 @@ class MigrateJobTestCase(TransactionTestCase):
patch.object(Compute, "status", True).start()
patch.object(Instance, "proxy", proxy).start()
self.dest_conn = patch("instances.utils.wvmInstances").start().return_value
self.dest_conn.moveto.return_value = "live"
self.dest_conn.moveto.return_value = ("live", [])
self.addCleanup(patch.stopall)
def queue(self, **form):
@ -76,6 +76,41 @@ class MigrateJobTestCase(TransactionTestCase):
self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest)
def test_a_copy_claims_its_volume_on_both_computes(self):
Instance.proxy.get_disk_devices.return_value = [
{"dev": "vda", "storage": "default", "image": "mig-vm.qcow2", "path": "/i/mig-vm.qcow2"},
{"dev": "vdb", "storage": None, "image": "/dev/sdb", "path": "/dev/sdb"}]
op = self.queue(live_migrate="true", delete_source="true", **{"pool-vda": "fast", "pool-vdb": ""})
self.assertTrue({(f"volume:{self.source.pk}:default/mig-vm.qcow2", "exclusive"),
(f"volume:{self.dest.pk}:fast/mig-vm.qcow2", "exclusive")}
<= set(Claim.objects.values_list("resource", "mode")))
self.assertEqual((op.params["disk_pools"], op.params["delete_source"]), ({"vda": "fast"}, True))
ctx = self.start(op)
get_kind(op.kind).run(op, ctx)
self.assertEqual(self.dest_conn.moveto.call_args.kwargs["pools"], {"vda": "fast"})
def test_the_migrate_tab_lists_each_disk_with_where_it_can_go(self):
Instance.proxy.instance.XMLDesc.return_value = "<domain/>"
Instance.proxy.get_status.return_value = 5
with patch("instances.views.wvmInstances") as instances:
dest = instances.return_value
fast, default = MagicMock(), MagicMock()
fast.info.return_value = default.info.return_value = [2, 0, 0, 8 << 30]
dest.file_pools.return_value = {"fast": (fast, "/srv/fast"), "default": (default, "/i")}
dest.disk_plan.return_value = [
{"target": "vda", "path": "/i/mig-vm.qcow2", "capacity": 1 << 30, "shared": False, "refusal": None},
{"target": "sda", "path": "/i/os.iso", "capacity": 1 << 20, "shared": False,
"refusal": "an image is not copied; eject it first"}]
page = self.client.get(reverse("instances:migrate_plan", args=[self.vm.pk]),
{"compute_id": self.dest.pk}).content.decode()
dest.close.assert_called_once_with()
# the pool on the disk's directory is chosen
self.assertIn('<option value="default" selected>Copy to default (8.0\xa0GB free)</option>', page)
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)
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):
op = self.queue(live_migrate="true")
ctx = self.start(op)

View file

@ -29,7 +29,7 @@ class MigrationBookkeepingTests(TestCase):
patch("instances.utils.wvmInstances") as instances, \
patch("instances.utils.wvmInstance") as instance, \
patch.object(Instance, "proxy", proxy):
instances.return_value.moveto.return_value = "live"
instances.return_value.moveto.return_value = ("live", [])
if autostart_error:
instance.return_value.set_autostart.side_effect = autostart_error
self.moveto = instances.return_value.moveto

View file

@ -26,7 +26,7 @@ class MigrationLogTests(TestCase):
self.dest = Compute.objects.create(name="ml-dst", hostname="198.51.100.9", login="root", password="", type=2)
self.vm = Instance.objects.create(compute=self.source, name="vm", uuid="u-ml")
def migrate(self, outcome, nvram=None, remove_nvram=True, **request):
def migrate(self, outcome, nvram=None, remove_nvram=True, copied=(), delete_fails=None, in_use=(), **request):
self.proxy = MagicMock()
self.proxy.get_autostart.return_value = False
self.proxy.get_nvram.return_value = nvram
@ -34,6 +34,9 @@ class MigrationLogTests(TestCase):
self.proxy.remove_nvram.side_effect = remove_nvram
else:
self.proxy.remove_nvram.return_value = remove_nvram
self.proxy.paths_in_use.return_value = set(in_use)
if delete_fails:
self.proxy.get_volume_by_path.return_value.delete.side_effect = delete_fails
with patch.object(Compute, "status", True), \
patch("instances.utils.wvmInstances") as instances, \
patch("instances.utils.wvmInstance"), \
@ -42,7 +45,7 @@ class MigrationLogTests(TestCase):
if isinstance(outcome, Exception):
instances.return_value.moveto.side_effect = outcome
else:
instances.return_value.moveto.return_value = outcome
instances.return_value.moveto.return_value = (outcome, list(copied))
return migrate_instance(self.dest, self.vm, self.admin, **request)
def test_the_nvram_file_left_on_the_source_is_removed(self):
@ -100,3 +103,37 @@ class MigrationLogTests(TestCase):
with self.assertRaisesRegex(util.OperationError, "A shut-off VM cannot be migrated live"):
self.migrate(util.OperationError("A shut-off VM cannot be migrated live"), live=True)
self.assertEqual(rows(), [("VM vm: migrate to 198.51.100.9: A shut-off VM cannot be migrated live", False)])
def test_the_source_copies_stay_unless_deleted(self):
disks = ["/var/lib/libvirt/images/vm.qcow2", "/var/lib/libvirt/images/vm-data.qcow2"]
self.migrate("live, disks copied: vda to default, vdb to default", copied=disks, live=True)
self.assertFalse(self.proxy.get_volume_by_path.called)
self.assertEqual(rows(), [
("VM vm: migrate (live, disks copied: vda to default, vdb to default) to 198.51.100.9", True),
("VM vm: keep the source copy /var/lib/libvirt/images/vm.qcow2", True),
("VM vm: keep the source copy /var/lib/libvirt/images/vm-data.qcow2", True),
])
def test_deleted_source_copies_and_a_failed_delete_are_logged(self):
self.migrate("live, disks copied: vda", copied=["/var/lib/libvirt/images/vm.qcow2"], live=True,
delete_source=True)
self.proxy.get_volume_by_path.assert_called_once_with("/var/lib/libvirt/images/vm.qcow2")
self.assertEqual(rows()[-1], ("VM vm: delete the source copy /var/lib/libvirt/images/vm.qcow2", True))
self.vm.compute = self.source
self.vm.save()
self.migrate("live, disks copied: vda", copied=["/var/lib/libvirt/images/vm.qcow2"], live=True,
delete_source=True, delete_fails=libvirtError("cannot delete"))
self.assertEqual(rows()[-1], ("VM vm: delete the source copy /var/lib/libvirt/images/vm.qcow2: cannot delete",
False))
self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest)
def test_a_source_copy_another_vm_or_volume_uses_stays(self):
disks = ["/var/lib/libvirt/images/vm.qcow2", "/var/lib/libvirt/images/base.qcow2"]
self.migrate("live, disks copied: vda, vdb", copied=disks, in_use={disks[1]}, live=True,
delete_source=True)
self.proxy.get_volume_by_path.assert_called_once_with(disks[0])
self.assertEqual(rows()[-2:], [
("VM vm: delete the source copy /var/lib/libvirt/images/vm.qcow2", True),
("VM vm: keep the source copy /var/lib/libvirt/images/base.qcow2: another VM or volume uses it", True),
])

View file

@ -138,6 +138,7 @@ ALLOWED_HTML_SINKS = {
("accounts/templates/accounts/otp_login.html", "$btn.html("): "static spinner markup",
("instances/templates/instance.html", "//sto_input.innerHTML = pool;"): "comment",
("instances/templates/instance.html", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
("instances/templates/instance.html", "migratePlan.innerHTML = html;"): "the panel's own fragment, migrate_plan.html, escaped by Django",
}

View file

@ -58,6 +58,7 @@ urlpatterns = [
path("<int:pk>/add_network/", views.add_network, name="add_network"),
path("<int:pk>/delete_network/", views.delete_network, name="delete_network"),
path("<int:pk>/host_devices/", views.host_devices, name="host_devices"),
path("<int:pk>/migrate_plan/", views.migrate_plan, name="migrate_plan"),
path("<int:pk>/add_usb_hostdev/", views.add_usb_hostdev, name="add_usb_hostdev"),
path("<int:pk>/delete_usb_hostdev/", views.delete_usb_hostdev, name="delete_usb_hostdev"),
path("<int:pk>/add_pci_hostdev/", views.add_pci_hostdev, name="add_pci_hostdev"),

View file

@ -158,10 +158,15 @@ def migrate_instance(
offline=False,
autoconverge=False,
compress=False,
delete_source=False,
disk_pools=None,
):
"""Migrates instance to new_compute and logs the outcome (the mode
used, or the error), then each later step that failed in a row of its
own. Returns the mode."""
own. disk_pools: {target: pool} of the disks copied into pools of
new_compute (moveto's pools); their source files go with delete_source
unless another VM or volume uses them, and are named in the log
otherwise. Returns the mode."""
source, target = instance.compute, migration_target(new_compute)
def log(action, error=None):
@ -179,7 +184,7 @@ def migrate_instance(
autostart = instance.proxy.get_autostart()
nvram = instance.proxy.get_nvram()
mode = conn_migrate.moveto(
mode, copied = conn_migrate.moveto(
instance.proxy,
instance.name,
live,
@ -188,6 +193,7 @@ def migrate_instance(
autoconverge,
compress,
uri=new_compute.migration_uri,
pools=disk_pools,
)
except Exception as error:
log(f"migrate to {target}", error)
@ -217,6 +223,24 @@ def migrate_instance(
if conn_new is not None:
conn_new.close()
in_use = set()
if copied and delete_source:
try:
in_use = instance.proxy.paths_in_use()
except libvirtError as error:
log("check the source copies before deleting them", error)
delete_source = False
for path in copied:
if not delete_source or path in in_use:
log(f"keep the source copy {path}" + (": another VM or volume uses it" if path in in_use else ""))
continue
try:
instance.proxy.get_volume_by_path(path).delete(0)
except libvirtError as error:
log(f"delete the source copy {path}", error)
else:
log(f"delete the source copy {path}")
# libvirt keeps the NVRAM file on the source; a VM created there
# later with the same name would start with these variables.
if nvram:

View file

@ -49,7 +49,7 @@ from operations.utils import busy_message, summary
from webvirtcloud.middleware import error_text, same_origin_referer
from vrtManager import util
from vrtManager.create import cpu_choices, wvmCreate
from vrtManager.instance import USAGE_MAX_WINDOW
from vrtManager.instance import USAGE_MAX_WINDOW, wvmInstances
from vrtManager.interface import wvmInterface
from vrtManager.nodedevs import device_names, hostdev_problems, node_devices, pci_problems
from vrtManager.storage import wvmStorage
@ -850,7 +850,10 @@ def migrate(request, pk):
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"),
"autoconverge": request.POST.get("autoconverge"), "compress": request.POST.get("compress")}
"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()
if key.startswith("pool-")}}
try:
op = jobs.submit("instance.migrate", request.user, raw, request.POST.get("submission_key", ""))
except claims.Busy as busy:
@ -863,6 +866,36 @@ def migrate(request, pk):
return get_safe_redirect(request, default=reverse("instances:instance", args=[pk]))
@superuser_only
def migrate_plan(request, pk):
"""The disks of the Migrate tab for the chosen destination, loaded when
one is chosen: each with the pools of the destination it can be copied
into, or the storage both hosts share."""
instance = get_instance(request.user, pk)
compute_id = request.GET.get("compute_id", "")
if not compute_id.isdigit():
raise Http404
target = get_object_or_404(Compute, pk=compute_id)
dest = None
try:
dest = wvmInstances(target.hostname, target.login, target.password, target.type)
pools = [{"name": name, "directory": directory, "free": pool.info()[3]}
for name, (pool, directory) in sorted(dest.file_pools().items())]
disks = dest.disk_plan(instance.proxy, instance.proxy.instance.XMLDesc(0))
for disk in disks:
# a copy goes by default to the pool on the same directory, else the first
names = [pool["name"] for pool in pools if pool["directory"] == os.path.dirname(disk["path"])]
names = names or [pool["name"] for pool in pools]
disk["default"] = names[0] if names else None
context = {"disks": disks, "pools": pools, "shut_off": instance.proxy.get_status() == 5}
except libvirtError as err:
context = {"error": error_text(request, err)}
finally:
if dest is not None:
dest.close()
return render(request, "instances/migrate_plan.html", context)
GSTFSD_PORT = 16510
GSTFSD_TIMEOUT = 120 # gstfsd answers within its 100 s limit for a request (GSTFSD_DEADLINE)

View file

@ -880,8 +880,11 @@ class wvmConnect(object):
if os.path.dirname(path) != NVRAM_DIR:
return False
with self._nvram_pool() as pool, destination._nvram_pool() as other:
if self._nvram_shared(pool, other):
return False
try:
if self.same_directory(pool, other):
return False
except libvirtError:
return False # not known to be the source's own file
vol = self._find_volume(pool, os.path.basename(path))
if vol is None:
return False
@ -889,11 +892,11 @@ class wvmConnect(object):
return True
@staticmethod
def _nvram_shared(pool, other):
"""Whether pools on NVRAM_DIR of two hosts are one directory, or other
is not known to be one of its own: creating the same file in both
fails then. The create is exclusive on the file server, unlike a
directory listing, which an NFS client may cache."""
def same_directory(pool, other):
"""Whether pools of two hosts are one directory: creating the same
file in both fails then, as it exists already. The create is
exclusive on the file server, unlike a directory listing, which an
NFS client may cache. Any other failure raises: it tells nothing."""
marker = (
f"<volume><name>webvirtcloud-check-{uuid.uuid4().hex}</name><capacity>0</capacity>"
"<target><format type='raw'/></target></volume>"
@ -901,22 +904,32 @@ class wvmConnect(object):
mine = pool.createXML(marker, 0)
try:
other.createXML(marker, 0).delete(0)
except libvirtError:
return True
except libvirtError as error:
# the file exists (OPERATION_INVALID), or the pool lists it already
if error.get_error_code() in (libvirt.VIR_ERR_OPERATION_INVALID, libvirt.VIR_ERR_STORAGE_VOL_EXIST):
return True
raise
finally:
mine.delete(0)
return False
def pool_at(self, directory):
"""The active pool whose directory is directory, or None. A pool
without a target path (rbd, gluster, ...) holds no files."""
for pool in self.wvm.listAllStoragePools(libvirt.VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
if (util.get_xml_path(pool.XMLDesc(0), "/pool/target/path") or "").rstrip("/") == directory:
return pool
return None
@contextlib.contextmanager
def _nvram_pool(self):
"""An active, refreshed pool on NVRAM_DIR: libvirt reads and deletes
files only as volumes of a pool. A transient one when none exists."""
for pool in self.wvm.listAllStoragePools(libvirt.VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
# a pool without a target path (rbd, gluster, ...) holds no files
if (util.get_xml_path(pool.XMLDesc(0), "/pool/target/path") or "").rstrip("/") == NVRAM_DIR:
pool.refresh(0)
yield pool
return
pool = self.pool_at(NVRAM_DIR)
if pool is not None:
pool.refresh(0)
yield pool
return
pool = self.wvm.storagePoolCreateXML(
f"<pool type='dir'><name>webvirtcloud-nvram-{uuid.uuid4().hex[:8]}</name>"
f"<target><path>{NVRAM_DIR}</path></target></pool>",

View file

@ -17,6 +17,7 @@ try:
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP,
VIR_DOMAIN_RUNNING,
VIR_DOMAIN_SHUTOFF,
VIR_DOMAIN_START_PAUSED,
VIR_DOMAIN_XML_SECURE,
VIR_DOMAIN_XML_INACTIVE,
VIR_DOMAIN_XML_MIGRATABLE,
@ -30,7 +31,12 @@ try:
VIR_MIGRATE_AUTO_CONVERGE,
VIR_MIGRATE_COMPRESSED,
VIR_MIGRATE_LIVE,
VIR_MIGRATE_NON_SHARED_DISK,
VIR_MIGRATE_OFFLINE,
VIR_MIGRATE_PARAM_DEST_XML,
VIR_MIGRATE_PARAM_MIGRATE_DISKS,
VIR_MIGRATE_PARAM_PERSIST_XML,
VIR_MIGRATE_PARAM_URI,
VIR_MIGRATE_PERSIST_DEST,
VIR_MIGRATE_UNDEFINE_SOURCE,
VIR_MIGRATE_UNSAFE,
@ -99,7 +105,8 @@ def _rate(before, after, seconds, scale=1):
class wvmInstances(wvmConnect):
def moveto(self, conn, name, live, unsafe, offline, autoconverge=False, compress=False, uri=None):
def moveto(self, conn, name, live, unsafe, offline, autoconverge=False, compress=False, uri=None,
pools=None):
"""Migrates the VM of conn (a wvmInstance on the source) here.
The mode follows the VM's state, read once: a running VM migrates
@ -108,8 +115,10 @@ class wvmInstances(wvmConnect):
fit the state is refused. unsafe, autoconverge (live only) and
compress apply to a running VM. uri: the native migration URI
(tcp://address) on the destination; None lets libvirt use the
destination's own hostname. Returns the mode and options used, for
the log."""
destination's own hostname. pools: {target: pool here} of the disks
libvirt copies into those pools during the migration (disk_copies);
the other disks must be on storage both hosts share. Returns the mode
and options used, for the log, and the paths of the copied disks."""
state = conn.get_status()
if state == 5:
if live:
@ -146,13 +155,17 @@ class wvmInstances(wvmConnect):
raise util.OperationError("A VM with snapshots cannot be migrated")
if state == 5:
self.refuse_local_state(dom.XMLDesc(0))
definitions = {dom.XMLDesc(0)}
# the definition the migration carries first: it has the disks a migration copies
definitions = [dom.XMLDesc(0)]
if state != 5:
definitions.add(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
definitions.append(dom.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
# a host device is of the source host: the destination may have none, or another one
if any(etree.fromstring(xml).xpath(HOST_DEVICES) for xml in definitions):
raise util.OperationError("A VM with host devices cannot be migrated; remove them first")
missing = self.missing_disks(definitions)
copies = self.disk_copies(conn, definitions, pools or {})
if copies:
mode.append("disks copied: " + ", ".join(f"{copy['target']} to {copy['pool'].name()}" for copy in copies))
missing = self.missing_disks(definitions, copied={copy["path"] for copy in copies})
if missing:
raise util.OperationError(
"The destination host does not have these disks of the VM: "
@ -167,7 +180,11 @@ class wvmInstances(wvmConnect):
"Destination host emulator is different. Cannot be migrated"
)
if state == 5:
if copies:
note = self.migrate_copying(conn, state, flags, uri, copies)
if note:
mode.append(note)
elif state == 5:
# Workaround for a libvirt bug (RHEL-156800), see move_definition.
# Once supported hosts all run a fixed libvirt, this branch and
# move_definition can go: the flags above are those for
@ -175,7 +192,185 @@ class wvmInstances(wvmConnect):
self.move_definition(dom, conn)
else:
dom.migrate(self.wvm, flags, None, uri, 0)
return ", ".join(mode)
return ", ".join(mode), [copy["path"] for copy in copies]
def migrate_copying(self, conn, state, flags, uri, copies):
"""Migrates the VM of conn while libvirt copies the disks of copies
into their pools here (NON_SHARED_DISK), the definitions naming the
new paths. libvirt copies the disks of a running or paused VM only: a
shut-off VM is started paused for it, its CPUs never run, and is shut
off here after it; its UEFI variables are copied first, as this host
would start from the firmware's template. Returns a note for the log
when the VM could not be shut off here."""
dom = conn.instance
nvram = None
xml_flags = VIR_DOMAIN_XML_SECURE | VIR_DOMAIN_XML_MIGRATABLE
params = {
VIR_MIGRATE_PARAM_MIGRATE_DISKS: [copy["target"] for copy in copies],
VIR_MIGRATE_PARAM_DEST_XML: self.moved_disks(dom.XMLDesc(xml_flags), copies),
VIR_MIGRATE_PARAM_PERSIST_XML: self.moved_disks(dom.XMLDesc(xml_flags | VIR_DOMAIN_XML_INACTIVE), copies),
}
if uri:
params[VIR_MIGRATE_PARAM_URI] = uri
try:
if state == 5:
flags &= ~VIR_MIGRATE_OFFLINE
nvram = self.copy_nvram(conn)
dom.createWithFlags(VIR_DOMAIN_START_PAUSED)
dom.migrate3(self.wvm, params, flags | VIR_MIGRATE_NON_SHARED_DISK)
except libvirtError as error:
if state == 5:
with contextlib.suppress(libvirtError):
dom.destroy() # shut off on the source again
self.remove_partial_copies(dom.UUIDString(), copies, error, nvram)
raise
if state == 5:
try:
self.wvm.lookupByUUIDString(dom.UUIDString()).destroy()
except libvirtError as error:
return f"left paused here, shut it off: {error}"
return None
@staticmethod
def moved_disks(xml, copies):
"""xml with each copied disk's source at its path here; a disk is
found by its target and its source path before the copy."""
tree = etree.fromstring(xml)
sources = [(source, copy["dest"]) for copy in copies for source in tree.xpath(
"devices/disk[target/@dev=$target]/source[@file=$path]", target=copy["target"], path=copy["path"])]
for source, dest in sources:
source.set("file", dest)
return etree.tostring(tree, encoding="unicode")
def disk_plan(self, conn, xml):
"""How the file disks of the VM of conn (on the source) in xml, the
definition the migration carries, can reach this host: a list of
{target, path, capacity, shared, refusal}. shared: both hosts see the
file at its path, as the directory both have as a storage pool is
one (same_directory); False when not, or when it cannot be told.
refusal: why the disk cannot be copied, or None."""
rows, shared = [], {}
for disk in ElementTree.fromstring(xml).iter("disk"):
source = disk.find("source")
if disk.get("type") != "file" or source is None or not source.get("file"):
continue
path = source.get("file")
directory = os.path.dirname(path)
if directory not in shared:
here, there = self.pool_at(directory), conn.pool_at(directory)
try:
shared[directory] = here is not None and there is not None and self.same_directory(there, here)
except libvirtError:
shared[directory] = False # not known to be shared: copied, never assumed
try:
volume = conn.wvm.storageVolLookupByPath(path)
except libvirtError:
volume = None
rows.append({"target": disk.find("target").get("dev"), "path": path, "shared": shared[directory],
"capacity": volume.info()[1] if volume else None,
"refusal": self.copy_refusal(disk, volume)})
return rows
@staticmethod
def copy_refusal(disk, volume):
"""Why the disk (its volume on the source, None if no pool has it) cannot be copied, or None."""
if disk.get("device") != "disk":
return "an image is not copied; eject it first"
if disk.find("readonly") is not None or disk.find("encryption") is not None \
or disk.find("source/encryption") is not None:
return "a read-only or encrypted disk is not copied"
driver = disk.find("driver")
if driver is None or driver.get("type") not in ("raw", "qcow2"):
return "only raw and qcow2 disks are copied"
if volume is None:
return "no storage pool of the source host has it"
if ElementTree.fromstring(volume.XMLDesc(0)).findtext("backingStore/path"):
return "a disk with a backing file is not copied"
return None
def file_pools(self):
"""The active pools of this host that keep disks as files, a copy's
destination: {name: (pool, directory)}."""
pools = {}
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
tree = ElementTree.fromstring(pool.XMLDesc(0))
if tree.get("type") in ("dir", "fs", "netfs") and tree.findtext("target/path"):
pools[pool.name()] = (pool, tree.findtext("target/path").rstrip("/"))
return pools
def disk_copies(self, conn, definitions, pools):
"""The copies of the disks pools names a pool here for: {target, path,
dest, pool}, from the first of definitions (XML, the one the migration
carries). Each other file disk (see disk_plan), also of the next
start's definition, must be on shared storage. A copy never replaces a file: one
of the same name in the pool refuses it (libvirt would write into it).
Every problem is named in one refusal."""
here = self.file_pools() if pools else {}
copies, problems, targets, carried = [], [], set(), set()
for row in self.disk_plan(conn, definitions[0]):
targets.add(row["target"])
carried.add(row["path"])
name = pools.get(row["target"])
label = f"{row['target']} ({row['path']})"
if not name:
if not row["shared"]:
problems.append(f"{label}: the destination host does not share it; choose a pool to copy it to")
continue
if row["refusal"]:
problems.append(f"{label}: {row['refusal']}")
continue
if name not in here:
problems.append(f"{label}: the destination host has no pool {name} for disk files")
continue
pool, directory = here[name]
pool.refresh(0)
file = os.path.basename(row["path"])
if self._find_volume(pool, file) is not None:
problems.append(f"{label}: pool {name} of the destination host already has a file {file}")
continue
if any(copy["dest"] == f"{directory}/{file}" for copy in copies):
problems.append(f"{label}: another disk is copied to {directory}/{file}")
continue
copies.append({"target": row["target"], "path": row["path"], "dest": f"{directory}/{file}",
"pool": pool})
problems += [f"{target}: the VM has no disk file to copy" for target in pools.keys() - targets if pools[target]]
for xml in definitions[1:]:
problems += [f"{row['target']} ({row['path']}) of the next start: the destination host does not share it"
for row in self.disk_plan(conn, xml) if row["path"] not in carried and not row["shared"]]
if problems:
raise util.OperationError("The disks cannot be migrated: " + "; ".join(problems))
return copies
def remove_partial_copies(self, uuid, copies, error, nvram=None):
"""Deletes the copies libvirt made here for a migration that failed,
and the copy of its NVRAM file, while this host has no VM of uuid (a
VM here has them as its disks)."""
try:
self.wvm.lookupByUUIDString(uuid)
return
except libvirtError as lookup_error:
if lookup_error.get_error_code() != VIR_ERR_NO_DOMAIN:
raise util.OperationError(
f"{error}; remove the partial disk copies from the destination host: "
+ ", ".join(copy["dest"] for copy in copies)
) from error
left = []
for copy in copies:
copy["pool"].refresh(0)
volume = self._find_volume(copy["pool"], os.path.basename(copy["dest"]))
try:
if volume is not None:
volume.delete(0)
except libvirtError:
left.append(copy["dest"])
try:
self.delete_nvram(nvram)
except libvirtError:
left.append(f"{NVRAM_DIR}/{nvram}")
if left:
raise util.OperationError(
f"{error}; remove the partial disk copies from the destination host: {', '.join(left)}"
) from error
@staticmethod
def refuse_local_state(xml):
@ -201,7 +396,7 @@ class wvmInstances(wvmConnect):
vol = self._find_volume(source, name)
if vol is None:
return None # never started: libvirt creates it at the first start
if self._nvram_shared(source, target):
if self.same_directory(source, target):
if self._find_volume(target, name) is None:
raise util.OperationError(f"The destination host cannot see the NVRAM file {path}")
return None
@ -218,7 +413,7 @@ class wvmInstances(wvmConnect):
with self._nvram_pool() as pool:
pool.storageVolLookupByName(name).delete(0)
def missing_disks(self, definitions):
def missing_disks(self, definitions, copied=()):
"""(target, source) of the disks in the VM definitions (XML) that are
not volumes of this host's active storage pools: the VM's own files,
their backing files (as the definition or this host's volume lists
@ -240,6 +435,8 @@ class wvmInstances(wvmConnect):
if kind == "volume":
disks[(source.get("pool"), source.get("volume"))] = target
elif kind in ("file", "block") and (source.get("file") or source.get("dev")):
if element is disk and source.get("file") in copied:
continue
disks[(None, source.get("file") or source.get("dev"))] = target
elif kind not in ("file", "block"):
disks[(kind, None)] = target

View file

@ -0,0 +1,289 @@
"""Migration with disks copied into pools of the destination: libvirt copies
the disks a pool is chosen for (NON_SHARED_DISK, only those targets), the
definitions name the new paths; every other file disk must be on storage
both hosts share. A shut-off VM is started paused for the copy and shut off
on the destination. Disks that cannot be copied are refused before anything
changes; the copies of a failed migration are removed again."""
import unittest
from unittest import mock
import libvirt
from django.conf import settings
if not settings.configured:
settings.configure(MAC_OUI="52:54:10")
from vrtManager import util
from vrtManager.connection import wvmConnect
from vrtManager.instance import wvmInstances
IMAGES = "/var/lib/libvirt/images"
FAST = "/srv/fast"
def error(code, message="error"):
err = libvirt.libvirtError(message)
err.err = (code, 0, message, 2, None, None, None, 0, 0)
return err
def disk(path, target="vda", device="disk", fmt="qcow2", extra=""):
return (f"<disk type='file' device='{device}'><driver name='qemu' type='{fmt}'/>"
f"<source file='{path}'/><target dev='{target}'/>{extra}</disk>")
def domain(*disks):
return f"<domain><devices>{''.join(disks)}</devices></domain>"
class Pool:
def __init__(self, name, path, files=(), kind="dir"):
self._name, self.path, self.kind = name, path, kind
self.files = set(files)
self.deleted = []
def name(self):
return self._name
def XMLDesc(self, flags=0):
return f"<pool type='{self.kind}'><name>{self._name}</name><target><path>{self.path}</path></target></pool>"
def refresh(self, flags=0):
pass
def storageVolLookupByName(self, name):
if name not in self.files:
raise error(libvirt.VIR_ERR_NO_STORAGE_VOL)
volume = mock.Mock()
volume.delete.side_effect = lambda flags: self.deleted.append(name)
return volume
class Source:
"""The source host's wvmInstance: a VM in state with the given running
(and next-start) definitions; its volumes have capacity and a backing
file; pools: the directories it has as pools."""
def __init__(self, state, xml, inactive=None, capacity=1 << 30, backing=None, pools=(IMAGES,)):
self.state = state
self.instance = mock.Mock()
self.instance.XMLDesc.side_effect = lambda flags: inactive if inactive and flags & 2 else xml
self.instance.snapshotNum.return_value = 0
self.instance.UUIDString.return_value = "u-1"
self.wvm = mock.Mock()
volume = self.wvm.storageVolLookupByPath.return_value
volume.info.return_value = [0, capacity, capacity]
backing_xml = f"<backingStore><path>{backing}</path></backingStore>" if backing else ""
volume.XMLDesc.return_value = f"<volume>{backing_xml}</volume>"
self.pools = pools
def get_status(self):
return self.state
def get_arch(self):
return "x86_64"
def get_dom_emulator(self):
return "/usr/bin/qemu-kvm"
def pool_at(self, directory):
return Pool("src", directory) if directory in self.pools else None
def destination(*pools, shared=False, vm_here=False):
dest = wvmInstances.__new__(wvmInstances)
dest.wvm = mock.Mock()
dest.wvm.listAllStoragePools.return_value = list(pools)
if not vm_here:
dest.wvm.lookupByUUIDString.side_effect = error(libvirt.VIR_ERR_NO_DOMAIN)
dest.get_emulator = lambda arch: "/usr/bin/qemu-kvm"
if isinstance(shared, Exception):
dest.same_directory = mock.Mock(side_effect=shared)
else:
dest.same_directory = mock.Mock(return_value=shared)
dest.missing_disks = mock.Mock(return_value=[])
dest.copy_nvram = mock.Mock(return_value=None)
dest.delete_nvram = mock.Mock()
return dest
def migrate(dest, source, pools, **options):
return dest.moveto(source, "vm", live=source.state == 1, unsafe=False, offline=source.state == 5,
uri="tcp://192.0.2.9", pools=pools, **options)
class DiskCopyTestCase(unittest.TestCase):
def test_disks_on_shared_storage_migrate_as_before(self):
source = Source(1, domain(disk(f"{IMAGES}/vm.qcow2")))
dest = destination(Pool("default", IMAGES), shared=True)
self.assertEqual(migrate(dest, source, {}), ("live", []))
source.instance.migrate.assert_called_once()
source.instance.migrate3.assert_not_called()
def test_a_disk_the_destination_does_not_share_needs_a_pool(self):
for pools, shared in (((IMAGES,), False), ((), True)): # not shared; no pool to probe on the source
with self.subTest(pools=pools):
source = Source(1, domain(disk(f"{IMAGES}/vm.qcow2")), pools=pools)
with self.assertRaisesRegex(util.OperationError, "vda .*does not share it; choose a pool"):
migrate(destination(Pool("default", IMAGES), shared=shared), source, {})
source.instance.migrate.assert_not_called()
def test_disks_are_copied_into_the_chosen_pools_under_their_new_paths(self):
xml = domain(disk(f"{IMAGES}/vm.qcow2"), disk(f"{IMAGES}/data.raw", "vdb", fmt="raw"),
"<disk type='network' device='disk'><source protocol='rbd' name='p/i'/><target dev='vdc'/></disk>")
source = Source(1, xml, inactive=xml)
dest = destination(Pool("default", IMAGES), Pool("fast", FAST))
mode, copied = migrate(dest, source, {"vda": "fast", "vdb": "default"})
self.assertEqual(mode, "live, disks copied: vda to fast, vdb to default")
self.assertEqual(copied, [f"{IMAGES}/vm.qcow2", f"{IMAGES}/data.raw"])
params, flags = source.instance.migrate3.call_args.args[1:]
self.assertEqual(params[libvirt.VIR_MIGRATE_PARAM_MIGRATE_DISKS], ["vda", "vdb"])
self.assertEqual(params[libvirt.VIR_MIGRATE_PARAM_URI], "tcp://192.0.2.9")
for key in (libvirt.VIR_MIGRATE_PARAM_DEST_XML, libvirt.VIR_MIGRATE_PARAM_PERSIST_XML):
self.assertIn(f'<source file="{FAST}/vm.qcow2"/>', params[key])
self.assertIn(f'<source file="{IMAGES}/data.raw"/>', params[key])
self.assertEqual(flags & (libvirt.VIR_MIGRATE_NON_SHARED_DISK | libvirt.VIR_MIGRATE_LIVE),
libvirt.VIR_MIGRATE_NON_SHARED_DISK | libvirt.VIR_MIGRATE_LIVE)
self.assertEqual(dest.missing_disks.call_args.kwargs["copied"], {f"{IMAGES}/vm.qcow2", f"{IMAGES}/data.raw"})
def test_a_shut_off_vm_runs_paused_for_the_copy_and_is_shut_off_here(self):
source = Source(5, domain(disk(f"{IMAGES}/vm.qcow2")))
dest = destination(Pool("default", IMAGES), vm_here=True)
mode, _copied = migrate(dest, source, {"vda": "default"})
self.assertEqual(mode, "offline, disks copied: vda to default")
dest.copy_nvram.assert_called_once_with(source) # this host would start from the firmware's template
source.instance.createWithFlags.assert_called_once_with(libvirt.VIR_DOMAIN_START_PAUSED)
flags = source.instance.migrate3.call_args.args[2]
self.assertFalse(flags & (libvirt.VIR_MIGRATE_OFFLINE | libvirt.VIR_MIGRATE_LIVE))
dest.wvm.lookupByUUIDString.return_value.destroy.assert_called_once_with()
def test_a_shut_off_vm_whose_migration_fails_is_shut_off_on_the_source_again(self):
source = Source(5, domain(disk(f"{IMAGES}/vm.qcow2")))
source.instance.migrate3.side_effect = libvirt.libvirtError("no route")
dest = destination(Pool("default", IMAGES))
dest.copy_nvram.return_value = "vm_VARS.fd"
with self.assertRaisesRegex(libvirt.libvirtError, "no route"):
migrate(dest, source, {"vda": "default"})
source.instance.destroy.assert_called_once_with()
dest.delete_nvram.assert_called_once_with("vm_VARS.fd")
def test_a_vm_left_paused_here_is_named_but_the_migration_stands(self):
source = Source(5, domain(disk(f"{IMAGES}/vm.qcow2")))
dest = destination(Pool("default", IMAGES), vm_here=True)
dest.wvm.lookupByUUIDString.return_value.destroy.side_effect = libvirt.libvirtError("busy")
mode, _copied = migrate(dest, source, {"vda": "default"})
self.assertEqual(mode, "offline, disks copied: vda to default, left paused here, shut it off: busy")
def test_disks_that_cannot_be_copied_are_refused_with_their_reasons(self):
cases = (
(disk(f"{IMAGES}/x.iso", "vda", device="cdrom", fmt="raw"), {}, "eject it first"),
(disk(f"{IMAGES}/vm.qcow2", extra="<readonly/>"), {}, "read-only or encrypted"),
(disk(f"{IMAGES}/vm.vmdk", fmt="vmdk"), {}, "only raw and qcow2"),
(disk(f"{IMAGES}/vm.qcow2"), {"backing": f"{IMAGES}/base.qcow2"}, "backing file"),
)
for xml, volume, reason in cases:
with self.subTest(reason=reason):
source = Source(1, domain(xml), **volume)
with self.assertRaisesRegex(util.OperationError, f"cannot be migrated: vda .*{reason}"):
migrate(destination(Pool("default", IMAGES)), source, {"vda": "default"})
source.instance.migrate3.assert_not_called()
def test_a_file_of_the_same_name_in_the_pool_is_never_overwritten(self):
source = Source(1, domain(disk(f"{IMAGES}/vm.qcow2")))
with self.assertRaises(util.OperationError) as refused:
migrate(destination(Pool("fast", FAST, files={"vm.qcow2"})), source, {"vda": "fast"})
self.assertEqual(str(refused.exception), "The disks cannot be migrated: "
f"vda ({IMAGES}/vm.qcow2): pool fast of the destination host already has a file vm.qcow2")
def test_only_a_pool_of_disk_files_and_a_disk_of_the_vm_can_be_chosen(self):
source = Source(1, domain(disk(f"{IMAGES}/vm.qcow2")))
dest = destination(Pool("vg", "/dev/vg", kind="logical"))
with self.assertRaisesRegex(util.OperationError, "has no pool vg for disk files; vdz: the VM has no disk"):
migrate(dest, source, {"vda": "vg", "vdz": "vg"})
def test_each_disk_keeps_its_own_new_path(self):
"""Disks of one file name in two directories: neither copy takes the other's path."""
xml = domain(disk(f"{IMAGES}/vm.qcow2"), disk(f"{FAST}/vm.qcow2", "vdb"))
source = Source(1, xml, pools=(IMAGES, FAST))
dest = destination(Pool("default", IMAGES), Pool("fast", FAST), Pool("slow", "/srv/slow"))
migrate(dest, source, {"vda": "fast", "vdb": "slow"})
dest_xml = source.instance.migrate3.call_args.args[1][libvirt.VIR_MIGRATE_PARAM_DEST_XML]
self.assertIn(f'<source file="{FAST}/vm.qcow2"/><target dev="vda"/>', dest_xml)
self.assertIn('<source file="/srv/slow/vm.qcow2"/><target dev="vdb"/>', dest_xml)
def test_a_disk_only_the_next_start_has_must_be_shared_too(self):
"""It is neither copied nor moved: a file of its name on the
destination would be another VM's, used at the next start."""
source = Source(1, domain(disk(f"{IMAGES}/live.qcow2")),
inactive=domain(disk(f"{IMAGES}/next.qcow2")))
with self.assertRaisesRegex(util.OperationError, f"vda \\({IMAGES}/next.qcow2\\) of the next start: "
"the destination host does not share it"):
migrate(destination(Pool("default", IMAGES)), source, {"vda": "default"})
source.instance.migrate3.assert_not_called()
def test_two_copies_never_go_to_one_file(self):
source = Source(1, domain(disk(f"{IMAGES}/vm.qcow2"), disk(f"{FAST}/vm.qcow2", "vdb")), pools=(IMAGES, FAST))
with self.assertRaisesRegex(util.OperationError, "vdb .*another disk is copied to /srv/slow/vm.qcow2"):
migrate(destination(Pool("slow", "/srv/slow")), source, {"vda": "slow", "vdb": "slow"})
@staticmethod
def fail_after_copying(source, pool, message):
"""libvirt made the copy here, then the migration failed."""
def migrate3(*args):
pool.files.add("vm.qcow2")
raise libvirt.libvirtError(message)
source.instance.migrate3.side_effect = migrate3
def test_a_failed_migration_removes_the_partial_copies(self):
source, pool = Source(1, domain(disk(f"{IMAGES}/vm.qcow2"))), Pool("fast", FAST)
self.fail_after_copying(source, pool, "operation aborted: canceled by client")
with self.assertRaisesRegex(libvirt.libvirtError, "canceled by client"):
migrate(destination(pool), source, {"vda": "fast"})
self.assertEqual(pool.deleted, ["vm.qcow2"])
def test_copies_a_vm_here_uses_are_kept(self):
source, pool = Source(1, domain(disk(f"{IMAGES}/vm.qcow2"))), Pool("fast", FAST)
self.fail_after_copying(source, pool, "lost reply")
with self.assertRaisesRegex(libvirt.libvirtError, "lost reply"):
migrate(destination(pool, vm_here=True), source, {"vda": "fast"})
self.assertEqual(pool.deleted, [])
class DiskPlanTestCase(unittest.TestCase):
def test_each_file_disk_with_its_storage_and_reason(self):
xml = domain(disk(f"{IMAGES}/vm.qcow2"), disk(f"{IMAGES}/x.iso", "sda", device="cdrom", fmt="raw"),
disk("/srv/local/data.qcow2", "vdb"))
dest = destination(Pool("default", IMAGES), Pool("local", "/srv/local"), shared=True)
rows = dest.disk_plan(Source(1, ""), xml)
self.assertEqual([(r["target"], r["shared"], r["refusal"], r["capacity"]) for r in rows], [
("vda", True, None, 1 << 30),
("sda", True, "an image is not copied; eject it first", 1 << 30),
("vdb", False, None, 1 << 30), # no pool on the source: cannot be told
])
dest.same_directory.assert_called_once() # one probe per directory
def test_a_probe_that_fails_means_not_shared(self):
dest = destination(Pool("default", IMAGES), shared=error(libvirt.VIR_ERR_SYSTEM_ERROR))
rows = dest.disk_plan(Source(1, ""), domain(disk(f"{IMAGES}/vm.qcow2")))
self.assertFalse(rows[0]["shared"])
class SameDirectoryTestCase(unittest.TestCase):
def check(self, other_create):
pool, other = mock.Mock(), mock.Mock()
other.createXML.side_effect = other_create
try:
return wvmConnect.same_directory(pool, other)
finally:
pool.createXML.return_value.delete.assert_called_once()
def test_a_file_the_other_host_has_already_means_one_directory(self):
for code in (libvirt.VIR_ERR_OPERATION_INVALID, libvirt.VIR_ERR_STORAGE_VOL_EXIST):
with self.subTest(code=code):
self.assertTrue(self.check(error(code)))
self.assertFalse(self.check(None))
def test_any_other_failure_tells_nothing(self):
with self.assertRaises(libvirt.libvirtError):
self.check(error(libvirt.VIR_ERR_SYSTEM_ERROR))

View file

@ -110,6 +110,9 @@ class FakePool:
def isActive(self):
return self.active
def XMLDesc(self, flags=0):
return "<pool><target><path>/elsewhere</path></target></pool>"
def refresh(self, flags=0):
self.host.refreshes += 1
self.host.volumes |= self.host.unlisted
@ -291,7 +294,7 @@ class MigrationModeTestCase(unittest.TestCase):
def migrate(self, state, **request):
source = FakeSource(state)
options = {"live": False, "unsafe": False, "offline": False, **request}
mode = destination().moveto(source, "vm", **options)
mode, _copied = destination().moveto(source, "vm", **options)
return mode, source.instance.calls[0][0] if source.instance.calls else None
def test_refused_requests(self):
@ -344,7 +347,14 @@ FILE_DISK = "<disk type='file' device='disk'><source file='/pool/vm.qcow2'/><tar
class DestinationDisksTestCase(unittest.TestCase):
"""Every disk of the VM must be a volume the destination knows; the
VM is refused before anything changes otherwise, naming the disks."""
VM is refused before anything changes otherwise, naming the disks.
(Which disks are copied, and that the others are shared, is
test_disk_copy_migration's.)"""
def setUp(self):
patcher = mock.patch.object(wvmInstances, "disk_copies", return_value=[])
patcher.start()
self.addCleanup(patcher.stop)
def migrate(self, host, xml, state=1, inactive_xml=None):
source = FakeSource(state, xml=xml)
@ -450,7 +460,7 @@ class NvramCopyTestCase(unittest.TestCase):
self.dest_pool = mock.MagicMock()
self.dest._nvram_pool = lambda: contextlib.nullcontext(self.dest_pool)
self.dest.wvm.newStream = mock.MagicMock()
self.shared = mock.patch.object(wvmInstances, "_nvram_shared", return_value=False)
self.shared = mock.patch.object(wvmInstances, "same_directory", return_value=False)
self.shared.start()
self.addCleanup(self.shared.stop)
@ -466,11 +476,19 @@ class NvramCopyTestCase(unittest.TestCase):
def test_a_shared_directory_is_not_copied(self):
self.shared.stop()
with mock.patch.object(wvmInstances, "_nvram_shared", return_value=True):
with mock.patch.object(wvmInstances, "same_directory", return_value=True):
self.move()
self.dest_pool.createXML.assert_not_called()
self.shared.start()
def test_a_directory_whose_sharing_cannot_be_told_stops_the_move(self):
self.shared.stop()
with mock.patch.object(wvmInstances, "same_directory", side_effect=libvirt.libvirtError("no marker")):
with self.assertRaisesRegex(libvirt.libvirtError, "no marker"):
self.move()
self.shared.start()
self.assertEqual(self.host.defined, [])
def test_the_copy_is_deleted_when_the_definition_fails(self):
# the raised error is this one: its message can be another libvirt
# error the thread recorded (a closed connection of an earlier live test)

View file

@ -94,6 +94,12 @@ class RemoveNvramTestCase(TestCase):
self.assertFalse(source.vols["vm_VARS.fd"].delete.called)
source.created[0].delete.assert_called_once_with(0) # the marker is gone
def test_a_directory_whose_sharing_cannot_be_told_is_left_alone(self):
source = pool(NVRAM_DIR, ["vm_VARS.fd"])
destination = connect([pool(NVRAM_DIR, create_error=error(libvirt.VIR_ERR_SYSTEM_ERROR))])
self.assertFalse(connect([source]).remove_nvram(PATH, destination))
self.assertFalse(source.vols["vm_VARS.fd"].delete.called)
def test_both_markers_are_removed(self):
source = pool(NVRAM_DIR, ["vm_VARS.fd"])
other = pool(NVRAM_DIR)