1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 20:26:48 +00:00
webvirtcloud/storages/tests/test_volume_clone_job.py
catborise 018ca69a3b fix(storages): a volume a running VM uses is not cloned
The copy was read while the guest wrote: a raw disk was copied with
data of different instants, and a qcow2 one failed on qemu-img's image
lock with a message that did not say why. Such a clone is refused, with
the running VMs named, when it is queued and again before it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kk4h2TcUiaWmU5MUszYSea
2026-11-07 23:56:04 +03:00

96 lines
5.4 KiB
Python

"""Volume clone runs as a job: the page queues it, the worker copies."""
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.test import TransactionTestCase
from django.urls import reverse
from computes.models import Compute
from operations.jobs import Context
from operations.models import Claim, Operation, acquire
from libvirt import libvirtError
from storages.jobs import CloneVolume
from vrtManager import util
class VolumeCloneJobTestCase(TransactionTestCase):
def setUp(self):
self.compute = Compute.objects.create(name="vol-c", hostname="192.0.2.20", login="root", password="", type=1)
admin = get_user_model().objects.create_superuser("vol_admin", "a@example.com", "pw")
self.client.force_login(admin)
patcher = patch("storages.jobs.wvmStorage")
self.stg = patcher.start().return_value
self.addCleanup(patcher.stop)
self.stg.get_volumes.return_value = ["base.qcow2"]
self.stg.get_volume_format_type.return_value = "qcow2"
self.stg.clone_name.return_value = "copy.qcow2"
self.stg.clone_volume.return_value = "copy.qcow2"
@patch("storages.views.wvmStorage")
def test_the_page_queues_the_copy_and_the_worker_makes_it(self, page):
page.return_value.is_active.return_value = False
page.return_value.get_size.return_value = (1, 1)
url = reverse("storage", args=[self.compute.pk, "pool-a"])
response = self.client.post(url, {"cln_volume": "1", "image": "base.qcow2", "name": "copy",
"submission_key": "key-vol00001"})
self.assertRedirects(response, url, fetch_redirect_response=False)
op = Operation.objects.get()
self.assertEqual((op.kind, op.intent["target"]), ("volume.clone", "copy.qcow2"))
self.assertIn((f"volume:{self.compute.pk}:pool-a/copy.qcow2", "exclusive"),
set(Claim.objects.values_list("resource", "mode")))
self.stg.clone_volume.assert_not_called()
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()
CloneVolume().run(op, Context(op, SimpleNamespace(identity="wvc-test:1:a")))
op.refresh_from_db()
self.stg.clone_volume.assert_called_once_with("base.qcow2", "copy", None, False)
self.assertEqual(op.intent["created"], "copy.qcow2")
self.assertIn(f"key=pool%3A{self.compute.pk}%3Apool-a", self.client.get(url).content.decode())
@patch("storages.views.wvmStorage")
def test_a_refused_copy_is_a_message(self, page):
# a name in use, an ISO image: the job's plan refuses, the page says why
page.return_value.is_active.return_value = False
page.return_value.get_size.return_value = (1, 1)
url = reverse("storage", args=[self.compute.pk, "pool-a"])
self.stg.get_volumes.return_value = ["base.qcow2", "copy.qcow2", "install.ISO"]
for key, image, why in (("key-vol00002", "base.qcow2", "Name of volume already in use"),
("key-vol00003", "install.ISO", "An ISO image is not cloned")):
with self.subTest(image=image):
response = self.client.post(url, {"cln_volume": "1", "image": image, "name": "copy",
"submission_key": key}, follow=True)
self.assertContains(response, why)
# a running guest's disk would be copied while it writes
self.stg.get_volumes.return_value = ["base.qcow2"]
self.stg.refuse_running_copy.side_effect = util.OperationError("The running VMs vm use volume base.qcow2")
response = self.client.post(url, {"cln_volume": "1", "image": "base.qcow2", "name": "copy",
"submission_key": "key-vol00004"}, follow=True)
self.assertContains(response, "The running VMs vm use volume base.qcow2")
self.assertFalse(Operation.objects.exists())
def test_recovery(self):
self.stg.get_volumes.return_value = ["base.qcow2", "copy.qcow2"]
op = Operation(params={"compute": self.compute.pk, "pool": "pool-a"}, intent={"target": "copy.qcow2"})
self.assertEqual(CloneVolume().reconcile(op).state, Operation.UNCERTAIN) # there, not proven complete
self.stg.get_volumes.return_value = []
self.assertEqual(CloneVolume().reconcile(op).state, Operation.FAILED)
op.intent["created"] = "copy.qcow2"
self.assertEqual(CloneVolume().reconcile(op).state, Operation.SUCCEEDED)
def test_the_worker_checks_the_user_and_the_name_again(self):
user = get_user_model().objects.get(username="vol_admin")
op = Operation(kind="volume.clone", user=user, intent={"target": "copy.qcow2"},
params={"compute": self.compute.pk, "pool": "pool-a", "image": "base.qcow2", "name": "copy",
"format": "", "prealloc": False})
CloneVolume().precheck(op, None)
self.stg.get_volumes.return_value = ["base.qcow2", "copy.qcow2"] # made outside the panel meanwhile
with self.assertRaisesMessage(libvirtError, "Name of volume already in use"):
CloneVolume().precheck(op, None)
user.is_superuser = False
with self.assertRaises(PermissionDenied):
CloneVolume().precheck(op, None)