diff --git a/instances/jobs.py b/instances/jobs.py index 464f0177..bf467460 100644 --- a/instances/jobs.py +++ b/instances/jobs.py @@ -923,3 +923,120 @@ class MigrateInstance(Kind): return Outcome(Operation.FAILED, error="The VM stayed on the source host") return Outcome(Operation.UNCERTAIN, error="The VM is on both computes or on neither; " "check them, then resolve this job.") + + +DISK_DEV = re.compile(r"[a-z][a-z0-9]*") + + +def _movable(user, params): + """The disk move's VM, for a superuser, and its plan (disk_move_plan).""" + if user is None or not user.is_active or not user.is_superuser: + raise PermissionDenied + instance = _instance(params["instance"]) + return instance, _proxy(instance).disk_move_plan(params["dev"], params["pool"]) + + +def _used_anywhere(path): + """Whether a VM or volume of any compute uses path (paths_in_use), as + shared storage holds it for every host that mounts it; a compute that + cannot be read might.""" + for c in Compute.objects.all(): + try: + if path in wvmConnect(c.hostname, c.login, c.password, c.type).paths_in_use(): + return True + except libvirtError: + return True + return False + + +@register +class MoveDisk(Kind): + """A disk of a VM to another storage pool of its host (disk_move_plan).""" + + name = "disk.move" + label = gettext_noop("Move disk") + cancellable = True # a block copy is aborted; a shut-off VM's volume copy runs to its end + progress_bytes = True # libvirt block job cur/end + + def normalize(self, raw): + dev = str(raw.get("dev", "")) + if not DISK_DEV.fullmatch(dev): + raise util.OperationError("Choose a disk of the VM") + return {"instance": int(raw["instance"]), "dev": dev, "pool": str(raw.get("pool", ""))} + + def validate(self, user, params): + _movable(user, params) + + def plan(self, user, params): + instance, move = _movable(user, params) + plan = _vm_plan(params) + source_pool = move["volume"].storagePoolLookupByVolume().name() + name = os.path.basename(move["path"]) + for pool in (source_pool, params["pool"]): + plan.claims[claims.volume_key(instance.compute_id, pool, name)] = Claim.EXCLUSIVE + return plan + + def precheck(self, op, ctx): + instance, _move = _movable(op.user, op.params) + if instance.compute_id != op.intent["compute"]: + raise util.OperationError("The VM moved to another compute after the job was queued") + + def run(self, op, ctx): + instance, move = _movable(op.user, op.params) + proxy = _proxy(instance) + ctx.record_intent(path=move["path"], dest=move["dest"]) + ctx.set_phase(gettext_noop("Copying the disk")) + try: + proxy.move_disk(move) + except libvirtError: + op.refresh_from_db(fields=["cancel_requested"]) + # cancelled only when the copy stopped and the VM kept its file + if op.cancel_requested and proxy.disk_named(move["dev"]) == {move["path"]} \ + and not (move["running"] and proxy.instance.blockJobInfo(move["dev"], 0)): + raise Cancelled from None + raise + # the move stands: a source file that cannot go is only left behind + try: + if not _used_anywhere(move["path"]): + move["volume"].delete(0) + return {"left_behind": []} + except libvirtError: + pass + return {"left_behind": [move["path"]]} + + def progress(self, op): + info = _proxy(_instance(op.params["instance"])).instance.blockJobInfo(op.params["dev"], 0) + return (info["cur"], info["end"]) if info else None + + def cancel(self, op): + dom = _proxy(_instance(op.params["instance"])).instance + # the worker sends a cancel once: one before the copy began waits for it + while dom.isActive() and not dom.blockJobInfo(op.params["dev"], 0): + if not Operation.objects.filter(pk=op.pk, state__in=Operation.ACTIVE).exists(): + return + time.sleep(1) + if dom.isActive(): + dom.blockJobAbort(op.params["dev"], 0) + + def reconcile(self, op): + proxy = _recovery_proxy(op) + if proxy is None: + return Outcome(Operation.UNCERTAIN, error=GONE) + dev, path, dest = op.params["dev"], op.intent.get("path"), op.intent.get("dest") + dom = proxy.instance + # a copy no worker waits for mirrors forever: it ends here + if dest and dom.isActive() and dom.blockJobInfo(dev, 0): + mirror = etree.fromstring(dom.XMLDesc(0)).xpath(f"devices/disk[target/@dev='{dev}']/mirror/source/@file") + if mirror != [dest]: + return Outcome(Operation.UNCERTAIN, error=f"Another block job runs on {dev}; check the VM") + dom.blockJobAbort(dev, 0) + named = proxy.disk_named(dev) + if named == {dest}: + return Outcome(Operation.SUCCEEDED, result={"left_behind": [path]}) + if named == {path}: + return Outcome(Operation.FAILED, error=f"The disk stayed in {path}; remove {dest} if it is there") + return Outcome(Operation.UNCERTAIN, error=f"The definitions of {dev} name {', '.join(sorted(map(str, named)))}; " + "check the VM") + + def log_entry(self, op, state): + return _log(op, state, f"move disk {op.params['dev']} to pool {op.params['pool']}") diff --git a/instances/templates/instances/move_disk.html b/instances/templates/instances/move_disk.html new file mode 100644 index 00000000..8bed5de9 --- /dev/null +++ b/instances/templates/instances/move_disk.html @@ -0,0 +1,30 @@ +{% load i18n %} +{% load bootstrap_icons %} +{% load operations %} +{# the Move button of a disk and its dialog: another storage pool of this host #} + + diff --git a/instances/templates/instances/settings_tab.html b/instances/templates/instances/settings_tab.html index 4d14e30f..118e5012 100644 --- a/instances/templates/instances/settings_tab.html +++ b/instances/templates/instances/settings_tab.html @@ -312,6 +312,7 @@ {% include 'instances/edit_instance_volume.html' with id=forloop.counter0 %} + {% if request.user.is_superuser %}{% include 'instances/move_disk.html' with id=forloop.counter0 %}{% endif %}
{% csrf_token %} diff --git a/instances/tests/test_disk_move_job.py b/instances/tests/test_disk_move_job.py new file mode 100644 index 00000000..354798ef --- /dev/null +++ b/instances/tests/test_disk_move_job.py @@ -0,0 +1,130 @@ +"""Moving a disk to another pool of its host runs as a job: the page queues +it, the worker moves it, recovery ends a copy no worker waits for and reads +the outcome from the VM's definitions.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TransactionTestCase +from django.urls import reverse +from libvirt import libvirtError + +from appsettings.settings import get_settings +from computes.models import Compute +from instances.jobs import MoveDisk +from instances.models import Instance +from operations.jobs import Cancelled, Context, get_kind +from operations.models import Claim, Operation, acquire + +UUID = "41414141-4242-5353-6464-757575757575" +OLD, NEW = "/var/lib/libvirt/images/vm.qcow2", "/srv/fast/vm.qcow2" + + +class MoveDiskJobTestCase(TransactionTestCase): + def setUp(self): + get_settings() + self.compute = Compute.objects.create(name="mv-host", hostname="192.0.2.60", login="root", password="", type=1) + self.vm = Instance.objects.create(compute=self.compute, name="mv-vm", uuid=UUID) + self.admin = get_user_model().objects.create_superuser("mv_admin", "a@example.com", "pw") + self.client.force_login(self.admin) + patch.object(Compute, "status", True).start() + self.proxy = patch("instances.jobs.wvmInstance").start().return_value + self.proxy.disk_move_plan.return_value = { + "dev": "vda", "path": OLD, "dest": NEW, "running": True, + "volume": MagicMock(**{"storagePoolLookupByVolume.return_value.name.return_value": "default"})} + patch("instances.views.get_instance", return_value=self.vm).start() + self.addCleanup(patch.stopall) + + def queue(self, **form): + self.client.post(reverse("instances:move_disk", args=[self.vm.pk]), + {"dev": "vda", "pool": "fast", "submission_key": "key-move0001", **form}) + return Operation.objects.filter(kind="disk.move").first() + + def start(self, op): + acquire("wvc-test:1:a", "wvc-test") + Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING, worker="wvc-test:1:a") + op.refresh_from_db() + return Context(op, SimpleNamespace(identity="wvc-test:1:a")) + + def test_the_page_queues_it_and_the_worker_moves_it(self): + op = self.queue() + self.assertTrue({(f"instance:{UUID}", "exclusive"), (f"volume:{self.compute.pk}:default/vm.qcow2", "exclusive"), + (f"volume:{self.compute.pk}:fast/vm.qcow2", "exclusive")} + <= set(Claim.objects.values_list("resource", "mode"))) + self.proxy.move_disk.assert_not_called() # nothing ran in the request + ctx = self.start(op) + get_kind(op.kind).precheck(op, ctx) + with patch("instances.jobs.wvmConnect") as hosts: + hosts.return_value.paths_in_use.return_value = set() + result = get_kind(op.kind).run(op, ctx) + self.proxy.move_disk.assert_called_once() + self.proxy.disk_move_plan.return_value["volume"].delete.assert_called_once_with(0) # the source went + op.refresh_from_db() + self.assertEqual((op.intent["path"], op.intent["dest"], result), (OLD, NEW, {"left_behind": []})) + + def test_a_source_file_any_host_may_use_is_left_behind(self): + """Shared storage: a VM or volume of another host may use it.""" + Compute.objects.create(name="mv-other", hostname="192.0.2.61", login="root", password="", type=1) + cases = ((set(), {OLD}, None), (set(), set(), libvirtError("unreachable")), + (set(), set(), "delete fails")) + for n, (here, other, problem) in enumerate(cases): + with self.subTest(case=n): + Operation.objects.all().delete() + op = self.queue(submission_key=f"key-left{n:04d}") + ctx = self.start(op) + volume = self.proxy.disk_move_plan.return_value["volume"] + volume.delete.reset_mock() + volume.delete.side_effect = libvirtError("busy") if problem == "delete fails" else None + with patch("instances.jobs.wvmConnect") as hosts: + reads = [here, other] if not isinstance(problem, libvirtError) else [here, problem] + hosts.return_value.paths_in_use.side_effect = reads + result = get_kind(op.kind).run(op, ctx) + self.assertEqual(result, {"left_behind": [OLD]}) + self.assertEqual(volume.delete.called, problem == "delete fails") + + def test_only_a_superuser_with_a_disk_name_queues_it(self): + self.assertIsNone(self.queue(dev="vda']")) + self.client.force_login(get_user_model().objects.create_user("mv_user", password="pw")) + self.assertIsNone(self.queue()) + + def test_a_cancel_ends_cancelled_only_when_the_vm_kept_its_file(self): + op = self.queue() + ctx = self.start(op) + self.proxy.move_disk.side_effect = libvirtError("The copy of vda ended before it was complete") + self.proxy.instance.blockJobInfo.return_value = {} + self.proxy.disk_named.return_value = {OLD} + Operation.objects.filter(pk=op.pk).update(cancel_requested=True) + with self.assertRaises(Cancelled): + get_kind(op.kind).run(op, ctx) + # the pivot may have happened: the error stands, not a cancel + self.proxy.disk_named.return_value = {NEW} + with self.assertRaisesRegex(libvirtError, "ended before it was complete"): + get_kind(op.kind).run(op, ctx) + + def reconcile(self, named, job=None, mirror=NEW): + op = Operation(params={"instance": self.vm.pk, "dev": "vda", "pool": "fast"}, + intent={"compute": self.compute.pk, "uuid": UUID, "path": OLD, "dest": NEW}) + dom = self.proxy.instance + dom.isActive.return_value = True + dom.blockJobInfo.return_value = job or {} + dom.XMLDesc.return_value = (f"" + "") + self.proxy.disk_named.return_value = named + return MoveDisk().reconcile(op) + + def test_recovery_reads_the_definitions(self): + self.assertEqual(self.reconcile({NEW}).state, Operation.SUCCEEDED) + failed = self.reconcile({OLD}) + self.assertEqual((failed.state, failed.error), (Operation.FAILED, f"The disk stayed in {OLD}; remove {NEW} " + "if it is there")) + self.assertEqual(self.reconcile({OLD, NEW}).state, Operation.UNCERTAIN) + self.proxy.instance.blockJobAbort.assert_not_called() + + def test_recovery_ends_the_copy_no_worker_waits_for(self): + self.assertEqual(self.reconcile({OLD}, job={"cur": 2, "end": 2}).state, Operation.FAILED) + self.proxy.instance.blockJobAbort.assert_called_once_with("vda", 0) + # a block job of something else is not ended + self.proxy.instance.blockJobAbort.reset_mock() + self.assertEqual(self.reconcile({OLD}, job={"cur": 1, "end": 2}, mirror="/other").state, Operation.UNCERTAIN) + self.proxy.instance.blockJobAbort.assert_not_called() diff --git a/instances/tests/test_live_jobs.py b/instances/tests/test_live_jobs.py index aee96c39..dc4016d8 100644 --- a/instances/tests/test_live_jobs.py +++ b/instances/tests/test_live_jobs.py @@ -197,6 +197,39 @@ class LiveJobsTestCase(TransactionTestCase): self.assertEqual((livetest.volume_exists(self.conn, path), livetest.volume_exists(dest_conn, path)), (True, True)) + def test_a_disk_moves_to_another_pool_and_back(self): + """There while the VM runs (block copy and pivot), back shut off + (volume copy); each time the source file goes.""" + other = self.conn.storagePoolDefineXML( + f"{P}move{livetest.POOL_PATH}-move") + other.create(libvirt.VIR_STORAGE_POOL_CREATE_WITH_BUILD) + + def remove_other(): + other.refresh(0) + for vol in other.listAllVolumes(): + vol.delete(0) + other.destroy() + other.delete(0) + other.undefine() + self.addCleanup(remove_other) + path = livetest.create_volume(self.conn, P + "move") + dom = livetest.define_vm(self.conn, P + "move", [path]) + dom.create() + instance = Instance.objects.create(compute=self.compute, name=dom.name(), uuid=dom.UUIDString()) + moved = f"{livetest.POOL_PATH}-move/{P}move.qcow2" + + def source(flags): + return dom.XMLDesc(flags).split("/delete_network/", views.delete_network, name="delete_network"), path("/host_devices/", views.host_devices, name="host_devices"), path("/migrate_plan/", views.migrate_plan, name="migrate_plan"), + path("/move_disk/", views.move_disk, name="move_disk"), path("/add_usb_hostdev/", views.add_usb_hostdev, name="add_usb_hostdev"), path("/delete_usb_hostdev/", views.delete_usb_hostdev, name="delete_usb_hostdev"), path("/add_pci_hostdev/", views.add_pci_hostdev, name="add_pci_hostdev"), diff --git a/instances/views.py b/instances/views.py index aa771601..bf913dc3 100755 --- a/instances/views.py +++ b/instances/views.py @@ -866,6 +866,22 @@ def migrate(request, pk): return get_safe_redirect(request, default=reverse("instances:instance", args=[pk])) +@require_POST +@superuser_only +def move_disk(request, pk): + get_instance(request.user, pk) + raw = {"instance": pk, "dev": request.POST.get("dev", ""), "pool": request.POST.get("pool", "")} + try: + op = jobs.submit("disk.move", request.user, raw, request.POST.get("submission_key", "")) + except claims.Busy as busy: + return _busy(request, pk, busy) + except (jobs.SubmissionConflict, libvirtError) as err: + messages.error(request, error_text(request, err)) + else: + jobs.remember_job(request, op) + return _back(request, pk, "disks") + + @superuser_only def migrate_plan(request, pk): """The disks of the Migrate tab for the chosen destination, loaded when diff --git a/static/icon_cache/arrow-left-right_None_None_None.svg b/static/icon_cache/arrow-left-right_None_None_None.svg new file mode 100644 index 00000000..b7737295 --- /dev/null +++ b/static/icon_cache/arrow-left-right_None_None_None.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/vrtManager/connection.py b/vrtManager/connection.py index 0c206e84..42de52ad 100644 --- a/vrtManager/connection.py +++ b/vrtManager/connection.py @@ -921,6 +921,16 @@ class wvmConnect(object): return pool 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(libvirt.VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE): + tree = etree.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 + @contextlib.contextmanager def _nvram_pool(self): """An active, refreshed pool on NVRAM_DIR: libvirt reads and deletes diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 195a002b..0c970165 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -15,6 +15,7 @@ try: VIR_DOMAIN_AFFECT_CONFIG, VIR_DOMAIN_AFFECT_LIVE, VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_ARP, + VIR_DOMAIN_PAUSED, VIR_DOMAIN_RUNNING, VIR_DOMAIN_SHUTOFF, VIR_DOMAIN_START_PAUSED, @@ -26,6 +27,8 @@ try: VIR_ERR_NO_STORAGE_POOL, VIR_ERR_NO_STORAGE_VOL, VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, + VIR_DOMAIN_BLOCK_COPY_TRANSIENT_JOB, + VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT, VIR_DOMAIN_BLOCK_RESIZE_BYTES, VIR_STORAGE_VOL_FILE, VIR_MIGRATE_AUTO_CONVERGE, @@ -288,16 +291,6 @@ class wvmInstances(wvmConnect): 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 @@ -2107,6 +2100,125 @@ class wvmInstance(wvmConnect): jobs.append({"dev": dev, "cur": info["cur"], "end": info["end"]}) return jobs + def disk_move_plan(self, dev, pool_name): + """Where the disk dev of the VM goes in the pool pool_name of this + host: {dev, path, dest, format, pool, volume, running}. Refused with + the reason unless the VM is persistent, running, paused or shut off, + without snapshots, a saved state or a block job; the disk a raw or + qcow2 file of a storage pool, without a backing file, not read-only, + shareable or encrypted, the same file in both definitions; the pool + one of disk files, another directory, without a file of that name.""" + dom = self.instance + state = dom.info()[0] + if not dom.isPersistent() or state not in (VIR_DOMAIN_RUNNING, VIR_DOMAIN_PAUSED, VIR_DOMAIN_SHUTOFF): + raise util.OperationError("A disk moves when its VM is defined and running, paused or shut off") + if dom.snapshotNum(0) or dom.hasManagedSaveImage(0) or self.get_snapshot_jobs(): + raise util.OperationError("A disk of a VM with snapshots, a saved state or a running block job does not move") + # the running definition (the persistent one of a shut-off VM) first + disks = [ElementTree.fromstring(xml).find(f"devices/disk/target[@dev='{dev}']/..") + for xml in (self._XMLDesc(0), self._XMLDesc(PERSISTENT_XML))] + sources = [disk.find("source") if disk is not None else None for disk in disks] + disk, source = disks[0], sources[0] + if disk is None or disk.get("type") != "file" or disk.get("device") != "disk" or None in sources \ + or len({s.get("file") for s in sources}) != 1: + raise util.OperationError(f"{dev} is not a disk file of the VM, the same in its running and next start's " + "definition") + driver = disk.find("driver") + fmt = driver.get("type") if driver is not None else None + if fmt not in ("raw", "qcow2") or disk.find("readonly") is not None or disk.find("shareable") is not None \ + or disk.find("encryption") is not None or source.find("encryption") is not None: + raise util.OperationError(f"{dev} does not move: only writable raw and qcow2 disks do") + path = source.get("file") + try: + volume = self.wvm.storageVolLookupByPath(path) + except libvirtError as err: + if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL: + raise + raise util.OperationError(f"{dev} does not move: no storage pool has {path}") from err + if ElementTree.fromstring(volume.XMLDesc(0)).findtext("backingStore/path"): + raise util.OperationError(f"{dev} does not move: it has a backing file") + pools = self.file_pools() + if pool_name not in pools: + raise util.OperationError(f"{pool_name} is not an active pool of disk files of this host") + pool, directory = pools[pool_name] + if directory == os.path.dirname(path): + raise util.OperationError(f"{dev} is in pool {pool_name} already") + pool.refresh(0) + name = os.path.basename(path) + if self._find_volume(pool, name) is not None: + raise util.OperationError(f"Pool {pool_name} already has a file {name}") + return {"dev": dev, "path": path, "dest": f"{directory}/{name}", "format": fmt, "pool": pool, + "volume": volume, "running": state != VIR_DOMAIN_SHUTOFF} + + def move_disk(self, move): + """Moves the disk as disk_move_plan planned it. A running or paused + VM's is copied by a block job (TRANSIENT_JOB: the VM is persistent) + into a file libvirt creates; at the pivot libvirt names the copy in + both definitions. A shut-off VM's volume is copied into the pool and + the copy named in the definition. The source file stays (see + MoveDisk). A failed move deletes the copy it made, unless a + definition names it; a file there before the copy began is never + ours.""" + dom, dev, dest = self.instance, move["dev"], move["dest"] + made = False + try: + if move["running"]: + xml = (f"" + f"") + dom.blockCopy(dev, xml, None, VIR_DOMAIN_BLOCK_COPY_TRANSIENT_JOB) + made = True + while True: # mirrored once the copy caught up: cur == end + info = dom.blockJobInfo(dev, 0) + if not info: + raise util.OperationError(f"The copy of {dev} ended before it was complete") + if info["end"] and info["cur"] == info["end"]: + break + time.sleep(1) + dom.blockJobAbort(dev, VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT) + move["pool"].refresh(0) # libvirt made the file past the pool: it lists it now + else: + move["pool"].createXMLFrom( + f"{util.xml_escape(os.path.basename(dest))}" + f"{move['volume'].info()[1]}" + f"", move["volume"], 0) + made = True + tree = ElementTree.fromstring(self._XMLDesc(PERSISTENT_XML)) + tree.find(f"devices/disk/target[@dev='{dev}']/../source").set("file", dest) + self._defineXML(ElementTree.tostring(tree, encoding="unicode")) + except libvirtError as error: + if made: + self.remove_disk_copy(move, error) + raise + + def disk_named(self, dev): + """The files the running (if active) and next start's definitions name for dev.""" + flags = [0, PERSISTENT_XML] if self.instance.isActive() else [PERSISTENT_XML] + files = set() + for flag in flags: + disk = ElementTree.fromstring(self.instance.XMLDesc(flag)).find(f"devices/disk/target[@dev='{dev}']/..") + source = disk.find("source") if disk is not None else None + files.add(source.get("file") if source is not None else None) + return files + + def remove_disk_copy(self, move, error): + """Deletes the copy of a failed move once nothing uses it: no block + job runs on the disk and no definition names it. Otherwise it stays, + and the error says so.""" + dev, dest = move["dev"], move["dest"] + try: + if move["running"] and self.instance.blockJobInfo(dev, 0): + self.instance.blockJobAbort(dev, 0) + named = dest in self.disk_named(dev) + if not named: + move["pool"].refresh(0) + copy = self._find_volume(move["pool"], os.path.basename(dest)) + if copy is not None: + copy.delete(0) + except libvirtError as cleanup_error: + raise util.OperationError(f"{error}; remove {dest} if it is there: {cleanup_error}") from error + if named: + raise util.OperationError(f"{error}; the VM may use {dest} now: check its disk {dev}") from error + def _snapshot(self, name, external): """The named snapshot; a snapshot of the other kind is refused.""" flag = VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL if external else VIR_DOMAIN_SNAPSHOT_LIST_INTERNAL diff --git a/vrtManager/tests/test_disk_move.py b/vrtManager/tests/test_disk_move.py new file mode 100644 index 00000000..b88e6a50 --- /dev/null +++ b/vrtManager/tests/test_disk_move.py @@ -0,0 +1,156 @@ +"""A disk moves to another storage pool of its host: a running or paused +VM's by a block copy and a pivot, a shut-off VM's by a volume copy and a +redefinition; the source file stays. Disks that cannot move are refused +before anything changes, and a failed move deletes the copy unless a +definition names it.""" + +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.instance import wvmInstance + +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 domain(path=f"{IMAGES}/vm.qcow2", fmt="qcow2", extra=""): + return (f"" + f"{extra}") + + +class Pool: + def __init__(self, files=()): + self.files = set(files) + self.deleted = [] + + refreshes = 0 + + def refresh(self, flags=0): + self.refreshes += 1 + + 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 + + +def vm(state=1, xml=None, config=None, backing=None, fast=None, snapshots=0): + """A wvmInstance of a VM in state whose vda is in IMAGES; pool fast is on FAST.""" + proxy = wvmInstance.__new__(wvmInstance) + proxy.instance = dom = mock.Mock() + dom.info.return_value = [state, 0, 0, 1, 0] + dom.isPersistent.return_value = True + dom.isActive.return_value = state != 5 + dom.snapshotNum.return_value = snapshots + dom.hasManagedSaveImage.return_value = 0 + dom.blockJobInfo.return_value = {} + xml = xml or domain() + dom.XMLDesc.side_effect = lambda flags: (config or xml) if flags & libvirt.VIR_DOMAIN_XML_INACTIVE else xml + proxy.wvm = mock.Mock() + volume = proxy.wvm.storageVolLookupByPath.return_value + volume.info.return_value = [0, 1 << 30, 1 << 20] + volume.XMLDesc.return_value = f"{backing}" \ + if backing else "" + proxy.fast = fast or Pool() + proxy.file_pools = lambda: {"fast": (proxy.fast, FAST), "default": (Pool(), IMAGES)} + proxy.get_snapshot_jobs = lambda: [] + return proxy + + +class DiskMovePlanTestCase(unittest.TestCase): + def test_a_disk_file_moves_into_another_pool(self): + move = vm().disk_move_plan("vda", "fast") + self.assertEqual((move["path"], move["dest"], move["format"], move["running"]), + (f"{IMAGES}/vm.qcow2", f"{FAST}/vm.qcow2", "qcow2", True)) + + def test_refusals(self): + cases = ( + (vm(state=4), "fast", "defined and running, paused or shut off"), + (vm(snapshots=1), "fast", "snapshots"), + (vm(xml=domain(extra="")), "fast", "only writable raw and qcow2"), + (vm(xml=domain(fmt="vmdk")), "fast", "only writable raw and qcow2"), + (vm(config=domain(path=f"{IMAGES}/next.qcow2")), "fast", "the same in its running and next start"), + (vm(backing=f"{IMAGES}/base.qcow2"), "fast", "backing file"), + (vm(), "rbd", "not an active pool of disk files"), + (vm(), "default", "in pool default already"), + (vm(fast=Pool(files={"vm.qcow2"})), "fast", "Pool fast already has a file vm.qcow2"), + ) + for proxy, pool, reason in cases: + with self.subTest(reason=reason): + with self.assertRaisesRegex(util.OperationError, reason): + proxy.disk_move_plan("vda", pool) + + +class MoveDiskTestCase(unittest.TestCase): + def test_a_running_vm_is_copied_and_pivoted(self): + proxy = vm() + proxy.instance.blockJobInfo.side_effect = [{"cur": 1, "end": 2}, {"cur": 2, "end": 2}] + with mock.patch("vrtManager.instance.time.sleep"): + proxy.move_disk(proxy.disk_move_plan("vda", "fast")) + dev, xml, params, flags = proxy.instance.blockCopy.call_args.args + self.assertEqual((dev, flags), ("vda", libvirt.VIR_DOMAIN_BLOCK_COPY_TRANSIENT_JOB)) + self.assertIn(f"", xml) + proxy.instance.blockJobAbort.assert_called_once_with("vda", libvirt.VIR_DOMAIN_BLOCK_JOB_ABORT_PIVOT) + self.assertEqual(proxy.fast.refreshes, 2) # the plan's, and one so that the pool lists the copy + + def test_a_shut_off_vm_gets_a_copy_and_a_new_definition(self): + proxy = vm(state=5) + proxy.fast.createXMLFrom = mock.Mock() + proxy.move_disk(proxy.disk_move_plan("vda", "fast")) + volume_xml, source, flags = proxy.fast.createXMLFrom.call_args.args + self.assertIn("vm.qcow21073741824", volume_xml) + self.assertIn(f'', proxy.wvm.defineXML.call_args.args[0]) + proxy.instance.blockCopy.assert_not_called() + + def test_a_copy_that_ends_early_is_deleted(self): + proxy = vm() + proxy.instance.blockJobInfo.return_value = {} # aborted by a cancel + move = proxy.disk_move_plan("vda", "fast") + proxy.fast.files.add("vm.qcow2") # libvirt made it + with self.assertRaisesRegex(util.OperationError, "ended before it was complete"): + proxy.move_disk(move) + self.assertEqual(proxy.fast.deleted, ["vm.qcow2"]) + + def test_a_copy_a_definition_names_is_kept(self): + """The pivot may have happened before its reply was lost.""" + proxy = vm() + proxy.instance.blockJobInfo.side_effect = [{"cur": 2, "end": 2}, {}] + proxy.instance.blockJobAbort.side_effect = libvirt.libvirtError("Cannot recv data") + move = proxy.disk_move_plan("vda", "fast") + proxy.instance.XMLDesc.side_effect = lambda flags: domain(path=f"{FAST}/vm.qcow2") + proxy.fast.files.add("vm.qcow2") + with self.assertRaisesRegex(util.OperationError, f"the VM may use {FAST}/vm.qcow2 now"): + proxy.move_disk(move) + self.assertEqual(proxy.fast.deleted, []) + + def test_a_file_made_by_someone_else_is_never_deleted(self): + """It appeared after the plan, and the copy failed on it.""" + proxy = vm(state=5) + move = proxy.disk_move_plan("vda", "fast") + proxy.fast.files.add("vm.qcow2") + proxy.fast.createXMLFrom = mock.Mock(side_effect=error(libvirt.VIR_ERR_STORAGE_VOL_EXIST)) + with self.assertRaises(libvirt.libvirtError): + proxy.move_disk(move) + self.assertEqual(proxy.fast.deleted, []) + running = vm() + move = running.disk_move_plan("vda", "fast") + running.fast.files.add("vm.qcow2") + running.instance.blockCopy.side_effect = error(libvirt.VIR_ERR_OPERATION_FAILED) + with self.assertRaises(libvirt.libvirtError): + running.move_disk(move) + self.assertEqual(running.fast.deleted, [])