From ab03d6ab09188097ceb4bf980d7ad3aebd27fb8f Mon Sep 17 00:00:00 2001 From: Wei Zhou Date: Tue, 6 Oct 2026 14:17:47 +0200 Subject: [PATCH] KVM: allow cluster-wide storage pools for VM deployment from volumes Allow both ZONE and CLUSTER scoped storage pools for VM deployment from existing volumes and snapshots. Previously only ZONE-scoped pools were accepted, restricting deployment flexibility for cluster-wide storage configurations like NFS and RBD pools. Update validation in UserVmManagerImpl to accept CLUSTER scope alongside ZONE scope, and update error messages accordingly. Update test_vm_lifecycle_with_snapshot_or_volume.py smoke tests to: - Accept both ZONE and CLUSTER scoped storage pools - Add RBD-specific handling for snapshot lifecycle (snapshots cannot outlive source volumes on RBD with snapshot.backup.to.secondary=false) - Skip unsupported test scenarios on RBD backends - Fix VM creation to use service configs instead of hardcoded values - Add reconnect logic to SSH client initialization - Remove unused uuid import --- .../java/com/cloud/vm/UserVmManagerImpl.java | 5 +- .../com/cloud/vm/UserVmManagerImplTest.java | 4 +- ...st_vm_lifecycle_with_snapshot_or_volume.py | 76 +++++++++++++------ 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java index 9bbbdc503508..a946dcc2dc0a 100644 --- a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java +++ b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java @@ -7011,8 +7011,9 @@ private VolumeInfo getVolume(long id, Long templateId, boolean isSnapshot) { if (volume != null) { if (!isSnapshot && (volume.getDataStore() == null - || !ScopeType.ZONE.equals(volume.getDataStore().getScope().getScopeType()))) { - throw new InvalidParameterValueException("Deployment of virtual machine is supported only for Zone-wide storage pools"); + || (!ScopeType.ZONE.equals(volume.getDataStore().getScope().getScopeType()) + && !ScopeType.CLUSTER.equals(volume.getDataStore().getScope().getScopeType())))) { + throw new InvalidParameterValueException("Deployment of virtual machine is supported only for Zone-wide or Cluster-wide storage pools"); } checkIfVolumeTemplateIsTheSameAsTheProvided(volume, templateId); if (volume.getInstanceId() != null && !isSnapshot) { diff --git a/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java b/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java index 4d631b293c17..1b39b6a7d574 100644 --- a/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java +++ b/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java @@ -4213,7 +4213,7 @@ public void createVirtualMachineWithVolumeFromNonZoneScopedStorageFails() { InvalidParameterValueException ex = assertThrows(InvalidParameterValueException.class, () -> userVmManagerImpl.createVirtualMachine(deployVMCmd)); - assertEquals("Deployment of virtual machine is supported only for Zone-wide storage pools", ex.getMessage()); + assertEquals("Deployment of virtual machine is supported only for Zone-wide or Cluster-wide storage pools", ex.getMessage()); } @Test @@ -4229,7 +4229,7 @@ public void createVirtualMachineWithVolumeWithNullDataStoreFails() { InvalidParameterValueException ex = assertThrows(InvalidParameterValueException.class, () -> userVmManagerImpl.createVirtualMachine(deployVMCmd)); - assertEquals("Deployment of virtual machine is supported only for Zone-wide storage pools", ex.getMessage()); + assertEquals("Deployment of virtual machine is supported only for Zone-wide or Cluster-wide storage pools", ex.getMessage()); } @Test diff --git a/test/integration/smoke/test_vm_lifecycle_with_snapshot_or_volume.py b/test/integration/smoke/test_vm_lifecycle_with_snapshot_or_volume.py index 871cf25a4021..e56505bb36e8 100644 --- a/test/integration/smoke/test_vm_lifecycle_with_snapshot_or_volume.py +++ b/test/integration/smoke/test_vm_lifecycle_with_snapshot_or_volume.py @@ -40,11 +40,19 @@ from marvin.codes import FAILED, PASS from nose.plugins.attrib import attr -import uuid import unittest class TestDeployVMFromSnapshotOrVolume(cloudstackTestCase): + def rbd_snapshot_cannot_outlive_source_volume(self): + ''' + On RBD, a snapshot lives on primary storage with its parent + volume, so with snapshot.backup.to.secondary=false, deleting the + parent volume destroys the snapshot too - it can no longer be + used to deploy a new volume/VM afterward. + ''' + return self.storage_pool.type == 'RBD' + @classmethod def setUpClass(cls): testClient = super(TestDeployVMFromSnapshotOrVolume, cls).getClsTestClient() @@ -80,23 +88,33 @@ def setUpClass(cls): cls._cleanup.append(cls.account) cls.debug(cls.account.id) + # Prefer a zone-wide pool, but also accept a cluster-scope NFS/RBD pool. storage_pools_response = list_storage_pools(cls.apiclient, zoneid=cls.zone.id, scope="ZONE") + if not storage_pools_response: + cluster_storage_pools = list_storage_pools(cls.apiclient, + zoneid=cls.zone.id, + scope="CLUSTER") or [] + storage_pools_response = [ + pool for pool in cluster_storage_pools + if getattr(pool, "type", None) in ("NetworkFilesystem", "RBD") + ] + if storage_pools_response: - cls.zone_wide_storage = storage_pools_response[0] + cls.storage_pool = storage_pools_response[0] cls.debug( - "zone wide storage id is %s" % - cls.zone_wide_storage.id) + "storage pool id is %s, scope is %s" % + (cls.storage_pool.id, cls.storage_pool.scope)) update1 = StoragePool.update(cls.apiclient, - id=cls.zone_wide_storage.id, + id=cls.storage_pool.id, tags="test-vm" ) cls.debug( "Storage %s pool tag%s" % - (cls.zone_wide_storage.id, update1.tags)) + (cls.storage_pool.id, update1.tags)) cls.service_offering = ServiceOffering.create( cls.apiclient, cls.services["service_offerings"]["small"], @@ -116,7 +134,7 @@ def setUpClass(cls): ) cls._cleanup.append(cls.disk_offering) else: - raise unittest.SkipTest("No zone wide storage found. Skipping tests") + raise unittest.SkipTest("No zone-wide or cluster-scope NFS/RBD storage pool found. Skipping tests") cls.virtual_machine = VirtualMachine.create( @@ -177,16 +195,16 @@ def test_03_deploy_vm_with_existing_volume_deleted_template(self): template = Template.create_from_snapshot(self.apiclient, self.snapshot, services) self._cleanup.append(template) virtual_machine = VirtualMachine.create(self.apiclient, - {"name": "Test-%s" % uuid.uuid4()}, + self.services['small'], accountid=self.account.name, domainid=self.account.domainid, zoneid=self.zone.id, serviceofferingid=self.service_offering.id, templateid=template.id, - mode="basic", + mode=self.services['mode'], ) try: - ssh_client = virtual_machine.get_ssh_client() + ssh_client = virtual_machine.get_ssh_client(reconnect=True) except Exception as e: self.fail("SSH failed for virtual machine: %s - %s" % (virtual_machine.ipaddress, e)) @@ -202,29 +220,35 @@ def test_03_deploy_vm_with_existing_volume_deleted_template(self): root_volume.id, ) VirtualMachine.delete(virtual_machine, self.apiclient, expunge=True) - self.create_volume_from_snapshot_deploy_vm(snapshot.id) + if not self.rbd_snapshot_cannot_outlive_source_volume(): + self.create_volume_from_snapshot_deploy_vm(snapshot.id) @attr(tags=["advanced"], required_hardware="false") def test_04_deploy_vm_with_existing_snapshot_deleted_template(self): ''' Deploy a Virtual machine with existing snapshot of a ROOT volume created from a templated which was deleted ''' + if self.rbd_snapshot_cannot_outlive_source_volume(): + raise unittest.SkipTest( + "Covered by test_03 on RBD: deploying from a snapshot after " + "its source ROOT volume was deleted is not supported here." + ) services = {"displaytext": "Template-1", "name": "Template-1-name", "ostypeid": self.template.ostypeid, "ispublic": "true"} template = Template.create_from_snapshot(self.apiclient, self.snapshot, services) self._cleanup.append(template) virtual_machine = VirtualMachine.create(self.apiclient, - {"name": "Test-%s" % uuid.uuid4()}, + self.services['small'], accountid=self.account.name, domainid=self.account.domainid, zoneid=self.zone.id, serviceofferingid=self.service_offering.id, templateid=template.id, - mode="basic", + mode=self.services['mode'], ) try: - ssh_client = virtual_machine.get_ssh_client() + ssh_client = virtual_machine.get_ssh_client(reconnect=True) except Exception as e: self.fail("SSH failed for virtual machine: %s - %s" % (virtual_machine.ipaddress, e)) @@ -247,18 +271,22 @@ def test_05_deploy_vm_with_existing_snapshot_deleted_volume(self): ''' Deploy a Virtual machine with existing snapshot of a ROOT volume which was deleted ''' - + if self.rbd_snapshot_cannot_outlive_source_volume(): + raise unittest.SkipTest( + "Covered by test_03 on RBD: deploying from a snapshot after " + "its source ROOT volume was deleted is not supported here." + ) virtual_machine = VirtualMachine.create(self.apiclient, - {"name": "Test-%s" % uuid.uuid4()}, + self.services['small'], accountid=self.account.name, domainid=self.account.domainid, zoneid=self.zone.id, serviceofferingid=self.service_offering.id, templateid=self.template.id, - mode="basic", + mode=self.services['mode'], ) try: - ssh_client = virtual_machine.get_ssh_client() + ssh_client = virtual_machine.get_ssh_client(reconnect=True) except Exception as e: self.fail("SSH failed for virtual machine: %s - %s" % (virtual_machine.ipaddress, e)) @@ -278,16 +306,16 @@ def test_05_deploy_vm_with_existing_snapshot_deleted_volume(self): def deploy_vm_from_snapshot(self, snapshot): virtual_machine = VirtualMachine.create(self.apiclient, - {"name": "Test-%s" % uuid.uuid4()}, + self.services['small'], accountid=self.account.name, domainid=self.account.domainid, zoneid=self.zone.id, serviceofferingid=self.service_offering.id, snapshotid=snapshot.id, - mode="basic", + mode=self.services['mode'], ) try: - ssh_client = virtual_machine.get_ssh_client() + ssh_client = virtual_machine.get_ssh_client(reconnect=True) except Exception as e: self.fail("SSH failed for virtual machine: %s - %s" % (virtual_machine.ipaddress, e)) @@ -303,16 +331,16 @@ def create_volume_from_snapshot_deploy_vm(self, snapshotid): zoneid=self.zone.id, ) virtual_machine = VirtualMachine.create(self.apiclient, - {"name": "Test-%s" % uuid.uuid4()}, + self.services['small'], accountid=self.account.name, domainid=self.account.domainid, zoneid=self.zone.id, serviceofferingid=self.service_offering.id, volumeid=volume.id, - mode="basic", + mode=self.services['mode'], ) try: - ssh_client = virtual_machine.get_ssh_client() + ssh_client = virtual_machine.get_ssh_client(reconnect=True) except Exception as e: self.fail("SSH failed for virtual machine: %s - %s" % (virtual_machine.ipaddress, e))