1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00

feat(instances): VM clone runs as a job

This commit is contained in:
catborise 2026-10-14 23:42:54 +03:00
parent 49232c6e1d
commit fbf82da5a9
12 changed files with 346 additions and 270 deletions

View file

@ -11,6 +11,7 @@ from accounts.models import UserInstance
from appsettings.models import AppSettings
from computes.models import Compute
from instances.models import Instance
from operations.models import Operation
LIVE = [{"dev": "vda", "path": "/pool/live.qcow2", "storage": "pool", "image": "live.qcow2", "size": 1 << 30}]
SAVED = [{"dev": "vdb", "path": "/pool/saved.qcow2", "storage": "pool", "image": "saved.qcow2", "size": 20 << 30}]
@ -30,16 +31,16 @@ class CloneDisksTestCase(TestCase):
wvm.return_value.get_vcpu.return_value = 1
wvm.return_value.get_memory.return_value = 512
wvm.return_value.get_net_devices.return_value = []
wvm.return_value.clone_instance.return_value = "abcdefab-0000-1111-2222-333333333333"
response = self.client.post(reverse("instances:clone", args=[self.vm.id]), post)
wvm.return_value.get_status.return_value = 5
response = self.client.post(reverse("instances:clone", args=[self.vm.id]), {"submission_key": "key-clone-disk1", **post})
self.messages = [str(m) for m in response.wsgi_request._messages]
return quota, wvm.return_value.clone_instance
return quota
def test_the_quota_counts_the_disks_the_clone_copies(self):
owner = get_user_model().objects.create_user("cd_owner", password="x")
owner.user_permissions.add(Permission.objects.get(codename="clone_instances"))
UserInstance.objects.create(instance=self.vm, user=owner, is_change=True)
quota, _ = self.clone(owner, name="cd-clone")
quota = self.clone(owner, name="cd-clone")
self.assertEqual(quota.call_args.args[4], 20)
def test_automatic_disk_names_are_for_the_disks_the_clone_copies(self):
@ -48,7 +49,7 @@ class CloneDisksTestCase(TestCase):
with patch("instances.views.utils.get_clone_free_names", return_value=["auto-1"]), patch(
"instances.views.utils.get_dhcp_mac_address", return_value="52:54:00:00:00:01"
):
_, clone_instance = self.clone(admin)
data = clone_instance.call_args.args[0]
self.clone(admin)
data = Operation.objects.get().intent["clone"]
self.assertIn("disk-vdb", data)
self.assertNotIn("disk-vda", data)

View file

