From 2081ac4666e2763755f4c34806a783d0f13f03fd Mon Sep 17 00:00:00 2001 From: Bryan Lima <42067040+BryanMLima@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:25:55 +0200 Subject: [PATCH 01/14] Guest OS rules (#10098) Co-authored-by: Fabricio Duarte --- api/src/main/java/com/cloud/host/Host.java | 2 + .../apache/cloudstack/api/ApiConstants.java | 1 + .../api/command/admin/host/UpdateHostCmd.java | 11 ++- .../cloudstack/api/response/HostResponse.java | 12 +++ .../src/main/java/com/cloud/host/HostVO.java | 5 +- .../main/java/com/cloud/host/dao/HostDao.java | 7 ++ .../java/com/cloud/host/dao/HostDaoImpl.java | 37 ++++++- .../META-INF/db/views/cloud.host_view.sql | 1 + .../cloudstack/quota/QuotaManagerImpl.java | 2 +- .../response/QuotaResponseBuilderImpl.java | 2 +- .../manager/allocator/impl/BaseAllocator.java | 25 +++++ .../allocator/impl/FirstFitAllocator.java | 7 +- .../allocator/impl/RandomAllocator.java | 3 +- .../main/java/com/cloud/api/ApiDBUtils.java | 12 +++ .../cloud/api/query/dao/HostJoinDaoImpl.java | 1 + .../api/query/dao/StoragePoolJoinDaoImpl.java | 5 +- .../com/cloud/api/query/vo/HostJoinVO.java | 7 ++ .../ConfigurationManagerImpl.java | 8 +- .../deploy/DeploymentPlanningManagerImpl.java | 52 ++++++++-- .../cloud/resource/ResourceManagerImpl.java | 98 +++++++++++++------ .../cloud/storage/VolumeApiServiceImpl.java | 5 +- .../heuristics/HeuristicRuleHelper.java | 2 +- .../allocator/impl/FirstFitAllocatorTest.java | 18 ++-- .../allocator/impl/RandomAllocatorTest.java | 8 ++ ui/public/locales/en.json | 2 + ui/public/locales/pt_BR.json | 2 + ui/src/views/infra/HostUpdate.vue | 32 +++++- ...RuleHelper.java => GenericRuleHelper.java} | 57 +++++++---- .../utils/jsinterpreter/JsInterpreter.java | 6 +- .../jsinterpreter/JsInterpreterTest.java | 4 +- 30 files changed, 341 insertions(+), 93 deletions(-) rename utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/{TagAsRuleHelper.java => GenericRuleHelper.java} (61%) diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index b5234820151..8b14cfd3a39 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -63,6 +63,8 @@ public interface Host extends StateObject, Identity, Partition, HAResour String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; String HOST_SSH_PORT = "host.ssh.port"; + String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; + String GUEST_OS_RULE = "guest.os.rule"; int DEFAULT_SSH_PORT = 22; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index c5671d22d07..17416f08690 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -449,6 +449,7 @@ public class ApiConstants { public static final String MAX_VGPU_PER_PHYSICAL_GPU = "maxvgpuperphysicalgpu"; public static final String GUEST_OS_LIST = "guestoslist"; public static final String GUEST_OS_COUNT = "guestoscount"; + public static final String GUEST_OS_RULE = "guestosrule"; public static final String OS_MAPPING_CHECK_ENABLED = "osmappingcheckenabled"; public static final String OUTOFBANDMANAGEMENT_POWERSTATE = "outofbandmanagementpowerstate"; public static final String OUTOFBANDMANAGEMENT_ENABLED = "outofbandmanagementenabled"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java index c085abd42c7..69bca7c79e2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java @@ -49,9 +49,14 @@ public class UpdateHostCmd extends BaseCmd { @Parameter(name = ApiConstants.OS_CATEGORY_ID, type = CommandType.UUID, entityType = GuestOSCategoryResponse.class, - description = "The ID of OS category to update the host with") + description = "the ID of OS category used to prioritize VMs with matching OS category during the allocation process. " + + "It cannot be used alongside the 'guestosrule' parameter.") private Long osCategoryId; + @Parameter(name = ApiConstants.GUEST_OS_RULE, type = CommandType.STRING, description = "the guest OS rule written in JavaScript to match with the OS of the VM." + + "It cannot be used alongside the 'oscategoryid' parameter.") + private String guestOsRule; + @Parameter(name = ApiConstants.ALLOCATION_STATE, type = CommandType.STRING, description = "Change resource state of host, valid values are [Enable, Disable]. Operation may failed if host in states not allowing Enable/Disable") @@ -96,6 +101,10 @@ public class UpdateHostCmd extends BaseCmd { return osCategoryId; } + public String getGuestOsRule() { + return guestOsRule; + } + public String getAllocationState() { return allocationState; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java index 5d085d1bee0..f8e1af8d5b7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java @@ -63,6 +63,10 @@ public class HostResponse extends BaseResponseWithAnnotations { @Param(description = "The OS category name of the host") private String osCategoryName; + @SerializedName(ApiConstants.GUEST_OS_RULE) + @Param(description = "the guest OS rule") + private String guestOsRule; + @SerializedName(ApiConstants.IP_ADDRESS) @Param(description = "The IP address of the host") private String ipAddress; @@ -999,4 +1003,12 @@ public class HostResponse extends BaseResponseWithAnnotations { public String getExtensionName() { return extensionName; } + + public String getGuestOsRule() { + return guestOsRule; + } + + public void setGuestOsRule(String guestOsRule) { + this.guestOsRule = guestOsRule; + } } diff --git a/engine/schema/src/main/java/com/cloud/host/HostVO.java b/engine/schema/src/main/java/com/cloud/host/HostVO.java index d51b4eca057..468caf1e227 100644 --- a/engine/schema/src/main/java/com/cloud/host/HostVO.java +++ b/engine/schema/src/main/java/com/cloud/host/HostVO.java @@ -45,7 +45,7 @@ import javax.persistence.Transient; import com.cloud.cpu.CPU; import org.apache.cloudstack.util.CPUArchConverter; import org.apache.cloudstack.util.HypervisorTypeConverter; -import org.apache.cloudstack.utils.jsinterpreter.TagAsRuleHelper; +import org.apache.cloudstack.utils.jsinterpreter.GenericRuleHelper; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang.BooleanUtils; @@ -856,7 +856,8 @@ public class HostVO implements Host { } if (BooleanUtils.isTrue(this.getIsTagARule())) { - return TagAsRuleHelper.interpretTagAsRule(this.getHostTags().get(0), serviceOffering.getHostTag(), HostTagsDao.hostTagRuleExecutionTimeout.value()); + return GenericRuleHelper.interpretTagAsRule(this.getHostTags().get(0), serviceOffering.getHostTag(), HostTagsDao.hostTagRuleExecutionTimeout.value(), + HostTagsDao.hostTagRuleExecutionTimeout.key()); } if (StringUtils.isEmpty(serviceOffering.getHostTag())) { diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java index 5f5b2affee0..5c0d04fb2be 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java @@ -32,12 +32,17 @@ import com.cloud.utils.Pair; import com.cloud.utils.db.GenericDao; import com.cloud.utils.fsm.StateDao; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.framework.config.ConfigKey; /** * Data Access Object for server * */ public interface HostDao extends GenericDao, StateDao { + + ConfigKey guestOsRuleExecutionTimeout = new ConfigKey<>("Advanced", Long.class, "guest.os.rule.execution.timeout", "3000", "The maximum runtime, in milliseconds, " + + "to execute a guest OS rule; if it is reached, a timeout will happen.", true); + long countBy(long clusterId, ResourceState... states); Integer countAllByType(final Host.Type type); @@ -220,6 +225,8 @@ public interface HostDao extends GenericDao, StateDao findHostsWithTagRuleThatMatchComputeOfferingTags(String computeOfferingTags); + List findHostsWithGuestOsRulesThatDidNotMatchOsOfGuestVm(String templateGuestOSName); + List findClustersThatMatchHostTagRule(String computeOfferingTags); List listSsvmHostsWithPendingMigrateJobsOrderedByJobCount(); diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java index 99c9a979c3b..cd4423dfa26 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java @@ -37,7 +37,9 @@ import javax.persistence.TableGenerator; import com.cloud.vm.VirtualMachine; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; -import org.apache.cloudstack.utils.jsinterpreter.TagAsRuleHelper; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.utils.jsinterpreter.GenericRuleHelper; import org.apache.commons.collections.CollectionUtils; import com.cloud.agent.api.VgpuTypesInfo; @@ -84,7 +86,7 @@ import org.apache.commons.lang3.ObjectUtils; @DB @TableGenerator(name = "host_req_sq", table = "op_host", pkColumnName = "id", valueColumnName = "sequence", allocationSize = 1) -public class HostDaoImpl extends GenericDaoBase implements HostDao { //FIXME: , ExternalIdDao { +public class HostDaoImpl extends GenericDaoBase implements HostDao, Configurable { private static final String LIST_HOST_IDS_BY_HOST_TAGS = "SELECT filtered.host_id, COUNT(filtered.tag) AS tag_count " + "FROM (SELECT host_id, tag, is_tag_a_rule FROM host_tags GROUP BY host_id,tag,is_tag_a_rule) AS filtered " @@ -1520,11 +1522,13 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao } } + @Override public List findHostsWithTagRuleThatMatchComputeOfferingTags(String computeOfferingTags) { List hostTagVOList = _hostTagsDao.findHostRuleTags(); List result = new ArrayList<>(); for (HostTagVO rule: hostTagVOList) { - if (TagAsRuleHelper.interpretTagAsRule(rule.getTag(), computeOfferingTags, HostTagsDao.hostTagRuleExecutionTimeout.value())) { + if (GenericRuleHelper.interpretTagAsRule(rule.getTag(), computeOfferingTags, HostTagsDao.hostTagRuleExecutionTimeout.value(), + HostTagsDao.hostTagRuleExecutionTimeout.key())) { result.add(findById(rule.getHostId())); } } @@ -1532,6 +1536,23 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao return result; } + @Override + public List findHostsWithGuestOsRulesThatDidNotMatchOsOfGuestVm(String templateGuestOSName) { + List hostIdsWithGuestOsRule = _detailsDao.findByName(Host.GUEST_OS_RULE); + List hostsWithIncompatibleRules = new ArrayList<>(); + for (DetailVO guestOsRule : hostIdsWithGuestOsRule) { + if (!GenericRuleHelper.interpretGuestOsRule(guestOsRule.getValue(), templateGuestOSName, HostDao.guestOsRuleExecutionTimeout.value(), + HostDao.guestOsRuleExecutionTimeout.key())) { + logger.trace("The guest OS rule [{}] of the host with ID [{}] is incompatible with the OS of the VM.", + guestOsRule.getHostId(), guestOsRule.getValue()); + hostsWithIncompatibleRules.add(findById(guestOsRule.getHostId())); + } + } + logger.trace("The hosts with the following IDs [{}] are incompatible with the VM considering their guest OS rule.", + hostsWithIncompatibleRules); + return hostsWithIncompatibleRules; + } + public List findClustersThatMatchHostTagRule(String computeOfferingTags) { Set result = new HashSet<>(); List hosts = findHostsWithTagRuleThatMatchComputeOfferingTags(computeOfferingTags); @@ -1984,4 +2005,14 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao return customSearch(sc, null); } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] {guestOsRuleExecutionTimeout}; + } + + @Override + public String getConfigComponentName() { + return HostDaoImpl.class.getSimpleName(); + } } diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.host_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.host_view.sql index d9f4e267159..255ce866c42 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.host_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.host_view.sql @@ -64,6 +64,7 @@ SELECT guest_os_category.id guest_os_category_id, guest_os_category.uuid guest_os_category_uuid, guest_os_category.name guest_os_category_name, + (SELECT `value` FROM `cloud`.`host_details` `hd` WHERE `hd`.`host_id` = `cloud`.`host`.`id` AND `hd`.`name` = 'guest.os.rule') AS `guest_os_rule`, mem_caps.used_capacity memory_used_capacity, mem_caps.reserved_capacity memory_reserved_capacity, cpu_caps.used_capacity cpu_used_capacity, diff --git a/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java b/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java index 20bc0b015bb..5afef8bc95b 100644 --- a/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java +++ b/framework/quota/src/main/java/org/apache/cloudstack/quota/QuotaManagerImpl.java @@ -320,7 +320,7 @@ public class QuotaManagerImpl extends ManagerBase implements QuotaManager { Map>> mapUsageAndQuotaUsage = new LinkedHashMap<>(); - try (JsInterpreter jsInterpreter = new JsInterpreter(QuotaConfig.QuotaActivationRuleTimeout.value())) { + try (JsInterpreter jsInterpreter = new JsInterpreter(QuotaConfig.QuotaActivationRuleTimeout.value(), QuotaConfig.QuotaActivationRuleTimeout.key())) { for (UsageVO usageRecord : usageRecords) { int usageType = usageRecord.getUsageType(); diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java index 77099bcdc88..b71057e6423 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java @@ -1115,7 +1115,7 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { addAllPresetVariables(PresetVariables.class, quotaType, usageTypeVariablesAndDescriptions, null); List usageTypeVariables = usageTypeVariablesAndDescriptions.stream().map(Pair::first).collect(Collectors.toList()); - try (JsInterpreter jsInterpreter = new JsInterpreter(QuotaConfig.QuotaActivationRuleTimeout.value())) { + try (JsInterpreter jsInterpreter = new JsInterpreter(QuotaConfig.QuotaActivationRuleTimeout.value(), QuotaConfig.QuotaActivationRuleTimeout.key())) { Map newVariables = injectUsageTypeVariables(jsInterpreter, usageTypeVariables); String scriptToExecute = jsInterpreterHelper.replaceScriptVariables(activationRule, newVariables); jsInterpreter.executeScript(String.format("new Function(\"%s\")", scriptToExecute.replaceAll("\n", ""))); diff --git a/server/src/main/java/com/cloud/agent/manager/allocator/impl/BaseAllocator.java b/server/src/main/java/com/cloud/agent/manager/allocator/impl/BaseAllocator.java index 58fcc62cdc3..ad5726e0a01 100644 --- a/server/src/main/java/com/cloud/agent/manager/allocator/impl/BaseAllocator.java +++ b/server/src/main/java/com/cloud/agent/manager/allocator/impl/BaseAllocator.java @@ -17,11 +17,13 @@ package com.cloud.agent.manager.allocator.impl; import com.cloud.agent.manager.allocator.HostAllocator; +import com.cloud.api.ApiDBUtils; import com.cloud.capacity.CapacityManager; import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; import com.cloud.offering.ServiceOffering; +import com.cloud.storage.VMTemplateVO; import com.cloud.utils.Pair; import com.cloud.utils.component.AdapterBase; import org.apache.commons.collections.CollectionUtils; @@ -69,6 +71,29 @@ public abstract class BaseAllocator extends AdapterBase implements HostAllocator clusterHosts.addAll(hostsWithTagRules); } + protected void filterHostsBasedOnGuestOsRules(VMTemplateVO vmTemplate, List clusterHosts) { + if (clusterHosts.isEmpty()) { + logger.info("Will not filter hosts based on guest OS as there is no available hosts left to verify."); + return; + } + + String templateGuestOSName = ApiDBUtils.getTemplateGuestOSName(vmTemplate); + List incompatibleHosts = hostDao.findHostsWithGuestOsRulesThatDidNotMatchOsOfGuestVm(templateGuestOSName); + + if (incompatibleHosts.isEmpty()) { + logger.info("No incompatible hosts found with guest OS rules matching the VM guest OS [{}].", templateGuestOSName); + return; + } + + logger.info("Found incompatible hosts {} with guest OS rules that did not match the VM guest OS [{}]. They will be removed from the suitable hosts list.", + incompatibleHosts, templateGuestOSName); + clusterHosts.removeAll(incompatibleHosts); + + if (clusterHosts.isEmpty()) { + logger.info("After filtering by guest OS rules, no compatible hosts were found for VM with OS [{}].", templateGuestOSName); + } + } + /** * Adds hosts with enough CPU capability and enough CPU capacity to the suitable hosts list. */ diff --git a/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java b/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java index 8b4ca318bf7..4bc34d8a5c6 100644 --- a/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java +++ b/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java @@ -123,7 +123,7 @@ public class FirstFitAllocator extends BaseAllocator { String hostTagOnTemplate = template.getTemplateTag(); String paramAsStringToLog = String.format("zone [%s], pod [%s], cluster [%s]", dcId, podId, clusterId); - List suitableHosts = retrieveHosts(vmProfile, type, (List) hosts, clusterId, podId, dcId, hostTagOnOffering, hostTagOnTemplate); + List suitableHosts = retrieveHosts(vmProfile, type, (List) hosts, template, clusterId, podId, dcId, hostTagOnOffering, hostTagOnTemplate); if (suitableHosts.isEmpty()) { logger.info("No suitable host found for VM [{}] in {}.", vmProfile, paramAsStringToLog); @@ -137,8 +137,8 @@ public class FirstFitAllocator extends BaseAllocator { return allocateTo(vmProfile, plan, offering, template, avoid, suitableHosts, returnUpTo, considerReservedCapacity, account); } - protected List retrieveHosts(VirtualMachineProfile vmProfile, Type type, List hostsToFilter, Long clusterId, Long podId, long dcId, String hostTagOnOffering, - String hostTagOnTemplate) { + protected List retrieveHosts(VirtualMachineProfile vmProfile, Type type, List hostsToFilter, VMTemplateVO template, + Long clusterId, Long podId, long dcId, String hostTagOnOffering, String hostTagOnTemplate) { String haVmTag = (String) vmProfile.getParameter(VirtualMachineProfile.Param.HaTag); List clusterHosts; @@ -159,6 +159,7 @@ public class FirstFitAllocator extends BaseAllocator { filterHostsWithUefiEnabled(type, vmProfile, clusterId, podId, dcId, clusterHosts); addHostsBasedOnTagRules(hostTagOnOffering, clusterHosts); + filterHostsBasedOnGuestOsRules(template, clusterHosts); return clusterHosts; diff --git a/server/src/main/java/com/cloud/agent/manager/allocator/impl/RandomAllocator.java b/server/src/main/java/com/cloud/agent/manager/allocator/impl/RandomAllocator.java index 96b4255b876..92fdaf7faf0 100644 --- a/server/src/main/java/com/cloud/agent/manager/allocator/impl/RandomAllocator.java +++ b/server/src/main/java/com/cloud/agent/manager/allocator/impl/RandomAllocator.java @@ -42,7 +42,7 @@ public class RandomAllocator extends BaseAllocator { private ResourceManager _resourceMgr; protected List findSuitableHosts(VirtualMachineProfile vmProfile, DeploymentPlan plan, Type type, ExcludeList avoid, List hosts, int returnUpTo, - boolean considerReservedCapacity) { + boolean considerReservedCapacity) { if (type == Host.Type.Storage) { return null; } @@ -115,6 +115,7 @@ public class RandomAllocator extends BaseAllocator { } addHostsBasedOnTagRules(offeringHostTag, availableHosts); + filterHostsBasedOnGuestOsRules(template, availableHosts); return availableHosts; } diff --git a/server/src/main/java/com/cloud/api/ApiDBUtils.java b/server/src/main/java/com/cloud/api/ApiDBUtils.java index 1d00e9ec16b..d8b3c155ae8 100644 --- a/server/src/main/java/com/cloud/api/ApiDBUtils.java +++ b/server/src/main/java/com/cloud/api/ApiDBUtils.java @@ -31,6 +31,7 @@ import javax.annotation.PostConstruct; import javax.inject.Inject; import com.cloud.cpu.CPU; +import com.cloud.storage.GuestOSVO; import org.apache.cloudstack.acl.Role; import org.apache.cloudstack.acl.RoleService; import org.apache.cloudstack.affinity.AffinityGroup; @@ -2345,4 +2346,15 @@ public class ApiDBUtils { public static List listZoneClustersArchs(long zoneId) { return s_clusterDao.getClustersArchsByZone(zoneId); } + + public static String getTemplateGuestOSName(VMTemplateVO template) { + long guestOSId = template.getGuestOSId(); + GuestOSVO guestOS = s_guestOSDao.findById(guestOSId); + + if (guestOS == null) { + return null; + } + + return guestOS.getDisplayName(); + } } diff --git a/server/src/main/java/com/cloud/api/query/dao/HostJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/HostJoinDaoImpl.java index 0200dec2cd3..b7464ba987c 100644 --- a/server/src/main/java/com/cloud/api/query/dao/HostJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/HostJoinDaoImpl.java @@ -225,6 +225,7 @@ public class HostJoinDaoImpl extends GenericDaoBase implements hostResponse.setHaHost(containsHostHATag(hostTags)); hostResponse.setExplicitHostTags(host.getExplicitTag()); hostResponse.setImplicitHostTags(host.getImplicitTag()); + hostResponse.setGuestOsRule(host.getGuestOsRule()); hostResponse.setHypervisorVersion(host.getHypervisorVersion()); if (host.getArch() != null) { diff --git a/server/src/main/java/com/cloud/api/query/dao/StoragePoolJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/StoragePoolJoinDaoImpl.java index a9d5b726b86..6c8e683d7b3 100644 --- a/server/src/main/java/com/cloud/api/query/dao/StoragePoolJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/StoragePoolJoinDaoImpl.java @@ -35,7 +35,7 @@ import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailVO; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; -import org.apache.cloudstack.utils.jsinterpreter.TagAsRuleHelper; +import org.apache.cloudstack.utils.jsinterpreter.GenericRuleHelper; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.springframework.stereotype.Component; @@ -401,7 +401,8 @@ public class StoragePoolJoinDaoImpl extends GenericDaoBase, Configurable { return false; } } + + DetailVO guestOsRule = _hostDetailsDao.findDetail(host.getId(), HostVO.GUEST_OS_RULE); + if (guestOsRule != null) { + return isHostGuestOsCompatible(vmProfile, host, guestOsRule); + } + + DetailVO guestOsCategoryId = _hostDetailsDao.findDetail(host.getId(), HostVO.GUEST_OS_CATEGORY_ID); + if (guestOsCategoryId != null) { + return isHostGuestOsCategoryCompatible(vmProfile, host, guestOsCategoryId); + } + + return true; + } + + private boolean isHostGuestOsCategoryCompatible(VirtualMachineProfile vmProfile, HostVO host, DetailVO guestOsCategoryId) { long guestOSId = vmProfile.getTemplate().getGuestOSId(); GuestOSVO guestOS = _guestOSDao.findById(guestOSId); - if (guestOS != null) { - long guestOSCategoryId = guestOS.getCategoryId(); - DetailVO hostDetail = _hostDetailsDao.findDetail(host.getId(), "guest.os.category.id"); - if (hostDetail != null) { - String guestOSCategoryIdString = hostDetail.getValue(); - if (String.valueOf(guestOSCategoryId) != guestOSCategoryIdString) { - logger.debug("The last host has different guest.os.category.id than guest os category of VM, skipping"); - return false; - } - } + + if (guestOS == null) { + return true; + } + + long guestOSCategoryId = guestOS.getCategoryId(); + String guestOSCategoryIdString = guestOsCategoryId.getValue(); + if (!String.valueOf(guestOSCategoryId).equals(guestOSCategoryIdString)) { + logger.debug("The last host has different `{}` than guest os category of VM, skipping", Host.GUEST_OS_CATEGORY_ID); + return false; + } + + return true; + } + + private boolean isHostGuestOsCompatible(VirtualMachineProfile vmProfile, HostVO host, DetailVO guestOsRule) { + VMTemplateVO template = (VMTemplateVO) vmProfile.getTemplate(); + String templateGuestOSName = ApiDBUtils.getTemplateGuestOSName(template); + + List incompatibleHosts = _hostDao.findHostsWithGuestOsRulesThatDidNotMatchOsOfGuestVm(templateGuestOSName); + + boolean isHostCompatible = incompatibleHosts.stream().noneMatch(incompatibleHost -> incompatibleHost.getId() == host.getId()); + + if (!isHostCompatible) { + logger.debug("The template [{}] is incompatible with the host [{}] due to the guest OS rule [{}].", template, host, guestOsRule); + return false; } return true; } diff --git a/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java b/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java index 0f6933664f9..8db55790fd8 100755 --- a/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java +++ b/server/src/main/java/com/cloud/resource/ResourceManagerImpl.java @@ -84,8 +84,8 @@ import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.utils.identity.ManagementServerNode; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; -import org.apache.commons.lang.ObjectUtils; import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.ObjectUtils; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Component; @@ -2046,31 +2046,74 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, _hostDao.update(host.getId(), host); } - private void updateHostGuestOSCategory(Long hostId, Long guestOSCategoryId) { - // Verify that the guest OS Category exists - if (!(guestOSCategoryId > 0) || _guestOSCategoryDao.findById(guestOSCategoryId) == null) { + /** + * Updates the guest OS details related to the host ({@code guest.os.rule} and {@code guest.os.category.id}); only one can be set at a time for a given host. + */ + private void updateGuestOsRelatedFields(Long hostId, Long cmdOsCategoryId, String cmdGuestOsRule) { + if (ObjectUtils.allNotNull(cmdOsCategoryId, cmdGuestOsRule)) { + throw new InvalidParameterValueException("Informing both guest OS category and guest OS rule is invalid; please, inform only one of them."); + } + + if (cmdOsCategoryId != null) { + updateHostCategoryOs(hostId, cmdOsCategoryId); + } else if (cmdGuestOsRule != null) { + updateHostGuestOsRule(hostId, cmdGuestOsRule); + } + } + + /** + * Associates/removes a guest OS rule to a host and removes any guest OS category associated with it. + */ + private void updateHostGuestOsRule(Long hostId, String cmdGuestOsRule) { + final DetailVO hostGuestOsRule = _hostDetailsDao.findDetail(hostId, Host.GUEST_OS_RULE); + + if (StringUtils.isNotBlank(cmdGuestOsRule)) { + createOrUpdateHostDetails(hostGuestOsRule, cmdGuestOsRule, Host.GUEST_OS_RULE, hostId); + } else if (hostGuestOsRule != null) { + _hostDetailsDao.remove(hostGuestOsRule.getId()); + } + removeHostDetails(hostId, Host.GUEST_OS_CATEGORY_ID); + } + + /** + * Removes a host details if it exists. + */ + private void removeHostDetails(Long hostId, String detailString) { + DetailVO detailVO = _hostDetailsDao.findDetail(hostId, detailString); + + if (detailVO != null) { + _hostDetailsDao.remove(detailVO.getId()); + } + } + + private void createOrUpdateHostDetails(DetailVO hostDetail, String detailValue, String detailName, Long hostId) { + if (hostDetail != null) { + hostDetail.setValue(detailValue); + _hostDetailsDao.update(hostDetail.getId(), hostDetail); + } else { + Map detail = new HashMap<>(); + detail.put(detailName, detailValue); + _hostDetailsDao.persist(hostId, detail); + } + } + + /** + * Associates/removes a valid guest OS category to a host and removes any guest OS rules associated with it. + */ + private void updateHostCategoryOs(Long hostId, Long cmdOsCategoryId) { + if (cmdOsCategoryId <= 0 || _guestOSCategoryDao.findById(cmdOsCategoryId) == null) { throw new InvalidParameterValueException("Please specify a valid guest OS category."); } - final GuestOSCategoryVO guestOSCategory = _guestOSCategoryDao.findById(guestOSCategoryId); - final DetailVO guestOSDetail = _hostDetailsDao.findDetail(hostId, "guest.os.category.id"); + GuestOSCategoryVO guestOSCategory = _guestOSCategoryDao.findById(cmdOsCategoryId); + DetailVO guestOSDetail = _hostDetailsDao.findDetail(hostId, Host.GUEST_OS_CATEGORY_ID); if (guestOSCategory != null && !GuestOSCategoryVO.CATEGORY_NONE.equalsIgnoreCase(guestOSCategory.getName())) { - // Create/Update an entry for guest.os.category.id - if (guestOSDetail != null) { - guestOSDetail.setValue(String.valueOf(guestOSCategory.getId())); - _hostDetailsDao.update(guestOSDetail.getId(), guestOSDetail); - } else { - final Map detail = new HashMap<>(); - detail.put("guest.os.category.id", String.valueOf(guestOSCategory.getId())); - _hostDetailsDao.persist(hostId, detail); - } - } else { - // Delete any existing entry for guest.os.category.id - if (guestOSDetail != null) { - _hostDetailsDao.remove(guestOSDetail.getId()); - } + createOrUpdateHostDetails(guestOSDetail, String.valueOf(guestOSCategory.getId()), Host.GUEST_OS_CATEGORY_ID, hostId); + } else if (guestOSDetail != null) { + _hostDetailsDao.remove(guestOSDetail.getId()); } + removeHostDetails(hostId, Host.GUEST_OS_RULE); } private void removeStorageAccessGroupsOnPodsInZone(long zoneId, List newStoragePoolTags, List tagsToDeleteOnZone) { @@ -2821,16 +2864,17 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, @Override public Host updateHost(final UpdateHostCmd cmd) throws NoTransitionException { - return updateHost(cmd.getId(), cmd.getName(), cmd.getOsCategoryId(), + return updateHost(cmd.getId(), cmd.getName(), cmd.getOsCategoryId(), cmd.getGuestOsRule(), cmd.getAllocationState(), cmd.getUrl(), cmd.getHostTags(), cmd.getIsTagARule(), cmd.getAnnotation(), false, cmd.getExternalDetails(), cmd.isCleanupExternalDetails()); } - private Host updateHost(Long hostId, String name, Long guestOSCategoryId, String allocationState, + private Host updateHost(Long hostId, String name, Long guestOSCategoryId, String guestOsRule, String allocationState, String url, List hostTags, Boolean isTagARule, String annotation, boolean isUpdateFromHostHealthCheck, Map externalDetails, boolean cleanupExternalDetails) throws NoTransitionException { jsInterpreterHelper.ensureInterpreterEnabledIfParameterProvided(ApiConstants.IS_TAG_A_RULE, Boolean.TRUE.equals(isTagARule)); + jsInterpreterHelper.ensureInterpreterEnabledIfParameterProvided(ApiConstants.GUEST_OS_RULE, guestOsRule != null); // Verify that the host exists final HostVO host = _hostDao.findById(hostId); @@ -2847,9 +2891,7 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, updateHostName(host, name); } - if (guestOSCategoryId != null) { - updateHostGuestOSCategory(hostId, guestOSCategoryId); - } + updateGuestOsRelatedFields(hostId, guestOSCategoryId, guestOsRule); if (hostTags != null) { updateHostTags(host, hostId, hostTags, isTagARule); @@ -2919,7 +2961,7 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, @Override public Host autoUpdateHostAllocationState(Long hostId, ResourceState.Event resourceEvent) throws NoTransitionException { - return updateHost(hostId, null, null, resourceEvent.toString(), null, null, null, null, true, null, false); + return updateHost(hostId, null, null, null, resourceEvent.toString(), null, null, null, null, true, null, false); } @Override @@ -3639,7 +3681,7 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, // If the server's private IP is the same as is public IP, this host has // a host-only private network. Don't check for conflicts with the // private IP address table. - if (!ObjectUtils.equals(serverPrivateIP, serverPublicIP)) { + if (!StringUtils.equals(serverPrivateIP, serverPublicIP)) { if (!_privateIPAddressDao.mark(dc.getId(), pod.getId(), serverPrivateIP)) { // If the server's private IP address is already in the // database, return false @@ -4284,7 +4326,7 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager, return null; } else { _hostDao.loadDetails(host); - final DetailVO detail = _hostDetailsDao.findDetail(hostId, "guest.os.category.id"); + final DetailVO detail = _hostDetailsDao.findDetail(hostId, Host.GUEST_OS_CATEGORY_ID); if (detail == null) { return null; } else { diff --git a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java index afe16c2b574..ee27e171a73 100644 --- a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java +++ b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java @@ -112,7 +112,7 @@ import org.apache.cloudstack.storage.image.datastore.ImageStoreEntity; import org.apache.cloudstack.storage.to.VolumeObjectTO; import org.apache.cloudstack.utils.identity.ManagementServerNode; import org.apache.cloudstack.utils.imagestore.ImageStoreUtil; -import org.apache.cloudstack.utils.jsinterpreter.TagAsRuleHelper; +import org.apache.cloudstack.utils.jsinterpreter.GenericRuleHelper; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import org.apache.cloudstack.utils.volume.VirtualMachineDiskInfo; import org.apache.commons.collections.CollectionUtils; @@ -3904,7 +3904,8 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic boolean result; if (storagePoolTags.second()) { - result = TagAsRuleHelper.interpretTagAsRule(storageTagsList.get(0), diskOfferingTags, storageTagRuleExecutionTimeout.value()); + result = GenericRuleHelper.interpretTagAsRule(storageTagsList.get(0), diskOfferingTags, storageTagRuleExecutionTimeout.value(), + storageTagRuleExecutionTimeout.key()); } else { result = CollectionUtils.isSubCollection(Arrays.asList(newDiskOfferingTagsAsStringArray), storageTagsList); } diff --git a/server/src/main/java/org/apache/cloudstack/storage/heuristics/HeuristicRuleHelper.java b/server/src/main/java/org/apache/cloudstack/storage/heuristics/HeuristicRuleHelper.java index beecf90d2b8..97c13f71447 100644 --- a/server/src/main/java/org/apache/cloudstack/storage/heuristics/HeuristicRuleHelper.java +++ b/server/src/main/java/org/apache/cloudstack/storage/heuristics/HeuristicRuleHelper.java @@ -257,7 +257,7 @@ public class HeuristicRuleHelper { * @return the {@link DataStore} returned by the script. */ public DataStore interpretHeuristicRule(String rule, HeuristicType heuristicType, Object obj, long zoneId) { - try (JsInterpreter jsInterpreter = new JsInterpreter(HEURISTICS_SCRIPT_TIMEOUT)) { + try (JsInterpreter jsInterpreter = new JsInterpreter(HEURISTICS_SCRIPT_TIMEOUT, StorageManager.HEURISTICS_SCRIPT_TIMEOUT.key())) { buildPresetVariables(jsInterpreter, heuristicType, zoneId, obj); Object scriptReturn = jsInterpreter.executeScript(rule); diff --git a/server/src/test/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocatorTest.java b/server/src/test/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocatorTest.java index 00cac5bbd52..f39ed06a656 100644 --- a/server/src/test/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocatorTest.java +++ b/server/src/test/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocatorTest.java @@ -193,7 +193,7 @@ public class FirstFitAllocatorTest { Mockito.doReturn(account).when(virtualMachineProfile).getOwner(); Mockito.doReturn(hostTag).when(serviceOffering).getHostTag(); Mockito.doReturn(templateTag).when(vmTemplateVO).getTemplateTag(); - Mockito.doReturn(emptyList).when(firstFitAllocatorSpy).retrieveHosts(Mockito.any(VirtualMachineProfile.class), Mockito.any(Host.Type.class), Mockito.nullable(List.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); + Mockito.doReturn(emptyList).when(firstFitAllocatorSpy).retrieveHosts(Mockito.any(VirtualMachineProfile.class), Mockito.any(Host.Type.class), Mockito.nullable(List.class), Mockito.any(VMTemplateVO.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); List suitableHosts = firstFitAllocatorSpy.allocateTo(virtualMachineProfile, deploymentPlan, type, excludeList, null, HostAllocator.RETURN_UPTO_ALL, considerReservedCapacity); Assert.assertNull(suitableHosts); @@ -208,7 +208,7 @@ public class FirstFitAllocatorTest { Mockito.doReturn(account).when(virtualMachineProfile).getOwner(); Mockito.doReturn(hostTag).when(serviceOffering).getHostTag(); Mockito.doReturn(templateTag).when(vmTemplateVO).getTemplateTag(); - Mockito.doReturn(hosts).when(firstFitAllocatorSpy).retrieveHosts(Mockito.any(VirtualMachineProfile.class), Mockito.any(Host.Type.class), Mockito.nullable(List.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); + Mockito.doReturn(hosts).when(firstFitAllocatorSpy).retrieveHosts(Mockito.any(VirtualMachineProfile.class), Mockito.any(Host.Type.class), Mockito.nullable(List.class), Mockito.any(VMTemplateVO.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); Mockito.doReturn(hosts).when(firstFitAllocatorSpy).allocateTo(Mockito.any(VirtualMachineProfile.class), Mockito.any(DeploymentPlan.class), Mockito.any(ServiceOffering.class), Mockito.any(VMTemplateVO.class), Mockito.any(DeploymentPlanner.ExcludeList.class), Mockito.anyList(), Mockito.anyInt(), Mockito.anyBoolean(), Mockito.any(Account.class)); Mockito.doNothing().when(firstFitAllocatorSpy).addHostsToAvoidSet(Mockito.any(Host.Type.class), Mockito.any(DeploymentPlanner.ExcludeList.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyList()); List suitableHosts = firstFitAllocatorSpy.allocateTo(virtualMachineProfile, deploymentPlan, type, excludeList, null, HostAllocator.RETURN_UPTO_ALL, considerReservedCapacity); @@ -226,7 +226,7 @@ public class FirstFitAllocatorTest { Mockito.doReturn(account).when(virtualMachineProfile).getOwner(); Mockito.doReturn(hostTag).when(serviceOffering).getHostTag(); Mockito.doReturn(templateTag).when(vmTemplateVO).getTemplateTag(); - Mockito.doReturn(hosts).when(firstFitAllocatorSpy).retrieveHosts(Mockito.any(VirtualMachineProfile.class), Mockito.any(Host.Type.class), Mockito.nullable(List.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); + Mockito.doReturn(hosts).when(firstFitAllocatorSpy).retrieveHosts(Mockito.any(VirtualMachineProfile.class), Mockito.any(Host.Type.class), Mockito.nullable(List.class), Mockito.any(VMTemplateVO.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); Mockito.doReturn(hosts).when(firstFitAllocatorSpy).allocateTo(Mockito.any(VirtualMachineProfile.class), Mockito.any(DeploymentPlan.class), Mockito.any(ServiceOffering.class), Mockito.any(VMTemplateVO.class), Mockito.any(DeploymentPlanner.ExcludeList.class), Mockito.anyList(), Mockito.anyInt(), Mockito.anyBoolean(), Mockito.any(Account.class)); Mockito.doNothing().when(firstFitAllocatorSpy).addHostsToAvoidSet(Mockito.any(Host.Type.class), Mockito.any(DeploymentPlanner.ExcludeList.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyList()); firstFitAllocatorSpy.allocateTo(virtualMachineProfile, deploymentPlan, type, excludeList, null, HostAllocator.RETURN_UPTO_ALL, considerReservedCapacity); @@ -245,7 +245,8 @@ public class FirstFitAllocatorTest { Mockito.doReturn(hostsWithHaTag).when(hostDaoMock).listByHostTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString()); Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsWithUefiEnabled(Mockito.any(Host.Type.class), Mockito.any(VirtualMachineProfile.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyList()); Mockito.doNothing().when(firstFitAllocatorSpy).addHostsBasedOnTagRules(Mockito.anyString(), Mockito.anyList()); - List resultHosts = firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, emptyList, clusterId, podId, dcId, hostTag, templateTag); + Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); + List resultHosts = firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, emptyList, vmTemplateVO, clusterId, podId, dcId, hostTag, templateTag); Assert.assertEquals(2, resultHosts.size()); Assert.assertEquals(host1, resultHosts.get(0)); @@ -262,7 +263,8 @@ public class FirstFitAllocatorTest { Mockito.doReturn(hostsWithHaTag).when(hostDaoMock).listByHostTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString()); Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsWithUefiEnabled(Mockito.any(Host.Type.class), Mockito.any(VirtualMachineProfile.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyList()); Mockito.doNothing().when(firstFitAllocatorSpy).addHostsBasedOnTagRules(Mockito.anyString(), Mockito.anyList()); - List resultHosts = firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, hostsToFilter, clusterId, podId, dcId, hostTag, templateTag); + Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); + List resultHosts = firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, hostsToFilter, vmTemplateVO, clusterId, podId, dcId, hostTag, templateTag); Assert.assertEquals(2, resultHosts.size()); Assert.assertEquals(host1, resultHosts.get(0)); @@ -278,7 +280,8 @@ public class FirstFitAllocatorTest { Mockito.doReturn(upAndEnabledHostsWithNoHa).when(resourceManagerMock).listAllUpAndEnabledNonHAHosts(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsWithUefiEnabled(Mockito.any(Host.Type.class), Mockito.any(VirtualMachineProfile.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyList()); Mockito.doNothing().when(firstFitAllocatorSpy).addHostsBasedOnTagRules(Mockito.nullable(String.class), Mockito.anyList()); - List resultHosts = firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, emptyList, clusterId, podId, dcId, null, null); + Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); + List resultHosts = firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, emptyList, vmTemplateVO, clusterId, podId, dcId, null, null); Assert.assertEquals(2, resultHosts.size()); Assert.assertEquals(host1, resultHosts.get(0)); @@ -293,7 +296,8 @@ public class FirstFitAllocatorTest { Mockito.doNothing().when(firstFitAllocatorSpy).retainHostsMatchingServiceOfferingAndTemplateTags(Mockito.anyList(), Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsWithUefiEnabled(Mockito.any(Host.Type.class), Mockito.any(VirtualMachineProfile.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyList()); Mockito.doNothing().when(firstFitAllocatorSpy).addHostsBasedOnTagRules(Mockito.anyString(), Mockito.anyList()); - firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, emptyList, clusterId, podId, dcId, hostTag, templateTag); + Mockito.doNothing().when(firstFitAllocatorSpy).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); + firstFitAllocatorSpy.retrieveHosts(virtualMachineProfile, type, emptyList, vmTemplateVO, clusterId, podId, dcId, hostTag, templateTag); Mockito.verify(firstFitAllocatorSpy, Mockito.times(1)).retainHostsMatchingServiceOfferingAndTemplateTags(Mockito.anyList(), Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()); } diff --git a/server/src/test/java/com/cloud/agent/manager/allocator/impl/RandomAllocatorTest.java b/server/src/test/java/com/cloud/agent/manager/allocator/impl/RandomAllocatorTest.java index ab9b322eb84..938ed7a152e 100644 --- a/server/src/test/java/com/cloud/agent/manager/allocator/impl/RandomAllocatorTest.java +++ b/server/src/test/java/com/cloud/agent/manager/allocator/impl/RandomAllocatorTest.java @@ -222,6 +222,7 @@ public class RandomAllocatorTest { Mockito.doReturn(upAndEnabledHosts).when(resourceManagerMock).listAllUpAndEnabledHosts(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(hostsWithNoRuleTagsAndHostTags).when(hostDao).listAllHostsThatHaveNoRuleTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(emptyList).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, null, vmTemplateVO, null, clusterId, podId, zoneId); Assert.assertEquals(1, availableHosts.size()); @@ -237,6 +238,7 @@ public class RandomAllocatorTest { Mockito.doReturn(upAndEnabledHosts).when(resourceManagerMock).listAllUpAndEnabledHosts(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(hostsWithNoRuleTagsAndHostTags).when(hostDao).listAllHostsThatHaveNoRuleTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(hostsMatchingRuleTags).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, null, vmTemplateVO, null, clusterId, podId, zoneId); Assert.assertEquals(2, availableHosts.size()); @@ -252,6 +254,7 @@ public class RandomAllocatorTest { Mockito.doReturn(upAndEnabledHosts).when(resourceManagerMock).listAllUpAndEnabledHosts(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(hostsWithMatchingTags).when(hostDao).listByHostTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString()); Mockito.doReturn(emptyList).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, null, vmTemplateVO, hostTag, clusterId, podId, zoneId); Assert.assertEquals(1, availableHosts.size()); @@ -267,6 +270,7 @@ public class RandomAllocatorTest { Mockito.doReturn(upAndEnabledHosts).when(resourceManagerMock).listAllUpAndEnabledHosts(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(hostsWithMatchingTags).when(hostDao).listByHostTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString()); Mockito.doReturn(hostsMatchingRuleTags).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, null, vmTemplateVO, hostTag, clusterId, podId, zoneId); Assert.assertEquals(2, availableHosts.size()); @@ -281,6 +285,7 @@ public class RandomAllocatorTest { Mockito.doReturn(hostsWithNoRuleTagsAndHostTags).when(hostDao).listAllHostsThatHaveNoRuleTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(emptyList).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, providedHosts, vmTemplateVO, null, clusterId, podId, zoneId); Assert.assertEquals(1, availableHosts.size()); @@ -295,6 +300,7 @@ public class RandomAllocatorTest { Mockito.doReturn(hostsWithNoRuleTagsAndHostTags).when(hostDao).listAllHostsThatHaveNoRuleTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong()); Mockito.doReturn(hostsMatchingRuleTags).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, providedHosts, vmTemplateVO, null, clusterId, podId, zoneId); Assert.assertEquals(2, availableHosts.size()); @@ -309,6 +315,7 @@ public class RandomAllocatorTest { Mockito.doReturn(hostsWithMatchingTags).when(hostDao).listByHostTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString()); Mockito.doReturn(emptyList).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, providedHosts, vmTemplateVO, hostTag, clusterId, podId, zoneId); Assert.assertEquals(1, availableHosts.size()); @@ -323,6 +330,7 @@ public class RandomAllocatorTest { Mockito.doReturn(hostsWithMatchingTags).when(hostDao).listByHostTag(Mockito.any(Host.Type.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString()); Mockito.doReturn(hostsMatchingRuleTags).when(hostDao).findHostsWithTagRuleThatMatchComputeOfferingTags(Mockito.nullable(String.class)); + Mockito.doNothing().when(randomAllocator).filterHostsBasedOnGuestOsRules(Mockito.any(VMTemplateVO.class), Mockito.anyList()); List availableHosts = randomAllocator.retrieveHosts(type, providedHosts, vmTemplateVO, hostTag, clusterId, podId, zoneId); Assert.assertEquals(2, availableHosts.size()); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 3a16d613f37..7d848e82113 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1219,6 +1219,8 @@ "label.guestnetwork": "Guest Network", "label.guestnetworkid": "Network ID", "label.guestnetworkname": "Network name", +"label.guestosasrule": "Guest OS as rule", +"label.guestosrule": "Guest OS rule", "label.gueststartip": "Guest start IP", "label.guest.vlan": "Guest VLAN", "label.guestvlanrange": "VLAN range(s)", diff --git a/ui/public/locales/pt_BR.json b/ui/public/locales/pt_BR.json index 91f0fd895e5..ad27813f1ce 100644 --- a/ui/public/locales/pt_BR.json +++ b/ui/public/locales/pt_BR.json @@ -1090,6 +1090,8 @@ "label.guestnetwork": "Rede guest", "label.guestnetworkid": "ID de rede", "label.guestnetworkname": "Nome da rede", +"label.guestosasrule": "SO guest como regra JS", +"label.guestosrule": "Regra SO guest", "label.gueststartip": "IP de in\u00edcio do guest", "label.guestvlanrange": "Intervalo(s) de VLAN", "label.guestvmcidr": "CIDR", diff --git a/ui/src/views/infra/HostUpdate.vue b/ui/src/views/infra/HostUpdate.vue index 1fee7a6846c..5ef762443e9 100644 --- a/ui/src/views/infra/HostUpdate.vue +++ b/ui/src/views/infra/HostUpdate.vue @@ -64,7 +64,22 @@ - + + + + + + + + + + @@ -149,6 +164,7 @@ export default { methods: { initForm () { this.formRef = ref() + const guestOsRule = this.resource?.guestosrule this.form = reactive({ name: this.resource.name, hosttags: this.resource.explicithosttags, @@ -157,7 +173,9 @@ export default { ? this.resource.storageaccessgroups.split(',') : [], oscategoryid: this.resource.oscategoryid, - externaldetails: this.resourceExternalDetails + externaldetails: this.resourceExternalDetails, + guestosasrule: guestOsRule !== undefined, + guestosrule: guestOsRule }) this.rules = reactive({}) }, @@ -194,7 +212,11 @@ export default { params.id = this.resource.id params.name = values.name params.hosttags = values.hosttags - params.oscategoryid = values.oscategoryid + if (values.guestosasrule === true) { + params.guestosrule = values.guestosrule + } else { + params.oscategoryid = values.oscategoryid || this.osCategories.opts.filter(os => os.name === 'None')[0]?.id + } if (values.istagarule !== undefined) { params.istagarule = values.istagarule } @@ -205,9 +227,11 @@ export default { } else { params.cleanupexternaldetails = true } + + Object.keys(params).forEach((key) => (params[key] == null) && delete params[key]) this.loading = true - postAPI('updateHost', params).then(json => { + postAPI('updateHost', params).then(() => { this.$message.success({ content: `${this.$t('label.action.update.host')} - ${values.name}`, duration: 2 diff --git a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/GenericRuleHelper.java similarity index 61% rename from utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java rename to utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/GenericRuleHelper.java index b3013567352..506bbc1ea5c 100644 --- a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/TagAsRuleHelper.java +++ b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/GenericRuleHelper.java @@ -16,41 +16,60 @@ //under the License. package org.apache.cloudstack.utils.jsinterpreter; +import com.cloud.utils.StringUtils; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import com.cloud.utils.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; +public class GenericRuleHelper { -import com.cloud.utils.exception.CloudRuntimeException; + protected static Logger LOGGER = LogManager.getLogger(GenericRuleHelper.class); -public class TagAsRuleHelper { - - protected static Logger LOGGER = LogManager.getLogger(TagAsRuleHelper.class); - - public static boolean interpretTagAsRule(String rule, String tags, long timeout) { + public static boolean interpretTagAsRule(String rule, String tags, long timeout, String configName) { List tagsPresetVariable = new ArrayList<>(); if (!StringUtils.isEmpty(tags)) { tagsPresetVariable.addAll(Arrays.asList(tags.split(","))); } - try (JsInterpreter jsInterpreter = new JsInterpreter(timeout)) { - jsInterpreter.injectVariable("tags", tagsPresetVariable); - Object scriptReturn = jsInterpreter.executeScript(rule); - if (scriptReturn instanceof Boolean) { - return (Boolean)scriptReturn; - } - } catch (IOException ex) { - String message = String.format("Error while executing script [%s].", rule); - LOGGER.error(message, ex); - throw new CloudRuntimeException(message, ex); + Boolean scriptReturn = interpretRule("tags", tagsPresetVariable, timeout, rule, configName); + + if (scriptReturn != null) { + return scriptReturn; } LOGGER.debug("Result of tag rule [{}] was not a boolean, returning false.", rule); return false; } + public static boolean interpretGuestOsRule(String rule, String vmGuestOs, long timeout, String configName) { + Boolean scriptReturn = interpretRule("vmGuestOs", vmGuestOs, timeout, rule, configName); + + if (scriptReturn != null) { + return scriptReturn; + } + + LOGGER.debug("Result of guest OS rule [{}] was not a boolean, returning false.", rule); + return false; + } + + private static Boolean interpretRule(String variableName, Object variableValue, long timeout, String script, String configName) { + try (JsInterpreter jsInterpreter = new JsInterpreter(timeout, configName)) { + jsInterpreter.injectVariable(variableName, variableValue); + Object scriptReturn = jsInterpreter.executeScript(script); + if (scriptReturn instanceof Boolean) { + return (Boolean) scriptReturn; + } + } catch (IOException ex) { + String message = String.format("Error while executing script [%s].", script); + LOGGER.error(message, ex); + throw new CloudRuntimeException(message, ex); + } + return null; + } + } diff --git a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java index 6e6ef2bbe59..85799e0f561 100644 --- a/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java +++ b/utils/src/main/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreter.java @@ -86,10 +86,10 @@ public class JsInterpreter implements Closeable { */ protected JsInterpreter() { } - public JsInterpreter(long timeout) { + public JsInterpreter(long timeout, String configName) { this.timeout = timeout; - this.timeoutDefaultMessage = String.format( - "Timeout (in milliseconds) defined in the global setting [quota.activationrule.timeout]: [%s].", this.timeout); + this.timeoutDefaultMessage = String.format("Timeout (in milliseconds) defined in the global setting [%s]: [%s].", + configName, this.timeout); if (System.getProperty("nashorn.args") == null) { System.setProperty("nashorn.args", "--no-java --no-syntax-extensions"); diff --git a/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java b/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java index e6ef43f2fc1..e4764b2af41 100644 --- a/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java +++ b/utils/src/test/java/org/apache/cloudstack/utils/jsinterpreter/JsInterpreterTest.java @@ -161,7 +161,7 @@ public class JsInterpreterTest { @Test public void executeScriptTestValidScriptShouldPassWithMixedVariables() { - try (JsInterpreter jsInterpreter = new JsInterpreter(1000)) { + try (JsInterpreter jsInterpreter = new JsInterpreter(1000, "timeout.configuration")) { jsInterpreter.injectVariable("x", 10); jsInterpreter.injectVariable("y", "hello"); jsInterpreter.injectVariable("z", true); @@ -174,7 +174,7 @@ public class JsInterpreterTest { } private void runMaliciousScriptFileTest(String script, String filename) { - try (JsInterpreter jsInterpreter = new JsInterpreter(1000)) { + try (JsInterpreter jsInterpreter = new JsInterpreter(1000, "timeout.configuration")) { jsInterpreter.executeScript(script); } catch (CloudRuntimeException ex) { Assert.assertTrue(ex.getMessage().contains("Unable to execute script")); From ac6c1c800d9f5c7504296522267f5f12c8d27ade Mon Sep 17 00:00:00 2001 From: dahn Date: Tue, 16 Jun 2026 09:48:51 +0200 Subject: [PATCH 02/14] Add bhouse-nexthop to contributors list (#13420) --- .asf.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.asf.yaml b/.asf.yaml index 3b2f7691d13..df0dc3ab56c 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -57,6 +57,7 @@ github: - Damans227 - jmsperu - GaOrtiga + - bhouse-nexthop protected_branches: ~ From ce9793c0bef6e33034a1c23bbbca95d7adcccbd7 Mon Sep 17 00:00:00 2001 From: Pearl Dsilva Date: Tue, 16 Jun 2026 06:46:51 -0400 Subject: [PATCH 03/14] CLVM enhancements and fixes (#12617) This PR enhances the existing CLVM implementation which was based on the deprecated CLVM technology which was based on corosync/pacemaker. With RHEL 7 having reached EOL, CLVM seems to be broken. CLVM supports RAW volumes on LVM , where as CLVM_NG support QCOW2 on LVM. Further details: https://cwiki.apache.org/confluence/display/CLOUDSTACK/Modernized+CLVM%3A+Enhancements+and+CLVM_NG+support NOTE: On testing - it was identified that incremental snapshots for clvm-ng do not work as expected. As of now it's been removed from scope. So, CLVM and CLVM_NG would only support full snapshots. * add support for proper cleanup of snapshots and prevent vol snapshot of running vm * remove snap vol restriction for sunning vms * refactor clvm code * add support for live migration * add support for migrating lvm lock * clvm deletion called explicitly * made necessary changes to allow migration of lock and deletion of detached volumes * fix create vol from snap and attach * add support to revert snapshot for clvm * add support to revert snapshot for clvm * make zero fill configurable * make setting non-dynamic & fix test * fix locking at vol/vm creation * fix revert snapshot format type and handle revert snapshot functionality for clvm * 1. Create clvmlockmanager and move common code \n 2. handle attaching volumes to stopped VMs \n 3. Handle lock transfer when VM is started on another host * add license * remove command/answer classes from sonar coverage check * add support for new gen clvm with template (qcow2) backing * Add support for clvm_ng - which allows qcow2 on block storage , linked clones, etc * fix test and use physical size + 50% of virtual size for backing file, while virtual size + pe for disk * migrate clvm volumes as full clone and allow migration from clvm to nfs * fix clvm_ng to nfs migration, and handle overhead calc * support live migration from clvm_ng to nfs and vice-versa * add support to migrate to and from clvm to nfs * fix creation of volume on destination host during migration to clvm/clvm-ng * support live vm migration between clvm -> clvm-ng (vice-versa), nfs -> clvm (vice-versa) and nfs->clvm-ng (vice-versa) * add unit tests for clvm/clvm_ng operations * Add support for incremental volume snapshots for clvm_ng * prevent snapshot backup for incremental clvm_ng snaps, fix build failure, add unit tests * fix lockhost on creation of volumes from snap and fix bitmap issue when migrating a vol with incremental snap * restrict pre and post migration commands to only kvm hosts where vm has CLVM/CLVM-NG volumes * evist lock tracking - use lvs command to get lock host than DB * add test for pre/post migration * Create a CLVM storage adaptor * update existing clvm get stats method * fix precommit check failure * Apply suggestions from code review Co-authored-by: Suresh Kumar Anaparti * Apply suggestions from code review Co-authored-by: Suresh Kumar Anaparti * improve lock host retrieval logic and quicker retrival using db host as first check point and then fanning out * add proper support for resizing of clvm_ng which calculated PE correctly for qcow2 metadata * fallback to full snapshots for clvm-ng - incremental not supported in 4.23 * expunge volume detail of lock host on vm expunge * if vmmigration with volume is done to the same clvm volume group, then dont do data transfer, just lock transfer and vm * add clvm pools with deterministic uuid , so as to prevent adding the same pool twic * added a small improvement to factor in a senario when lv is inactive on all hosts, could happen in storage outage issue * address comment - extract common code for endpoint identification if clvm pool type * Address comments - add early return guard to reduce indentation * minor improvement - when migrating vm with volumes, if there's a failures, change the clvm vols to exclusive on source from shared, and on success, change dest vol to exclusive only for cross-pool migration * cleanup unused code and tests for incremental snaps for clvmng and other cleanups * allow storage browser to list lv in clvm, fix clvm shrink, overprovisioning factor isnt used for clvm pools - so set it to 1 and prevented display of provisioning type for clvm * no need to have locktransfercommand to execute in sequence * increase lv cmd timeouts to consider cluster load --------- Co-authored-by: Pearl Dsilva Co-authored-by: Suresh Kumar Anaparti --- .../main/java/com/cloud/storage/Storage.java | 1 + .../com/cloud/agent/api/MigrateCommand.java | 28 + .../cloud/agent/api/PostMigrationAnswer.java | 42 + .../cloud/agent/api/PostMigrationCommand.java | 59 + .../cloud/agent/api/PreMigrationCommand.java | 61 + .../clvm/command/ClvmLockTransferAnswer.java | 90 ++ .../clvm/command/ClvmLockTransferCommand.java | 99 ++ .../subsystem/api/storage/VolumeInfo.java | 17 + .../subsystem/api/storage/VolumeService.java | 68 + .../cloud/vm/VirtualMachineManagerImpl.java | 95 ++ .../orchestration/VolumeOrchestrator.java | 196 ++- ...ring-engine-orchestration-core-context.xml | 2 + .../vm/VirtualMachineManagerImplTest.java | 192 +++ .../orchestration/VolumeOrchestratorTest.java | 306 +++++ .../motion/AncientDataMotionStrategy.java | 32 + ...vmNonManagedStorageDataMotionStrategy.java | 24 +- .../StorageSystemDataMotionStrategy.java | 170 ++- .../motion/AncientDataMotionStrategyTest.java | 193 +++ ...NonManagedStorageSystemDataMotionTest.java | 4 + .../StorageSystemDataMotionStrategyTest.java | 282 +++++ .../snapshot/DefaultSnapshotStrategy.java | 33 + .../vmsnapshot/DefaultVMSnapshotStrategy.java | 29 + .../vmsnapshot/StorageVMSnapshotStrategy.java | 7 + .../snapshot/DefaultSnapshotStrategyTest.java | 233 ++++ .../DefaultVMSnapshotStrategyTest.java | 115 +- .../endpoint/DefaultEndPointSelector.java | 208 +++- .../endpoint/DefaultEndPointSelectorTest.java | 359 +++++- .../provider/DefaultHostListener.java | 13 + .../storage/volume/VolumeObject.java | 26 + .../storage/volume/VolumeServiceImpl.java | 173 +++ .../volume/VolumeServiceImplClvmTest.java | 520 ++++++++ .../resource/LibvirtComputingResource.java | 313 ++++- ...LibvirtClvmLockTransferCommandWrapper.java | 173 +++ .../wrapper/LibvirtMigrateCommandWrapper.java | 99 +- ...ibvirtModifyStoragePoolCommandWrapper.java | 12 +- .../LibvirtPostMigrationCommandWrapper.java | 82 ++ .../LibvirtPreMigrationCommandWrapper.java | 84 ++ ...virtPrepareForMigrationCommandWrapper.java | 14 + .../LibvirtResizeVolumeCommandWrapper.java | 3 +- .../LibvirtRevertSnapshotCommandWrapper.java | 2 +- .../kvm/storage/ClvmStorageAdaptor.java | 1071 ++++++++++++++++ .../kvm/storage/KVMStoragePool.java | 1 + .../kvm/storage/KVMStoragePoolManager.java | 49 +- .../kvm/storage/KVMStorageProcessor.java | 292 ++++- .../kvm/storage/LibvirtStorageAdaptor.java | 107 +- .../kvm/storage/LibvirtStoragePool.java | 10 +- .../kvm/storage/StorageAdaptor.java | 4 + .../LibvirtComputingResourceTest.java | 311 ++++- ...irtClvmLockTransferCommandWrapperTest.java | 462 +++++++ ...rtModifyStoragePoolCommandWrapperTest.java | 341 +++++ ...ibvirtPostMigrationCommandWrapperTest.java | 363 ++++++ ...LibvirtPreMigrationCommandWrapperTest.java | 282 +++++ .../kvm/storage/ClvmStorageAdaptorTest.java | 1104 +++++++++++++++++ .../kvm/storage/KVMStorageProcessorTest.java | 167 +++ .../storage/LibvirtStorageAdaptorTest.java | 171 +++ .../CloudStackPrimaryDataStoreDriverImpl.java | 67 +- ...oudStackPrimaryDataStoreLifeCycleImpl.java | 7 +- ...udStackPrimaryDataStoreDriverImplTest.java | 286 +++++ pom.xml | 4 + scripts/storage/qcow2/managesnapshot.sh | 176 ++- scripts/storage/qcow2/resizevolume.sh | 144 +++ .../api/query/dao/StoragePoolJoinDaoImpl.java | 13 +- .../api/query/dao/VolumeJoinDaoImpl.java | 7 +- .../com/cloud/storage/StorageManagerImpl.java | 5 + .../cloud/storage/VolumeApiServiceImpl.java | 224 +++- .../cloud/storage/clvm/ClvmPoolManager.java | 485 ++++++++ .../storage/snapshot/SnapshotManagerImpl.java | 26 +- .../java/com/cloud/vm/UserVmManagerImpl.java | 7 + .../vm/snapshot/VMSnapshotManagerImpl.java | 6 + .../spring-server-core-managers-context.xml | 2 + .../cloud/storage/ClvmPoolManagerTest.java | 936 ++++++++++++++ .../storage/VolumeApiServiceImplTest.java | 307 ++++- .../snapshot/SnapshotManagerImplTest.java | 59 + ui/src/views/infra/AddPrimaryStorage.vue | 16 +- .../main/java/com/cloud/utils/UriUtils.java | 33 + .../java/com/cloud/utils/UriUtilsTest.java | 176 +++ 76 files changed, 11955 insertions(+), 225 deletions(-) create mode 100644 core/src/main/java/com/cloud/agent/api/PostMigrationAnswer.java create mode 100644 core/src/main/java/com/cloud/agent/api/PostMigrationCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/PreMigrationCommand.java create mode 100644 core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferAnswer.java create mode 100644 core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferCommand.java create mode 100644 engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPostMigrationCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPreMigrationCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/ClvmStorageAdaptor.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtModifyStoragePoolCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPostMigrationCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPreMigrationCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/ClvmStorageAdaptorTest.java create mode 100644 plugins/storage/volume/default/src/test/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImplTest.java create mode 100644 server/src/main/java/com/cloud/storage/clvm/ClvmPoolManager.java create mode 100644 server/src/test/java/com/cloud/storage/ClvmPoolManagerTest.java diff --git a/api/src/main/java/com/cloud/storage/Storage.java b/api/src/main/java/com/cloud/storage/Storage.java index 5b3e97698fd..ddf5978497b 100644 --- a/api/src/main/java/com/cloud/storage/Storage.java +++ b/api/src/main/java/com/cloud/storage/Storage.java @@ -170,6 +170,7 @@ public class Storage { ISO(false, false, EncryptionSupport.Unsupported), // for iso image LVM(false, false, EncryptionSupport.Unsupported), // XenServer local LVM SR CLVM(true, false, EncryptionSupport.Unsupported), + CLVM_NG(true, false, EncryptionSupport.Hypervisor), RBD(true, true, EncryptionSupport.Unsupported), // http://libvirt.org/storage.html#StorageBackendRBD SharedMountPoint(true, true, EncryptionSupport.Hypervisor), VMFS(true, true, EncryptionSupport.Unsupported), // VMware VMFS storage diff --git a/core/src/main/java/com/cloud/agent/api/MigrateCommand.java b/core/src/main/java/com/cloud/agent/api/MigrateCommand.java index 5ac4e9ae445..7196247ffc2 100644 --- a/core/src/main/java/com/cloud/agent/api/MigrateCommand.java +++ b/core/src/main/java/com/cloud/agent/api/MigrateCommand.java @@ -26,6 +26,7 @@ import java.util.Map; import com.cloud.agent.api.to.DpdkTO; import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.storage.Storage; public class MigrateCommand extends Command { private String vmName; @@ -42,6 +43,7 @@ public class MigrateCommand extends Command { private Map dpdkInterfaceMapping = new HashMap<>(); private int newVmCpuShares; + private boolean clvmCrossPoolMigration; Map vlanToPersistenceMap = new HashMap<>(); @@ -149,6 +151,14 @@ public class MigrateCommand extends Command { this.newVmCpuShares = newVmCpuShares; } + public boolean isClvmCrossPoolMigration() { + return clvmCrossPoolMigration; + } + + public void setClvmCrossPoolMigration(boolean clvmCrossPoolMigration) { + this.clvmCrossPoolMigration = clvmCrossPoolMigration; + } + public static class MigrateDiskInfo { public enum DiskType { FILE, BLOCK; @@ -184,6 +194,8 @@ public class MigrateCommand extends Command { private final String sourceText; private final String backingStoreText; private boolean isSourceDiskOnStorageFileSystem; + private Storage.StoragePoolType sourcePoolType; + private Storage.StoragePoolType destPoolType; public MigrateDiskInfo(final String serialNumber, final DiskType diskType, final DriverType driverType, final Source source, final String sourceText) { this.serialNumber = serialNumber; @@ -232,6 +244,22 @@ public class MigrateCommand extends Command { public void setSourceDiskOnStorageFileSystem(boolean isDiskOnFileSystemStorage) { this.isSourceDiskOnStorageFileSystem = isDiskOnFileSystemStorage; } + + public Storage.StoragePoolType getSourcePoolType() { + return sourcePoolType; + } + + public void setSourcePoolType(Storage.StoragePoolType sourcePoolType) { + this.sourcePoolType = sourcePoolType; + } + + public Storage.StoragePoolType getDestPoolType() { + return destPoolType; + } + + public void setDestPoolType(Storage.StoragePoolType destPoolType) { + this.destPoolType = destPoolType; + } } @Override diff --git a/core/src/main/java/com/cloud/agent/api/PostMigrationAnswer.java b/core/src/main/java/com/cloud/agent/api/PostMigrationAnswer.java new file mode 100644 index 00000000000..24fdf840202 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/PostMigrationAnswer.java @@ -0,0 +1,42 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.agent.api; + +/** + * Answer for PostMigrationCommand. + * Indicates success or failure of post-migration operations on the destination host. + */ +public class PostMigrationAnswer extends Answer { + + protected PostMigrationAnswer() { + } + + public PostMigrationAnswer(PostMigrationCommand cmd, String detail) { + super(cmd, false, detail); + } + + public PostMigrationAnswer(PostMigrationCommand cmd, Exception ex) { + super(cmd, ex); + } + + public PostMigrationAnswer(PostMigrationCommand cmd) { + super(cmd, true, null); + } +} diff --git a/core/src/main/java/com/cloud/agent/api/PostMigrationCommand.java b/core/src/main/java/com/cloud/agent/api/PostMigrationCommand.java new file mode 100644 index 00000000000..938000c3593 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/PostMigrationCommand.java @@ -0,0 +1,59 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.agent.api; + +import com.cloud.agent.api.to.VirtualMachineTO; + +/** + * PostMigrationCommand is sent to the destination host after a successful VM migration. + * It performs post-migration tasks such as: + * - Claiming exclusive locks on CLVM volumes (converting from shared to exclusive mode) + * - Other post-migration cleanup operations + */ +public class PostMigrationCommand extends Command { + private VirtualMachineTO vm; + private String vmName; + + protected PostMigrationCommand() { + } + + public PostMigrationCommand(VirtualMachineTO vm, String vmName) { + this.vm = vm; + this.vmName = vmName; + } + + public VirtualMachineTO getVirtualMachine() { + return vm; + } + + public String getVmName() { + return vmName; + } + + @Override + public boolean executeInSequence() { + return true; + } + + @Override + public boolean isBypassHostMaintenance() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/PreMigrationCommand.java b/core/src/main/java/com/cloud/agent/api/PreMigrationCommand.java new file mode 100644 index 00000000000..3ff30391eae --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/PreMigrationCommand.java @@ -0,0 +1,61 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.agent.api; + +import com.cloud.agent.api.to.VirtualMachineTO; + +/** + * PreMigrationCommand is sent to the source host before VM migration starts. + * It performs pre-migration tasks such as: + * - Converting CLVM volume exclusive locks to shared mode so destination host can access them + * - Other pre-migration preparation operations on the source host + * + * This command runs on the SOURCE host before PrepareForMigrationCommand runs on the DESTINATION host. + */ +public class PreMigrationCommand extends Command { + private VirtualMachineTO vm; + private String vmName; + + protected PreMigrationCommand() { + } + + public PreMigrationCommand(VirtualMachineTO vm, String vmName) { + this.vm = vm; + this.vmName = vmName; + } + + public VirtualMachineTO getVirtualMachine() { + return vm; + } + + public String getVmName() { + return vmName; + } + + @Override + public boolean executeInSequence() { + return true; + } + + @Override + public boolean isBypassHostMaintenance() { + return true; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferAnswer.java b/core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferAnswer.java new file mode 100644 index 00000000000..f3c43c400b2 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferAnswer.java @@ -0,0 +1,90 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.storage.clvm.command; + +import com.cloud.agent.api.Answer; + +/** + * Answer for ClvmLockTransferCommand, containing lock state information. + * This answer includes the current lock holder information when querying lock state. + */ +public class ClvmLockTransferAnswer extends Answer { + + private String currentLockHostname; + private boolean isActive; + private boolean isOpen; + private String lvAttributes; + + public ClvmLockTransferAnswer(ClvmLockTransferCommand cmd, boolean result, String details) { + super(cmd, result, details); + } + + public ClvmLockTransferAnswer(ClvmLockTransferCommand cmd, boolean result, String details, + String currentLockHostname, boolean isActive, boolean isOpen, + String lvAttributes) { + super(cmd, result, details); + this.currentLockHostname = currentLockHostname; + this.isActive = isActive; + this.isOpen = isOpen; + this.lvAttributes = lvAttributes; + } + + /** + * Get the hostname from lv_host. Retained for diagnostics only — + * do NOT use this to determine lock holder identity. + */ + public String getCurrentLockHostname() { + return currentLockHostname; + } + + public void setCurrentLockHostname(String currentLockHostname) { + this.currentLockHostname = currentLockHostname; + } + + /** + * Whether the LV is locally active on the queried host (lv_attr[4]=='a'). + * This is the authoritative signal for lock holder discovery via fan-out. + */ + public boolean isActive() { + return isActive; + } + + public void setActive(boolean active) { + isActive = active; + } + + /** + * Whether a process has the device file open on the queried host (lv_attr[5]=='o'). + * true means a VM is actively doing I/O on this host right now — do NOT deactivate. + */ + public boolean isOpen() { + return isOpen; + } + + public void setOpen(boolean open) { + isOpen = open; + } + + public String getLvAttributes() { + return lvAttributes; + } + + public void setLvAttributes(String lvAttributes) { + this.lvAttributes = lvAttributes; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferCommand.java b/core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferCommand.java new file mode 100644 index 00000000000..218fb798869 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/storage/clvm/command/ClvmLockTransferCommand.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.storage.clvm.command; + +import com.cloud.agent.api.Command; + +/** + * Command to transfer CLVM (Clustered LVM) exclusive lock between hosts. + * This enables lightweight volume migration for CLVM storage pools where volumes + * reside in the same Volume Group (VG) but need to be accessed from different hosts. + * + *

Instead of copying volume data (traditional migration), this command simply + * deactivates the LV on the source host and activates it exclusively on the destination host. + * + *

This is significantly faster (10-100x) than traditional migration and uses no network bandwidth. + */ +public class ClvmLockTransferCommand extends Command { + + /** + * Operation to perform on the CLVM volume. + * Maps to lvchange flags for LVM operations. + */ + public enum Operation { + /** Deactivate the volume on this host (-an) */ + DEACTIVATE("-an", "deactivate"), + + /** Activate the volume exclusively on this host (-aey) */ + ACTIVATE_EXCLUSIVE("-aey", "activate exclusively"), + + /** Activate the volume in shared mode on this host (-asy) */ + ACTIVATE_SHARED("-asy", "activate in shared mode"), + + /** Query the current lock state (lvs -o lv_attr,lv_host) */ + QUERY_LOCK_STATE("query", "query lock state"); + + private final String lvchangeFlag; + private final String description; + + Operation(String lvchangeFlag, String description) { + this.lvchangeFlag = lvchangeFlag; + this.description = description; + } + + public String getLvchangeFlag() { + return lvchangeFlag; + } + + public String getDescription() { + return description; + } + } + + private String lvPath; + private Operation operation; + private String volumeUuid; + + public ClvmLockTransferCommand() { + // For serialization + } + + public ClvmLockTransferCommand(Operation operation, String lvPath, String volumeUuid) { + this.operation = operation; + this.lvPath = lvPath; + this.volumeUuid = volumeUuid; + setWait(65); + } + + public String getLvPath() { + return lvPath; + } + + public Operation getOperation() { + return operation; + } + + public String getVolumeUuid() { + return volumeUuid; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeInfo.java b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeInfo.java index 8b017187076..4937edd33d1 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeInfo.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeInfo.java @@ -103,4 +103,21 @@ public interface VolumeInfo extends DownloadableDataInfo, Volume { List getCheckpointPaths(); Set getCheckpointImageStoreUrls(); + + /** + * Gets the destination host ID hint for CLVM volume creation. + * This is used to route volume creation commands to the specific host where the VM will be deployed. + * Only applicable for CLVM storage pools to avoid shared mode activation. + * + * @return The host ID where the volume should be created, or null if not set + */ + Long getDestinationHostId(); + + /** + * Sets the destination host ID hint for CLVM volume creation. + * This should be set before volume creation when the destination host is known. + * + * @param hostId The host ID where the volume should be created + */ + void setDestinationHostId(Long hostId); } diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeService.java index 682473ec94f..a7d82d0b962 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeService.java @@ -30,6 +30,7 @@ import com.cloud.exception.StorageAccessException; import com.cloud.host.Host; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.offering.DiskOffering; +import com.cloud.storage.Storage.StoragePoolType; import com.cloud.storage.Volume; import com.cloud.user.Account; import com.cloud.utils.Pair; @@ -123,4 +124,71 @@ public interface VolumeService { void checkAndRepairVolumeBasedOnConfig(DataObject dataObject, Host host); void validateChangeDiskOfferingEncryptionType(long existingDiskOfferingId, long newDiskOfferingId); + + /** + * Transfers exclusive lock for a volume on cluster-based storage (e.g., CLVM/CLVM_NG) from one host to another. + * This is used for storage that requires host-level lock management for volumes on shared storage pools. + * For non-CLVM pool types, this method returns false without taking action. + * + * @param volume The volume to transfer lock for + * @param sourceHostId Host currently holding the exclusive lock + * @param destHostId Host to receive the exclusive lock + * @return true if lock transfer succeeded or was not needed, false if it failed + */ + boolean transferVolumeLock(VolumeInfo volume, Long sourceHostId, Long destHostId); + + /** + * Finds which host currently has the exclusive lock on a CLVM volume. + * Checks in order: explicit lock tracking, attached VM's host, or first available cluster host. + * + * @param volume The CLVM volume + * @return Host ID that has the exclusive lock, or null if cannot be determined + */ + Long findVolumeLockHost(VolumeInfo volume); + + /** + * Performs lightweight CLVM lock migration for a volume to a target host. + * This transfers the LVM exclusive lock without copying data (CLVM volumes are on shared cluster storage). + * If the volume already has the lock on the destination host, no action is taken. + * + * @param volume The volume to migrate lock for + * @param destHostId Destination host ID + * @return Updated VolumeInfo after lock migration + */ + VolumeInfo performLockMigration(VolumeInfo volume, Long destHostId); + + /** + * Checks if both storage pools are CLVM type (CLVM or CLVM_NG). + * + * @param volumePoolType Storage pool type for the volume + * @param vmPoolType Storage pool type for the VM + * @return true if both pools are CLVM type (CLVM or CLVM_NG) + */ + boolean areBothPoolsClvmType(StoragePoolType volumePoolType, StoragePoolType vmPoolType); + + /** + * Determines if CLVM lock transfer is required when a volume is already on the correct storage pool. + * + * @param volumeToAttach The volume being attached + * @param volumePoolType Storage pool type for the volume + * @param vmPoolType Storage pool type for the VM's existing volume + * @param volumePoolId Storage pool ID for the volume + * @param vmPoolId Storage pool ID for the VM's existing volume + * @param vmHostId VM's current host ID (or last host ID if stopped) + * @return true if CLVM lock transfer is needed + */ + boolean isLockTransferRequired(VolumeInfo volumeToAttach, StoragePoolType volumePoolType, StoragePoolType vmPoolType, + Long volumePoolId, Long vmPoolId, Long vmHostId); + + /** + * Determines if lightweight CLVM migration is needed instead of full data copy. + * + * @param volumePoolType Storage pool type for the volume + * @param vmPoolType Storage pool type for the VM + * @param volumePoolPath Storage pool path for the volume + * @param vmPoolPath Storage pool path for the VM + * @return true if lightweight migration should be used + */ + boolean isLightweightMigrationNeeded(StoragePoolType volumePoolType, StoragePoolType vmPoolType, + String volumePoolPath, String vmPoolPath); } diff --git a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java index db50a5134d8..c27240823b1 100755 --- a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java @@ -50,6 +50,8 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; import javax.persistence.EntityExistsException; +import com.cloud.agent.api.PostMigrationCommand; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.hypervisor.KVMGuru; import org.apache.cloudstack.affinity.dao.AffinityGroupVMMapDao; import org.apache.cloudstack.annotation.AnnotationService; @@ -136,6 +138,7 @@ import com.cloud.agent.api.PrepareExternalProvisioningAnswer; import com.cloud.agent.api.PrepareExternalProvisioningCommand; import com.cloud.agent.api.PrepareForMigrationAnswer; import com.cloud.agent.api.PrepareForMigrationCommand; +import com.cloud.agent.api.PreMigrationCommand; import com.cloud.agent.api.RebootAnswer; import com.cloud.agent.api.RebootCommand; import com.cloud.agent.api.RecreateCheckpointsCommand; @@ -267,6 +270,7 @@ import com.cloud.storage.dao.StoragePoolHostDao; import com.cloud.storage.dao.VMTemplateDao; import com.cloud.storage.dao.VMTemplateZoneDao; import com.cloud.storage.dao.VolumeDao; +import com.cloud.storage.dao.VolumeDetailsDao; import com.cloud.storage.snapshot.SnapshotManager; import com.cloud.template.VirtualMachineTemplate; import com.cloud.user.Account; @@ -361,6 +365,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private VolumeDao _volsDao; @Inject + private VolumeDetailsDao _volsDetailsDao; + @Inject private HighAvailabilityManager _haMgr; @Inject private HostPodDao _podDao; @@ -463,6 +469,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac ExtensionsManager extensionsManager; @Inject ExtensionDetailsDao extensionDetailsDao; + @Inject + ClvmPoolManager clvmPoolManager; VmWorkJobHandlerProxy _jobHandlerProxy = new VmWorkJobHandlerProxy(this); @@ -3150,6 +3158,9 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac updateOverCommitRatioForVmProfile(profile, dest.getHost().getClusterId()); final VirtualMachineTO to = toVmTO(profile); + + executePreMigrationCommand(vm, to, srcHostId); + final PrepareForMigrationCommand pfmc = new PrepareForMigrationCommand(to); setVmNetworkDetails(vm, to); @@ -3281,6 +3292,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac logger.warn("Error while checking the vm {} on host {}", vm, dest.getHost(), e); } migrated = true; + executePostMigrationCommand(vm, to, dstHostId); } finally { if (!migrated) { logger.info("Migration was unsuccessful. Cleaning up: {}", vm); @@ -3320,6 +3332,30 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac } } + private void executePostMigrationCommand(VMInstanceVO vm, VirtualMachineTO to, long dstHostId) { + if (!(vm.getHypervisorType() == HypervisorType.KVM && hasClvmVolumes(vm.getId()))) { + return; + } + final String dstHostUuid = _hostDao.findById(dstHostId).getUuid(); + try { + logger.info("Executing post-migration tasks for VM {} with CLVM volumes on destination host {}", vm.getInstanceName(), dstHostUuid); + final PostMigrationCommand postMigrationCommand = new PostMigrationCommand(to, vm.getInstanceName()); + final Answer postMigrationAnswer = _agentMgr.send(dstHostId, postMigrationCommand); + + if (postMigrationAnswer == null || !postMigrationAnswer.getResult()) { + final String details = postMigrationAnswer != null ? postMigrationAnswer.getDetails() : "null answer returned"; + logger.warn("Post-migration tasks failed for VM {} on destination host {}: {}. Migration completed but some cleanup may be needed.", + vm.getInstanceName(), dstHostUuid, details); + } else { + logger.info("Successfully completed post-migration tasks for VM {} on destination host {}", vm.getInstanceName(), dstHostUuid); + } + } catch (Exception e) { + logger.warn("Exception during post-migration tasks for VM {} on destination host {}: {}. Migration completed but some cleanup may be needed.", + vm.getInstanceName(), dstHostUuid, e.getMessage(), e); + } + updateClvmLockHostForVmVolumes(vm.getId(), dstHostId); + } + /** * Create and set parameters for the {@link MigrateCommand} used in the migration and scaling of VMs. */ @@ -3366,6 +3402,27 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac _vmDao.persist(newVm); } + /** + * Updates CLVM_LOCK_HOST_ID for all CLVM volumes attached to a VM after VM migration. + * This ensures that subsequent operations on CLVM volumes are routed to the correct host. + * + * @param vmId The ID of the VM that was migrated + * @param destHostId The destination host ID where the VM now resides + */ + private void updateClvmLockHostForVmVolumes(long vmId, long destHostId) { + List volumes = _volsDao.findByInstance(vmId); + if (CollectionUtils.isEmpty(volumes)) { + return; + } + + for (VolumeVO volume : volumes) { + StoragePoolVO pool = _storagePoolDao.findById(volume.getPoolId()); + if (pool != null && ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + clvmPoolManager.setClvmLockHostId(volume.getId(), destHostId); + } + } + } + /** * We create the mapping of volumes and storage pool to migrate the VMs according to the information sent by the user. * If the user did not enter a complete mapping, the volumes that were left behind will be auto mapped using {@link #createStoragePoolMappingsForVolumes(VirtualMachineProfile, DataCenterDeployment, Map, List)} @@ -4922,6 +4979,12 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac volumeMgr.prepareForMigration(profile, dest); final VirtualMachineTO to = toVmTO(profile); + + // Step 1: Send PreMigrationCommand to source host to convert CLVM volumes to shared mode + // This must happen BEFORE PrepareForMigrationCommand on destination to avoid lock conflicts + executePreMigrationCommand(vm, to, srcHostId); + + // Step 2: Send PrepareForMigrationCommand to destination host final PrepareForMigrationCommand pfmc = new PrepareForMigrationCommand(to); ItWorkVO work = new ItWorkVO(UUID.randomUUID().toString(), _nodeId, State.Migrating, vm.getType(), vm.getId()); @@ -5006,6 +5069,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac } migrated = true; + executePostMigrationCommand(vm, to, dstHostId); } finally { if (!migrated) { logger.info("Migration was unsuccessful. Cleaning up: {}", vm); @@ -6441,6 +6505,37 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac return findClusterAndHostIdForVm(vm, false); } + private boolean hasClvmVolumes(long vmId) { + List volumes = _volsDao.findByInstance(vmId); + return volumes.stream() + .map(v -> _storagePoolDao.findById(v.getPoolId())) + .anyMatch(pool -> pool != null && ClvmPoolManager.isClvmPoolType(pool.getPoolType())); + } + + private void executePreMigrationCommand(VMInstanceVO vm, VirtualMachineTO to, long srcHostId) { + if (!(vm.getHypervisorType() == HypervisorType.KVM && hasClvmVolumes(vm.getId()))) { + return; + } + final String vmInstanceName = vm.getInstanceName(); + final String srcHostUuid = _hostDao.findById(srcHostId).getUuid(); + logger.info("Sending PreMigrationCommand to source host {} for VM {} with CLVM volumes", srcHostUuid, vmInstanceName); + final PreMigrationCommand preMigCmd = new PreMigrationCommand(to, vmInstanceName); + Answer preMigAnswer = null; + try { + preMigAnswer = _agentMgr.send(srcHostId, preMigCmd); + if (preMigAnswer == null || !preMigAnswer.getResult()) { + final String details = preMigAnswer != null ? preMigAnswer.getDetails() : "null answer returned"; + final String msg = "Failed to prepare source host for migration: " + details; + logger.error("Failed to prepare source host {} for migration of VM {}: {}", srcHostUuid, vmInstanceName, details); + throw new CloudRuntimeException(msg); + } + logger.info("Successfully prepared source host {} for migration of VM {}", srcHostUuid, vmInstanceName); + } catch (final AgentUnavailableException | OperationTimedoutException e) { + logger.error("Failed to send PreMigrationCommand to source host {}: {}", srcHostUuid, e.getMessage(), e); + throw new CloudRuntimeException("Failed to prepare source host for migration: " + e.getMessage(), e); + } + } + @Override public Pair findClusterAndHostIdForVm(long vmId) { VMInstanceVO vm = _vmDao.findById(vmId); diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index bf3985d3ce7..c4a6a9dd684 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -38,8 +38,10 @@ import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.agent.AgentManager; import com.cloud.deploy.DeploymentClusterPlanner; import com.cloud.exception.ResourceAllocationException; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.resourcelimit.ReservationHelper; import com.cloud.storage.DiskOfferingVO; import com.cloud.storage.VMTemplateVO; @@ -275,6 +277,10 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati ConfigurationDao configurationDao; @Inject VMInstanceDao vmInstanceDao; + @Inject + ClvmPoolManager clvmPoolManager; + @Inject + AgentManager _agentMgr; @Inject protected SnapshotHelper snapshotHelper; @@ -747,6 +753,17 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati logger.debug("Trying to create volume [{}] on storage pool [{}].", volumeToString, poolToString); DataStore store = dataStoreMgr.getDataStore(pool.getId(), DataStoreRole.Primary); + + // For CLVM pools, set the lock host hint so volume is created on the correct host + // This avoids the need for shared mode activation and improves performance + if (ClvmPoolManager.isClvmPoolType(pool.getPoolType()) && hostId != null) { + logger.info("CLVM pool detected. Setting lock host {} for volume {} to route creation to correct host", + hostId, volumeInfo.getUuid()); + volumeInfo.setDestinationHostId(hostId); + + clvmPoolManager.setClvmLockHostId(volumeInfo.getId(), hostId); + } + for (int i = 0; i < 2; i++) { // retry one more time in case of template reload is required for Vmware case AsyncCallFuture future = null; @@ -788,6 +805,122 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati return String.format("uuid: %s, name: %s", volume.getUuid(), volume.getName()); } + /** + * Updates the CLVM_LOCK_HOST_ID for a migrated volume if applicable. + * For CLVM volumes that are attached to a VM, this updates the lock host tracking + * to point to the VM's current host after volume migration. + * + * @param migratedVolume The volume that was migrated + * @param destPool The destination storage pool + * @param operationType Description of the operation (e.g., "migrated", "live-migrated") for logging + */ + private void updateClvmLockHostAfterMigration(Volume migratedVolume, StoragePool destPool, String operationType) { + if (migratedVolume == null || destPool == null) { + return; + } + + StoragePoolVO pool = _storagePoolDao.findById(destPool.getId()); + if (pool == null || !ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + return; + } + + if (migratedVolume.getInstanceId() == null) { + return; + } + + VMInstanceVO vm = vmInstanceDao.findById(migratedVolume.getInstanceId()); + if (vm == null || vm.getHostId() == null) { + return; + } + + clvmPoolManager.setClvmLockHostId(migratedVolume.getId(), vm.getHostId()); + logger.debug("Updated CLVM_LOCK_HOST_ID for {} volume {} to host {} where VM {} is running", + operationType, migratedVolume.getUuid(), vm.getHostId(), vm.getInstanceName()); + } + + /** + * Retrieves the CLVM lock host ID from any existing volume of the specified VM. + * This is useful when attaching a new volume to a stopped VM - we want to maintain + * consistency by using the same host that manages the VM's other CLVM volumes. + * + * @param vmId The ID of the VM + * @return The host ID if found, null otherwise + */ + private Long getClvmLockHostFromVmVolumes(Long vmId) { + if (vmId == null) { + return null; + } + + List vmVolumes = _volsDao.findByInstance(vmId); + if (vmVolumes == null || vmVolumes.isEmpty()) { + return null; + } + + for (VolumeVO volume : vmVolumes) { + if (volume.getPoolId() == null) { + continue; + } + + StoragePoolVO pool = _storagePoolDao.findById(volume.getPoolId()); + if (pool == null || !ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + continue; + } + Long lockHostId = clvmPoolManager.getClvmLockHostId( + volume.getId(), + volume.getUuid(), + volume.getPath(), + pool, + true + ); + if (lockHostId != null) { + logger.debug("Found actual CLVM lock host {} from volume {} of VM {} via LVM query", + lockHostId, volume.getUuid(), vmId); + return lockHostId; + } + } + + return null; + } + + private void transferClvmLocksForVmStart(List volumes, Long destHostId, VMInstanceVO vm) { + if (volumes == null || volumes.isEmpty() || destHostId == null) { + return; + } + + for (VolumeVO volume : volumes) { + if (volume.getPoolId() == null) { + continue; + } + + StoragePoolVO pool = _storagePoolDao.findById(volume.getPoolId()); + if (pool == null || !ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + continue; + } + + Long currentLockHost = clvmPoolManager.getClvmLockHostId( + volume.getId(), + volume.getUuid(), + volume.getPath(), + pool, + true + ); + + if (currentLockHost == null) { + clvmPoolManager.setClvmLockHostId(volume.getId(), destHostId); + } else if (!currentLockHost.equals(destHostId)) { + logger.info("CLVM volume {} is locked on host {} but VM {} starting on host {}. Transferring lock.", + volume.getUuid(), currentLockHost, vm.getInstanceName(), destHostId); + + if (!clvmPoolManager.transferClvmVolumeLock(volume.getUuid(), volume.getId(), + volume.getPath(), pool, currentLockHost, destHostId)) { + throw new CloudRuntimeException( + String.format("Failed to transfer CLVM lock for volume %s from host %s to host %s", + volume.getUuid(), currentLockHost, destHostId)); + } + } + } + } + public String getRandomVolumeName() { return UUID.randomUUID().toString(); } @@ -1206,10 +1339,22 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati Long clusterId = storagePool.getClusterId(); logger.trace("storage-pool {}/{} is associated with cluster {}",storagePool.getName(), storagePool.getUuid(), clusterId); Long hostId = vm.getHostId(); - if (hostId == null && storagePool.isLocal()) { - List poolHosts = storagePoolHostDao.listByPoolId(storagePool.getId()); - if (poolHosts.size() > 0) { - hostId = poolHosts.get(0).getHostId(); + if (hostId == null && (storagePool.isLocal() || ClvmPoolManager.isClvmPoolType(storagePool.getPoolType()))) { + if (ClvmPoolManager.isClvmPoolType(storagePool.getPoolType())) { + hostId = getClvmLockHostFromVmVolumes(vm.getId()); + if (hostId != null) { + logger.debug("Using CLVM lock host {} from VM {}'s existing volumes for new volume creation", + hostId, vm.getUuid()); + } + } + + if (hostId == null) { + List poolHosts = storagePoolHostDao.listByPoolId(storagePool.getId()); + if (!poolHosts.isEmpty()) { + hostId = poolHosts.get(0).getHostId(); + logger.debug("Selected host {} from storage pool {} for stopped VM {} volume creation", + hostId, storagePool.getUuid(), vm.getUuid()); + } } } @@ -1454,6 +1599,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati _snapshotDao.updateVolumeIds(vol.getId(), result.getVolume().getId()); _snapshotDataStoreDao.updateVolumeIds(vol.getId(), result.getVolume().getId()); } + + // For CLVM volumes attached to a VM, update the CLVM_LOCK_HOST_ID after migration + updateClvmLockHostAfterMigration(result.getVolume(), destPool, "migrated"); } return result.getVolume(); } catch (InterruptedException | ExecutionException e) { @@ -1479,6 +1627,10 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati logger.error("Volume [{}] migration failed due to [{}].", volToString, result.getResult()); return null; } + + // For CLVM volumes attached to a VM, update the CLVM_LOCK_HOST_ID after live migration + updateClvmLockHostAfterMigration(result.getVolume(), destPool, "live-migrated"); + return result.getVolume(); } catch (InterruptedException | ExecutionException e) { logger.error("Volume [{}] migration failed due to [{}].", volToString, e.getMessage()); @@ -1521,6 +1673,22 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati logger.error(msg); throw new CloudRuntimeException(msg); } + for (Map.Entry entry : volumeToPool.entrySet()) { + Volume volume = entry.getKey(); + StoragePool destPool = entry.getValue(); + StoragePoolVO srcPool = _storagePoolDao.findById(volume.getPoolId()); + if (srcPool != null && srcPool.getId() == destPool.getId() && + ClvmPoolManager.isClvmPoolType(srcPool.getPoolType())) { + if (!clvmPoolManager.transferClvmVolumeLock(volume.getUuid(), volume.getId(), + volume.getPath(), srcPool, srcHost.getId(), destHost.getId())) { + throw new CloudRuntimeException(String.format( + "Failed to transfer CLVM lock for volume [%s] to destination host [%s].", + volume.getUuid(), destHost.getId())); + } + } else { + updateClvmLockHostAfterMigration(volume, destPool, "vm-migrated"); + } + } } catch (InterruptedException | ExecutionException e) { logger.error("Failed to migrate VM [{}] along with its volumes due to [{}].", vm, e.getMessage()); logger.debug("Exception: ", e); @@ -1853,6 +2021,19 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati future = volService.createManagedStorageVolumeFromTemplateAsync(volume, destPool.getId(), templ, hostId); } else { + // For CLVM pools, set the destination host hint so volume is created on the correct host + // This avoids the need for shared mode activation and improves performance + StoragePoolVO poolVO = _storagePoolDao.findById(destPool.getId()); + if (poolVO != null && ClvmPoolManager.isClvmPoolType(poolVO.getPoolType())) { + Long hostId = vm.getVirtualMachine().getHostId(); + if (hostId != null) { + volume.setDestinationHostId(hostId); + clvmPoolManager.setClvmLockHostId(volume.getId(), hostId); + logger.info("CLVM pool detected during volume creation from template. Setting lock host {} for volume {} (persisted to DB) to route creation to correct host", + hostId, volume.getUuid()); + } + } + future = volService.createVolumeFromTemplateAsync(volume, destPool.getId(), templ); } } @@ -1976,13 +2157,18 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati throw new CloudRuntimeException(msg); } - // don't allow to start vm that doesn't have a root volume if (_volsDao.findByInstanceAndType(vm.getId(), Volume.Type.ROOT).isEmpty()) { throw new CloudRuntimeException(String.format("ROOT volume is missing, unable to prepare volumes for the VM [%s].", vm.getVirtualMachine())); } List vols = _volsDao.findUsableVolumesForInstance(vm.getId()); + VirtualMachine vmInstance = vm.getVirtualMachine(); + VMInstanceVO vmInstanceVO = vmInstanceDao.findById(vmInstance.getId()); + if (vmInstance.getState() == State.Starting && dest.getHost() != null) { + transferClvmLocksForVmStart(vols, dest.getHost().getId(), vmInstanceVO); + } + List tasks = getTasks(vols, dest.getStorageForDisks(), vm); Volume vol = null; PrimaryDataStore store; diff --git a/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml b/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml index 49c668f50e8..8f93ae5b35a 100644 --- a/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml +++ b/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml @@ -44,6 +44,8 @@ value="#{storagePoolAllocatorsRegistry.registered}" /> + + (), destHostId, vmInstance); + + Mockito.verify(clvmPoolManager, Mockito.never()).getClvmLockHostId(Mockito.anyLong(), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.anyBoolean()); + } + + @Test + public void testTransferClvmLocksForVmStart_NullPoolId() throws Exception { + Long destHostId = 2L; + + VolumeVO volumeWithoutPool = Mockito.mock(VolumeVO.class); + Mockito.when(volumeWithoutPool.getPoolId()).thenReturn(null); + + VMInstanceVO vmInstance = Mockito.mock(VMInstanceVO.class); + + ClvmPoolManager clvmPoolManager = Mockito.mock(ClvmPoolManager.class); + setField(volumeOrchestrator, "clvmPoolManager", clvmPoolManager); + setField(volumeOrchestrator, "_storagePoolDao", storagePoolDao); + + Method method = VolumeOrchestrator.class.getDeclaredMethod( + "transferClvmLocksForVmStart", List.class, Long.class, VMInstanceVO.class); + method.setAccessible(true); + + method.invoke(volumeOrchestrator, List.of(volumeWithoutPool), destHostId, vmInstance); + + Mockito.verify(storagePoolDao, Mockito.never()).findById(Mockito.anyLong()); + } + + @Test + public void testTransferClvmLocksForVmStart_SetInitialLockHost() throws Exception { + Long destHostId = 2L; + Long poolId = 10L; + + VolumeVO clvmVolume = Mockito.mock(VolumeVO.class); + Mockito.when(clvmVolume.getId()).thenReturn(101L); + Mockito.when(clvmVolume.getPoolId()).thenReturn(poolId); + + StoragePoolVO clvmPool = Mockito.mock(StoragePoolVO.class); + Mockito.when(clvmPool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + + VMInstanceVO vmInstance = Mockito.mock(VMInstanceVO.class); + + ClvmPoolManager clvmPoolManager = Mockito.mock(ClvmPoolManager.class); + Mockito.when(clvmPoolManager.getClvmLockHostId(Mockito.eq(101L), ArgumentMatchers.nullable(String.class), + ArgumentMatchers.nullable(String.class), Mockito.any(), Mockito.eq(true))).thenReturn(null); + + Mockito.when(storagePoolDao.findById(poolId)).thenReturn(clvmPool); + + setField(volumeOrchestrator, "clvmPoolManager", clvmPoolManager); + setField(volumeOrchestrator, "_storagePoolDao", storagePoolDao); + + Method method = VolumeOrchestrator.class.getDeclaredMethod( + "transferClvmLocksForVmStart", List.class, Long.class, VMInstanceVO.class); + method.setAccessible(true); + + method.invoke(volumeOrchestrator, List.of(clvmVolume), destHostId, vmInstance); + + Mockito.verify(clvmPoolManager, Mockito.times(1)).setClvmLockHostId(101L, destHostId); + Mockito.verify(clvmPoolManager, Mockito.never()).transferClvmVolumeLock( + Mockito.anyString(), Mockito.anyLong(), Mockito.anyString(), + Mockito.any(), Mockito.anyLong(), Mockito.anyLong()); + } + + @Test + public void testTransferClvmLocksForVmStart_MixedVolumes() throws Exception { + Long destHostId = 2L; + Long currentHostId = 1L; + Long clvmPoolId = 10L; + Long nfsPoolId = 20L; + + VolumeVO clvmVolume = Mockito.mock(VolumeVO.class); + Mockito.when(clvmVolume.getId()).thenReturn(101L); + Mockito.when(clvmVolume.getPoolId()).thenReturn(clvmPoolId); + Mockito.when(clvmVolume.getUuid()).thenReturn("clvm-vol-uuid"); + Mockito.when(clvmVolume.getPath()).thenReturn("clvm-vol-path"); + + VolumeVO nfsVolume = Mockito.mock(VolumeVO.class); + Mockito.when(nfsVolume.getPoolId()).thenReturn(nfsPoolId); + + StoragePoolVO clvmPool = Mockito.mock(StoragePoolVO.class); + Mockito.when(clvmPool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + + StoragePoolVO nfsPool = Mockito.mock(StoragePoolVO.class); + Mockito.when(nfsPool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + + VMInstanceVO vmInstance = Mockito.mock(VMInstanceVO.class); + Mockito.when(vmInstance.getInstanceName()).thenReturn(MOCK_VM_NAME); + + ClvmPoolManager clvmPoolManager = Mockito.mock(ClvmPoolManager.class); + Mockito.when(clvmPoolManager.getClvmLockHostId(Mockito.eq(101L), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.eq(true))).thenReturn(currentHostId); + Mockito.when(clvmPoolManager.transferClvmVolumeLock(Mockito.anyString(), Mockito.anyLong(), + Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyLong())).thenReturn(true); + + Mockito.when(storagePoolDao.findById(clvmPoolId)).thenReturn(clvmPool); + Mockito.when(storagePoolDao.findById(nfsPoolId)).thenReturn(nfsPool); + + setField(volumeOrchestrator, "clvmPoolManager", clvmPoolManager); + setField(volumeOrchestrator, "_storagePoolDao", storagePoolDao); + + Method method = VolumeOrchestrator.class.getDeclaredMethod( + "transferClvmLocksForVmStart", List.class, Long.class, VMInstanceVO.class); + method.setAccessible(true); + + method.invoke(volumeOrchestrator, List.of(clvmVolume, nfsVolume), destHostId, vmInstance); + + Mockito.verify(clvmPoolManager, Mockito.times(1)).transferClvmVolumeLock( + Mockito.eq("clvm-vol-uuid"), Mockito.eq(101L), Mockito.eq("clvm-vol-path"), + Mockito.eq(clvmPool), Mockito.eq(currentHostId), Mockito.eq(destHostId)); + } + + @Test(expected = CloudRuntimeException.class) + public void testTransferClvmLocksForVmStart_TransferFails() throws Throwable { + Long destHostId = 2L; + Long currentHostId = 1L; + Long poolId = 10L; + + VolumeVO clvmVolume = Mockito.mock(VolumeVO.class); + Mockito.when(clvmVolume.getId()).thenReturn(101L); + Mockito.when(clvmVolume.getPoolId()).thenReturn(poolId); + Mockito.when(clvmVolume.getUuid()).thenReturn("vol-uuid"); + Mockito.when(clvmVolume.getPath()).thenReturn("vol-path"); + + StoragePoolVO clvmPool = Mockito.mock(StoragePoolVO.class); + Mockito.when(clvmPool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + + VMInstanceVO vmInstance = Mockito.mock(VMInstanceVO.class); + Mockito.when(vmInstance.getInstanceName()).thenReturn(MOCK_VM_NAME); + + ClvmPoolManager clvmPoolManager = Mockito.mock(ClvmPoolManager.class); + Mockito.when(clvmPoolManager.getClvmLockHostId(Mockito.eq(101L), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.eq(true))).thenReturn(currentHostId); + Mockito.when(clvmPoolManager.transferClvmVolumeLock(Mockito.anyString(), Mockito.anyLong(), + Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyLong())).thenReturn(false); + + Mockito.when(storagePoolDao.findById(poolId)).thenReturn(clvmPool); + + setField(volumeOrchestrator, "clvmPoolManager", clvmPoolManager); + setField(volumeOrchestrator, "_storagePoolDao", storagePoolDao); + + Method method = VolumeOrchestrator.class.getDeclaredMethod( + "transferClvmLocksForVmStart", List.class, Long.class, VMInstanceVO.class); + method.setAccessible(true); + + try { + method.invoke(volumeOrchestrator, List.of(clvmVolume), destHostId, vmInstance); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = findField(target.getClass(), fieldName); + if (field == null) { + throw new NoSuchFieldException("Field " + fieldName + " not found in " + target.getClass()); + } + field.setAccessible(true); + field.set(target, value); + } + + private Field findField(Class clazz, String fieldName) { + Class current = clazz; + while (current != null && current != Object.class) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + return null; + } + } diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java index 8145158dfa4..95362f44b13 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java @@ -27,6 +27,7 @@ import java.util.Objects; import javax.inject.Inject; import com.cloud.agent.api.to.DiskTO; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.storage.Storage; import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; import org.apache.cloudstack.engine.subsystem.api.storage.CopyCommandResult; @@ -75,6 +76,7 @@ import com.cloud.storage.ScopeType; import com.cloud.storage.Snapshot.Type; import com.cloud.storage.SnapshotVO; import com.cloud.storage.StorageManager; +import com.cloud.storage.Storage.ImageFormat; import com.cloud.storage.Storage.StoragePoolType; import com.cloud.storage.StoragePool; import com.cloud.storage.VolumeVO; @@ -108,6 +110,8 @@ public class AncientDataMotionStrategy implements DataMotionStrategy { StorageCacheManager cacheMgr; @Inject VolumeDataStoreDao volumeDataStoreDao; + @Inject + ClvmPoolManager clvmPoolManager; @Inject StorageManager storageManager; @@ -309,6 +313,8 @@ public class AncientDataMotionStrategy implements DataMotionStrategy { ep = selector.select(srcData, volObj); } + updateLockHostForVolume(ep, volObj); + CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(volObj.getTO()), _createVolumeFromSnapshotWait, VirtualMachineManager.ExecuteInSequence.value()); Answer answer = null; @@ -331,6 +337,29 @@ public class AncientDataMotionStrategy implements DataMotionStrategy { } } + private void updateLockHostForVolume(EndPoint ep, DataObject volObj) { + if (ep == null || !(volObj instanceof VolumeInfo)) { + return; + } + VolumeInfo volumeInfo = (VolumeInfo) volObj; + StoragePool destPool = (StoragePool) volObj.getDataStore(); + if (destPool == null || !ClvmPoolManager.isClvmPoolType(destPool.getPoolType())) { + return; + } + Long hostId = ep.getId(); + Long existingHostId = clvmPoolManager.getClvmLockHostId( + volumeInfo.getId(), + volumeInfo.getUuid(), + volumeInfo.getPath(), + destPool, + true + ); + if (existingHostId == null) { + clvmPoolManager.setClvmLockHostId(volumeInfo.getId(), hostId); + logger.debug("Set lock host ID {} for CLVM volume {} being created from snapshot", hostId, volumeInfo.getId()); + } + } + protected Answer cloneVolume(DataObject template, DataObject volume) { CopyCommand cmd = new CopyCommand(template.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(volume.getTO()), 0, VirtualMachineManager.ExecuteInSequence.value()); try { @@ -581,6 +610,9 @@ public class AncientDataMotionStrategy implements DataMotionStrategy { volumeVo.setPoolId(destPool.getId()); volumeVo.setPoolType(destPool.getPoolType()); volumeVo.setLastPoolId(oldPoolId); + if (destPool.getPoolType() == StoragePoolType.CLVM) { + volumeVo.setFormat(ImageFormat.RAW); + } // For SMB, pool credentials are also stored in the uri query string. We trim the query string // part here to make sure the credentials do not get stored in the db unencrypted. String folder = destPool.getPath(); diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageDataMotionStrategy.java index 947b4af8f69..867470dac04 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageDataMotionStrategy.java @@ -144,12 +144,16 @@ public class KvmNonManagedStorageDataMotionStrategy extends StorageSystemDataMot } /** - * Configures a {@link MigrateDiskInfo} object configured for migrating a File System volume and calls rootImageProvisioning. + * Configures a {@link MigrateDiskInfo} object configured for migrating a File System volume. */ @Override protected MigrateCommand.MigrateDiskInfo configureMigrateDiskInfo(VolumeInfo srcVolumeInfo, String destPath, String backingPath) { - return new MigrateCommand.MigrateDiskInfo(srcVolumeInfo.getPath(), MigrateCommand.MigrateDiskInfo.DiskType.FILE, MigrateCommand.MigrateDiskInfo.DriverType.QCOW2, - MigrateCommand.MigrateDiskInfo.Source.FILE, destPath, backingPath); + return new MigrateCommand.MigrateDiskInfo(srcVolumeInfo.getPath(), + MigrateCommand.MigrateDiskInfo.DiskType.FILE, + MigrateCommand.MigrateDiskInfo.DriverType.QCOW2, + MigrateCommand.MigrateDiskInfo.Source.FILE, + destPath, + backingPath); } /** @@ -158,6 +162,17 @@ public class KvmNonManagedStorageDataMotionStrategy extends StorageSystemDataMot */ @Override protected String generateDestPath(Host destHost, StoragePoolVO destStoragePool, VolumeInfo destVolumeInfo) { + if (destStoragePool.getPoolType() == StoragePoolType.CLVM || destStoragePool.getPoolType() == StoragePoolType.CLVM_NG) { + String vgName = destStoragePool.getPath(); + if (StringUtils.isBlank(vgName)) { + throw new CloudRuntimeException(String.format("CLVM/CLVM_NG destination pool [%s] has empty VG path", destStoragePool.getUuid())); + } + if (vgName.startsWith("/")) { + vgName = vgName.substring(1); + } + return String.format("/dev/%s/%s", vgName, destVolumeInfo.getUuid()); + } + return new File(destStoragePool.getPath(), destVolumeInfo.getUuid()).getAbsolutePath(); } @@ -285,6 +300,7 @@ public class KvmNonManagedStorageDataMotionStrategy extends StorageSystemDataMot } protected Boolean supportStoragePoolType(StoragePoolType storagePoolType) { - return super.supportStoragePoolType(storagePoolType, StoragePoolType.Filesystem); + return super.supportStoragePoolType(storagePoolType, StoragePoolType.Filesystem, + StoragePoolType.CLVM, StoragePoolType.CLVM_NG); } } diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java index bcade3a371c..2c791d05980 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java @@ -36,6 +36,8 @@ import com.cloud.agent.api.CheckVirtualMachineAnswer; import com.cloud.agent.api.CheckVirtualMachineCommand; import com.cloud.agent.api.PrepareForMigrationAnswer; import com.cloud.resource.ResourceManager; +import com.cloud.storage.clvm.ClvmPoolManager; +import org.apache.cloudstack.storage.clvm.command.ClvmLockTransferCommand; import org.apache.cloudstack.engine.subsystem.api.storage.ChapInfo; import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; import org.apache.cloudstack.engine.subsystem.api.storage.CopyCommandResult; @@ -87,6 +89,7 @@ import com.cloud.agent.api.MigrateCommand; import com.cloud.agent.api.MigrateCommand.MigrateDiskInfo; import com.cloud.agent.api.ModifyTargetsAnswer; import com.cloud.agent.api.ModifyTargetsCommand; +import com.cloud.agent.api.PreMigrationCommand; import com.cloud.agent.api.PrepareForMigrationCommand; import com.cloud.agent.api.storage.CopyVolumeAnswer; import com.cloud.agent.api.storage.CopyVolumeCommand; @@ -206,6 +209,8 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { private VolumeDataFactory _volFactory; @Inject ResourceManager resourceManager; + @Inject + private ClvmPoolManager clvmPoolManager; @Override public StrategyPriority canHandle(DataObject srcData, DataObject destData) { @@ -2023,6 +2028,7 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { String errMsg = null; boolean success = false; Map srcVolumeInfoToDestVolumeInfo = new HashMap<>(); + List samePoolClvmVolumes = new ArrayList<>(); try { if (srcHost.getHypervisorType() != HypervisorType.KVM) { @@ -2052,6 +2058,13 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { continue; } + if (sourceStoragePool.getId() == destStoragePool.getId() && + ClvmPoolManager.isClvmPoolType(destStoragePool.getPoolType())) { + logger.info("Same-pool CLVM migration for volume [{}]: skipping data copy.", srcVolumeInfo.getUuid()); + samePoolClvmVolumes.add(srcVolumeInfo); + continue; + } + if (!shouldMigrateVolume(sourceStoragePool, destHost, destStoragePool)) { continue; } @@ -2071,6 +2084,13 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { setVolumeMigrationOptions(srcVolumeInfo, destVolumeInfo, vmTO, srcHost, destStoragePool, migrationType); + if (ClvmPoolManager.isClvmPoolType(destStoragePool.getPoolType())) { + destVolumeInfo.setDestinationHostId(destHost.getId()); + clvmPoolManager.setClvmLockHostId(destVolume.getId(), destHost.getId()); + logger.info("Set CLVM lock host {} for volume {} during migration to ensure creation on destination host", + destHost.getId(), destVolumeInfo.getUuid()); + } + // create a volume on the destination storage destDataStore.getDriver().createAsync(destDataStore, destVolumeInfo, null); @@ -2096,7 +2116,7 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { MigrateCommand.MigrateDiskInfo migrateDiskInfo; - boolean isNonManagedToNfs = supportStoragePoolType(sourceStoragePool.getPoolType(), StoragePoolType.Filesystem) && destStoragePool.getPoolType() == StoragePoolType.NetworkFilesystem && !managedStorageDestination; + boolean isNonManagedToNfs = supportStoragePoolType(sourceStoragePool.getPoolType(), StoragePoolType.Filesystem, StoragePoolType.CLVM, StoragePoolType.CLVM_NG) && destStoragePool.getPoolType() == StoragePoolType.NetworkFilesystem && !managedStorageDestination; if (isNonManagedToNfs) { migrateDiskInfo = new MigrateCommand.MigrateDiskInfo(srcVolumeInfo.getPath(), MigrateCommand.MigrateDiskInfo.DiskType.FILE, @@ -2106,9 +2126,12 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { } else { String backingPath = generateBackingPath(destStoragePool, destVolumeInfo); migrateDiskInfo = configureMigrateDiskInfo(srcVolumeInfo, destPath, backingPath); + migrateDiskInfo = updateMigrateDiskInfoForBlockDevice(migrateDiskInfo, destStoragePool); migrateDiskInfo.setSourceDiskOnStorageFileSystem(isStoragePoolTypeOfFile(sourceStoragePool)); migrateDiskInfoList.add(migrateDiskInfo); } + migrateDiskInfo.setSourcePoolType(sourceStoragePool.getPoolType()); + migrateDiskInfo.setDestPoolType(destVolumeInfo.getStoragePoolType()); prepareDiskWithSecretConsumerDetail(vmTO, srcVolumeInfo, destVolumeInfo.getPath()); migrateStorage.put(srcVolumeInfo.getPath(), migrateDiskInfo); @@ -2116,6 +2139,8 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { srcVolumeInfoToDestVolumeInfo.put(srcVolumeInfo, destVolumeInfo); } + prepareDisksForMigrationForClvm(vmTO, volumeDataStoreMap, srcHost); + PrepareForMigrationCommand pfmc = new PrepareForMigrationCommand(vmTO); Answer pfma; @@ -2132,6 +2157,25 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { throw new AgentUnavailableException("Operation timed out", destHost.getId()); } + for (VolumeInfo vol : samePoolClvmVolumes) { + StoragePoolVO samePoolClvmPool = _storagePoolDao.findById(vol.getPoolId()); + String vgName = samePoolClvmPool.getPath(); + if (vgName.startsWith("/")) { + vgName = vgName.substring(1); + } + String lvPath = String.format("/dev/%s/%s", vgName, vol.getPath()); + logger.info("Activating CLVM volume [{}] in shared mode on dest host [{}] for same-pool migration.", + vol.getUuid(), destHost.getId()); + Answer activateAnswer = agentManager.send(destHost.getId(), + new ClvmLockTransferCommand(ClvmLockTransferCommand.Operation.ACTIVATE_SHARED, lvPath, vol.getUuid())); + if (activateAnswer == null || !activateAnswer.getResult()) { + throw new CloudRuntimeException(String.format( + "Failed to activate CLVM volume [%s] in shared mode on dest host [%s]: %s", + vol.getUuid(), destHost.getId(), + activateAnswer != null ? activateAnswer.getDetails() : "null answer")); + } + } + VMInstanceVO vm = _vmDao.findById(vmTO.getId()); boolean isWindows = _guestOsCategoryDao.findById(_guestOsDao.findById(vm.getGuestOSId()).getCategoryId()).getName().equalsIgnoreCase("Windows"); @@ -2141,6 +2185,9 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { migrateCommand.setMigrateDiskInfoList(migrateDiskInfoList); migrateCommand.setMigrateStorageManaged(managedStorageDestination); migrateCommand.setMigrateNonSharedInc(migrateNonSharedInc); + boolean hasClvmCrossPoolVolume = migrateStorage.values().stream() + .anyMatch(info -> ClvmPoolManager.isClvmPoolType(info.getSourcePoolType())); + migrateCommand.setClvmCrossPoolMigration(hasClvmCrossPoolVolume); Integer newVmCpuShares = ((PrepareForMigrationAnswer) pfma).getNewVmCpuShares(); if (newVmCpuShares != null) { @@ -2171,7 +2218,7 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { } } - handlePostMigration(success, srcVolumeInfoToDestVolumeInfo, vmTO, destHost); + handlePostMigration(success, srcVolumeInfoToDestVolumeInfo, vmTO, srcHost, destHost); if (!success) { if (migrateAnswer == null) { @@ -2211,10 +2258,43 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { } } + private void prepareDisksForMigrationForClvm(VirtualMachineTO vmTO, Map volumeDataStoreMap, Host srcHost) { + // For CLVM/CLVM_NG source pools, convert volumes from exclusive to shared mode + // on the source host BEFORE PrepareForMigrationCommand on the destination. + boolean hasClvmSource = volumeDataStoreMap.keySet().stream() + .map(v -> _storagePoolDao.findById(v.getPoolId())) + .anyMatch(p -> p != null && (p.getPoolType() == StoragePoolType.CLVM || p.getPoolType() == StoragePoolType.CLVM_NG)); + if (hasClvmSource && srcHost.getHypervisorType() == HypervisorType.KVM) { + logger.info("CLVM/CLVM_NG source pool detected for VM [{}], sending PreMigrationCommand to source host [{}] to convert volumes to shared mode.", vmTO.getName(), srcHost.getId()); + PreMigrationCommand preMigCmd = new PreMigrationCommand(vmTO, vmTO.getName()); + try { + Answer preMigAnswer = agentManager.send(srcHost.getId(), preMigCmd); + if (preMigAnswer == null || !preMigAnswer.getResult()) { + String details = preMigAnswer != null ? preMigAnswer.getDetails() : "null answer returned"; + logger.warn("PreMigrationCommand failed for CLVM/CLVM_NG VM [{}] on source host [{}]: {}. Migration will continue but may fail if volumes are exclusively locked.", vmTO.getName(), srcHost.getId(), details); + } else { + logger.info("Successfully converted CLVM/CLVM_NG volumes to shared mode on source host [{}] for VM [{}].", srcHost.getId(), vmTO.getName()); + } + } catch (Exception e) { + logger.warn("Failed to send PreMigrationCommand to source host [{}] for VM [{}]: {}. Migration will continue but may fail if volumes are exclusively locked.", srcHost.getId(), vmTO.getName(), e.getMessage()); + } + } else if (hasClvmSource) { + logger.debug("Skipping PreMigrationCommand for non-KVM hypervisor type: {} on host [{}]", srcHost.getHypervisorType(), srcHost.getId()); + } + } + private MigrationOptions.Type decideMigrationTypeAndCopyTemplateIfNeeded(Host destHost, VMInstanceVO vmInstance, VolumeInfo srcVolumeInfo, StoragePoolVO sourceStoragePool, StoragePoolVO destStoragePool, DataStore destDataStore) { VMTemplateVO vmTemplate = _vmTemplateDao.findById(vmInstance.getTemplateId()); String srcVolumeBackingFile = getVolumeBackingFile(srcVolumeInfo); + + // Check if source is CLVM/CLVM_NG (block device storage) + // LinkedClone (VIR_MIGRATE_NON_SHARED_INC) only works for file → file migrations + // For block device sources, use FullClone (VIR_MIGRATE_NON_SHARED_DISK) + boolean sourceIsBlockDevice = sourceStoragePool.getPoolType() == StoragePoolType.CLVM || + sourceStoragePool.getPoolType() == StoragePoolType.CLVM_NG; + if (StringUtils.isNotBlank(srcVolumeBackingFile) && supportStoragePoolType(destStoragePool.getPoolType(), StoragePoolType.Filesystem) && + !sourceIsBlockDevice && srcVolumeInfo.getTemplateId() != null && Objects.nonNull(vmTemplate) && !Arrays.asList(KVM_VM_IMPORT_DEFAULT_TEMPLATE_NAME, VM_IMPORT_DEFAULT_TEMPLATE_NAME).contains(vmTemplate.getName())) { @@ -2222,8 +2302,12 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { copyTemplateToTargetFilesystemStorageIfNeeded(srcVolumeInfo, sourceStoragePool, destDataStore, destStoragePool, destHost); return MigrationOptions.Type.LinkedClone; } - logger.debug(String.format("Skipping copy template from source storage pool [%s] to target storage pool [%s] before migration due to volume [%s] does not have a " + - "template or we are doing full clone migration.", sourceStoragePool.getId(), destStoragePool.getId(), srcVolumeInfo.getId())); + + if (sourceIsBlockDevice) { + logger.debug(String.format("Source storage pool [%s] is block device (CLVM/CLVM_NG). Using FullClone migration for volume [%s] to target storage pool [%s]. Template copy skipped as entire volume will be copied.", sourceStoragePool.getId(), srcVolumeInfo.getId(), destStoragePool.getId())); + } else { + logger.debug(String.format("Skipping copy template from source storage pool [%s] to target storage pool [%s] before migration due to volume [%s] does not have a template or we are doing full clone migration.", sourceStoragePool.getId(), destStoragePool.getId(), srcVolumeInfo.getId())); + } return MigrationOptions.Type.FullClone; } @@ -2289,6 +2373,39 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { MigrateCommand.MigrateDiskInfo.Source.DEV, destPath, backingPath); } + /** + * UpdatesMigrateDiskInfo for CLVM/CLVM_NG block devices by returning a new instance with corrected disk type, driver type, and source. + * For CLVM/CLVM_NG destinations, returns a new MigrateDiskInfo with BLOCK disk type, DEV source, and appropriate driver type (QCOW2 for CLVM_NG, RAW for CLVM). + * For other storage types, returns the original MigrateDiskInfo unchanged. + * + * @param migrateDiskInfo The original MigrateDiskInfo object + * @param destStoragePool The destination storage pool + * @return A new MigrateDiskInfo with updated values for CLVM/CLVM_NG, or the original for other storage types + */ + protected MigrateCommand.MigrateDiskInfo updateMigrateDiskInfoForBlockDevice(MigrateCommand.MigrateDiskInfo migrateDiskInfo, + StoragePoolVO destStoragePool) { + if (ClvmPoolManager.isClvmPoolType(destStoragePool.getPoolType())) { + + MigrateCommand.MigrateDiskInfo.DriverType driverType = + (destStoragePool.getPoolType() == StoragePoolType.CLVM_NG) ? + MigrateCommand.MigrateDiskInfo.DriverType.QCOW2 : + MigrateCommand.MigrateDiskInfo.DriverType.RAW; + + logger.debug("Updating MigrateDiskInfo for {} destination: setting BLOCK disk type, DEV source, and {} driver type", + destStoragePool.getPoolType(), driverType); + + return new MigrateCommand.MigrateDiskInfo( + migrateDiskInfo.getSerialNumber(), + MigrateCommand.MigrateDiskInfo.DiskType.BLOCK, + driverType, + MigrateCommand.MigrateDiskInfo.Source.DEV, + migrateDiskInfo.getSourceText(), + migrateDiskInfo.getBackingStoreText()); + } + + return migrateDiskInfo; + } + /** * Sets the volume path as the iScsi name in case of a configured iScsi. */ @@ -2320,7 +2437,26 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { return null; } - private void handlePostMigration(boolean success, Map srcVolumeInfoToDestVolumeInfo, VirtualMachineTO vmTO, Host destHost) { + private void sendClvmLockCommand(long hostId, StoragePoolVO pool, VolumeInfo volumeInfo, + ClvmLockTransferCommand.Operation operation) { + String vgName = pool.getPath(); + if (vgName.startsWith("/")) { + vgName = vgName.substring(1); + } + String lvPath = String.format("/dev/%s/%s", vgName, volumeInfo.getPath()); + try { + Answer answer = agentManager.send(hostId, + new ClvmLockTransferCommand(operation, lvPath, volumeInfo.getUuid())); + if (answer == null || !answer.getResult()) { + String details = answer != null ? answer.getDetails() : "null answer"; + logger.warn("CLVM lock command [{}] failed for LV [{}] on host [{}]: {}", operation, lvPath, hostId, details); + } + } catch (AgentUnavailableException | OperationTimedoutException e) { + logger.warn("Exception sending CLVM lock command [{}] for LV [{}] on host [{}]: {}", operation, lvPath, hostId, e.getMessage()); + } + } + + private void handlePostMigration(boolean success, Map srcVolumeInfoToDestVolumeInfo, VirtualMachineTO vmTO, Host srcHost, Host destHost) { if (!success) { try { PrepareForMigrationCommand pfmc = new PrepareForMigrationCommand(vmTO); @@ -2339,6 +2475,17 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { catch (Exception e) { logger.debug("Failed to disconnect one or more (original) dest volumes", e); } + + if (srcHost != null && srcHost.getHypervisorType() == HypervisorType.KVM) { + for (VolumeInfo srcVolumeInfo : srcVolumeInfoToDestVolumeInfo.keySet()) { + StoragePoolVO srcPool = _storagePoolDao.findById(srcVolumeInfo.getPoolId()); + if (srcPool == null || !ClvmPoolManager.isClvmPoolType(srcPool.getPoolType())) { + continue; + } + sendClvmLockCommand(srcHost.getId(), srcPool, srcVolumeInfo, + ClvmLockTransferCommand.Operation.ACTIVATE_EXCLUSIVE); + } + } } for (Map.Entry entry : srcVolumeInfoToDestVolumeInfo.entrySet()) { @@ -2349,12 +2496,13 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { if (success) { VolumeVO volumeVO = _volumeDao.findById(destVolumeInfo.getId()); - volumeVO.setFormat(ImageFormat.QCOW2); + StoragePoolVO srcPoolVO = _storagePoolDao.findById(srcVolumeInfo.getPoolId()); + StoragePoolVO destPoolVO = _storagePoolDao.findById(destVolumeInfo.getPoolId()); + volumeVO.setFormat(destPoolVO != null && destPoolVO.getPoolType() == StoragePoolType.CLVM + ? ImageFormat.RAW : ImageFormat.QCOW2); volumeVO.setLastId(srcVolumeInfo.getId()); if (Objects.equals(srcVolumeInfo.getDiskOfferingId(), destVolumeInfo.getDiskOfferingId())) { - StoragePoolVO srcPoolVO = _storagePoolDao.findById(srcVolumeInfo.getPoolId()); - StoragePoolVO destPoolVO = _storagePoolDao.findById(destVolumeInfo.getPoolId()); if (srcPoolVO != null && destPoolVO != null && ((srcPoolVO.isShared() && destPoolVO.isLocal()) || (srcPoolVO.isLocal() && destPoolVO.isShared()))) { Long offeringId = getSuitableDiskOfferingForVolumeOnPool(volumeVO, destPoolVO); @@ -2365,6 +2513,12 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { } _volumeDao.update(volumeVO.getId(), volumeVO); + if (destPoolVO != null && ClvmPoolManager.isClvmPoolType(destPoolVO.getPoolType()) + && (srcPoolVO == null || srcPoolVO.getId() != destPoolVO.getId())) { + sendClvmLockCommand(destHost.getId(), destPoolVO, destVolumeInfo, + ClvmLockTransferCommand.Operation.ACTIVATE_EXCLUSIVE); + clvmPoolManager.setClvmLockHostId(destVolumeInfo.getId(), destHost.getId()); + } _volumeService.copyPoliciesBetweenVolumesAndDestroySourceVolumeAfterMigration(Event.OperationSucceeded, null, srcVolumeInfo, destVolumeInfo, false); diff --git a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java index e167cc0a965..e84163656b1 100755 --- a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java +++ b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java @@ -28,14 +28,18 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; import static org.mockito.Mockito.any; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.storage.Storage; import com.cloud.storage.StorageManager; import com.cloud.storage.StoragePool; import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; +import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; import org.apache.cloudstack.engine.subsystem.api.storage.HostScope; +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; import org.apache.cloudstack.engine.subsystem.api.storage.StorageCacheManager; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; @@ -95,6 +99,14 @@ public class AncientDataMotionStrategyTest { f.set(configKey, value); } + private ClvmPoolManager injectMockedClvmPoolManager() throws Exception { + ClvmPoolManager clvmPoolManager = Mockito.mock(ClvmPoolManager.class); + Field clvmPoolManagerField = AncientDataMotionStrategy.class.getDeclaredField("clvmPoolManager"); + clvmPoolManagerField.setAccessible(true); + clvmPoolManagerField.set(strategy, clvmPoolManager); + return clvmPoolManager; + } + @Test public void testAddFullCloneFlagOnVMwareDest(){ strategy.addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(dataTO); @@ -288,4 +300,185 @@ public class AncientDataMotionStrategyTest { canBypassSecondaryStorage = (boolean) method.invoke(strategy, destVolumeInfo, srcVolumeInfo); Assert.assertTrue(canBypassSecondaryStorage); } + + @Test + public void testUpdateLockHostForVolume_CLVMPool_SetsLockHost() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + EndPoint endPoint = Mockito.mock(EndPoint.class); + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + DataStore dataStore = Mockito.mock(DataStore.class, Mockito.withSettings().extraInterfaces(StoragePool.class)); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + Long hostId = 123L; + Long volumeId = 456L; + String volumeUuid = "test-volume-uuid"; + + Mockito.when(endPoint.getId()).thenReturn(hostId); + Mockito.when(volumeInfo.getDataStore()).thenReturn(dataStore); + Mockito.when(volumeInfo.getId()).thenReturn(volumeId); + Mockito.when(volumeInfo.getUuid()).thenReturn(volumeUuid); + Mockito.when(volumeInfo.getPath()).thenReturn("test-volume-path"); + Mockito.when(((StoragePool) dataStore).getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(clvmPoolManager.getClvmLockHostId(Mockito.eq(volumeId), Mockito.eq(volumeUuid), + Mockito.anyString(), Mockito.any(StoragePool.class), Mockito.eq(true))).thenReturn(null); + + method.invoke(strategy, endPoint, volumeInfo); + + Mockito.verify(clvmPoolManager).setClvmLockHostId(volumeId, hostId); + } + + @Test + public void testUpdateLockHostForVolume_CLVM_NG_Pool_SetsLockHost() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + EndPoint endPoint = Mockito.mock(EndPoint.class); + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + DataStore dataStore = Mockito.mock(DataStore.class, Mockito.withSettings().extraInterfaces(StoragePool.class)); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + Long hostId = 789L; + Long volumeId = 101L; + String volumeUuid = "test-clvm-ng-volume-uuid"; + + Mockito.when(endPoint.getId()).thenReturn(hostId); + Mockito.when(volumeInfo.getDataStore()).thenReturn(dataStore); + Mockito.when(volumeInfo.getId()).thenReturn(volumeId); + Mockito.when(volumeInfo.getUuid()).thenReturn(volumeUuid); + Mockito.when(volumeInfo.getPath()).thenReturn("test-clvm-ng-volume-path"); + Mockito.when(((StoragePool) dataStore).getPoolType()).thenReturn(Storage.StoragePoolType.CLVM_NG); + Mockito.when(clvmPoolManager.getClvmLockHostId(Mockito.eq(volumeId), Mockito.eq(volumeUuid), + Mockito.anyString(), Mockito.any(StoragePool.class), Mockito.eq(true))).thenReturn(null); + + try { + method.invoke(strategy, endPoint, volumeInfo); + } catch (InvocationTargetException e) { + e.getCause().printStackTrace(); + throw e; + } + + Mockito.verify(clvmPoolManager).setClvmLockHostId(volumeId, hostId); + } + + @Test + public void testUpdateLockHostForVolume_NonCLVMPool_DoesNotSetLockHost() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + EndPoint endPoint = Mockito.mock(EndPoint.class); + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + // Create mock that implements both DataStore and StoragePool interfaces + DataStore dataStore = Mockito.mock(DataStore.class, Mockito.withSettings().extraInterfaces(StoragePool.class)); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + Mockito.when(volumeInfo.getDataStore()).thenReturn(dataStore); + Mockito.when(((StoragePool) dataStore).getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + + method.invoke(strategy, endPoint, volumeInfo); + + Mockito.verify(clvmPoolManager, never()).setClvmLockHostId(any(Long.class), any(Long.class)); + Mockito.verify(clvmPoolManager, never()).getClvmLockHostId(any(Long.class), any(String.class), + any(String.class), any(StoragePool.class), Mockito.anyBoolean()); + } + + @Test + public void testUpdateLockHostForVolume_ExistingLockHost_DoesNotOverwrite() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + EndPoint endPoint = Mockito.mock(EndPoint.class); + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + DataStore dataStore = Mockito.mock(DataStore.class, Mockito.withSettings().extraInterfaces(StoragePool.class)); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + Long hostId = 555L; + Long existingHostId = 666L; + Long volumeId = 777L; + String volumeUuid = "existing-lock-volume-uuid"; + + Mockito.when(endPoint.getId()).thenReturn(hostId); + Mockito.when(volumeInfo.getDataStore()).thenReturn(dataStore); + Mockito.when(volumeInfo.getId()).thenReturn(volumeId); + Mockito.when(volumeInfo.getUuid()).thenReturn(volumeUuid); + Mockito.when(volumeInfo.getPath()).thenReturn("existing-lock-volume-path"); + Mockito.when(((StoragePool) dataStore).getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(clvmPoolManager.getClvmLockHostId(Mockito.eq(volumeId), Mockito.eq(volumeUuid), + Mockito.anyString(), Mockito.any(StoragePool.class), Mockito.eq(true))).thenReturn(existingHostId); + + method.invoke(strategy, endPoint, volumeInfo); + + Mockito.verify(clvmPoolManager, never()).setClvmLockHostId(any(Long.class), any(Long.class)); + Mockito.verify(clvmPoolManager).getClvmLockHostId(Mockito.eq(volumeId), Mockito.eq(volumeUuid), + Mockito.anyString(), Mockito.any(StoragePool.class), Mockito.eq(true)); + } + + @Test + public void testUpdateLockHostForVolume_NullEndPoint_DoesNotSetLockHost() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + method.invoke(strategy, null, volumeInfo); + + Mockito.verify(clvmPoolManager, never()).setClvmLockHostId(any(Long.class), any(Long.class)); + Mockito.verify(clvmPoolManager, never()).getClvmLockHostId(any(Long.class), any(String.class), + any(String.class), any(StoragePool.class), Mockito.anyBoolean()); + } + + @Test + public void testUpdateLockHostForVolume_NonVolumeDataObject_DoesNotSetLockHost() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + EndPoint endPoint = Mockito.mock(EndPoint.class); + SnapshotInfo snapshotInfo = Mockito.mock(SnapshotInfo.class); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + method.invoke(strategy, endPoint, snapshotInfo); + + Mockito.verify(clvmPoolManager, never()).setClvmLockHostId(any(Long.class), any(Long.class)); + Mockito.verify(clvmPoolManager, never()).getClvmLockHostId(any(Long.class), any(String.class), + any(String.class), any(StoragePool.class), Mockito.anyBoolean()); + } + + @Test + public void testUpdateLockHostForVolume_NullPool_DoesNotSetLockHost() throws Exception { + Method method = AncientDataMotionStrategy.class.getDeclaredMethod( + "updateLockHostForVolume", + EndPoint.class, + DataObject.class); + method.setAccessible(true); + + EndPoint endPoint = Mockito.mock(EndPoint.class); + VolumeInfo volumeInfo = Mockito.mock(VolumeInfo.class); + ClvmPoolManager clvmPoolManager = injectMockedClvmPoolManager(); + + method.invoke(strategy, endPoint, volumeInfo); + + Mockito.verify(clvmPoolManager, never()).setClvmLockHostId(any(Long.class), any(Long.class)); + Mockito.verify(clvmPoolManager, never()).getClvmLockHostId(any(Long.class), any(String.class), + any(String.class), any(StoragePool.class), Mockito.anyBoolean()); + } } diff --git a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageSystemDataMotionTest.java b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageSystemDataMotionTest.java index 808c319b40f..6f0776b27c8 100644 --- a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageSystemDataMotionTest.java +++ b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/KvmNonManagedStorageSystemDataMotionTest.java @@ -168,6 +168,8 @@ public class KvmNonManagedStorageSystemDataMotionTest { supportedTypes.add(StoragePoolType.Filesystem); supportedTypes.add(StoragePoolType.NetworkFilesystem); supportedTypes.add(StoragePoolType.SharedMountPoint); + supportedTypes.add(StoragePoolType.CLVM); + supportedTypes.add(StoragePoolType.CLVM_NG); return supportedTypes.contains(storagePoolType); } @@ -505,6 +507,8 @@ public class KvmNonManagedStorageSystemDataMotionTest { supportedTypes.add(StoragePoolType.Filesystem); supportedTypes.add(StoragePoolType.NetworkFilesystem); supportedTypes.add(StoragePoolType.SharedMountPoint); + supportedTypes.add(StoragePoolType.CLVM); + supportedTypes.add(StoragePoolType.CLVM_NG); for (StoragePoolType poolType : StoragePoolType.values()) { boolean isSupported = kvmNonManagedStorageDataMotionStrategy.supportStoragePoolType(poolType); diff --git a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java index 45357fa64b2..fc51eeba7b6 100644 --- a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java +++ b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategyTest.java @@ -369,4 +369,286 @@ public class StorageSystemDataMotionStrategyTest { assertFalse(strategy.isStoragePoolTypeInList(StoragePoolType.SharedMountPoint, listTypes)); } + + /** + * Test updateMigrateDiskInfoForBlockDevice with CLVM destination pool + * Should set driver type to RAW for CLVM + */ + @Test + public void testUpdateMigrateDiskInfoForBlockDevice_ClvmDestination() { + MigrateCommand.MigrateDiskInfo originalDiskInfo = new MigrateCommand.MigrateDiskInfo( + "serial123", + MigrateCommand.MigrateDiskInfo.DiskType.FILE, + MigrateCommand.MigrateDiskInfo.DriverType.QCOW2, + MigrateCommand.MigrateDiskInfo.Source.FILE, + "/source/path", + null + ); + + StoragePoolVO destStoragePool = new StoragePoolVO(); + destStoragePool.setPoolType(StoragePoolType.CLVM); + + MigrateCommand.MigrateDiskInfo updatedDiskInfo = strategy.updateMigrateDiskInfoForBlockDevice( + originalDiskInfo, destStoragePool); + + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DiskType.BLOCK, updatedDiskInfo.getDiskType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DriverType.RAW, updatedDiskInfo.getDriverType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.Source.DEV, updatedDiskInfo.getSource()); + Assert.assertEquals("serial123", updatedDiskInfo.getSerialNumber()); + Assert.assertEquals("/source/path", updatedDiskInfo.getSourceText()); + } + + /** + * Test updateMigrateDiskInfoForBlockDevice with CLVM_NG destination pool + * Should set driver type to QCOW2 for CLVM_NG + */ + @Test + public void testUpdateMigrateDiskInfoForBlockDevice_ClvmNgDestination() { + MigrateCommand.MigrateDiskInfo originalDiskInfo = new MigrateCommand.MigrateDiskInfo( + "serial456", + MigrateCommand.MigrateDiskInfo.DiskType.FILE, + MigrateCommand.MigrateDiskInfo.DriverType.RAW, + MigrateCommand.MigrateDiskInfo.Source.FILE, + "/source/path", + "/backing/path" + ); + + StoragePoolVO destStoragePool = new StoragePoolVO(); + destStoragePool.setPoolType(StoragePoolType.CLVM_NG); + + MigrateCommand.MigrateDiskInfo updatedDiskInfo = strategy.updateMigrateDiskInfoForBlockDevice( + originalDiskInfo, destStoragePool); + + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DiskType.BLOCK, updatedDiskInfo.getDiskType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DriverType.QCOW2, updatedDiskInfo.getDriverType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.Source.DEV, updatedDiskInfo.getSource()); + Assert.assertEquals("serial456", updatedDiskInfo.getSerialNumber()); + Assert.assertEquals("/source/path", updatedDiskInfo.getSourceText()); + Assert.assertEquals("/backing/path", updatedDiskInfo.getBackingStoreText()); + } + + /** + * Test updateMigrateDiskInfoForBlockDevice with non-CLVM destination pool + * Should return original DiskInfo unchanged + */ + @Test + public void testUpdateMigrateDiskInfoForBlockDevice_NonClvmDestination() { + MigrateCommand.MigrateDiskInfo originalDiskInfo = new MigrateCommand.MigrateDiskInfo( + "serial789", + MigrateCommand.MigrateDiskInfo.DiskType.FILE, + MigrateCommand.MigrateDiskInfo.DriverType.QCOW2, + MigrateCommand.MigrateDiskInfo.Source.FILE, + "/source/path", + null + ); + + StoragePoolVO destStoragePool = new StoragePoolVO(); + destStoragePool.setPoolType(StoragePoolType.NetworkFilesystem); + + MigrateCommand.MigrateDiskInfo updatedDiskInfo = strategy.updateMigrateDiskInfoForBlockDevice( + originalDiskInfo, destStoragePool); + + Assert.assertSame(originalDiskInfo, updatedDiskInfo); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DiskType.FILE, updatedDiskInfo.getDiskType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DriverType.QCOW2, updatedDiskInfo.getDriverType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.Source.FILE, updatedDiskInfo.getSource()); + } + + /** + * Test supportStoragePoolType with CLVM and CLVM_NG types + */ + @Test + public void testSupportStoragePoolType_ClvmTypes() { + assertTrue(strategy.supportStoragePoolType(StoragePoolType.CLVM, StoragePoolType.CLVM, StoragePoolType.CLVM_NG)); + assertTrue(strategy.supportStoragePoolType(StoragePoolType.CLVM_NG, StoragePoolType.CLVM, StoragePoolType.CLVM_NG)); + + assertFalse(strategy.supportStoragePoolType(StoragePoolType.CLVM)); + assertFalse(strategy.supportStoragePoolType(StoragePoolType.CLVM_NG)); + } + + /** + * Test configureMigrateDiskInfo with CLVM destination + */ + @Test + public void testConfigureMigrateDiskInfo_ForClvm() { + VolumeObject srcVolumeInfo = Mockito.spy(new VolumeObject()); + Mockito.doReturn("/dev/vg/volume-path").when(srcVolumeInfo).getPath(); + + MigrateCommand.MigrateDiskInfo migrateDiskInfo = strategy.configureMigrateDiskInfo( + srcVolumeInfo, "/dev/vg/dest-path", null); + + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DiskType.BLOCK, migrateDiskInfo.getDiskType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DriverType.RAW, migrateDiskInfo.getDriverType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.Source.DEV, migrateDiskInfo.getSource()); + Assert.assertEquals("/dev/vg/dest-path", migrateDiskInfo.getSourceText()); + Assert.assertEquals("/dev/vg/volume-path", migrateDiskInfo.getSerialNumber()); + } + + /** + * Test configureMigrateDiskInfo with CLVM_NG destination and backing file + */ + @Test + public void testConfigureMigrateDiskInfo_ForClvmNgWithBacking() { + VolumeObject srcVolumeInfo = Mockito.spy(new VolumeObject()); + Mockito.doReturn("/dev/vg/volume-path").when(srcVolumeInfo).getPath(); + + MigrateCommand.MigrateDiskInfo migrateDiskInfo = strategy.configureMigrateDiskInfo( + srcVolumeInfo, "/dev/vg/dest-path", "/dev/vg/backing-template"); + + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DiskType.BLOCK, migrateDiskInfo.getDiskType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.DriverType.RAW, migrateDiskInfo.getDriverType()); + Assert.assertEquals(MigrateCommand.MigrateDiskInfo.Source.DEV, migrateDiskInfo.getSource()); + Assert.assertEquals("/dev/vg/dest-path", migrateDiskInfo.getSourceText()); + Assert.assertEquals("/dev/vg/backing-template", migrateDiskInfo.getBackingStoreText()); + Assert.assertEquals("/dev/vg/volume-path", migrateDiskInfo.getSerialNumber()); + } + + /** + * Test isStoragePoolTypeInList with CLVM types + */ + @Test + public void testIsStoragePoolTypeInList_WithClvmTypes() { + StoragePoolType[] clvmTypes = new StoragePoolType[] { + StoragePoolType.CLVM, + StoragePoolType.CLVM_NG, + StoragePoolType.Filesystem + }; + + assertTrue(strategy.isStoragePoolTypeInList(StoragePoolType.CLVM, clvmTypes)); + assertTrue(strategy.isStoragePoolTypeInList(StoragePoolType.CLVM_NG, clvmTypes)); + assertTrue(strategy.isStoragePoolTypeInList(StoragePoolType.Filesystem, clvmTypes)); + assertFalse(strategy.isStoragePoolTypeInList(StoragePoolType.NetworkFilesystem, clvmTypes)); + } + + /** + * Test supportStoragePoolType with mixed CLVM and NFS types + */ + @Test + public void testSupportStoragePoolType_MixedClvmAndNfs() { + assertTrue(strategy.supportStoragePoolType( + StoragePoolType.CLVM, + StoragePoolType.CLVM, + StoragePoolType.CLVM_NG, + StoragePoolType.NetworkFilesystem + )); + + assertTrue(strategy.supportStoragePoolType( + StoragePoolType.CLVM_NG, + StoragePoolType.CLVM, + StoragePoolType.CLVM_NG, + StoragePoolType.NetworkFilesystem + )); + + assertTrue(strategy.supportStoragePoolType( + StoragePoolType.NetworkFilesystem, + StoragePoolType.CLVM, + StoragePoolType.CLVM_NG + )); + } + + /** + * Test internalCanHandle with CLVM source and managed destination + */ + @Test + public void testInternalCanHandle_ClvmSourceManagedDestination() { + VolumeObject volumeInfo = Mockito.spy(new VolumeObject()); + Mockito.doReturn(0L).when(volumeInfo).getPoolId(); + + DataStore ds = Mockito.spy(new PrimaryDataStoreImpl()); + + Map volumeMap = new HashMap<>(); + volumeMap.put(volumeInfo, ds); + + StoragePoolVO sourcePool = Mockito.spy(new StoragePoolVO()); + Mockito.lenient().doReturn(StoragePoolType.CLVM).when(sourcePool).getPoolType(); + Mockito.doReturn(true).when(sourcePool).isManaged(); + + Mockito.doReturn(sourcePool).when(primaryDataStoreDao).findById(0L); + + StrategyPriority result = strategy.internalCanHandle( + volumeMap, new HostVO("srcHostUuid"), new HostVO("destHostUuid")); + + Assert.assertEquals(StrategyPriority.HIGHEST, result); + } + + /** + * Test internalCanHandle with CLVM_NG source and managed destination + */ + @Test + public void testInternalCanHandle_ClvmNgSourceManagedDestination() { + VolumeObject volumeInfo = Mockito.spy(new VolumeObject()); + Mockito.doReturn(0L).when(volumeInfo).getPoolId(); + + DataStore ds = Mockito.spy(new PrimaryDataStoreImpl()); + + Map volumeMap = new HashMap<>(); + volumeMap.put(volumeInfo, ds); + + StoragePoolVO sourcePool = Mockito.spy(new StoragePoolVO()); + Mockito.lenient().doReturn(StoragePoolType.CLVM_NG).when(sourcePool).getPoolType(); + Mockito.doReturn(true).when(sourcePool).isManaged(); + + Mockito.doReturn(sourcePool).when(primaryDataStoreDao).findById(0L); + + StrategyPriority result = strategy.internalCanHandle( + volumeMap, new HostVO("srcHostUuid"), new HostVO("destHostUuid")); + + Assert.assertEquals(StrategyPriority.HIGHEST, result); + } + + /** + * Test internalCanHandle with both CLVM source and CLVM_NG destination + */ + @Test + public void testInternalCanHandle_ClvmToClvmNg() { + VolumeObject volumeInfo = Mockito.spy(new VolumeObject()); + Mockito.doReturn(0L).when(volumeInfo).getPoolId(); + + DataStore ds = Mockito.spy(new PrimaryDataStoreImpl()); + + Map volumeMap = new HashMap<>(); + volumeMap.put(volumeInfo, ds); + + StoragePoolVO sourcePool = Mockito.spy(new StoragePoolVO()); + Mockito.lenient().doReturn(StoragePoolType.CLVM).when(sourcePool).getPoolType(); + Mockito.doReturn(true).when(sourcePool).isManaged(); + + StoragePoolVO destPool = Mockito.spy(new StoragePoolVO()); + Mockito.lenient().doReturn(StoragePoolType.CLVM_NG).when(destPool).getPoolType(); + + Mockito.doReturn(sourcePool).when(primaryDataStoreDao).findById(0L); + + StrategyPriority result = strategy.internalCanHandle( + volumeMap, new HostVO("srcHostUuid"), new HostVO("destHostUuid")); + + Assert.assertEquals(StrategyPriority.HIGHEST, result); + } + + /** + * Test internalCanHandle with CLVM_NG to CLVM migration + */ + @Test + public void testInternalCanHandle_ClvmNgToClvm() { + VolumeObject volumeInfo = Mockito.spy(new VolumeObject()); + Mockito.doReturn(0L).when(volumeInfo).getPoolId(); + + DataStore ds = Mockito.spy(new PrimaryDataStoreImpl()); + + Map volumeMap = new HashMap<>(); + volumeMap.put(volumeInfo, ds); + + StoragePoolVO sourcePool = Mockito.spy(new StoragePoolVO()); + Mockito.lenient().doReturn(StoragePoolType.CLVM_NG).when(sourcePool).getPoolType(); + Mockito.doReturn(true).when(sourcePool).isManaged(); + + StoragePoolVO destPool = Mockito.spy(new StoragePoolVO()); + Mockito.lenient().doReturn(StoragePoolType.CLVM).when(destPool).getPoolType(); + + Mockito.doReturn(sourcePool).when(primaryDataStoreDao).findById(0L); + + StrategyPriority result = strategy.internalCanHandle( + volumeMap, new HostVO("srcHostUuid"), new HostVO("destHostUuid")); + + Assert.assertEquals(StrategyPriority.HIGHEST, result); + } } diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategy.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategy.java index 88f479c0904..d85e862bfb6 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategy.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategy.java @@ -60,6 +60,7 @@ import com.cloud.event.UsageEventUtils; import com.cloud.exception.InvalidParameterValueException; import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.storage.CreateSnapshotPayload; import com.cloud.storage.DataStoreRole; import com.cloud.storage.Snapshot; @@ -643,6 +644,10 @@ public class DefaultSnapshotStrategy extends SnapshotStrategyBase { return StrategyPriority.DEFAULT; } + if (isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)) { + return StrategyPriority.DEFAULT; + } + return StrategyPriority.CANT_HANDLE; } if (zoneId != null && SnapshotOperation.DELETE.equals(op)) { @@ -691,4 +696,32 @@ public class DefaultSnapshotStrategy extends SnapshotStrategyBase { dataStoreMgr.getStoreZoneId(s.getDataStoreId(), s.getRole()), volumeVO.getDataCenterId())); } + /** + * Checks if a CLVM volume snapshot is stored on secondary storage in the same zone. + * CLVM snapshots are backed up to secondary storage and removed from primary storage. + */ + protected boolean isSnapshotStoredOnSecondaryForCLVMVolume(Snapshot snapshot, VolumeVO volumeVO) { + if (volumeVO == null) { + return false; + } + + Long poolId = volumeVO.getPoolId(); + if (poolId == null) { + return false; + } + + StoragePool pool = (StoragePool) dataStoreMgr.getDataStore(poolId, DataStoreRole.Primary); + if (pool == null || !ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + return false; + } + + List snapshotStores = snapshotStoreDao.listReadyBySnapshot(snapshot.getId(), DataStoreRole.Image); + if (CollectionUtils.isEmpty(snapshotStores)) { + return false; + } + + return snapshotStores.stream().anyMatch(s -> Objects.equals( + dataStoreMgr.getStoreZoneId(s.getDataStoreId(), s.getRole()), volumeVO.getDataCenterId())); + } + } diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategy.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategy.java index b71d6cf3afa..665c3a4659c 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategy.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategy.java @@ -27,6 +27,7 @@ import javax.naming.ConfigurationException; import com.cloud.hypervisor.Hypervisor; import com.cloud.storage.Snapshot; +import com.cloud.storage.Storage; import com.cloud.storage.dao.SnapshotDao; import com.cloud.vm.snapshot.VMSnapshotDetailsVO; import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao; @@ -468,6 +469,13 @@ public class DefaultVMSnapshotStrategy extends ManagerBase implements VMSnapshot @Override public StrategyPriority canHandle(VMSnapshot vmSnapshot) { + UserVmVO vm = userVmDao.findById(vmSnapshot.getVmId()); + String cantHandleLog = String.format("Default VM snapshot cannot handle VM snapshot for [%s]", vm); + + if (isRunningVMVolumeOnCLVMStorage(vm, cantHandleLog)) { + return StrategyPriority.CANT_HANDLE; + } + return StrategyPriority.DEFAULT; } @@ -493,10 +501,31 @@ public class DefaultVMSnapshotStrategy extends ManagerBase implements VMSnapshot return vmSnapshotDao.remove(vmSnapshot.getId()); } + protected boolean isRunningVMVolumeOnCLVMStorage(UserVmVO vm, String cantHandleLog) { + Long vmId = vm.getId(); + if (State.Running.equals(vm.getState())) { + List volumes = volumeDao.findByInstance(vmId); + for (VolumeVO volume : volumes) { + StoragePool pool = primaryDataStoreDao.findById(volume.getPoolId()); + if (pool != null && pool.getPoolType() == Storage.StoragePoolType.CLVM) { + logger.warn("Rejecting VM snapshot request: {} - VM is running on CLVM storage (pool: {}, poolType: CLVM)", + cantHandleLog, pool.getName()); + return true; + } + } + } + return false; + } + @Override public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMemory) { UserVmVO vm = userVmDao.findById(vmId); String cantHandleLog = String.format("Default VM snapshot cannot handle VM snapshot for [%s]", vm); + + if (isRunningVMVolumeOnCLVMStorage(vm, cantHandleLog)) { + return StrategyPriority.CANT_HANDLE; + } + if (State.Running.equals(vm.getState()) && !snapshotMemory) { logger.debug("{} as it is running and its memory will not be affected.", cantHandleLog, vm); return StrategyPriority.CANT_HANDLE; diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java index 31b13fc279e..4ae6e26fbd9 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java @@ -345,6 +345,13 @@ public class StorageVMSnapshotStrategy extends DefaultVMSnapshotStrategy { } } + Long vmId = vmSnapshot.getVmId(); + UserVmVO vm = userVmDao.findById(vmId); + String cantHandleLog = String.format("Storage VM snapshot strategy cannot handle VM snapshot for [%s]", vm); + if (vm != null && isRunningVMVolumeOnCLVMStorage(vm, cantHandleLog)) { + return StrategyPriority.CANT_HANDLE; + } + if ( SnapshotManager.VmStorageSnapshotKvm.value() && userVm.getHypervisorType() == Hypervisor.HypervisorType.KVM && vmSnapshot.getType() == VMSnapshot.Type.Disk) { return StrategyPriority.HYPERVISOR; diff --git a/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategyTest.java b/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategyTest.java index 41bfaa6f0c7..c27d4e13fa4 100644 --- a/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategyTest.java +++ b/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/snapshot/DefaultSnapshotStrategyTest.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.StoragePool; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; @@ -322,4 +323,236 @@ public class DefaultSnapshotStrategyTest { prepareMocksForIsSnapshotStoredOnSameZoneStoreForQCOW2VolumeTest(100L); Assert.assertTrue(defaultSnapshotStrategySpy.isSnapshotStoredOnSameZoneStoreForQCOW2Volume(snapshot, volumeVO)); } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_NullVolume() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, null)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_NullPoolId() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(null); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_NullPool() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn(null); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_NonCLVMPool() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_RBDPool() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolNoSnapshotStores() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)).thenReturn(new ArrayList<>()); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolSnapshotInDifferentZone() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(volumeVO.getDataCenterId()).thenReturn(100L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + SnapshotDataStoreVO snapshotStore1 = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore1.getDataStoreId()).thenReturn(201L); + Mockito.when(snapshotStore1.getRole()).thenReturn(DataStoreRole.Image); + + SnapshotDataStoreVO snapshotStore2 = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore2.getDataStoreId()).thenReturn(202L); + Mockito.when(snapshotStore2.getRole()).thenReturn(DataStoreRole.Image); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)) + .thenReturn(List.of(snapshotStore1, snapshotStore2)); + + Mockito.when(dataStoreManager.getStoreZoneId(201L, DataStoreRole.Image)).thenReturn(111L); + Mockito.when(dataStoreManager.getStoreZoneId(202L, DataStoreRole.Image)).thenReturn(112L); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolSnapshotInSameZone() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(volumeVO.getDataCenterId()).thenReturn(100L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + SnapshotDataStoreVO snapshotStore = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore.getDataStoreId()).thenReturn(201L); + Mockito.when(snapshotStore.getRole()).thenReturn(DataStoreRole.Image); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)) + .thenReturn(List.of(snapshotStore)); + + Mockito.when(dataStoreManager.getStoreZoneId(201L, DataStoreRole.Image)).thenReturn(100L); + + Assert.assertTrue(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolMultipleSnapshotsOneMatches() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(volumeVO.getDataCenterId()).thenReturn(100L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + SnapshotDataStoreVO snapshotStore1 = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore1.getDataStoreId()).thenReturn(201L); + Mockito.when(snapshotStore1.getRole()).thenReturn(DataStoreRole.Image); + + SnapshotDataStoreVO snapshotStore2 = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore2.getDataStoreId()).thenReturn(202L); + Mockito.when(snapshotStore2.getRole()).thenReturn(DataStoreRole.Image); + + SnapshotDataStoreVO snapshotStore3 = Mockito.mock(SnapshotDataStoreVO.class); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)) + .thenReturn(List.of(snapshotStore1, snapshotStore2, snapshotStore3)); + + Mockito.when(dataStoreManager.getStoreZoneId(201L, DataStoreRole.Image)).thenReturn(111L); + Mockito.when(dataStoreManager.getStoreZoneId(202L, DataStoreRole.Image)).thenReturn(100L); + + Assert.assertTrue(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolNullZoneIds() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(volumeVO.getDataCenterId()).thenReturn(100L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + SnapshotDataStoreVO snapshotStore = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore.getDataStoreId()).thenReturn(201L); + Mockito.when(snapshotStore.getRole()).thenReturn(DataStoreRole.Image); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)) + .thenReturn(List.of(snapshotStore)); + + Mockito.when(dataStoreManager.getStoreZoneId(201L, DataStoreRole.Image)).thenReturn(null); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolVolumeNullDataCenter() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(volumeVO.getDataCenterId()).thenReturn(1L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + SnapshotDataStoreVO snapshotStore = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore.getDataStoreId()).thenReturn(201L); + Mockito.when(snapshotStore.getRole()).thenReturn(DataStoreRole.Image); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)) + .thenReturn(List.of(snapshotStore)); + + Mockito.when(dataStoreManager.getStoreZoneId(201L, DataStoreRole.Image)).thenReturn(100L); + + Assert.assertFalse(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } + + @Test + public void testIsSnapshotStoredOnSecondaryForCLVMVolume_CLVMPoolMultipleSnapshotsAllInSameZone() { + Snapshot snapshot = Mockito.mock(Snapshot.class); + Mockito.when(snapshot.getId()).thenReturn(1L); + + VolumeVO volumeVO = Mockito.mock(VolumeVO.class); + Mockito.when(volumeVO.getPoolId()).thenReturn(10L); + Mockito.when(volumeVO.getDataCenterId()).thenReturn(100L); + + StoragePool pool = Mockito.mock(StoragePool.class, Mockito.withSettings().extraInterfaces(DataStore.class)); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.CLVM); + Mockito.when(dataStoreManager.getDataStore(10L, DataStoreRole.Primary)).thenReturn((DataStore) pool); + + SnapshotDataStoreVO snapshotStore1 = Mockito.mock(SnapshotDataStoreVO.class); + Mockito.when(snapshotStore1.getDataStoreId()).thenReturn(201L); + Mockito.when(snapshotStore1.getRole()).thenReturn(DataStoreRole.Image); + + SnapshotDataStoreVO snapshotStore2 = Mockito.mock(SnapshotDataStoreVO.class); + + Mockito.when(snapshotDataStoreDao.listReadyBySnapshot(1L, DataStoreRole.Image)) + .thenReturn(List.of(snapshotStore1, snapshotStore2)); + + Mockito.when(dataStoreManager.getStoreZoneId(201L, DataStoreRole.Image)).thenReturn(100L); + + Assert.assertTrue(defaultSnapshotStrategySpy.isSnapshotStoredOnSecondaryForCLVMVolume(snapshot, volumeVO)); + } } diff --git a/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategyTest.java b/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategyTest.java index da377f96ec3..365ba3d4eb3 100644 --- a/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategyTest.java +++ b/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/DefaultVMSnapshotStrategyTest.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import org.apache.cloudstack.engine.subsystem.api.storage.StrategyPriority; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.to.VolumeObjectTO; @@ -39,6 +40,10 @@ import com.cloud.storage.Storage; import com.cloud.storage.Volume; import com.cloud.storage.VolumeVO; import com.cloud.storage.dao.VolumeDao; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.snapshot.VMSnapshot; @RunWith(MockitoJUnitRunner.class) public class DefaultVMSnapshotStrategyTest { @@ -46,6 +51,8 @@ public class DefaultVMSnapshotStrategyTest { VolumeDao volumeDao; @Mock PrimaryDataStoreDao primaryDataStoreDao; + @Mock + UserVmDao userVmDao; @Spy @InjectMocks @@ -85,7 +92,7 @@ public class DefaultVMSnapshotStrategyTest { Mockito.when(vol2.getChainInfo()).thenReturn(newVolChain); Mockito.when(vol2.getSize()).thenReturn(vmSnapshotChainSize); Mockito.when(vol2.getId()).thenReturn(volumeId); - VolumeVO volumeVO = new VolumeVO("name", 0l, 0l, 0l, 0l, 0l, "folder", "path", Storage.ProvisioningType.THIN, 0l, Volume.Type.ROOT); + VolumeVO volumeVO = new VolumeVO("name", 0L, 0L, 0L, 0L, 0L, "folder", "path", Storage.ProvisioningType.THIN, 0L, Volume.Type.ROOT); volumeVO.setPoolId(oldPoolId); volumeVO.setChainInfo(oldVolChain); volumeVO.setPath(oldVolPath); @@ -103,4 +110,110 @@ public class DefaultVMSnapshotStrategyTest { Assert.assertEquals(vmSnapshotChainSize, persistedVolume.getVmSnapshotChainSize()); Assert.assertEquals(newVolChain, persistedVolume.getChainInfo()); } + + @Test + public void testCanHandleRunningVMOnClvmStorageCantHandle() { + Long vmId = 1L; + VMSnapshot vmSnapshot = Mockito.mock(VMSnapshot.class); + Mockito.when(vmSnapshot.getVmId()).thenReturn(vmId); + + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getState()).thenReturn(State.Running); + Mockito.when(userVmDao.findById(vmId)).thenReturn(vm); + + VolumeVO volumeOnClvm = createVolume(vmId, 1L); + List volumes = List.of(volumeOnClvm); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(volumes); + + StoragePoolVO clvmPool = createStoragePool("clvm-pool", Storage.StoragePoolType.CLVM); + Mockito.when(primaryDataStoreDao.findById(1L)).thenReturn(clvmPool); + + StrategyPriority result = defaultVMSnapshotStrategy.canHandle(vmSnapshot); + + Assert.assertEquals("Should return CANT_HANDLE for running VM on CLVM storage", + StrategyPriority.CANT_HANDLE, result); + } + + @Test + public void testCanHandleStoppedVMOnClvmStorageCanHandle() { + Long vmId = 1L; + VMSnapshot vmSnapshot = Mockito.mock(VMSnapshot.class); + Mockito.when(vmSnapshot.getVmId()).thenReturn(vmId); + + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getState()).thenReturn(State.Stopped); + Mockito.when(userVmDao.findById(vmId)).thenReturn(vm); + + StrategyPriority result = defaultVMSnapshotStrategy.canHandle(vmSnapshot); + Assert.assertEquals("Should return DEFAULT for stopped VM on CLVM storage", + StrategyPriority.DEFAULT, result); + } + + @Test + public void testCanHandleRunningVMOnNfsStorageCanHandle() { + Long vmId = 1L; + VMSnapshot vmSnapshot = Mockito.mock(VMSnapshot.class); + Mockito.when(vmSnapshot.getVmId()).thenReturn(vmId); + + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getState()).thenReturn(State.Running); + Mockito.when(userVmDao.findById(vmId)).thenReturn(vm); + + VolumeVO volumeOnNfs = createVolume(vmId, 1L); + List volumes = List.of(volumeOnNfs); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(volumes); + + StoragePoolVO nfsPool = createStoragePool("nfs-pool", Storage.StoragePoolType.NetworkFilesystem); + Mockito.when(primaryDataStoreDao.findById(1L)).thenReturn(nfsPool); + + StrategyPriority result = defaultVMSnapshotStrategy.canHandle(vmSnapshot); + + Assert.assertEquals("Should return DEFAULT for running VM on NFS storage", + StrategyPriority.DEFAULT, result); + } + + @Test + public void testCanHandleRunningVMWithMixedStorageClvmAndNfsCantHandle() { + // Arrange - VM has volumes on both CLVM and NFS + Long vmId = 1L; + VMSnapshot vmSnapshot = Mockito.mock(VMSnapshot.class); + Mockito.when(vmSnapshot.getVmId()).thenReturn(vmId); + + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getState()).thenReturn(State.Running); + Mockito.when(userVmDao.findById(vmId)).thenReturn(vm); + + VolumeVO volumeOnClvm = createVolume(vmId, 1L); + VolumeVO volumeOnNfs = createVolume(vmId, 2L); + List volumes = List.of(volumeOnClvm, volumeOnNfs); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(volumes); + + StoragePoolVO clvmPool = createStoragePool("clvm-pool", Storage.StoragePoolType.CLVM); + StoragePoolVO nfsPool = createStoragePool("nfs-pool", Storage.StoragePoolType.NetworkFilesystem); + Mockito.when(primaryDataStoreDao.findById(1L)).thenReturn(clvmPool); + + StrategyPriority result = defaultVMSnapshotStrategy.canHandle(vmSnapshot); + + Assert.assertEquals("Should return CANT_HANDLE if any volume is on CLVM storage for running VM", + StrategyPriority.CANT_HANDLE, result); + } + + private VolumeVO createVolume(Long vmId, Long poolId) { + VolumeVO volume = new VolumeVO("volume", 0L, 0L, 0L, 0L, 0L, + "folder", "path", Storage.ProvisioningType.THIN, 0L, Volume.Type.ROOT); + volume.setInstanceId(vmId); + volume.setPoolId(poolId); + return volume; + } + + private StoragePoolVO createStoragePool(String name, Storage.StoragePoolType poolType) { + StoragePoolVO pool = Mockito.mock(StoragePoolVO.class); + Mockito.when(pool.getName()).thenReturn(name); + Mockito.when(pool.getPoolType()).thenReturn(poolType); + return pool; + } } diff --git a/engine/storage/src/main/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelector.java b/engine/storage/src/main/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelector.java index 061d18dc376..7eee32b9f1b 100644 --- a/engine/storage/src/main/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelector.java +++ b/engine/storage/src/main/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelector.java @@ -32,8 +32,12 @@ import javax.inject.Inject; import com.cloud.dc.DedicatedResourceVO; import com.cloud.dc.dao.DedicatedResourceDao; +import com.cloud.storage.Volume; +import com.cloud.storage.clvm.ClvmPoolManager; +import com.cloud.storage.dao.VolumeDetailsDao; import com.cloud.user.Account; import com.cloud.utils.Pair; +import com.cloud.utils.db.QueryBuilder; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; @@ -46,6 +50,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.storage.LocalHostEndpoint; import org.apache.cloudstack.storage.RemoteHostEndPoint; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; import org.springframework.stereotype.Component; @@ -59,8 +64,8 @@ import com.cloud.hypervisor.Hypervisor; import com.cloud.storage.DataStoreRole; import com.cloud.storage.ScopeType; import com.cloud.storage.Storage.TemplateType; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import com.cloud.utils.db.DB; -import com.cloud.utils.db.QueryBuilder; import com.cloud.utils.db.SearchCriteria.Op; import com.cloud.utils.db.TransactionLegacy; import com.cloud.utils.exception.CloudRuntimeException; @@ -75,6 +80,12 @@ public class DefaultEndPointSelector implements EndPointSelector { private HostDao hostDao; @Inject private DedicatedResourceDao dedicatedResourceDao; + @Inject + private PrimaryDataStoreDao _storagePoolDao; + @Inject + private VolumeDetailsDao _volDetailsDao; + @Inject + private ClvmPoolManager clvmPoolManager; private static final String VOL_ENCRYPT_COLUMN_NAME = "volume_encryption_support"; private final String findOneHostOnPrimaryStorage = "select t.id from " @@ -264,6 +275,27 @@ public class DefaultEndPointSelector implements EndPointSelector { @Override public EndPoint select(DataObject srcData, DataObject destData, boolean volumeEncryptionSupportRequired) { + if (destData instanceof VolumeInfo) { + EndPoint clvmEndpoint = selectClvmEndpointIfApplicable((VolumeInfo) destData, "template-to-volume copy"); + if (clvmEndpoint != null) { + return clvmEndpoint; + } + } + + // Check if SOURCE is a CLVM volume with active lock (for operations copying FROM CLVM to secondary storage) + if (srcData instanceof VolumeInfo) { + VolumeInfo srcVolume = (VolumeInfo) srcData; + DataStore srcStore = srcVolume.getDataStore(); + if (srcStore.getRole() == DataStoreRole.Primary) { + StoragePoolVO pool = _storagePoolDao.findById(srcStore.getId()); + EndPoint clvmEp = tryRouteToClvmLockHolder(srcVolume, pool, "copy operation"); + if (clvmEp != null) { + return clvmEp; + } + } + } + + // Default behavior for non-CLVM or when no destination host is set DataStore srcStore = srcData.getDataStore(); DataStore destStore = destData.getDataStore(); if (moveBetweenPrimaryImage(srcStore, destStore)) { @@ -305,7 +337,6 @@ public class DefaultEndPointSelector implements EndPointSelector { @Override public EndPoint select(DataObject srcData, DataObject destData, StorageAction action, boolean encryptionRequired) { - logger.error("IR24 select BACKUPSNAPSHOT from primary to secondary {} dest={}", srcData, destData); if (action == StorageAction.BACKUPSNAPSHOT && srcData.getDataStore().getRole() == DataStoreRole.Primary) { SnapshotInfo srcSnapshot = (SnapshotInfo)srcData; VolumeInfo volumeInfo = srcSnapshot.getBaseVolume(); @@ -314,6 +345,17 @@ public class DefaultEndPointSelector implements EndPointSelector { if (vm != null && vm.getState() == VirtualMachine.State.Running) { return getEndPointFromHostId(vm.getHostId()); } + // For CLVM pools, the snapshot LVM device only exists on the lock-holder host. + // Route the backup CopyCommand to that same host regardless of VM state. + DataStore srcStore = volumeInfo.getDataStore(); + if (srcStore != null && srcStore.getRole() == DataStoreRole.Primary) { + StoragePoolVO pool = _storagePoolDao.findById(srcStore.getId()); + logger.debug("Checking if CLVM store and lock-holder routing applicable for snapshot {}", srcSnapshot.getUuid()); + EndPoint clvmEp = tryRouteToClvmLockHolder(volumeInfo, pool, "snapshot backup"); + if (clvmEp != null) { + return clvmEp; + } + } } if (srcSnapshot.getHypervisorType() == Hypervisor.HypervisorType.VMware) { if (vm != null) { @@ -388,18 +430,103 @@ public class DefaultEndPointSelector implements EndPointSelector { return sc.list(); } + /** + * Selects endpoint for CLVM volumes with destination host hint. + * This ensures volumes are created on the correct host with exclusive locks. + * + * @param volume The volume to check for CLVM routing + * @param operation Description of the operation (for logging) + * @return EndPoint for the destination host if CLVM routing applies, null otherwise + */ + private EndPoint selectClvmEndpointIfApplicable(VolumeInfo volume, String operation) { + DataStore store = volume.getDataStore(); + + if (store.getRole() != DataStoreRole.Primary) { + return null; + } + + + // Check if this is a CLVM pool + StoragePoolVO pool = _storagePoolDao.findById(store.getId()); + if (pool == null || !ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + return null; + } + if (Volume.State.Allocated == volume.getState()) { + // Check if destination host hint is set + Long destHostId = volume.getDestinationHostId(); + if (destHostId == null) { + return null; + } + + logger.info("CLVM {}: routing volume {} to destination host {} for optimal exclusive lock placement", + operation, volume.getUuid(), destHostId); + + EndPoint ep = getEndPointFromHostId(destHostId); + if (ep != null) { + return ep; + } + } + + Long lockHostId = getClvmLockHostId(volume); + if (lockHostId == null) { + return null; + } + logger.info("CLVM {}: routing existing volume {} to live lock-holder host {}", + operation, volume.getUuid(), lockHostId); + EndPoint ep = getEndPointFromHostId(lockHostId); + if (ep != null) { + return ep; + } + logger.warn("Could not get endpoint for lock host {}, falling back to default selection", lockHostId); + return null; + } + @Override public EndPoint select(DataObject object, boolean encryptionSupportRequired) { DataStore store = object.getDataStore(); + + // This ensures volumes are created on the correct host with exclusive locks + String operation = ""; + if (DataStoreRole.Primary == store.getRole()) { + VolumeInfo volume = null; + if (object instanceof VolumeInfo) { + volume = (VolumeInfo) object; + operation = "volume creation"; + } else if (object instanceof SnapshotInfo) { + volume = ((SnapshotInfo) object).getBaseVolume(); + operation = "snapshot creation"; + } + + if (volume != null) { + EndPoint clvmEndpoint = selectClvmEndpointIfApplicable(volume, operation); + if (clvmEndpoint != null) { + return clvmEndpoint; + } + } + } + + // Default behavior for non-CLVM or when no destination host is set if (store.getRole() == DataStoreRole.Primary) { return findEndPointInScope(store.getScope(), findOneHostOnPrimaryStorage, store.getId(), encryptionSupportRequired); } throw new CloudRuntimeException(String.format("Storage role %s doesn't support encryption", store.getRole())); } + @Override public EndPoint select(DataObject object) { DataStore store = object.getDataStore(); + + // For CLVM volumes, check if there's a lock host ID to route to + if (object instanceof VolumeInfo && store.getRole() == DataStoreRole.Primary) { + VolumeInfo volume = (VolumeInfo) object; + StoragePoolVO pool = _storagePoolDao.findById(store.getId()); + EndPoint clvmEp = tryRouteToClvmLockHolder(volume, pool, "operation"); + if (clvmEp != null) { + return clvmEp; + } + } + EndPoint ep = select(store); if (ep != null) { return ep; @@ -493,6 +620,19 @@ public class DefaultEndPointSelector implements EndPointSelector { } case DELETEVOLUME: { VolumeInfo volume = (VolumeInfo) object; + + // For CLVM volumes, route to the host holding the exclusive lock + if (volume.getHypervisorType() == Hypervisor.HypervisorType.KVM) { + DataStore store = volume.getDataStore(); + if (store.getRole() == DataStoreRole.Primary) { + StoragePoolVO pool = _storagePoolDao.findById(store.getId()); + EndPoint clvmEp = tryRouteToClvmLockHolder(volume, pool, "deletion"); + if (clvmEp != null) { + return clvmEp; + } + } + } + if (volume.getHypervisorType() == Hypervisor.HypervisorType.VMware) { VirtualMachine vm = volume.getAttachedVM(); if (vm != null) { @@ -540,6 +680,14 @@ public class DefaultEndPointSelector implements EndPointSelector { if (vm.getState() == VirtualMachine.State.Running) { return getEndPointFromHostId(vm.getHostId()); + } else if (vm.getState() == VirtualMachine.State.Stopped) { + StoragePoolVO pool = _storagePoolDao.findById(volumeInfo.getPoolId()); + if (pool != null && ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + EndPoint ep = getApplicableEndpointForClvm(snapshotInfo, volumeInfo); + if (ep != null) { + return ep; + } + } } Long hostId = vm.getLastHostId(); @@ -552,6 +700,20 @@ public class DefaultEndPointSelector implements EndPointSelector { return select(snapshotInfo, encryptionRequired); } + private EndPoint getApplicableEndpointForClvm(SnapshotInfo snapshotInfo, VolumeInfo volumeInfo) { + Long lockHostId = getClvmLockHostId(volumeInfo); + if (lockHostId != null) { + logger.debug("CLVM snapshot operation: routing snapshot [{}] to lock-holder host [{}]", + snapshotInfo.getUuid(), lockHostId); + EndPoint ep = getEndPointFromHostId(lockHostId); + if (ep != null) { + return ep; + } + logger.warn("Could not get endpoint for CLVM lock host {}, falling back", lockHostId); + } + return null; + } + @Override public EndPoint select(Scope scope, Long storeId) { return findEndPointInScope(scope, findOneHostOnPrimaryStorage, storeId); @@ -589,4 +751,46 @@ public class DefaultEndPointSelector implements EndPointSelector { } return endPoints; } + + protected EndPoint tryRouteToClvmLockHolder(VolumeInfo volume, StoragePoolVO pool, String operation) { + if (pool == null || !ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + return null; + } + Long lockHostId = getClvmLockHostId(volume); + if (lockHostId == null) { + logger.debug("No CLVM lock host tracked for volume {}, using default endpoint selection", volume.getUuid()); + return null; + } + logger.info("Routing CLVM volume {} {} to lock holder host {}", volume.getUuid(), operation, lockHostId); + EndPoint ep = getEndPointFromHostId(lockHostId); + if (ep != null) { + return ep; + } + logger.warn("Could not get endpoint for CLVM lock host {}, falling back to default selection", lockHostId); + return null; + } + + /** + * Gets the CLVM lock host ID for a volume by querying actual LVM state. + * + * @param volume The CLVM volume + * @return Host ID holding the lock, or null if not found + */ + protected Long getClvmLockHostId(VolumeInfo volume) { + StoragePoolVO pool = _storagePoolDao.findById(volume.getPoolId()); + + Long lockHostId = clvmPoolManager.getClvmLockHostId( + volume.getId(), + volume.getUuid(), + volume.getPath(), + pool, + true + ); + + if (lockHostId != null) { + logger.debug("Found actual lock host {} for volume {} via LVM query", lockHostId, volume.getUuid()); + } + + return lockHostId; + } } diff --git a/engine/storage/src/test/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelectorTest.java b/engine/storage/src/test/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelectorTest.java index 9f01ab162ba..ca124ec30f0 100644 --- a/engine/storage/src/test/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelectorTest.java +++ b/engine/storage/src/test/java/org/apache/cloudstack/storage/endpoint/DefaultEndPointSelectorTest.java @@ -17,20 +17,41 @@ package org.apache.cloudstack.storage.endpoint; +import com.cloud.host.Host; +import com.cloud.host.dao.HostDao; import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.Volume; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.storage.DataStoreRole; +import com.cloud.storage.Storage.StoragePoolType; +import com.cloud.storage.VolumeDetailVO; +import com.cloud.storage.dao.VolumeDetailsDao; import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; +import org.apache.cloudstack.engine.subsystem.api.storage.Scope; import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; +import org.apache.cloudstack.engine.subsystem.api.storage.StorageAction; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.storage.RemoteHostEndPoint; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Mockito; -import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mockStatic; + @RunWith(MockitoJUnitRunner.class) public class DefaultEndPointSelectorTest { @@ -46,12 +67,55 @@ public class DefaultEndPointSelectorTest { @Mock private DataStore datastoreMock; - @Spy - private DefaultEndPointSelector defaultEndPointSelectorSpy; + @Mock + private StoragePoolVO storagePoolVOMock; + + @Mock + private PrimaryDataStoreDao _storagePoolDao; + + @Mock + private VolumeDetailsDao _volDetailsDao; + + @Mock + private VolumeDetailVO volumeDetailVOMock; + + @Mock + private EndPoint endPointMock; + + @Mock + ClvmPoolManager clvmPoolManager; + + @Mock + HostDao hostDao; + + static MockedStatic remoteHostEndPointMock; + + @InjectMocks + private DefaultEndPointSelector defaultEndPointSelectorSpy = Mockito.spy(new DefaultEndPointSelector()); + + private static final Long VOLUME_ID = 1L; + private static final Long HOST_ID = 10L; + private static final Long DEST_HOST_ID = 20L; + private static final Long STORE_ID = 100L; + private static final String VOLUME_UUID = "test-volume-uuid"; + + @BeforeClass + public static void init() { + remoteHostEndPointMock = mockStatic(RemoteHostEndPoint.class); + } + + @AfterClass + public static void close() { + remoteHostEndPointMock.close(); + } @Before public void setup() { Mockito.doReturn(volumeInfoMock).when(snapshotInfoMock).getBaseVolume(); + + // Common volume mock setup + Mockito.when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + Mockito.when(volumeInfoMock.getUuid()).thenReturn(VOLUME_UUID); } @Test @@ -197,4 +261,293 @@ public class DefaultEndPointSelectorTest { Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).select(snapshotInfoMock, false); } + + @Test + public void testSelectClvmEndpoint_VolumeWithDestinationHost_CLVM() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.when(volumeInfoMock.getDestinationHostId()).thenReturn(DEST_HOST_ID); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(DEST_HOST_ID); + Mockito.when(volumeInfoMock.getState()).thenReturn(Volume.State.Allocated); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(DEST_HOST_ID); + } + + @Test + public void testSelectClvmEndpoint_VolumeWithDestinationHost_CLVM_NG() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM_NG); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(DEST_HOST_ID); + Mockito.doReturn(DEST_HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(DEST_HOST_ID); + } + + @Test + public void testSelectClvmEndpoint_VolumeWithoutDestinationHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.when(volumeInfoMock.getDestinationHostId()).thenReturn(null); + Mockito.when(datastoreMock.getScope()).thenReturn(Mockito.mock(Scope.class)); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).findEndPointInScope( + Mockito.any(), Mockito.anyString(), Mockito.eq(STORE_ID), Mockito.eq(false)); + Mockito.when(volumeInfoMock.getState()).thenReturn(Volume.State.Allocated); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, false); + + assertNotNull(result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.never()).getEndPointFromHostId(DEST_HOST_ID); + } + + @Test + public void testSelectClvmEndpoint_NonCLVMPool() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.NetworkFilesystem); + Mockito.when(datastoreMock.getScope()).thenReturn(Mockito.mock(Scope.class)); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).findEndPointInScope( + Mockito.any(), Mockito.anyString(), Mockito.eq(STORE_ID), Mockito.eq(false)); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, false); + + assertNotNull(result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.never()).getEndPointFromHostId(DEST_HOST_ID); + } + + @Test + public void testSelectClvmEndpoint_SnapshotWithBaseVolumeDestHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(snapshotInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(snapshotInfoMock.getBaseVolume()).thenReturn(volumeInfoMock); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM_NG); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(DEST_HOST_ID); + Mockito.doReturn(DEST_HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + Mockito.when(volumeInfoMock.getState()).thenReturn(Volume.State.Creating); + + EndPoint result = defaultEndPointSelectorSpy.select(snapshotInfoMock, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(DEST_HOST_ID); + } + + @Test + public void testSelectWithAction_DeleteVolume_CLVMWithLockHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(volumeInfoMock.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(HOST_ID); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, StorageAction.DELETEVOLUME, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(HOST_ID); + } + + @Test + public void testSelectWithAction_DeleteVolume_CLVM_NG_WithLockHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(volumeInfoMock.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM_NG); + Mockito.doReturn(HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(HOST_ID); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, StorageAction.DELETEVOLUME, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(HOST_ID); + } + + @Test + public void testSelectWithAction_DeleteVolume_CLVMWithoutLockHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(volumeInfoMock.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).select(volumeInfoMock, false); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, StorageAction.DELETEVOLUME, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).select(volumeInfoMock, false); + } + + @Test + public void testSelectWithAction_DeleteVolume_NonCLVM() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(volumeInfoMock.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.NetworkFilesystem); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).select(volumeInfoMock, false); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock, StorageAction.DELETEVOLUME, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(_volDetailsDao, Mockito.never()).findDetail(Mockito.anyLong(), Mockito.anyString()); + } + + @Test + public void testSelectObject_CLVMVolumeWithLockHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(HOST_ID); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(HOST_ID); + } + + @Test + public void testSelectObject_CLVM_NG_VolumeWithLockHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM_NG); + Mockito.doReturn(HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(HOST_ID); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(HOST_ID); + } + + @Test + public void testSelectObject_CLVMVolumeWithoutLockHost() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).select(datastoreMock); + RemoteHostEndPoint ep = Mockito.mock(RemoteHostEndPoint.class); + Host lockHost = Mockito.mock(Host.class); + remoteHostEndPointMock.when(() -> RemoteHostEndPoint.getHypervisorHostEndPoint(lockHost)).thenReturn(ep); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).select(datastoreMock); + } + + @Test + public void testSelectObject_CLVMVolumeWithInvalidLockHostId() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).select(datastoreMock); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).select(datastoreMock); + } + + @Test + public void testSelectObject_CLVMVolumeWithEmptyLockHostId() { + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).select(datastoreMock); + + EndPoint result = defaultEndPointSelectorSpy.select(volumeInfoMock); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).select(datastoreMock); + } + + @Test + public void testSelectTwoObjects_TemplateToVolume_CLVMWithDestHost() { + DataObject srcDataMock = Mockito.mock(DataObject.class); + + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM_NG); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).getEndPointFromHostId(DEST_HOST_ID); + Mockito.doReturn(DEST_HOST_ID).when(defaultEndPointSelectorSpy).getClvmLockHostId(volumeInfoMock); + Mockito.when(volumeInfoMock.getState()).thenReturn(Volume.State.Creating); + + EndPoint result = defaultEndPointSelectorSpy.select(srcDataMock, volumeInfoMock, false); + + assertNotNull(result); + assertEquals(endPointMock, result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).getEndPointFromHostId(DEST_HOST_ID); + } + + @Test + public void testSelectTwoObjects_TemplateToVolume_CLVMWithoutDestHost() { + DataObject srcDataMock = Mockito.mock(DataObject.class); + DataStore srcStoreMock = Mockito.mock(DataStore.class); + + Mockito.when(srcDataMock.getDataStore()).thenReturn(srcStoreMock); + Mockito.when(srcStoreMock.getRole()).thenReturn(DataStoreRole.Image); + + Mockito.when(volumeInfoMock.getDataStore()).thenReturn(datastoreMock); + Mockito.when(datastoreMock.getRole()).thenReturn(DataStoreRole.Primary); + Mockito.when(datastoreMock.getId()).thenReturn(STORE_ID); + Mockito.when(_storagePoolDao.findById(STORE_ID)).thenReturn(storagePoolVOMock); + Mockito.when(storagePoolVOMock.getPoolType()).thenReturn(StoragePoolType.CLVM); + Mockito.doReturn(endPointMock).when(defaultEndPointSelectorSpy).findEndPointForImageMove( + srcStoreMock, datastoreMock, false); + + EndPoint result = defaultEndPointSelectorSpy.select(srcDataMock, volumeInfoMock, false); + + assertNotNull(result); + Mockito.verify(defaultEndPointSelectorSpy, Mockito.times(1)).findEndPointForImageMove(srcStoreMock, datastoreMock, false); + } + } diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/datastore/provider/DefaultHostListener.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/datastore/provider/DefaultHostListener.java index 7de9000782e..7644d4688f7 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/datastore/provider/DefaultHostListener.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/datastore/provider/DefaultHostListener.java @@ -37,6 +37,7 @@ import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; import com.cloud.offerings.NetworkOfferingVO; import com.cloud.offerings.dao.NetworkOfferingDao; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.storage.DataStoreRole; import com.cloud.storage.Storage; import com.cloud.storage.StorageManager; @@ -139,6 +140,18 @@ public class DefaultHostListener implements HypervisorHostListener { Map nfsMountOpts = storageManager.getStoragePoolNFSMountOpts(pool, null).first(); Optional.ofNullable(nfsMountOpts).ifPresent(detailsMap::putAll); + + // Propagate CLVM secure zero-fill setting to the host + // Note: This is done during host connection (agent start, MS restart, host reconnection) + // so the setting is non-dynamic. Changes require host reconnection to take effect. + if (ClvmPoolManager.isClvmPoolType(pool.getPoolType())) { + Boolean clvmSecureZeroFill = ClvmPoolManager.CLVMSecureZeroFill.valueIn(poolId); + if (clvmSecureZeroFill != null) { + detailsMap.put("clvmsecurezerofill", String.valueOf(clvmSecureZeroFill)); + logger.debug("Added CLVM secure zero-fill setting: {} for storage pool: {}", clvmSecureZeroFill, pool); + } + } + ModifyStoragePoolCommand cmd = new ModifyStoragePoolCommand(true, pool, detailsMap); cmd.setWait(modifyStoragePoolCommandWait); HostVO host = hostDao.findById(hostId); diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java index 43218b3f6a0..20d677f9013 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java @@ -24,6 +24,7 @@ import com.cloud.configuration.Resource.ResourceType; import com.cloud.dc.VsphereStoragePolicyVO; import com.cloud.dc.dao.VsphereStoragePolicyDao; import com.cloud.storage.StorageManager; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.utils.Pair; import com.cloud.utils.db.Transaction; import com.cloud.utils.db.TransactionCallbackNoReturn; @@ -126,6 +127,7 @@ public class VolumeObject implements VolumeInfo { private boolean directDownload; private String vSphereStoragePolicyId; private boolean followRedirects; + private Long destinationHostId; // For CLVM: hints where volume should be created private List checkpointPaths; private Set checkpointImageStoreUrls; @@ -361,6 +363,30 @@ public class VolumeObject implements VolumeInfo { this.directDownload = directDownload; } + @Override + public Long getDestinationHostId() { + // If not in memory, try to load from the database (volume_details table) + // For CLVM volumes, this uses the CLVM_LOCK_HOST_ID, which serves a dual purpose: + // 1. During creation: hints where to create the volume + // 2. After creation: tracks which host holds the exclusive lock + if (destinationHostId == null && volumeVO != null) { + VolumeDetailVO detail = volumeDetailsDao.findDetail(volumeVO.getId(), ClvmPoolManager.CLVM_LOCK_HOST_ID); + if (detail != null && detail.getValue() != null && !detail.getValue().isEmpty()) { + try { + destinationHostId = Long.parseLong(detail.getValue()); + } catch (NumberFormatException e) { + logger.warn("Invalid CLVM lock host ID value in volume_details for volume {}: {}", volumeVO.getUuid(), detail.getValue()); + } + } + } + return destinationHostId; + } + + @Override + public void setDestinationHostId(Long hostId) { + this.destinationHostId = hostId; + } + public void update() { volumeDao.update(volumeVO.getId(), volumeVO); volumeVO = volumeDao.findById(volumeVO.getId()); diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java index 8731e8791dd..6fe4c2708c5 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java @@ -32,6 +32,8 @@ import java.util.concurrent.ExecutionException; import javax.inject.Inject; +import com.cloud.storage.clvm.ClvmPoolManager; +import com.cloud.vm.VMInstanceVO; import com.cloud.vm.dao.VMInstanceDao; import org.apache.cloudstack.annotation.AnnotationService; import org.apache.cloudstack.annotation.dao.AnnotationDao; @@ -221,6 +223,8 @@ public class VolumeServiceImpl implements VolumeService { private PassphraseDao passphraseDao; @Inject protected DiskOfferingDao diskOfferingDao; + @Inject + ClvmPoolManager clvmPoolManager; public VolumeServiceImpl() { } @@ -2970,4 +2974,173 @@ public class VolumeServiceImpl implements VolumeService { protected String buildVolumePath(long accountId, long volumeId) { return String.format("%s/%s/%s", TemplateConstants.DEFAULT_VOLUME_ROOT_DIR, accountId, volumeId); } + + @Override + public boolean transferVolumeLock(VolumeInfo volume, Long sourceHostId, Long destHostId) { + StoragePoolVO pool = storagePoolDao.findById(volume.getPoolId()); + if (pool == null) { + logger.error("Cannot transfer volume lock for volume {}: storage pool not found", volume.getUuid()); + return false; + } + + logger.info("Transferring CLVM lock for volume {} (pool: {}) from host {} to host {}", + volume.getUuid(), pool.getName(), sourceHostId, destHostId); + + return clvmPoolManager.transferClvmVolumeLock(volume.getUuid(), volume.getId(), volume.getPath(), + pool, sourceHostId, destHostId); + } + + @Override + public Long findVolumeLockHost(VolumeInfo volume) { + if (volume == null) { + logger.warn("Cannot find volume lock host: volume is null"); + return null; + } + + StoragePoolVO pool = storagePoolDao.findById(volume.getPoolId()); + + Long lockHostId = clvmPoolManager.getClvmLockHostId( + volume.getId(), + volume.getUuid(), + volume.getPath(), + pool, + true + ); + + if (lockHostId != null) { + logger.debug("Found actual lock host {} for volume {}", lockHostId, volume.getUuid()); + return lockHostId; + } + + Long instanceId = volume.getInstanceId(); + if (instanceId != null) { + VMInstanceVO vmInstance = vmDao.findById(instanceId); + if (vmInstance != null && vmInstance.getHostId() != null) { + logger.debug("Volume {} is attached to VM {} on host {}", + volume.getUuid(), vmInstance.getUuid(), vmInstance.getHostId()); + return vmInstance.getHostId(); + } + } + + if (pool != null && pool.getClusterId() != null) { + List hosts = _hostDao.findByClusterId(pool.getClusterId()); + if (hosts != null && !hosts.isEmpty()) { + for (HostVO host : hosts) { + if (host.getStatus() == com.cloud.host.Status.Up) { + logger.debug("Using fallback: first UP host {} in cluster {} for volume {}", + host.getId(), pool.getClusterId(), volume.getUuid()); + return host.getId(); + } + } + } + } + + logger.warn("Could not determine lock host for volume {}", volume.getUuid()); + return null; + } + + @Override + public VolumeInfo performLockMigration(VolumeInfo volume, Long destHostId) { + if (volume == null) { + throw new CloudRuntimeException("Cannot perform CLVM lock migration: volume is null"); + } + + String volumeUuid = volume.getUuid(); + logger.info("Starting CLVM lock migration for volume {} (id: {}) to host {}", + volumeUuid, volume.getUuid(), destHostId); + + Long sourceHostId = findVolumeLockHost(volume); + if (sourceHostId == null) { + logger.warn("Could not determine source host for CLVM volume {} lock, assuming volume is not exclusively locked", + volumeUuid); + sourceHostId = destHostId; + } + + if (sourceHostId.equals(destHostId)) { + logger.info("CLVM volume {} already has lock on destination host {}, no migration needed", + volumeUuid, destHostId); + return volume; + } + + logger.info("Migrating CLVM volume {} lock from host {} to host {}", + volumeUuid, sourceHostId, destHostId); + + boolean success = transferVolumeLock(volume, sourceHostId, destHostId); + if (!success) { + throw new CloudRuntimeException( + String.format("Failed to transfer CLVM lock for volume %s from host %s to host %s", + volumeUuid, sourceHostId, destHostId)); + } + + logger.info("Successfully migrated CLVM volume {} lock from host {} to host {}", + volumeUuid, sourceHostId, destHostId); + + return volFactory.getVolume(volume.getId()); + } + + @Override + public boolean areBothPoolsClvmType(StoragePoolType volumePoolType, StoragePoolType vmPoolType) { + if (volumePoolType == null || vmPoolType == null) { + logger.debug("Cannot check if both pools are CLVM type: one or both pool types are null"); + return false; + } + return ClvmPoolManager.isClvmPoolType(volumePoolType) && + ClvmPoolManager.isClvmPoolType(vmPoolType); + } + + @Override + public boolean isLockTransferRequired(VolumeInfo volumeToAttach, StoragePoolType volumePoolType, StoragePoolType vmPoolType, + Long volumePoolId, Long vmPoolId, Long vmHostId) { + if (volumePoolType != null && !ClvmPoolManager.isClvmPoolType(volumePoolType)) { + return false; + } + + if (volumePoolId == null || !volumePoolId.equals(vmPoolId)) { + return false; + } + + Long volumeLockHostId = findVolumeLockHost(volumeToAttach); + + if (volumeLockHostId == null) { + VolumeVO volumeVO = _volumeDao.findById(volumeToAttach.getId()); + if (volumeVO != null && volumeVO.getState() == Volume.State.Ready && volumeVO.getInstanceId() == null) { + logger.debug("CLVM volume {} is detached on same pool, lock transfer may be needed", + volumeToAttach.getUuid()); + return true; + } + } + + if (volumeLockHostId != null && vmHostId != null && !volumeLockHostId.equals(vmHostId)) { + logger.info("CLVM lock transfer required: Volume {} lock is on host {} but VM is on host {}", + volumeToAttach.getUuid(), volumeLockHostId, vmHostId); + return true; + } + + return false; + } + + @Override + public boolean isLightweightMigrationNeeded(StoragePoolType volumePoolType, StoragePoolType vmPoolType, + String volumePoolPath, String vmPoolPath) { + if (!areBothPoolsClvmType(volumePoolType, vmPoolType)) { + return false; + } + + String volumeVgName = extractVgNameFromPath(volumePoolPath); + String vmVgName = extractVgNameFromPath(vmPoolPath); + + if (volumeVgName != null && volumeVgName.equals(vmVgName)) { + logger.info("CLVM lightweight migration detected: Volume is in same VG ({}), only lock transfer needed (no data copy)", volumeVgName); + return true; + } + + return false; + } + + private String extractVgNameFromPath(String poolPath) { + if (poolPath == null) { + return null; + } + return poolPath.startsWith("/") ? poolPath.substring(1) : poolPath; + } } diff --git a/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java b/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java new file mode 100644 index 00000000000..38af2a7550b --- /dev/null +++ b/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java @@ -0,0 +1,520 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.storage.volume; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.eq; + +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.storage.clvm.ClvmPoolManager; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.dao.VMInstanceDao; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.storage.Storage.StoragePoolType; +import com.cloud.storage.Volume; +import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.VolumeDao; + +/** + * Tests for CLVM lock management methods in VolumeServiceImpl. + */ +@RunWith(MockitoJUnitRunner.class) +public class VolumeServiceImplClvmTest { + + @Spy + @InjectMocks + private VolumeServiceImpl volumeService; + + @Mock + private VolumeDao volumeDao; + + @Mock + private PrimaryDataStoreDao storagePoolDao; + + @Mock + private HostDao _hostDao; + + @Mock + private VMInstanceDao vmDao; + + @Mock + private VolumeDataFactory volFactory; + + @Mock + private VolumeInfo volumeInfoMock; + + @Mock + private VolumeVO volumeVOMock; + + @Mock + private StoragePoolVO storagePoolVOMock; + + @Mock + private HostVO hostVOMock; + + @Mock + private VMInstanceVO vmInstanceVOMock; + + @Mock + private ClvmPoolManager clvmPoolManager; + + private static final Long VOLUME_ID = 1L; + private static final Long POOL_ID_1 = 100L; + private static final Long POOL_ID_2 = 200L; + private static final Long HOST_ID_1 = 10L; + private static final Long HOST_ID_2 = 20L; + private static final String POOL_PATH_VG1 = "/vg1"; + + @Before + public void setup() { + when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + when(volumeInfoMock.getUuid()).thenReturn("test-volume-uuid"); + when(volumeInfoMock.getPath()).thenReturn("test-volume-path"); + + volumeService.storagePoolDao = storagePoolDao; + volumeService._hostDao = _hostDao; + volumeService.vmDao = vmDao; + volumeService.volFactory = volFactory; + volumeService._volumeDao = volumeDao; + volumeService.clvmPoolManager = clvmPoolManager; + } + + @Test + public void testAreBothPoolsClvmType_BothCLVM() { + assertTrue(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM, StoragePoolType.CLVM)); + } + + @Test + public void testAreBothPoolsClvmType_BothCLVM_NG() { + assertTrue(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM_NG, StoragePoolType.CLVM_NG)); + } + + @Test + public void testAreBothPoolsClvmType_MixedCLVMAndCLVM_NG() { + assertTrue(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM, StoragePoolType.CLVM_NG)); + assertTrue(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM_NG, StoragePoolType.CLVM)); + } + + @Test + public void testAreBothPoolsClvmType_OneCLVMOneNFS() { + assertFalse(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM, StoragePoolType.NetworkFilesystem)); + assertFalse(volumeService.areBothPoolsClvmType(StoragePoolType.NetworkFilesystem, StoragePoolType.CLVM)); + } + + @Test + public void testAreBothPoolsClvmType_OneCLVM_NGOneNFS() { + assertFalse(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM_NG, StoragePoolType.NetworkFilesystem)); + assertFalse(volumeService.areBothPoolsClvmType(StoragePoolType.NetworkFilesystem, StoragePoolType.CLVM_NG)); + } + + @Test + public void testAreBothPoolsClvmType_BothNFS() { + assertFalse(volumeService.areBothPoolsClvmType(StoragePoolType.NetworkFilesystem, StoragePoolType.NetworkFilesystem)); + } + + @Test + public void testAreBothPoolsClvmType_NullVolumePoolType() { + assertFalse(volumeService.areBothPoolsClvmType(null, StoragePoolType.CLVM)); + } + + @Test + public void testAreBothPoolsClvmType_NullVmPoolType() { + assertFalse(volumeService.areBothPoolsClvmType(StoragePoolType.CLVM, null)); + } + + @Test + public void testAreBothPoolsClvmType_BothNull() { + assertFalse(volumeService.areBothPoolsClvmType(null, null)); + } + + + @Test + public void testIsLockTransferRequired_NonCLVMPool() { + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.NetworkFilesystem, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_1, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_DifferentPools() { + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_2, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_NullPoolIds() { + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + null, POOL_ID_1, HOST_ID_1)); + + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, null, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_DetachedVolumeReady() { + when(volumeDao.findById(VOLUME_ID)).thenReturn(volumeVOMock); + when(volumeVOMock.getState()).thenReturn(Volume.State.Ready); + when(volumeVOMock.getInstanceId()).thenReturn(null); // Detached + + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(null); + + assertTrue(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_1, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_DetachedVolumeNotReady() { + when(volumeDao.findById(VOLUME_ID)).thenReturn(volumeVOMock); + when(volumeVOMock.getState()).thenReturn(Volume.State.Allocated); + + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(null); + + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_1, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_DifferentHosts() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_1); + + assertTrue(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_1, HOST_ID_2)); + } + + @Test + public void testIsLockTransferRequired_SameHost() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_1); + + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_1, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_NullVmHostId() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_1); + + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_1, null)); + } + + @Test + public void testIsLockTransferRequired_CLVM_NG_DifferentHosts() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_1); + + assertTrue(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM_NG, StoragePoolType.CLVM_NG, + POOL_ID_1, POOL_ID_1, HOST_ID_2)); + } + + @Test + public void testIsLightweightMigrationNeeded_NonCLVMPools() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.NetworkFilesystem, StoragePoolType.NetworkFilesystem, + POOL_PATH_VG1, POOL_PATH_VG1)); + } + + @Test + public void testIsLightweightMigrationNeeded_OneCLVMOneNFS() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.NetworkFilesystem, + POOL_PATH_VG1, POOL_PATH_VG1)); + } + + @Test + public void testIsLightweightMigrationNeeded_SameVG() { + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "/vg1", "/vg1")); + } + + @Test + public void testIsLightweightMigrationNeeded_SameVG_NoSlash() { + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "vg1", "vg1")); + } + + @Test + public void testIsLightweightMigrationNeeded_SameVG_MixedSlash() { + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "/vg1", "vg1")); + + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "vg1", "/vg1")); + } + + @Test + public void testIsLightweightMigrationNeeded_DifferentVG() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "/vg1", "/vg2")); + } + + @Test + public void testIsLightweightMigrationNeeded_CLVM_NG_SameVG() { + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM_NG, StoragePoolType.CLVM_NG, + "/vg1", "/vg1")); + } + + @Test + public void testIsLightweightMigrationNeeded_CLVM_NG_DifferentVG() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM_NG, StoragePoolType.CLVM_NG, + "/vg1", "/vg2")); + } + + @Test + public void testIsLightweightMigrationNeeded_MixedCLVM_CLVM_NG_SameVG() { + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM_NG, + "/vg1", "/vg1")); + + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM_NG, StoragePoolType.CLVM, + "/vg1", "/vg1")); + } + + @Test + public void testIsLightweightMigrationNeeded_NullVolumePath() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + null, "/vg1")); + } + + @Test + public void testIsLightweightMigrationNeeded_NullVmPath() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "/vg1", null)); + } + + @Test + public void testIsLightweightMigrationNeeded_BothPathsNull() { + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + null, null)); + } + + @Test + public void testIsLightweightMigrationNeeded_ComplexVGNames() { + assertTrue(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "/cloudstack-vg-01", "/cloudstack-vg-01")); + + assertFalse(volumeService.isLightweightMigrationNeeded( + StoragePoolType.CLVM, StoragePoolType.CLVM, + "/cloudstack-vg-01", "/cloudstack-vg-02")); + } + + @Test + public void testTransferVolumeLock_Success() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + when(volumeInfoMock.getPath()).thenReturn("/dev/vg1/volume-1"); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(storagePoolVOMock.getName()).thenReturn("test-pool"); + when(clvmPoolManager.transferClvmVolumeLock( + "test-volume-uuid", VOLUME_ID, "/dev/vg1/volume-1", storagePoolVOMock, HOST_ID_1, HOST_ID_2)) + .thenReturn(true); + + assertTrue(volumeService.transferVolumeLock(volumeInfoMock, HOST_ID_1, HOST_ID_2)); + } + + @Test + public void testTransferVolumeLock_Failure() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + when(volumeInfoMock.getPath()).thenReturn("/dev/vg1/volume-1"); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(storagePoolVOMock.getName()).thenReturn("test-pool"); + when(clvmPoolManager.transferClvmVolumeLock( + "test-volume-uuid", VOLUME_ID, "/dev/vg1/volume-1", storagePoolVOMock, HOST_ID_1, HOST_ID_2)) + .thenReturn(false); + + assertFalse(volumeService.transferVolumeLock(volumeInfoMock, HOST_ID_1, HOST_ID_2)); + } + + @Test + public void testTransferVolumeLock_PoolNotFound() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(null); + + assertFalse(volumeService.transferVolumeLock(volumeInfoMock, HOST_ID_1, HOST_ID_2)); + } + + @Test + public void testFindVolumeLockHost_NullVolume() { + Long result = volumeService.findVolumeLockHost(null); + assertNull(result); + } + + @Test + public void testFindVolumeLockHost_ExplicitLockFound() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("test-volume-path"), eq(storagePoolVOMock), eq(true))) + .thenReturn(HOST_ID_1); + + Long result = volumeService.findVolumeLockHost(volumeInfoMock); + assertEquals(HOST_ID_1, result); + } + + @Test + public void testFindVolumeLockHost_FromAttachedVM() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("test-volume-path"), eq(storagePoolVOMock), eq(true))) + .thenReturn(null); + when(volumeInfoMock.getInstanceId()).thenReturn(100L); + when(vmDao.findById(100L)).thenReturn(vmInstanceVOMock); + when(vmInstanceVOMock.getUuid()).thenReturn("vm-uuid"); + when(vmInstanceVOMock.getHostId()).thenReturn(HOST_ID_1); + + Long result = volumeService.findVolumeLockHost(volumeInfoMock); + assertEquals(HOST_ID_1, result); + } + + @Test + public void testFindVolumeLockHost_FallbackToClusterHost() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("test-volume-path"), eq(storagePoolVOMock), eq(true))) + .thenReturn(null); + when(volumeInfoMock.getInstanceId()).thenReturn(null); + when(storagePoolVOMock.getClusterId()).thenReturn(10L); + when(hostVOMock.getId()).thenReturn(HOST_ID_1); + when(hostVOMock.getStatus()).thenReturn(com.cloud.host.Status.Up); + when(_hostDao.findByClusterId(10L)).thenReturn(java.util.Collections.singletonList(hostVOMock)); + + Long result = volumeService.findVolumeLockHost(volumeInfoMock); + assertEquals(HOST_ID_1, result); + } + + @Test + public void testFindVolumeLockHost_NoHostFound() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("test-volume-path"), eq(storagePoolVOMock), eq(true))) + .thenReturn(null); + when(volumeInfoMock.getInstanceId()).thenReturn(null); + when(storagePoolVOMock.getClusterId()).thenReturn(10L); + when(_hostDao.findByClusterId(10L)).thenReturn(java.util.Collections.emptyList()); + + Long result = volumeService.findVolumeLockHost(volumeInfoMock); + assertNull(result); + } + + @Test + public void testPerformLockMigration_Success() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + when(volumeInfoMock.getPath()).thenReturn("/dev/vg1/volume-1"); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("/dev/vg1/volume-1"), eq(storagePoolVOMock), eq(true))) + .thenReturn(HOST_ID_1); + when(storagePoolVOMock.getName()).thenReturn("test-pool"); + when(clvmPoolManager.transferClvmVolumeLock( + "test-volume-uuid", VOLUME_ID, "/dev/vg1/volume-1", storagePoolVOMock, HOST_ID_1, HOST_ID_2)) + .thenReturn(true); + when(volFactory.getVolume(VOLUME_ID)).thenReturn(volumeInfoMock); + + VolumeInfo result = volumeService.performLockMigration(volumeInfoMock, HOST_ID_2); + assertNotNull(result); + } + + @Test + public void testPerformLockMigration_SameHost() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("test-volume-path"), eq(storagePoolVOMock), eq(true))) + .thenReturn(HOST_ID_1); + + VolumeInfo result = volumeService.performLockMigration(volumeInfoMock, HOST_ID_1); + assertEquals(volumeInfoMock, result); + } + + @Test + public void testPerformLockMigration_SourceHostNull() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("test-volume-path"), eq(storagePoolVOMock), eq(true))) + .thenReturn(null); + when(volumeInfoMock.getInstanceId()).thenReturn(null); + when(storagePoolVOMock.getClusterId()).thenReturn(null); + + VolumeInfo result = volumeService.performLockMigration(volumeInfoMock, HOST_ID_2); + assertNotNull(result); + } + + @Test(expected = com.cloud.utils.exception.CloudRuntimeException.class) + public void testPerformLockMigration_NullVolume() { + volumeService.performLockMigration(null, HOST_ID_2); + } + + @Test(expected = com.cloud.utils.exception.CloudRuntimeException.class) + public void testPerformLockMigration_TransferFails() { + when(volumeInfoMock.getPoolId()).thenReturn(POOL_ID_1); + when(volumeInfoMock.getId()).thenReturn(VOLUME_ID); + when(volumeInfoMock.getPath()).thenReturn("/dev/vg1/volume-1"); + when(storagePoolDao.findById(POOL_ID_1)).thenReturn(storagePoolVOMock); + when(clvmPoolManager.getClvmLockHostId( + eq(VOLUME_ID), eq("test-volume-uuid"), eq("/dev/vg1/volume-1"), eq(storagePoolVOMock), eq(true))) + .thenReturn(HOST_ID_1); + when(storagePoolVOMock.getName()).thenReturn("test-pool"); + when(clvmPoolManager.transferClvmVolumeLock( + "test-volume-uuid", VOLUME_ID, "/dev/vg1/volume-1", storagePoolVOMock, HOST_ID_1, HOST_ID_2)) + .thenReturn(false); + + volumeService.performLockMigration(volumeInfoMock, HOST_ID_2); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index d0a162e0f6f..4a93b1bce4a 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -79,6 +79,7 @@ import org.apache.cloudstack.command.ReconcileCommandService; import org.apache.cloudstack.command.ReconcileCommandUtils; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.gpu.GpuDevice; +import org.apache.cloudstack.storage.command.browser.ListDataStoreObjectsAnswer; import org.apache.cloudstack.storage.command.browser.ListDataStoreObjectsCommand; import org.apache.cloudstack.storage.configdrive.ConfigDrive; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; @@ -228,6 +229,7 @@ import com.cloud.storage.JavaStorageLayer; import com.cloud.storage.Storage; import com.cloud.storage.Storage.StoragePoolType; import com.cloud.storage.StorageLayer; +import com.cloud.storage.clvm.ClvmPoolManager; import com.cloud.storage.Volume; import com.cloud.storage.resource.StorageSubsystemCommandHandler; import com.cloud.storage.resource.StorageSubsystemCommandHandlerBase; @@ -2584,6 +2586,8 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv if (pool.getType() == StoragePoolType.CLVM && volFormat == PhysicalDiskFormat.RAW) { return "CLVM"; + } else if (poolType == StoragePoolType.CLVM_NG) { + return "CLVM_NG"; } else if ((poolType == StoragePoolType.NetworkFilesystem || poolType == StoragePoolType.SharedMountPoint || poolType == StoragePoolType.Filesystem @@ -3822,13 +3826,18 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv final String glusterVolume = pool.getSourceDir().replace("/", ""); disk.defNetworkBasedDisk(glusterVolume + path.replace(mountpoint, ""), pool.getSourceHost(), pool.getSourcePort(), null, null, devId, diskBusType, DiskProtocol.GLUSTER, DiskDef.DiskFmtType.QCOW2); - } else if (pool.getType() == StoragePoolType.CLVM || physicalDisk.getFormat() == PhysicalDiskFormat.RAW) { + } else if (pool.getType() == StoragePoolType.CLVM || pool.getType() == StoragePoolType.CLVM_NG || physicalDisk.getFormat() == PhysicalDiskFormat.RAW) { if (volume.getType() == Volume.Type.DATADISK && !(isWindowsTemplate && isUefiEnabled)) { disk.defBlockBasedDisk(physicalDisk.getPath(), devId, diskBusTypeData); - } - else { + } else { disk.defBlockBasedDisk(physicalDisk.getPath(), devId, diskBusType); } + + // CLVM_NG uses QCOW2 format on block devices, override the default RAW format + if (pool.getType() == StoragePoolType.CLVM_NG) { + disk.setDiskFormatType(DiskDef.DiskFmtType.QCOW2); + } + if (pool.getType() == StoragePoolType.Linstor && isQemuDiscardBugFree(diskBusType)) { disk.setDiscard(DiscardType.UNMAP); } @@ -5728,10 +5737,73 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public Answer listFilesAtPath(ListDataStoreObjectsCommand command) { DataStoreTO store = command.getStore(); - KVMStoragePool storagePool = storagePoolManager.getStoragePool(StoragePoolType.NetworkFilesystem, store.getUuid()); + StoragePoolType poolType = StoragePoolType.NetworkFilesystem; + if (store instanceof PrimaryDataStoreTO) { + poolType = ((PrimaryDataStoreTO) store).getPoolType(); + } + KVMStoragePool storagePool = storagePoolManager.getStoragePool(poolType, store.getUuid()); + if (ClvmPoolManager.isClvmPoolType(poolType)) { + return listLvmVolumes(storagePool.getLocalPath(), command.getStartIndex(), command.getPageSize()); + } return listFilesAtPath(storagePool.getLocalPath(), command.getPath(), command.getStartIndex(), command.getPageSize()); } + private Answer listLvmVolumes(String localPath, int startIndex, int pageSize) { + String vgName = localPath; + if (vgName.startsWith("/")) { + String[] parts = vgName.split("/"); + for (int i = parts.length - 1; i >= 0; i--) { + if (!parts[i].isEmpty()) { + vgName = parts[i]; + break; + } + } + } + + Script lvs = new Script("lvs", 30000, logger); + lvs.add("--noheadings"); + lvs.add("--nosuffix"); + lvs.add("-o", "lv_name,lv_size"); + lvs.add("--units", "b"); + lvs.add(vgName); + AllLinesParser parser = new AllLinesParser(); + String result = lvs.execute(parser); + + List names = new ArrayList<>(); + List paths = new ArrayList<>(); + List absPaths = new ArrayList<>(); + List isDirs = new ArrayList<>(); + List sizes = new ArrayList<>(); + List lastModified = new ArrayList<>(); + + if (result != null) { + logger.warn("lvs listing failed for VG {}: {}", vgName, result); + return new ListDataStoreObjectsAnswer(false, 0, names, paths, absPaths, isDirs, sizes, lastModified); + } + + List entries = new ArrayList<>(); + for (String line : parser.getLines().split("\n")) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) continue; + String[] cols = trimmed.split("\\s+"); + if (cols.length >= 2) entries.add(cols); + } + + int count = entries.size(); + for (int i = startIndex; i < startIndex + pageSize && i < count; i++) { + String lvName = entries.get(i)[0]; + long size = 0; + try { size = Long.parseLong(entries.get(i)[1]); } catch (NumberFormatException ignored) {} + names.add(lvName); + paths.add("/" + lvName); + absPaths.add("/dev/" + vgName + "/" + lvName); + isDirs.add(false); + sizes.add(size); + lastModified.add(0L); + } + return new ListDataStoreObjectsAnswer(true, count, names, paths, absPaths, isDirs, sizes, lastModified); + } + public boolean addNetworkRules(final String vmName, final String vmId, final String guestIP, final String guestIP6, final String sig, final String seq, final String mac, final String rules, final String vif, final String brname, final String secIps) { if (!canBridgeFirewall) { @@ -6820,4 +6892,237 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public String getGuestCpuArch() { return guestCpuArch; } + + /** + * CLVM volume state for migration operations on source host + */ + public enum ClvmVolumeState { + /** Shared mode (-asy) - used before migration to allow both hosts to access volume */ + SHARED("-asy", "shared", "Before migration: activating in shared mode"), + + /** Deactivate (-an) - used after successful migration to release volume on source */ + DEACTIVATE("-an", "deactivated", "After successful migration: deactivating volume"), + + /** Exclusive mode (-aey) - used after failed migration to revert to original exclusive state */ + EXCLUSIVE("-aey", "exclusive", "After failed migration: reverting to exclusive mode"); + + private final String lvchangeFlag; + private final String description; + private final String logMessage; + + ClvmVolumeState(String lvchangeFlag, String description, String logMessage) { + this.lvchangeFlag = lvchangeFlag; + this.description = description; + this.logMessage = logMessage; + } + + public String getLvchangeFlag() { + return lvchangeFlag; + } + + public String getDescription() { + return description; + } + + public String getLogMessage() { + return logMessage; + } + } + + public static void modifyClvmVolumesStateForMigration(List disks, VirtualMachineTO vmSpec, ClvmVolumeState state) { + for (DiskDef disk : disks) { + if (isClvmVolume(disk, vmSpec)) { + String volumePath = disk.getDiskPath(); + try { + modifyClvmVolumeState(volumePath, state.getLvchangeFlag(), state.getDescription(), state.getLogMessage()); + } catch (Exception e) { + LOGGER.error("[CLVM Migration] Exception while setting volume [{}] to {} state: {}", + volumePath, state.getDescription(), e.getMessage(), e); + } + } + } + } + + private static void modifyClvmVolumeState(String volumePath, String lvchangeFlag, + String stateDescription, String logMessage) { + try { + LOGGER.info("{} for volume [{}]", logMessage, volumePath); + + Script cmd = new Script("lvchange", Duration.standardSeconds(300), LOGGER); + cmd.add(lvchangeFlag); + cmd.add(volumePath); + + String result = cmd.execute(); + if (result != null) { + String errorMsg = String.format( + "Failed to set volume [%s] to %s state. Command result: %s", + volumePath, stateDescription, result); + LOGGER.error(errorMsg); + throw new CloudRuntimeException(errorMsg); + } else { + LOGGER.info("Successfully set volume [{}] to {} state.", + volumePath, stateDescription); + } + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + String errorMsg = String.format( + "Exception while setting volume [%s] to %s state: %s", + volumePath, stateDescription, e.getMessage()); + LOGGER.error(errorMsg, e); + throw new CloudRuntimeException(errorMsg, e); + } + } + + public static void activateClvmVolumeExclusive(String volumePath) { + modifyClvmVolumeState(volumePath, ClvmVolumeState.EXCLUSIVE.getLvchangeFlag(), + ClvmVolumeState.EXCLUSIVE.getDescription(), + "Activating CLVM volume in exclusive mode"); + } + + public static void deactivateClvmVolume(String volumePath) { + try { + modifyClvmVolumeState(volumePath, ClvmVolumeState.DEACTIVATE.getLvchangeFlag(), + ClvmVolumeState.DEACTIVATE.getDescription(), + "Deactivating CLVM volume"); + } catch (Exception e) { + LOGGER.warn("Failed to deactivate CLVM volume {}: {}", volumePath, e.getMessage()); + } + } + + public static void setClvmVolumeToSharedMode(String volumePath) { + try { + modifyClvmVolumeState(volumePath, ClvmVolumeState.SHARED.getLvchangeFlag(), + ClvmVolumeState.SHARED.getDescription(), + "Setting CLVM volume to shared mode"); + } catch (Exception e) { + LOGGER.warn("Failed to set CLVM volume {} to shared mode: {}", volumePath, e.getMessage()); + } + } + + /** + * Determines if a disk is on a CLVM storage pool by checking the actual pool type from VirtualMachineTO. + * This is the most reliable method as it uses CloudStack's own storage pool information. + * + * @param disk The disk definition to check + * @param resource The LibvirtComputingResource instance (unused but kept for compatibility) + * @param vmSpec The VirtualMachineTO specification containing disk and pool information + * @return true if the disk is on a CLVM storage pool, false otherwise + */ + private static boolean isClvmVolume(DiskDef disk, VirtualMachineTO vmSpec) { + String diskPath = disk.getDiskPath(); + if (diskPath == null || vmSpec == null) { + return false; + } + + try { + if (vmSpec.getDisks() != null) { + for (DiskTO diskTO : vmSpec.getDisks()) { + if (!(diskTO.getData() instanceof VolumeObjectTO)) { + continue; + } + VolumeObjectTO volumeTO = (VolumeObjectTO) diskTO.getData(); + if (!diskPath.equals(volumeTO.getPath()) && !diskPath.equals(diskTO.getPath())) { + continue; + } + DataStoreTO dataStore = volumeTO.getDataStore(); + if (!(dataStore instanceof PrimaryDataStoreTO)) { + continue; + } + PrimaryDataStoreTO primaryStore = (PrimaryDataStoreTO) dataStore; + boolean isClvm = StoragePoolType.CLVM == primaryStore.getPoolType() || + StoragePoolType.CLVM_NG == primaryStore.getPoolType(); + LOGGER.debug("Disk {} identified as CLVM/CLVM_NG={} via VirtualMachineTO pool type: {}", + diskPath, isClvm, primaryStore.getPoolType()); + return isClvm; + } + } + + if (diskPath.startsWith("/dev/") && !diskPath.contains("/dev/mapper/")) { + String vgName = extractVolumeGroupFromPath(diskPath); + if (vgName != null) { + boolean isClustered = checkIfVolumeGroupIsClustered(vgName); + LOGGER.debug("Disk {} VG {} identified as clustered={} via vgs attribute check", + diskPath, vgName, isClustered); + return isClustered; + } + } + + } catch (Exception e) { + LOGGER.error("Error determining if volume {} is CLVM: {}", diskPath, e.getMessage(), e); + } + + return false; + } + + /** + * Extracts the volume group name from a device path. + * + * @param devicePath The device path (e.g., /dev/vgname/lvname) + * @return The volume group name, or null if cannot be determined + */ + static String extractVolumeGroupFromPath(String devicePath) { + if (devicePath == null || !devicePath.startsWith("/dev/")) { + return null; + } + + // Format: /dev// + String[] parts = devicePath.split("/"); + if (parts.length >= 3) { + return parts[2]; // ["", "dev", "vgname", ...] + } + + return null; + } + + /** + * Checks if a volume group is clustered (CLVM) by examining its attributes. + * Uses 'vgs' command to check for the clustered/shared flag in VG attributes. + * + * VG Attr format (6 characters): wz--nc or wz--ns + * Position 6: Clustered flag - 'c' = CLVM (clustered), 's' = shared (lvmlockd), '-' = not clustered + * + * @param vgName The volume group name + * @return true if the VG is clustered or shared, false otherwise + */ + static boolean checkIfVolumeGroupIsClustered(String vgName) { + if (vgName == null) { + return false; + } + + try { + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + Script vgsCmd = new Script("vgs", 30000, LOGGER); + vgsCmd.add("--noheadings"); + vgsCmd.add("--unbuffered"); + vgsCmd.add("-o"); + vgsCmd.add("vg_attr"); + vgsCmd.add(vgName); + + String result = vgsCmd.execute(parser); + + if (result == null && parser.getLines() != null) { + String output = parser.getLines(); + if (output != null && !output.isEmpty()) { + String vgAttr = output.trim(); + if (vgAttr.length() >= 6) { + char clusterFlag = vgAttr.charAt(5); // Position 6 (0-indexed 5) + boolean isClustered = (clusterFlag == 'c' || clusterFlag == 's'); + LOGGER.debug("VG {} has attributes '{}', cluster/shared flag '{}' = {}", + vgName, vgAttr, clusterFlag, isClustered); + return isClustered; + } else { + LOGGER.warn("VG {} attributes '{}' have unexpected format (expected 6+ chars)", vgName, vgAttr); + } + } + } else { + LOGGER.warn("Failed to get VG attributes for {}: {}", vgName, result); + } + + } catch (Exception e) { + LOGGER.debug("Error checking if VG {} is clustered: {}", vgName, e.getMessage()); + } + + return false; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapper.java new file mode 100644 index 00000000000..18c4324addf --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapper.java @@ -0,0 +1,173 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.Answer; +import org.apache.cloudstack.storage.clvm.command.ClvmLockTransferCommand; +import org.apache.cloudstack.storage.clvm.command.ClvmLockTransferAnswer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.script.Script; +import com.cloud.utils.script.OutputInterpreter; + +@ResourceWrapper(handles = ClvmLockTransferCommand.class) +public class LibvirtClvmLockTransferCommandWrapper + extends CommandWrapper { + + @Override + public Answer execute(ClvmLockTransferCommand cmd, LibvirtComputingResource serverResource) { + String lvPath = cmd.getLvPath(); + ClvmLockTransferCommand.Operation operation = cmd.getOperation(); + String volumeUuid = cmd.getVolumeUuid(); + + logger.info("Executing CLVM lock transfer: operation={}, lv={}, volume={}", + operation, lvPath, volumeUuid); + + try { + + if (operation == ClvmLockTransferCommand.Operation.QUERY_LOCK_STATE) { + return handleQueryLockState(cmd, lvPath, volumeUuid); + } + + String lvchangeOpt; + String operationDesc; + switch (operation) { + case DEACTIVATE: + lvchangeOpt = "-an"; + operationDesc = "deactivated"; + break; + case ACTIVATE_EXCLUSIVE: + lvchangeOpt = "-aey"; + operationDesc = "activated exclusively"; + break; + case ACTIVATE_SHARED: + lvchangeOpt = "-asy"; + operationDesc = "activated in shared mode"; + break; + default: + return new ClvmLockTransferAnswer(cmd, false, "Unknown operation: " + operation); + } + + Script script = new Script("/usr/sbin/lvchange", 60000, logger); + script.add(lvchangeOpt); + script.add(lvPath); + + String result = script.execute(); + + if (result != null) { + logger.error("CLVM lock transfer failed for volume {}: {}", + volumeUuid, result); + return new ClvmLockTransferAnswer(cmd, false, + String.format("lvchange %s %s failed: %s", lvchangeOpt, lvPath, result)); + } + + logger.info("Successfully executed CLVM lock transfer: {} {} for volume {}", + lvchangeOpt, lvPath, volumeUuid); + + return new ClvmLockTransferAnswer(cmd, true, + String.format("Successfully %s CLVM volume %s", operationDesc, volumeUuid)); + + } catch (Exception e) { + logger.error("Exception during CLVM lock transfer for volume {}: {}", + volumeUuid, e.getMessage(), e); + return new ClvmLockTransferAnswer(cmd, false, "Exception: " + e.getMessage()); + } + } + + /** + * Query whether this host currently has the CLVM LV activated locally. + * Executes: lvs -o lv_attr,lv_host,lv_active --noheadings + * + * lv_attr[4]=='a' (isActive) is LOCAL and is the authoritative signal — true only on + * the host where the LV is currently activated. The management server fans out this + * query to all cluster hosts; the one returning isActive=true is the lock holder. + * lv_attr[5]=='o' (isOpen) means a VM has the device open on this host (doing I/O). + * lv_host is retained for diagnostic logging only — do NOT use it to identify the + * lock holder. + */ + private Answer handleQueryLockState(ClvmLockTransferCommand cmd, String lvPath, String volumeUuid) { + try { + Script script = new Script("/usr/sbin/lvs", 30000, logger); + script.add("-o"); + script.add("lv_attr,lv_host"); + script.add("--noheadings"); + script.add(lvPath); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = script.execute(parser); + + if (result != null) { + logger.error("Failed to query lock state for volume {}: {}", volumeUuid, result); + return new ClvmLockTransferAnswer(cmd, false, + String.format("lvs command failed: %s", result)); + } + + String[] lines = parser.getLines().split("\n"); + String dataLine = null; + + for (String line : lines) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && + trimmed.length() >= 10 && + "-wrsvmpco".indexOf(trimmed.charAt(0)) >= 0) { + dataLine = trimmed; + break; + } + } + + if (dataLine == null) { + String allOutput = parser.getLines(); + logger.warn("Could not find lv_attr data line in lvs output for volume {}: {}", + volumeUuid, allOutput); + return new ClvmLockTransferAnswer(cmd, false, + String.format("Could not parse lvs output. Full output: %s", allOutput)); + } + + logger.debug("Parsed lv_attr data line for volume {}: {}", volumeUuid, dataLine); + + String[] parts = dataLine.split("\\s+"); + if (parts.length < 1) { + return new ClvmLockTransferAnswer(cmd, false, "Invalid lvs output format"); + } + + String lvAttr = parts[0]; + // lv_host: for diagnostics only, unreliable for lock-holder identification + String hostname = parts.length > 1 ? parts[1] : null; + + // lv_attr[4]=='a' → LV is active on THIS host (local activation state) + boolean isActive = lvAttr.length() > 4 && lvAttr.charAt(4) == 'a'; + // lv_attr[5]=='o' → a process has the device file open on this host (VM doing I/O) + boolean isOpen = lvAttr.length() > 5 && lvAttr.charAt(5) == 'o'; + + logger.info("Queried lock state for volume {}: attr={}, hostname={}, active={}, open={}", + volumeUuid, lvAttr, hostname, isActive, isOpen); + + return new ClvmLockTransferAnswer(cmd, true, + String.format("Lock state: active=%s, open=%s, host=%s", + isActive, isOpen, hostname != null ? hostname : "none"), + hostname, isActive, isOpen, lvAttr); + + } catch (Exception e) { + logger.error("Exception during lock state query for volume {}: {}", + volumeUuid, e.getMessage(), e); + return new ClvmLockTransferAnswer(cmd, false, "Exception: " + e.getMessage()); + } + } + +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java index f54918bbc22..ed02ae6da38 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java @@ -1,5 +1,3 @@ -// -// Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file @@ -42,9 +40,16 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import com.cloud.agent.api.VgpuTypesInfo; +import com.cloud.agent.api.to.DataTO; import com.cloud.agent.api.to.GPUDeviceTO; import com.cloud.hypervisor.kvm.resource.LibvirtGpuDef; import com.cloud.hypervisor.kvm.resource.LibvirtXMLParser; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.Ternary; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.VirtualMachine; import org.apache.cloudstack.utils.security.ParserUtils; import org.apache.commons.collections.MapUtils; import org.apache.commons.collections4.CollectionUtils; @@ -69,7 +74,6 @@ import com.cloud.agent.api.Command; import com.cloud.agent.api.MigrateAnswer; import com.cloud.agent.api.MigrateCommand; import com.cloud.agent.api.MigrateCommand.MigrateDiskInfo; -import com.cloud.agent.api.to.DataTO; import com.cloud.agent.api.to.DiskTO; import com.cloud.agent.api.to.DpdkTO; import com.cloud.agent.api.to.VirtualMachineTO; @@ -82,11 +86,6 @@ import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.DiskDef; import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.InterfaceDef; import com.cloud.hypervisor.kvm.resource.MigrateKVMAsync; import com.cloud.hypervisor.kvm.resource.VifDriver; -import com.cloud.resource.CommandWrapper; -import com.cloud.resource.ResourceWrapper; -import com.cloud.utils.Ternary; -import com.cloud.utils.exception.CloudRuntimeException; -import com.cloud.vm.VirtualMachine; @ResourceWrapper(handles = MigrateCommand.class) public final class LibvirtMigrateCommandWrapper extends CommandWrapper { @@ -117,7 +116,8 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper ifaces = null; - List disks; + List disks = new ArrayList<>(); + VirtualMachineTO to = null; Domain dm = null; Connect dconn = null; @@ -136,7 +136,7 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper migrateDiskInfoList, List disks) { for (DiskDef disk : disks) { - MigrateDiskInfo migrateDiskInfo = searchDiskDefOnMigrateDiskInfoList(migrateDiskInfoList, disk); + MigrateCommand.MigrateDiskInfo migrateDiskInfo = searchDiskDefOnMigrateDiskInfoList(migrateDiskInfoList, disk); if (migrateDiskInfo != null && migrateDiskInfo.isSourceDiskOnStorageFileSystem()) { deleteLocalVolume(disk.getDiskPath()); } else { @@ -800,7 +812,10 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper]*type=['\"]vnc['\"][^>]*passwd=['\"])([^'\"]*)(['\"])", "$1*****$3"); } + + /** + * Checks if any of the destination disks in the migration target a CLVM or CLVM_NG storage pool. + * This is used to determine if incremental migration should be disabled to avoid libvirt + * precreate errors with QCOW2-on-LVM setups. + * + * @param mapMigrateStorage the map containing migration disk information with destination pool types + * @return true if any destination disk targets CLVM or CLVM_NG, false otherwise + */ + protected boolean hasClvmDestinationDisks(Map mapMigrateStorage) { + if (MapUtils.isEmpty(mapMigrateStorage)) { + return false; + } + + try { + for (Map.Entry entry : mapMigrateStorage.entrySet()) { + MigrateCommand.MigrateDiskInfo diskInfo = entry.getValue(); + if (isClvmBlockDevice(diskInfo)) { + logger.debug("Found disk targeting CLVM/CLVM_NG destination pool"); + return true; + } + } + } catch (final Exception e) { + logger.debug("Failed to check for CLVM destination disks: {}. Assuming no CLVM disks.", e.getMessage()); + } + + return false; + } + + private boolean isClvmBlockDevice(MigrateCommand.MigrateDiskInfo diskInfo) { + if (diskInfo == null ||diskInfo.getDestPoolType() == null) { + return false; + } + return (Storage.StoragePoolType.CLVM.equals(diskInfo.getDestPoolType()) || Storage.StoragePoolType.CLVM_NG.equals(diskInfo.getDestPoolType())); + } + + /** + * Determines if the driver type should be updated during migration based on CLVM involvement. + * The driver type needs to be updated when: + * - Managed storage is being migrated, OR + * - Source pool is CLVM or CLVM_NG, OR + * - Destination pool is CLVM or CLVM_NG + * + * This ensures the libvirt XML driver type matches the destination format (raw/qcow2/etc). + * + * @param migrateStorageManaged true if migrating managed storage + * @param migrateDiskInfo the migration disk information containing source and destination pool types + * @return true if driver type should be updated, false otherwise + */ + private boolean shouldUpdateDriverTypeForMigration(boolean migrateStorageManaged, + MigrateCommand.MigrateDiskInfo migrateDiskInfo) { + boolean sourceIsClvm = Storage.StoragePoolType.CLVM == migrateDiskInfo.getSourcePoolType() || + Storage.StoragePoolType.CLVM_NG == migrateDiskInfo.getSourcePoolType(); + + boolean destIsClvm = Storage.StoragePoolType.CLVM == migrateDiskInfo.getDestPoolType() || + Storage.StoragePoolType.CLVM_NG == migrateDiskInfo.getDestPoolType(); + + boolean isClvmRelatedMigration = sourceIsClvm || destIsClvm; + return migrateStorageManaged || isClvmRelatedMigration; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtModifyStoragePoolCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtModifyStoragePoolCommandWrapper.java index 990cefda8f3..bc22d7bfd70 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtModifyStoragePoolCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtModifyStoragePoolCommandWrapper.java @@ -52,9 +52,19 @@ public final class LibvirtModifyStoragePoolCommandWrapper extends CommandWrapper final KVMStoragePool storagepool; try { + Map poolDetails = command.getDetails(); + if (poolDetails == null) { + poolDetails = new HashMap<>(); + } + + // Ensure CLVM secure zero-fill setting has a default value if not provided by MS + if (!poolDetails.containsKey(KVMStoragePool.CLVM_SECURE_ZERO_FILL)) { + poolDetails.put(KVMStoragePool.CLVM_SECURE_ZERO_FILL, "false"); + } + storagepool = storagePoolMgr.createStoragePool(command.getPool().getUuid(), command.getPool().getHost(), command.getPool().getPort(), command.getPool().getPath(), command.getPool() - .getUserInfo(), command.getPool().getType(), command.getDetails()); + .getUserInfo(), command.getPool().getType(), poolDetails); if (storagepool == null) { return new Answer(command, false, " Failed to create storage pool"); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPostMigrationCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPostMigrationCommandWrapper.java new file mode 100644 index 00000000000..608770974dc --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPostMigrationCommandWrapper.java @@ -0,0 +1,82 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.List; + +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.libvirt.Connect; +import org.libvirt.LibvirtException; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.PostMigrationAnswer; +import com.cloud.agent.api.PostMigrationCommand; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.resource.LibvirtConnection; +import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.DiskDef; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; + +/** + * Wrapper for PostMigrationCommand on KVM hypervisor. + * Handles post-migration tasks on the destination host after a VM has been successfully migrated. + * Primary responsibility: Convert CLVM volumes from shared mode to exclusive mode on destination. + */ +@ResourceWrapper(handles = PostMigrationCommand.class) +public final class LibvirtPostMigrationCommandWrapper extends CommandWrapper { + + protected Logger logger = LogManager.getLogger(getClass()); + + @Override + public Answer execute(final PostMigrationCommand command, final LibvirtComputingResource libvirtComputingResource) { + final VirtualMachineTO vm = command.getVirtualMachine(); + final String vmName = command.getVmName(); + + if (vm == null || vmName == null) { + return new PostMigrationAnswer(command, "VM or VM name is null"); + } + + logger.debug("Executing post-migration tasks for VM {} on destination host", vmName); + + try { + final Connect conn = LibvirtConnection.getConnectionByVmName(vmName); + + List disks = libvirtComputingResource.getDisks(conn, vmName); + logger.debug("[CLVM Post-Migration] Processing volumes for VM {} to claim exclusive locks on any CLVM volumes", vmName); + LibvirtComputingResource.modifyClvmVolumesStateForMigration( + disks, + vm, + LibvirtComputingResource.ClvmVolumeState.EXCLUSIVE + ); + + logger.debug("Successfully completed post-migration tasks for VM {}", vmName); + return new PostMigrationAnswer(command); + + } catch (final LibvirtException e) { + logger.error("Libvirt error during post-migration for VM {}: {}", vmName, e.getMessage(), e); + return new PostMigrationAnswer(command, e); + } catch (final Exception e) { + logger.error("Error during post-migration for VM {}: {}", vmName, e.getMessage(), e); + return new PostMigrationAnswer(command, e); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPreMigrationCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPreMigrationCommandWrapper.java new file mode 100644 index 00000000000..c47760040c8 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPreMigrationCommandWrapper.java @@ -0,0 +1,84 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.PreMigrationCommand; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.DiskDef; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.libvirt.Connect; +import org.libvirt.Domain; +import org.libvirt.LibvirtException; + +import java.util.List; + +/** + * Handles PreMigrationCommand on the source host before live migration. + * Converts CLVM volume locks from exclusive to shared mode so the destination host can access them. + */ +@ResourceWrapper(handles = PreMigrationCommand.class) +public class LibvirtPreMigrationCommandWrapper extends CommandWrapper { + protected Logger logger = LogManager.getLogger(getClass()); + + @Override + public Answer execute(PreMigrationCommand command, LibvirtComputingResource libvirtComputingResource) { + String vmName = command.getVmName(); + VirtualMachineTO vmSpec = command.getVirtualMachine(); + + logger.info("Preparing source host for migration of VM: {}", vmName); + + Connect conn = null; + Domain dm = null; + + try { + LibvirtUtilitiesHelper libvirtUtilitiesHelper = libvirtComputingResource.getLibvirtUtilitiesHelper(); + conn = libvirtUtilitiesHelper.getConnectionByVmName(vmName); + dm = conn.domainLookupByName(vmName); + + List disks = libvirtComputingResource.getDisks(conn, vmName); + logger.info("Converting CLVM volumes to shared mode for VM: {}", vmName); + LibvirtComputingResource.modifyClvmVolumesStateForMigration( + disks, + vmSpec, + LibvirtComputingResource.ClvmVolumeState.SHARED + ); + + logger.info("Successfully prepared source host for migration of VM: {}", vmName); + return new Answer(command, true, "Source host prepared for migration"); + + } catch (LibvirtException e) { + logger.error("Failed to prepare source host for migration of VM: {}", vmName, e); + return new Answer(command, false, "Failed to prepare source host: " + e.getMessage()); + } finally { + if (dm != null) { + try { + dm.free(); + } catch (LibvirtException e) { + logger.warn("Failed to free domain {}: {}", vmName, e.getMessage()); + } + } + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareForMigrationCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareForMigrationCommandWrapper.java index d9323df4477..f7ca79127da 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareForMigrationCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareForMigrationCommandWrapper.java @@ -21,6 +21,7 @@ package com.cloud.hypervisor.kvm.resource.wrapper; import java.net.URISyntaxException; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.apache.cloudstack.storage.configdrive.ConfigDrive; @@ -124,6 +125,19 @@ public final class LibvirtPrepareForMigrationCommandWrapper extends CommandWrapp return new PrepareForMigrationAnswer(command, "failed to connect physical disks to host"); } + // Activate CLVM volumes in shared mode on destination host for live migration + try { + List disks = libvirtComputingResource.getDisks(conn, vm.getName()); + LibvirtComputingResource.modifyClvmVolumesStateForMigration( + disks, + vm, + LibvirtComputingResource.ClvmVolumeState.SHARED + ); + } catch (Exception e) { + logger.warn("Failed to activate CLVM volumes in shared mode on destination for VM {}: {}", + vm.getName(), e.getMessage(), e); + } + logger.info("Successfully prepared destination host for migration of VM {}", vm.getName()); return createPrepareForMigrationAnswer(command, dpdkInterfaceMapping, libvirtComputingResource, vm); } catch (final LibvirtException | CloudRuntimeException | InternalErrorException | URISyntaxException e) { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java index f2af46d4cc8..a43b584dd6d 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java @@ -113,7 +113,8 @@ public final class LibvirtResizeVolumeCommandWrapper extends CommandWrapper details, boolean isPrimaryStorage) { + logger.info("Attempting to create CLVM/CLVM_NG storage pool {} in libvirt", name); + + Connect conn; + try { + conn = LibvirtConnection.getConnection(); + } catch (LibvirtException e) { + throw new CloudRuntimeException(e.toString()); + } + + StoragePool sp = createCLVMStoragePool(conn, name, host, path); + if (sp == null) { + logger.info("Falling back to virtual CLVM/CLVM_NG pool without libvirt for: {}", name); + return createVirtualClvmPool(name, host, path, type, details); + } + + try { + if (!isPrimaryStorage) { + incStoragePoolRefCount(name); + } + // CLVM/CLVM_NG pools are kept inactive in libvirt; we use direct LVM commands + return getStoragePool(name); + } catch (Exception e) { + decStoragePoolRefCount(name); + throw new CloudRuntimeException("Failed to create CLVM storage pool: " + name, e); + } + } + + @Override + public KVMStoragePool getStoragePool(String uuid, boolean refreshInfo) { + logger.info("Fetching CLVM/CLVM_NG storage pool {} ", uuid); + try { + Connect conn = LibvirtConnection.getConnection(); + StoragePool storage = conn.storagePoolLookupByUUIDString(uuid); + + LibvirtStoragePoolDef spd = getStoragePoolDef(conn, storage); + if (spd == null) { + throw new CloudRuntimeException("Unable to parse storage pool definition for pool " + uuid); + } + + // CLVM pools in libvirt are always LOGICAL type + StoragePoolType type = StoragePoolType.CLVM; + + // Do NOT activate the pool — CLVM/CLVM_NG pools stay inactive in libvirt + LibvirtStoragePool pool = new LibvirtStoragePool(uuid, storage.getName(), type, this, storage); + pool.setLocalPath(spd.getTargetPath()); + + // Always read capacity from LVM directly + String vgName = storage.getName(); + try { + long[] vgStats = getVgStats(vgName); + setPoolCapacityFromVgStats(pool, vgStats, vgName); + } catch (CloudRuntimeException e) { + logger.warn("Failed to get VG stats for CLVM/CLVM_NG pool {}: {}. Using libvirt values (may be 0)", vgName, e.getMessage()); + pool.setCapacity(storage.getInfo().capacity); + pool.setUsed(storage.getInfo().allocation); + pool.setAvailable(storage.getInfo().available); + } + + return pool; + } catch (LibvirtException e) { + logger.debug("CLVM/CLVM_NG pool {} not found in libvirt, creating virtual pool", uuid); + throw new CloudRuntimeException(e.toString(), e); + } + } + + @Override + public KVMPhysicalDisk getPhysicalDisk(String volumeUuid, KVMStoragePool pool) { + LibvirtStoragePool libvirtPool = (LibvirtStoragePool) pool; + + // Pool has no libvirt backing - go directly to block device + if (libvirtPool.getPool() == null) { + logger.debug("CLVM/CLVM_NG pool has no libvirt backing, using direct block device access for volume: {}", volumeUuid); + return getPhysicalDiskViaDirectBlockDevice(volumeUuid, pool); + } + + try { + StorageVol vol = getVolume(libvirtPool.getPool(), volumeUuid); + if (vol == null) { + logger.debug("Volume {} not found in libvirt, falling back to CLVM direct access", volumeUuid); + return getPhysicalDiskWithClvmFallback(volumeUuid, pool, libvirtPool); + } + + boolean isQcow2 = StoragePoolType.CLVM_NG.equals(pool.getType()); + KVMPhysicalDisk disk = new KVMPhysicalDisk(vol.getPath(), vol.getName(), pool); + disk.setSize(vol.getInfo().allocation); + disk.setVirtualSize(isQcow2 ? getQcow2VirtualSize(vol.getPath()) : vol.getInfo().capacity); + disk.setFormat(isQcow2 ? PhysicalDiskFormat.QCOW2 : PhysicalDiskFormat.RAW); + return disk; + } catch (LibvirtException e) { + logger.warn("LibvirtException looking up volume {}: {}", volumeUuid, e.getMessage()); + return getPhysicalDiskWithClvmFallback(volumeUuid, pool, libvirtPool); + } + } + + @Override + public KVMPhysicalDisk createPhysicalDisk(String name, KVMStoragePool pool, + PhysicalDiskFormat format, Storage.ProvisioningType provisioningType, long size, byte[] passphrase) { + logger.info("Creating CLVM/CLVM_NG volume {} in pool {} with size {}", name, pool.getUuid(), toHumanReadableSize(size)); + + if (StoragePoolType.CLVM_NG.equals(pool.getType())) { + return createClvmNgDiskWithBacking(name, 0, size, null, pool, provisioningType); + } else { + return createClvmVolume(name, size, pool); + } + } + + @Override + public boolean connectPhysicalDisk(String name, KVMStoragePool pool, Map details, boolean isVMMigrate) { + if (isVMMigrate) { + logger.info("Activating CLVM/CLVM_NG volume {} in shared mode for VM migration", name); + Script activateVol = new Script("lvchange", 30000, logger); + activateVol.add("-asy"); + activateVol.add(pool.getLocalPath() + File.separator + name); + String result = activateVol.execute(); + if (result != null) { + logger.error("Failed to activate CLVM/CLVM_NG volume {} in shared mode. Output: {}", name, result); + return false; + } + } + + if (StoragePoolType.CLVM_NG.equals(pool.getType())) { + ensureClvmNgBackingFileAccessible(name, pool); + } + + return true; + } + + @Override + public KVMPhysicalDisk createDiskFromTemplate(KVMPhysicalDisk template, + String name, PhysicalDiskFormat format, Storage.ProvisioningType provisioningType, + long size, KVMStoragePool destPool, int timeout, byte[] passphrase) { + + if (StoragePoolType.CLVM_NG.equals(destPool.getType()) && format == PhysicalDiskFormat.QCOW2) { + logger.info("Creating CLVM_NG volume {} with backing file from template {}", name, template.getName()); + String backingFile = getClvmBackingFile(template, destPool); + return createClvmNgDiskWithBacking(name, timeout, size, backingFile, destPool, provisioningType); + } + + return super.createDiskFromTemplate(template, name, format, provisioningType, size, destPool, timeout, passphrase); + } + + @Override + public void createTemplate(String templatePath, String templateUuid, int timeout, KVMStoragePool pool) { + String vgName = getVgName(pool.getLocalPath()); + String lvName = "template-" + templateUuid; + String lvPath = "/dev/" + vgName + "/" + lvName; + + if (lvExists(lvPath)) { + logger.info("Template LV {} already exists in VG {}. Skipping creation.", lvName, vgName); + return; + } + + logger.info("Creating new template LV {} in VG {} for template {}", lvName, vgName, templateUuid); + + long virtualSize = getQcow2VirtualSize(templatePath); + long physicalSize = getQcow2PhysicalSize(templatePath); + long lvSize = virtualSize; + + logger.info("Template source - Physical: {} bytes, Virtual: {} bytes, LV will be: {} bytes", physicalSize, virtualSize, lvSize); + + Script lvcreate = new Script("lvcreate", Duration.millis(timeout), logger); + lvcreate.add("-n", lvName); + lvcreate.add("-L", lvSize + "B"); + lvcreate.add("--yes"); + lvcreate.add(vgName); + String result = lvcreate.execute(); + if (result != null) { + throw new CloudRuntimeException("Failed to create LV for CLVM_NG template: " + result); + } + + Script qemuImgConvert = new Script("qemu-img", Duration.millis(timeout), logger); + qemuImgConvert.add("convert"); + qemuImgConvert.add(templatePath); + qemuImgConvert.add("-O", "qcow2"); + qemuImgConvert.add("-o", "cluster_size=64k,extended_l2=off,preallocation=off"); + qemuImgConvert.add(lvPath); + result = qemuImgConvert.execute(); + + if (result != null) { + removeLvOnFailure(lvPath, timeout); + throw new CloudRuntimeException("Failed to convert template to CLVM_NG volume: " + result); + } + + long actualVirtualSize = getQcow2VirtualSize(lvPath); + + try { + ensureTemplateLvInSharedMode(lvPath, true); + } catch (CloudRuntimeException e) { + logger.error("Failed to activate template LV {} in shared mode. Cleaning up.", lvPath); + removeLvOnFailure(lvPath, timeout); + throw e; + } + + KVMPhysicalDisk templateDisk = new KVMPhysicalDisk(lvPath, lvName, pool); + templateDisk.setFormat(PhysicalDiskFormat.QCOW2); + templateDisk.setVirtualSize(actualVirtualSize); + templateDisk.setSize(lvSize); + } + + private StoragePool createCLVMStoragePool(Connect conn, String uuid, String host, String path) { + String volgroupPath = "/dev/" + path; + String volgroupName = path; + volgroupName = volgroupName.replaceFirst("^/", ""); + + Script checkVgExists = new Script("vgs", 30000, logger); + checkVgExists.add("--noheadings"); + checkVgExists.add("-o", "vg_name"); + checkVgExists.add(volgroupName); + String vgCheckResult = checkVgExists.execute(); + + if (vgCheckResult != null) { + logger.error("Volume group {} does not exist or is not accessible", volgroupName); + return null; + } + + logger.info("Volume group {} verified, creating libvirt pool definition for CLVM/CLVM_NG", volgroupName); + LibvirtStoragePoolDef poolDef = new LibvirtStoragePoolDef( + LibvirtStoragePoolDef.PoolType.LOGICAL, + volgroupName, + uuid, + null, + volgroupName, + volgroupPath + ); + + try { + StoragePool pool = conn.storagePoolDefineXML(poolDef.toString(), 0); + logger.info("Created libvirt pool definition for CLVM/CLVM_NG VG: {} (pool will remain inactive)", volgroupName); + pool.setAutostart(1); + return pool; + } catch (LibvirtException e) { + logger.warn("Failed to define CLVM/CLVM_NG pool in libvirt: {}", e.getMessage()); + return null; + } + } + + private void setPoolCapacityFromVgStats(LibvirtStoragePool pool, long[] vgStats, String vgName) { + long capacity = vgStats[0]; + long available = vgStats[1]; + long used = capacity - available; + + pool.setCapacity(capacity); + pool.setAvailable(available); + pool.setUsed(used); + + logger.debug("CLVM/CLVM_NG pool {} - Capacity: {}, Used: {}, Available: {}", + vgName, toHumanReadableSize(capacity), toHumanReadableSize(used), toHumanReadableSize(available)); + } + + private long[] getVgStats(String vgName) { + Script getVgStats = new Script("vgs", 30000, logger); + getVgStats.add("--noheadings"); + getVgStats.add("--units", "b"); + getVgStats.add("--nosuffix"); + getVgStats.add("-o", "vg_size,vg_free"); + getVgStats.add(vgName); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = getVgStats.execute(parser); + + if (result != null) { + String errorMsg = "Failed to get statistics for volume group " + vgName + ": " + result; + logger.error(errorMsg); + throw new CloudRuntimeException(errorMsg); + } + + String output = parser.getLines().trim(); + String[] lines = output.split("\\n"); + String dataLine = null; + + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty() && Character.isDigit(line.charAt(0))) { + dataLine = line; + break; + } + } + + if (dataLine == null) { + String errorMsg = "No valid data line found in vgs output for " + vgName + ": " + output; + logger.error(errorMsg); + throw new CloudRuntimeException(errorMsg); + } + + String[] stats = dataLine.split("\\s+"); + + if (stats.length < 2) { + String errorMsg = "Unexpected output from vgs command for " + vgName + ": " + dataLine; + logger.error(errorMsg); + throw new CloudRuntimeException(errorMsg); + } + + try { + long capacity = Long.parseLong(stats[0].trim()); + long available = Long.parseLong(stats[1].trim()); + return new long[]{capacity, available}; + } catch (NumberFormatException e) { + String errorMsg = "Failed to parse VG statistics for " + vgName + ": " + e.getMessage(); + logger.error(errorMsg); + throw new CloudRuntimeException(errorMsg, e); + } + } + + private KVMStoragePool createVirtualClvmPool(String uuid, String host, String path, StoragePoolType type, Map details) { + String volgroupName = path.replaceFirst("^/", ""); + String volgroupPath = "/dev/" + volgroupName; + + logger.info("Creating virtual CLVM/CLVM_NG pool {} without libvirt using direct LVM access", volgroupName); + + long[] vgStats = getVgStats(volgroupName); + + LibvirtStoragePool pool = new LibvirtStoragePool(uuid, volgroupName, type, this, null); + pool.setLocalPath(volgroupPath); + setPoolCapacityFromVgStats(pool, vgStats, volgroupName); + + if (details != null) { + pool.setDetails(details); + } + + return pool; + } + + /** + * CLVM fallback: First tries to refresh libvirt pool to make volume visible, + * if that fails, accesses volume directly via block device path. + */ + private KVMPhysicalDisk getPhysicalDiskWithClvmFallback(String volumeUuid, KVMStoragePool pool, LibvirtStoragePool libvirtPool) { + logger.info("CLVM volume not visible to libvirt, attempting pool refresh for volume: {}", volumeUuid); + + try { + logger.debug("Refreshing libvirt storage pool: {}", pool.getUuid()); + libvirtPool.getPool().refresh(0); + + StorageVol vol = getVolume(libvirtPool.getPool(), volumeUuid); + if (vol != null) { + logger.info("Volume found after pool refresh: {}", volumeUuid); + boolean isQcow2 = StoragePoolType.CLVM_NG.equals(pool.getType()); + KVMPhysicalDisk disk = new KVMPhysicalDisk(vol.getPath(), vol.getName(), pool); + disk.setSize(vol.getInfo().allocation); + disk.setVirtualSize(isQcow2 ? getQcow2VirtualSize(vol.getPath()) : vol.getInfo().capacity); + disk.setFormat(isQcow2 ? PhysicalDiskFormat.QCOW2 : PhysicalDiskFormat.RAW); + return disk; + } + } catch (LibvirtException refreshEx) { + logger.debug("Pool refresh failed or volume still not found: {}", refreshEx.getMessage()); + } + + logger.info("Falling back to direct block device access for volume: {}", volumeUuid); + return getPhysicalDiskViaDirectBlockDevice(volumeUuid, pool); + } + + private String getVgName(String sourceDir) { + String vgName = sourceDir; + if (vgName.startsWith("/")) { + String[] parts = vgName.split("/"); + List tokens = Arrays.stream(parts) + .filter(s -> !s.isEmpty()).collect(Collectors.toList()); + + vgName = tokens.size() > 1 ? tokens.get(1) + : tokens.size() == 1 ? tokens.get(0) + : ""; + } + return vgName; + } + + private String extractVgNameFromPool(KVMStoragePool pool) { + String sourceDir = pool.getLocalPath(); + if (sourceDir == null || sourceDir.isEmpty()) { + throw new CloudRuntimeException("CLVM pool sourceDir is not set, cannot determine VG name"); + } + String vgName = getVgName(sourceDir); + logger.debug("Using VG name: {} (from sourceDir: {})", vgName, sourceDir); + return vgName; + } + + /** + * For CLVM volumes that exist in LVM but are not visible to libvirt, + * access them directly via block device path. + */ + private KVMPhysicalDisk getPhysicalDiskViaDirectBlockDevice(String volumeUuid, KVMStoragePool pool) { + try { + String vgName = extractVgNameFromPool(pool); + + verifyLvExistsInVg(volumeUuid, vgName); + + logger.info("Volume {} exists in LVM but not visible to libvirt, accessing directly", volumeUuid); + + String lvPath = findAccessibleDeviceNode(volumeUuid, vgName, pool); + long size = getClvmVolumeSize(lvPath); + + KVMPhysicalDisk disk = createPhysicalDiskFromClvmLv(lvPath, volumeUuid, pool, size); + ensureTemplateAccessibility(volumeUuid, lvPath, pool); + + return disk; + } catch (CloudRuntimeException ex) { + throw ex; + } catch (Exception ex) { + logger.error("Failed to access CLVM volume via direct block device: {}", volumeUuid, ex); + throw new CloudRuntimeException(String.format("Could not find volume %s: %s", volumeUuid, ex.getMessage())); + } + } + + private void verifyLvExistsInVg(String volumeUuid, String vgName) { + logger.debug("Checking if volume {} exists in VG {}", volumeUuid, vgName); + Script checkLvCmd = new Script("/usr/sbin/lvs", 30000, logger); + checkLvCmd.add("--noheadings"); + checkLvCmd.add("--unbuffered"); + checkLvCmd.add(vgName + "/" + volumeUuid); + String checkResult = checkLvCmd.execute(); + if (checkResult != null) { + throw new CloudRuntimeException(String.format("Storage volume not found: no storage vol with matching name '%s'", volumeUuid)); + } + } + + private String findAccessibleDeviceNode(String volumeUuid, String vgName, KVMStoragePool pool) { + String lvPath = "/dev/" + vgName + "/" + volumeUuid; + File lvDevice = new File(lvPath); + + if (!lvDevice.exists()) { + lvPath = tryDeviceMapperPath(volumeUuid, vgName); + if (!new File(lvPath).exists()) { + lvPath = handleMissingDeviceNode(volumeUuid, vgName, pool); + } + } + + return lvPath; + } + + private String tryDeviceMapperPath(String volumeUuid, String vgName) { + String vgNameEscaped = vgName.replace("-", "--"); + String volumeUuidEscaped = volumeUuid.replace("-", "--"); + return "/dev/mapper/" + vgNameEscaped + "-" + volumeUuidEscaped; + } + + private String handleMissingDeviceNode(String volumeUuid, String vgName, KVMStoragePool pool) { + if (StoragePoolType.CLVM_NG.equals(pool.getType()) && volumeUuid.startsWith("template-")) { + return activateTemplateAndGetPath(volumeUuid, vgName); + } + throw new CloudRuntimeException(String.format("Could not find volume %s in VG %s - volume exists in LVM but device node not accessible", volumeUuid, vgName)); + } + + private String activateTemplateAndGetPath(String volumeUuid, String vgName) { + logger.info("Template volume {} device node not found. Attempting to activate in shared mode.", volumeUuid); + String templateLvPath = "/dev/" + vgName + "/" + volumeUuid; + + try { + ensureTemplateLvInSharedMode(templateLvPath, false); + + String lvPath = findDeviceNodeAfterActivation(templateLvPath, volumeUuid, vgName); + + logger.info("Successfully activated template volume {} at {}", volumeUuid, lvPath); + return lvPath; + } catch (CloudRuntimeException e) { + throw new CloudRuntimeException(String.format("Failed to activate template volume %s in VG %s: %s", volumeUuid, vgName, e.getMessage()), e); + } + } + + private String findDeviceNodeAfterActivation(String templateLvPath, String volumeUuid, String vgName) { + File lvDevice = new File(templateLvPath); + String lvPath = templateLvPath; + + if (!lvDevice.exists()) { + String vgNameEscaped = vgName.replace("-", "--"); + String volumeUuidEscaped = volumeUuid.replace("-", "--"); + lvPath = "/dev/mapper/" + vgNameEscaped + "-" + volumeUuidEscaped; + lvDevice = new File(lvPath); + } + + if (!lvDevice.exists()) { + logger.error("Template volume {} still not accessible after activation attempt", volumeUuid); + throw new CloudRuntimeException(String.format("Could not activate template volume %s in VG %s - device node not accessible after activation", volumeUuid, vgName)); + } + + return lvPath; + } + + private void ensureTemplateAccessibility(String volumeUuid, String lvPath, KVMStoragePool pool) { + if (StoragePoolType.CLVM_NG.equals(pool.getType()) && volumeUuid.startsWith("template-")) { + logger.info("Detected template volume {}. Ensuring it's activated in shared mode.", volumeUuid); + ensureTemplateLvInSharedMode(lvPath, false); + } + } + + private long getClvmVolumeSize(String lvPath) { + try { + Script lvsCmd = new Script("/usr/sbin/lvs", 30000, logger); + lvsCmd.add("--noheadings"); + lvsCmd.add("--units"); + lvsCmd.add("b"); + lvsCmd.add("-o"); + lvsCmd.add("lv_size"); + lvsCmd.add(lvPath); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = lvsCmd.execute(parser); + + String output = (result == null) ? parser.getLines() : result; + + if (output != null && !output.isEmpty()) { + String sizeStr = output.trim().replaceAll("[^0-9]", ""); + if (!sizeStr.isEmpty()) { + return Long.parseLong(sizeStr); + } + } + } catch (Exception sizeEx) { + logger.warn("Failed to get size for CLVM volume via lvs: {}", sizeEx.getMessage()); + File lvDevice = new File(lvPath); + if (lvDevice.isFile()) { + return lvDevice.length(); + } + } + return 0; + } + + private KVMPhysicalDisk createPhysicalDiskFromClvmLv(String lvPath, String volumeUuid, KVMStoragePool pool, long size) { + PhysicalDiskFormat diskFormat = StoragePoolType.CLVM_NG.equals(pool.getType()) + ? PhysicalDiskFormat.QCOW2 : PhysicalDiskFormat.RAW; + + logger.debug("{} pool detected, setting disk format to {} for volume {}", pool.getType(), diskFormat, volumeUuid); + + KVMPhysicalDisk disk = new KVMPhysicalDisk(lvPath, volumeUuid, pool); + disk.setFormat(diskFormat); + disk.setSize(size); + disk.setVirtualSize(diskFormat == PhysicalDiskFormat.QCOW2 ? getQcow2VirtualSize(lvPath) : size); + + logger.info("Successfully accessed CLVM/CLVM_NG volume via direct block device: {} with format: {} and size: {} bytes", + lvPath, diskFormat, size); + return disk; + } + + /** + * Checks if a CLVM_NG QCOW2 volume has a backing file (template) and ensures it's activated in shared mode. + */ + private void ensureClvmNgBackingFileAccessible(String volumeName, KVMStoragePool pool) { + try { + String vgName = getVgName(pool.getLocalPath()); + String volumePath = "/dev/" + vgName + "/" + volumeName; + + logger.debug("Checking if CLVM_NG volume {} has a backing file that needs activation", volumePath); + + Script qemuImgInfo = new Script("qemu-img", Duration.millis(10000), logger); + qemuImgInfo.add("info"); + qemuImgInfo.add("--output=json"); + qemuImgInfo.add(volumePath); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = qemuImgInfo.execute(parser); + + if (result == null && parser.getLines() != null && !parser.getLines().isEmpty()) { + String jsonOutput = parser.getLines(); + + if (jsonOutput.contains("\"backing-filename\"")) { + int backingStart = jsonOutput.indexOf("\"backing-filename\""); + if (backingStart > 0) { + int valueStart = jsonOutput.indexOf(":", backingStart); + if (valueStart > 0) { + valueStart = jsonOutput.indexOf("\"", valueStart) + 1; + int valueEnd = jsonOutput.indexOf("\"", valueStart); + + if (valueEnd > valueStart) { + String backingFile = jsonOutput.substring(valueStart, valueEnd).trim(); + if (!backingFile.isEmpty() && backingFile.startsWith("/dev/")) { + logger.info("Volume {} has backing file: {}. Ensuring backing file is in shared mode.", volumePath, backingFile); + ensureTemplateLvInSharedMode(backingFile, false); + } + } + } + } + } else { + logger.debug("Volume {} does not have a backing file (full clone)", volumePath); + } + } + } catch (Exception e) { + logger.warn("Failed to check/activate backing file for volume {}: {}. VM deployment may fail if template is not accessible.", + volumeName, e.getMessage()); + } + } + + private String getClvmBackingFile(KVMPhysicalDisk template, KVMStoragePool destPool) { + String templateLvName = template.getName(); + KVMPhysicalDisk templateOnPrimary = null; + + try { + templateOnPrimary = destPool.getPhysicalDisk(templateLvName); + } catch (CloudRuntimeException e) { + logger.warn("Template {} not found on CLVM_NG pool {}.", templateLvName, destPool.getUuid()); + } + + if (templateOnPrimary != null) { + String backingFile = templateOnPrimary.getPath(); + logger.info("Using template on primary storage as backing file: {}", backingFile); + ensureTemplateLvInSharedMode(backingFile); + return backingFile; + } + + logger.error("Template {} should be on primary storage before creating volumes from it", templateLvName); + throw new CloudRuntimeException(String.format("Template not found on CLVM_NG primary storage: %s. Template must be copied to primary storage first.", templateLvName)); + } + + /** + * Ensures a template LV is activated in shared mode so multiple VMs can use it as a backing file. + */ + private void ensureTemplateLvInSharedMode(String templatePath, boolean throwOnFailure) { + try { + Script checkLvs = new Script("lvs", Duration.millis(30000), logger); + checkLvs.add("--noheadings"); + checkLvs.add("-o", "lv_attr"); + checkLvs.add(templatePath); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = checkLvs.execute(parser); + + if (result == null && parser.getLines() != null && !parser.getLines().isEmpty()) { + String lvAttr = parser.getLines().trim(); + if (lvAttr.length() >= 6) { + boolean isActive = (lvAttr.indexOf('a') >= 0); + boolean isShared = (lvAttr.indexOf('s') >= 0); + + if (!isShared || !isActive) { + logger.info("Template LV {} is not in shared mode (attr: {}). Activating in shared mode.", templatePath, lvAttr); + LibvirtComputingResource.setClvmVolumeToSharedMode(templatePath); + } else { + logger.debug("Template LV {} is already in shared mode (attr: {})", templatePath, lvAttr); + } + } + } + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + String errorMsg = "Failed to check/ensure template LV shared mode for " + templatePath + ": " + e.getMessage(); + if (throwOnFailure) { + throw new CloudRuntimeException(errorMsg, e); + } else { + logger.warn(errorMsg, e); + } + } + } + + private void ensureTemplateLvInSharedMode(String templatePath) { + ensureTemplateLvInSharedMode(templatePath, false); + } + + private long getVgPhysicalExtentSize(String vgName) { + final long DEFAULT_PE_SIZE = 4 * 1024 * 1024L; + String warningMessage = String.format("Failed to get PE size for VG %s, defaulting to 4MiB", vgName); + + try { + Script vgDisplay = new Script("vgdisplay", 300000, logger); + vgDisplay.add("--units", "b"); + vgDisplay.add("-C"); + vgDisplay.add("--noheadings"); + vgDisplay.add("-o", "vg_extent_size"); + vgDisplay.add(vgName); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = vgDisplay.execute(parser); + + if (result != null) { + logger.warn("{}: {}", warningMessage, result); + return DEFAULT_PE_SIZE; + } + + String output = parser.getLines(); + if (output == null || output.trim().isEmpty()) { + logger.warn("{}: empty output", warningMessage); + return DEFAULT_PE_SIZE; + } + + output = output.trim(); + if (output.endsWith("B")) { + output = output.substring(0, output.length() - 1).trim(); + } + + long peSize = Long.parseLong(output); + logger.debug("Physical Extent size for VG {} is {} bytes", vgName, peSize); + return peSize; + } catch (NumberFormatException e) { + logger.warn("{}: failed to parse PE size", warningMessage, e); + } catch (Exception e) { + logger.warn("{}: {}", warningMessage, e.getMessage()); + } + + logger.info("Using default PE size for VG {}: {} bytes (4 MiB)", vgName, DEFAULT_PE_SIZE); + return DEFAULT_PE_SIZE; + } + + /** + * Calculate LVM LV size for CLVM_NG volume allocation. + * {@code peSize} must be the Physical Extent size of the VG (from {@link #getVgPhysicalExtentSize}). + */ + private long calculateClvmNgLvSize(long virtualSize, long peSize) { + long clusterSize = 64 * 1024L; + long l2Multiplier = 4096L; + + long numDataClusters = (virtualSize + clusterSize - 1) / clusterSize; + long numL2Clusters = (numDataClusters + l2Multiplier - 1) / l2Multiplier; + long l2TableSize = numL2Clusters * clusterSize; + long refcountTableSize = l2TableSize; + + long headerOverhead = 2 * 1024 * 1024L; + long metadataOverhead = l2TableSize + refcountTableSize + headerOverhead; + long targetSize = virtualSize + metadataOverhead; + long roundedSize = ((targetSize + peSize - 1) / peSize) * peSize; + long virtualSizeGiB = virtualSize / (1024 * 1024 * 1024L); + long overheadMiB = metadataOverhead / (1024 * 1024L); + + logger.info("Calculated volume LV size: {} bytes (virtual: {} GiB, QCOW2 metadata overhead: {} MiB, rounded to {} PEs, PE size = {} bytes)", + roundedSize, virtualSizeGiB, overheadMiB, roundedSize / peSize, peSize); + + return roundedSize; + } + + private long getQcow2VirtualSize(String imagePath) { + Script qemuImg = new Script("qemu-img", 300000, logger); + qemuImg.add("info"); + qemuImg.add("--output=json"); + qemuImg.add("-U"); + qemuImg.add(imagePath); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = qemuImg.execute(parser); + + if (result != null) { + throw new CloudRuntimeException("Failed to get QCOW2 virtual size for " + imagePath + ": " + result); + } + + String output = parser.getLines(); + if (output == null || output.trim().isEmpty()) { + throw new CloudRuntimeException("qemu-img info returned empty output for " + imagePath); + } + + JsonObject info = JsonParser.parseString(output).getAsJsonObject(); + return info.get("virtual-size").getAsLong(); + } + + private long getQcow2PhysicalSize(String imagePath) { + Script qemuImg = new Script("qemu-img", Duration.millis(300000), logger); + qemuImg.add("info"); + qemuImg.add("--output=json"); + qemuImg.add(imagePath); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = qemuImg.execute(parser); + + if (result != null) { + throw new CloudRuntimeException("Failed to get QCOW2 physical size for " + imagePath + ": " + result); + } + + String output = parser.getLines(); + if (output == null || output.trim().isEmpty()) { + throw new CloudRuntimeException("qemu-img info returned empty output for " + imagePath); + } + + JsonObject info = JsonParser.parseString(output).getAsJsonObject(); + return info.get("actual-size").getAsLong(); + } + + private KVMPhysicalDisk createClvmNgDiskWithBacking(String volumeUuid, int timeout, long virtualSize, String backingFile, + KVMStoragePool pool, Storage.ProvisioningType provisioningType) { + String vgName = getVgName(pool.getLocalPath()); + // Query PE size once and reuse for both the QCOW2 virtual-size alignment and the + long peSize = getVgPhysicalExtentSize(vgName); + long peAlignedVirtualSize = ((virtualSize + peSize - 1) / peSize) * peSize; + long lvSize = calculateClvmNgLvSize(peAlignedVirtualSize, peSize); + String volumePath = "/dev/" + vgName + "/" + volumeUuid; + + logger.debug("Creating CLVM_NG volume {} with LV size {} bytes (requested virtual: {} bytes, PE-aligned virtual: {} bytes, provisioning: {})", + volumeUuid, lvSize, virtualSize, peAlignedVirtualSize, provisioningType); + + Script lvcreate = new Script("lvcreate", Duration.millis(timeout), logger); + lvcreate.add("-n", volumeUuid); + lvcreate.add("-L", lvSize + "B"); + lvcreate.add("--yes"); + lvcreate.add(vgName); + + String result = lvcreate.execute(); + if (result != null) { + throw new CloudRuntimeException("Failed to create LV for CLVM_NG volume: " + result); + } + + Script qemuImg = new Script("qemu-img", Duration.millis(timeout), logger); + qemuImg.add("create"); + qemuImg.add("-f", "qcow2"); + + StringBuilder qcow2Options = new StringBuilder(); + String preallocation = (provisioningType == Storage.ProvisioningType.THIN) ? "off" : "metadata"; + qcow2Options.append("preallocation=").append(preallocation); + qcow2Options.append(",extended_l2=on"); + qcow2Options.append(",cluster_size=64k"); + + if (backingFile != null && !backingFile.isEmpty()) { + qcow2Options.append(",backing_file=").append(backingFile); + qcow2Options.append(",backing_fmt=qcow2"); + logger.debug("Creating CLVM_NG volume with backing file: {}", backingFile); + } + + qemuImg.add("-o", qcow2Options.toString()); + qemuImg.add(volumePath); + qemuImg.add(peAlignedVirtualSize + ""); + + result = qemuImg.execute(); + if (result != null) { + removeLvOnFailure(volumePath, timeout); + throw new CloudRuntimeException("Failed to create QCOW2 on CLVM_NG volume: " + result); + } + + long actualSize = getClvmVolumeSize(volumePath); + KVMPhysicalDisk disk = new KVMPhysicalDisk(volumePath, volumeUuid, pool); + disk.setFormat(PhysicalDiskFormat.QCOW2); + disk.setSize(actualSize); + disk.setVirtualSize(peAlignedVirtualSize); + + logger.info("Successfully created CLVM_NG volume {} (LV size: {}, PE-aligned virtual size: {}, provisioning: {}, preallocation: {})", + volumeUuid, lvSize, peAlignedVirtualSize, provisioningType, preallocation); + + return disk; + } + + private boolean lvExists(String lvPath) { + Script checkLv = new Script("lvs", Duration.millis(30000), logger); + checkLv.add("--noheadings"); + checkLv.add("--unbuffered"); + checkLv.add(lvPath); + return checkLv.execute() == null; + } + + private void removeLvOnFailure(String lvPath, int timeout) { + Script lvremove = new Script("lvremove", Duration.millis(timeout), logger); + lvremove.add("-f"); + lvremove.add(lvPath); + lvremove.execute(); + } + + private KVMPhysicalDisk createClvmVolume(String volumeName, long size, KVMStoragePool pool) { + String vgName = getVgName(pool.getLocalPath()); + String volumePath = "/dev/" + vgName + "/" + volumeName; + int timeout = 30000; + + logger.info("Creating CLVM volume {} in VG {} with size {} bytes", volumeName, vgName, size); + + Script lvcreate = new Script("lvcreate", Duration.millis(timeout), logger); + lvcreate.add("-n", volumeName); + lvcreate.add("-L", size + "B"); + lvcreate.add("--yes"); + lvcreate.add(vgName); + + String result = lvcreate.execute(); + if (result != null) { + throw new CloudRuntimeException("Failed to create CLVM volume: " + result); + } + + logger.info("Successfully created CLVM volume {} at {} with size {}", volumeName, volumePath, toHumanReadableSize(size)); + + long actualSize = getClvmVolumeSize(volumePath); + KVMPhysicalDisk disk = new KVMPhysicalDisk(volumePath, volumeName, pool); + disk.setFormat(PhysicalDiskFormat.RAW); + disk.setSize(actualSize); + disk.setVirtualSize(actualSize); + + return disk; + } + + @Override + public boolean deletePhysicalDisk(String uuid, KVMStoragePool pool, Storage.ImageFormat format) { + logger.info("CLVM/CLVM_NG pool detected - using direct LVM cleanup with secure zero-fill for volume {}", uuid); + return cleanupCLVMVolume(uuid, pool); + } + + /** + * Clean up CLVM volume and its snapshots directly using LVM commands. + */ + private boolean cleanupCLVMVolume(String uuid, KVMStoragePool pool) { + logger.info("Starting direct LVM cleanup for CLVM volume: {} in pool: {}", uuid, pool.getUuid()); + + try { + String sourceDir = pool.getLocalPath(); + if (sourceDir == null || sourceDir.isEmpty()) { + logger.debug("Source directory is null or empty, cannot determine VG name for CLVM pool {}, skipping direct cleanup", pool.getUuid()); + return true; + } + String vgName = getVgName(sourceDir); + logger.info("Determined VG name: {} for pool: {}", vgName, pool.getUuid()); + + if (vgName == null || vgName.isEmpty()) { + logger.warn("Cannot determine VG name for CLVM pool {}, skipping direct cleanup", pool.getUuid()); + return true; + } + + String lvPath = "/dev/" + vgName + "/" + uuid; + logger.debug("Volume path: {}", lvPath); + + Script checkLvs = new Script("lvs", 30000, logger); + checkLvs.add("--noheadings"); + checkLvs.add("--unbuffered"); + checkLvs.add(lvPath); + + logger.info("Checking if volume exists: lvs --noheadings --unbuffered {}", lvPath); + String checkResult = checkLvs.execute(); + + if (checkResult != null) { + logger.info("CLVM volume {} does not exist in LVM (check returned: {}), considering it as already deleted", uuid, checkResult); + return true; + } + + logger.info("Volume {} exists, proceeding with cleanup", uuid); + + boolean secureZeroFillEnabled = shouldSecureZeroFill(pool); + + if (secureZeroFillEnabled) { + logger.info("Step 1: Zero-filling volume {} for security", uuid); + secureZeroFillVolume(lvPath, uuid); + } else { + logger.info("Secure zero-fill is disabled, skipping zero-filling for volume {}", uuid); + } + + logger.info("Step 2: Removing volume {}", uuid); + Script removeLv = new Script("lvremove", 30000, logger); + removeLv.add("-f"); + removeLv.add(lvPath); + + logger.info("Executing command: lvremove -f {}", lvPath); + String removeResult = removeLv.execute(); + + if (removeResult == null) { + logger.info("Successfully removed CLVM volume {} using direct LVM cleanup", uuid); + return true; + } else { + logger.warn("Command 'lvremove -f {}' returned error: {}", lvPath, removeResult); + if (removeResult.contains("not found") || removeResult.contains("Failed to find")) { + logger.info("CLVM volume {} not found during cleanup, considering it as already deleted", uuid); + return true; + } + return false; + } + } catch (Exception ex) { + logger.error("Exception during CLVM volume cleanup for {}: {}", uuid, ex.getMessage(), ex); + return true; + } + } + + private boolean shouldSecureZeroFill(KVMStoragePool pool) { + Map details = pool.getDetails(); + String secureZeroFillStr = (details != null) ? details.get(KVMStoragePool.CLVM_SECURE_ZERO_FILL) : null; + return Boolean.parseBoolean(secureZeroFillStr); + } + + /** + * Securely zero-fill a volume before deletion to prevent data leakage. + * Uses blkdiscard (fast TRIM) as primary method, with dd zero-fill as fallback. + */ + private void secureZeroFillVolume(String lvPath, String volumeUuid) { + logger.info("Starting secure zero-fill for CLVM volume: {} at path: {}", volumeUuid, lvPath); + + boolean blkdiscardSuccess = false; + + try { + Script blkdiscard = new Script("blkdiscard", 300000, logger); + blkdiscard.add("-f"); + blkdiscard.add(lvPath); + + String result = blkdiscard.execute(); + if (result == null) { + logger.info("Successfully zero-filled CLVM volume {} using blkdiscard (TRIM)", volumeUuid); + blkdiscardSuccess = true; + } else { + if (result.contains("Operation not supported") || result.contains("BLKDISCARD ioctl failed")) { + logger.info("blkdiscard not supported for volume {} (device doesn't support TRIM/DISCARD), using dd fallback", volumeUuid); + } else { + logger.warn("blkdiscard failed for volume {}: {}, will try dd fallback", volumeUuid, result); + } + } + } catch (Exception e) { + logger.warn("Exception during blkdiscard for volume {}: {}, will try dd fallback", volumeUuid, e.getMessage()); + } + + if (!blkdiscardSuccess) { + logger.info("Attempting zero-fill using dd for CLVM volume: {}", volumeUuid); + try { + String command = String.format( + "nice -n 19 ionice -c 2 -n 7 dd if=/dev/zero of=%s bs=1M oflag=direct 2>&1 || true", + lvPath + ); + + Script ddZeroFill = new Script("/bin/bash", 3600000, logger); + ddZeroFill.add("-c"); + ddZeroFill.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String ddResult = ddZeroFill.execute(parser); + String output = parser.getLines(); + + if (output != null && (output.contains("copied") || output.contains("records in") || + output.contains("No space left on device"))) { + logger.info("Successfully zero-filled CLVM volume {} using dd", volumeUuid); + } else if (ddResult == null) { + logger.info("Zero-fill completed for CLVM volume {}", volumeUuid); + } else { + logger.warn("dd zero-fill for volume {} completed with output: {}", volumeUuid, + output != null ? output : ddResult); + } + } catch (Exception e) { + logger.warn("Failed to zero-fill CLVM volume {} before deletion: {}. Proceeding with deletion anyway.", + volumeUuid, e.getMessage()); + } + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java index 3e35ed9476b..a8207cec3fa 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java @@ -33,6 +33,7 @@ import com.cloud.storage.Storage.StoragePoolType; public interface KVMStoragePool { + public static final String CLVM_SECURE_ZERO_FILL = "clvmsecurezerofill"; long HeartBeatUpdateTimeoutInMs = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.HEARTBEAT_UPDATE_TIMEOUT); long HeartBeatUpdateFreqInMs = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.KVM_HEARTBEAT_UPDATE_FREQUENCY); long HeartBeatCheckerTimeoutInMs = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.KVM_HEARTBEAT_CHECKER_TIMEOUT); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java index 35cc864268c..996398a286f 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java @@ -72,7 +72,9 @@ public class KVMStoragePoolManager { private void addStoragePool(String uuid, StoragePoolInformation pool) { synchronized (_storagePools) { - if (!_storagePools.containsKey(uuid)) { + // Insert on first registration; on subsequent calls (e.g. ModifyStoragePoolCommand) + // overwrite when new details are present so config changes are reflected + if (!_storagePools.containsKey(uuid) || MapUtils.isNotEmpty(pool.getDetails())) { _storagePools.put(uuid, pool); } } @@ -81,6 +83,10 @@ public class KVMStoragePoolManager { public KVMStoragePoolManager(StorageLayer storagelayer, KVMHAMonitor monitor) { this._haMonitor = monitor; this._storageMapper.put("libvirt", new LibvirtStorageAdaptor(storagelayer)); + // Register CLVM/CLVM_NG adaptor explicitly for both types (one shared instance) + ClvmStorageAdaptor clvmAdaptor = new ClvmStorageAdaptor(storagelayer); + this._storageMapper.put(StoragePoolType.CLVM.toString(), clvmAdaptor); + this._storageMapper.put(StoragePoolType.CLVM_NG.toString(), clvmAdaptor); // add other storage adaptors manually here // add any adaptors that wish to register themselves via call to adaptor.getStoragePoolType() @@ -92,8 +98,8 @@ public class KVMStoragePoolManager { logger.debug("Skipping registration of abstract class / interface " + storageAdaptorClass.getName()); continue; } - if (storageAdaptorClass.isAssignableFrom(LibvirtStorageAdaptor.class)) { - logger.debug("Skipping re-registration of LibvirtStorageAdaptor"); + if (storageAdaptorClass == LibvirtStorageAdaptor.class || storageAdaptorClass == ClvmStorageAdaptor.class) { + logger.debug("Skipping re-registration of explicitly registered adaptor: {}", storageAdaptorClass.getSimpleName()); continue; } try { @@ -288,19 +294,45 @@ public class KVMStoragePoolManager { } if (pool instanceof LibvirtStoragePool) { - addPoolDetails(uuid, (LibvirtStoragePool) pool); + LibvirtStoragePool libvirtPool = (LibvirtStoragePool) pool; + addPoolDetails(uuid, libvirtPool); + ((LibvirtStoragePool) pool).setType(type); + updatePoolTypeIfApplicable(libvirtPool, pool, type, uuid); } return pool; } + private void updatePoolTypeIfApplicable(LibvirtStoragePool libvirtPool, KVMStoragePool pool, + StoragePoolType type, String uuid) { + StoragePoolType correctType = type; + if (correctType == null || correctType == StoragePoolType.CLVM) { + StoragePoolInformation info = _storagePools.get(uuid); + if (info != null && info.getPoolType() != null) { + correctType = info.getPoolType(); + } + } + + if (correctType != null && correctType != pool.getType() && + (correctType == StoragePoolType.CLVM || correctType == StoragePoolType.CLVM_NG) && + (pool.getType() == StoragePoolType.CLVM || pool.getType() == StoragePoolType.CLVM_NG)) { + logger.debug("Correcting pool type from {} to {} for pool {} based on caller/cached information", + pool.getType(), correctType, uuid); + libvirtPool.setType(correctType); + } + } + /** * As the class {@link LibvirtStoragePool} is constrained to the {@link org.libvirt.StoragePool} class, there is no way of saving a generic parameter such as the details, hence, * this method was created to always make available the details of libvirt primary storages for when they are needed. */ private void addPoolDetails(String uuid, LibvirtStoragePool pool) { StoragePoolInformation storagePoolInformation = _storagePools.get(uuid); + if (storagePoolInformation == null) { + logger.warn("No cached StoragePoolInformation found for pool UUID {}, pool details will not be set.", uuid); + return; + } Map details = storagePoolInformation.getDetails(); if (MapUtils.isNotEmpty(details)) { @@ -454,6 +486,10 @@ public class KVMStoragePoolManager { return adaptor.createDiskFromTemplate(template, name, PhysicalDiskFormat.RAW, provisioningType, size, destPool, timeout, passphrase); + } else if (destPool.getType() == StoragePoolType.CLVM_NG) { + return adaptor.createDiskFromTemplate(template, name, + PhysicalDiskFormat.QCOW2, provisioningType, + size, destPool, timeout, passphrase); } else if (template.getFormat() == PhysicalDiskFormat.DIR) { return adaptor.createDiskFromTemplate(template, name, PhysicalDiskFormat.DIR, provisioningType, @@ -495,6 +531,11 @@ public class KVMStoragePoolManager { return adaptor.createTemplateFromDirectDownloadFile(templateFilePath, destTemplatePath, destPool, format, timeout); } + public void createTemplateOnClvmNg(String templatePath, String templateUuid, int timeout, KVMStoragePool pool) { + StorageAdaptor adaptor = getStorageAdaptor(pool.getType()); + adaptor.createTemplate(templatePath, templateUuid, timeout, pool); + } + public Ternary, String> prepareStorageClient(StoragePoolType type, String uuid, Map details) { StorageAdaptor adaptor = getStorageAdaptor(type); return adaptor.prepareStorageClient(uuid, details); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java index f95ebff5326..4a77f7e9e19 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java @@ -55,6 +55,7 @@ import javax.xml.xpath.XPathFactory; import com.cloud.agent.api.Command; import com.cloud.hypervisor.kvm.resource.LibvirtXMLParser; +import com.cloud.storage.clvm.ClvmPoolManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -225,6 +226,26 @@ public class KVMStorageProcessor implements StorageProcessor { " \n" + ""; + private static final String DUMMY_VM_XML_BLOCK = "\n" + + " %s\n" + + " 256\n" + + " 256\n" + + " 1\n" + + " \n" + + " hvm\n" + + " \n" + + " \n" + + " \n" + + " %s\n" + + " \n" + + " \n"+ + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""; + public KVMStorageProcessor(final KVMStoragePoolManager storagePoolMgr, final LibvirtComputingResource resource) { this.storagePoolMgr = storagePoolMgr; @@ -347,15 +368,28 @@ public class KVMStorageProcessor implements StorageProcessor { path = destTempl.getUuid(); } - if (path != null && !storagePoolMgr.connectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path, details)) { - logger.warn("Failed to connect physical disk at path: {}, in storage pool [id: {}, name: {}]", path, primaryStore.getUuid(), primaryStore.getName()); - return new PrimaryStorageDownloadAnswer("Failed to spool template disk at path: " + path + ", in storage pool id: " + primaryStore.getUuid()); - } + if (primaryPool.getType() == StoragePoolType.CLVM_NG) { + logger.info("Copying template {} to CLVM_NG pool {}", + destTempl.getUuid(), primaryPool.getUuid()); - primaryVol = storagePoolMgr.copyPhysicalDisk(tmplVol, path != null ? path : destTempl.getUuid(), primaryPool, cmd.getWaitInMillSeconds()); + try { + storagePoolMgr.createTemplateOnClvmNg(tmplVol.getPath(), path, cmd.getWaitInMillSeconds(), primaryPool); + primaryVol = primaryPool.getPhysicalDisk("template-" + path); + } catch (Exception e) { + logger.error("Failed to create CLVM_NG template: {}", e.getMessage(), e); + return new PrimaryStorageDownloadAnswer("Failed to create CLVM_NG template: " + e.getMessage()); + } + } else { + if (path != null && !storagePoolMgr.connectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path, details)) { + logger.warn("Failed to connect physical disk at path: {}, in storage pool [id: {}, name: {}]", path, primaryStore.getUuid(), primaryStore.getName()); + return new PrimaryStorageDownloadAnswer("Failed to spool template disk at path: " + path + ", in storage pool id: " + primaryStore.getUuid()); + } - if (!storagePoolMgr.disconnectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path)) { - logger.warn("Failed to disconnect physical disk at path: {}, in storage pool [id: {}, name: {}]", path, primaryStore.getUuid(), primaryStore.getName()); + primaryVol = storagePoolMgr.copyPhysicalDisk(tmplVol, path != null ? path : destTempl.getUuid(), primaryPool, cmd.getWaitInMillSeconds()); + + if (!storagePoolMgr.disconnectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path)) { + logger.warn("Failed to disconnect physical disk at path: {}, in storage pool [id: {}, name: {}]", path, primaryStore.getUuid(), primaryStore.getName()); + } } } else { primaryVol = storagePoolMgr.copyPhysicalDisk(tmplVol, UUID.randomUUID().toString(), primaryPool, cmd.getWaitInMillSeconds()); @@ -376,7 +410,8 @@ public class KVMStorageProcessor implements StorageProcessor { StoragePoolType.RBD, StoragePoolType.PowerFlex, StoragePoolType.Linstor, - StoragePoolType.FiberChannel).contains(primaryPool.getType())) { + StoragePoolType.FiberChannel, + StoragePoolType.CLVM).contains(primaryPool.getType())) { newTemplate.setFormat(ImageFormat.RAW); } else { newTemplate.setFormat(ImageFormat.QCOW2); @@ -589,7 +624,9 @@ public class KVMStorageProcessor implements StorageProcessor { String path = details != null ? details.get(DiskTO.IQN) : null; - storagePoolMgr.connectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path, details); + if (!ClvmPoolManager.isClvmPoolType(primaryStore.getPoolType())) { + storagePoolMgr.connectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path, details); + } final String volumeName = UUID.randomUUID().toString(); @@ -618,7 +655,9 @@ public class KVMStorageProcessor implements StorageProcessor { final KVMPhysicalDisk newDisk = storagePoolMgr.copyPhysicalDisk(volume, path != null ? path : volumeName, primaryPool, cmd.getWaitInMillSeconds()); resource.createOrUpdateLogFileForCommand(cmd, Command.State.COMPLETED); - storagePoolMgr.disconnectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path); + if (!ClvmPoolManager.isClvmPoolType(primaryStore.getPoolType())) { + storagePoolMgr.disconnectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), path); + } final VolumeObjectTO newVol = new VolumeObjectTO(); @@ -1118,7 +1157,14 @@ public class KVMStorageProcessor implements StorageProcessor { } } else { final Script command = new Script(_manageSnapshotPath, cmd.getWaitInMillSeconds(), logger); - command.add("-b", isCreatedFromVmSnapshot ? snapshotDisk.getPath() : snapshot.getPath()); + String backupPath; + if (primaryPool.getType() == StoragePoolType.CLVM || primaryPool.getType() == StoragePoolType.CLVM_NG) { + backupPath = snapshotDisk.getPath(); + logger.debug("Using snapshotDisk path for CLVM/CLVM_NG backup: " + backupPath); + } else { + backupPath = isCreatedFromVmSnapshot ? snapshotDisk.getPath() : snapshot.getPath(); + } + command.add("-b", backupPath); command.add(NAME_OPTION, snapshotName); command.add("-p", snapshotDestPath); @@ -1163,6 +1209,90 @@ public class KVMStorageProcessor implements StorageProcessor { } } + /** + * Parse CLVM/CLVM_NG snapshot path and compute MD5 hash. + * Snapshot path format: /dev/vgname/volumeuuid/snapshotuuid + * + * @param snapshotPath The snapshot path from database + * @param poolType Storage pool type (for logging) + * @return Array of [vgName, volumeUuid, snapshotUuid, md5Hash] or null if invalid + */ + private String[] parseClvmSnapshotPath(String snapshotPath, StoragePoolType poolType) { + String[] pathParts = snapshotPath.split("/"); + if (pathParts.length < 5) { + logger.warn("Invalid {} snapshot path format: {}, expected format: /dev/vgname/volume-uuid/snapshot-uuid", + poolType, snapshotPath); + return null; + } + + String vgName = pathParts[2]; + String volumeUuid = pathParts[3]; + String snapshotUuid = pathParts[4]; + + logger.info("Parsed {} snapshot path - VG: {}, Volume: {}, Snapshot: {}", + poolType, vgName, volumeUuid, snapshotUuid); + + String md5Hash = computeMd5Hash(snapshotUuid); + logger.debug("Computed MD5 hash for snapshot UUID {}: {}", snapshotUuid, md5Hash); + + return new String[]{vgName, volumeUuid, snapshotUuid, md5Hash}; + } + + /** + * Delete a CLVM or CLVM_NG snapshot using managesnapshot.sh script. + * For both CLVM and CLVM_NG, the snapshot path stored in DB is: /dev/vgname/volumeuuid/snapshotuuid + * The script handles MD5 transformation and pool-specific deletion commands internally: + * - CLVM: Uses lvremove to delete LVM snapshot + * - CLVM_NG: Uses qemu-img snapshot -d to delete QCOW2 internal snapshot + * This approach is consistent with snapshot creation and backup which also use the script. + * + * @param snapshotPath The snapshot path from database + * @param poolType Storage pool type (CLVM or CLVM_NG) + * @param checkExistence If true, checks if snapshot exists before cleanup (for explicit deletion) + * If false, always performs cleanup (for post-backup cleanup) + * @return true if cleanup was performed, false if snapshot didn't exist (when checkExistence=true) + */ + private boolean deleteClvmSnapshot(String snapshotPath, StoragePoolType poolType, boolean checkExistence) { + logger.info("Starting {} snapshot deletion for path: {}, checkExistence: {}", poolType, snapshotPath, checkExistence); + + try { + String[] parsed = parseClvmSnapshotPath(snapshotPath, poolType); + if (parsed == null) { + return false; + } + + String vgName = parsed[0]; + String volumeUuid = parsed[1]; + String snapshotUuid = parsed[2]; + String volumePath = "/dev/" + vgName + "/" + volumeUuid; + + // Use managesnapshot.sh script for deletion (consistent with create/backup) + // Script handles MD5 transformation and pool-specific commands internally + Script deleteCommand = new Script(_manageSnapshotPath, 30000, logger); + deleteCommand.add("-d", volumePath); + deleteCommand.add("-n", snapshotUuid); + + logger.info("Executing: managesnapshot.sh -d {} -n {}", volumePath, snapshotUuid); + String result = deleteCommand.execute(); + + if (result == null) { + logger.info("Successfully deleted {} snapshot: {}", poolType, snapshotPath); + return true; + } else { + if (checkExistence && result.contains("does not exist")) { + logger.info("{} snapshot {} already deleted, no cleanup needed", poolType, snapshotPath); + return true; + } + logger.warn("Failed to delete {} snapshot {}: {}", poolType, snapshotPath, result); + return false; + } + + } catch (Exception ex) { + logger.error("Exception while deleting {} snapshot {}", poolType, snapshotPath, ex); + return false; + } + } + private void deleteSnapshotOnPrimary(final CopyCommand cmd, final SnapshotObjectTO snapshot, KVMStoragePool primaryPool) { String snapshotPath = snapshot.getPath(); @@ -1175,7 +1305,19 @@ public class KVMStorageProcessor implements StorageProcessor { if ((backupSnapshotAfterTakingSnapshot == null || BooleanUtils.toBoolean(backupSnapshotAfterTakingSnapshot)) && deleteSnapshotOnPrimary) { try { - Files.deleteIfExists(Paths.get(snapshotPath)); + if (primaryPool.getType() == StoragePoolType.CLVM || primaryPool.getType() == StoragePoolType.CLVM_NG) { + // Both CLVM and CLVM_NG use the same deletion method via managesnapshot.sh script + boolean cleanedUp = deleteClvmSnapshot(snapshotPath, primaryPool.getType(), false); + if (!cleanedUp) { + String[] parsedPath = parseClvmSnapshotPath(snapshotPath, primaryPool.getType()); + String snapMd5 = (parsedPath != null) ? computeMd5Hash(parsedPath[2]) : computeMd5Hash(snapshotPath); + logger.warn("Deletion of Snapshot: {} on primary store may have failed as it doesn't exist: {} " + + "(MD5 of snapshot UUID: {} - admins can use this to manually locate and delete the LV via managesnapshot.sh or lvremove)", + primaryPool.getType(), snapshotPath, snapMd5); + } + } else { + Files.deleteIfExists(Paths.get(snapshotPath)); + } } catch (IOException ex) { logger.error("Failed to delete snapshot [{}] on primary storage [{}].", snapshot.getId(), snapshot.getName(), ex); } @@ -1184,6 +1326,26 @@ public class KVMStorageProcessor implements StorageProcessor { } } + + /** + * Compute MD5 hash of a string, matching what managesnapshot.sh does: + * echo "${snapshot}" | md5sum -t | awk '{ print $1 }' + */ + private String computeMd5Hash(String input) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5"); + byte[] array = md.digest((input + "\n").getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(); + for (byte b : array) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + logger.error("Failed to compute MD5 hash for: {}", input, e); + return input; + } + } + protected synchronized void attachOrDetachISO(final Connect conn, final String vmName, String isoPath, final boolean isAttach, Map params, DataStoreTO store) throws LibvirtException, InternalErrorException { DiskDef iso = new DiskDef(); @@ -1523,6 +1685,10 @@ public class KVMStorageProcessor implements StorageProcessor { if (attachingDisk.getFormat() == PhysicalDiskFormat.QCOW2) { diskdef.setDiskFormatType(DiskDef.DiskFmtType.QCOW2); } + } else if (attachingPool.getType() == StoragePoolType.CLVM_NG) { + // CLVM_NG uses QCOW2 format on block devices + diskdef.defBlockBasedDisk(attachingDisk.getPath(), devId, busT); + diskdef.setDiskFormatType(DiskDef.DiskFmtType.QCOW2); } else if (attachingDisk.getFormat() == PhysicalDiskFormat.QCOW2) { diskdef.defFileBasedDisk(attachingDisk.getPath(), devId, busT, DiskDef.DiskFmtType.QCOW2); } else if (attachingDisk.getFormat() == PhysicalDiskFormat.RAW) { @@ -1738,13 +1904,22 @@ public class KVMStorageProcessor implements StorageProcessor { primaryPool = storagePoolMgr.getStoragePool(primaryStore.getPoolType(), primaryStore.getUuid()); disksize = volume.getSize(); PhysicalDiskFormat format; - if (volume.getFormat() == null || StoragePoolType.RBD.equals(primaryStore.getPoolType())) { + + MigrationOptions migrationOptions = volume.getMigrationOptions(); + boolean useDstPoolFormat = useDestPoolFormat(migrationOptions, primaryStore); + + if (volume.getFormat() == null || StoragePoolType.RBD.equals(primaryStore.getPoolType()) || useDstPoolFormat) { format = primaryPool.getDefaultFormat(); + if (useDstPoolFormat) { + logger.debug("Using destination pool default format {} for volume {} due to CLVM migration (src: {}, dst: {})", + format, volume.getUuid(), + migrationOptions != null ? migrationOptions.getSrcPoolType() : "unknown", + primaryStore.getPoolType()); + } } else { format = PhysicalDiskFormat.valueOf(volume.getFormat().toString().toUpperCase()); } - MigrationOptions migrationOptions = volume.getMigrationOptions(); if (migrationOptions != null) { int timeout = migrationOptions.getTimeout(); @@ -1769,7 +1944,11 @@ public class KVMStorageProcessor implements StorageProcessor { format = vol.getFormat(); } } - newVol.setSize(volume.getSize()); + if (StoragePoolType.CLVM_NG.equals(primaryStore.getPoolType()) && vol != null && vol.getVirtualSize() > 0) { + newVol.setSize(vol.getVirtualSize()); + } else { + newVol.setSize(volume.getSize()); + } newVol.setFormat(ImageFormat.valueOf(format.toString().toUpperCase())); return new CreateObjectAnswer(newVol); @@ -1781,6 +1960,29 @@ public class KVMStorageProcessor implements StorageProcessor { } } + /** + * For migration involving CLVM (RAW format), use destination pool's default format + * CLVM uses RAW format which may not match destination pool's format (e.g., NFS uses QCOW2) + * This specifically handles: + * - CLVM (RAW) -> NFS/Local/CLVM_NG (QCOW2) + * - NFS/Local/CLVM_NG (QCOW2) -> CLVM (RAW) + * @param migrationOptions + * @param primaryStore + * @return + */ + private boolean useDestPoolFormat(MigrationOptions migrationOptions, PrimaryDataStoreTO primaryStore) { + boolean useDstPoolFormat = false; + if (migrationOptions != null && migrationOptions.getSrcPoolType() != null) { + StoragePoolType srcPoolType = migrationOptions.getSrcPoolType(); + StoragePoolType dstPoolType = primaryStore.getPoolType(); + + if (srcPoolType != dstPoolType) { + useDstPoolFormat = (srcPoolType == StoragePoolType.CLVM || dstPoolType == StoragePoolType.CLVM); + } + } + return useDstPoolFormat; + } + /** * XML to take disk-only snapshot of the VM.

* 1st parameter: snapshot's name;
@@ -1870,10 +2072,22 @@ public class KVMStorageProcessor implements StorageProcessor { if (snapshotSize != null) { newSnapshot.setPhysicalSize(snapshotSize); } - } else if (primaryPool.getType() == StoragePoolType.CLVM) { - CreateObjectAnswer result = takeClvmVolumeSnapshotOfStoppedVm(disk, snapshotName); - if (result != null) return result; - newSnapshot.setPath(snapshotPath); + } else if (primaryPool.getType() == StoragePoolType.CLVM || primaryPool.getType() == StoragePoolType.CLVM_NG) { + if (primaryPool.getType() == StoragePoolType.CLVM_NG && snapshotTO.isKvmIncrementalSnapshot()) { + if (secondaryPool == null) { + String errorMsg = String.format("Incremental snapshots for CLVM_NG require secondary storage. " + + "Please configure secondary storage or disable incremental snapshots for volume [%s].", volume.getName()); + logger.error(errorMsg); + return new CreateObjectAnswer(errorMsg); + } + logger.info("Taking incremental snapshot of CLVM_NG volume [{}] using QCOW2 backup to secondary storage.", volume.getName()); + newSnapshot = takeIncrementalVolumeSnapshotOfStoppedVm(snapshotTO, primaryPool, secondaryPool, + imageStoreTo.getUrl(), snapshotName, volume, conn, cmd.getWait()); + } else { + CreateObjectAnswer result = takeClvmVolumeSnapshotOfStoppedVm(disk, snapshotName); + if (result != null) return result; + newSnapshot.setPath(snapshotPath); + } } else { if (snapshotTO.isKvmIncrementalSnapshot()) { newSnapshot = takeIncrementalVolumeSnapshotOfStoppedVm(snapshotTO, primaryPool, secondaryPool, imageStoreTo != null ? imageStoreTo.getUrl() : null, snapshotName, volume, conn, cmd.getWait()); @@ -1946,7 +2160,11 @@ public class KVMStorageProcessor implements StorageProcessor { String machine = resource.isGuestAarch64() ? LibvirtComputingResource.VIRT : LibvirtComputingResource.PC; String cpuArch = resource.getGuestCpuArch() != null ? resource.getGuestCpuArch() : "x86_64"; - return String.format(DUMMY_VM_XML, vmName, cpuArch, machine, resource.getHypervisorPath(), primaryPool.getLocalPathFor(volumeObjectTo.getPath())); + String volumePath = primaryPool.getLocalPathFor(volumeObjectTo.getPath()); + boolean isClvmNg = StoragePoolType.CLVM_NG == primaryPool.getType(); + + String xmlTemplate = isClvmNg ? DUMMY_VM_XML_BLOCK : DUMMY_VM_XML; + return String.format(xmlTemplate, vmName, cpuArch, machine, resource.getHypervisorPath(), volumePath); } private SnapshotObjectTO takeIncrementalVolumeSnapshotOfRunningVm(SnapshotObjectTO snapshotObjectTO, KVMStoragePool primaryPool, KVMStoragePool secondaryPool, @@ -2667,11 +2885,13 @@ public class KVMStorageProcessor implements StorageProcessor { final PrimaryDataStoreTO primaryStore = (PrimaryDataStoreTO)vol.getDataStore(); try { final KVMStoragePool pool = storagePoolMgr.getStoragePool(primaryStore.getPoolType(), primaryStore.getUuid()); - try { - pool.getPhysicalDisk(vol.getPath()); - } catch (final Exception e) { - logger.debug(String.format("can't find volume: %s, return true", vol)); - return new Answer(null); + if (pool.getType() != StoragePoolType.CLVM && pool.getType() != StoragePoolType.CLVM_NG) { + try { + pool.getPhysicalDisk(vol.getPath()); + } catch (final Exception e) { + logger.debug(String.format("can't find volume: %s, return true", vol)); + return new Answer(null); + } } pool.deletePhysicalDisk(vol.getPath(), vol.getFormat()); return new Answer(null); @@ -2900,6 +3120,25 @@ public class KVMStorageProcessor implements StorageProcessor { if (snapshotTO.isKvmIncrementalSnapshot()) { deleteCheckpoint(snapshotTO); } + } else if (primaryPool.getType() == StoragePoolType.CLVM || primaryPool.getType() == StoragePoolType.CLVM_NG) { + // For CLVM/CLVM_NG, snapshots are typically already deleted from primary storage during backup + // via deleteSnapshotOnPrimary in the backupSnapshot finally block. + // This is called when the user explicitly deletes the snapshot via UI/API. + // We check if the snapshot still exists and clean it up if needed. + logger.info("Processing CLVM/CLVM_NG snapshot deletion (id={}, name={}, path={}) on primary storage", + snapshotTO.getId(), snapshotTO.getName(), snapshotTO.getPath()); + + String snapshotPath = snapshotTO.getPath(); + if (snapshotPath != null && !snapshotPath.isEmpty()) { + boolean wasDeleted = deleteClvmSnapshot(snapshotPath, primaryPool.getType(), true); + if (wasDeleted) { + logger.info("Successfully cleaned up {} snapshot {} from primary storage", primaryPool.getType(), snapshotName); + } else { + logger.info("{} snapshot {} was already deleted from primary storage during backup, no cleanup needed", primaryPool.getType(), snapshotName); + } + } else { + logger.debug("{} snapshot path is null or empty, assuming already cleaned up", primaryPool.getType()); + } } else { logger.warn("Operation not implemented for storage pool type of " + primaryPool.getType().toString()); throw new InternalErrorException("Operation not implemented for storage pool type of " + primaryPool.getType().toString()); @@ -3175,7 +3414,8 @@ public class KVMStorageProcessor implements StorageProcessor { StoragePoolType.RBD, StoragePoolType.PowerFlex, StoragePoolType.Linstor, - StoragePoolType.FiberChannel).contains(poolType)) { + StoragePoolType.FiberChannel, + StoragePoolType.CLVM).contains(poolType)) { return ImageFormat.RAW; } else { return ImageFormat.QCOW2; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java index a03daeb197b..ed159c92790 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java @@ -231,6 +231,11 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { } public StorageVol getVolume(StoragePool pool, String volName) { + if (pool == null) { + logger.debug("LibVirt StoragePool is null (likely CLVM/CLVM_NG virtual pool), cannot lookup volume {} via libvirt", volName); + return null; + } + StorageVol vol = null; try { @@ -254,9 +259,12 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { try { vol = pool.storageVolLookupByName(volName); - logger.debug("Found volume " + volName + " in storage pool " + pool.getName() + " after refreshing the pool"); + if (vol != null) { + logger.debug("Found volume " + volName + " in storage pool " + pool.getName() + " after refreshing the pool"); + } } catch (LibvirtException e) { - throw new CloudRuntimeException("Could not find volume " + volName + ": " + e.getMessage()); + logger.debug("Volume " + volName + " still not found after pool refresh: " + e.getMessage()); + return null; } } @@ -349,38 +357,6 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { } } - private StoragePool createCLVMStoragePool(Connect conn, String uuid, String host, String path) { - - String volgroupPath = "/dev/" + path; - String volgroupName = path; - volgroupName = volgroupName.replaceFirst("/", ""); - - LibvirtStoragePoolDef spd = new LibvirtStoragePoolDef(PoolType.LOGICAL, volgroupName, uuid, host, volgroupPath, volgroupPath); - StoragePool sp = null; - try { - logger.debug(spd.toString()); - sp = conn.storagePoolCreateXML(spd.toString(), 0); - return sp; - } catch (LibvirtException e) { - logger.error(e.toString()); - if (sp != null) { - try { - if (sp.isPersistent() == 1) { - sp.destroy(); - sp.undefine(); - } else { - sp.destroy(); - } - sp.free(); - } catch (LibvirtException l) { - logger.debug("Failed to define clvm storage pool with: " + l.toString()); - } - } - return null; - } - - } - private List getNFSMountOptsFromDetails(StoragePoolType type, Map details) { List nfsMountOpts = null; if (!type.equals(StoragePoolType.NetworkFilesystem) || details == null) { @@ -580,14 +556,11 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { Connect conn = LibvirtConnection.getConnection(); storage = conn.storagePoolLookupByUUIDString(uuid); - if (storage.getInfo().state != StoragePoolState.VIR_STORAGE_POOL_RUNNING) { - logger.warn("Storage pool " + uuid + " is not in running state. Attempting to start it."); - storage.create(0); - } LibvirtStoragePoolDef spd = getStoragePoolDef(conn, storage); if (spd == null) { throw new CloudRuntimeException("Unable to parse the storage pool definition for storage pool " + uuid); } + StoragePoolType type = null; if (spd.getPoolType() == LibvirtStoragePoolDef.PoolType.NETFS) { type = StoragePoolType.NetworkFilesystem; @@ -603,6 +576,12 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { type = StoragePoolType.PowerFlex; } + // Activate pool if not running + if (storage.getInfo().state != StoragePoolState.VIR_STORAGE_POOL_RUNNING) { + logger.warn("Storage pool " + uuid + " is not in running state. Attempting to start it."); + storage.create(0); + } + LibvirtStoragePool pool = new LibvirtStoragePool(uuid, storage.getName(), type, this, storage); if (pool.getType() != StoragePoolType.RBD && pool.getType() != StoragePoolType.PowerFlex) @@ -640,15 +619,17 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { logger.info("Asking libvirt to refresh storage pool " + uuid); pool.refresh(); } + pool.setCapacity(storage.getInfo().capacity); pool.setUsed(storage.getInfo().allocation); - updateLocalPoolIops(pool); pool.setAvailable(storage.getInfo().available); - logger.debug("Successfully refreshed pool " + uuid + - " Capacity: " + toHumanReadableSize(storage.getInfo().capacity) + - " Used: " + toHumanReadableSize(storage.getInfo().allocation) + - " Available: " + toHumanReadableSize(storage.getInfo().available)); + logger.debug("Successfully refreshed pool {} Capacity: {} Used: {} Available: {}", + uuid, toHumanReadableSize(storage.getInfo().capacity), + toHumanReadableSize(storage.getInfo().allocation), + toHumanReadableSize(storage.getInfo().available)); + + updateLocalPoolIops(pool); return pool; } catch (LibvirtException e) { @@ -663,6 +644,10 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { try { StorageVol vol = getVolume(libvirtPool.getPool(), volumeUuid); + if (vol == null) { + throw new CloudRuntimeException("Volume " + volumeUuid + " not found in libvirt pool"); + } + KVMPhysicalDisk disk; LibvirtStorageVolumeDef voldef = getStorageVolumeDef(libvirtPool.getPool().getConnect(), vol); disk = new KVMPhysicalDisk(vol.getPath(), vol.getName(), pool); @@ -693,7 +678,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { } return disk; } catch (LibvirtException e) { - logger.debug("Failed to get physical disk:", e); + logger.debug("Failed to get volume from libvirt: " + e.getMessage()); throw new CloudRuntimeException(e.toString()); } } @@ -722,7 +707,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { * Thread-safe increment storage pool usage refcount * @param uuid UUID of the storage pool to increment the count */ - private void incStoragePoolRefCount(String uuid) { + protected void incStoragePoolRefCount(String uuid) { adjustStoragePoolRefCount(uuid, 1); } /** @@ -730,7 +715,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { * @param uuid UUID of the storage pool to decrement the count * @return true if the storage pool is still used, else false. */ - private boolean decStoragePoolRefCount(String uuid) { + protected boolean decStoragePoolRefCount(String uuid) { return adjustStoragePoolRefCount(uuid, -1) > 0; } @@ -814,7 +799,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { try { sp = createNetfsStoragePool(PoolType.NETFS, conn, name, host, path, nfsMountOpts); } catch (LibvirtException e) { - logger.error("Failed to create netfs mount: " + host + ":" + path , e); + logger.error("Failed to create netfs mount: " + host + ":" + path, e); logger.error(e.getStackTrace()); throw new CloudRuntimeException(e.toString()); } @@ -822,7 +807,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { try { sp = createNetfsStoragePool(PoolType.GLUSTERFS, conn, name, host, path, null); } catch (LibvirtException e) { - logger.error("Failed to create glusterfs mount: " + host + ":" + path , e); + logger.error("Failed to create glusterlvm_fs mount: " + host + ":" + path, e); logger.error(e.getStackTrace()); throw new CloudRuntimeException(e.toString()); } @@ -830,8 +815,6 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { sp = createSharedStoragePool(conn, name, host, path); } else if (type == StoragePoolType.RBD) { sp = createRBDStoragePool(conn, name, host, port, userInfo, path); - } else if (type == StoragePoolType.CLVM) { - sp = createCLVMStoragePool(conn, name, host, path); } } @@ -845,7 +828,6 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { // to be always mounted, as long the primary storage isn't fully deleted. incStoragePoolRefCount(name); } - if (sp.isActive() == 0) { logger.debug("Attempting to activate pool " + name); sp.create(0); @@ -1116,6 +1098,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { @Override public boolean connectPhysicalDisk(String name, KVMStoragePool pool, Map details, boolean isVMMigrate) { // this is for managed storage that needs to prep disks prior to use + return true; } @@ -1227,7 +1210,11 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { LibvirtStoragePool libvirtPool = (LibvirtStoragePool)pool; try { StorageVol vol = getVolume(libvirtPool.getPool(), uuid); - logger.debug("Instructing libvirt to remove volume " + uuid + " from pool " + pool.getUuid()); + if (vol == null) { + logger.warn("Volume {} not found in libvirt pool {}, it may have been already deleted", uuid, pool.getUuid()); + return true; + } + logger.debug("Instructing libvirt to remove volume {} from pool {}", uuid, pool.getUuid()); if(Storage.ImageFormat.DIR.equals(format)){ deleteDirVol(libvirtPool, vol); } else { @@ -1420,9 +1407,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { rbd.close(destImage); } else { logger.debug("The source image " + srcPool.getSourceDir() + "/" + template.getName() - + " is RBD format 2. We will perform a RBD clone using snapshot " - + rbdTemplateSnapName); - /* The source image is format 2, we can do a RBD snapshot+clone (layering) */ + + " is RBD format 2. We will perform a RBD snapshot+clone (layering)"); logger.debug("Checking if RBD snapshot " + srcPool.getSourceDir() + "/" + template.getName() @@ -1618,9 +1603,12 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { } else { destFile = new QemuImgFile(destPath, destFormat); try { - boolean isQCOW2 = PhysicalDiskFormat.QCOW2.equals(sourceFormat); + boolean keepBitmaps = PhysicalDiskFormat.QCOW2.equals(sourceFormat); + if (destPool.getType() == StoragePoolType.CLVM) { + keepBitmaps = false; + } qemu.convert(srcFile, destFile, null, null, new QemuImageOptions(srcFile.getFormat(), srcFile.getFileName(), null), - null, false, isQCOW2); + null, false, keepBitmaps); Map destInfo = qemu.info(destFile); Long virtualSize = Long.parseLong(destInfo.get(QemuImg.VIRTUAL_SIZE)); newDisk.setVirtualSize(virtualSize); @@ -1684,8 +1672,8 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { } } else { /** - We let Qemu-Img do the work here. Although we could work with librbd and have that do the cloning - it doesn't benefit us. It's better to keep the current code in place which works + We let Qemu-Img do the work here. Although we could work with librbd and have that do the cloning + it doesn't benefit us. It's better to keep the current code in place which works */ srcFile = new QemuImgFile(KVMPhysicalDisk.RBDStringBuilder(srcPool, sourcePath)); srcFile.setFormat(sourceFormat); @@ -1737,6 +1725,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { vol.delete(0); } + private void deleteDirVol(LibvirtStoragePool pool, StorageVol vol) throws LibvirtException { Script.runSimpleBashScript("rm -r --interactive=never " + vol.getPath()); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java index 910f0eb15e0..a8c32baa6ef 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java @@ -213,7 +213,7 @@ public class LibvirtStoragePool implements KVMStoragePool { @Override public boolean isExternalSnapshot() { - if (this.type == StoragePoolType.CLVM || type == StoragePoolType.RBD) { + if (this.type == StoragePoolType.CLVM || this.type == StoragePoolType.CLVM_NG || type == StoragePoolType.RBD) { return true; } return false; @@ -278,6 +278,10 @@ public class LibvirtStoragePool implements KVMStoragePool { return this.type; } + public void setType(StoragePoolType type) { + this.type = type; + } + public StoragePool getPool() { return this._pool; } @@ -420,8 +424,4 @@ public class LibvirtStoragePool implements KVMStoragePool { return true; } } - - public void setType(StoragePoolType type) { - this.type = type; - } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/StorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/StorageAdaptor.java index 76b5a413e70..fb474a6bc7d 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/StorageAdaptor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/StorageAdaptor.java @@ -148,4 +148,8 @@ public interface StorageAdaptor { default Pair unprepareStorageClient(String uuid, Map details) { return new Pair<>(true, ""); } + + default void createTemplate(String templatePath, String templateUuid, int timeout, KVMStoragePool pool) { + // no-op + } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java index b3bdafb7375..ce431d9bab2 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java @@ -2728,8 +2728,11 @@ public class LibvirtComputingResourceTest { @Test public void testModifyStoragePoolCommand() { - final StoragePool pool = Mockito.mock(StoragePool.class);; + final StoragePool pool = Mockito.mock(StoragePool.class); final ModifyStoragePoolCommand command = new ModifyStoragePoolCommand(true, pool); + Map details = new HashMap<>(); + details.put(KVMStoragePool.CLVM_SECURE_ZERO_FILL, "false"); + command.setDetails(details); final KVMStoragePoolManager storagePoolMgr = Mockito.mock(KVMStoragePoolManager.class); final KVMStoragePool kvmStoragePool = Mockito.mock(KVMStoragePool.class); @@ -2753,8 +2756,11 @@ public class LibvirtComputingResourceTest { @Test public void testModifyStoragePoolCommandFailure() { - final StoragePool pool = Mockito.mock(StoragePool.class);; + final StoragePool pool = Mockito.mock(StoragePool.class); final ModifyStoragePoolCommand command = new ModifyStoragePoolCommand(true, pool); + Map details = new HashMap<>(); + details.put(KVMStoragePool.CLVM_SECURE_ZERO_FILL, "false"); + command.setDetails(details); final KVMStoragePoolManager storagePoolMgr = Mockito.mock(KVMStoragePoolManager.class); @@ -7245,6 +7251,307 @@ public class LibvirtComputingResourceTest { libvirtComputingResourceSpy.getInterface(connMock, vmName, invalidMacAddress); } + @Test + public void testExtractVolumeGroupFromPath_ValidPath() { + String devicePath = "/dev/vg1/volume-123"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("vg1", vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_ComplexVGName() { + String devicePath = "/dev/cloudstack-vg-primary/volume-456"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("cloudstack-vg-primary", vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_MultiLevelPath() { + String devicePath = "/dev/vg-cluster-01/lv-data-001"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("vg-cluster-01", vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_NullPath() { + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(null); + assertNull(vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_EmptyPath() { + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(""); + assertNull(vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_NonDevPath() { + String devicePath = "/var/lib/libvirt/images/disk.qcow2"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertNull(vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_InvalidFormat() { + String devicePath = "/dev/"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertNull(vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_OnlyVG() { + String devicePath = "/dev/vg1"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + // Implementation extracts parts[2] regardless of whether there's an LV name + assertEquals("vg1", vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_MapperPath() { + String devicePath = "/dev/mapper/vg1-volume"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("mapper", vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_WithDashes() { + String devicePath = "/dev/vg-name-with-dashes/lv-name"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("vg-name-with-dashes", vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_WithUnderscores() { + String devicePath = "/dev/vg_name_with_underscores/lv_name"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("vg_name_with_underscores", vgName); + } + + @Test + public void testCheckIfVolumeGroupIsClustered_NullVGName() { + boolean result = LibvirtComputingResource.checkIfVolumeGroupIsClustered(null); + assertFalse(result); + } + + @Test + public void testCheckIfVolumeGroupIsClustered_EmptyVGName() { + boolean result = LibvirtComputingResource.checkIfVolumeGroupIsClustered(""); + assertFalse(result); + } + + @Test + public void testActivateClvmVolumeExclusive_ValidPath() { + try { + String volumePath = "/dev/test-vg/test-lv"; + LibvirtComputingResource.activateClvmVolumeExclusive(volumePath); + } catch (Exception e) { + String message = e.getMessage().toLowerCase(); + assertTrue("Should be LVM-related error", + message.contains("lvm") || + message.contains("lvchange") || + message.contains("volume") || + message.contains("not found") || + message.contains("failed")); + } + } + + @Test + public void testDeactivateClvmVolume_ValidPath() { + String volumePath = "/dev/test-vg/test-lv"; + + LibvirtComputingResource.deactivateClvmVolume(volumePath); + + assertTrue(true); + } + + @Test + public void testSetClvmVolumeToSharedMode_ValidPath() { + String volumePath = "/dev/test-vg/test-lv"; + + LibvirtComputingResource.setClvmVolumeToSharedMode(volumePath); + + assertTrue(true); + } + + @Test + public void testDeactivateClvmVolume_NullPath() { + LibvirtComputingResource.deactivateClvmVolume(null); + assertTrue(true); + } + + @Test + public void testSetClvmVolumeToSharedMode_NullPath() { + LibvirtComputingResource.setClvmVolumeToSharedMode(null); + assertTrue(true); // Passes if no exception + } + + @Test + public void testDeactivateClvmVolume_EmptyPath() { + LibvirtComputingResource.deactivateClvmVolume(""); + assertTrue(true); + } + + @Test + public void testSetClvmVolumeToSharedMode_EmptyPath() { + LibvirtComputingResource.setClvmVolumeToSharedMode(""); + assertTrue(true); + } + + @Test + public void testDeactivateClvmVolume_InvalidPath() { + String invalidPath = "/invalid/path/that/does/not/exist"; + LibvirtComputingResource.deactivateClvmVolume(invalidPath); + assertTrue(true); + } + + @Test + public void testSetClvmVolumeToSharedMode_InvalidPath() { + // Should handle invalid path gracefully without throwing + String invalidPath = "/invalid/path/that/does/not/exist"; + LibvirtComputingResource.setClvmVolumeToSharedMode(invalidPath); + assertTrue(true); // Passes if no exception + } + + @Test + public void testExtractVolumeGroupFromPath_RealWorldPaths() { + assertEquals("acsvg", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/acsvg/volume-123")); + assertEquals("cloudstack-primary", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/cloudstack-primary/vm-disk-1")); + assertEquals("ceph-vg", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/ceph-vg/snapshot-456")); + assertEquals("vg01", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/vg01/data")); + } + + @Test + public void testCheckIfVolumeGroupIsClustered_NonExistentVG() { + String nonExistentVG = "non-existent-vg-" + System.currentTimeMillis(); + boolean result = LibvirtComputingResource.checkIfVolumeGroupIsClustered(nonExistentVG); + assertFalse(result); + } + + @Test + public void testActivateClvmVolumeExclusive_ComplexPath() { + try { + String complexPath = "/dev/cloudstack-vg-primary-cluster-01/volume-123-456-789-abc"; + LibvirtComputingResource.activateClvmVolumeExclusive(complexPath); + } catch (Exception e) { + String message = e.getMessage().toLowerCase(); + assertTrue("Should be LVM-related error", + message.contains("lvm") || + message.contains("lvchange") || + message.contains("volume") || + message.contains("not found") || + message.contains("failed")); + } + } + + @Test + public void testDeactivateClvmVolume_ComplexPath() { + String complexPath = "/dev/cloudstack-vg-primary-cluster-01/volume-123-456-789-abc"; + LibvirtComputingResource.deactivateClvmVolume(complexPath); + assertTrue(true); + } + + @Test + public void testExtractVolumeGroupFromPath_SpecialCharacters() { + assertEquals("vg.name", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/vg.name/lv")); + assertEquals("vg_name", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/vg_name/lv")); + assertEquals("vg-name", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/vg-name/lv")); + assertEquals("vg123", LibvirtComputingResource.extractVolumeGroupFromPath("/dev/vg123/lv456")); + } + + @Test + public void testExtractVolumeGroupFromPath_TrailingSlash() { + String devicePath = "/dev/vg1/volume-123/"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("vg1", vgName); + } + + @Test + public void testCheckIfVolumeGroupIsClustered_WhitespaceVGName() { + boolean result = LibvirtComputingResource.checkIfVolumeGroupIsClustered(" "); + assertFalse(result); + } + + @Test + public void testExtractVolumeGroupFromPath_DevMapperExcluded() { + String mapperPath1 = "/dev/mapper/vg1-lv1"; + String mapperPath2 = "/dev/mapper/cloudstack--vg-volume--1"; + + assertEquals("mapper", LibvirtComputingResource.extractVolumeGroupFromPath(mapperPath1)); + assertEquals("mapper", LibvirtComputingResource.extractVolumeGroupFromPath(mapperPath2)); + } + + @Test + public void testExtractVolumeGroupFromPath_EdgeCases() { + assertNull(LibvirtComputingResource.extractVolumeGroupFromPath("/dev")); + assertNull(LibvirtComputingResource.extractVolumeGroupFromPath("/dev/")); + assertNull(LibvirtComputingResource.extractVolumeGroupFromPath("dev/vg/lv")); + assertNull(LibvirtComputingResource.extractVolumeGroupFromPath("//dev//vg//lv")); + } + + @Test + public void testClvmVolumeActivationSequence() { + // Test a typical sequence: deactivate -> activate exclusive -> deactivate -> shared + String volumePath = "/dev/test-vg/test-volume"; + + LibvirtComputingResource.deactivateClvmVolume(volumePath); + + try { + LibvirtComputingResource.activateClvmVolumeExclusive(volumePath); + } catch (Exception e) { + // Expected in test environment + } + + LibvirtComputingResource.deactivateClvmVolume(volumePath); + LibvirtComputingResource.setClvmVolumeToSharedMode(volumePath); + + assertTrue(true); // Test passes if sequence completes + } + + @Test + public void testExtractVolumeGroupFromPath_LongVGName() { + String longVGName = "a".repeat(100); + String devicePath = "/dev/" + longVGName + "/volume"; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals(longVGName, vgName); + } + + @Test + public void testExtractVolumeGroupFromPath_LongLVName() { + String longLVName = "volume-" + "b".repeat(100); + String devicePath = "/dev/vg1/" + longLVName; + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(devicePath); + assertEquals("vg1", vgName); + } + + @Test + public void testCheckIfVolumeGroupIsClustered_SpecialCharactersInName() { + assertFalse(LibvirtComputingResource.checkIfVolumeGroupIsClustered("vg.test.name")); + assertFalse(LibvirtComputingResource.checkIfVolumeGroupIsClustered("vg_test_name")); + assertFalse(LibvirtComputingResource.checkIfVolumeGroupIsClustered("vg-test-name")); + } + + @Test + public void testClvmMethodsWithMultiplePaths() { + String[] paths = { + "/dev/vg1/vol1", + "/dev/vg2/vol2", + "/dev/cloudstack-primary/vol3", + "/dev/test-vg/test-vol" + }; + + for (String path : paths) { + LibvirtComputingResource.deactivateClvmVolume(path); + LibvirtComputingResource.setClvmVolumeToSharedMode(path); + + String vgName = LibvirtComputingResource.extractVolumeGroupFromPath(path); + assertNotNull("Should extract VG from: " + path, vgName); + + boolean clustered = LibvirtComputingResource.checkIfVolumeGroupIsClustered(vgName); + } + + assertTrue(true); // Passes if all paths processed + } + @Test public void updateCpuQuotaAndPeriodTestAssertPeriodAndQuotaAreNotUpdatedWhenLibvirtVersionIsLessThanTheMinimum() throws LibvirtException { libvirtComputingResourceSpy.hypervisorLibvirtVersion = 8999; diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapperTest.java new file mode 100644 index 00000000000..5d11cf209a4 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtClvmLockTransferCommandWrapperTest.java @@ -0,0 +1,462 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.storage.clvm.command.ClvmLockTransferCommand; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.utils.script.Script; + +/** + * Tests for LibvirtClvmLockTransferCommandWrapper + */ +@RunWith(MockitoJUnitRunner.class) +public class LibvirtClvmLockTransferCommandWrapperTest { + + @Mock + private LibvirtComputingResource libvirtComputingResource; + + private LibvirtClvmLockTransferCommandWrapper wrapper; + + private static final String TEST_LV_PATH = "/dev/vg1/volume-123"; + private static final String TEST_VOLUME_UUID = "test-volume-uuid-456"; + + @Before + public void setUp() { + wrapper = new LibvirtClvmLockTransferCommandWrapper(); + } + + @Test + public void testExecute_DeactivateSuccess() { + ClvmLockTransferCommand cmd = new ClvmLockTransferCommand( + ClvmLockTransferCommand.Operation.DEACTIVATE, + TEST_LV_PATH, + TEST_VOLUME_UUID + ); + + try (MockedConstruction + + diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue index 4145eeb9be6..ecff34b2949 100644 --- a/ui/src/components/view/DetailsTab.vue +++ b/ui/src/components/view/DetailsTab.vue @@ -97,7 +97,7 @@ {{ $t(dataResource[item].toLowerCase()) }} {{ dataResource[item] }} -

+
{{ $toLocaleDate(dataResource[item]) }}
{{ dataResource[item] }}
@@ -168,7 +168,7 @@
{{ dataResource[item] }}
- +
{{ $t('label.' + item.replace('date', '.date.and.time'))}}
diff --git a/ui/src/components/view/InfoCard.vue b/ui/src/components/view/InfoCard.vue index 3094bf58dbc..41c822de238 100644 --- a/ui/src/components/view/InfoCard.vue +++ b/ui/src/components/view/InfoCard.vue @@ -120,12 +120,18 @@ -
+
{{ $t('label.status') }}
+
+
{{ $t('label.apikeyaccess') }}
+
+ +
+
{{ $t('label.allocationstate') }}
@@ -159,6 +165,42 @@
+
+
+ + + {{ $t('label.apikey') }} + + +
+ {{ resource.apikey.substring(0, 20) }}... +
+

+
+ + + {{ $t('label.secretkey') }} + + +
+ {{ resource.secretkey.substring(0, 20) }}... +
+
+
{{ $t('label.ostypename') }}
@@ -796,6 +838,14 @@ {{ resource.account }}
+
+
{{ $t('label.user') }}
+
+ + {{ resource.username }} + {{ resource.username }} +
+
{{ $t('label.role') }}
@@ -883,54 +933,6 @@ :osCategoryId="osCategoryId" />
- - - - +
@@ -231,10 +243,14 @@ export default { socialLogin: false, googleprovider: false, githubprovider: false, + keycloakprovider: false, googleredirecturi: '', githubredirecturi: '', + keycloakredirecturi: '', googleclientid: '', githubclientid: '', + keycloakclientid: '', + keycloakauthorizeurl: '', loginType: 0, state: { time: 60, @@ -325,8 +341,14 @@ export default { this.githubclientid = item.clientid this.githubredirecturi = item.redirecturi } + if (item.provider === 'keycloak') { + this.keycloakprovider = item.enabled + this.keycloakclientid = item.clientid + this.keycloakredirecturi = item.redirecturi + this.keycloakauthorizeurl = item.authorizeurl + } }) - this.socialLogin = this.googleprovider || this.githubprovider + this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider } }) postAPI('forgotPassword', {}).then(response => { @@ -362,6 +384,10 @@ export default { this.handleDomain() this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'google') }, + handleKeycloakProviderAndDomain () { + this.handleDomain() + this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'keycloak') + }, handleDomain () { const values = toRaw(this.form) if (!values.domain) { @@ -401,6 +427,20 @@ export default { return `${rootUrl}?${qs.toString()}` }, + getKeycloakUrl (from) { + const rootURl = this.keycloakauthorizeurl + const options = { + redirect_uri: this.keycloakredirecturi, + client_id: this.keycloakclientid, + response_type: 'code', + scope: 'openid email', + state: 'cloudstack' + } + + const qs = new URLSearchParams(options) + + return `${rootURl}?${qs.toString()}` + }, handleSubmit (e) { e.preventDefault() if (this.state.loginBtn) return From 21e4475d961a4cf645ccf043158f6d2bb7655a61 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Anaparti Date: Tue, 23 Jun 2026 20:11:19 +0530 Subject: [PATCH 12/14] Optimize the DB updates to use bulk UPDATE instead of row-level locks. (#13349) Co-authored-by: Aaron Chung --- .../com/cloud/alert/dao/AlertDaoImpl.java | 28 ++++++------- .../com/cloud/event/dao/EventDaoImpl.java | 29 ++++++++------ .../java/com/cloud/host/dao/HostDaoImpl.java | 22 +++++++---- .../dao/SecurityGroupWorkDaoImpl.java | 39 +++++-------------- 4 files changed, 53 insertions(+), 65 deletions(-) diff --git a/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java b/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java index 94d01f472ba..97b7c54f084 100644 --- a/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java @@ -20,6 +20,7 @@ import java.util.Date; import java.util.List; +import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; import com.cloud.alert.AlertVO; @@ -28,7 +29,7 @@ import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; -import com.cloud.utils.db.TransactionLegacy; +import com.cloud.utils.db.UpdateBuilder; @Component public class AlertDaoImpl extends GenericDaoBase implements AlertDao { @@ -107,25 +108,20 @@ public class AlertDaoImpl extends GenericDaoBase implements Alert } sc.setParameters("archived", false); - boolean result = true; - ; List alerts = listBy(sc); if (ids != null && alerts.size() < ids.size()) { - result = false; - return result; + return false; } - if (alerts != null && !alerts.isEmpty()) { - TransactionLegacy txn = TransactionLegacy.currentTxn(); - txn.start(); - for (AlertVO alert : alerts) { - alert = lockRow(alert.getId(), true); - alert.setArchived(true); - update(alert.getId(), alert); - txn.commit(); - } - txn.close(); + + if (CollectionUtils.isEmpty(alerts)) { + return true; } - return result; + + AlertVO alertForUpdate = createForUpdate(); + alertForUpdate.setArchived(true); + UpdateBuilder ub = getUpdateBuilder(alertForUpdate); + update(ub, sc, null); + return true; } @Override diff --git a/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java b/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java index e748e98900e..9417ddd1259 100644 --- a/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java @@ -18,8 +18,10 @@ package com.cloud.event.dao; import java.util.Date; import java.util.List; +import java.util.stream.Collectors; +import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; import com.cloud.event.Event.State; @@ -29,12 +31,13 @@ import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; -import com.cloud.utils.db.TransactionLegacy; +import com.cloud.utils.db.UpdateBuilder; @Component public class EventDaoImpl extends GenericDaoBase implements EventDao { protected final SearchBuilder CompletedEventSearch; protected final SearchBuilder ToArchiveOrDeleteEventSearch; + protected final SearchBuilder ArchiveByIdsSearch; public EventDaoImpl() { CompletedEventSearch = createSearchBuilder(); @@ -51,6 +54,10 @@ public class EventDaoImpl extends GenericDaoBase implements Event ToArchiveOrDeleteEventSearch.and("createdDateL", ToArchiveOrDeleteEventSearch.entity().getCreateDate(), Op.LTEQ); ToArchiveOrDeleteEventSearch.and("archived", ToArchiveOrDeleteEventSearch.entity().getArchived(), Op.EQ); ToArchiveOrDeleteEventSearch.done(); + + ArchiveByIdsSearch = createSearchBuilder(); + ArchiveByIdsSearch.and("id", ArchiveByIdsSearch.entity().getId(), Op.IN); + ArchiveByIdsSearch.done(); } @Override @@ -100,16 +107,16 @@ public class EventDaoImpl extends GenericDaoBase implements Event @Override public void archiveEvents(List events) { - if (events != null && !events.isEmpty()) { - TransactionLegacy txn = TransactionLegacy.currentTxn(); - txn.start(); - for (EventVO event : events) { - event = lockRow(event.getId(), true); - event.setArchived(true); - update(event.getId(), event); - txn.commit(); - } - txn.close(); + if (CollectionUtils.isEmpty(events)) { + return; } + + List ids = events.stream().map(EventVO::getId).collect(Collectors.toList()); + SearchCriteria sc = ArchiveByIdsSearch.create(); + sc.setParameters("id", ids.toArray(new Object[ids.size()])); + EventVO eventForUpdate = createForUpdate(); + eventForUpdate.setArchived(true); + UpdateBuilder ub = getUpdateBuilder(eventForUpdate); + update(ub, sc, null); } } diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java index cd4423dfa26..5b8a38b8e5b 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java @@ -646,16 +646,22 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao sc.setParameters("lastPinged", lastPingSecondsAfter); sc.setParameters("status", Status.Disconnected, Status.Down, Status.Alert); - StringBuilder sb = new StringBuilder(); - List hosts = lockRows(sc, null, true); // exclusive lock - for (HostVO host : hosts) { - host.setManagementServerId(null); - update(host.getId(), host); - sb.append(host.getId()); - sb.append(" "); + // SELECT before bulk UPDATE to preserve per-host-ID trace logging — the bulk UPDATE + // cannot return which rows it matched since the WHERE column is being set to NULL + if (logger.isTraceEnabled()) { + List hosts = listBy(sc); + StringBuilder sb = new StringBuilder(); + for (HostVO host : hosts) { + sb.append(host.getId()); + sb.append(" "); + } + logger.trace("Following hosts will be reset: {}", sb); } - logger.trace("Following hosts got reset: {}", sb); + HostVO host = createForUpdate(); + host.setManagementServerId(null); + UpdateBuilder ub = getUpdateBuilder(host); + update(ub, sc, null); } /* diff --git a/engine/schema/src/main/java/com/cloud/network/security/dao/SecurityGroupWorkDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/security/dao/SecurityGroupWorkDaoImpl.java index 327d12c759a..3180ef30a3c 100644 --- a/engine/schema/src/main/java/com/cloud/network/security/dao/SecurityGroupWorkDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/security/dao/SecurityGroupWorkDaoImpl.java @@ -116,7 +116,7 @@ public class SecurityGroupWorkDaoImpl extends GenericDaoBase sc = VmIdSeqNumSearch.create(); sc.setParameters("vmId", vmId); sc.setParameters("seqno", logSequenceNumber); - final Filter filter = new Filter(SecurityGroupWorkVO.class, null, true, 0l, 1l); - - final List vos = lockRows(sc, filter, true); - if (vos.size() == 0) { - txn.commit(); - return; - } - SecurityGroupWorkVO work = vos.get(0); - work.setStep(step); - update(work.getId(), work); - - txn.commit(); + SecurityGroupWorkVO workForUpdate = createForUpdate(); + workForUpdate.setStep(step); + // LIMIT 1 preserves the original single-row semantics: op_nwgrp_work has no + // uniqueness on (instance_id, seq_no), so without it duplicate rows would all be updated. + update(workForUpdate, sc, 1); } @Override @@ -172,21 +162,10 @@ public class SecurityGroupWorkDaoImpl extends GenericDaoBase Date: Wed, 24 Jun 2026 07:08:26 -0400 Subject: [PATCH 13/14] Multiple CD-ROM / ISO Support Per VM (#13101) * pre-allocate a second empty cdrom slot at boot (hardcoded) * drive cdrom slot count via vm.cdrom.max.count ConfigKey * add vm_iso_map table + VO/DAO * persist multi-ISO state via vm_iso_map * carry target cdrom slot through AttachCommand to KVM agent * enforce per-VM cdrom cap, clamp to hypervisor max * make detachIso accepts an ISO id * expose attached ISOs as isos[] in listVirtualMachines response * extract CDROM_PRIMARY_DEVICE_SEQ constant * unit tests for cdrom slot allocation logic * implement multi-ISO attachment and detachment for VMs with enhanced validation * implement multi-ISO display in InstanceTab with computed property for attached ISOs * add warning alert for max CDROM selections and enhance global capacity fetching * enhance ISO attachment validation to handle multiple ISOs and prevent duplicates * refactor ISO attachment logic for detachment and validation * add unit tests for ISO detachment resolution and validation logic * add mock for VmIsoMapDao in UserVmJoinDaoImplTest and set lenient behavior for listByVmId * refactor ISO attachment logic and enhance UI for multi-CDROM management * refactor ISO attachment methods to use VM ID and improve parameter handling * remove unnecessary mock for VM ISO mapping in TemplateManagerImplTest * add 'since' attribute to ISO detach command parameter description * scope vm.cdrom.max.count to cluster * add support for configurable CD-ROM count per VM and improve handling in TemplateManager * add HostDetailsDao mock to UserVmJoinDaoImplTest * fix: handle null poolId when loading attached ISO slots in prepareIsoForVmProfile * implement listByIsoId method in VmIsoMapDao and update TemplateManagerImpl for ISO deletion checks * improve logging messages for ISO deletion checks * add unit tests for CD-ROM handling and enforce limits in TemplateManager * refactor: update configuration value handling and improve notification logic * refactor: rename CD-ROM references to ISO and update related logic * refactor: enhance effective CD-ROM max count logic to handle missing host IDs and improve cluster ID retrieval * refactor: enhance effective CD-ROM max count logic to handle misconfigurations during VM boot * refactor: enhance effective CD-ROM max count logic to retrieve host ID from candidates based on hypervisor type * refactor: enhance host ID retrieval logic for VMs based on hypervisor type * feat: add bootable ISO flag to AttachedIsoResponse and update UI to display it * refactor: simplify effectiveMaxCdroms method and improve logging for CD-ROM capacity * test: update AttachedIsoResponseTest to include bootable flag in constructor tests * feat: include bootable flag in AttachedIsoResponse for user VMs * feat: enhance CD-ROM management by defining empty slots for user VMs --- api/src/main/java/com/cloud/host/Host.java | 1 + .../api/command/user/iso/DetachIsoCmd.java | 7 +- .../api/response/AttachedIsoResponse.java | 76 +++++ .../api/response/UserVmResponse.java | 24 ++ .../api/response/AttachedIsoResponseTest.java | 46 +++ .../com/cloud/template/TemplateManager.java | 15 + .../main/java/com/cloud/vm/VmIsoMapVO.java | 83 +++++ .../java/com/cloud/vm/dao/VmIsoMapDao.java | 34 ++ .../com/cloud/vm/dao/VmIsoMapDaoImpl.java | 92 ++++++ ...spring-engine-schema-core-daos-context.xml | 1 + .../META-INF/db/schema-42210to42300.sql | 14 + .../java/com/cloud/vm/VmIsoMapVOTest.java | 41 +++ .../resource/LibvirtComputingResource.java | 16 + .../hypervisor/kvm/resource/LibvirtVMDef.java | 4 + .../kvm/storage/KVMStorageProcessor.java | 15 +- .../api/query/dao/UserVmJoinDaoImpl.java | 70 +++++ .../cloud/template/TemplateManagerImpl.java | 292 ++++++++++++++---- .../api/query/dao/UserVmJoinDaoImplTest.java | 46 +++ .../template/TemplateManagerImplTest.java | 243 +++++++++++++++ ui/src/config/section/compute.js | 28 +- ui/src/views/compute/AttachIso.vue | 112 +++++-- ui/src/views/compute/DetachIso.vue | 178 +++++++++++ ui/src/views/compute/InstanceTab.vue | 34 +- ui/src/views/setting/ConfigurationValue.vue | 5 +- 24 files changed, 1360 insertions(+), 117 deletions(-) create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java create mode 100644 api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java create mode 100644 engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java create mode 100644 ui/src/views/compute/DetachIso.vue diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index 8b14cfd3a39..c110e4ca94e 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -63,6 +63,7 @@ public interface Host extends StateObject, Identity, Partition, HAResour String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; String HOST_SSH_PORT = "host.ssh.port"; + String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count"; String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; String GUEST_OS_RULE = "guest.os.rule"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java index cf4aa41f795..2560d837de1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java @@ -27,6 +27,7 @@ import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.command.user.vm.DeployVMCmd; +import org.apache.cloudstack.api.response.TemplateResponse; import org.apache.cloudstack.api.response.UserVmResponse; import com.cloud.event.EventTypes; @@ -51,6 +52,10 @@ public class DetachIsoCmd extends BaseAsyncCmd implements UserCmd { description = "If true, ejects the ISO before detaching on VMware. Default: false", since = "4.15.1") protected Boolean forced; + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = TemplateResponse.class, + description = "The ID of the ISO to detach. Required when the Instance has more than one ISO attached.", since = "4.23.0") + protected Long id; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -104,7 +109,7 @@ public class DetachIsoCmd extends BaseAsyncCmd implements UserCmd { @Override public void execute() { - boolean result = _templateService.detachIso(virtualMachineId, null, isForced()); + boolean result = _templateService.detachIso(virtualMachineId, id, isForced()); if (result) { UserVm userVm = _entityMgr.findById(UserVm.class, virtualMachineId); UserVmResponse response = _responseGenerator.createUserVmResponse(getResponseView(), "virtualmachine", userVm).get(0); diff --git a/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java new file mode 100644 index 00000000000..b259de56218 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class AttachedIsoResponse extends BaseResponse { + + @SerializedName("id") + @Param(description = "The ID of the attached ISO") + private String id; + + @SerializedName("name") + @Param(description = "The name of the attached ISO") + private String name; + + @SerializedName("displaytext") + @Param(description = "The display text of the attached ISO") + private String displayText; + + @SerializedName("deviceseq") + @Param(description = "The cdrom slot that holds this ISO (3=hdc, 4=hdd, ...)") + private Integer deviceSeq; + + @SerializedName("bootable") + @Param(description = "Whether this is the bootable ISO for the VM") + private Boolean bootable; + + public AttachedIsoResponse() { + } + + public AttachedIsoResponse(String id, String name, String displayText, Integer deviceSeq, boolean bootable) { + this.id = id; + this.name = name; + this.displayText = displayText; + this.deviceSeq = deviceSeq; + this.bootable = bootable; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDisplayText() { + return displayText; + } + + public Integer getDeviceSeq() { + return deviceSeq; + } + + public Boolean getBootable() { + return bootable; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java index a7f6dff96f8..4d6eae2fad2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java @@ -166,6 +166,14 @@ public class UserVmResponse extends BaseResponseWithTagInformation implements Co @Param(description = "An alternate display text of the ISO attached to the Instance") private String isoDisplayText; + @SerializedName("isos") + @Param(description = "All ISOs attached to the Instance, keyed by cdrom slot. The first entry mirrors isoid/isoname for back-compat.", responseObject = AttachedIsoResponse.class, since = "4.23.0") + private List isos; + + @SerializedName("isomaxcount") + @Param(description = "Maximum number of ISOs that may be attached to this Instance, after applying the cluster-scoped vm.iso.max.count and the hypervisor's own cap.", since = "4.23.0") + private Integer isoMaxCount; + @SerializedName(ApiConstants.SERVICE_OFFERING_ID) @Param(description = "The ID of the service offering of the Instance") private String serviceOfferingId; @@ -871,6 +879,22 @@ public class UserVmResponse extends BaseResponseWithTagInformation implements Co this.isoId = isoId; } + public void setIsos(List isos) { + this.isos = isos; + } + + public List getIsos() { + return isos; + } + + public void setIsoMaxCount(Integer isoMaxCount) { + this.isoMaxCount = isoMaxCount; + } + + public Integer getIsoMaxCount() { + return isoMaxCount; + } + public void setIsoName(String isoName) { this.isoName = isoName; } diff --git a/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java b/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java new file mode 100644 index 00000000000..09d4eb598ab --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public final class AttachedIsoResponseTest { + + @Test + public void testFullConstructorPopulatesAllFields() { + AttachedIsoResponse response = new AttachedIsoResponse("uuid-1", "alpine-iso", "Alpine boot", 3, true); + Assert.assertEquals("uuid-1", response.getId()); + Assert.assertEquals("alpine-iso", response.getName()); + Assert.assertEquals("Alpine boot", response.getDisplayText()); + Assert.assertEquals(Integer.valueOf(3), response.getDeviceSeq()); + Assert.assertTrue(response.getBootable()); + } + + @Test + public void testNoArgConstructorLeavesFieldsNull() { + AttachedIsoResponse response = new AttachedIsoResponse(); + Assert.assertNull(response.getId()); + Assert.assertNull(response.getName()); + Assert.assertNull(response.getDisplayText()); + Assert.assertNull(response.getDeviceSeq()); + Assert.assertNull(response.getBootable()); + } +} diff --git a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java index f1891c774ed..24d7bf621f6 100644 --- a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java +++ b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java @@ -64,6 +64,21 @@ public interface TemplateManager { true, ConfigKey.Scope.Global); + ConfigKey VmIsoMaxCount = new ConfigKey("Advanced", + Integer.class, + "vm.iso.max.count", "1", + "Maximum number of ISOs that may be attached to a VM.", + true, + ConfigKey.Scope.Cluster); + + // KVM/libvirt maps deviceSeq=3 to hdc (hda/hdb are taken by the root volume on i440fx/IDE). + // user_vm.iso_id has always pointed at this slot; additional cdroms live in vm_iso_map. + int CDROM_PRIMARY_DEVICE_SEQ = 3; + + // Fallback per-VM cdrom cap when the placement host hasn't advertised host.cdrom.max.count + // (older agent, never-deployed VM, etc.). + int DEFAULT_CDROM_MAX_PER_VM = 1; + static final String VMWARE_TOOLS_ISO = "vmware-tools.iso"; static final String XS_TOOLS_ISO = "xs-tools.iso"; diff --git a/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java b/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java new file mode 100644 index 00000000000..f4a3f116818 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import java.util.Date; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.apache.cloudstack.api.InternalIdentity; + +@Entity +@Table(name = "vm_iso_map") +public class VmIsoMapVO implements InternalIdentity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "vm_id") + private long vmId; + + @Column(name = "iso_id") + private long isoId; + + @Column(name = "device_seq") + private int deviceSeq; + + @Column(name = "created") + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + public VmIsoMapVO() { + } + + public VmIsoMapVO(long vmId, long isoId, int deviceSeq) { + this.vmId = vmId; + this.isoId = isoId; + this.deviceSeq = deviceSeq; + this.created = new Date(); + } + + @Override + public long getId() { + return id; + } + + public long getVmId() { + return vmId; + } + + public long getIsoId() { + return isoId; + } + + public int getDeviceSeq() { + return deviceSeq; + } + + public Date getCreated() { + return created; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java new file mode 100644 index 00000000000..a472a3b4dec --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java @@ -0,0 +1,34 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.List; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmIsoMapVO; + +public interface VmIsoMapDao extends GenericDao { + List listByVmId(long vmId); + + List listByIsoId(long isoId); + + VmIsoMapVO findByVmIdDeviceSeq(long vmId, int deviceSeq); + + VmIsoMapVO findByVmIdIsoId(long vmId, long isoId); + + int removeByVmId(long vmId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java new file mode 100644 index 00000000000..44749eea75f --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmIsoMapVO; + +@Component +public class VmIsoMapDaoImpl extends GenericDaoBase implements VmIsoMapDao { + + private SearchBuilder ListByVmId; + private SearchBuilder ListByIsoId; + private SearchBuilder ByVmIdDeviceSeq; + private SearchBuilder ByVmIdIsoId; + + protected VmIsoMapDaoImpl() { + ListByVmId = createSearchBuilder(); + ListByVmId.and("vmId", ListByVmId.entity().getVmId(), SearchCriteria.Op.EQ); + ListByVmId.done(); + + ListByIsoId = createSearchBuilder(); + ListByIsoId.and("isoId", ListByIsoId.entity().getIsoId(), SearchCriteria.Op.EQ); + ListByIsoId.done(); + + ByVmIdDeviceSeq = createSearchBuilder(); + ByVmIdDeviceSeq.and("vmId", ByVmIdDeviceSeq.entity().getVmId(), SearchCriteria.Op.EQ); + ByVmIdDeviceSeq.and("deviceSeq", ByVmIdDeviceSeq.entity().getDeviceSeq(), SearchCriteria.Op.EQ); + ByVmIdDeviceSeq.done(); + + ByVmIdIsoId = createSearchBuilder(); + ByVmIdIsoId.and("vmId", ByVmIdIsoId.entity().getVmId(), SearchCriteria.Op.EQ); + ByVmIdIsoId.and("isoId", ByVmIdIsoId.entity().getIsoId(), SearchCriteria.Op.EQ); + ByVmIdIsoId.done(); + } + + @Override + public List listByVmId(long vmId) { + SearchCriteria sc = ListByVmId.create(); + sc.setParameters("vmId", vmId); + return listBy(sc); + } + + @Override + public List listByIsoId(long isoId) { + SearchCriteria sc = ListByIsoId.create(); + sc.setParameters("isoId", isoId); + return listBy(sc); + } + + @Override + public VmIsoMapVO findByVmIdDeviceSeq(long vmId, int deviceSeq) { + SearchCriteria sc = ByVmIdDeviceSeq.create(); + sc.setParameters("vmId", vmId); + sc.setParameters("deviceSeq", deviceSeq); + return findOneBy(sc); + } + + @Override + public VmIsoMapVO findByVmIdIsoId(long vmId, long isoId) { + SearchCriteria sc = ByVmIdIsoId.create(); + sc.setParameters("vmId", vmId); + sc.setParameters("isoId", isoId); + return findOneBy(sc); + } + + @Override + public int removeByVmId(long vmId) { + SearchCriteria sc = ListByVmId.create(); + sc.setParameters("vmId", vmId); + return remove(sc); + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index 26181d3fce0..3f72ad9dfc8 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -108,6 +108,7 @@ + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index bd5ecbab21c..31e7e237afb 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -136,6 +136,20 @@ CREATE TABLE IF NOT EXISTS `cloud_usage`.`quota_tariff_usage` ( CONSTRAINT `fk_quota_tariff_usage__tariff_id` FOREIGN KEY (`tariff_id`) REFERENCES `cloud_usage`.`quota_tariff` (`id`), CONSTRAINT `fk_quota_tariff_usage__quota_usage_id` FOREIGN KEY (`quota_usage_id`) REFERENCES `cloud_usage`.`quota_usage` (`id`)); +--- Per-VM ISO attachments. user_vm.iso_id remains as the primary/bootable ISO pointer. +CREATE TABLE IF NOT EXISTS `cloud`.`vm_iso_map` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `vm_id` bigint(20) unsigned NOT NULL COMMENT 'foreign key to user_vm', + `iso_id` bigint(20) unsigned NOT NULL COMMENT 'foreign key to vm_template (ISOs are templates of format ISO)', + `device_seq` int(10) unsigned NOT NULL COMMENT 'cdrom slot index used to derive the libvirt device label (3=hdc, 4=hdd)', + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uc_vm_iso_map__vm_iso` (`vm_id`, `iso_id`), + UNIQUE KEY `uc_vm_iso_map__vm_seq` (`vm_id`, `device_seq`), + CONSTRAINT `fk_vm_iso_map__vm_id` FOREIGN KEY (`vm_id`) REFERENCES `cloud`.`user_vm` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vm_iso_map__iso_id` FOREIGN KEY (`iso_id`) REFERENCES `cloud`.`vm_template` (`id`) +); + -- Add the 'keep_mac_address_on_public_nic' column to the 'cloud.networks' and 'cloud.vpc' tables CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.networks', 'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1'); CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc', 'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1'); diff --git a/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java b/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java new file mode 100644 index 00000000000..d5b1fef7a76 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.junit.Assert; +import org.junit.Test; + +public class VmIsoMapVOTest { + + @Test + public void testFullConstructorPopulatesAllFields() { + VmIsoMapVO row = new VmIsoMapVO(7L, 42L, 4); + Assert.assertEquals(7L, row.getVmId()); + Assert.assertEquals(42L, row.getIsoId()); + Assert.assertEquals(4, row.getDeviceSeq()); + Assert.assertNotNull(row.getCreated()); + } + + @Test + public void testNoArgConstructorLeavesNonIdFieldsAtDefaults() { + VmIsoMapVO row = new VmIsoMapVO(); + Assert.assertEquals(0L, row.getVmId()); + Assert.assertEquals(0L, row.getIsoId()); + Assert.assertEquals(0, row.getDeviceSeq()); + Assert.assertNull(row.getCreated()); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index 4a93b1bce4a..41716881fa4 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -16,6 +16,7 @@ // under the License. package com.cloud.hypervisor.kvm.resource; +import static com.cloud.host.Host.HOST_CDROM_MAX_COUNT; import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION; import static com.cloud.host.Host.HOST_OVFTOOL_VERSION; import static com.cloud.host.Host.HOST_VDDK_LIB_DIR; @@ -226,6 +227,7 @@ import com.cloud.resource.ResourceStatusUpdater; import com.cloud.resource.ServerResource; import com.cloud.resource.ServerResourceBase; import com.cloud.storage.JavaStorageLayer; +import com.cloud.template.TemplateManager; import com.cloud.storage.Storage; import com.cloud.storage.Storage.StoragePoolType; import com.cloud.storage.StorageLayer; @@ -3696,6 +3698,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv if (vmSpec.getOs().toLowerCase().contains("window")) { isWindowsTemplate = true; } + final Set definedCdromSlots = new HashSet<>(); for (final DiskTO volume : disks) { KVMPhysicalDisk physicalDisk = null; KVMStoragePool pool = null; @@ -3774,6 +3777,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv if (volume.getType() == Volume.Type.ISO) { final DiskDef.DiskType diskType = getDiskType(physicalDisk); disk.defISODisk(volPath, devId, isUefiEnabled, diskType); + definedCdromSlots.add(devId); if (guestCpuArch != null && (guestCpuArch.equals("aarch64") || guestCpuArch.equals("s390x"))) { disk.setBusType(DiskDef.DiskBus.SCSI); @@ -3871,6 +3875,17 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv vm.getDevices().addDevice(disk); } + if (vmSpec.getType() == VirtualMachine.Type.User) { + for (int slot = TemplateManager.CDROM_PRIMARY_DEVICE_SEQ; + slot < TemplateManager.CDROM_PRIMARY_DEVICE_SEQ + LibvirtVMDef.MAX_CDROMS_PER_VM; slot++) { + if (!definedCdromSlots.contains(slot)) { + final DiskDef emptyCdrom = new DiskDef(); + emptyCdrom.defISODisk(null, slot, isUefiEnabled, DiskDef.DiskType.FILE); + vm.getDevices().addDevice(emptyCdrom); + } + } + } + if (vmSpec.getType() != VirtualMachine.Type.User) { final DiskDef iso = new DiskDef(); iso.defISODisk(sysvmISOPath, DiskDef.DiskType.FILE); @@ -4381,6 +4396,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv boolean instanceConversionSupported = hostSupportsInstanceConversion(); cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION, String.valueOf(instanceConversionSupported)); cmd.getHostDetails().put(HOST_VDDK_SUPPORT, String.valueOf(hostSupportsVddk())); + cmd.getHostDetails().put(HOST_CDROM_MAX_COUNT, String.valueOf(LibvirtVMDef.MAX_CDROMS_PER_VM)); if (StringUtils.isNotBlank(vddkLibDir)) { cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java index bf8b1af6c18..7f6725b6d15 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java @@ -57,6 +57,10 @@ import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME; public class LibvirtVMDef { protected static Logger LOGGER = LogManager.getLogger(LibvirtVMDef.class); + // CD-ROM slot allocation: getDevLabel() maps deviceSeq=3,4 to hdc and hdd on the IDE bus. + // Bumping this requires extending getDevLabel() (e.g. to spill onto SATA or a second IDE controller). + public static final int MAX_CDROMS_PER_VM = 2; + private String _hvsType; private static long s_libvirtVersion; private static long s_qemuVersion; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java index 4a77f7e9e19..009e1decee2 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java @@ -21,6 +21,8 @@ package com.cloud.hypervisor.kvm.storage; import static com.cloud.utils.NumbersUtil.toHumanReadableSize; import static com.cloud.utils.storage.S3.S3Utils.putFile; +import com.cloud.template.TemplateManager; + import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -1346,10 +1348,11 @@ public class KVMStorageProcessor implements StorageProcessor { } } - protected synchronized void attachOrDetachISO(final Connect conn, final String vmName, String isoPath, final boolean isAttach, Map params, DataStoreTO store) throws + protected synchronized void attachOrDetachISO(final Connect conn, final String vmName, String isoPath, final boolean isAttach, Map params, DataStoreTO store, Integer deviceSeq) throws LibvirtException, InternalErrorException { DiskDef iso = new DiskDef(); boolean isUefiEnabled = MapUtils.isNotEmpty(params) && params.containsKey("UEFI"); + Integer devId = (deviceSeq != null) ? deviceSeq : TemplateManager.CDROM_PRIMARY_DEVICE_SEQ; if (isoPath != null && isAttach) { final int index = isoPath.lastIndexOf("/"); final String path = isoPath.substring(0, index); @@ -1365,9 +1368,9 @@ public class KVMStorageProcessor implements StorageProcessor { final DiskDef.DiskType isoDiskType = LibvirtComputingResource.getDiskType(isoVol); isoPath = isoVol.getPath(); - iso.defISODisk(isoPath, isUefiEnabled, isoDiskType); + iso.defISODisk(isoPath, devId, isUefiEnabled, isoDiskType); } else { - iso.defISODisk(null, isUefiEnabled, DiskDef.DiskType.FILE); + iso.defISODisk(null, devId, isUefiEnabled, DiskDef.DiskType.FILE); } final List disks = resource.getDisks(conn, vmName); @@ -1387,11 +1390,12 @@ public class KVMStorageProcessor implements StorageProcessor { final DiskTO disk = cmd.getDisk(); final TemplateObjectTO isoTO = (TemplateObjectTO)disk.getData(); final DataStoreTO store = isoTO.getDataStore(); + final Integer deviceSeq = (disk.getDiskSeq() != null) ? disk.getDiskSeq().intValue() : null; try { String dataStoreUrl = getDataStoreUrlFromStore(store); final Connect conn = LibvirtConnection.getConnectionByVmName(cmd.getVmName()); - attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), true, cmd.getControllerInfo(), store); + attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), true, cmd.getControllerInfo(), store, deviceSeq); } catch (final LibvirtException e) { return new Answer(cmd, false, e.toString()); } catch (final InternalErrorException e) { @@ -1408,11 +1412,12 @@ public class KVMStorageProcessor implements StorageProcessor { final DiskTO disk = cmd.getDisk(); final TemplateObjectTO isoTO = (TemplateObjectTO)disk.getData(); final DataStoreTO store = isoTO.getDataStore(); + final Integer deviceSeq = (disk.getDiskSeq() != null) ? disk.getDiskSeq().intValue() : null; try { String dataStoreUrl = getDataStoreUrlFromStore(store); final Connect conn = LibvirtConnection.getConnectionByVmName(cmd.getVmName()); - attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), false, cmd.getParams(), store); + attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), false, cmd.getParams(), store, deviceSeq); } catch (final LibvirtException e) { return new Answer(cmd, false, e.toString()); } catch (final InternalErrorException e) { diff --git a/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java index 4877eb844af..aeb54de1290 100644 --- a/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java @@ -39,6 +39,7 @@ import org.apache.cloudstack.annotation.dao.AnnotationDao; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiConstants.VMDetails; import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.response.AttachedIsoResponse; import org.apache.cloudstack.api.response.NicExtraDhcpOptionResponse; import org.apache.cloudstack.api.response.NicResponse; import org.apache.cloudstack.api.response.NicSecondaryIpResponse; @@ -62,6 +63,11 @@ import com.cloud.gpu.GPU; import com.cloud.gpu.dao.VgpuProfileDao; import com.cloud.host.ControlState; import com.cloud.hypervisor.Hypervisor; +import com.cloud.host.DetailVO; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.network.IpAddress; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -72,6 +78,7 @@ import com.cloud.storage.GuestOS; import com.cloud.storage.Storage.TemplateType; import com.cloud.storage.VMTemplateVO; import com.cloud.storage.VnfTemplateDetailVO; +import com.cloud.template.TemplateManager; import com.cloud.storage.VnfTemplateNicVO; import com.cloud.storage.Volume; import com.cloud.storage.dao.VMTemplateDao; @@ -93,10 +100,12 @@ import com.cloud.vm.UserVmManager; import com.cloud.vm.VMInstanceDetailVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.VmIsoMapVO; import com.cloud.vm.VmStats; import com.cloud.vm.dao.NicExtraDhcpOptionDao; import com.cloud.vm.dao.NicSecondaryIpVO; import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.cloud.vm.dao.VmIsoMapDao; @Component public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation implements UserVmJoinDao { @@ -130,6 +139,12 @@ public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation VmDetailSearch; @@ -246,6 +261,23 @@ public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation attachedIsos = new ArrayList<>(); + if (userVm.getIsoUuid() != null) { + VMTemplateVO bootIso = vmTemplateDao.findById(userVm.getIsoId()); + boolean bootIsoBootable = bootIso != null && bootIso.isBootable(); + attachedIsos.add(new AttachedIsoResponse(userVm.getIsoUuid(), userVm.getIsoName(), + userVm.getIsoDisplayText(), TemplateManager.CDROM_PRIMARY_DEVICE_SEQ, bootIsoBootable)); + } + for (VmIsoMapVO row : vmIsoMapDao.listByVmId(userVm.getId())) { + VMTemplateVO tmpl = vmTemplateDao.findById(row.getIsoId()); + if (tmpl != null) { + attachedIsos.add(new AttachedIsoResponse(tmpl.getUuid(), tmpl.getName(), + tmpl.getDisplayText(), row.getDeviceSeq(), false)); + } + } + userVmResponse.setIsos(attachedIsos); + userVmResponse.setIsoMaxCount(effectiveCdromMaxCount(userVm)); } if (details.contains(VMDetails.all) || details.contains(VMDetails.servoff)) { userVmResponse.setServiceOfferingId(userVm.getServiceOfferingUuid()); @@ -540,6 +572,44 @@ public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation 0 + ? userVm.getHostId() : userVm.getLastHostId(); + if (hostId == null && userVm.getHypervisorType() != null) { + List candidates = hostDao.listByDataCenterIdAndHypervisorType(userVm.getDataCenterId(), userVm.getHypervisorType()); + if (!candidates.isEmpty()) { + hostId = candidates.get(0).getId(); + } + } + Long clusterId = userVm.getClusterId(); + if (clusterId == null && hostId != null) { + HostVO host = hostDao.findById(hostId); + if (host != null) { + clusterId = host.getClusterId(); + } + } + int configuredCap = TemplateManager.VmIsoMaxCount.valueIn(clusterId); + int hypervisorCap = advertisedCdromCap(hostId); + // List endpoint clamps for display robustness; the action paths in TemplateManagerImpl + // throw on misconfiguration so operators still see the loud error when they try to attach. + return Math.min(configuredCap, hypervisorCap); + } + + int advertisedCdromCap(Long hostId) { + if (hostId == null) { + return TemplateManager.DEFAULT_CDROM_MAX_PER_VM; + } + DetailVO detail = hostDetailsDao.findDetail(hostId, Host.HOST_CDROM_MAX_COUNT); + if (detail == null || detail.getValue() == null) { + return TemplateManager.DEFAULT_CDROM_MAX_PER_VM; + } + try { + return Integer.parseInt(detail.getValue()); + } catch (NumberFormatException e) { + return TemplateManager.DEFAULT_CDROM_MAX_PER_VM; + } + } + private void addVnfInfoToserVmResponse(UserVmJoinVO userVm, UserVmResponse userVmResponse) { List vnfNics = vnfTemplateNicDao.listByTemplateId(userVm.getTemplateId()); for (VnfTemplateNicVO nic : vnfNics) { diff --git a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java index 3aaebc69130..6cac485c4e1 100755 --- a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java +++ b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java @@ -21,6 +21,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -145,8 +146,11 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.StorageUnavailableException; +import com.cloud.host.DetailVO; +import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.hypervisor.HypervisorGuru; @@ -222,7 +226,9 @@ import com.cloud.vm.VirtualMachine.State; import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.VmIsoMapVO; import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VmIsoMapDao; import com.cloud.vm.dao.VMInstanceDao; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -252,10 +258,14 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Inject private HostDao _hostDao; @Inject + private HostDetailsDao _hostDetailsDao; + @Inject private DataCenterDao _dcDao; @Inject private UserVmDao _userVmDao; @Inject + private VmIsoMapDao _vmIsoMapDao; + @Inject private VolumeDao _volumeDao; @Inject private SnapshotDao _snapshotDao; @@ -679,47 +689,75 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Override public void prepareIsoForVmProfile(VirtualMachineProfile profile, DeployDestination dest) { UserVmVO vm = _userVmDao.findById(profile.getId()); - if (vm.getIsoId() != null) { - Map storageForDisks = dest.getStorageForDisks(); - Long poolId = null; - TemplateInfo template; - if (MapUtils.isNotEmpty(storageForDisks)) { - for (StoragePool storagePool : storageForDisks.values()) { - if (poolId != null && storagePool.getId() != poolId) { - throw new CloudRuntimeException("Cannot determine where to download ISO"); - } - poolId = storagePool.getId(); - } - } - template = prepareIso(vm.getIsoId(), vm.getDataCenterId(), dest.getHost().getId(), poolId); + Map slotToIsoId = loadAttachedIsoSlots(vm); + Long poolId = slotToIsoId.isEmpty() ? null : singleStoragePoolId(dest); - if (template == null){ - logger.error("Failed to prepare ISO on secondary or cache storage"); - throw new CloudRuntimeException("Failed to prepare ISO on secondary or cache storage"); - } - if (template.isBootable()) { - profile.setBootLoaderType(BootloaderType.CD); - } - - GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId()); - String displayName = null; - if (guestOS != null) { - displayName = guestOS.getDisplayName(); - } - - TemplateObjectTO iso = (TemplateObjectTO)template.getTO(); - iso.setDirectDownload(template.isDirectDownload()); - iso.setGuestOsType(displayName); - DiskTO disk = new DiskTO(iso, 3L, null, Volume.Type.ISO); - profile.addDisk(disk); - } else { - TemplateObjectTO iso = new TemplateObjectTO(); - iso.setFormat(ImageFormat.ISO); - DiskTO disk = new DiskTO(iso, 3L, null, Volume.Type.ISO); - profile.addDisk(disk); + // Pre-allocate every cdrom slot at boot. QEMU/IDE refuses to hot-add new cdrom drives, so + // runtime attachIso can only media-swap into a slot the domain already owns. + int totalSlots = Math.max(effectiveMaxCdroms(vm, dest.getHost().getId()), slotsNeededFor(slotToIsoId)); + for (int i = 0; i < totalSlots; i++) { + int diskSeq = CDROM_PRIMARY_DEVICE_SEQ + i; + Long isoId = slotToIsoId.get(diskSeq); + profile.addDisk(isoId != null + ? buildIsoDisk(profile, vm, dest, poolId, diskSeq, isoId) + : buildEmptyCdromDisk(diskSeq)); } } + private Long singleStoragePoolId(DeployDestination dest) { + Long poolId = null; + Map storageForDisks = dest.getStorageForDisks(); + if (MapUtils.isNotEmpty(storageForDisks)) { + for (StoragePool pool : storageForDisks.values()) { + if (poolId != null && pool.getId() != poolId) { + throw new CloudRuntimeException("Cannot determine where to download ISO"); + } + poolId = pool.getId(); + } + } + return poolId; + } + + private Map loadAttachedIsoSlots(UserVmVO vm) { + Map slots = new HashMap<>(); + if (vm.getIsoId() != null) { + slots.put(CDROM_PRIMARY_DEVICE_SEQ, vm.getIsoId()); + } + for (VmIsoMapVO row : _vmIsoMapDao.listByVmId(vm.getId())) { + slots.put(row.getDeviceSeq(), row.getIsoId()); + } + return slots; + } + + private int slotsNeededFor(Map slotToIsoId) { + if (slotToIsoId.isEmpty()) { + return 0; + } + return Collections.max(slotToIsoId.keySet()) - CDROM_PRIMARY_DEVICE_SEQ + 1; + } + + private DiskTO buildIsoDisk(VirtualMachineProfile profile, UserVmVO vm, DeployDestination dest, Long poolId, int diskSeq, long isoId) { + TemplateInfo template = prepareIso(isoId, vm.getDataCenterId(), dest.getHost().getId(), poolId); + if (template == null) { + logger.error("Failed to prepare ISO on secondary or cache storage"); + throw new CloudRuntimeException("Failed to prepare ISO on secondary or cache storage"); + } + if (diskSeq == CDROM_PRIMARY_DEVICE_SEQ && template.isBootable()) { + profile.setBootLoaderType(BootloaderType.CD); + } + GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId()); + TemplateObjectTO iso = (TemplateObjectTO) template.getTO(); + iso.setDirectDownload(template.isDirectDownload()); + iso.setGuestOsType(guestOS != null ? guestOS.getDisplayName() : null); + return new DiskTO(iso, (long) diskSeq, null, Volume.Type.ISO); + } + + private DiskTO buildEmptyCdromDisk(int diskSeq) { + TemplateObjectTO empty = new TemplateObjectTO(); + empty.setFormat(ImageFormat.ISO); + return new DiskTO(empty, (long) diskSeq, null, Volume.Type.ISO); + } + private void prepareTemplateInOneStoragePool(final VMTemplateVO template, final StoragePoolVO pool) { logger.info("Schedule to preload Template {} into primary storage {}", template, pool); if (pool.getPoolType() == Storage.StoragePoolType.DatastoreCluster) { @@ -1206,17 +1244,20 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Override public boolean templateIsDeleteable(long templateId) { + // ISO can only be referenced by user_vm.iso_id (primary cdrom slot) or vm_iso_map (extra slots). + // Templates always live on primary storage and aren't tracked here. List userVmUsingIso = _userVmJoinDao.listActiveByIsoId(templateId); - // check if there is any Vm using this ISO. We only need to check the - // case where templateId is an ISO since - // VM can be launched from ISO in secondary storage, while template will - // always be copied to - // primary storage before deploying VM. if (!userVmUsingIso.isEmpty()) { - logger.debug("ISO " + templateId + " is not deleteable because it is attached to " + userVmUsingIso.size() + " Instances"); + logger.debug("Unable to delete ISO {} because it is attached to {} Instances", templateId, userVmUsingIso.size()); return false; } - + for (VmIsoMapVO row : _vmIsoMapDao.listByIsoId(templateId)) { + UserVmVO vm = _userVmDao.findById(row.getVmId()); + if (vm != null && vm.getState() != State.Error && vm.getState() != State.Expunging) { + logger.debug("Unable to delete ISO {} because it is attached to Instance {} at slot {}", templateId, vm.getUuid(), row.getDeviceSeq()); + return false; + } + } return true; } @@ -1237,7 +1278,14 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, _accountMgr.checkAccess(caller, null, true, virtualMachine); - Long isoId = !isVirtualRouter ? ((UserVm) virtualMachine).getIsoId() : isoParamId; + Long isoId; + if (isVirtualRouter) { + isoId = isoParamId; + } else { + Long primaryIsoId = ((UserVm) virtualMachine).getIsoId(); + List extras = _vmIsoMapDao.listByVmId(vmId); + isoId = resolveIsoIdForDetach(primaryIsoId, extras, isoParamId); + } if (isoId == null) { throw new InvalidParameterValueException("The specified instance has no ISO attached to it."); } @@ -1321,6 +1369,9 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, if (VMWARE_TOOLS_ISO.equals(iso.getUniqueName()) && vm.getHypervisorType() != Hypervisor.HypervisorType.VMware) { throw new InvalidParameterValueException("Cannot attach VMware tools drivers to incompatible hypervisor " + vm.getHypervisorType()); } + if (!isVirtualRouter) { + enforceCdromAttachLimits(vmId, (UserVm) vm, isoId); + } boolean result = attachISOToVM(vmId, userId, isoId, true, forced, isVirtualRouter); if (result) { return result; @@ -1360,7 +1411,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, } } - private boolean attachISOToVM(long vmId, long isoId, boolean attach, boolean forced, boolean isVirtualRouter) { + private boolean attachISOToVM(long vmId, long isoId, int deviceSeq, boolean attach, boolean forced, boolean isVirtualRouter) { VirtualMachine vm = !isVirtualRouter ? _userVmDao.findById(vmId) : _vmInstanceDao.findById(vmId); if (vm == null || (isVirtualRouter && vm.getType() != VirtualMachine.Type.DomainRouter)) { @@ -1384,7 +1435,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, } DataTO isoTO = tmplt.getTO(); - DiskTO disk = new DiskTO(isoTO, null, null, Volume.Type.ISO); + DiskTO disk = new DiskTO(isoTO, (long) deviceSeq, null, Volume.Type.ISO); HypervisorGuru hvGuru = _hvGuruMgr.getGuru(vm.getHypervisorType()); VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm); @@ -1402,22 +1453,150 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, return (a != null && a.getResult()); } - private boolean attachISOToVM(long vmId, long userId, long isoId, boolean attach, boolean forced, boolean isVirtualRouter) { + boolean attachISOToVM(long vmId, long userId, long isoId, boolean attach, boolean forced, boolean isVirtualRouter) { UserVmVO vm = _userVmDao.findById(vmId); VMTemplateVO iso = _tmpltDao.findById(isoId); - boolean success = attachISOToVM(vmId, isoId, attach, forced, isVirtualRouter); - if (success && attach && !isVirtualRouter) { - vm.setIsoId(iso.getId()); - _userVmDao.update(vmId, vm); + int targetSlot = attach ? chooseAttachSlot(vmId, vm) : findAttachedSlot(vmId, vm, isoId); + boolean success = attachISOToVM(vmId, isoId, targetSlot, attach, forced, isVirtualRouter); + if (!success || isVirtualRouter) { + return success; } - if (success && !attach && !isVirtualRouter) { - vm.setIsoId(null); - _userVmDao.update(vmId, vm); + if (attach) { + persistIsoAttachment(vmId, vm, iso, targetSlot); + } else { + persistIsoDetachment(vmId, vm, isoId, targetSlot); } return success; } + private int chooseAttachSlot(long vmId, UserVmVO vm) { + if (vm.getIsoId() == null) { + return CDROM_PRIMARY_DEVICE_SEQ; + } + VmIsoMapVO highest = highestCdromMapEntry(vmId); + return highest == null ? CDROM_PRIMARY_DEVICE_SEQ + 1 : highest.getDeviceSeq() + 1; + } + + private int findAttachedSlot(long vmId, UserVmVO vm, long isoId) { + if (vm.getIsoId() != null && vm.getIsoId() == isoId) { + return CDROM_PRIMARY_DEVICE_SEQ; + } + VmIsoMapVO entry = _vmIsoMapDao.findByVmIdIsoId(vmId, isoId); + return entry != null ? entry.getDeviceSeq() : CDROM_PRIMARY_DEVICE_SEQ; + } + + private void persistIsoAttachment(long vmId, UserVmVO vm, VMTemplateVO iso, int slot) { + if (slot == CDROM_PRIMARY_DEVICE_SEQ) { + vm.setIsoId(iso.getId()); + _userVmDao.update(vmId, vm); + } else { + _vmIsoMapDao.persist(new VmIsoMapVO(vmId, iso.getId(), slot)); + } + } + + private void persistIsoDetachment(long vmId, UserVmVO vm, long isoId, int slot) { + if (slot == CDROM_PRIMARY_DEVICE_SEQ) { + vm.setIsoId(null); + _userVmDao.update(vmId, vm); + return; + } + VmIsoMapVO entry = _vmIsoMapDao.findByVmIdIsoId(vmId, isoId); + if (entry != null) { + _vmIsoMapDao.remove(entry.getId()); + } + } + + VmIsoMapVO highestCdromMapEntry(long vmId) { + VmIsoMapVO highest = null; + for (VmIsoMapVO row : _vmIsoMapDao.listByVmId(vmId)) { + if (highest == null || row.getDeviceSeq() > highest.getDeviceSeq()) { + highest = row; + } + } + return highest; + } + + Long resolveIsoIdForDetach(Long primaryIsoId, List extras, Long isoParamId) { + if (isoParamId != null) { + boolean attached = (primaryIsoId != null && primaryIsoId.equals(isoParamId)) + || extras.stream().anyMatch(r -> r.getIsoId() == isoParamId); + if (!attached) { + throw new InvalidParameterValueException("The specified ISO is not attached to this Instance."); + } + return isoParamId; + } + int totalAttached = (primaryIsoId != null ? 1 : 0) + extras.size(); + if (totalAttached == 0) { + throw new InvalidParameterValueException("The specified instance has no ISO attached to it."); + } + if (totalAttached > 1) { + throw new InvalidParameterValueException("Instance has more than one ISO attached; specify the 'id' parameter to choose which to detach."); + } + return primaryIsoId != null ? primaryIsoId : extras.get(0).getIsoId(); + } + + boolean isIsoAlreadyAttached(long vmId, Long primaryIsoId, long isoId) { + if (primaryIsoId != null && primaryIsoId.equals(isoId)) { + return true; + } + return _vmIsoMapDao.findByVmIdIsoId(vmId, isoId) != null; + } + + void enforceCdromAttachLimits(long vmId, UserVm vm, long isoId) { + Long primaryIsoId = vm.getIsoId(); + if (isIsoAlreadyAttached(vmId, primaryIsoId, isoId)) { + throw new InvalidParameterValueException("The specified ISO is already attached to this Instance."); + } + int effectiveMax = effectiveMaxCdroms(vm, hostIdForVm(vm)); + int attached = (primaryIsoId != null ? 1 : 0) + _vmIsoMapDao.listByVmId(vmId).size(); + if (attached >= effectiveMax) { + throw new InvalidParameterValueException(String.format( + "Instance has reached the maximum of %d attached CD-ROM(s); detach one before attaching another.", effectiveMax)); + } + } + + int effectiveMaxCdroms(VirtualMachine vm, Long hostId) { + HostVO host = hostId != null ? _hostDao.findById(hostId) : null; + Long clusterId = host != null ? host.getClusterId() : null; + int configuredCap = VmIsoMaxCount.valueIn(clusterId); + int hypervisorCap = advertisedCdromCap(hostId); + if (configuredCap > hypervisorCap) { + logger.warn("{} is set to {} but the placement host supports a maximum of {} CD-ROM(s) per Instance. Clamping to {}.", + VmIsoMaxCount.key(), configuredCap, hypervisorCap, hypervisorCap); + return hypervisorCap; + } + return configuredCap; + } + + int advertisedCdromCap(Long hostId) { + if (hostId == null) { + return DEFAULT_CDROM_MAX_PER_VM; + } + DetailVO detail = _hostDetailsDao.findDetail(hostId, Host.HOST_CDROM_MAX_COUNT); + if (detail == null || detail.getValue() == null) { + return DEFAULT_CDROM_MAX_PER_VM; + } + try { + return Integer.parseInt(detail.getValue()); + } catch (NumberFormatException e) { + logger.warn("Invalid {} value '{}' for host {}; using default {}.", + Host.HOST_CDROM_MAX_COUNT, detail.getValue(), hostId, DEFAULT_CDROM_MAX_PER_VM); + return DEFAULT_CDROM_MAX_PER_VM; + } + } + + Long hostIdForVm(VirtualMachine vm) { + Long hostId = vm.getHostId() != null ? vm.getHostId() : vm.getLastHostId(); + if (hostId == null && vm.getHypervisorType() != null) { + List candidates = _hostDao.listByDataCenterIdAndHypervisorType(vm.getDataCenterId(), vm.getHypervisorType()); + if (!candidates.isEmpty()) { + hostId = candidates.get(0).getId(); + } + } + return hostId; + } + @Override @ActionEvent(eventType = EventTypes.EVENT_TEMPLATE_DELETE, eventDescription = "Deleting Template", async = true) public boolean deleteTemplate(DeleteTemplateCmd cmd) { @@ -2538,7 +2717,8 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, return new ConfigKey[] {AllowPublicUserTemplates, TemplatePreloaderPoolSize, ValidateUrlIsResolvableBeforeRegisteringTemplate, - TemplateDeleteFromPrimaryStorage}; + TemplateDeleteFromPrimaryStorage, + VmIsoMaxCount}; } public List getTemplateAdapters() { diff --git a/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java b/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java index e4146fd2265..f657a8bbf04 100755 --- a/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java +++ b/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java @@ -16,10 +16,12 @@ // under the License. package com.cloud.api.query.dao; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.MockitoAnnotations.openMocks; import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; import com.cloud.storage.dao.VMTemplateDao; @@ -49,9 +51,11 @@ import com.cloud.user.AccountManager; import com.cloud.user.UserStatisticsVO; import com.cloud.user.dao.UserDao; import com.cloud.user.dao.UserStatisticsDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.cloud.vm.dao.VmIsoMapDao; @RunWith(MockitoJUnitRunner.class) public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseTest { @@ -83,6 +87,12 @@ public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseT @Mock private VMTemplateDao vmTemplateDao; + @Mock + private VmIsoMapDao vmIsoMapDao; + + @Mock + private HostDetailsDao hostDetailsDao; + @Mock ExtensionHelper extensionHelper; @@ -103,6 +113,7 @@ public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseT @Before public void setup() { closeable = openMocks(this); + Mockito.lenient().when(vmIsoMapDao.listByVmId(anyLong())).thenReturn(Collections.emptyList()); prepareSetup(); } @@ -166,4 +177,39 @@ public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseT Assert.assertEquals(2, response.getVnfNics().size()); Assert.assertEquals(3, response.getVnfDetails().size()); } + + @Test + public void advertisedCdromCapReturnsDefaultWhenHostIdNull() { + Assert.assertEquals(com.cloud.template.TemplateManager.DEFAULT_CDROM_MAX_PER_VM, + _userVmJoinDaoImpl.advertisedCdromCap(null)); + } + + @Test + public void advertisedCdromCapReturnsParsedValue() { + com.cloud.host.DetailVO detail = Mockito.mock(com.cloud.host.DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("2"); + Mockito.when(hostDetailsDao.findDetail(7L, com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(2, _userVmJoinDaoImpl.advertisedCdromCap(7L)); + } + + @Test + public void advertisedCdromCapFallsBackOnInvalidValue() { + com.cloud.host.DetailVO detail = Mockito.mock(com.cloud.host.DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("xyz"); + Mockito.when(hostDetailsDao.findDetail(7L, com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(com.cloud.template.TemplateManager.DEFAULT_CDROM_MAX_PER_VM, + _userVmJoinDaoImpl.advertisedCdromCap(7L)); + } + + @Test + public void effectiveCdromMaxCountClampsToHypervisorCap() { + UserVmJoinVO userVm = Mockito.mock(UserVmJoinVO.class); + Mockito.when(userVm.getHostId()).thenReturn(7L); + Mockito.when(userVm.getClusterId()).thenReturn(5L); + com.cloud.host.DetailVO detail = Mockito.mock(com.cloud.host.DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("2"); + Mockito.when(hostDetailsDao.findDetail(7L, com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + // Configured cap defaults to 1 (no cluster override mocked); host advertises 2; clamps to 1. + Assert.assertEquals(1, _userVmJoinDaoImpl.effectiveCdromMaxCount(userVm)); + } } diff --git a/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java b/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java index 6288180a9f4..47099c371dc 100755 --- a/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java +++ b/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java @@ -22,6 +22,7 @@ package com.cloud.template; import com.cloud.agent.AgentManager; import com.cloud.api.query.dao.SnapshotJoinDao; import com.cloud.api.query.dao.UserVmJoinDao; +import com.cloud.api.query.vo.UserVmJoinVO; import com.cloud.dc.dao.DataCenterDao; import com.cloud.deployasis.dao.TemplateDeployAsIsDetailsDao; import com.cloud.domain.dao.DomainDao; @@ -29,7 +30,11 @@ import com.cloud.event.dao.UsageEventDao; import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceAllocationException; import com.cloud.host.Status; +import com.cloud.host.DetailVO; +import com.cloud.host.Host; +import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.HypervisorGuruManager; import com.cloud.projects.ProjectManager; @@ -66,9 +71,15 @@ import com.cloud.user.UserVO; import com.cloud.user.dao.AccountDao; import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.uservm.UserVm; +import com.cloud.vm.UserVmVO; import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.VmIsoMapVO; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VmIsoMapDao; import junit.framework.TestCase; @@ -133,6 +144,7 @@ import org.springframework.core.type.filter.TypeFilter; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.UUID; import java.util.concurrent.BlockingQueue; @@ -220,6 +232,21 @@ public class TemplateManagerImplTest extends TestCase { @Mock HeuristicRuleHelper heuristicRuleHelperMock; + @Mock + UserVmDao _userVmDao; + + @Mock + VmIsoMapDao _vmIsoMapDao; + + @Mock + HostDao _hostDao; + + @Mock + HostDetailsDao _hostDetailsDao; + + @Mock + UserVmJoinDao _userVmJoinDao; + public class CustomThreadPoolExecutor extends ThreadPoolExecutor { AtomicInteger ai = new AtomicInteger(0); public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, @@ -750,6 +777,222 @@ public class TemplateManagerImplTest extends TestCase { Mockito.verify(heuristicRuleHelperMock, Mockito.times(1)).getImageStoreIfThereIsHeuristicRule(1L, HeuristicType.TEMPLATE, vmTemplateVOMock); } + @Test + public void highestCdromMapEntryReturnsNullWhenMapIsEmpty() { + Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(new ArrayList<>()); + Assert.assertNull(templateManager.highestCdromMapEntry(1L)); + } + + @Test + public void highestCdromMapEntryReturnsEntryWithMaxDeviceSeq() { + VmIsoMapVO low = new VmIsoMapVO(1L, 100L, 4); + VmIsoMapVO high = new VmIsoMapVO(1L, 200L, 5); + Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(Arrays.asList(low, high)); + VmIsoMapVO result = templateManager.highestCdromMapEntry(1L); + Assert.assertNotNull(result); + Assert.assertEquals(5, result.getDeviceSeq()); + } + + @Test + public void attachISOToVMAttachWritesToIsoIdWhenPrimarySlotEmpty() { + UserVmVO vm = Mockito.mock(UserVmVO.class); + VMTemplateVO iso = Mockito.mock(VMTemplateVO.class); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Mockito.when(vmTemplateDao.findById(42L)).thenReturn(iso); + Mockito.when(iso.getId()).thenReturn(42L); + Mockito.when(vm.getIsoId()).thenReturn(null); + + boolean result = templateManager.attachISOToVM(1L, 1L, 42L, true, false, false); + + Assert.assertTrue(result); + Mockito.verify(vm).setIsoId(42L); + Mockito.verify(_userVmDao).update(eq(1L), eq(vm)); + Mockito.verify(_vmIsoMapDao, Mockito.never()).persist(any(VmIsoMapVO.class)); + } + + @Test + public void resolveIsoIdForDetachReturnsPrimaryWhenOnlyPrimaryIsAttached() { + Long resolved = templateManager.resolveIsoIdForDetach(99L, new ArrayList<>(), null); + Assert.assertEquals(Long.valueOf(99L), resolved); + } + + @Test + public void resolveIsoIdForDetachReturnsMapEntryWhenOnlyMapHasOne() { + VmIsoMapVO row = new VmIsoMapVO(1L, 100L, 4); + Long resolved = templateManager.resolveIsoIdForDetach(null, Arrays.asList(row), null); + Assert.assertEquals(Long.valueOf(100L), resolved); + } + + @Test(expected = InvalidParameterValueException.class) + public void resolveIsoIdForDetachThrowsWhenMultipleAttachedAndNoIdGiven() { + VmIsoMapVO row = new VmIsoMapVO(1L, 100L, 4); + templateManager.resolveIsoIdForDetach(99L, Arrays.asList(row), null); + } + + @Test(expected = InvalidParameterValueException.class) + public void resolveIsoIdForDetachThrowsWhenNothingAttached() { + templateManager.resolveIsoIdForDetach(null, new ArrayList<>(), null); + } + + @Test(expected = InvalidParameterValueException.class) + public void resolveIsoIdForDetachThrowsWhenIdNotAttached() { + templateManager.resolveIsoIdForDetach(99L, new ArrayList<>(), 42L); + } + + @Test + public void isIsoAlreadyAttachedReturnsTrueWhenPrimaryMatches() { + Assert.assertTrue(templateManager.isIsoAlreadyAttached(1L, 42L, 42L)); + } + + @Test + public void isIsoAlreadyAttachedReturnsTrueWhenInMap() { + Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(new VmIsoMapVO(1L, 42L, 4)); + Assert.assertTrue(templateManager.isIsoAlreadyAttached(1L, 99L, 42L)); + } + + @Test + public void isIsoAlreadyAttachedReturnsFalseWhenNotAttached() { + Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(null); + Assert.assertFalse(templateManager.isIsoAlreadyAttached(1L, null, 42L)); + } + + @Test + public void attachISOToVMAttachWritesToVmIsoMapWhenPrimarySlotOccupied() { + UserVmVO vm = Mockito.mock(UserVmVO.class); + VMTemplateVO iso = Mockito.mock(VMTemplateVO.class); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Mockito.when(vmTemplateDao.findById(42L)).thenReturn(iso); + Mockito.when(iso.getId()).thenReturn(42L); + Mockito.when(vm.getIsoId()).thenReturn(99L); + Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(new ArrayList<>()); + + boolean result = templateManager.attachISOToVM(1L, 1L, 42L, true, false, false); + + Assert.assertTrue(result); + Mockito.verify(_vmIsoMapDao).persist(Mockito.argThat(row -> + row.getVmId() == 1L && row.getIsoId() == 42L + && row.getDeviceSeq() == TemplateManager.CDROM_PRIMARY_DEVICE_SEQ + 1)); + Mockito.verify(vm, Mockito.never()).setIsoId(anyLong()); + } + + @Test(expected = InvalidParameterValueException.class) + public void enforceCdromAttachLimitsThrowsWhenIsoAlreadyAttachedAtPrimary() { + UserVm vm = Mockito.mock(UserVm.class); + Mockito.when(vm.getIsoId()).thenReturn(42L); + templateManager.enforceCdromAttachLimits(1L, vm, 42L); + } + + @Test(expected = InvalidParameterValueException.class) + public void enforceCdromAttachLimitsThrowsWhenIsoAlreadyAttachedInMap() { + UserVm vm = Mockito.mock(UserVm.class); + Mockito.when(vm.getIsoId()).thenReturn(99L); + Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(new VmIsoMapVO(1L, 42L, 4)); + templateManager.enforceCdromAttachLimits(1L, vm, 42L); + } + + @Test + public void advertisedCdromCapReturnsDefaultWhenHostIdNull() { + Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM, templateManager.advertisedCdromCap(null)); + } + + @Test + public void advertisedCdromCapReturnsDefaultWhenDetailMissing() { + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(null); + Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM, templateManager.advertisedCdromCap(7L)); + } + + @Test + public void advertisedCdromCapReturnsParsedValue() { + DetailVO detail = Mockito.mock(DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("3"); + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(3, templateManager.advertisedCdromCap(7L)); + } + + @Test + public void advertisedCdromCapFallsBackOnInvalidValue() { + DetailVO detail = Mockito.mock(DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("not-a-number"); + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM, templateManager.advertisedCdromCap(7L)); + } + + @Test + public void hostIdForVmReturnsCurrentHost() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getHostId()).thenReturn(42L); + Assert.assertEquals(Long.valueOf(42L), templateManager.hostIdForVm(vm)); + } + + @Test + public void hostIdForVmFallsBackToLastHost() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getHostId()).thenReturn(null); + Mockito.when(vm.getLastHostId()).thenReturn(99L); + Assert.assertEquals(Long.valueOf(99L), templateManager.hostIdForVm(vm)); + } + + @Test + public void hostIdForVmReturnsNullWhenNoHost() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getHostId()).thenReturn(null); + Mockito.when(vm.getLastHostId()).thenReturn(null); + Assert.assertNull(templateManager.hostIdForVm(vm)); + } + + @Test + public void effectiveMaxCdromsReturnsConfiguredCapWhenWithinHypervisorCap() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + DetailVO detail = Mockito.mock(DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("2"); + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getClusterId()).thenReturn(5L); + Mockito.when(_hostDao.findById(7L)).thenReturn(host); + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + // Configured cap defaults to 1 (no cluster override mocked); hypervisor cap is 2; 1 <= 2 → no throw, returns 1. + Assert.assertEquals(1, templateManager.effectiveMaxCdroms(vm, 7L)); + } + + @Test + public void templateIsDeleteableReturnsTrueWhenNoVmsUseIso() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new ArrayList<>()); + Mockito.when(_vmIsoMapDao.listByIsoId(42L)).thenReturn(new ArrayList<>()); + Assert.assertTrue(templateManager.templateIsDeleteable(42L)); + } + + @Test + public void templateIsDeleteableReturnsFalseWhenPrimarySlotInUse() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)) + .thenReturn(java.util.Collections.singletonList(Mockito.mock(UserVmJoinVO.class))); + Assert.assertFalse(templateManager.templateIsDeleteable(42L)); + // Should not even need to consult vm_iso_map once primary slot in use. + Mockito.verify(_vmIsoMapDao, Mockito.never()).listByIsoId(anyLong()); + } + + @Test + public void templateIsDeleteableReturnsFalseWhenAttachedViaVmIsoMapToActiveVm() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new ArrayList<>()); + Mockito.when(_vmIsoMapDao.listByIsoId(42L)) + .thenReturn(java.util.Collections.singletonList(new VmIsoMapVO(1L, 42L, 4))); + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getState()).thenReturn(State.Running); + Mockito.when(vm.getUuid()).thenReturn("uuid-1"); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Assert.assertFalse(templateManager.templateIsDeleteable(42L)); + } + + @Test + public void templateIsDeleteableIgnoresVmIsoMapForDestroyedVm() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new ArrayList<>()); + Mockito.when(_vmIsoMapDao.listByIsoId(42L)) + .thenReturn(java.util.Collections.singletonList(new VmIsoMapVO(1L, 42L, 4))); + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getState()).thenReturn(State.Expunging); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Assert.assertTrue(templateManager.templateIsDeleteable(42L)); + } + + @Configuration @ComponentScan(basePackageClasses = {TemplateManagerImpl.class}, includeFilters = {@ComponentScan.Filter(value = TestConfiguration.Library.class, type = FilterType.CUSTOM)}, diff --git a/ui/src/config/section/compute.js b/ui/src/config/section/compute.js index 6b7a5428b1f..d054d2d3db4 100644 --- a/ui/src/config/section/compute.js +++ b/ui/src/config/section/compute.js @@ -22,6 +22,15 @@ import { getAPI, postAPI, getBaseUrl } from '@/api' import { getLatestKubernetesIsoParams } from '@/utils/acsrepo' import kubernetesIcon from '@/assets/icons/kubernetes.svg?inline' +const attachedIsoCount = (record) => (record.isos && record.isos.length) || (record.isoid ? 1 : 0) +// Server pre-computes the effective cap (cluster-scoped vm.iso.max.count clamped to the +// hypervisor's own limit). Fall back to the hypervisor floor for older servers. +const isoMaxCount = (record) => record.isomaxcount != null + ? record.isomaxcount + : (record.hypervisor === 'KVM' ? 2 : 1) +const isoActionAvailable = (record) => + record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' + export default { name: 'compute', title: 'label.compute', @@ -299,7 +308,7 @@ export default { docHelp: 'adminguide/templates.html#attaching-an-iso-to-a-vm', dataView: true, popup: true, - show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && !record.isoid && record.vmtype !== 'sharedfsvm' }, + show: (record) => isoActionAvailable(record) && attachedIsoCount(record) < isoMaxCount(record), disabled: (record) => { return record.hostcontrolstate === 'Offline' || record.hostcontrolstate === 'Maintenance' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/AttachIso.vue'))) }, @@ -307,22 +316,11 @@ export default { api: 'detachIso', icon: 'link-outlined', label: 'label.action.detach.iso', - message: 'message.detach.iso.confirm', dataView: true, - args: (record, store) => { - var args = ['virtualmachineid'] - if (record && record.hypervisor && record.hypervisor === 'VMware') { - args.push('forced') - } - return args - }, - show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && 'isoid' in record && record.isoid && record.vmtype !== 'sharedfsvm' }, + popup: true, + show: (record) => isoActionAvailable(record) && attachedIsoCount(record) > 0, disabled: (record) => { return record.hostcontrolstate === 'Offline' || record.hostcontrolstate === 'Maintenance' }, - mapping: { - virtualmachineid: { - value: (record, params) => { return record.id } - } - } + component: shallowRef(defineAsyncComponent(() => import('@/views/compute/DetachIso.vue'))) }, { api: 'updateVMAffinityGroup', diff --git a/ui/src/views/compute/AttachIso.vue b/ui/src/views/compute/AttachIso.vue index 60694cb8f57..daa555c4538 100644 --- a/ui/src/views/compute/AttachIso.vue +++ b/ui/src/views/compute/AttachIso.vue @@ -17,23 +17,38 @@ @@ -1037,21 +1037,11 @@ /> -