andrijapanicsb commented on code in PR #14256: URL: https://github.com/apache/cloudstack/pull/14256#discussion_r4132455133
########## server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java: ########## @@ -0,0 +1,2978 @@ +// 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.vm; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.admin.vm.CancelVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.CheckVmwareCbtMigrationPrerequisitesCmd; +import org.apache.cloudstack.api.command.admin.vm.CutoverVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.DeleteVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.ListVmwareCbtMigrationsCmd; +import org.apache.cloudstack.api.command.admin.vm.StartVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.SyncVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightDiskResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightFindingResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationCycleResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationDiskResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CheckConvertInstanceAnswer; +import com.cloud.agent.api.CheckConvertInstanceCommand; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.VmwareCbtCleanupCommand; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtPrepareCommand; +import com.cloud.agent.api.VmwareCbtRbdProbeCommand; +import com.cloud.agent.api.VmwareCbtSyncCommand; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.VmwareDatacenterVO; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.VmwareDatacenterDao; +import com.cloud.storage.DiskOfferingVO; +import com.cloud.storage.dao.DiskOfferingDao; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.ResourceState; +import com.cloud.network.Network; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.Storage; +import com.cloud.storage.dao.StoragePoolHostDao; +import com.cloud.user.Account; +import com.cloud.user.AccountService; +import com.cloud.user.UserVO; +import com.cloud.utils.Pair; +import com.cloud.vm.VmDetailConstants; +import com.cloud.user.dao.UserDao; +import com.cloud.uservm.UserVm; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VmwareCbtMigrationCycleVO; +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import com.cloud.vm.VmwareCbtMigrationVO; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VmwareCbtMigrationCycleDao; +import com.cloud.vm.dao.VmwareCbtMigrationDao; +import com.cloud.vm.dao.VmwareCbtMigrationDiskDao; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +public class VmwareCbtMigrationManagerImpl implements VmwareCbtMigrationManager, Configurable { + + /** vCenter refuses snapshot removal while the source is still busy right after a cancel; + * retry rather than orphaning the snapshot on the customer's VM. */ + private static final int SNAPSHOT_REMOVAL_ATTEMPTS = 4; + private static final long SNAPSHOT_REMOVAL_RETRY_INTERVAL_MS = 15000L; + + private static final String OBJECT_NAME = "vmwarecbtmigration"; + private static final String DETAIL_VDDK_TRANSPORTS = "vddk.transports"; + private static final String DETAIL_VDDK_THUMBPRINT = "vddk.thumbprint"; + private static final String REDACTED_SECRET = "******"; + private static final String DEFAULT_CBT_DISK_BASE_PATH = "/var/lib/libvirt/images/cloudstack-cbt"; + private static final String KVM_STORAGE_POOL_MOUNT_BASE_PATH = "/mnt"; + static final String CBT_FINALIZED_DISK_CONTROLLER = "virtio"; + private static final String RBD_PROBE_IMAGE_PREFIX = "cloudstack-cbt-probe-"; + private static final List<Storage.StoragePoolType> CBT_COMPATIBLE_STORAGE_POOL_TYPES = Arrays.asList( + Storage.StoragePoolType.NetworkFilesystem, + Storage.StoragePoolType.Filesystem, + Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.RBD, + Storage.StoragePoolType.Linstor); + private static final int CLEANUP_WAIT_SECONDS = 300; + private static final int DELETE_CLEANUP_WAIT_SECONDS = 30; + private static final Logger LOGGER = LogManager.getLogger(VmwareCbtMigrationManagerImpl.class); + private static final Gson GSON = new Gson(); + + static final ConfigKey<Integer> VmwareCbtMigrationMinCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.min.cycles", + "Advanced", + "1", + "Minimum number of CBT delta synchronization cycles to run before CloudStack can recommend final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey<Integer> VmwareCbtMigrationMaxCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.max.cycles", + "Advanced", + "5", + "Maximum number of CBT delta synchronization cycles to run before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey<Integer> VmwareCbtMigrationQuietCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.quiet.cycles", + "Advanced", + "2", + "Number of consecutive quiet CBT delta synchronization cycles required before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey<Long> VmwareCbtMigrationQuietBytes = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.bytes", + "Advanced", + "1073741824", + "Maximum changed bytes in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey<Long> VmwareCbtMigrationQuietDirtyRate = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.dirty.rate", + "Advanced", + "16777216", + "Maximum changed bytes per second in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey<Boolean> VmwareCbtAllowNonInPlaceFinalization = new ConfigKey<>(Boolean.class, + "vmware.cbt.allow.non.inplace.finalization", + "Advanced", + "false", + "If true, VMware CBT cutover may fall back to regular virt-v2v finalization for qcow2 file targets when true in-place finalization is unavailable. The fallback stages temporary data on the selected primary storage and requires additional free space.", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey<Integer> VmwareCbtMigrationAgentCommandTimeout = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.agent.command.timeout", + "Advanced", + "86400", + "Timeout in seconds for long-running VMware CBT data-plane commands dispatched to the KVM agent, including initial full sync, delta sync, final delta sync, and cutover finalization.", + true, + ConfigKey.Scope.Global, + null); + + @Inject + private VmwareCbtMigrationDao vmwareCbtMigrationDao; + @Inject + private DataCenterDao dataCenterDao; + @Inject + private ClusterDao clusterDao; + @Inject + private HostDao hostDao; + @Inject + private PrimaryDataStoreDao primaryDataStoreDao; + @Inject + private VmwareDatacenterDao vmwareDatacenterDao; + @Inject + private AccountService accountService; + @Inject + private UserDao userDao; + @Inject + private ServiceOfferingDao serviceOfferingDao; + @Inject + private DiskOfferingDao diskOfferingDao; + @Inject + private UserVmDao userVmDao; + @Inject + private VmwareCbtMigrationDiskDao vmwareCbtMigrationDiskDao; + @Inject + private VmwareCbtMigrationCycleDao vmwareCbtMigrationCycleDao; + @Inject + private StoragePoolHostDao storagePoolHostDao; + @Inject + private AgentManager agentManager; + @Inject + private UnmanagedVMsManager unmanagedVMsManager; + @Autowired(required = false) + private VmwareCbtMigrationService vmwareCbtMigrationService; + + @Override + public List<Class<?>> getCommands() { + final List<Class<?>> cmdList = new ArrayList<>(); + cmdList.add(CheckVmwareCbtMigrationPrerequisitesCmd.class); + cmdList.add(StartVmwareCbtMigrationCmd.class); + cmdList.add(ListVmwareCbtMigrationsCmd.class); + cmdList.add(SyncVmwareCbtMigrationCmd.class); + cmdList.add(CutoverVmwareCbtMigrationCmd.class); + cmdList.add(CancelVmwareCbtMigrationCmd.class); + cmdList.add(DeleteVmwareCbtMigrationCmd.class); + return cmdList; + } + + @Override + public VmwareCbtMigrationPreflightResponse checkVmwareCbtMigrationPrerequisites(CheckVmwareCbtMigrationPrerequisitesCmd cmd) { + PreflightFindingCollector findings = new PreflightFindingCollector(); + VmwareCbtMigrationPreflightResponse response = new VmwareCbtMigrationPreflightResponse(); + response.setObjectName("vmwarecbtmigrationpreflight"); + + DataCenterVO zone = getPreflightZone(cmd.getZoneId(), findings); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + + ClusterVO destinationCluster = getPreflightDestinationCluster(cmd.getClusterId(), zone, findings); + if (destinationCluster != null) { + response.setClusterId(destinationCluster.getUuid()); + response.setClusterName(destinationCluster.getName()); + } + + StoragePoolVO storagePool = getPreflightStoragePool(cmd.getStoragePoolId(), zone, destinationCluster, findings); + VmwareCbtStorageTarget storageTarget = null; + if (storagePool != null) { + storageTarget = VmwareCbtStorageTarget.forPool(storagePool); + populateStorageTargetResponse(response, storageTarget); + addStorageTargetFinding(storageTarget, findings); + } + + HostVO cbtHost = getPreflightCbtHost(cmd.getConvertInstanceHostId(), destinationCluster, storageTarget, findings); + if (cbtHost != null) { + response.setConvertInstanceHostId(cbtHost.getUuid()); + response.setConvertInstanceHostName(cbtHost.getName()); + response.setConvertInstanceHostInPlaceFinalizationSupported(hostSupportsInPlaceFinalization(cbtHost)); + } + addStorageTargetFinalizationFinding(storageTarget, cbtHost, findings); + addRbdStorageAccessFinding(storageTarget, cbtHost, findings); + + String sourceVmName = StringUtils.trimToNull(cmd.getSourceVmName()); + if (sourceVmName == null) { + findings.fail("sourceVm.name.present", "vmware", null, "Source VM name is required."); + } else { + response.setSourceVmName(sourceVmName); + } + + VmwareSource source = getPreflightVmwareSource(cmd, findings); + if (source != null) { + response.setVcenter(source.vcenter); + response.setDatacenterName(source.datacenterName); + response.setSourceHost(source.sourceHost); + } + + if (source != null && sourceVmName != null) { + populateSourceVmPreflight(response, source, sourceVmName, cbtHost, cmd.getServiceOfferingId(), + cmd.getDetails(), zone, findings); + } + + response.setFindings(findings.getFindings()); + response.setReady(!findings.hasFailures()); + return response; + } + + private VmwareCbtPreflightInfo validateSourceVmPreflightForStart(VmwareSource source, String sourceVmName) { + VmwareCbtPreflightInfo preflightInfo; + try { + preflightInfo = getVmwareCbtMigrationService().getPreflightInfo(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + } catch (RuntimeException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Unable to validate VMware CBT prerequisites for source VM %s: %s", + sourceVmName, sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source))); + } + + if (Boolean.FALSE.equals(preflightInfo.getChangeTrackingSupported())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source VM %s does not report VMware CBT support", sourceVmName)); + } + if (Boolean.TRUE.equals(preflightInfo.getConsolidationNeeded())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source VM %s reports pending disk consolidation; consolidate VMware disks before starting CBT migration", + sourceVmName)); + } + if (CollectionUtils.isEmpty(preflightInfo.getDisks())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("No source VMware disks were discovered for VM %s", sourceVmName)); + } + for (VmwareCbtPreflightDiskInfo disk : preflightInfo.getDisks()) { + validateSourceDiskPreflightForStart(disk); + } + return preflightInfo; + } + + private void ensureSourceVmChangeTrackingEnabledForStart(VmwareSource source, String sourceVmName, + VmwareCbtPreflightInfo preflightInfo) { + if (Boolean.TRUE.equals(preflightInfo.getChangeTrackingEnabled())) { + return; + } + try { + getVmwareCbtMigrationService().ensureChangeTrackingEnabled(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + } catch (RuntimeException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Unable to enable VMware CBT on source VM %s before initial full sync: %s", + sourceVmName, sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source))); + } + } + + private void validateSourceDiskPreflightForStart(VmwareCbtPreflightDiskInfo disk) { + String diskId = StringUtils.defaultIfBlank(disk.getSourceDiskId(), disk.getSourceDiskPath()); + if (disk.getSourceDiskDeviceKey() == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s does not expose a VMware device key required for CBT delta queries", + diskId)); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s does not expose a VMware backing path", diskId)); + } + if (disk.isIndependentDisk()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s uses independent disk mode %s, which is not supported for VMware CBT migration", + diskId, disk.getDiskMode())); + } + if (disk.isPhysicalRdm()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s is a physical-mode RDM, which is not supported for VMware CBT migration", + diskId)); + } + } + + private ServiceOfferingVO getServiceOfferingForVmwareCbtMigration(Long serviceOfferingId, Account owner, DataCenterVO zone) { + if (serviceOfferingId == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Service offering ID cannot be null"); + } + ServiceOfferingVO serviceOffering = serviceOfferingDao.findById(serviceOfferingId); + if (serviceOffering == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Service offering ID: %d cannot be found", serviceOfferingId)); + } + accountService.checkAccess(owner, serviceOffering, zone); + serviceOfferingDao.loadDetails(serviceOffering); + return serviceOffering; + } + + static VmwareCbtOfferingResources resolveRequestedOfferingResources(ServiceOfferingVO serviceOffering, Map<String, String> details) { + if (serviceOffering == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Service offering cannot be null"); + } + Map<String, String> callerDetails = details == null ? new HashMap<>() : details; + Map<String, String> offeringDetails = serviceOffering.getDetails(); + Integer cpu = firstInteger(VmDetailConstants.CPU_NUMBER, callerDetails, + serviceOffering.getCpu(), ApiConstants.MIN_CPU_NUMBER, offeringDetails); + Integer cpuSpeed = firstInteger(VmDetailConstants.CPU_SPEED, callerDetails, + serviceOffering.getSpeed(), null, offeringDetails); + Integer memory = firstInteger(VmDetailConstants.MEMORY, callerDetails, + serviceOffering.getRamSize(), ApiConstants.MIN_MEMORY, offeringDetails); + return new VmwareCbtOfferingResources(cpu, cpuSpeed, memory); + } + + private static Integer firstInteger(String callerDetailKey, Map<String, String> callerDetails, + Integer offeringValue, String offeringDetailKey, + Map<String, String> offeringDetails) { + if (offeringValue != null) { + return offeringValue; + } + Integer callerValue = parseIntegerDetail(callerDetails, callerDetailKey); + if (callerValue != null) { + return callerValue; + } + return StringUtils.isBlank(offeringDetailKey) ? null : parseIntegerDetail(offeringDetails, offeringDetailKey); + } + + private static Integer parseIntegerDetail(Map<String, String> details, String key) { + if (MapUtils.isEmpty(details) || StringUtils.isBlank(key) || StringUtils.isBlank(details.get(key))) { + return null; + } + try { + return Integer.valueOf(details.get(key)); + } catch (NumberFormatException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Please provide a valid integer value for detail '%s'.", key)); + } + } + + static void validateSelectedServiceOfferingResourcesForSourceVm(VmwareCbtPreflightInfo preflightInfo, + ServiceOfferingVO serviceOffering, + Map<String, String> details) { + VmwareCbtOfferingResources requestedResources = resolveRequestedOfferingResources(serviceOffering, details); + validateRequestedResourceAtLeastSource(preflightInfo.getCpuCores(), requestedResources.cpuNumber, "CPU number"); + validateRequestedResourceAtLeastSource(preflightInfo.getCpuSpeed(), requestedResources.cpuSpeed, "CPU speed"); + validateRequestedResourceAtLeastSource(preflightInfo.getMemoryMb(), requestedResources.memoryMb, "Memory"); + } + + private static void validateRequestedResourceAtLeastSource(Integer sourceResource, Integer requestedResource, String resourceName) { + if (sourceResource == null || requestedResource == null || sourceResource <= 0 || requestedResource <= 0) { + return; + } + if (requestedResource < sourceResource) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("The requested %s (%d) is less than the source VM %s (%d)", + resourceName, requestedResource, resourceName, sourceResource)); + } + } + + @Override + public VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigrationCmd cmd) { + Account caller = CallContext.current().getCallingAccount(); + if (caller == null) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Unable to determine calling account"); + } + Account owner = accountService.getActiveAccountById(cmd.getEntityOwnerId()); + if (owner == null) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Unable to determine target account"); + } + + DataCenterVO zone = getZone(cmd.getZoneId()); + ClusterVO destinationCluster = getDestinationCluster(cmd.getClusterId(), zone.getId()); + StoragePoolVO storagePool = getStoragePool(cmd.getStoragePoolId(), zone, destinationCluster); + VmwareCbtStorageTarget storageTarget = VmwareCbtStorageTarget.forPool(storagePool); + if (!storageTarget.isSupported()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, storageTarget.getSupportMessage()); + } + HostVO convertHost = selectCbtHost(cmd.getConvertInstanceHostId(), destinationCluster, storageTarget); + validateStorageTargetFinalizationSupport(storageTarget, convertHost); + validateRbdStorageAccessForStart(storageTarget, convertHost); + + String sourceVmName = StringUtils.trimToNull(cmd.getSourceVmName()); + if (sourceVmName == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Source VM name is required"); + } + + VmwareSource source = resolveVmwareSource(cmd); + VmwareCbtPreflightInfo preflightInfo = validateSourceVmPreflightForStart(source, sourceVmName); + ServiceOfferingVO serviceOffering = getServiceOfferingForVmwareCbtMigration(cmd.getServiceOfferingId(), owner, zone); + validateSelectedServiceOfferingResourcesForSourceVm(preflightInfo, serviceOffering, cmd.getDetails()); + validateWindowsGuestConversionSupportForStart(convertHost, sourceVmName, preflightInfo, cmd.getDetails()); + ensureSourceVmChangeTrackingEnabledForStart(source, sourceVmName, preflightInfo); + List<VmwareCbtDiskInfo> sourceDisks = discoverSourceDisks(source, sourceVmName); + validateDataDiskOfferingMappingsForStart(sourceVmName, sourceDisks, cmd.getDataDiskToDiskOfferingList(), owner, zone); + validateNicMappingsForStart(sourceVmName, preflightInfo.getNics(), cmd.getNicNetworkList(), cmd.getNicIpAddressList()); + String displayName = StringUtils.defaultIfBlank(cmd.getDisplayName(), sourceVmName); + + VmwareCbtMigrationVO migration = new VmwareCbtMigrationVO(zone.getId(), owner.getId(), getUserIdForOwner(owner), + destinationCluster.getId(), displayName, source.vcenter, source.datacenterName, cmd.getSourceHost(), cmd.getSourceCluster(), sourceVmName); + migration.setExistingVcenterId(source.existingVcenterId); + storeExternalVmwareSourceCredentials(migration, source); + if (convertHost != null) { + migration.setConvertHostId(convertHost.getId()); + } + if (storagePool != null) { + migration.setStoragePoolId(storagePool.getId()); + } + migration.setHostName(StringUtils.trimToNull(cmd.getHostName())); + migration.setTemplateId(cmd.getTemplateId()); + migration.setServiceOfferingId(cmd.getServiceOfferingId()); + // Derive the guest OS and hardware details from the source VM's configuration when the + // caller did not choose them: without this the cutover import inherits the dummy import + // template's generic OS type and the KVM defaults (BIOS firmware, cirrus video), which + // leaves a UEFI source unbootable and gives Windows guests Linux clock semantics. + migration.setGuestOsId(cmd.getGuestOsId() != null ? cmd.getGuestOsId() + : unmanagedVMsManager.resolveGuestOsIdForVmwareImport(preflightInfo.getOperatingSystem(), preflightInfo.getOperatingSystemId())); + migration.setDataDiskOfferingMap(serializeMap(cmd.getDataDiskToDiskOfferingList())); + migration.setNicNetworkMap(serializeMap(cmd.getNicNetworkList())); + // Preserve static source IPs captured during preflight: NICs mapped to a network but + // given no explicit IP keep their guest-configured address when it fits the target + // network. The source is running at this point, so Tools data is available. + Map<String, List<String>> sourceNicCidrs = new HashMap<>(); + for (VmwareCbtPreflightNicInfo nic : preflightInfo.getNics()) { + if (StringUtils.isNotBlank(nic.getSourceNicId()) && CollectionUtils.isNotEmpty(nic.getIpv4Cidrs())) { + sourceNicCidrs.put(nic.getSourceNicId(), nic.getIpv4Cidrs()); + } + } + migration.setNicIpAddressMap(serializeNicIpAddressMap(unmanagedVMsManager.autoFillStaticNicIpAddresses( + cmd.getNicNetworkList(), cmd.getNicIpAddressList(), sourceNicCidrs))); + migration.setImportDetails(serializeMap(unmanagedVMsManager.applyVmwareImportHardwareDetails(cmd.getDetails(), + preflightInfo.getBootType(), preflightInfo.getBootMode(), preflightInfo.getOperatingSystem()))); + migration.setForced(cmd.isForced()); + applyVddkDetails(migration, cmd.getDetails()); + migration.setState(VmwareCbtMigration.State.InitialSync); + migration.setCurrentStep(String.format("Discovered %s source disk(s); preparing initial VDDK full sync", sourceDisks.size())); + migration.setUpdated(new Date()); + migration = vmwareCbtMigrationDao.persist(migration); + persistSourceDisks(migration, sourceDisks); + return runInitialFullSync(source, migration, storageTarget); + } + + @Override + public ListResponse<VmwareCbtMigrationResponse> listVmwareCbtMigrations(ListVmwareCbtMigrationsCmd cmd) { + VmwareCbtMigration.State state = parseState(cmd.getState()); + Pair<List<VmwareCbtMigrationVO>, Integer> result = vmwareCbtMigrationDao.listMigrations(cmd.getId(), cmd.getZoneId(), cmd.getAccountId(), + cmd.getVcenter(), cmd.getSourceVmName(), state, cmd.getStartIndex(), cmd.getPageSizeVal()); + + List<VmwareCbtMigrationResponse> responses = new ArrayList<>(); + for (VmwareCbtMigrationVO migration : result.first()) { + responses.add(createVmwareCbtMigrationResponse(migration)); + } + ListResponse<VmwareCbtMigrationResponse> listResponse = new ListResponse<>(); + listResponse.setResponses(responses, result.second()); + return listResponse; + } + + @Override + public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + rejectTerminalMigration(migration, "synchronize"); + requireMigrationState(migration, "synchronize", VmwareCbtMigration.State.Replicating, + VmwareCbtMigration.State.ReadyForCutover); + VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); + validateInitialSyncTargetDisks(migration); + HostVO cbtHost = getCbtHostForMigration(migration); + int cycleNumber = migration.getCompletedCycles() + 1; + VmwareCbtMigrationCycleVO cycle = new VmwareCbtMigrationCycleVO(migration.getId(), cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.CopyingChangedBlocks); + cycle.setDescription("Dispatching CBT delta synchronization to KVM agent"); + cycle.setUpdated(new Date()); + cycle = vmwareCbtMigrationCycleDao.persist(cycle); + + migration.setState(VmwareCbtMigration.State.Replicating); + migration.setCurrentStep(String.format("Running CBT delta synchronization cycle %s", cycleNumber)); + migration.setLastError(null); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, String.format("start of CBT delta cycle %s", cycleNumber)); + + VmwareCbtSnapshotInfo snapshot = null; + try { + snapshot = createDeltaSnapshot(source, migration, cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.QueryingChangedAreas); + cycle.setSnapshotMor(snapshot.getSnapshotMor()); + cycle.setDescription("Querying VMware CBT changed disk areas"); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtChangedBlockQueryResult changedBlockQuery = queryChangedBlocks(source, migration, + snapshot.getSnapshotMor()); + + cycle.setState(VmwareCbtMigrationCycle.State.CopyingChangedBlocks); + cycle.setDescription(String.format("Dispatching %s VMware CBT changed block range(s) to KVM agent", + changedBlockQuery.changedBlocks.size())); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), + createRemoteInstance(source, migration, snapshot.getSourceVmMor()), getDiskTransferObjects(migration), + changedBlockQuery.changedBlocks, cycleNumber, snapshot.getSnapshotMor(), false); + applyVddkDetails(syncCommand, migration); + applyTargetStorageDetails(syncCommand, migration); + syncCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "synchronize", + migration.getUuid()); + if (!answer.getResult()) { + String details = sanitizeSensitiveMessage(answer.getDetails(), source); + markCycleFailed(cycle, details); + markMigrationFailed(migration, "CBT delta synchronization failed", details); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + applyDiskResults(migration, answer.getDiskResults()); + updateDiskChangeIds(migration, changedBlockQuery.changedDisks); + long changedBytes = answer.getChangedBytes(); + long durationSeconds = answer.getDurationSeconds(); + long dirtyRate = getDirtyRateBytesPerSecond(changedBytes, durationSeconds, answer.getDirtyRateBytesPerSecond()); + VmwareCbtMigrationCutoverPolicy cutoverPolicy = getCutoverPolicy(); + VmwareCbtMigrationCutoverPolicy.Decision cutoverDecision = cutoverPolicy.decide(cycleNumber, + migration.getQuietCycles(), changedBytes, durationSeconds); + int quietCycles = cutoverPolicy.isQuietCycle(changedBytes, durationSeconds) ? + migration.getQuietCycles() + 1 : 0; + + cycle.setState(VmwareCbtMigrationCycle.State.Completed); + cycle.setChangedBytes(changedBytes); + cycle.setDirtyRate(dirtyRate); + cycle.setDuration(durationSeconds * 1000); + cycle.setDescription(sanitizeSensitiveMessage(answer.getDetails(), source)); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + migration.setCompletedCycles(cycleNumber); + migration.setQuietCycles(quietCycles); + migration.setTotalChangedBytes(migration.getTotalChangedBytes() + changedBytes); + migration.setLastChangedBytes(changedBytes); + migration.setLastDirtyRate(dirtyRate); + migration.setState(cutoverDecision == VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE ? + VmwareCbtMigration.State.Replicating : VmwareCbtMigration.State.ReadyForCutover); + migration.setCurrentStep(getCutoverDecisionStep(cutoverDecision)); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "CBT delta synchronization progress"); + return createVmwareCbtMigrationResponse(migration); + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + markCycleFailed(cycle, error); + markMigrationFailed(migration, "CBT delta synchronization failed", error); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } finally { + removeDeltaSnapshotIfPossible(source, migration, snapshot); + } + } + + @Override + public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + rejectTerminalMigration(migration, "cut over"); + requireMigrationState(migration, "cut over", VmwareCbtMigration.State.ReadyForCutover, + VmwareCbtMigration.State.ReadyForImport); + VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); + validateInitialSyncTargetDisks(migration); + if (migration.getState() == VmwareCbtMigration.State.ReadyForImport) { + return importCutoverMigration(source, migration); + } + HostVO cbtHost = getCbtHostForMigration(migration); + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + validateStorageTargetFinalizationSupport(storageTarget, cbtHost); + requireSourceVmPoweredOff(source, migration); + + migration.setState(VmwareCbtMigration.State.CuttingOver); + migration.setCurrentStep("Running final CBT delta synchronization before cutover"); + migration.setLastError(null); + migration.setUpdated(new Date()); + if (!vmwareCbtMigrationDao.updateIfNotTerminal(migration)) { + // Cancelled between the state check above and here: refuse rather than cut over a + // migration whose target disks the cancellation has already removed. + VmwareCbtMigrationVO current = vmwareCbtMigrationDao.findById(migration.getId()); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot cut over VMware CBT migration %s: it reached state %s while the cutover was starting.", + migration.getUuid(), current == null ? "unknown" : current.getState())); + } + + int finalCycleNumber = migration.getCompletedCycles() + 1; + if (!runFinalDeltaSync(source, migration, cbtHost, finalCycleNumber)) { + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + migration = vmwareCbtMigrationDao.findById(migration.getId()); + boolean inPlaceFinalization = hostSupportsInPlaceFinalization(cbtHost); + String finalizationDescription = inPlaceFinalization ? "in-place conversion" : "virt-v2v fallback conversion"; + migration.setCurrentStep(String.format("Final CBT delta synchronization completed; running %s", + finalizationDescription)); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "final delta synchronization progress"); + + VmwareCbtCutoverCommand cutoverCommand = new VmwareCbtCutoverCommand(migration.getUuid(), createRemoteInstance(source, migration), + getDiskTransferObjects(migration), finalCycleNumber, true); + applyVddkDetails(cutoverCommand, migration); + applyTargetStorageDetails(cutoverCommand, migration); + cutoverCommand.setAllowNonInPlaceFinalization(isNonInPlaceFinalizationFallbackAllowed(storageTarget)); + cutoverCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, cutoverCommand, "cut over", migration.getUuid()); + if (!answer.getResult()) { + markMigrationFailed(migration, "CBT cutover failed", sanitizeSensitiveMessage(answer.getDetails(), source)); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + applyDiskResults(migration, answer.getDiskResults()); + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setState(VmwareCbtMigration.State.ReadyForImport); + migration.setCurrentStep(String.format("Final %s completed; importing VM into CloudStack", + finalizationDescription)); + migration.setUpdated(new Date()); + if (!vmwareCbtMigrationDao.updateIfNotTerminal(migration)) { + // Cancelled while the final conversion ran: do not import disks the cancellation + // has already scheduled for removal. + LOGGER.info("Not importing VMware CBT migration {}: it reached a terminal state while the final conversion was running.", + migration.getUuid()); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + return importCutoverMigration(source, migration); + } + + private VmwareCbtMigrationResponse importCutoverMigration(VmwareSource source, VmwareCbtMigrationVO migration) { + VmwareCbtMigrationVO stored = vmwareCbtMigrationDao.findById(migration.getId()); + if (stored != null && stored.getState() != null && stored.getState().isTerminal()) { + // Importing is expensive and creates a real VM, so re-check right before starting it: + // a cancel may have landed while the preceding conversion was running. + LOGGER.info("Not importing VMware CBT migration {}: it is already in terminal state {}.", + migration.getUuid(), stored.getState()); + return createVmwareCbtMigrationResponse(stored); + } + if (migration.getServiceOfferingId() == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Cannot import VMware CBT migration because serviceofferingid was not recorded when the migration was started"); + } + Account caller = CallContext.current().getCallingAccount(); + Account owner = accountService.getActiveAccountById(migration.getAccountId()); + if (caller == null || owner == null) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Unable to resolve caller or target account for VMware CBT import"); + } + try { + UserVm userVm = unmanagedVMsManager.importConvertedVmwareCbtInstanceToKvm(source.vcenter, source.datacenterName, + source.username, source.password, migration.getSourceCluster(), migration.getSourceHost(), + migration.getSourceVmName(), createConvertedInstanceForImport(migration), getZone(migration.getZoneId()), + getDestinationCluster(migration.getDestinationClusterId(), migration.getZoneId()), migration.getDisplayName(), + migration.getHostName(), caller, owner, migration.getUserId(), migration.getTemplateId(), + migration.getServiceOfferingId(), deserializeLongMap(migration.getDataDiskOfferingMap()), + deserializeLongMap(migration.getNicNetworkMap()), deserializeNicIpAddressMap(migration.getNicIpAddressMap()), + migration.getGuestOsId(), deserializeStringMap(migration.getImportDetails()), migration.isForced(), + migration.getStoragePoolId()); + + if (!persistImportedVmResult(migration, userVm)) { + // The VM does exist, so surface it rather than losing the reference: the operator + // cancelled during the import and now has to reconcile the imported VM by hand. + LOGGER.warn("VMware CBT migration {} was cancelled while its import was running, but the import completed and created VM {}. " + + "The migration record keeps its cancelled state; the imported VM must be reviewed manually.", + migration.getUuid(), userVm.getUuid()); + } + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setState(VmwareCbtMigration.State.ReadyForImport); + migration.setCurrentStep("CloudStack VM import failed; retry cutover to import the converted disks"); + migration.setLastError(error); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "failed CloudStack VM import"); + } + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + boolean persistImportedVmResult(VmwareCbtMigrationVO migration, UserVm userVm) { + Date updated = new Date(); + if (vmwareCbtMigrationDao.completeImportIfNotTerminal(migration.getId(), userVm.getId(), updated)) { + return true; + } + vmwareCbtMigrationDao.recordImportedVmAndClearCredentials(migration.getId(), userVm.getId(), updated); + return false; + } + + private UnmanagedInstanceTO createConvertedInstanceForImport(VmwareCbtMigrationVO migration) { + List<VmwareCbtMigrationDiskVO> migrationDisks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + if (CollectionUtils.isEmpty(migrationDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No synchronized disks were found for VMware CBT migration %s", migration.getUuid())); + } + UnmanagedInstanceTO convertedInstance = new UnmanagedInstanceTO(); + convertedInstance.setName(migration.getSourceVmName()); + convertedInstance.setHypervisorType(Hypervisor.HypervisorType.KVM.name()); + convertedInstance.setPowerState(UnmanagedInstanceTO.PowerState.PowerOff); + + StoragePoolVO storagePool = primaryDataStoreDao.findById(migration.getStoragePoolId()); + Storage.StoragePoolType storagePoolType = storagePool == null ? null : storagePool.getPoolType(); + List<UnmanagedInstanceTO.Disk> disks = new ArrayList<>(); + int position = 0; + for (VmwareCbtMigrationDiskVO migrationDisk : migrationDisks) { + if (StringUtils.isBlank(migrationDisk.getTargetPath())) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("Target disk path is missing for source disk %s in VMware CBT migration %s", + migrationDisk.getSourceDiskId(), migration.getUuid())); + } + UnmanagedInstanceTO.Disk disk = new UnmanagedInstanceTO.Disk(); + disk.setDiskId(migrationDisk.getSourceDiskId()); + disk.setLabel(migrationDisk.getSourceDiskPath()); + disk.setCapacity(migrationDisk.getCapacityBytes()); + disk.setPosition(position); + disk.setControllerUnit(position); + disk.setController(CBT_FINALIZED_DISK_CONTROLLER); + disk.setImagePath(migrationDisk.getTargetPath()); + disk.setFileBaseName(getVolumePathForCbtTarget(migrationDisk.getTargetPath(), migration.getUuid(), storagePoolType)); + if (storagePool != null) { + disk.setDatastoreName(storagePool.getUuid()); + disk.setDatastoreType(storagePool.getPoolType().name()); + } + disks.add(disk); + position++; + } + convertedInstance.setDisks(disks); + convertedInstance.setNics(new ArrayList<>()); + return convertedInstance; + } + + private String getVolumePathForCbtTarget(String targetPath, String migrationUuid, Storage.StoragePoolType storagePoolType) { + String normalizedPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + if (storagePoolType == Storage.StoragePoolType.RBD || storagePoolType == Storage.StoragePoolType.Linstor) { + return StringUtils.contains(normalizedPath, "/") ? StringUtils.substringAfterLast(normalizedPath, "/") : normalizedPath; + } + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalizedPath.indexOf(marker); + if (markerIndex >= 0) { + return String.format("cloudstack-cbt/%s/%s", migrationUuid, + normalizedPath.substring(markerIndex + marker.length())); + } + return StringUtils.substringAfterLast(normalizedPath, "/"); + } + + private Map<String, Long> deserializeLongMap(String json) { + if (StringUtils.isBlank(json)) { + return new HashMap<>(); + } + try { + return GSON.fromJson(json, new TypeToken<Map<String, Long>>() { + }.getType()); + } catch (RuntimeException e) { + throw new InvalidParameterValueException("Unable to parse stored VMware CBT import ID map"); + } + } + + private Map<String, String> deserializeStringMap(String json) { + if (StringUtils.isBlank(json)) { + return new HashMap<>(); + } + try { + return GSON.fromJson(json, new TypeToken<Map<String, String>>() { + }.getType()); + } catch (RuntimeException e) { + throw new InvalidParameterValueException("Unable to parse stored VMware CBT import details"); + } + } + + private Map<String, Network.IpAddresses> deserializeNicIpAddressMap(String json) { + Map<String, Network.IpAddresses> nicIpAddressMap = new HashMap<>(); + for (Map.Entry<String, String> entry : deserializeStringMap(json).entrySet()) { + nicIpAddressMap.put(entry.getKey(), new Network.IpAddresses(entry.getValue(), null)); + } + return nicIpAddressMap; + } + + private boolean runFinalDeltaSync(VmwareSource source, VmwareCbtMigrationVO migration, HostVO cbtHost, + int cycleNumber) { + VmwareCbtMigrationCycleVO cycle = new VmwareCbtMigrationCycleVO(migration.getId(), cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.Created); + cycle.setDescription("Creating final VMware CBT snapshot for cutover"); + cycle.setUpdated(new Date()); + cycle = vmwareCbtMigrationCycleDao.persist(cycle); + + VmwareCbtSnapshotInfo snapshot = null; + try { + snapshot = createDeltaSnapshot(source, migration, cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.QueryingChangedAreas); + cycle.setSnapshotMor(snapshot.getSnapshotMor()); + cycle.setDescription("Querying final VMware CBT changed disk areas"); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtChangedBlockQueryResult changedBlockQuery = queryChangedBlocks(source, migration, + snapshot.getSnapshotMor()); + + cycle.setState(VmwareCbtMigrationCycle.State.CopyingChangedBlocks); + cycle.setDescription(String.format("Dispatching %s final VMware CBT changed block range(s) to KVM agent", + changedBlockQuery.changedBlocks.size())); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), + createRemoteInstance(source, migration, snapshot.getSourceVmMor()), getDiskTransferObjects(migration), + changedBlockQuery.changedBlocks, cycleNumber, snapshot.getSnapshotMor(), true); + applyVddkDetails(syncCommand, migration); + applyTargetStorageDetails(syncCommand, migration); + syncCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "perform final synchronization", + migration.getUuid()); + if (!answer.getResult()) { + String details = sanitizeSensitiveMessage(answer.getDetails(), source); + markCycleFailed(cycle, details); + markMigrationFailed(migration, "Final CBT delta synchronization failed", details); + return false; + } + + applyDiskResults(migration, answer.getDiskResults()); + updateDiskChangeIds(migration, changedBlockQuery.changedDisks); + long changedBytes = answer.getChangedBytes(); + long durationSeconds = answer.getDurationSeconds(); + long dirtyRate = getDirtyRateBytesPerSecond(changedBytes, durationSeconds, answer.getDirtyRateBytesPerSecond()); + + cycle.setState(VmwareCbtMigrationCycle.State.Completed); + cycle.setChangedBytes(changedBytes); + cycle.setDirtyRate(dirtyRate); + cycle.setDuration(durationSeconds * 1000); + cycle.setDescription(sanitizeSensitiveMessage(answer.getDetails(), source)); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + migration.setCompletedCycles(cycleNumber); + migration.setQuietCycles(0); + migration.setTotalChangedBytes(migration.getTotalChangedBytes() + changedBytes); + migration.setLastChangedBytes(changedBytes); + migration.setLastDirtyRate(dirtyRate); + migration.setCurrentStep("Final CBT delta synchronization completed"); + migration.setUpdated(new Date()); + persistMigrationProgress(migration, "final CBT delta synchronization result"); + return true; + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + markCycleFailed(cycle, error); + markMigrationFailed(migration, "Final CBT delta synchronization failed", error); + return false; + } finally { + removeDeltaSnapshotIfPossible(source, migration, snapshot); + } + } + + private void requireSourceVmPoweredOff(VmwareSource source, VmwareCbtMigrationVO migration) { + UnmanagedInstanceTO.PowerState powerState = getVmwareCbtMigrationService().getPowerState(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName()); + if (powerState != UnmanagedInstanceTO.PowerState.PowerOff) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot cut over VMware CBT migration %s while source VM %s is in power state %s. Gracefully shut down the source VM, then retry cutover.", + migration.getUuid(), migration.getSourceVmName(), powerState)); + } + } + + @Override + public VmwareCbtMigrationResponse cancelVmwareCbtMigration(CancelVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + if (!migration.getState().isTerminal()) { + // Mark the migration cancelled before removing the target disks, not after: an + // in-flight replication or cutover job only persists its progress while the record is + // non-terminal, so winning this conditional update is what stops it from writing a + // replicating state back over the cancellation once its disks are already gone. + migration.setState(VmwareCbtMigration.State.Cancelled); + migration.setCurrentStep("Cancelled"); + migration.setUpdated(new Date()); + if (vmwareCbtMigrationDao.updateIfNotTerminal(migration)) { + sendCleanupCommandIfPossible(migration); + // Only after the cleanup command has returned: it is what stops the nbdkit/VDDK + // readers still streaming from the snapshot. While one of those holds the disk open + // vCenter answers "The operation is not allowed in the current state" and the + // snapshot would be orphaned on the source VM. + removeLingeringSourceSnapshots(migration); + } else { + LOGGER.info("VMware CBT migration {} reached a terminal state concurrently; leaving it as is and skipping target cleanup.", + migration.getUuid()); + } + migration = vmwareCbtMigrationDao.findById(migration.getId()); + } + clearStoredSourceCredentials(migration); + return createVmwareCbtMigrationResponse(migration); + } + + @Override + public boolean deleteVmwareCbtMigration(DeleteVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + if (!canDeleteMigrationState(migration.getState())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Only failed, cancelled or completed VMware CBT migrations can be deleted. Migration %s is in state %s.", + migration.getUuid(), migration.getState())); + } + if (shouldCleanupDeletedMigration(migration.getState(), cmd.getCleanup())) { + sendCleanupCommandForDelete(migration); + // Last chance to drop a snapshot vCenter refused earlier, and it has to happen here: + // the source credentials are cleared immediately below, and the disk rows carrying the + // snapshot MORs are deleted at the end of this method - after either, the snapshot can + // no longer be found or authenticated for. + removeLingeringSourceSnapshots(migration); + } else if (migration.getState() == VmwareCbtMigration.State.Completed && cmd.getCleanup()) { + LOGGER.debug("Skipping target disk cleanup for completed VMware CBT migration {}. Completed migration deletion is record-only.", + migration.getUuid()); + } + clearStoredSourceCredentials(migration); + for (VmwareCbtMigrationCycleVO cycle : vmwareCbtMigrationCycleDao.listByMigrationId(migration.getId())) { + vmwareCbtMigrationCycleDao.remove(cycle.getId()); + } + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + vmwareCbtMigrationDiskDao.remove(disk.getId()); + } + return vmwareCbtMigrationDao.remove(migration.getId()); + } + + boolean canDeleteMigrationState(VmwareCbtMigration.State state) { + return state == VmwareCbtMigration.State.Failed || state == VmwareCbtMigration.State.Cancelled || + state == VmwareCbtMigration.State.Completed; + } + + boolean shouldCleanupDeletedMigration(VmwareCbtMigration.State state, boolean cleanupRequested) { + return cleanupRequested && (state == VmwareCbtMigration.State.Failed || state == VmwareCbtMigration.State.Cancelled); + } + + private long getUserIdForOwner(Account owner) { + long userId = CallContext.current().getCallingUserId(); + List<UserVO> users = userDao.listByAccount(owner.getAccountId()); + if (CollectionUtils.isNotEmpty(users)) { + userId = users.get(0).getId(); + } + return userId; + } + + private String serializeMap(Map<?, ?> map) { + if (map == null || map.isEmpty()) { + return null; + } + return GSON.toJson(map); + } + + private String serializeNicIpAddressMap(Map<String, Network.IpAddresses> nicIpAddressMap) { + if (nicIpAddressMap == null || nicIpAddressMap.isEmpty()) { + return null; + } + Map<String, String> ip4AddressMap = new HashMap<>(); + for (Map.Entry<String, Network.IpAddresses> entry : nicIpAddressMap.entrySet()) { + if (entry.getValue() != null && StringUtils.isNotBlank(entry.getValue().getIp4Address())) { + ip4AddressMap.put(entry.getKey(), entry.getValue().getIp4Address()); + } + } + return serializeMap(ip4AddressMap); + } + + private DataCenterVO getZone(Long zoneId) { + DataCenterVO zone = dataCenterDao.findById(zoneId); + if (zone == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find zone with ID %s", zoneId)); + } + return zone; + } + + private ClusterVO getDestinationCluster(Long clusterId, long zoneId) { + ClusterVO cluster = clusterDao.findById(clusterId); + if (cluster == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find cluster with ID %s", clusterId)); + } + if (cluster.getDataCenterId() != zoneId) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Destination cluster does not belong to the requested zone"); + } + if (Hypervisor.HypervisorType.KVM != cluster.getHypervisorType()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Destination cluster must be a KVM cluster for VMware CBT migration"); + } + return cluster; + } + + private HostVO selectCbtHost(Long hostId, ClusterVO destinationCluster, VmwareCbtStorageTarget storageTarget) { + boolean requireInPlaceFinalization = requiresInPlaceFinalizationSupport(storageTarget); + boolean requireRbdSupport = requiresRbdStorageSupport(storageTarget); + if (hostId == null) { + String hostCapability = getRequiredHostCapability(requireInPlaceFinalization, requireRbdSupport); + List<HostVO> hosts = hostDao.listByClusterHypervisorTypeAndHostCapability(destinationCluster.getId(), + destinationCluster.getHypervisorType(), hostCapability); + if (requiresBlockDeviceStorageSupport(storageTarget) && CollectionUtils.isNotEmpty(hosts)) { + StoragePoolVO pool = storageTarget.getPool(); + hosts = hosts.stream() + .filter(h -> pool != null && storagePoolHostDao.findByPoolHost(pool.getId(), h.getId()) != null) + .collect(Collectors.toList()); + } + if (CollectionUtils.isEmpty(hosts)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + getNoSuitableCbtHostMessage(destinationCluster, requireInPlaceFinalization, requireRbdSupport)); + } + HostVO host = hosts.get(0); + hostDao.loadDetails(host); + return host; + } + HostVO host = hostDao.findById(hostId); + if (host == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find CBT migration host with ID %s", hostId)); + } + if (host.getClusterId() == null || host.getClusterId() != destinationCluster.getId()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "CBT migration host must belong to the destination KVM cluster"); + } + if (Hypervisor.HypervisorType.KVM != host.getHypervisorType()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "CBT migration host must be a KVM host"); + } + if (host.getType() != Host.Type.Routing || host.getStatus() != Status.Up || host.getResourceState() != ResourceState.Enabled) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "CBT migration host must be an enabled, running routing host"); + } + hostDao.loadDetails(host); + if (!Boolean.parseBoolean(host.getDetail(Host.HOST_VDDK_BLOCKCOPY_SUPPORT))) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("CBT migration host %s does not report VMware CBT migration support", host.getName())); + } + validateStorageTargetFinalizationSupport(storageTarget, host); + validateRbdHostSupport(storageTarget, host); + validateBlockDeviceHostSupport(storageTarget, host); + return host; + } + + private String getRequiredHostCapability(boolean requireInPlaceFinalization, boolean requireRbdSupport) { + if (requireRbdSupport) { + return Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT; + } + if (requireInPlaceFinalization) { + return Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT; + } + return Host.HOST_VDDK_BLOCKCOPY_SUPPORT; + } + + private String getRequiredHostCapabilityDescription(boolean requireInPlaceFinalization, boolean requireRbdSupport) { + if (requireRbdSupport) { + return "VMware CBT migration, in-place finalization and qemu RBD support"; + } + if (requireInPlaceFinalization) { + return "VMware CBT migration and in-place finalization support"; + } + return "VMware CBT migration support"; + } + + private String getNoSuitableCbtHostMessage(ClusterVO destinationCluster, boolean requireInPlaceFinalization, + boolean requireRbdSupport) { + if (requireRbdSupport) { + return String.format("Could not find an enabled KVM host in cluster %s with '%s' enabled, which is required to perform VMware CBT migration to RBD storage. " + + "This usually means the host is missing VMware CBT migration support, qemu-img RBD support, or in-place virt-v2v support through the virt-v2v-in-place binary or virt-v2v --in-place option.", + destinationCluster.getName(), Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT); + } + if (requireInPlaceFinalization) { + return String.format("Could not find an enabled KVM host in cluster %s with '%s' enabled, which is required to perform VMware CBT in-place finalization. " + + "This usually means the host is missing virt-v2v-in-place or virt-v2v --in-place support.", + destinationCluster.getName(), Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT); + } + return String.format("Could not find an enabled KVM host in cluster %s that reports %s", + destinationCluster.getName(), getRequiredHostCapabilityDescription(requireInPlaceFinalization, false)); + } + + void validateStorageTargetFinalizationSupport(VmwareCbtStorageTarget storageTarget, HostVO host) { + if (storageTarget == null || hostSupportsInPlaceFinalization(host)) { + return; + } + if (isNonInPlaceFinalizationFallbackAllowed(storageTarget)) { + return; + } + String hostName = host == null ? "selected KVM host" : host.getName(); + if (storageTarget.supportsNonInPlaceFinalizationFallback()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Selected KVM host %s cannot finalize VMware CBT migration in-place. Enable virt-v2v in-place support or explicitly allow non-in-place fallback finalization with %s.", + hostName, VmwareCbtAllowNonInPlaceFinalization.key())); + } + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Destination storage requires virt-v2v in-place finalization, but %s does not report '%s=true'. " + + "Ensure virt-v2v-in-place or virt-v2v --in-place is available on the conversion host.", + hostName, Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT)); + } + + private boolean hostSupportsInPlaceFinalization(HostVO host) { + return host != null && Boolean.parseBoolean(host.getDetail(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT)); + } + + private boolean requiresInPlaceFinalizationSupport(VmwareCbtStorageTarget storageTarget) { + if (storageTarget == null) { + return false; + } + return storageTarget.requiresInPlaceFinalization() || !isNonInPlaceFinalizationFallbackAllowed(storageTarget); + } + + private boolean requiresRbdStorageSupport(VmwareCbtStorageTarget storageTarget) { + return storageTarget != null && storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW; + } + + private boolean requiresBlockDeviceStorageSupport(VmwareCbtStorageTarget storageTarget) { + return storageTarget != null && storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE; + } + + private boolean requiresStorageAccessProbe(VmwareCbtStorageTarget storageTarget) { + return requiresRbdStorageSupport(storageTarget) || requiresBlockDeviceStorageSupport(storageTarget); + } + + private void validateBlockDeviceHostSupport(VmwareCbtStorageTarget storageTarget, HostVO host) { + if (!requiresBlockDeviceStorageSupport(storageTarget)) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + if (host == null || pool == null || storagePoolHostDao.findByPoolHost(pool.getId(), host.getId()) == null) { + String hostName = host == null ? "selected KVM host" : host.getName(); + String poolName = pool == null ? "selected storage pool" : pool.getName(); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Destination block device storage requires the conversion host to have access to the storage pool, " + + "but %s does not have access to %s. For Linstor the host must be a LINSTOR satellite connected to the pool.", + hostName, poolName)); + } + } + + private void validateRbdHostSupport(VmwareCbtStorageTarget storageTarget, HostVO host) { + if (!requiresRbdStorageSupport(storageTarget)) { + return; + } + if (host == null || !Boolean.parseBoolean(host.getDetail(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT))) { + String hostName = host == null ? "selected KVM host" : host.getName(); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Destination RBD storage requires qemu RBD support on the conversion host, but %s does not report '%s=true'. " + + "This usually means the host is missing VMware CBT migration support, qemu-img RBD support, or in-place virt-v2v support through the virt-v2v-in-place binary or virt-v2v --in-place option.", + hostName, Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT)); + } + } + + private boolean isNonInPlaceFinalizationFallbackAllowed(VmwareCbtStorageTarget storageTarget) { + return storageTarget != null && storageTarget.supportsNonInPlaceFinalizationFallback() && + Boolean.TRUE.equals(VmwareCbtAllowNonInPlaceFinalization.value()); + } + + private StoragePoolVO getStoragePool(Long storagePoolId, DataCenterVO zone, ClusterVO destinationCluster) { + if (storagePoolId == null) { + return getImplicitStoragePool(zone, destinationCluster); + } + StoragePoolVO pool = primaryDataStoreDao.findById(storagePoolId); + if (pool == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find storage pool with ID %s", storagePoolId)); + } + if (pool.getDataCenterId() != zone.getId()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Storage pool does not belong to the requested zone"); + } + if (pool.getClusterId() != null && !pool.getClusterId().equals(destinationCluster.getId())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Cluster-scoped storage pool must belong to the destination KVM cluster"); + } + return pool; + } + + private StoragePoolVO getImplicitStoragePool(DataCenterVO zone, ClusterVO destinationCluster) { + List<StoragePoolVO> pools = listCbtCompatibleStoragePools(zone, destinationCluster); + if (CollectionUtils.isEmpty(pools)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find a suitable CBT-compatible primary storage pool in cluster %s for VMware CBT migration. Supported target pool types are NetworkFilesystem, Filesystem, SharedMountPoint, RBD and Linstor.", + destinationCluster.getName())); + } + if (pools.size() > 1) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Multiple CBT-compatible primary storage pools are available in cluster %s. Please provide storagepoolid.", + destinationCluster.getName())); + } + return pools.get(0); + } + + List<StoragePoolVO> listCbtCompatibleStoragePools(DataCenterVO zone, ClusterVO destinationCluster) { + Map<Long, StoragePoolVO> poolsById = new LinkedHashMap<>(); + for (Storage.StoragePoolType poolType : CBT_COMPATIBLE_STORAGE_POOL_TYPES) { + for (StoragePoolVO pool : CollectionUtils.emptyIfNull(primaryDataStoreDao.findClusterWideStoragePoolsByHypervisorAndPoolType(destinationCluster.getId(), + Hypervisor.HypervisorType.KVM, poolType))) { + poolsById.put(pool.getId(), pool); + } + for (StoragePoolVO pool : CollectionUtils.emptyIfNull(primaryDataStoreDao.findZoneWideStoragePoolsByHypervisorAndPoolType(zone.getId(), + Hypervisor.HypervisorType.KVM, poolType))) { + poolsById.put(pool.getId(), pool); + } + } + return new ArrayList<>(poolsById.values()); + } + + private DataCenterVO getPreflightZone(Long zoneId, PreflightFindingCollector findings) { + try { + DataCenterVO zone = getZone(zoneId); + findings.pass("destination.zone.exists", "cloudstack", zone.getUuid(), + String.format("Destination zone %s was found.", zone.getName())); + return zone; + } catch (ServerApiException e) { + findings.fail("destination.zone.exists", "cloudstack", String.valueOf(zoneId), e.getDescription()); + return null; + } + } + + private ClusterVO getPreflightDestinationCluster(Long clusterId, DataCenterVO zone, + PreflightFindingCollector findings) { + if (zone == null) { + return null; + } + try { + ClusterVO cluster = getDestinationCluster(clusterId, zone.getId()); + findings.pass("destination.cluster.kvm", "cloudstack", cluster.getUuid(), + String.format("Destination cluster %s is a KVM cluster in the selected zone.", cluster.getName())); + return cluster; + } catch (ServerApiException e) { + findings.fail("destination.cluster.kvm", "cloudstack", String.valueOf(clusterId), e.getDescription()); + return null; + } + } + + private HostVO getPreflightCbtHost(Long hostId, ClusterVO destinationCluster, VmwareCbtStorageTarget storageTarget, + PreflightFindingCollector findings) { + if (destinationCluster == null) { + return null; + } + try { + HostVO host = selectCbtHost(hostId, destinationCluster, storageTarget); + findings.pass("destinationHost.vmwareCbtSupport", "kvm", host.getUuid(), + String.format("KVM host %s reports VMware CBT migration support.", host.getName())); + return host; + } catch (ServerApiException e) { + findings.fail("destinationHost.vmwareCbtSupport", "kvm", + hostId == null ? destinationCluster.getUuid() : String.valueOf(hostId), e.getDescription()); + return null; + } + } + + private StoragePoolVO getPreflightStoragePool(Long storagePoolId, DataCenterVO zone, ClusterVO destinationCluster, + PreflightFindingCollector findings) { + if (zone == null || destinationCluster == null) { + return null; + } + try { + StoragePoolVO pool = getStoragePool(storagePoolId, zone, destinationCluster); + findings.pass("destinationStorage.poolSelected", "storage", pool.getUuid(), + String.format("Destination storage pool %s was selected.", pool.getName())); + return pool; + } catch (ServerApiException e) { + findings.fail("destinationStorage.poolSelected", "storage", + storagePoolId == null ? destinationCluster.getUuid() : String.valueOf(storagePoolId), + e.getDescription()); + return null; + } + } + + private VmwareSource getPreflightVmwareSource(CheckVmwareCbtMigrationPrerequisitesCmd cmd, + PreflightFindingCollector findings) { + try { + VmwareSource source = resolveVmwareSource(cmd.getExistingVcenterId(), cmd.getVcenter(), + cmd.getDatacenterName(), cmd.getUsername(), cmd.getPassword(), cmd.getSourceHost()); + findings.pass("sourceVcenter.credentialsResolved", "vmware", source.vcenter, + "Source vCenter credentials were resolved."); + return source; + } catch (ServerApiException e) { + findings.fail("sourceVcenter.credentialsResolved", "vmware", cmd.getVcenter(), e.getDescription()); + return null; + } + } + + private void populateStorageTargetResponse(VmwareCbtMigrationPreflightResponse response, + VmwareCbtStorageTarget storageTarget) { + if (storageTarget == null) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + if (pool != null) { + response.setStoragePoolId(pool.getUuid()); + response.setStoragePoolName(pool.getName()); + response.setStoragePoolType(pool.getPoolType().name()); + } + response.setStorageWriterType(storageTarget.getTargetStorageType().name()); + response.setStorageWriterSupported(storageTarget.isSupported()); + response.setStorageRequiresInPlaceFinalization(storageTarget.requiresInPlaceFinalization()); + response.setNonInPlaceFinalizationFallbackAllowed(isNonInPlaceFinalizationFallbackAllowed(storageTarget)); + response.setNonInPlaceFinalizationFallbackSupported(storageTarget.supportsNonInPlaceFinalizationFallback()); + } + + private void addStorageTargetFinding(VmwareCbtStorageTarget storageTarget, PreflightFindingCollector findings) { + if (storageTarget == null) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + String resource = pool == null ? null : pool.getUuid(); + if (storageTarget.isSupported()) { + findings.pass("destinationStorage.writerSupported", "storage", resource, storageTarget.getSupportMessage()); + } else { + findings.fail("destinationStorage.writerSupported", "storage", resource, storageTarget.getSupportMessage()); + } + } + + private void addStorageTargetFinalizationFinding(VmwareCbtStorageTarget storageTarget, HostVO cbtHost, + PreflightFindingCollector findings) { + if (storageTarget == null || !storageTarget.isSupported()) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + String resource = pool == null ? null : pool.getUuid(); + if (hostSupportsInPlaceFinalization(cbtHost)) { + findings.pass("destinationStorage.finalizationSupported", "storage", resource, + String.format("KVM host %s reports VMware CBT in-place finalization support.", + cbtHost.getName())); + return; + } + if (cbtHost == null) { + findings.fail("destinationStorage.finalizationSupported", "storage", resource, + "No suitable KVM host was selected for VMware CBT finalization."); + return; + } + if (isNonInPlaceFinalizationFallbackAllowed(storageTarget)) { + findings.pass("destinationStorage.finalizationSupported", "storage", resource, + String.format("KVM host %s does not report VMware CBT in-place finalization support, but non-in-place fallback is explicitly enabled for qcow2 file targets. CloudStack will stage fallback temporary data on the selected primary storage and verify free space before cutover.", + cbtHost.getName())); + return; + } + if (storageTarget.supportsNonInPlaceFinalizationFallback()) { + findings.fail("destinationStorage.finalizationSupported", "storage", resource, + String.format("KVM host %s does not report '%s=true'. Ensure virt-v2v-in-place or virt-v2v --in-place is available, or explicitly allow non-in-place fallback finalization with %s.", + cbtHost.getName(), Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, + VmwareCbtAllowNonInPlaceFinalization.key())); + } else { + findings.fail("destinationStorage.finalizationSupported", "storage", resource, + String.format("Destination storage requires virt-v2v in-place finalization, but KVM host %s does not report '%s=true'. Ensure virt-v2v-in-place or virt-v2v --in-place is available on the conversion host.", + cbtHost.getName(), Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT)); + } + } + + private void addRbdStorageAccessFinding(VmwareCbtStorageTarget storageTarget, HostVO cbtHost, + PreflightFindingCollector findings) { + if (!requiresStorageAccessProbe(storageTarget)) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + String resource = pool == null ? null : pool.getUuid(); + String targetDescription = requiresRbdStorageSupport(storageTarget) ? "RBD image" : "block device volume"; + if (cbtHost == null) { + findings.fail("destinationStorage.rbdAccess", "storage", resource, + "No suitable KVM host was selected for the destination storage access probe."); + return; + } + Answer answer = probeTargetStorageAccess(storageTarget, cbtHost); + if (answer != null && answer.getResult()) { + findings.pass("destinationStorage.rbdAccess", "storage", resource, + String.format("KVM host %s can create, write, read and delete a temporary %s in storage pool %s.", + cbtHost.getName(), targetDescription, pool.getName())); + } else { + findings.fail("destinationStorage.rbdAccess", "storage", resource, getStorageProbeFailureMessage(storageTarget, answer, cbtHost, pool)); + } + } + + private void validateRbdStorageAccessForStart(VmwareCbtStorageTarget storageTarget, HostVO cbtHost) { + if (!requiresStorageAccessProbe(storageTarget)) { + return; + } + Answer answer = probeTargetStorageAccess(storageTarget, cbtHost); + if (answer == null || !answer.getResult()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + getStorageProbeFailureMessage(storageTarget, answer, cbtHost, storageTarget.getPool())); + } + } + + private Answer probeTargetStorageAccess(VmwareCbtStorageTarget storageTarget, HostVO cbtHost) { + StoragePoolVO pool = storageTarget.getPool(); + String probeName = requiresBlockDeviceStorageSupport(storageTarget) + ? getBlockDeviceProbeVolumeName() + : RBD_PROBE_IMAGE_PREFIX + UUID.randomUUID(); + VmwareCbtRbdProbeCommand command = new VmwareCbtRbdProbeCommand(pool.getPoolType(), pool.getUuid(), probeName); + command.setTargetStorageType(storageTarget.getTargetStorageType()); + command.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + try { + return agentManager.send(cbtHost.getId(), command); + } catch (AgentUnavailableException | OperationTimedoutException e) { + String details = String.format("Unable to run VMware CBT destination storage probe on host %s for storage pool %s: %s", + cbtHost.getName(), pool.getName(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + LOGGER.warn(details, e); + return new Answer(command, false, details); + } + } + + /** + * Probe volume names on block-device pools become LINSTOR resource names with a + * "cs-" prefix; LINSTOR caps resource names at 48 characters, so a short random + * suffix is used instead of the full RBD-style probe name. + */ + private String getBlockDeviceProbeVolumeName() { + return "cbt-probe-" + UUID.randomUUID().toString().substring(0, 8); + } + + private String getStorageProbeFailureMessage(VmwareCbtStorageTarget storageTarget, Answer answer, HostVO cbtHost, StoragePoolVO pool) { + String details = answer == null ? null : answer.getDetails(); + if (StringUtils.isNotBlank(details)) { + return details; + } + String hostName = cbtHost == null ? "selected KVM host" : cbtHost.getName(); + if (requiresBlockDeviceStorageSupport(storageTarget)) { + String poolName = pool == null ? "selected block device storage pool" : pool.getName(); + return String.format("KVM host %s cannot verify access to block device storage pool %s. Verify the CloudStack primary-storage configuration, " + + "that the host is a LINSTOR satellite connected to the pool, controller connectivity and qemu-io availability.", + hostName, poolName); + } + String poolName = pool == null ? "selected RBD storage pool" : pool.getName(); + return String.format("KVM host %s cannot verify access to RBD storage pool %s. Verify CloudStack RBD primary-storage configuration, Ceph monitor connectivity, qemu RBD block-driver support, librados/librbd client library versions, Java RADOS/RBD bindings and Ceph authentication.", + hostName, poolName); + } + + private void populateSourceVmPreflight(VmwareCbtMigrationPreflightResponse response, VmwareSource source, + String sourceVmName, HostVO cbtHost, Long serviceOfferingId, + Map<String, String> details, DataCenterVO zone, + PreflightFindingCollector findings) { + try { + VmwareCbtPreflightInfo preflightInfo = getVmwareCbtMigrationService().getPreflightInfo(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, sourceVmName); + response.setSourceVmMor(preflightInfo.getSourceVmMor()); + response.setChangeTrackingSupported(preflightInfo.getChangeTrackingSupported()); + response.setChangeTrackingEnabled(preflightInfo.getChangeTrackingEnabled()); + response.setConsolidationNeeded(preflightInfo.getConsolidationNeeded()); + response.setExistingSnapshotCount(preflightInfo.getExistingSnapshotCount()); + response.setSourceCpuNumber(preflightInfo.getCpuCores()); + response.setSourceCpuSpeed(preflightInfo.getCpuSpeed()); + response.setSourceMemory(preflightInfo.getMemoryMb()); + response.setSourceGuestOsId(preflightInfo.getOperatingSystemId()); + response.setSourceGuestOs(preflightInfo.getOperatingSystem()); + response.setDisks(createPreflightDiskResponses(preflightInfo.getDisks())); + addSourceVmFindings(preflightInfo, findings); + addWindowsGuestConversionSupportFinding(cbtHost, sourceVmName, preflightInfo, details, findings); + addServiceOfferingResourceFindings(preflightInfo, serviceOfferingId, details, zone, findings); + } catch (RuntimeException e) { + findings.fail("sourceVm.inspect", "vmware", sourceVmName, + sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source)); + } + } + + private void validateWindowsGuestConversionSupportForStart(HostVO cbtHost, String sourceVmName, + VmwareCbtPreflightInfo preflightInfo, + Map<String, String> details) { + if (!isWindowsSourceVm(preflightInfo)) { + return; + } + CheckConvertInstanceAnswer answer = checkWindowsGuestConversionSupportOnHost(cbtHost, sourceVmName, details); + if (!answer.getResult()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + getWindowsGuestConversionSupportError(cbtHost, sourceVmName, answer)); + } + } + + private void addWindowsGuestConversionSupportFinding(HostVO cbtHost, String sourceVmName, + VmwareCbtPreflightInfo preflightInfo, + Map<String, String> details, + PreflightFindingCollector findings) { + if (!isWindowsSourceVm(preflightInfo)) { + return; + } + if (cbtHost == null) { + findings.fail("destinationHost.windowsGuestConversionSupport", "host", null, + "No suitable KVM host was selected to validate Windows guest conversion support."); + return; + } + try { + CheckConvertInstanceAnswer answer = checkWindowsGuestConversionSupportOnHost(cbtHost, sourceVmName, details); + if (answer.getResult()) { + findings.pass("destinationHost.windowsGuestConversionSupport", "host", cbtHost.getUuid(), + String.format("KVM host %s reports Windows guest conversion support.", cbtHost.getName())); + return; + } + findings.fail("destinationHost.windowsGuestConversionSupport", "host", cbtHost.getUuid(), + getWindowsGuestConversionSupportError(cbtHost, sourceVmName, answer)); + } catch (ServerApiException e) { + findings.fail("destinationHost.windowsGuestConversionSupport", "host", cbtHost.getUuid(), + e.getDescription()); + } + } + + private CheckConvertInstanceAnswer checkWindowsGuestConversionSupportOnHost(HostVO cbtHost, String sourceVmName, + Map<String, String> details) { + CheckConvertInstanceCommand command = new CheckConvertInstanceCommand(true, true); + if (MapUtils.isNotEmpty(details)) { + command.setVddkLibDir(StringUtils.trimToNull(details.get(Host.HOST_VDDK_LIB_DIR))); + } + command.setWait(60); + try { + return (CheckConvertInstanceAnswer) agentManager.send(cbtHost.getId(), command); + } catch (AgentUnavailableException | OperationTimedoutException e) { + String error = String.format("Failed to check windows guest conversion support on host %s for VMware CBT migration of source VM %s due to: %s", + cbtHost, sourceVmName, e.getMessage()); + LOGGER.error(error, e); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, error); + } + } + + private String getWindowsGuestConversionSupportError(HostVO cbtHost, String sourceVmName, + CheckConvertInstanceAnswer answer) { + return String.format("The host %s doesn't support conversion of instance %s from VMware to KVM due to: %s", + cbtHost, sourceVmName, StringUtils.defaultIfBlank(answer.getDetails(), "unknown reason")); + } + + static boolean isWindowsSourceVm(VmwareCbtPreflightInfo preflightInfo) { + return preflightInfo != null && + (StringUtils.containsIgnoreCase(preflightInfo.getOperatingSystemId(), "windows") || + StringUtils.containsIgnoreCase(preflightInfo.getOperatingSystem(), "windows")); + } + + private void addSourceVmFindings(VmwareCbtPreflightInfo preflightInfo, PreflightFindingCollector findings) { + String sourceVmName = preflightInfo.getSourceVmName(); + findings.pass("sourceVm.found", "vmware", sourceVmName, "Source VM was found in vCenter."); + + if (Boolean.FALSE.equals(preflightInfo.getChangeTrackingSupported())) { + findings.fail("vmCapability.changeTrackingSupported", "vmware", sourceVmName, + "Source VM does not report VMware CBT support."); + } else if (Boolean.TRUE.equals(preflightInfo.getChangeTrackingSupported())) { + findings.pass("vmCapability.changeTrackingSupported", "vmware", sourceVmName, + "Source VM reports VMware CBT support."); + } else { + findings.warn("vmCapability.changeTrackingSupported", "vmware", sourceVmName, + "Source VM CBT capability could not be determined."); + } + + if (Boolean.TRUE.equals(preflightInfo.getChangeTrackingEnabled())) { + findings.pass("vmConfig.changeTrackingEnabled", "vmware", sourceVmName, + "CBT is already enabled on the source VM."); + } else if (Boolean.FALSE.equals(preflightInfo.getChangeTrackingEnabled())) { + findings.warn("vmConfig.changeTrackingEnabled", "vmware", sourceVmName, + "CBT is not currently enabled; startVmwareCbtMigration will enable it before creating the baseline snapshot."); + } else { + findings.warn("vmConfig.changeTrackingEnabled", "vmware", sourceVmName, + "CBT enabled state could not be determined."); + } + + if (Boolean.TRUE.equals(preflightInfo.getConsolidationNeeded())) { + findings.fail("vmRuntime.consolidationNeeded", "vmware", sourceVmName, + "Source VM reports that disk consolidation is needed."); + } else if (Boolean.FALSE.equals(preflightInfo.getConsolidationNeeded())) { + findings.pass("vmRuntime.consolidationNeeded", "vmware", sourceVmName, + "Source VM does not report pending disk consolidation."); + } else { + findings.warn("vmRuntime.consolidationNeeded", "vmware", sourceVmName, + "Source VM consolidation state could not be determined."); + } + + if (preflightInfo.getExistingSnapshotCount() != null && preflightInfo.getExistingSnapshotCount() > 0) { + findings.warn("vmSnapshot.existingSnapshots", "vmware", sourceVmName, + String.format("Source VM has %s existing VMware snapshot(s). Initial full copy may still work, but CBT migration is safer after snapshot consolidation.", + preflightInfo.getExistingSnapshotCount())); + } else { + findings.pass("vmSnapshot.existingSnapshots", "vmware", sourceVmName, + "Source VM has no existing VMware snapshots."); + } + + if (CollectionUtils.isEmpty(preflightInfo.getDisks())) { + findings.fail("sourceVm.disks.present", "vmware", sourceVmName, + "No source VMware disks were discovered."); + return; + } + for (VmwareCbtPreflightDiskInfo disk : preflightInfo.getDisks()) { + addSourceDiskFindings(disk, findings); + } + } + + private void addServiceOfferingResourceFindings(VmwareCbtPreflightInfo preflightInfo, Long serviceOfferingId, + Map<String, String> details, DataCenterVO zone, + PreflightFindingCollector findings) { + if (serviceOfferingId == null) { + findings.warn("serviceOffering.present", "serviceoffering", null, + "No service offering was provided; source VM CPU and memory sizing was not validated."); + return; + } + + Account caller = CallContext.current().getCallingAccount(); + ServiceOfferingVO serviceOffering = getPreflightServiceOffering(serviceOfferingId, caller, zone, findings); + if (serviceOffering == null) { + return; + } + + VmwareCbtOfferingResources requestedResources; + try { + requestedResources = resolveRequestedOfferingResources(serviceOffering, details); + } catch (ServerApiException e) { + findings.fail("serviceOffering.resources.valid", "serviceoffering", serviceOffering.getUuid(), e.getDescription()); + return; + } + + addServiceOfferingResourceFinding(preflightInfo.getCpuCores(), requestedResources.getCpuNumber(), + "serviceOffering.cpuNumber", "CPU number", serviceOffering, findings); + addServiceOfferingResourceFinding(preflightInfo.getCpuSpeed(), requestedResources.getCpuSpeed(), + "serviceOffering.cpuSpeed", "CPU speed", serviceOffering, findings); + addServiceOfferingResourceFinding(preflightInfo.getMemoryMb(), requestedResources.getMemoryMb(), + "serviceOffering.memory", "Memory", serviceOffering, findings); + } + + private ServiceOfferingVO getPreflightServiceOffering(Long serviceOfferingId, Account caller, DataCenterVO zone, + PreflightFindingCollector findings) { + ServiceOfferingVO serviceOffering = serviceOfferingDao.findById(serviceOfferingId); + if (serviceOffering == null) { + findings.fail("serviceOffering.present", "serviceoffering", String.valueOf(serviceOfferingId), + String.format("Service offering ID %d cannot be found.", serviceOfferingId)); + return null; + } + try { + if (caller != null && zone != null) { + accountService.checkAccess(caller, serviceOffering, zone); + } + serviceOfferingDao.loadDetails(serviceOffering); + findings.pass("serviceOffering.present", "serviceoffering", serviceOffering.getUuid(), + "Service offering was found."); + return serviceOffering; + } catch (RuntimeException e) { + findings.fail("serviceOffering.access", "serviceoffering", serviceOffering.getUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + return null; + } + } + + private void addServiceOfferingResourceFinding(Integer sourceResource, Integer requestedResource, + String code, String resourceName, ServiceOfferingVO serviceOffering, + PreflightFindingCollector findings) { + if (sourceResource == null || sourceResource <= 0) { + findings.warn(code, "serviceoffering", serviceOffering.getUuid(), + String.format("Source VM %s could not be determined.", resourceName)); + return; + } + if (requestedResource == null || requestedResource <= 0) { + findings.warn(code, "serviceoffering", serviceOffering.getUuid(), + String.format("Selected service offering %s could not be determined.", resourceName)); + return; + } + if (requestedResource < sourceResource) { + findings.fail(code, "serviceoffering", serviceOffering.getUuid(), + String.format("The requested %s (%d) is less than the source VM %s (%d).", + resourceName, requestedResource, resourceName, sourceResource)); + return; + } + findings.pass(code, "serviceoffering", serviceOffering.getUuid(), + String.format("The requested %s (%d) is sufficient for the source VM %s (%d).", + resourceName, requestedResource, resourceName, sourceResource)); + } + + private void addSourceDiskFindings(VmwareCbtPreflightDiskInfo disk, PreflightFindingCollector findings) { + String diskId = StringUtils.defaultIfBlank(disk.getSourceDiskId(), disk.getSourceDiskPath()); + if (disk.getSourceDiskDeviceKey() == null) { + findings.fail("disk.deviceKey.present", "vmware", diskId, + "Source disk device key is missing; CBT changed-area queries require a disk device key."); + } else { + findings.pass("disk.deviceKey.present", "vmware", diskId, + "Source disk device key is available."); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + findings.fail("disk.backing.path.present", "vmware", diskId, + "Source disk backing path is missing."); + } else { + findings.pass("disk.backing.path.present", "vmware", diskId, + "Source disk backing path is available."); + } + if (disk.isIndependentDisk()) { + findings.fail("disk.mode.independent", "vmware", diskId, + String.format("Source disk mode %s is independent; independent disks are not supported for VMware CBT migration.", + disk.getDiskMode())); + } else { + findings.pass("disk.mode.independent", "vmware", diskId, + "Source disk is not configured as independent."); + } + if (disk.isPhysicalRdm()) { + findings.fail("disk.rdm.physical", "vmware", diskId, + "Source disk is a physical-mode RDM; physical RDM disks are not supported for VMware CBT migration."); + } else { + findings.pass("disk.rdm.physical", "vmware", diskId, + "Source disk is not a physical-mode RDM."); + } + if (StringUtils.isBlank(disk.getChangeId())) { + findings.warn("disk.changeId.present", "vmware", diskId, + "A current CBT changeId is not visible for this disk. This can be normal before CloudStack enables CBT and creates the baseline snapshot; startVmwareCbtMigration will validate the baseline snapshot changeId after the initial full sync."); + } else { + findings.pass("disk.changeId.present", "vmware", diskId, + "A current CBT changeId is visible for this disk."); + } + } + + private List<VmwareCbtMigrationPreflightDiskResponse> createPreflightDiskResponses(List<VmwareCbtPreflightDiskInfo> disks) { + List<VmwareCbtMigrationPreflightDiskResponse> responses = new ArrayList<>(); + if (CollectionUtils.isEmpty(disks)) { + return responses; + } + for (VmwareCbtPreflightDiskInfo disk : disks) { + VmwareCbtMigrationPreflightDiskResponse response = new VmwareCbtMigrationPreflightDiskResponse(); + response.setObjectName("disk"); + response.setSourceDiskId(disk.getSourceDiskId()); + response.setSourceDiskDeviceKey(disk.getSourceDiskDeviceKey()); + response.setSourceDiskPath(disk.getSourceDiskPath()); + response.setDatastoreName(disk.getDatastoreName()); + response.setCapacityBytes(disk.getCapacityBytes()); + response.setBackingType(disk.getBackingType()); + response.setDiskMode(disk.getDiskMode()); + response.setRdmCompatibilityMode(disk.getRdmCompatibilityMode()); + response.setIndependentDisk(disk.isIndependentDisk()); + response.setPhysicalRdm(disk.isPhysicalRdm()); + response.setChangeIdPresent(StringUtils.isNotBlank(disk.getChangeId())); + responses.add(response); + } + return responses; + } + + private VmwareSource resolveVmwareSource(StartVmwareCbtMigrationCmd cmd) { + return resolveVmwareSource(cmd.getExistingVcenterId(), cmd.getVcenter(), cmd.getDatacenterName(), + cmd.getUsername(), cmd.getPassword(), cmd.getSourceHost()); + } + + private VmwareSource resolveVmwareSource(Long existingVcenterId, String vcenter, String datacenterName, + String username, String password, String sourceHost) { + if ((existingVcenterId == null && StringUtils.isBlank(vcenter)) || + (existingVcenterId != null && StringUtils.isNotBlank(vcenter))) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Please provide an existing vCenter ID or a vCenter IP/Name, parameters are mutually exclusive"); + } + + if (existingVcenterId != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(existingVcenterId); + if (existingDc == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find any existing VMware datacenter with ID %s", existingVcenterId)); + } + return new VmwareSource(existingDc.getId(), existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + existingDc.getUser(), existingDc.getPassword(), sourceHost); + } + + if (StringUtils.isAnyBlank(vcenter, datacenterName, username, password)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please set all the information for a vCenter IP/Name, datacenter, username and password"); + } + return new VmwareSource(null, vcenter, datacenterName, username, password, sourceHost); + } + + private VmwareSource resolveVmwareSource(VmwareCbtMigrationVO migration, String username, String password) { + if (migration.getExistingVcenterId() != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(migration.getExistingVcenterId()); + if (existingDc == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find any existing VMware datacenter with ID %s", + migration.getExistingVcenterId())); + } + return new VmwareSource(existingDc.getId(), existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + existingDc.getUser(), existingDc.getPassword(), migration.getSourceHost()); + } + + boolean usernameOverrideProvided = StringUtils.isNotBlank(username); + boolean passwordOverrideProvided = StringUtils.isNotBlank(password); + if (usernameOverrideProvided != passwordOverrideProvided) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please provide both source vCenter username and password when overriding stored VMware CBT migration credentials"); + } + + String resolvedUsername = usernameOverrideProvided ? username : migration.getSourceUsername(); + String resolvedPassword = passwordOverrideProvided ? password : migration.getSourcePassword(); + if (StringUtils.isAnyBlank(migration.getVcenter(), migration.getDatacenter(), resolvedUsername, resolvedPassword)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please provide source vCenter username and password for this VMware CBT migration"); + } + + if (usernameOverrideProvided) { + migration.setSourceUsername(resolvedUsername); + migration.setSourcePassword(resolvedPassword); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + return new VmwareSource(null, migration.getVcenter(), migration.getDatacenter(), resolvedUsername, resolvedPassword, + migration.getSourceHost()); + } + + private void storeExternalVmwareSourceCredentials(VmwareCbtMigrationVO migration, VmwareSource source) { + if (source.existingVcenterId != null) { + return; + } + migration.setSourceUsername(source.username); + migration.setSourcePassword(source.password); + } + + private void clearStoredSourceCredentials(VmwareCbtMigrationVO migration) { + if (migration == null || + (StringUtils.isBlank(migration.getSourceUsername()) && StringUtils.isBlank(migration.getSourcePassword()))) { + return; + } + migration.setSourceUsername(null); + migration.setSourcePassword(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + + private VmwareCbtMigration.State parseState(String state) { + if (StringUtils.isBlank(state)) { + return null; + } + try { + return VmwareCbtMigration.State.getValue(state); + } catch (IllegalArgumentException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } + } + + private VmwareCbtMigrationVO getMigration(Long id) { + VmwareCbtMigrationVO migration = vmwareCbtMigrationDao.findById(id); + if (migration == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find VMware CBT migration with ID %s", id)); + } + return migration; + } + + private void rejectTerminalMigration(VmwareCbtMigrationVO migration, String action) { + if (migration.getState().isTerminal()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot %s VMware CBT migration %s because it is already in state %s", + action, migration.getUuid(), migration.getState())); + } + } + + private void requireMigrationState(VmwareCbtMigrationVO migration, String action, + VmwareCbtMigration.State... allowedStates) { + for (VmwareCbtMigration.State allowedState : allowedStates) { + if (migration.getState() == allowedState) { + return; + } + } + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot %s VMware CBT migration %s while it is in state %s. Expected state: %s", + action, migration.getUuid(), migration.getState(), StringUtils.join(allowedStates, ", "))); + } + + private List<VmwareCbtDiskInfo> discoverSourceDisks(VmwareSource source, String sourceVmName) { + List<VmwareCbtDiskInfo> sourceDisks = getVmwareCbtMigrationService().listSourceDisks(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + if (CollectionUtils.isEmpty(sourceDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No source disks were discovered for VMware VM %s", sourceVmName)); + } + return sourceDisks; + } + + private void persistSourceDisks(VmwareCbtMigrationVO migration, List<VmwareCbtDiskInfo> sourceDisks) { + for (VmwareCbtDiskInfo sourceDisk : sourceDisks) { + VmwareCbtMigrationDiskVO disk = new VmwareCbtMigrationDiskVO(migration.getId(), + StringUtils.defaultIfBlank(sourceDisk.getSourceDiskId(), sourceDisk.getLabel()), + sourceDisk.getSourceDiskDeviceKey(), sourceDisk.getSourceDiskPath(), sourceDisk.getDatastoreName(), + sourceDisk.getCapacityBytes()); + disk.setChangeId(sourceDisk.getChangeId()); + disk.setTargetFormat("qcow2"); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.persist(disk); + } + } + + private void prepareInitialSyncDiskTargets(VmwareCbtMigrationVO migration, VmwareCbtStorageTarget storageTarget) { + if (storageTarget == null) { + return; + } + + String targetBasePath = storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.QCOW2_FILE ? + getQcow2InitialSyncTargetBasePath(migration, storageTarget) : null; + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + String targetFormat = getInitialSyncTargetFormat(storageTarget); + disk.setTargetFormat(targetFormat); + if (StringUtils.isBlank(disk.getTargetPath())) { + switch (storageTarget.getTargetStorageType()) { + case RBD_RAW: + disk.setTargetPath(getRbdInitialSyncTargetImageName(migration, disk)); + break; + case RAW_BLOCK_DEVICE: + disk.setTargetPath(getBlockDeviceInitialSyncTargetName(migration, disk)); + break; + default: + disk.setTargetPath(String.format("%s/%s", targetBasePath, getTargetDiskFileName(disk, targetFormat))); + break; + } + } + disk.setState(VmwareCbtMigrationDisk.State.Syncing); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + + private String getInitialSyncTargetFormat(VmwareCbtStorageTarget storageTarget) { + if (storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return "qcow2"; + } + + private String getQcow2InitialSyncTargetBasePath(VmwareCbtMigrationVO migration, VmwareCbtStorageTarget storageTarget) { + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + if (storagePool == null) { + return String.format("%s/%s", DEFAULT_CBT_DISK_BASE_PATH, migration.getUuid()); + } + return String.format("%s/%s/cloudstack-cbt/%s", KVM_STORAGE_POOL_MOUNT_BASE_PATH, storagePool.getUuid(), + migration.getUuid()); + } + + private String getTargetDiskFileName(VmwareCbtMigrationDiskVO disk, String targetFormat) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getSourceDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getSourceDiskId()), "."); + return String.format("%s-%s.%s", sanitizeFileName(disk.getSourceDiskId()), sanitizeFileName(sourceName), + sanitizeFileName(targetFormat)); + } + + private String getRbdInitialSyncTargetImageName(VmwareCbtMigrationVO migration, VmwareCbtMigrationDiskVO disk) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getSourceDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getSourceDiskId()), "."); + return String.format("cloudstack-cbt-%s-%s-%s", sanitizeFileName(migration.getUuid()), + sanitizeFileName(disk.getSourceDiskId()), sanitizeFileName(sourceName)); + } + + /** + * Block-device target names become LINSTOR resource names with a "cs-" prefix and + * LINSTOR caps resource names at 48 characters, so the RBD-style naming scheme with + * the full migration UUID does not fit. A short migration marker plus the source + * disk ID keeps the name unique, identifiable for cleanup and within the limit. + */ + private String getBlockDeviceInitialSyncTargetName(VmwareCbtMigrationVO migration, VmwareCbtMigrationDiskVO disk) { + String name = String.format("%s%s", getBlockDeviceTargetMarker(migration.getUuid()), + sanitizeFileName(disk.getSourceDiskId())); + return name.length() > 44 ? name.substring(0, 44) : name; + } + + static String getBlockDeviceTargetMarker(String migrationUuid) { + return String.format("cbt-%s-", StringUtils.defaultString(migrationUuid).replace("-", "").substring(0, 8)); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private VmwareCbtMigrationResponse runInitialFullSync(VmwareSource source, VmwareCbtMigrationVO migration, + VmwareCbtStorageTarget storageTarget) { + VmwareCbtSnapshotInfo baselineSnapshot = null; + try { + migration.setCurrentStep("Creating VMware CBT baseline snapshot"); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "baseline snapshot progress"); + + baselineSnapshot = createBaselineSnapshot(source, migration); + prepareInitialSyncDiskTargets(migration, storageTarget); + + migration.setCurrentStep("Initial VDDK full sync is running on KVM agent"); + migration.setLastError(null); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "initial full sync progress"); + + HostVO cbtHost = getCbtHostForMigration(migration); + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + VmwareCbtPrepareCommand prepareCommand = new VmwareCbtPrepareCommand(migration.getUuid(), + createRemoteInstance(source, migration, baselineSnapshot.getSourceVmMor()), getDiskTransferObjects(migration), + storagePool == null ? null : storagePool.getPoolType(), + storagePool == null ? null : storagePool.getUuid(), + storageTarget == null ? null : storageTarget.getTargetStorageType(), + baselineSnapshot.getSnapshotMor()); + applyVddkDetails(prepareCommand, migration); + prepareCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, prepareCommand, "prepare", + migration.getUuid()); + if (!answer.getResult()) { + markInitialSyncDisksFailed(migration); + markMigrationFailed(migration, "Initial VDDK full sync failed", sanitizeSensitiveMessage(answer.getDetails(), source)); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + applyDiskResults(migration, answer.getDiskResults()); + migration.setCurrentStep("Recording VMware CBT baseline metadata"); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "baseline metadata progress"); + + refreshBaselineDiskChangeIds(source, migration, baselineSnapshot.getSnapshotMor()); + validateInitialSyncTargetDisks(migration); + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setState(VmwareCbtMigration.State.Replicating); + migration.setCurrentStep("Initial VDDK full sync completed; ready for CBT delta synchronization"); + migration.setLastError(null); + migration.setUpdated(new Date()); + migration = persistMigrationProgress(migration, "initial full sync result"); + return createVmwareCbtMigrationResponse(migration); + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + markInitialSyncDisksFailed(migration); + markMigrationFailed(migration, "Initial VDDK full sync failed", error); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } finally { + removeDeltaSnapshotIfPossible(source, migration, baselineSnapshot); + } + } + + private VmwareCbtSnapshotInfo createBaselineSnapshot(VmwareSource source, VmwareCbtMigrationVO migration) { + String snapshotName = String.format("cloudstack-cbt-%s-baseline", migration.getUuid()); + String description = String.format("CloudStack VMware CBT migration %s baseline", migration.getUuid()); + return getVmwareCbtMigrationService().createSnapshot(source.vcenter, source.datacenterName, source.username, + source.password, source.sourceHost, migration.getSourceVmName(), snapshotName, description, false); + } + + private void refreshBaselineDiskChangeIds(VmwareSource source, VmwareCbtMigrationVO migration, + String snapshotMor) { + List<VmwareCbtDiskInfo> sourceDisks = getVmwareCbtMigrationService().listSnapshotDisks(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName(), snapshotMor); + if (CollectionUtils.isEmpty(sourceDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No source disks were discovered in VMware CBT baseline snapshot %s for VM %s", + snapshotMor, migration.getSourceVmName())); + } + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + VmwareCbtDiskInfo sourceDisk = findSourceDisk(sourceDisks, disk); + if (sourceDisk == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("Unable to refresh VMware CBT baseline metadata for source disk %s", + disk.getSourceDiskId())); + } + if (sourceDisk.getSourceDiskDeviceKey() != null) { + disk.setSourceDiskDeviceKey(sourceDisk.getSourceDiskDeviceKey()); + } + if (StringUtils.isNotBlank(sourceDisk.getChangeId())) { + disk.setChangeId(sourceDisk.getChangeId()); + } + disk.setSnapshotMor(snapshotMor); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + + private VmwareCbtDiskInfo findSourceDisk(List<VmwareCbtDiskInfo> sourceDisks, VmwareCbtMigrationDiskVO disk) { + for (VmwareCbtDiskInfo sourceDisk : sourceDisks) { + if (StringUtils.equals(sourceDisk.getSourceDiskId(), disk.getSourceDiskId()) || + StringUtils.equals(sourceDisk.getSourceDiskPath(), disk.getSourceDiskPath()) || + (sourceDisk.getSourceDiskDeviceKey() != null && + sourceDisk.getSourceDiskDeviceKey().equals(disk.getSourceDiskDeviceKey()))) { + return sourceDisk; + } + } + return null; + } + + private void validateInitialSyncTargetDisks(VmwareCbtMigrationVO migration) { + List<VmwareCbtMigrationDiskVO> disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + if (CollectionUtils.isEmpty(disks)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s has no discovered source disks", migration.getUuid())); + } + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s cannot run delta sync before initial full sync registers target disk path for source disk %s", + migration.getUuid(), disk.getSourceDiskId())); + } + if (StringUtils.isBlank(disk.getChangeId())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s completed initial full sync but cannot run delta sync before CloudStack records a baseline change ID for source disk %s", + migration.getUuid(), disk.getSourceDiskId())); + } + } + } + + private VmwareCbtSnapshotInfo createDeltaSnapshot(VmwareSource source, VmwareCbtMigrationVO migration, + int cycleNumber) { + String snapshotName = String.format("cloudstack-cbt-%s-%s", migration.getUuid(), cycleNumber); + String description = String.format("CloudStack VMware CBT migration %s cycle %s", migration.getUuid(), + cycleNumber); + return getVmwareCbtMigrationService().createSnapshot(source.vcenter, source.datacenterName, source.username, + source.password, source.sourceHost, migration.getSourceVmName(), snapshotName, description, false); + } + + private VmwareCbtChangedBlockQueryResult queryChangedBlocks(VmwareSource source, VmwareCbtMigrationVO migration, + String snapshotMor) { + List<VmwareCbtMigrationDiskVO> disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + List<VmwareCbtDiskInfo> sourceDisks = new ArrayList<>(); + for (VmwareCbtMigrationDiskVO disk : disks) { + sourceDisks.add(new VmwareCbtDiskInfo(disk.getSourceDiskId(), disk.getSourceDiskDeviceKey(), null, + disk.getSourceDiskPath(), disk.getDatastoreName(), disk.getCapacityBytes(), disk.getChangeId())); + disk.setSnapshotMor(snapshotMor); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + + List<VmwareCbtChangedDiskInfo> changedDisks = getVmwareCbtMigrationService().queryChangedDiskAreas(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName(), sourceDisks, snapshotMor); + List<VmwareCbtChangedBlockRangeTO> changedBlocks = new ArrayList<>(); + for (VmwareCbtChangedDiskInfo changedDisk : changedDisks) { + for (VmwareCbtChangedBlockInfo changedBlock : changedDisk.getChangedBlocks()) { + changedBlocks.add(new VmwareCbtChangedBlockRangeTO(changedDisk.getSourceDiskId(), + changedBlock.getStartOffset(), changedBlock.getLength())); + } + } + return new VmwareCbtChangedBlockQueryResult(changedBlocks, changedDisks); + } + + private void updateDiskChangeIds(VmwareCbtMigrationVO migration, List<VmwareCbtChangedDiskInfo> changedDisks) { + List<VmwareCbtMigrationDiskVO> disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + for (VmwareCbtChangedDiskInfo changedDisk : changedDisks) { + if (StringUtils.isNotBlank(changedDisk.getNextChangeId()) && Review Comment: As requested, the else block does not make much sense: a plain else would log on every non-matching disk pair in the nested loop. With two disks, for example, each disk is compared with both results, so it would generate misleading noise. Also, 0 changed blocks and nextChangeId are different things: - A cycle with 0 changed blocks is valid, although uncommon for a running VM. - VMware should still return a non-blank nextChangeId. - A blank nextChangeId would indicate an abnormal VMware response or implementation problem. So for now - no, I prefer it stays as it is, but we can think of improvement is a follow-up PR. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