@ -0,0 +1,112 @@
"""VM clone runs as a job: the page queues it, the worker clones and records it."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import libvirt
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 accounts.models import UserInstance
from appsettings.settings import get_settings
from computes.models import Compute
from instances.jobs import CloneInstance
from instances.models import Instance
from operations.jobs import Context
from vrtManager.util import OperationError
from operations.models import Claim, Operation, acquire
def proxy():
p = MagicMock()
p.get_status.return_value = 5
p.get_disk_devices.return_value = [{"dev": "vda", "image": "src.qcow2", "size": 1 << 30}]
p.get_net_devices.return_value = [{"mac": "52:54:00:aa:bb:cc"}]
p.get_vcpu.return_value = 1
p.get_memory.return_value = 512
return p
def pool(volumes):
p = MagicMock()
p.listVolumes.return_value = volumes
return p
class CloneJobTestCase(TransactionTestCase):
def setUp(self):
get_settings()
self.admin = get_user_model().objects.create_superuser("clone_admin", "a@example.com", "pw")
self.client.force_login(self.admin)
self.compute = Compute.objects.create(name="clone-c", hostname="192.0.2.30", login="root", password="", type=1)
self.source = Instance.objects.create(compute=self.compute, name="src", uuid="12121212-3434-5656-7878-909090909090")
patcher = patch("instances.models.wvmInstance", return_value=proxy())
self.proxy = patcher.start().return_value
self.addCleanup(patcher.stop)
def test_the_page_queues_the_clone_and_the_worker_makes_and_records_it(self):
response = self.client.post(reverse("instances:clone", args=[self.source.pk]),
{"name": "copy", "disk-vda": "copy.qcow2", "clone-net-mac-0": "52:54:00:00:00:01",
"submission_key": "key-clone001"})
self.assertRedirects(response, reverse("instances:instance", args=[self.source.pk]),
fetch_redirect_response=False)
op = Operation.objects.get()
c = self.compute.pk
self.assertEqual(set(Claim.objects.values_list("resource", "mode")), {
(f"compute:{c}", "shared"), (f"reconcile:{c}", "exclusive"),
(f"instance:{self.source.uuid}", "shared"), (f"name:{c}:copy", "exclusive")})
self.proxy.clone_instance.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()
CloneInstance().run(op, Context(op, SimpleNamespace(identity="wvc-test:1:a")))
self.proxy.clone_instance.assert_called_once_with(op.intent["clone"], op.intent["uuid"])
clone = Instance.objects.get(uuid=op.intent["uuid"])
self.assertTrue(UserInstance.objects.filter(instance=clone, user=self.admin).exists())
def queued(self):
self.client.post(reverse("instances:clone", args=[self.source.pk]),
{"name": "copy", "disk-vda": "copy.qcow2", "clone-net-mac-0": "52:54:00:00:00:01",
"submission_key": "key-clone002"})
return Operation.objects.get()
def test_the_worker_refuses_a_clone_whose_access_was_revoked(self):
op = self.queued()
self.admin.is_superuser = False
self.admin.save()
with self.assertRaises(PermissionDenied):
CloneInstance().precheck(op, None)
def test_the_worker_refuses_a_clone_whose_source_moved(self):
op = self.queued()
other = Compute.objects.create(name="clone-d", hostname="192.0.2.31", login="root", password="", type=1)
Instance.objects.filter(pk=self.source.pk).update(compute=other)
with self.assertRaises(OperationError):
CloneInstance().precheck(op, None)
@patch("instances.jobs.wvmConnect")
def test_recovery(self, conn):
op = Operation(user=self.admin, intent={"compute": self.compute.pk, "uuid": "abababab-0000-1111-2222-333333333333",
"name": "copy", "clone": {"name": "copy", "disk-vda": "copy.qcow2"}})
self.assertEqual(CloneInstance().reconcile(op).state, Operation.SUCCEEDED) # the VM is defined
self.assertTrue(Instance.objects.filter(uuid=op.intent["uuid"]).exists())
missing = libvirt.libvirtError("no domain")
missing.err = (libvirt.VIR_ERR_NO_DOMAIN, libvirt.VIR_FROM_QEMU, "no domain")
conn.return_value.wvm.lookupByUUIDString.side_effect = missing
conn.return_value.wvm.listAllStoragePools.return_value = [pool(["copy.qcow2", "other.qcow2"])]
outcome = CloneInstance().reconcile(op)
self.assertEqual(outcome.state, Operation.UNCERTAIN)
self.assertIn("copy.qcow2", outcome.error)
@patch("instances.jobs.wvmConnect")
def test_a_clone_that_left_nothing_failed(self, conn):
op = Operation(user=self.admin, intent={"compute": self.compute.pk, "uuid": "abababab-0000-1111-2222-333333333333",
"name": "copy", "clone": {"name": "copy", "disk-vda": "copy.qcow2"}})
missing = libvirt.libvirtError("no domain")
missing.err = (libvirt.VIR_ERR_NO_DOMAIN, libvirt.VIR_FROM_QEMU, "no domain")
conn.return_value.wvm.lookupByUUIDString.side_effect = missing
conn.return_value.wvm.listAllStoragePools.return_value = [pool(["other.qcow2"])]
self.assertEqual(CloneInstance().reconcile(op).state, Operation.FAILED)

View file

@ -17,6 +17,7 @@ from appsettings.settings import app_settings
from computes.models import Compute
from instances.models import Instance
from instances.utils import get_dhcp_mac_address
from operations.models import Operation
SRC_DISK = {
"dev": "vda",
@ -71,9 +72,9 @@ class ClonePermissionTestCase(TestCase):
proxy = mock_wvm.return_value
proxy.get_disk_devices.return_value = [SRC_DISK]
proxy.get_net_devices.return_value = [{"mac": "52:54:00:aa:bb:cc"}]
proxy.get_status.return_value = 5
proxy.get_vcpu.return_value = 1
proxy.get_memory.return_value = 512
proxy.clone_instance.return_value = "abababab-cdcd-efef-0101-232323232323"
yield proxy
def _clone(self, user, data):
@ -91,50 +92,49 @@ class ClonePermissionTestCase(TestCase):
"disk-vda": "clone-vm.qcow2",
"clone-title": "",
"clone-description": "",
"submission_key": "key-clone-perm1",
}
data.update(extra)
return data
def test_viewer_cannot_clone_someone_elses_vm(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
res = self._clone(self.viewer, self._valid_post())
self.assertEqual(res.status_code, 403)
proxy.clone_instance.assert_not_called()
self.assertFalse(Operation.objects.exists())
def test_owner_without_is_change_cannot_clone(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
res = self._clone(self.owner_readonly, self._valid_post())
self.assertEqual(res.status_code, 403)
proxy.clone_instance.assert_not_called()
self.assertFalse(Operation.objects.exists())
def test_a_clone_stays_on_the_host_of_its_vm(self):
# the removed "VM Clone Auto Migrate" setting sent it to a random host
other = Compute.objects.create(name="other-host", hostname="198.51.100.20", login="", password="", type=4)
with self.mocked_libvirt() as proxy, \
with self.mocked_libvirt(), \
patch("instances.views.utils.migrate_instance") as migrate, \
patch.object(app_settings, "CLONE_INSTANCE_AUTO_MIGRATE", "True", create=True), \
patch("instances.views.Compute.objects.order_by") as random_host:
random_host.return_value.first.return_value = other
self._clone(self.owner_change, self._valid_post())
proxy.clone_instance.assert_called_once()
migrate.assert_not_called()
self.assertEqual(Instance.objects.get(name="clone-vm").compute, self.instance.compute)
self.assertEqual(Operation.objects.get().intent["compute"], self.instance.compute_id)
def test_viewer_can_clone_a_template(self):
self.instance.is_template = True
self.instance.save(update_fields=["is_template"])
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
self._clone(self.viewer, self._valid_post())
proxy.clone_instance.assert_called_once()
self.assertEqual(Operation.objects.count(), 1)
def test_posted_disk_owner_and_unknown_keys_are_ignored(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
self._clone(
self.owner_change,
self._valid_post(disk_owner_uid="4242", disk_owner_gid="4242", evil="x"),
)
proxy.clone_instance.assert_called_once()
clone_data = proxy.clone_instance.call_args[0][0]
clone_data = Operation.objects.get().intent["clone"]
self.assertEqual(
clone_data["disk_owner_uid"],
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
@ -146,53 +146,37 @@ class ClonePermissionTestCase(TestCase):
self.assertNotIn("evil", clone_data)
def test_non_superuser_disk_names_are_derived_by_the_server(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
self._clone(
self.owner_change,
self._valid_post(**{"disk-vda": "../../../etc/passwd"}),
)
proxy.clone_instance.assert_called_once()
clone_data = proxy.clone_instance.call_args[0][0]
clone_data = Operation.objects.get().intent["clone"]
self.assertEqual(clone_data["disk-vda"], "clone-vm.qcow2")
def test_superuser_unsafe_disk_name_is_rejected(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
self._clone(
self.superuser,
self._valid_post(**{"disk-vda": "x</name><evil/>"}),
)
proxy.clone_instance.assert_not_called()
self.assertFalse(Operation.objects.exists())
def test_superuser_invalid_second_mac_is_rejected(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
self._clone(
self.superuser,
self._valid_post(**{"clone-net-mac-1": "not-a-mac"}),
)
proxy.clone_instance.assert_not_called()
def test_clone_holds_the_compute_exclusively(self):
# Clones of different VMs must not race for the destination name.
calls = []
@contextmanager
def record(compute, *, shared=False):
calls.append(shared)
yield
with self.mocked_libvirt() as proxy, patch("instances.views.libvirt_compute_lock", record):
self._clone(self.superuser, self._valid_post())
proxy.clone_instance.assert_called_once()
self.assertEqual(calls, [False])
self.assertFalse(Operation.objects.exists())
def test_superuser_valid_clone_passes_posted_values(self):
with self.mocked_libvirt() as proxy:
with self.mocked_libvirt():
self._clone(
self.superuser,
self._valid_post(**{"disk-vda": "custom-name.qcow2", "meta-vda": "true"}),
)
proxy.clone_instance.assert_called_once()
clone_data = proxy.clone_instance.call_args[0][0]
clone_data = Operation.objects.get().intent["clone"]
self.assertEqual(clone_data["disk-vda"], "custom-name.qcow2")
self.assertEqual(clone_data["meta-vda"], "true")
self.assertEqual(clone_data["clone-net-mac-0"], "52:54:00:12:34:56")
@ -203,8 +187,7 @@ class ClonePermissionTestCase(TestCase):
with self.mocked_libvirt() as proxy:
proxy.get_disk_devices.return_value = [SRC_DISK, empty_disk]
self._clone(self.owner_change, self._valid_post())
proxy.clone_instance.assert_called_once()
clone_data = proxy.clone_instance.call_args[0][0]
clone_data = Operation.objects.get().intent["clone"]
self.assertEqual(clone_data["disk-vda"], "clone-vm.qcow2")
self.assertIsNone(clone_data["disk-vdb"])
@ -213,9 +196,9 @@ class ClonePermissionTestCase(TestCase):
with self.mocked_libvirt() as proxy:
proxy.get_disk_devices.return_value = [odd_disk]
self._clone(self.owner_change, self._valid_post())
proxy.clone_instance.assert_called_once()
self.assertEqual(Operation.objects.count(), 1)
self.assertEqual(
proxy.clone_instance.call_args[0][0]["disk-vda"], "my-disk-2-clone.qcow2"
Operation.objects.get().intent["clone"]["disk-vda"], "my-disk-2-clone.qcow2"
)

View file

@ -68,7 +68,7 @@ class LiveCloneTestCase(SimpleTestCase):
before = self.pool_volumes()
with self.assertRaises(Exception):
vm.clone_instance(self.clone_data("clone-dst"))
vm.clone_instance(self.clone_data("clone-dst"), "abababab-0000-1111-2222-333333333333")
self.assertEqual(self.pool_volumes(), before, "copies left behind or a volume deleted")
self.assertTrue(livetest.volume_exists(self.conn, taken))
@ -81,7 +81,7 @@ class LiveCloneTestCase(SimpleTestCase):
before = self.pool_volumes()
with self.assertRaises(Exception):
vm.clone_instance(self.clone_data("clone-run-copy"))
vm.clone_instance(self.clone_data("clone-run-copy"), "abababab-0000-1111-2222-333333333333")
self.assertEqual(self.pool_volumes(), before)
@ -94,7 +94,7 @@ class LiveCloneTestCase(SimpleTestCase):
source = vm.get_nvram()
uuid = vm.clone_instance(
{"name": P + "clone-uefi-copy", "disk-vda": P + "clone-uefi-copy-a.qcow2", "disk_owner_uid": 0, "disk_owner_gid": 0}
{"name": P + "clone-uefi-copy", "disk-vda": P + "clone-uefi-copy-a.qcow2", "disk_owner_uid": 0, "disk_owner_gid": 0}, "abababab-0000-1111-2222-333333333333"
)
clone = self.conn.lookupByUUIDString(uuid)
@ -111,7 +111,7 @@ class LiveCloneTestCase(SimpleTestCase):
def test_clone_of_a_stopped_vm(self):
_, vm = self.source("clone-ok")
uuid = vm.clone_instance(self.clone_data("clone-ok-copy"))
uuid = vm.clone_instance(self.clone_data("clone-ok-copy"), "abababab-0000-1111-2222-333333333333")
clone = self.conn.lookupByUUIDString(uuid)
self.assertEqual(clone.name(), P + "clone-ok-copy")
self.assertTrue({P + "clone-ok-copy-a.qcow2", P + "clone-ok-copy-b.qcow2"} <= self.pool_volumes())

View file

@ -69,74 +69,3 @@ class MovedWhileWaitingTestCase(TestCase):
self.assertTrue(Instance.objects.filter(pk=self.vm.pk).exists())
messages = [str(m) for m in response.wsgi_request._messages]
self.assertIn("The VM was moved meanwhile; try again.", messages)
def clone(self, user, change):
@contextmanager
def compute_lock_meanwhile(compute, *, shared=False):
change()
yield
self.client.force_login(user)
with patch("instances.views.libvirt_compute_lock", compute_lock_meanwhile), patch(
"instances.models.wvmInstance"
) as wvm, patch.object(Compute, "status", new_callable=PropertyMock, return_value=True), patch(
"instances.views.utils.check_user_quota", return_value="" # not what this tests
):
wvm.return_value.get_disk_devices.return_value = []
wvm.return_value.get_vcpu.return_value = 1
wvm.return_value.get_memory.return_value = 512
response = self.client.post(reverse("instances:clone", args=[self.vm.id]), {"name": "moved-clone"})
return response, wvm.return_value.clone_instance.called
def test_a_clone_of_a_vm_moved_while_it_waited_is_not_made(self):
from accounts.models import UserInstance
from django.contrib.auth.models import Permission
# an owner who may clone it (a superuser would also choose MACs)
owner = get_user_model().objects.create_user("clone_owner", password="x")
owner.user_permissions.add(Permission.objects.get(codename="clone_instances"))
UserInstance.objects.create(instance=self.vm, user=owner, is_change=True)
response, cloned = self.clone(
owner, lambda: Instance.objects.filter(pk=self.vm.pk).update(compute=self.target)
)
self.assertFalse(cloned)
self.assertIn("The VM was moved meanwhile; try again.", [str(m) for m in response.wsgi_request._messages])
def test_a_template_unmarked_while_the_request_waited_is_not_cloned_by_a_viewer(self):
from django.contrib.auth.models import Permission
viewer = get_user_model().objects.create_user("clone_viewer", password="x")
for codename in ("view_instances", "clone_instances"):
viewer.user_permissions.add(Permission.objects.get(codename=codename))
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
response, cloned = self.clone(viewer, lambda: Instance.objects.filter(pk=self.vm.pk).update(is_template=False))
self.assertFalse(cloned)
self.assertEqual(response.status_code, 403)
def test_a_clone_checks_the_quota_against_the_source_as_it_is_under_the_lock(self):
from accounts.models import UserInstance
from django.contrib.auth.models import Permission
owner = get_user_model().objects.create_user("clone_quota", password="x")
owner.user_permissions.add(Permission.objects.get(codename="clone_instances"))
UserInstance.objects.create(instance=self.vm, user=owner, is_change=True)
self.client.force_login(owner)
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_disk_devices.return_value = []
wvm.return_value.get_vcpu.return_value = 1
wvm.return_value.get_memory.return_value = 512
@contextmanager
def compute_lock_after_a_resize(compute, *, shared=False):
wvm.return_value.get_vcpu.return_value = 8 # resized meanwhile
yield
with patch("instances.views.libvirt_compute_lock", compute_lock_after_a_resize), patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
), patch(
"instances.views.utils.check_user_quota",
side_effect=lambda user, count, cpu, memory, disk: "cpu" if cpu > 4 else "",
):
response = self.client.post(reverse("instances:clone", args=[self.vm.id]), {"name": "grown-clone"})
self.assertFalse(wvm.return_value.clone_instance.called)
self.assertTrue(any("quota" in str(m) for m in response.wsgi_request._messages))