diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 9670b6e7c13a..720a89a5e599 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -39,3 +39,23 @@ example.ver.1 > example.ver.2: which can now be attached to Instances. This is to prevent the Secondary Storage to grow to enormous sizes as Linux Distributions keep growing in size while a stripped down Linux should fit on a 2.88MB floppy. + +4.23.0.0: + * VMware-to-KVM import using VDDK can import powered-off VMware VMs directly + into Ceph RBD primary storage when forceconverttopool is enabled and the + selected KVM conversion host supports qemu RBD access and in-place virt-v2v + finalization. Hosts without in-place finalization support can still use the + staged VDDK flow, converting to temporary qcow2 storage before copying the + finalized disks into RBD. + + * Linstor primary storage can now be the destination for VMware-to-KVM + imports in all three modes. Staged imports (virt-v2v to a temporary NFS + location) copy the converted disks into pre-created DRBD block devices + through the Linstor storage adaptor; the import host must be a LINSTOR + satellite connected to the destination pool. Direct VDDK imports with + forceconverttopool write each powered-off VMware disk over nbdkit/VDDK + straight into the DRBD device and finalize it with virt-v2v in place. + VMware CBT warm migrations replicate the initial full copy and all + incremental changed-block cycles directly into Linstor volumes and + finalize in place at cutover; the conversion host requires VDDK, + in-place virt-v2v support and access to the Linstor pool. diff --git a/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java b/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java index 7daeb9649177..8aec16d4b101 100644 --- a/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java +++ b/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java @@ -38,6 +38,7 @@ public class RemoteInstanceTO implements Serializable { private String datacenterName; private String clusterName; private String hostName; + private String vmwareMoref; public RemoteInstanceTO() { } @@ -65,6 +66,11 @@ public RemoteInstanceTO(String instanceName, String instancePath, String vcenter this.hostName = hostName; } + public RemoteInstanceTO(String instanceName, String instancePath, String vcenterHost, String vcenterUsername, String vcenterPassword, String datacenterName, String clusterName, String hostName, String vmwareMoref) { + this(instanceName, instancePath, vcenterHost, vcenterUsername, vcenterPassword, datacenterName, clusterName, hostName); + this.vmwareMoref = vmwareMoref; + } + public Hypervisor.HypervisorType getHypervisorType() { return this.hypervisorType; } @@ -100,4 +106,12 @@ public String getClusterName() { public String getHostName() { return hostName; } + + public String getVmwareMoref() { + return vmwareMoref; + } + + public void setVmwareMoref(String vmwareMoref) { + this.vmwareMoref = vmwareMoref; + } } diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java new file mode 100644 index 000000000000..ee8fcc59a290 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtChangedBlockRangeTO implements Serializable { + + private String diskId; + private long startOffset; + private long length; + + public VmwareCbtChangedBlockRangeTO() { + } + + public VmwareCbtChangedBlockRangeTO(String diskId, long startOffset, long length) { + this.diskId = diskId; + this.startOffset = startOffset; + this.length = length; + } + + public String getDiskId() { + return diskId; + } + + public long getStartOffset() { + return startOffset; + } + + public long getLength() { + return length; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java new file mode 100644 index 000000000000..c22ea0bb24c0 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtDiskSyncResultTO implements Serializable { + + private String diskId; + private String targetPath; + private String changeId; + private String snapshotMor; + private long changedBytes; + private long durationSeconds; + private boolean result; + private String details; + + public VmwareCbtDiskSyncResultTO() { + } + + public VmwareCbtDiskSyncResultTO(String diskId, String targetPath, String changeId, String snapshotMor, + long changedBytes, long durationSeconds, boolean result, String details) { + this.diskId = diskId; + this.targetPath = targetPath; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + this.changedBytes = changedBytes; + this.durationSeconds = durationSeconds; + this.result = result; + this.details = details; + } + + public String getDiskId() { + return diskId; + } + + public String getTargetPath() { + return targetPath; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public long getChangedBytes() { + return changedBytes; + } + + public long getDurationSeconds() { + return durationSeconds; + } + + public boolean getResult() { + return result; + } + + public String getDetails() { + return details; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java new file mode 100644 index 000000000000..e6dcf1bfbca6 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtDiskTO implements Serializable { + + private String diskId; + private Integer diskDeviceKey; + private String sourceDiskPath; + private String datastoreName; + private String targetPath; + private String targetFormat; + private String changeId; + private String snapshotMor; + private long capacityBytes; + + public VmwareCbtDiskTO() { + } + + public VmwareCbtDiskTO(String diskId, Integer diskDeviceKey, String sourceDiskPath, String datastoreName, + String targetPath, String targetFormat, String changeId, String snapshotMor, + long capacityBytes) { + this.diskId = diskId; + this.diskDeviceKey = diskDeviceKey; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.targetPath = targetPath; + this.targetFormat = targetFormat; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + this.capacityBytes = capacityBytes; + } + + public String getDiskId() { + return diskId; + } + + public Integer getDiskDeviceKey() { + return diskDeviceKey; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public String getTargetPath() { + return targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public long getCapacityBytes() { + return capacityBytes; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java new file mode 100644 index 000000000000..b980c611abc9 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareVddkSourceDiskTO implements Serializable { + + private String diskId; + private String sourceDiskPath; + private long capacityBytes; + private Integer position; + + public VmwareVddkSourceDiskTO() { + } + + public VmwareVddkSourceDiskTO(String diskId, String sourceDiskPath, long capacityBytes, Integer position) { + this.diskId = diskId; + this.sourceDiskPath = sourceDiskPath; + this.capacityBytes = capacityBytes; + this.position = position; + } + + public String getDiskId() { + return diskId; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public long getCapacityBytes() { + return capacityBytes; + } + + public Integer getPosition() { + return position; + } +} diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index f7d13343d469..47a0bda3b1d2 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -127,6 +127,9 @@ public class EventTypes { public static final String EVENT_VM_RESTORE = "VM.RESTORE"; public static final String EVENT_VM_EXPUNGE = "VM.EXPUNGE"; public static final String EVENT_VM_IMPORT = "VM.IMPORT"; + public static final String EVENT_VMWARE_CBT_MIGRATION_START = "VMWARE.CBT.MIGRATION.START"; + public static final String EVENT_VMWARE_CBT_MIGRATION_SYNC = "VMWARE.CBT.MIGRATION.SYNC"; + public static final String EVENT_VMWARE_CBT_MIGRATION_CUTOVER = "VMWARE.CBT.MIGRATION.CUTOVER"; public static final String EVENT_VM_UNMANAGE = "VM.UNMANAGE"; public static final String EVENT_VM_RECOVER = "VM.RECOVER"; diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index c110e4ca94e1..427a74c81b71 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -60,8 +60,18 @@ public static String[] toStrings(Host.Type... types) { String HOST_VDDK_SUPPORT = "host.vddk.support"; String HOST_VDDK_LIB_DIR = "vddk.lib.dir"; String HOST_VDDK_VERSION = "host.vddk.version"; + String HOST_VDDK_BLOCKCOPY_SUPPORT = "host.vddk.blockcopy.support"; + String HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT = "host.vddk.blockcopy.inplace.finalization.support"; + String HOST_VDDK_BLOCKCOPY_RBD_SUPPORT = "host.vddk.blockcopy.rbd.support"; + String HOST_QEMU_IMG_VERSION = "host.qemu.img.version"; + String HOST_QEMU_NBD_VERSION = "host.qemu.nbd.version"; + String HOST_QEMU_IO_VERSION = "host.qemu.io.version"; String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; + String HOST_VIRTV2V_INPLACE_VERSION = "host.virtv2v.inplace.version"; + String HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT = "host.vddk.rbd.direct.import.support"; + String HOST_VIRTV2V_INPLACE_SUPPORT = "host.virtv2v.inplace.support"; + String HOST_QEMU_RBD_SUPPORT = "host.qemu.rbd.support"; String HOST_SSH_PORT = "host.ssh.port"; String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count"; String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index ac6acdf42516..693c9696222b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -652,6 +652,29 @@ public class ApiConstants { public static final String USER_SECURITY_GROUP_LIST = "usersecuritygrouplist"; public static final String USER_SECRET_KEY = "usersecretkey"; public static final String USE_VDDK = "usevddk"; + public static final String VMWARE_MIGRATION_MODE = "vmwaremigrationmode"; + public static final String VMWARE_CBT_MIGRATION_ID = "vmwarecbtmigrationid"; + public static final String SOURCE_HOST = "sourcehost"; + public static final String SOURCE_CLUSTER = "sourcecluster"; + public static final String SOURCE_VM_NAME = "sourcevmname"; + public static final String SOURCE_DISK_ID = "sourcediskid"; + public static final String SOURCE_DISK_DEVICE_KEY = "sourcediskdevicekey"; + public static final String SOURCE_DISK_PATH = "sourcediskpath"; + public static final String TARGET_PATH = "targetpath"; + public static final String TARGET_FORMAT = "targetformat"; + public static final String CHANGE_ID = "changeid"; + public static final String SNAPSHOT_MOR = "snapshotmor"; + public static final String CURRENT_STEP = "currentstep"; + public static final String LAST_ERROR = "lasterror"; + public static final String COMPLETED_CYCLES = "completedcycles"; + public static final String QUIET_CYCLES = "quietcycles"; + public static final String LAST_CHANGED_BYTES = "lastchangedbytes"; + public static final String LAST_DIRTY_RATE = "lastdirtyrate"; + public static final String TOTAL_CHANGED_BYTES = "totalchangedbytes"; + public static final String CYCLE = "cycle"; + public static final String CYCLE_NUMBER = "cyclenumber"; + public static final String CHANGED_BYTES = "changedbytes"; + public static final String DIRTY_RATE = "dirtyrate"; public static final String USE_VIRTUAL_NETWORK = "usevirtualnetwork"; public static final String USE_VIRTUAL_ROUTER_IP_RESOLVER = "userouteripresolver"; public static final String UPDATE_IN_SEQUENCE = "updateinsequence"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..872557c9d90a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java @@ -0,0 +1,73 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "cancelVmwareCbtMigration", + description = "Cancel a VMware CBT migration session", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class CancelVmwareCbtMigrationCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + public Long getId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.cancelVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java new file mode 100644 index 000000000000..de00c9f423fd --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java @@ -0,0 +1,204 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ClusterResponse; +import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.ServiceOfferingResponse; +import org.apache.cloudstack.api.response.StoragePoolResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightResponse; +import org.apache.cloudstack.api.response.VmwareDatacenterResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; +import org.apache.commons.collections.MapUtils; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "checkVmwareCbtMigrationPrerequisites", + description = "Check whether a VMware VM and KVM destination are ready for VMware CBT based migration", + responseObject = VmwareCbtMigrationPreflightResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class CheckVmwareCbtMigrationPrerequisitesCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + required = true, description = "the destination zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.CLUSTER_ID, type = CommandType.UUID, entityType = ClusterResponse.class, + required = true, description = "the destination KVM cluster ID") + private Long clusterId; + + @Parameter(name = ApiConstants.SOURCE_VM_NAME, type = CommandType.STRING, + required = true, description = "the source VMware VM name") + private String sourceVmName; + + @Parameter(name = ApiConstants.EXISTING_VCENTER_ID, type = CommandType.UUID, entityType = VmwareDatacenterResponse.class, + description = "UUID of a linked existing vCenter") + private Long existingVcenterId; + + @Parameter(name = ApiConstants.VCENTER, type = CommandType.STRING, + description = "the source vCenter IP address or FQDN") + private String vcenter; + + @Parameter(name = ApiConstants.DATACENTER_NAME, type = CommandType.STRING, + description = "the source VMware datacenter name") + private String datacenterName; + + @Parameter(name = ApiConstants.CLUSTER_NAME, type = CommandType.STRING, + description = "the source VMware cluster name") + private String sourceCluster; + + @Parameter(name = ApiConstants.HOST_IP, type = CommandType.STRING, + description = "the source VMware ESXi host IP address or FQDN") + private String sourceHost; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "the username for the source vCenter") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "the password for the source vCenter") + private String password; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, + description = "optional KVM host to perform CBT block replication") + private Long convertInstanceHostId; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, + description = "optional primary storage pool for converted disks") + private Long storagePoolId; + + @Parameter(name = ApiConstants.SERVICE_OFFERING_ID, type = CommandType.UUID, entityType = ServiceOfferingResponse.class, + description = "optional service offering to validate against the source VM CPU and memory requirements") + private Long serviceOfferingId; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "optional service offering custom parameters and migration details") + private Map details; + + public Long getZoneId() { + return zoneId; + } + + public Long getClusterId() { + return clusterId; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenterName() { + return datacenterName; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public Long getConvertInstanceHostId() { + return convertInstanceHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + @SuppressWarnings("unchecked") + public Map getDetails() { + Map params = new HashMap<>(); + if (MapUtils.isEmpty(details)) { + return params; + } + + for (Object value : details.values()) { + if (!(value instanceof Map)) { + continue; + } + Map detailMap = (Map)value; + for (Map.Entry entry : detailMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + params.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + } + return params; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationPreflightResponse response = vmwareCbtMigrationManager.checkVmwareCbtMigrationPrerequisites(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..ca6d83ddd46a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "cutoverVmwareCbtMigration", + description = "Perform final cutover for a VMware CBT migration", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class CutoverVmwareCbtMigrationCmd extends BaseAsyncCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "optional username override for the source vCenter. Stored external vCenter credentials are used when available") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "optional password override for the source vCenter. Stored external vCenter credentials are used when available") + private String password; + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_VMWARE_CBT_MIGRATION_CUTOVER; + } + + @Override + public String getEventDescription() { + return String.format("Cutting over VMware CBT migration: %s", id); + } + + @Override + public String getSyncObjType() { + return BaseAsyncCmd.migrationSyncObject; + } + + @Override + public Long getSyncObjId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.cutoverVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..4bcc419c22c1 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "deleteVmwareCbtMigration", + description = "Deletes a failed, cancelled or completed VMware CBT migration record. Failed and cancelled migrations can optionally clean up replicated target disks; completed migrations are record-only deletes.", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class DeleteVmwareCbtMigrationCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.CLEANUP, type = CommandType.BOOLEAN, + description = "If true, clean up replicated target disks before removing a failed or cancelled migration record. True by default. Ignored for completed migrations.") + private Boolean cleanup; + + public Long getId() { + return id; + } + + public boolean getCleanup() { + return cleanup == null || cleanup; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + boolean result = vmwareCbtMigrationManager.deleteVmwareCbtMigration(this); + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setSuccess(result); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java index db7dcc3fb44f..94249dd92e9b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java @@ -57,6 +57,24 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { @Inject public VmImportService vmImportService; + public enum VmwareMigrationMode { + OVF, + VDDK, + CBT; + + public static VmwareMigrationMode fromValue(String value, boolean useVddkFallback) { + if (StringUtils.isBlank(value)) { + return useVddkFallback ? VDDK : OVF; + } + for (VmwareMigrationMode mode : values()) { + if (mode.name().equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException(String.format("Unsupported VMware migration mode: %s", value)); + } + } + ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// ///////////////////////////////////////////////////// @@ -153,7 +171,8 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { private Long importInstanceHostId; @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, - description = "(only for importing VMs from VMware to KVM) optional - the temporary storage pool to perform the virt-v2v migration from VMware to KVM.") + description = "(only for importing VMs from VMware to KVM) optional - the temporary storage pool to perform the virt-v2v migration from VMware to KVM. " + + "When " + ApiConstants.USE_VDDK + " and " + ApiConstants.FORCE_CONVERT_TO_POOL + " are true, this can be an RBD primary storage pool for direct RBD import.") private Long convertStoragePoolId; @Parameter(name = ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES, type = CommandType.BOOLEAN, @@ -183,9 +202,18 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { type = CommandType.BOOLEAN, since = "4.22.1", description = "(only for importing VMs from VMware to KVM) optional - if true, uses VDDK on the KVM conversion host for converting the VM. " + - "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ".") + "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ". " + + "With " + ApiConstants.FORCE_CONVERT_TO_POOL + "=true and an RBD conversion pool, the source VMware VM must be powered off and " + + "the conversion host must support qemu RBD access and in-place virt-v2v finalization.") private Boolean useVddk; + @Parameter(name = ApiConstants.VMWARE_MIGRATION_MODE, + type = CommandType.STRING, + since = "4.22.1", + description = "(only for importing VMs from VMware to KVM) optional - migration mode to use. Valid values are OVF, VDDK, and CBT. " + + "When omitted, CloudStack preserves the existing usevddk behavior.") + private String vmwareMigrationMode; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// @@ -267,6 +295,10 @@ public boolean getUseVddk() { return BooleanUtils.toBooleanDefaultIfNull(useVddk, true); } + public String getVmwareMigrationMode() { + return vmwareMigrationMode; + } + public String getTmpPath() { return tmpPath; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java new file mode 100644 index 000000000000..577afef1ca43 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java @@ -0,0 +1,109 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; + +@APICommand(name = "listVmwareCbtMigrations", + description = "List VMware CBT based migration sessions", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class ListVmwareCbtMigrationsCmd extends BaseListCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "the destination zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "the account ID") + private Long accountId; + + @Parameter(name = ApiConstants.VCENTER, type = CommandType.STRING, + description = "the source VMware vCenter") + private String vcenter; + + @Parameter(name = ApiConstants.SOURCE_VM_NAME, type = CommandType.STRING, + description = "the source VMware VM name") + private String sourceVmName; + + @Parameter(name = ApiConstants.STATE, type = CommandType.STRING, + description = "the migration state") + private String state; + + public Long getId() { + return id; + } + + public Long getZoneId() { + return zoneId; + } + + public Long getAccountId() { + return accountId; + } + + public String getVcenter() { + return vcenter; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getState() { + return state; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + ListResponse response = vmwareCbtMigrationManager.listVmwareCbtMigrations(this); + response.setResponseName(getCommandName()); + response.setObjectName("vmwarecbtmigration"); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..3c876b3b8bf4 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java @@ -0,0 +1,361 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ClusterResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.GuestOSResponse; +import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.ProjectResponse; +import org.apache.cloudstack.api.response.ServiceOfferingResponse; +import org.apache.cloudstack.api.response.StoragePoolResponse; +import org.apache.cloudstack.api.response.TemplateResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.api.response.VmwareDatacenterResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; +import com.cloud.offering.DiskOffering; +import com.cloud.user.Account; +import com.cloud.utils.net.NetUtils; +import com.cloud.vm.VmDetailConstants; + +@APICommand(name = "startVmwareCbtMigration", + description = "Start tracking a VMware CBT based migration session", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class StartVmwareCbtMigrationCmd extends BaseAsyncCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + required = true, description = "the destination zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.CLUSTER_ID, type = CommandType.UUID, entityType = ClusterResponse.class, + required = true, description = "the destination KVM cluster ID") + private Long clusterId; + + @Parameter(name = ApiConstants.DISPLAY_NAME, type = CommandType.STRING, + description = "the display name of the migrated VM") + private String displayName; + + @Parameter(name = ApiConstants.HOST_NAME, type = CommandType.STRING, + description = "the host name of the migrated VM") + private String hostName; + + @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, + description = "optional account for the migrated VM. Must be used with domainid") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "import the migrated VM to the specified domain") + private Long domainId; + + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, + description = "import the migrated VM for the project") + private Long projectId; + + @Parameter(name = ApiConstants.TEMPLATE_ID, type = CommandType.UUID, entityType = TemplateResponse.class, + description = "the template ID for the migrated VM") + private Long templateId; + + @Parameter(name = ApiConstants.SERVICE_OFFERING_ID, type = CommandType.UUID, entityType = ServiceOfferingResponse.class, + required = true, description = "the service offering for the migrated VM") + private Long serviceOfferingId; + + @Parameter(name = ApiConstants.OS_ID, type = CommandType.UUID, entityType = GuestOSResponse.class, + description = "optional guest OS ID for the migrated VM") + private Long guestOsId; + + @Parameter(name = ApiConstants.SOURCE_VM_NAME, type = CommandType.STRING, + required = true, description = "the source VMware VM name") + private String sourceVmName; + + @Parameter(name = ApiConstants.EXISTING_VCENTER_ID, type = CommandType.UUID, entityType = VmwareDatacenterResponse.class, + description = "UUID of a linked existing vCenter") + private Long existingVcenterId; + + @Parameter(name = ApiConstants.VCENTER, type = CommandType.STRING, + description = "the source vCenter IP address or FQDN") + private String vcenter; + + @Parameter(name = ApiConstants.DATACENTER_NAME, type = CommandType.STRING, + description = "the source VMware datacenter name") + private String datacenterName; + + @Parameter(name = ApiConstants.CLUSTER_NAME, type = CommandType.STRING, + description = "the source VMware cluster name") + private String sourceCluster; + + @Parameter(name = ApiConstants.HOST_IP, type = CommandType.STRING, + description = "the source VMware ESXi host IP address or FQDN") + private String sourceHost; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "the username for the source vCenter") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "the password for the source vCenter") + private String password; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, + description = "optional KVM host to perform virt-v2v conversion and CBT block replication") + private Long convertInstanceHostId; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, + description = "optional primary storage pool for converted disks") + private Long storagePoolId; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "optional migration details, including VDDK settings such as vddk.lib.dir, vddk.transports and vddk.thumbprint") + private Map details; + + @Parameter(name = ApiConstants.NIC_NETWORK_LIST, type = CommandType.MAP, + description = "VM NIC to network id mapping using keys NIC and network") + private Map nicNetworkList; + + @Parameter(name = ApiConstants.NIC_IP_ADDRESS_LIST, type = CommandType.MAP, + description = "VM NIC to ip address mapping using keys NIC, ip4Address") + private Map nicIpAddressList; + + @Parameter(name = ApiConstants.DATADISK_OFFERING_LIST, type = CommandType.MAP, + description = "datadisk to disk-offering mapping using keys disk and diskOffering") + private Map dataDiskToDiskOfferingList; + + @Parameter(name = ApiConstants.FORCED, type = CommandType.BOOLEAN, + description = "import despite duplicate NIC MAC addresses; CloudStack generates a new MAC address when a duplicate exists") + private Boolean forced; + + public Long getZoneId() { + return zoneId; + } + + public Long getClusterId() { + return clusterId; + } + + public String getDisplayName() { + return displayName; + } + + public String getHostName() { + return hostName; + } + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + public Long getProjectId() { + return projectId; + } + + public Long getTemplateId() { + return templateId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + public Long getGuestOsId() { + return guestOsId; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenterName() { + return datacenterName; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public Long getConvertInstanceHostId() { + return convertInstanceHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + @SuppressWarnings("unchecked") + public Map getDetails() { + Map params = new HashMap<>(); + if (MapUtils.isEmpty(details)) { + return params; + } + + for (Object value : details.values()) { + if (!(value instanceof Map)) { + continue; + } + Map detailMap = (Map)value; + for (Map.Entry entry : detailMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + params.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + } + return params; + } + + @SuppressWarnings("unchecked") + public Map getNicNetworkList() { + Map nicNetworkMap = new HashMap<>(); + if (MapUtils.isNotEmpty(nicNetworkList)) { + for (Map entry : (Collection>) nicNetworkList.values()) { + String nic = entry.get(VmDetailConstants.NIC); + String networkUuid = entry.get(VmDetailConstants.NETWORK); + if (StringUtils.isAnyEmpty(nic, networkUuid) || _entityMgr.findByUuid(Network.class, networkUuid) == null) { + throw new InvalidParameterValueException(String.format("Network ID: %s for NIC ID: %s is invalid", networkUuid, nic)); + } + nicNetworkMap.put(nic, _entityMgr.findByUuid(Network.class, networkUuid).getId()); + } + } + return nicNetworkMap; + } + + @SuppressWarnings("unchecked") + public Map getNicIpAddressList() { + Map nicIpAddressMap = new HashMap<>(); + if (MapUtils.isNotEmpty(nicIpAddressList)) { + for (Map entry : (Collection>) nicIpAddressList.values()) { + String nic = entry.get(VmDetailConstants.NIC); + String ipAddress = StringUtils.defaultIfEmpty(entry.get(VmDetailConstants.IP4_ADDRESS), null); + if (StringUtils.isEmpty(nic)) { + throw new InvalidParameterValueException(String.format("NIC ID: '%s' is invalid for IP address mapping", nic)); + } + if (StringUtils.isEmpty(ipAddress)) { + throw new InvalidParameterValueException(String.format("Empty address for NIC ID: %s is invalid", nic)); + } + if (StringUtils.isNotEmpty(ipAddress) && !ipAddress.equals("auto") && !NetUtils.isValidIp4(ipAddress)) { + throw new InvalidParameterValueException(String.format("IP address '%s' for NIC ID: %s is invalid", ipAddress, nic)); + } + nicIpAddressMap.put(nic, new Network.IpAddresses(ipAddress, null)); + } + } + return nicIpAddressMap; + } + + @SuppressWarnings("unchecked") + public Map getDataDiskToDiskOfferingList() { + Map dataDiskToDiskOfferingMap = new HashMap<>(); + if (MapUtils.isNotEmpty(dataDiskToDiskOfferingList)) { + for (Map entry : (Collection>) dataDiskToDiskOfferingList.values()) { + String disk = entry.get(VmDetailConstants.DISK); + String offeringUuid = entry.get(VmDetailConstants.DISK_OFFERING); + if (StringUtils.isAnyEmpty(disk, offeringUuid) || _entityMgr.findByUuid(DiskOffering.class, offeringUuid) == null) { + throw new InvalidParameterValueException(String.format("Disk offering ID: %s for disk ID: %s is invalid", offeringUuid, disk)); + } + dataDiskToDiskOfferingMap.put(disk, _entityMgr.findByUuid(DiskOffering.class, offeringUuid).getId()); + } + } + return dataDiskToDiskOfferingMap; + } + + public boolean isForced() { + return BooleanUtils.isTrue(forced); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_VMWARE_CBT_MIGRATION_START; + } + + @Override + public String getEventDescription() { + return String.format("Starting VMware CBT migration for source VM: %s", sourceVmName); + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.startVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); + if (accountId != null) { + return accountId; + } + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..7f4e59883578 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "syncVmwareCbtMigration", + description = "Run a VMware CBT delta synchronization cycle", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class SyncVmwareCbtMigrationCmd extends BaseAsyncCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "optional username override for the source vCenter. Stored external vCenter credentials are used when available") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "optional password override for the source vCenter. Stored external vCenter credentials are used when available") + private String password; + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_VMWARE_CBT_MIGRATION_SYNC; + } + + @Override + public String getEventDescription() { + return String.format("Synchronizing VMware CBT migration: %s", id); + } + + @Override + public String getSyncObjType() { + return BaseAsyncCmd.migrationSyncObject; + } + + @Override + public Long getSyncObjId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.syncVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java new file mode 100644 index 000000000000..2ceab991b179 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import java.util.Date; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.VmwareCbtMigrationCycle; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = VmwareCbtMigrationCycle.class) +public class VmwareCbtMigrationCycleResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the VMware CBT migration cycle") + private String id; + + @SerializedName(ApiConstants.CYCLE_NUMBER) + @Param(description = "the CBT delta synchronization cycle number") + private int cycleNumber; + + @SerializedName(ApiConstants.SNAPSHOT_MOR) + @Param(description = "the VMware snapshot managed object reference used for this cycle") + private String snapshotMor; + + @SerializedName(ApiConstants.CHANGED_BYTES) + @Param(description = "the changed bytes copied in this cycle") + private Long changedBytes; + + @SerializedName(ApiConstants.DIRTY_RATE) + @Param(description = "the dirty rate in bytes per second observed in this cycle") + private Long dirtyRate; + + @SerializedName(ApiConstants.DURATION) + @Param(description = "the cycle duration in milliseconds") + private Long duration; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the cycle state") + private String state; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "the cycle status or failure description") + private String description; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the create date of the VMware CBT migration cycle") + private Date created; + + @SerializedName(ApiConstants.LAST_UPDATED) + @Param(description = "the last updated date of the VMware CBT migration cycle") + private Date lastUpdated; + + public void setId(String id) { + this.id = id; + } + + public void setCycleNumber(int cycleNumber) { + this.cycleNumber = cycleNumber; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public void setChangedBytes(Long changedBytes) { + this.changedBytes = changedBytes; + } + + public void setDirtyRate(Long dirtyRate) { + this.dirtyRate = dirtyRate; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public void setState(String state) { + this.state = state; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setLastUpdated(Date lastUpdated) { + this.lastUpdated = lastUpdated; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java new file mode 100644 index 000000000000..9a23cf44cad1 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.VmwareCbtMigrationDisk; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = VmwareCbtMigrationDisk.class) +public class VmwareCbtMigrationDiskResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the VMware CBT migration disk") + private String id; + + @SerializedName(ApiConstants.SOURCE_DISK_ID) + @Param(description = "the source VMware disk identifier") + private String sourceDiskId; + + @SerializedName(ApiConstants.SOURCE_DISK_DEVICE_KEY) + @Param(description = "the source VMware disk device key") + private Integer sourceDiskDeviceKey; + + @SerializedName(ApiConstants.SOURCE_DISK_PATH) + @Param(description = "the source VMware disk path") + private String sourceDiskPath; + + @SerializedName(ApiConstants.DATASTORE_NAME) + @Param(description = "the source VMware datastore name") + private String datastoreName; + + @SerializedName(ApiConstants.CAPACITY) + @Param(description = "the source disk capacity in bytes") + private Long capacityBytes; + + @SerializedName(ApiConstants.TARGET_PATH) + @Param(description = "the planned or actual KVM target disk path") + private String targetPath; + + @SerializedName(ApiConstants.TARGET_FORMAT) + @Param(description = "the KVM target disk format") + private String targetFormat; + + @SerializedName(ApiConstants.CHANGE_ID) + @Param(description = "the VMware CBT change ID used for the next delta query") + private String changeId; + + @SerializedName(ApiConstants.SNAPSHOT_MOR) + @Param(description = "the VMware snapshot managed object reference currently associated with this disk") + private String snapshotMor; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the disk replication state") + private String state; + + public void setId(String id) { + this.id = id; + } + + public void setSourceDiskId(String sourceDiskId) { + this.sourceDiskId = sourceDiskId; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public void setSourceDiskPath(String sourceDiskPath) { + this.sourceDiskPath = sourceDiskPath; + } + + public void setDatastoreName(String datastoreName) { + this.datastoreName = datastoreName; + } + + public void setCapacityBytes(Long capacityBytes) { + this.capacityBytes = capacityBytes; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public void setChangeId(String changeId) { + this.changeId = changeId; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java new file mode 100644 index 000000000000..e637619a9a8d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class VmwareCbtMigrationPreflightDiskResponse extends BaseResponse { + + @SerializedName(ApiConstants.SOURCE_DISK_ID) + @Param(description = "the source VMware disk identifier") + private String sourceDiskId; + + @SerializedName(ApiConstants.SOURCE_DISK_DEVICE_KEY) + @Param(description = "the source VMware disk device key") + private Integer sourceDiskDeviceKey; + + @SerializedName(ApiConstants.SOURCE_DISK_PATH) + @Param(description = "the source VMware disk path") + private String sourceDiskPath; + + @SerializedName(ApiConstants.DATASTORE_NAME) + @Param(description = "the source VMware datastore name") + private String datastoreName; + + @SerializedName(ApiConstants.CAPACITY) + @Param(description = "the source disk capacity in bytes") + private Long capacityBytes; + + @SerializedName("backingtype") + @Param(description = "the VMware disk backing class") + private String backingType; + + @SerializedName("diskmode") + @Param(description = "the VMware disk mode") + private String diskMode; + + @SerializedName("rdmcompatibilitymode") + @Param(description = "the RDM compatibility mode, when the disk is an RDM") + private String rdmCompatibilityMode; + + @SerializedName("independentdisk") + @Param(description = "whether the VMware disk is configured as independent") + private boolean independentDisk; + + @SerializedName("physicalrdm") + @Param(description = "whether the VMware disk is a physical-mode RDM") + private boolean physicalRdm; + + @SerializedName("changeidpresent") + @Param(description = "whether a VMware CBT change ID is currently visible for this disk") + private boolean changeIdPresent; + + public void setSourceDiskId(String sourceDiskId) { + this.sourceDiskId = sourceDiskId; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public void setSourceDiskPath(String sourceDiskPath) { + this.sourceDiskPath = sourceDiskPath; + } + + public void setDatastoreName(String datastoreName) { + this.datastoreName = datastoreName; + } + + public void setCapacityBytes(Long capacityBytes) { + this.capacityBytes = capacityBytes; + } + + public void setBackingType(String backingType) { + this.backingType = backingType; + } + + public void setDiskMode(String diskMode) { + this.diskMode = diskMode; + } + + public void setRdmCompatibilityMode(String rdmCompatibilityMode) { + this.rdmCompatibilityMode = rdmCompatibilityMode; + } + + public void setIndependentDisk(boolean independentDisk) { + this.independentDisk = independentDisk; + } + + public void setPhysicalRdm(boolean physicalRdm) { + this.physicalRdm = physicalRdm; + } + + public void setChangeIdPresent(boolean changeIdPresent) { + this.changeIdPresent = changeIdPresent; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java new file mode 100644 index 000000000000..a4cccd06fa69 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class VmwareCbtMigrationPreflightFindingResponse extends BaseResponse { + + @SerializedName("severity") + @Param(description = "finding severity: PASS, WARN or FAIL") + private String severity; + + @SerializedName("code") + @Param(description = "technical preflight finding code") + private String code; + + @SerializedName("component") + @Param(description = "component checked by the preflight finding") + private String component; + + @SerializedName("resource") + @Param(description = "resource checked by the preflight finding") + private String resource; + + @SerializedName("message") + @Param(description = "human-readable preflight finding message") + private String message; + + public VmwareCbtMigrationPreflightFindingResponse(String severity, String code, String component, + String resource, String message) { + this.severity = severity; + this.code = code; + this.component = component; + this.resource = resource; + this.message = message; + setObjectName("finding"); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java new file mode 100644 index 000000000000..ec151189ee3a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java @@ -0,0 +1,284 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class VmwareCbtMigrationPreflightResponse extends BaseResponse { + + @SerializedName("ready") + @Param(description = "whether the source VM and destination are ready for VMware CBT migration start") + private boolean ready; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the destination zone ID") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the destination zone name") + private String zoneName; + + @SerializedName(ApiConstants.CLUSTER_ID) + @Param(description = "the destination KVM cluster ID") + private String clusterId; + + @SerializedName(ApiConstants.CLUSTER_NAME) + @Param(description = "the destination KVM cluster name") + private String clusterName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_HOST_ID) + @Param(description = "the KVM host selected for conversion and CBT replication") + private String convertInstanceHostId; + + @SerializedName("convertinstancehostname") + @Param(description = "the KVM host selected for conversion and CBT replication") + private String convertInstanceHostName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID) + @Param(description = "the destination storage pool ID") + private String storagePoolId; + + @SerializedName(ApiConstants.POOL_NAME) + @Param(description = "the destination storage pool name") + private String storagePoolName; + + @SerializedName("storagepooltype") + @Param(description = "the destination storage pool type") + private String storagePoolType; + + @SerializedName("storagewritertype") + @Param(description = "the VMware CBT target storage writer type") + private String storageWriterType; + + @SerializedName("storagewritersupported") + @Param(description = "whether the target storage writer is implemented") + private boolean storageWriterSupported; + + @SerializedName("storagerequiresinplacefinalization") + @Param(description = "whether the target storage writer requires virt-v2v in-place finalization") + private boolean storageRequiresInPlaceFinalization; + + @SerializedName("convertinstancehostinplacefinalizationsupported") + @Param(description = "whether the selected KVM host reports VMware CBT in-place finalization support") + private boolean convertInstanceHostInPlaceFinalizationSupported; + + @SerializedName("noninplacefinalizationfallbackallowed") + @Param(description = "whether VMware CBT non-in-place fallback finalization is enabled by configuration") + private boolean nonInPlaceFinalizationFallbackAllowed; + + @SerializedName("noninplacefinalizationfallbacksupported") + @Param(description = "whether the selected target storage can use non-in-place fallback finalization") + private boolean nonInPlaceFinalizationFallbackSupported; + + @SerializedName(ApiConstants.VCENTER) + @Param(description = "the source VMware vCenter") + private String vcenter; + + @SerializedName(ApiConstants.DATACENTER_NAME) + @Param(description = "the source VMware datacenter") + private String datacenterName; + + @SerializedName(ApiConstants.SOURCE_HOST) + @Param(description = "the source VMware ESXi host") + private String sourceHost; + + @SerializedName(ApiConstants.SOURCE_VM_NAME) + @Param(description = "the source VMware VM name") + private String sourceVmName; + + @SerializedName("sourcevmmor") + @Param(description = "the source VMware VM managed object reference") + private String sourceVmMor; + + @SerializedName("changetrackingsupported") + @Param(description = "whether the source VM reports CBT support") + private Boolean changeTrackingSupported; + + @SerializedName("changetrackingenabled") + @Param(description = "whether CBT is currently enabled on the source VM") + private Boolean changeTrackingEnabled; + + @SerializedName("consolidationneeded") + @Param(description = "whether the source VM reports disk consolidation is needed") + private Boolean consolidationNeeded; + + @SerializedName("existingsnapshotcount") + @Param(description = "number of existing VMware snapshots on the source VM") + private Integer existingSnapshotCount; + + @SerializedName("sourcecpunumber") + @Param(description = "source VMware VM CPU count") + private Integer sourceCpuNumber; + + @SerializedName("sourcecpuspeed") + @Param(description = "source VMware VM CPU reservation in MHz used for offering validation, when available") + private Integer sourceCpuSpeed; + + @SerializedName("sourcememory") + @Param(description = "source VMware VM memory in MB") + private Integer sourceMemory; + + @SerializedName("sourceguestosid") + @Param(description = "source VMware VM guest OS identifier") + private String sourceGuestOsId; + + @SerializedName("sourceguestos") + @Param(description = "source VMware VM guest OS name") + private String sourceGuestOs; + + @SerializedName(ApiConstants.DISK) + @Param(description = "source VMware disk preflight information") + private List disks; + + @SerializedName("finding") + @Param(description = "preflight findings") + private List findings; + + public void setReady(boolean ready) { + this.ready = ready; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setClusterId(String clusterId) { + this.clusterId = clusterId; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public void setConvertInstanceHostId(String convertInstanceHostId) { + this.convertInstanceHostId = convertInstanceHostId; + } + + public void setConvertInstanceHostName(String convertInstanceHostName) { + this.convertInstanceHostName = convertInstanceHostName; + } + + public void setStoragePoolId(String storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public void setStoragePoolName(String storagePoolName) { + this.storagePoolName = storagePoolName; + } + + public void setStoragePoolType(String storagePoolType) { + this.storagePoolType = storagePoolType; + } + + public void setStorageWriterType(String storageWriterType) { + this.storageWriterType = storageWriterType; + } + + public void setStorageWriterSupported(boolean storageWriterSupported) { + this.storageWriterSupported = storageWriterSupported; + } + + public void setStorageRequiresInPlaceFinalization(boolean storageRequiresInPlaceFinalization) { + this.storageRequiresInPlaceFinalization = storageRequiresInPlaceFinalization; + } + + public void setConvertInstanceHostInPlaceFinalizationSupported(boolean convertInstanceHostInPlaceFinalizationSupported) { + this.convertInstanceHostInPlaceFinalizationSupported = convertInstanceHostInPlaceFinalizationSupported; + } + + public void setNonInPlaceFinalizationFallbackAllowed(boolean nonInPlaceFinalizationFallbackAllowed) { + this.nonInPlaceFinalizationFallbackAllowed = nonInPlaceFinalizationFallbackAllowed; + } + + public void setNonInPlaceFinalizationFallbackSupported(boolean nonInPlaceFinalizationFallbackSupported) { + this.nonInPlaceFinalizationFallbackSupported = nonInPlaceFinalizationFallbackSupported; + } + + public void setVcenter(String vcenter) { + this.vcenter = vcenter; + } + + public void setDatacenterName(String datacenterName) { + this.datacenterName = datacenterName; + } + + public void setSourceHost(String sourceHost) { + this.sourceHost = sourceHost; + } + + public void setSourceVmName(String sourceVmName) { + this.sourceVmName = sourceVmName; + } + + public void setSourceVmMor(String sourceVmMor) { + this.sourceVmMor = sourceVmMor; + } + + public void setChangeTrackingSupported(Boolean changeTrackingSupported) { + this.changeTrackingSupported = changeTrackingSupported; + } + + public void setChangeTrackingEnabled(Boolean changeTrackingEnabled) { + this.changeTrackingEnabled = changeTrackingEnabled; + } + + public void setConsolidationNeeded(Boolean consolidationNeeded) { + this.consolidationNeeded = consolidationNeeded; + } + + public void setExistingSnapshotCount(Integer existingSnapshotCount) { + this.existingSnapshotCount = existingSnapshotCount; + } + + public void setSourceCpuNumber(Integer sourceCpuNumber) { + this.sourceCpuNumber = sourceCpuNumber; + } + + public void setSourceCpuSpeed(Integer sourceCpuSpeed) { + this.sourceCpuSpeed = sourceCpuSpeed; + } + + public void setSourceMemory(Integer sourceMemory) { + this.sourceMemory = sourceMemory; + } + + public void setSourceGuestOsId(String sourceGuestOsId) { + this.sourceGuestOsId = sourceGuestOsId; + } + + public void setSourceGuestOs(String sourceGuestOs) { + this.sourceGuestOs = sourceGuestOs; + } + + public void setDisks(List disks) { + this.disks = disks; + } + + public void setFindings(List findings) { + this.findings = findings; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java new file mode 100644 index 000000000000..37ee6906f9f3 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java @@ -0,0 +1,288 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import java.util.Date; +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = VmwareCbtMigration.class) +public class VmwareCbtMigrationResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the VMware CBT migration") + private String id; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the destination zone ID") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the destination zone name") + private String zoneName; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account name") + private String accountName; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "the account ID") + private String accountId; + + @SerializedName(ApiConstants.VIRTUAL_MACHINE_ID) + @Param(description = "the ID of the imported VM after cutover") + private String virtualMachineId; + + @SerializedName(ApiConstants.CLUSTER_ID) + @Param(description = "the destination KVM cluster ID") + private String clusterId; + + @SerializedName(ApiConstants.CLUSTER_NAME) + @Param(description = "the destination KVM cluster name") + private String clusterName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_HOST_ID) + @Param(description = "the KVM host selected for conversion and CBT replication") + private String convertInstanceHostId; + + @SerializedName("convertinstancehostname") + @Param(description = "the KVM host name selected for conversion and CBT replication") + private String convertInstanceHostName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID) + @Param(description = "the storage pool selected for converted disks") + private String storagePoolId; + + @SerializedName(ApiConstants.POOL_NAME) + @Param(description = "the storage pool name selected for converted disks") + private String storagePoolName; + + @SerializedName(ApiConstants.DISPLAY_NAME) + @Param(description = "the display name of the target VM") + private String displayName; + + @SerializedName(ApiConstants.VCENTER) + @Param(description = "the source VMware vCenter") + private String vcenter; + + @SerializedName(ApiConstants.EXISTING_VCENTER_ID) + @Param(description = "the linked existing vCenter ID, when used") + private String existingVcenterId; + + @SerializedName(ApiConstants.DATACENTER_NAME) + @Param(description = "the source VMware datacenter") + private String datacenterName; + + @SerializedName(ApiConstants.SOURCE_HOST) + @Param(description = "the source VMware ESXi host") + private String sourceHost; + + @SerializedName(ApiConstants.SOURCE_CLUSTER) + @Param(description = "the source VMware cluster") + private String sourceCluster; + + @SerializedName(ApiConstants.SOURCE_VM_NAME) + @Param(description = "the source VMware VM name") + private String sourceVmName; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the migration state") + private String state; + + @SerializedName(ApiConstants.CURRENT_STEP) + @Param(description = "the current migration step") + private String currentStep; + + @SerializedName("currentstepduration") + @Param(description = "the elapsed time spent in the current migration step") + private String currentStepDuration; + + @SerializedName(ApiConstants.LAST_ERROR) + @Param(description = "the last migration error") + private String lastError; + + @SerializedName(ApiConstants.COMPLETED_CYCLES) + @Param(description = "the number of completed CBT delta cycles") + private int completedCycles; + + @SerializedName(ApiConstants.QUIET_CYCLES) + @Param(description = "the number of consecutive quiet CBT delta cycles") + private int quietCycles; + + @SerializedName(ApiConstants.TOTAL_CHANGED_BYTES) + @Param(description = "the total changed bytes synchronized across CBT delta cycles") + private long totalChangedBytes; + + @SerializedName(ApiConstants.LAST_CHANGED_BYTES) + @Param(description = "the changed bytes observed in the last CBT delta cycle") + private Long lastChangedBytes; + + @SerializedName(ApiConstants.LAST_DIRTY_RATE) + @Param(description = "the dirty rate in bytes per second observed in the last CBT delta cycle") + private Long lastDirtyRate; + + @SerializedName(ApiConstants.DISK) + @Param(description = "the source and target disks tracked by this VMware CBT migration") + private List disks; + + @SerializedName(ApiConstants.CYCLE) + @Param(description = "the CBT delta synchronization cycles tracked by this VMware CBT migration") + private List cycles; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the create date of the VMware CBT migration") + private Date created; + + @SerializedName(ApiConstants.LAST_UPDATED) + @Param(description = "the last updated date of the VMware CBT migration") + private Date lastUpdated; + + public void setId(String id) { + this.id = id; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public void setVirtualMachineId(String virtualMachineId) { + this.virtualMachineId = virtualMachineId; + } + + public void setClusterId(String clusterId) { + this.clusterId = clusterId; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public void setConvertInstanceHostId(String convertInstanceHostId) { + this.convertInstanceHostId = convertInstanceHostId; + } + + public void setConvertInstanceHostName(String convertInstanceHostName) { + this.convertInstanceHostName = convertInstanceHostName; + } + + public void setStoragePoolId(String storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public void setStoragePoolName(String storagePoolName) { + this.storagePoolName = storagePoolName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public void setVcenter(String vcenter) { + this.vcenter = vcenter; + } + + public void setExistingVcenterId(String existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + + public void setDatacenterName(String datacenterName) { + this.datacenterName = datacenterName; + } + + public void setSourceHost(String sourceHost) { + this.sourceHost = sourceHost; + } + + public void setSourceCluster(String sourceCluster) { + this.sourceCluster = sourceCluster; + } + + public void setSourceVmName(String sourceVmName) { + this.sourceVmName = sourceVmName; + } + + public void setState(String state) { + this.state = state; + } + + public void setCurrentStep(String currentStep) { + this.currentStep = currentStep; + } + + public void setCurrentStepDuration(String currentStepDuration) { + this.currentStepDuration = currentStepDuration; + } + + public void setLastError(String lastError) { + this.lastError = lastError; + } + + public void setCompletedCycles(int completedCycles) { + this.completedCycles = completedCycles; + } + + public void setQuietCycles(int quietCycles) { + this.quietCycles = quietCycles; + } + + public void setTotalChangedBytes(long totalChangedBytes) { + this.totalChangedBytes = totalChangedBytes; + } + + public void setLastChangedBytes(Long lastChangedBytes) { + this.lastChangedBytes = lastChangedBytes; + } + + public void setLastDirtyRate(Long lastDirtyRate) { + this.lastDirtyRate = lastDirtyRate; + } + + public void setDisks(List disks) { + this.disks = disks; + } + + public void setCycles(List cycles) { + this.cycles = cycles; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setLastUpdated(Date lastUpdated) { + this.lastUpdated = lastUpdated; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java index cbb7c4de698a..60f4d959ebe4 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java +++ b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java @@ -66,6 +66,7 @@ public enum PowerState { private String bootType; private String bootMode; + private String vmwareMoref; public String getName() { return name; @@ -234,6 +235,14 @@ public void setBootMode(String bootMode) { this.bootMode = bootMode; } + public String getVmwareMoref() { + return vmwareMoref; + } + + public void setVmwareMoref(String vmwareMoref) { + this.vmwareMoref = vmwareMoref; + } + public static class Disk { private String diskId; diff --git a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java index e1963313eb6f..a3fef8f4c855 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java +++ b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java @@ -17,10 +17,19 @@ package org.apache.cloudstack.vm; +import java.util.Map; + +import com.cloud.dc.DataCenter; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Network; +import com.cloud.org.Cluster; +import com.cloud.user.Account; import com.cloud.utils.component.PluggableService; +import com.cloud.uservm.UserVm; + import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; + import static com.cloud.hypervisor.Hypervisor.HypervisorType.KVM; import static com.cloud.hypervisor.Hypervisor.HypervisorType.VMware; @@ -75,4 +84,15 @@ public interface UnmanagedVMsManager extends VmImportService, UnmanageVMService, static boolean isSupported(Hypervisor.HypervisorType hypervisorType) { return hypervisorType == VMware || hypervisorType == KVM; } + + UserVm importConvertedVmwareCbtInstanceToKvm(String vcenter, String datacenterName, String username, String password, + String clusterName, String sourceHostName, String sourceVMName, + UnmanagedInstanceTO convertedInstance, DataCenter zone, + Cluster destinationCluster, String displayName, String hostName, + Account caller, Account owner, long userId, Long templateId, + Long serviceOfferingId, Map dataDiskOfferingMap, + Map nicNetworkMap, + Map nicIpAddressMap, + Long guestOsId, Map details, boolean forced, + Long storagePoolId); } diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java new file mode 100644 index 000000000000..998bfc701dd0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java @@ -0,0 +1,36 @@ +// 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; + +public class VmwareCbtChangedBlockInfo { + + private final long startOffset; + private final long length; + + public VmwareCbtChangedBlockInfo(long startOffset, long length) { + this.startOffset = startOffset; + this.length = length; + } + + public long getStartOffset() { + return startOffset; + } + + public long getLength() { + return length; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java new file mode 100644 index 000000000000..958cbffa3519 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import java.util.Collections; +import java.util.List; + +public class VmwareCbtChangedDiskInfo { + + private final String sourceDiskId; + private final String nextChangeId; + private final List changedBlocks; + + public VmwareCbtChangedDiskInfo(String sourceDiskId, String nextChangeId, + List changedBlocks) { + this.sourceDiskId = sourceDiskId; + this.nextChangeId = nextChangeId; + this.changedBlocks = changedBlocks == null ? Collections.emptyList() : changedBlocks; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public String getNextChangeId() { + return nextChangeId; + } + + public List getChangedBlocks() { + return changedBlocks; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java new file mode 100644 index 000000000000..99ed6d5de530 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java @@ -0,0 +1,67 @@ +// 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; + +public class VmwareCbtDiskInfo { + + private final String sourceDiskId; + private final Integer sourceDiskDeviceKey; + private final String label; + private final String sourceDiskPath; + private final String datastoreName; + private final Long capacityBytes; + private final String changeId; + + public VmwareCbtDiskInfo(String sourceDiskId, Integer sourceDiskDeviceKey, String label, String sourceDiskPath, + String datastoreName, Long capacityBytes, String changeId) { + this.sourceDiskId = sourceDiskId; + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + this.label = label; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.capacityBytes = capacityBytes; + this.changeId = changeId; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + + public String getLabel() { + return label; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public Long getCapacityBytes() { + return capacityBytes; + } + + public String getChangeId() { + return changeId; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java new file mode 100644 index 000000000000..3f56a9c64451 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java @@ -0,0 +1,47 @@ +// 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 org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface VmwareCbtMigration extends Identity, InternalIdentity { + enum State { + Created, + InitialSync, + Replicating, + ReadyForCutover, + CuttingOver, + ReadyForImport, + Completed, + Failed, + Cancelled; + + public boolean isTerminal() { + return this == Completed || this == Failed || this == Cancelled; + } + + public static State getValue(String state) { + for (State value : values()) { + if (value.name().equalsIgnoreCase(state)) { + return value; + } + } + throw new IllegalArgumentException("Invalid VMware CBT migration state: " + state); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java new file mode 100644 index 000000000000..e1e8758358aa --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java @@ -0,0 +1,30 @@ +// 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 org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface VmwareCbtMigrationCycle extends Identity, InternalIdentity { + enum State { + Created, + QueryingChangedAreas, + CopyingChangedBlocks, + Completed, + Failed + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java new file mode 100644 index 000000000000..41d672de69c9 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java @@ -0,0 +1,30 @@ +// 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 org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface VmwareCbtMigrationDisk extends Identity, InternalIdentity { + enum State { + Created, + Prepared, + Syncing, + Ready, + Failed + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java new file mode 100644 index 000000000000..bf248782c2a0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +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.VmwareCbtMigrationPreflightResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; + +import com.cloud.utils.component.PluggableService; + +public interface VmwareCbtMigrationManager extends PluggableService { + VmwareCbtMigrationPreflightResponse checkVmwareCbtMigrationPrerequisites(CheckVmwareCbtMigrationPrerequisitesCmd cmd); + + VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigrationCmd cmd); + + ListResponse listVmwareCbtMigrations(ListVmwareCbtMigrationsCmd cmd); + + VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationCmd cmd); + + VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd); + + VmwareCbtMigrationResponse cancelVmwareCbtMigration(CancelVmwareCbtMigrationCmd cmd); + + boolean deleteVmwareCbtMigration(DeleteVmwareCbtMigrationCmd cmd); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java new file mode 100644 index 000000000000..9527ac67fd1f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java @@ -0,0 +1,47 @@ +// 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.List; + +public interface VmwareCbtMigrationService { + VmwareCbtPreflightInfo getPreflightInfo(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); + + List listSourceDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); + + List listSnapshotDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotMor); + + void ensureChangeTrackingEnabled(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); + + VmwareCbtSnapshotInfo createSnapshot(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotName, + String snapshotDescription, boolean quiesce); + + List queryChangedDiskAreas(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName, + List disks, String snapshotMor); + + UnmanagedInstanceTO.PowerState getPowerState(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName); + + void removeSnapshot(String vcenter, String datacenterName, String username, String password, String sourceHost, + String sourceVmName, String snapshotMor); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java new file mode 100644 index 000000000000..a574af96a083 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java @@ -0,0 +1,59 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtPreflightDiskInfo extends VmwareCbtDiskInfo { + + private final String backingType; + private final String diskMode; + private final String rdmCompatibilityMode; + private final boolean independentDisk; + private final boolean physicalRdm; + + public VmwareCbtPreflightDiskInfo(String sourceDiskId, Integer sourceDiskDeviceKey, String label, + String sourceDiskPath, String datastoreName, Long capacityBytes, + String changeId, String backingType, String diskMode, + String rdmCompatibilityMode, boolean independentDisk, + boolean physicalRdm) { + super(sourceDiskId, sourceDiskDeviceKey, label, sourceDiskPath, datastoreName, capacityBytes, changeId); + this.backingType = backingType; + this.diskMode = diskMode; + this.rdmCompatibilityMode = rdmCompatibilityMode; + this.independentDisk = independentDisk; + this.physicalRdm = physicalRdm; + } + + public String getBackingType() { + return backingType; + } + + public String getDiskMode() { + return diskMode; + } + + public String getRdmCompatibilityMode() { + return rdmCompatibilityMode; + } + + public boolean isIndependentDisk() { + return independentDisk; + } + + public boolean isPhysicalRdm() { + return physicalRdm; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java new file mode 100644 index 000000000000..668a92a72798 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java @@ -0,0 +1,113 @@ +// 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.Collections; +import java.util.List; + +public class VmwareCbtPreflightInfo { + + private final String sourceVmName; + private final String sourceVmMor; + private final Boolean changeTrackingSupported; + private final Boolean changeTrackingEnabled; + private final Boolean consolidationNeeded; + private final Integer existingSnapshotCount; + private final List disks; + private final Integer cpuCores; + private final Integer cpuSpeed; + private final Integer memoryMb; + private final String operatingSystemId; + private final String operatingSystem; + + public VmwareCbtPreflightInfo(String sourceVmName, String sourceVmMor, + Boolean changeTrackingSupported, Boolean changeTrackingEnabled, + Boolean consolidationNeeded, Integer existingSnapshotCount, + List disks, + Integer cpuCores, Integer cpuSpeed, Integer memoryMb) { + this(sourceVmName, sourceVmMor, changeTrackingSupported, changeTrackingEnabled, consolidationNeeded, + existingSnapshotCount, disks, cpuCores, cpuSpeed, memoryMb, null, null); + } + + public VmwareCbtPreflightInfo(String sourceVmName, String sourceVmMor, + Boolean changeTrackingSupported, Boolean changeTrackingEnabled, + Boolean consolidationNeeded, Integer existingSnapshotCount, + List disks, + Integer cpuCores, Integer cpuSpeed, Integer memoryMb, + String operatingSystemId, String operatingSystem) { + this.sourceVmName = sourceVmName; + this.sourceVmMor = sourceVmMor; + this.changeTrackingSupported = changeTrackingSupported; + this.changeTrackingEnabled = changeTrackingEnabled; + this.consolidationNeeded = consolidationNeeded; + this.existingSnapshotCount = existingSnapshotCount; + this.disks = disks == null ? Collections.emptyList() : Collections.unmodifiableList(disks); + this.cpuCores = cpuCores; + this.cpuSpeed = cpuSpeed; + this.memoryMb = memoryMb; + this.operatingSystemId = operatingSystemId; + this.operatingSystem = operatingSystem; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getSourceVmMor() { + return sourceVmMor; + } + + public Boolean getChangeTrackingSupported() { + return changeTrackingSupported; + } + + public Boolean getChangeTrackingEnabled() { + return changeTrackingEnabled; + } + + public Boolean getConsolidationNeeded() { + return consolidationNeeded; + } + + public Integer getExistingSnapshotCount() { + return existingSnapshotCount; + } + + public List getDisks() { + return disks; + } + + public Integer getCpuCores() { + return cpuCores; + } + + public Integer getCpuSpeed() { + return cpuSpeed; + } + + public Integer getMemoryMb() { + return memoryMb; + } + + public String getOperatingSystemId() { + return operatingSystemId; + } + + public String getOperatingSystem() { + return operatingSystem; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java new file mode 100644 index 000000000000..c8bfe3edbe84 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtSnapshotInfo { + + private final String snapshotName; + private final String snapshotMor; + private final String sourceVmMor; + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor) { + this(snapshotName, snapshotMor, null); + } + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor, String sourceVmMor) { + this.snapshotName = snapshotName; + this.snapshotMor = snapshotMor; + this.sourceVmMor = sourceVmMor; + } + + public String getSnapshotName() { + return snapshotName; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public String getSourceVmMor() { + return sourceVmMor; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java index c46fb697a3c1..9bd5bf168a3d 100644 --- a/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java @@ -19,6 +19,8 @@ public class CheckConvertInstanceCommand extends Command { boolean checkWindowsGuestConversionSupport = false; boolean useVddk = false; + boolean checkVddkRbdDirectImportSupport = false; + boolean checkVddkInPlaceFinalizationSupport = false; String vddkLibDir; public CheckConvertInstanceCommand() { @@ -50,6 +52,22 @@ public void setUseVddk(boolean useVddk) { this.useVddk = useVddk; } + public boolean isCheckVddkRbdDirectImportSupport() { + return checkVddkRbdDirectImportSupport; + } + + public void setCheckVddkRbdDirectImportSupport(boolean checkVddkRbdDirectImportSupport) { + this.checkVddkRbdDirectImportSupport = checkVddkRbdDirectImportSupport; + } + + public boolean isCheckVddkInPlaceFinalizationSupport() { + return checkVddkInPlaceFinalizationSupport; + } + + public void setCheckVddkInPlaceFinalizationSupport(boolean checkVddkInPlaceFinalizationSupport) { + this.checkVddkInPlaceFinalizationSupport = checkVddkInPlaceFinalizationSupport; + } + public String getVddkLibDir() { return vddkLibDir; } diff --git a/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java index 38e0dca7736c..dd7139e8bd7d 100644 --- a/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java @@ -18,8 +18,11 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareVddkSourceDiskTO; import com.cloud.hypervisor.Hypervisor; +import java.util.List; + public class ConvertInstanceCommand extends Command { private RemoteInstanceTO sourceInstance; @@ -35,6 +38,7 @@ public class ConvertInstanceCommand extends Command { private String vddkLibDir; private String vddkTransports; private String vddkThumbprint; + private List vmwareVddkSourceDisks; public ConvertInstanceCommand() { } @@ -126,6 +130,14 @@ public void setVddkThumbprint(String vddkThumbprint) { this.vddkThumbprint = vddkThumbprint; } + public List getVmwareVddkSourceDisks() { + return vmwareVddkSourceDisks; + } + + public void setVmwareVddkSourceDisks(List vmwareVddkSourceDisks) { + this.vmwareVddkSourceDisks = vmwareVddkSourceDisks; + } + @Override public boolean executeInSequence() { return false; diff --git a/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java index eadfa6556f86..e5b970814fab 100644 --- a/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java @@ -18,6 +18,7 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.storage.Storage; import java.util.List; @@ -25,6 +26,7 @@ public class ImportConvertedInstanceCommand extends Command { private RemoteInstanceTO sourceInstance; private List destinationStoragePools; + private List destinationStoragePoolTypes; private DataStoreTO conversionTemporaryLocation; private String temporaryConvertUuid; private boolean forceConvertToPool; @@ -34,15 +36,24 @@ public ImportConvertedInstanceCommand() { public ImportConvertedInstanceCommand(RemoteInstanceTO sourceInstance, List destinationStoragePools, + List destinationStoragePoolTypes, DataStoreTO conversionTemporaryLocation, String temporaryConvertUuid, boolean forceConvertToPool) { this.sourceInstance = sourceInstance; this.destinationStoragePools = destinationStoragePools; + this.destinationStoragePoolTypes = destinationStoragePoolTypes; this.conversionTemporaryLocation = conversionTemporaryLocation; this.temporaryConvertUuid = temporaryConvertUuid; this.forceConvertToPool = forceConvertToPool; } + public ImportConvertedInstanceCommand(RemoteInstanceTO sourceInstance, + List destinationStoragePools, + DataStoreTO conversionTemporaryLocation, String temporaryConvertUuid, + boolean forceConvertToPool) { + this(sourceInstance, destinationStoragePools, null, conversionTemporaryLocation, temporaryConvertUuid, forceConvertToPool); + } + public RemoteInstanceTO getSourceInstance() { return sourceInstance; } @@ -51,6 +62,10 @@ public List getDestinationStoragePools() { return destinationStoragePools; } + public List getDestinationStoragePoolTypes() { + return destinationStoragePoolTypes; + } + public DataStoreTO getConversionTemporaryLocation() { return conversionTemporaryLocation; } diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java new file mode 100644 index 000000000000..65c028995900 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtCleanupCommand extends Command { + + private String migrationUuid; + private List disks; + private boolean removeTemporarySnapshots; + private boolean removeNbdExports; + private boolean removePartialTargetDisks; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + + public VmwareCbtCleanupCommand() { + } + + public VmwareCbtCleanupCommand(String migrationUuid, List disks, boolean removeTemporarySnapshots, + boolean removeNbdExports, boolean removePartialTargetDisks) { + this.migrationUuid = migrationUuid; + this.disks = disks; + this.removeTemporarySnapshots = removeTemporarySnapshots; + this.removeNbdExports = removeNbdExports; + this.removePartialTargetDisks = removePartialTargetDisks; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public List getDisks() { + return disks; + } + + public boolean getRemoveTemporarySnapshots() { + return removeTemporarySnapshots; + } + + public boolean getRemoveNbdExports() { + return removeNbdExports; + } + + public boolean getRemovePartialTargetDisks() { + return removePartialTargetDisks; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java new file mode 100644 index 000000000000..24d82c434afe --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtCutoverCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private int finalCycleNumber; + private boolean runVirtV2vFinalization; + private boolean allowNonInPlaceFinalization; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtCutoverCommand() { + } + + public VmwareCbtCutoverCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + int finalCycleNumber, boolean runVirtV2vFinalization) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.finalCycleNumber = finalCycleNumber; + this.runVirtV2vFinalization = runVirtV2vFinalization; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public int getFinalCycleNumber() { + return finalCycleNumber; + } + + public boolean getRunVirtV2vFinalization() { + return runVirtV2vFinalization; + } + + public boolean getAllowNonInPlaceFinalization() { + return allowNonInPlaceFinalization; + } + + public void setAllowNonInPlaceFinalization(boolean allowNonInPlaceFinalization) { + this.allowNonInPlaceFinalization = allowNonInPlaceFinalization; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java new file mode 100644 index 000000000000..2a0435639418 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; + +public class VmwareCbtMigrationAnswer extends Answer { + + private String migrationUuid; + private int cycleNumber; + private long changedBytes; + private long dirtyRateBytesPerSecond; + private long durationSeconds; + private boolean readyForCutover; + private List diskResults; + + public VmwareCbtMigrationAnswer() { + super(); + } + + public VmwareCbtMigrationAnswer(Command command, boolean result, String details, String migrationUuid) { + super(command, result, details); + this.migrationUuid = migrationUuid; + } + + public VmwareCbtMigrationAnswer(Command command, boolean result, String details, String migrationUuid, int cycleNumber, + long changedBytes, long dirtyRateBytesPerSecond, long durationSeconds, + boolean readyForCutover, List diskResults) { + super(command, result, details); + this.migrationUuid = migrationUuid; + this.cycleNumber = cycleNumber; + this.changedBytes = changedBytes; + this.dirtyRateBytesPerSecond = dirtyRateBytesPerSecond; + this.durationSeconds = durationSeconds; + this.readyForCutover = readyForCutover; + this.diskResults = diskResults; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public long getChangedBytes() { + return changedBytes; + } + + public long getDirtyRateBytesPerSecond() { + return dirtyRateBytesPerSecond; + } + + public long getDurationSeconds() { + return durationSeconds; + } + + public boolean getReadyForCutover() { + return readyForCutover; + } + + public List getDiskResults() { + return diskResults; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java new file mode 100644 index 000000000000..0791d3b06a6f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtPrepareCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String baselineSnapshotMor; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtPrepareCommand() { + } + + public VmwareCbtPrepareCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + Storage.StoragePoolType destinationStoragePoolType, String destinationStoragePoolUuid, + VmwareCbtTargetStorageType targetStorageType, String baselineSnapshotMor) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.destinationStoragePoolType = destinationStoragePoolType; + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + this.targetStorageType = targetStorageType; + this.baselineSnapshotMor = baselineSnapshotMor; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public String getBaselineSnapshotMor() { + return baselineSnapshotMor; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java new file mode 100644 index 000000000000..73b532bfff71 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java @@ -0,0 +1,63 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtRbdProbeCommand extends Command { + + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private String probeImageName; + private VmwareCbtTargetStorageType targetStorageType; + + public VmwareCbtRbdProbeCommand() { + } + + public VmwareCbtRbdProbeCommand(Storage.StoragePoolType destinationStoragePoolType, + String destinationStoragePoolUuid, String probeImageName) { + this.destinationStoragePoolType = destinationStoragePoolType; + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + this.probeImageName = probeImageName; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public String getProbeImageName() { + return probeImageName; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java new file mode 100644 index 000000000000..a58c0029c43f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java @@ -0,0 +1,138 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtSyncCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private List changedBlocks; + private int cycleNumber; + private String snapshotMor; + private boolean finalSync; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtSyncCommand() { + } + + public VmwareCbtSyncCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + List changedBlocks, int cycleNumber, String snapshotMor, + boolean finalSync) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.changedBlocks = changedBlocks; + this.cycleNumber = cycleNumber; + this.snapshotMor = snapshotMor; + this.finalSync = finalSync; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public List getChangedBlocks() { + return changedBlocks; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public boolean getFinalSync() { + return finalSync; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java new file mode 100644 index 000000000000..3707ea498744 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +public enum VmwareCbtTargetStorageType { + QCOW2_FILE, + RBD_RAW, + /** + * Raw writes into a host-local block device provided by the destination + * primary storage (e.g. a Linstor/DRBD volume). The device is pre-created + * at source capacity through the storage adaptor before any data copy. + */ + RAW_BLOCK_DEVICE +} diff --git a/docs/vmware-cbt-migration.md b/docs/vmware-cbt-migration.md new file mode 100644 index 000000000000..a5fb364271f6 --- /dev/null +++ b/docs/vmware-cbt-migration.md @@ -0,0 +1,351 @@ + + +# VMware CBT migration to KVM + +This document describes the proposed VMware Changed Block Tracking (CBT) +migration flow for Apache CloudStack and the host-level prerequisites required +on KVM migration hosts. + +The goal of this flow is to reduce the downtime and bandwidth needed when +migrating VMware guests to KVM. Instead of repeatedly copying the full VMware +disk, CloudStack performs one initial full synchronization and then uses VMware +CBT to copy only changed disk ranges until the VM is quiet enough for final +cutover. + +No additional CloudStack software is installed on the VMware ESXi hosts or +vCenter for this flow. The extra migration tooling is installed on the selected +CloudStack KVM host that performs the VDDK read, virt-v2v conversion, and CBT +delta application work. + +## Current implementation status + +The current implementation contains the CloudStack management model, API +surface, state tracking, cutover policy, VMware changed-block query +orchestration, KVM agent initial sync, KVM agent delta sync, cutover +finalization, import retry handling, delete/cancel cleanup, and UI status +tracking. + +## High-level flow + +1. Start a VMware CBT migration session. + CloudStack records the destination zone, KVM cluster, optional conversion + host, optional storage pool, source vCenter/ESXi details, and discovered + VMware disks. + +2. Run the initial full sync. + The initial full copy should use the existing VMware-to-KVM VDDK and + virt-v2v workflow. The result must be one target disk per VMware source disk + on KVM primary storage. + +3. Register target disks. + The management server records the target disk path, format, initial VMware + CBT change ID, and snapshot reference for each migrated disk. + +4. Run CBT delta synchronization cycles. + For each cycle CloudStack creates a VMware snapshot, calls + `QueryChangedDiskAreas()` for the source disks, dispatches changed byte + ranges to the selected KVM host, records copied bytes and dirty rate, and + removes the VMware snapshot. + +5. Decide when to cut over. + The decision is controlled by global settings: + + * `vmware.cbt.migration.min.cycles` + * `vmware.cbt.migration.max.cycles` + * `vmware.cbt.migration.quiet.cycles` + * `vmware.cbt.migration.quiet.bytes` + * `vmware.cbt.migration.quiet.dirty.rate` + + Long-running KVM agent commands are controlled separately by: + + * `vmware.cbt.migration.agent.command.timeout` + +6. Final cutover. + After the operator powers down the VMware VM, CloudStack performs the final + delta sync and imports/registers the VM on KVM. + +## Long-running operation timeout + +CBT initial full sync, regular delta sync, final delta sync, and cutover +finalization run as KVM agent commands. The command wait value and the +agent-side child process timeout are both controlled by: + +```text +vmware.cbt.migration.agent.command.timeout=86400 +``` + +The value is in seconds. The default is 24 hours. It is intentionally separate +from `convert.vmware.instance.to.kvm.timeout`, which applies to the older +OVF/VDDK `ConvertInstanceCommand` import path, and from +`remote.kvm.instance.disks.copy.timeout`, which applies to remote KVM unmanaged +disk copy imports. + +For very large source disks, set +`vmware.cbt.migration.agent.command.timeout` above the worst-case expected +duration. A 1 TiB initial sync can easily require multiple hours depending on +VDDK transport, ESXi storage, destination storage, and sparse extent behavior. +The setting is finite because the shared CloudStack script runner treats zero +as its default one-hour timeout, not as "no timeout". + +## KVM migration host selection + +CBT migration runs on a KVM host in the destination cluster. If the API call +does not specify a conversion host, CloudStack selects an enabled KVM routing +host in the destination cluster that reports: + +```text +host.vmware.cbt.support=true +``` + +The KVM agent reports this capability during host startup. It is true only when +the host satisfies the required VDDK, virt-v2v, qemu-img, and qemu-nbd checks. + +Related host detail keys: + +```text +host.vddk.support +vddk.lib.dir +host.vddk.version +host.vmware.cbt.support +host.qemu.img.version +host.qemu.nbd.version +host.virtv2v.version +``` + +Restart `cloudstack-agent` after installing packages or changing +`agent.properties` so these details are refreshed on the management server. + +## Required host capabilities + +Each KVM host that can be selected for VMware CBT migration needs: + +* A normal CloudStack KVM agent installation. +* `virt-v2v` for the initial VMware-to-KVM conversion and final conversion + steps. +* `nbdkit` and an `nbdkit` VDDK plugin. +* VMware VDDK libraries, with a directory containing + `lib64/libvixDiskLib.so`. +* `qemu-img` for target image inspection, creation, and manipulation. +* `qemu-nbd` for block device exposure during incremental copy workflows. +* Network access from the KVM host to vCenter and ESXi hosts. +* Access to the destination KVM primary storage pool. +* `virtio-win` drivers when migrating Windows guests. + +Destination-specific requirements: + +* Ceph/RBD destination pools additionally require qemu built with the RBD + block driver on the conversion host, plus in-place `virt-v2v` support + (the `virt-v2v-in-place` binary or the `virt-v2v --in-place` option). +* Linstor destination pools additionally require in-place `virt-v2v` support, + and the conversion host must be a LINSTOR satellite connected to the + destination pool (the CloudStack primary storage must be attached to the + host, and the host's `hostname` must match its LINSTOR node name). Target + volumes are pre-created through the Linstor storage adaptor and written as + raw data to the local DRBD device path, so no extra qemu block driver is + needed. DRBD permits only one writer: make sure no other node holds the + target resource Primary while a migration writes to it. + +You do not have to install these packages on every KVM host in the zone unless +you want every KVM host to be eligible for VMware CBT migration. A common +deployment model is to prepare a smaller set of conversion-capable KVM hosts and +explicitly select one of them for migration work. + +The current KVM agent checks these commands: + +```bash +virt-v2v --version +nbdkit vddk --version +qemu-img --version +qemu-nbd --version +``` + +On Ubuntu and Debian hosts the current conversion support check also verifies +that the `nbdkit` package is installed with `dpkg -l nbdkit`. + +## VMware VDDK installation + +Install VMware VDDK on every KVM host that may run a VMware-to-KVM conversion or +CBT replication session. A common layout is: + +```bash +mkdir -p /opt/vmware +tar -xzf VMware-vix-disklib-*.tar.gz -C /opt/vmware +``` + +The extracted directory is usually named: + +```text +/opt/vmware/vmware-vix-disklib-distrib +``` + +CloudStack auto-detects the first directory named +`vmware-vix-disklib-distrib`. If auto-detection is not desirable, configure the +path explicitly in the KVM agent properties: + +```properties +vddk.lib.dir=/opt/vmware/vmware-vix-disklib-distrib +``` + +Optional VDDK settings: + +```properties +vddk.transports=nbdssl:nbd +vddk.thumbprint= +``` + +If `vddk.thumbprint` is not set, CloudStack attempts to compute the vCenter +thumbprint on the KVM host. + +## EL8 and Oracle Linux 8 host packages + +On an EL8-compatible KVM host, the CloudStack agent package already brings in +the normal KVM runtime dependencies such as libvirt, qemu-kvm, qemu-img, and +Python libvirt bindings. The VMware CBT path additionally needs virt-v2v, +nbdkit, the nbdkit VDDK plugin, qemu-nbd support, and VDDK itself. + +Example package installation: + +```bash +dnf install -y cloudstack-agent +dnf install -y virt-v2v nbdkit qemu-img qemu-kvm-core qemu-kvm-block-curl +dnf install -y nbdkit-plugin-vddk || dnf install -y nbdkit-vddk-plugin +``` + +Package names can differ between RHEL, Oracle Linux, Rocky, AlmaLinux, and +enabled module streams. The important validation is not the exact package name, +but that these commands work: + +```bash +virt-v2v --version +qemu-img --version +qemu-nbd --version +nbdkit vddk --version +``` + +For Windows guests, install a `virtio-win` package or otherwise make the +VirtIO drivers available to virt-v2v. Some EL8 environments provide this from +an additional repository rather than the base distribution repositories. + +If the nbdkit VDDK plugin package conflicts with the installed nbdkit module +stream, align the enabled AppStream or vendor repository so `nbdkit` and the +VDDK plugin come from compatible builds. + +## Ubuntu 24.04 host packages + +On Ubuntu 24.04, the base KVM and conversion packages are available from the +standard repositories, but the nbdkit VDDK plugin is not necessarily provided by +the default Ubuntu archive. Plan for an internal package, a vendor-provided +package, or a locally built nbdkit VDDK plugin. + +Example base package installation: + +```bash +apt-get update +apt-get install -y cloudstack-agent +apt-get install -y qemu-utils qemu-system-x86 libvirt-daemon-system libvirt-clients +apt-get install -y virt-v2v nbdkit libguestfs-tools +``` + +Then install or build the nbdkit VDDK plugin and verify: + +```bash +nbdkit vddk --version +``` + +`qemu-img` and `qemu-nbd` are provided by `qemu-utils` on Ubuntu 24.04. + +For Windows guests, ensure a `virtio-win` package or equivalent VirtIO driver +bundle is available. The current KVM agent check looks for a package named +`virtio-win` on Debian-based hosts, so an internal package with that name is +the easiest way to satisfy the current check. + +## Network and VMware requirements + +The selected KVM migration host must be able to reach: + +* vCenter HTTPS, normally TCP 443. +* ESXi hosts used for VDDK/NFC access, commonly TCP 902 for NFC paths. +* Any network paths required by the selected VDDK transport mode. +* The destination primary storage backend. + +The VMware account used for migration must be able to discover the VM, create +and remove snapshots, read virtual disk data, and query changed disk areas. + +VMware CBT must be enabled and valid for the source VM disks before incremental +cycles can be trusted. A full resync is required when VMware invalidates CBT +state, for example after CBT reset, certain disk changes, failed snapshot +consolidation, or storage operations that change disk backing identity. + +Avoid or explicitly handle VMware disk types that do not participate cleanly in +CBT, such as independent disks and unsupported raw device mappings. + +## Validation checklist + +Run this checklist on every candidate KVM migration host: + +```bash +virt-v2v --version +qemu-img --version +qemu-nbd --version +nbdkit vddk --version +test -f /opt/vmware/vmware-vix-disklib-distrib/lib64/libvixDiskLib.so +systemctl restart cloudstack-agent +``` + +Then confirm the host details from the CloudStack API or UI show: + +```text +host.vddk.support=true +host.vmware.cbt.support=true +host.qemu.img.version= +host.qemu.nbd.version= +host.virtv2v.version= +``` + +If `host.vmware.cbt.support` is false, check the agent log on the KVM host and +validate each command above manually. + +## Operational notes + +* Prefer `nbdssl` or another encrypted VDDK transport where supported. +* Run migrations from hosts with fast access to destination primary storage. +* Keep enough temporary space for virt-v2v and libguestfs scratch data; use + `convert.instance.env.tmpdir` and `convert.instance.env.virtv2v.tmpdir` when + the default temporary filesystem is too small. +* Use the current quiet-cycle settings to avoid cutting over while the source + VM is still producing a high dirty rate. +* Increase `vmware.cbt.migration.agent.command.timeout` before migrating very + large VMs if the baseline copy or cutover finalization can exceed 24 hours. +* Always clean up VMware snapshots after failed or cancelled cycles. +* Treat CBT change IDs as per-disk state. If any disk loses valid CBT state, + restart that disk from a full sync. +* On Linstor destinations, prefer a conversion host that holds a diskful + replica of the target resources; writes from a diskless satellite traverse + the DRBD replication network. DRBD rounds volume sizes up to its extent + granularity, so target devices can be slightly larger than the source + disks; qemu writes only within the source capacity. +* Linstor storage-pool heartbeats can fence (reboot) an HA-enabled KVM host if + the LINSTOR controller becomes unreachable. Prefer conversion hosts that do + not run HA-protected guests, or ensure controller availability for the whole + duration of long migrations. +* Migrated Linstor volumes keep their migration-time names (the LINSTOR + resource is `cs-` plus the recorded volume path, e.g. `cs-cbt--` + for warm migrations). This is expected and does not affect volume lifecycle + operations, which address volumes by their recorded path. diff --git a/docs/vmware-cbt/README.md b/docs/vmware-cbt/README.md new file mode 100644 index 000000000000..7e7c7f35291e --- /dev/null +++ b/docs/vmware-cbt/README.md @@ -0,0 +1,1829 @@ + + +# VMware CBT Migration Implementation Notes + +## Introduction + +This document describes the VMware Changed Block Tracking (CBT) migration design +and implementation for Apache CloudStack 4.24.0.0. It is written as source material +for an Apache CloudStack CWIKI architecture page and should be treated as an +implementation guide, not only as a design sketch. + +The feature provides a warm VMware-to-KVM migration flow. CloudStack creates a +KVM-side source-equivalent disk replica from a VMware VDDK snapshot, keeps that +replica current with one or more VMware CBT delta cycles, requires the operator +to power off the VMware source VM, runs one final CBT delta cycle, finalizes the +replica with `virt-v2v`, and imports the finalized disk(s) as a regular +CloudStack KVM VM. + +The essential architectural point is that VMware CBT migration is a two-phase +disk strategy: + +- Phase 1 keeps a KVM-side source-equivalent replica current using VDDK and + VMware CBT. +- Phase 2 finalizes the fully synchronized replica with `virt-v2v` only after + the source VM is powered off. + +This gives CloudStack warm migration behavior without trying to apply VMware +changed blocks to a disk that has already been transformed for KVM, and without +requiring a second full VMware read at cutover time. + +## Problem Statement + +The existing VMware import paths are useful for one-time imports, but they do +not provide a warm migration loop where the target disk is kept current while +the source VM continues to run on VMware. Large VMs therefore either require a +longer downtime window or repeated full-copy style attempts. + +VMware CBT provides the changed-block metadata needed for a replication-style +workflow, but the design must avoid a common trap: once `virt-v2v` modifies a +disk for KVM guest bootability, VMware CBT byte ranges can no longer be safely +applied to that transformed disk. The implementation must therefore keep the +replication target source-equivalent until final cutover, then run guest +conversion only after the final delta is applied. + +## Goals + +- Provide an operator-driven warm migration flow from VMware to CloudStack KVM. +- Support both existing registered vCenter records and external vCenter details + supplied during import. +- Run the initial full copy once through VDDK and `qemu-img`. +- Apply subsequent VMware CBT delta cycles to the KVM-side replica. +- Make long-running start, sync, and cutover operations asynchronous. +- Keep migration progress visible through list APIs and the UI migration table. +- Validate destination storage, host capabilities, and compute offering sizing + before expensive work starts. +- Persist external vCenter credentials only through CloudStack encrypted DAO + fields and never expose passwords in API responses. +- Reuse the existing CloudStack converted-VM import path after cutover + finalization. + +## Scope + +Current implementation scope: + +- Target hypervisor: KVM. +- Source hypervisor: VMware through VDDK and VMware CBT. +- Admin API surface for preflight, start, list, sync, cutover, cancel, and + delete. +- Async API execution for long-running start, sync, and cutover operations. +- Management-server state model for migrations, disks, and delta cycles. +- External vCenter credential persistence for long-lived UI sessions, encrypted + with CloudStack DAO encryption. +- KVM agent wrappers for initial VDDK full sync, delta block copy, cutover + finalization, and cleanup. +- Filesystem-like primary storage target support using QCOW2 files. +- Ceph/RBD primary storage target support using raw RBD images. +- Non-in-place `virt-v2v` fallback for QCOW2 file targets, gated by global + configuration. +- Early compute-offering validation against discovered VMware VM CPU and memory. +- Dynamic/custom service offering support through the same `details` keys used + by the existing OVF/VDDK import flow. +- UI integration in the Import/Export Instances area, including job polling and + table auto-refresh while CBT migrations are active. + +## Non-Goals + +Not implemented or intentionally blocked: + +- Non-in-place `virt-v2v` fallback for Ceph/RBD targets. RBD targets require + in-place finalization because fallback produces local QCOW2 files. +- Automatic source VM shutdown during cutover. +- Continuous byte-level progress from `qemu-img convert`. The UI currently + receives state transitions, disk target paths, delta cycle byte counts, dirty + rates, and cycle descriptions through the list API. +- Applying CBT deltas to a guest-converted disk. +- Replacing the existing OVF/VDDK one-time import flows. + +## Key Principles + +- CloudStack does not power off the VMware source VM automatically. The + operator must shut it down before final cutover. +- CloudStack does not store vCenter passwords in clear text. +- Initial sync and delta sync keep the target disk source-equivalent. +- `virt-v2v` runs only at finalization, after the final CBT delta cycle. +- The final `virt-v2v` work is local to the KVM-side replica and does not + perform a second full VMware read. +- The detailed status source is `listVmwareCbtMigrations`, not only the async + job result. +- Failed final import after successful finalization is retryable through the + `ReadyForImport` state. + +## High-Level Architecture + +The implementation has four main layers: + +- CloudStack management-server APIs and orchestration. +- CloudStack database state for migration sessions, source disks, and delta + cycles. +- KVM agent commands that perform VDDK reads, QCOW2 or raw RBD writes, + `virt-v2v` finalization, and cleanup. +- UI workflow in the Import/Export Instances area. + +Management-server API and orchestration: + +- `StartVmwareCbtMigrationCmd` +- `CheckVmwareCbtMigrationPrerequisitesCmd` +- `ListVmwareCbtMigrationsCmd` +- `SyncVmwareCbtMigrationCmd` +- `CutoverVmwareCbtMigrationCmd` +- `CancelVmwareCbtMigrationCmd` +- `DeleteVmwareCbtMigrationCmd` +- `VmwareCbtMigrationManagerImpl` +- `VmwareCbtStorageTarget` +- `VmwareCbtMigrationCutoverPolicy` + +Persistence: + +- `cloud.vmware_cbt_migration` +- `cloud.vmware_cbt_migration_disk` +- `cloud.vmware_cbt_migration_cycle` + +KVM agent wrappers: + +- `LibvirtVmwareCbtPrepareCommandWrapper` +- `LibvirtVmwareCbtSyncCommandWrapper` +- `LibvirtVmwareCbtCutoverCommandWrapper` +- `LibvirtVmwareCbtCleanupCommandWrapper` + +UI: + +- `ui/src/views/tools/ImportUnmanagedInstance.vue` +- `ui/src/views/tools/ManageInstances.vue` +- `ui/src/views/tools/VmwareCbtMigrations.vue` + +## Architecture Diagrams + +The diagrams below are intentionally plain text so they can be pasted directly +into Apache CWIKI source and remain editable. + +### End-To-End CBT Flow + +```text +VMware source VM + | + | VMware snapshot + CBT metadata + v +VDDK / nbdkit-vddk on KVM conversion host + | + | Initial baseline: full logical disk read + v +CloudStack primary storage + | + | Source-equivalent replica + | - qcow2 file on filesystem-like storage + | - raw RBD image on Ceph/RBD storage + v +Delta sync cycles + | + | CBT changed ranges only + v +Operator powers off source VM + | + | Cutover: final delta + virt-v2v finalization + v +CloudStack KVM VM +``` + +### Initial Sync Transfer Behavior + +VMware CBT initial sync is not guest-filesystem-used-space-oriented. CloudStack +does not inspect the guest filesystem to decide which files are in use; it reads +the disk image exposed by VDDK/NBD and writes the target replica. + +The amount of data transferred depends on what VDDK exposes for the source disk +and datastore. In lab testing, an NFS-backed VMware datastore exposed the initial +baseline as one fully allocated logical range, so a mostly empty disk still +transferred close to its full capacity. A VMFS6 datastore, after guest free-space +reclamation, exposed sparse `hole,zero` extents through the same VDDK/NBD path, +so the transfer was lower while the resulting target remained thin/sparse on +the destination backend. + +```text +Example VMware disk capacity: 5 GiB +Guest-used data: near 0 GiB +Datastore/path behavior: datastore-dependent + +During initial sync: + VMware/VDDK exposes allocation/extents to NBD. + CloudStack reads the ranges that qemu sees through that NBD export. + qemu-img writes a qcow2 target and can preserve thin/sparse output. + +Observed NFS-style outcome: approximately 5 GiB transfer +Observed VMFS6/reclaim case: sparse data/hole map, lower transfer +Target qcow2 file: sparse/thin when zero/hole ranges are preserved +``` + +Example VMFS6/reclaimed extent probe: + +```text +export-size: 5242880000 (5000M) + 0 4194304 0 data + 4194304 1048576 3 hole,zero + 5242880 3145728 0 data + 8388608 1048576 3 hole,zero + 9437184 1048576 0 data + 10485760 7340032 3 hole,zero +``` + +### Linked Clone And Full Clone Baselines + +The initial baseline reads the logical disk presented by VDDK. Linked clone vs +full clone is not enough by itself to predict transfer volume. The decisive +question is whether VDDK/NBD exposes trustworthy allocation and zero extents for +the source datastore and disk chain. + +```text +Linked clone source + child delta VMDK + | + v + parent/base VMDK chain + | + v + VDDK presents one logical disk + +Full clone source + single/full VMDK + | + v + VDDK presents one logical disk + +CBT initial sync: + both are read as the full logical disk capacity. + The target qcow2 can still be sparse/thin after the write. +``` + +### Delta Sync And Cutover Eligibility + +```text +Initial sync completed + | + v +Delta cycle 1 -> changed bytes / dirty rate measured + | + v +Delta cycle 2 -> quiet-cycle counters updated + | + v +Repeat until one boundary is met: + + min cycles satisfied + AND quiet bytes threshold satisfied + AND quiet dirty-rate threshold satisfied + AND quiet cycle count satisfied + +OR + + max cycle count reached + + | + v +Migration becomes ReadyForCutover +``` + +### Cutover Finalization Paths + +```text +Final CBT delta sync + | + v +Check selected conversion host capability + | + +--> virt-v2v-in-place available + | | + | v + | run virt-v2v-in-place finalization + | + +--> virt-v2v supports --in-place + | | + | v + | run virt-v2v --in-place finalization + | + +--> non-in-place fallback explicitly allowed + | + v + run regular virt-v2v -o local fallback finalization + +Result: + finalized qcow2 moved to primary storage root + VM imported with VirtIO root disk controller +``` + +### Final Disk Placement + +```text +During sync: + /mnt//cloudstack-cbt//... + +After finalization/import: + /mnt//.qcow2 + +CloudStack DB target path: + updated to the final primary-storage-root location +``` + +## End-To-End Flow + +### 1. Preflight + +`checkVmwareCbtMigrationPrerequisites` validates the source and destination +before a long-running job is started. The command is synchronous and returns a +structured preflight response with pass, warn, and fail findings. + +The preflight checks include: + +- Destination zone and KVM cluster lookup. +- Destination primary storage lookup or implicit pool selection. +- Storage target classification. +- KVM conversion host selection. +- Host VMware CBT capability checks. +- Host in-place finalization capability checks. +- Host qemu RBD capability checks and an active temporary RBD create/write/read/delete + probe when the selected primary storage is Ceph/RBD. +- vCenter source resolution, either by `existingvcenterid` or external + vCenter fields. +- Source VM discovery. +- Source VM CBT support. +- Source VM CBT enabled state. +- Source VM consolidation-needed state. +- Existing VMware snapshot count. +- Source disk discovery. +- Windows guest conversion dependency check on the selected KVM conversion host + when the source VM is a Windows guest. +- Optional compute offering sizing validation. + +The preflight response includes, among other fields: + +- `ready` +- `storagewritertype` +- `storagewritersupported` +- `storagerequiresinplacefinalization` +- `convertinstancehostinplacefinalizationsupported` +- `noninplacefinalizationfallbackallowed` +- `noninplacefinalizationfallbacksupported` +- `sourcecpunumber` +- `sourcecpuspeed` +- `sourcememory` +- `sourceguestosid` +- `sourceguestos` +- `disk` +- `finding` + +If CBT is supported but not enabled, preflight reports a warning. The start +path can enable CBT before creating the baseline snapshot. + +The source guest OS fields are populated from the VMware inventory and are used +to decide whether Windows-specific conversion prerequisites apply. They do not +change the migration mode or disk-copy behavior by themselves. + +### Guest OS Mapping And Source OS Labels + +VMware exposes more than one guest OS signal. The VM's configured guest OS +option can differ from the runtime guest OS label reported by VMware Tools. For +example, a powered-on VM can show `Ubuntu Linux (64-bit)` in the vSphere summary +while VM Options still show `Other (64-bit)`. CloudStack discovery and preflight +therefore treat source guest OS values as inventory facts rather than as a safe +automatic target OS selection. + +The target CloudStack guest OS is the optional `osid` supplied to +`startVmwareCbtMigration` and used later by the shared KVM import path. Operators +should verify this value before starting migration, especially for generic VMware +guest IDs such as `ubuntu64Guest`, which can represent multiple Ubuntu releases. +If the desired target guest OS is not selectable, the corresponding +`guest_os_hypervisor` mapping must exist for the source hypervisor version. + +### 2. Start And Initial Full Sync + +`startVmwareCbtMigration` is async. It creates the migration record, persists +source disk rows, creates a baseline VMware snapshot, prepares target paths, and +dispatches the initial full sync to the selected KVM host. + +For Windows source VMs, the start path repeats the same conversion dependency +check reported by preflight before the baseline snapshot and initial sync are +started. This prevents a long CBT copy from producing a disk that cannot be made +bootable because the selected KVM conversion host is missing `virtio-win`. + +State transitions: + +- `Created` +- `InitialSync` +- `Replicating` when the initial VDDK full sync completes successfully +- `Failed` if any initial copy or metadata step fails + +Important implementation details: + +- For filesystem-like storage, target paths are prepared before dispatch and + are shown in API/UI while the sync is running. +- Default target path pattern with explicit primary storage: + `/mnt//cloudstack-cbt//.qcow2` +- Default target path pattern without explicit pool: + `/var/lib/libvirt/images/cloudstack-cbt//.qcow2` +- The agent writes the vCenter password to a temporary password file and passes + it to `nbdkit` as `password=+`. +- After successful copy, management refreshes and records the baseline disk + `changeId` values from the VMware snapshot. +- The baseline snapshot is removed after the initial sync attempt when possible. + +### 3. Delta Synchronization + +`syncVmwareCbtMigration` is async and serialized by migration ID through +`BaseAsyncCmd.migrationSyncObject`. + +Allowed migration states: + +- `Replicating` +- `ReadyForCutover` + +Delta cycle sequence: + +1. Create a new `vmware_cbt_migration_cycle` row. +2. Create a VMware snapshot for the cycle. +3. Query VMware CBT changed disk areas from the previous per-disk `changeId`. +4. Persist cycle state as `QueryingChangedAreas`, then `CopyingChangedBlocks`. +5. Send changed block ranges to the KVM agent. +6. Agent copies only those ranges from the VDDK NBD source into the target + replica. +7. Management records changed bytes, duration, dirty rate, and the next + per-disk `changeId`. +8. The cycle snapshot is removed when possible. +9. The cutover policy decides whether to remain in `Replicating` or move to + `ReadyForCutover`. + +The cycle response exposes: + +- `cyclenumber` +- `snapshotmor` +- `changedbytes` +- `dirtyrate` +- `duration` +- `state` +- `description` +- `created` +- `lastupdated` + +Delta sync progress is represented as state and cycle metadata rather than a +streamed progress bar. While a cycle is running, the row description can show +what the manager knows, for example that changed areas are being queried or +that a number of ranges has been dispatched. After the agent returns, the cycle +shows copied bytes, dirty rate, duration, and a textual summary. + +### 4. Cutover + +`cutoverVmwareCbtMigration` is async and serialized by migration ID. + +Allowed migration states: + +- `ReadyForCutover` +- `ReadyForImport` + +Cutover requires the source VM to be powered off. CloudStack checks the VMware +power state and rejects cutover if the source VM is still powered on: + +```text +Cannot cut over VMware CBT migration while source VM is in power state PowerOn. +Gracefully shut down the source VM, then retry cutover. +``` + +CloudStack does not attempt a graceful shutdown or power-off. The operator must +shut down the VMware source VM before final cutover. This avoids surprising the +operator and avoids needing to encode site-specific guest shutdown policy in the +migration path. + +Cutover sequence: + +1. Verify source VM is powered off. +2. Set migration state to `CuttingOver`. +3. Run a final CBT delta sync while the VM is off. +4. Run `virt-v2v` finalization on the KVM host. +5. Move finalized disk files to the primary storage root with generated UUID + names, then update disk target paths from the agent result. +6. Set state to `ReadyForImport`. +7. Call the shared external KVM VM import path. +8. On success, set state to `Completed`, store the new CloudStack VM ID, and + clear stored external vCenter credentials. +9. On import failure, keep state as `ReadyForImport` so the operator can retry + cutover to repeat only the import step against already finalized disks. + +### 5. Cancel And Delete + +`cancelVmwareCbtMigration` is synchronous. If the migration is not terminal, it +sends a best-effort cleanup command, marks the migration `Cancelled`, and clears +stored external vCenter credentials. + +`deleteVmwareCbtMigration` is synchronous. It is allowed for `Failed`, +`Cancelled`, and `Completed` migrations. With `cleanup=true` (the default), it +sends a cleanup command before deleting failed or cancelled child rows and the +parent row. `Completed` migration deletion is always record-only: CloudStack +removes the CBT bookkeeping rows but never deletes the imported VM, CloudStack +volumes, finalized target disks, or primary-storage files. + +Deletion behavior: + +- Deletes `vmware_cbt_migration_cycle` rows. +- Deletes `vmware_cbt_migration_disk` rows. +- Deletes the `vmware_cbt_migration` row. +- Clears stored source credentials before row removal. +- For `Failed` or `Cancelled` rows, cleans target files only if the paths are + under the migration-specific `/cloudstack-cbt//` directory + and cleanup is enabled. For RBD targets, cleanup removes only temporary RBD + image names containing the CloudStack-owned `cloudstack-cbt--` + marker. +- For `Completed` rows, ignores cleanup and removes only CBT migration records. +- Skips immediate cleanup if another active migration is running on the same + conversion host, to avoid disturbing active agent-side work. + +The KVM cleanup wrapper resolves migration directories from disk target paths +and removes only the migration-specific directory tree for file targets. For RBD +targets it uses the destination storage pool and deletes only marked temporary +images, never arbitrary RBD images. + +## Conversion Model: One VMware Baseline Read + +The feature deliberately separates replication from final conversion. + +Initial full sync does not run `virt-v2v`. The KVM agent opens the VMware source +snapshot through `nbdkit` with the VDDK plugin and runs `qemu-img convert` +against the NBD URI. Filesystem-like primary storage writes QCOW2: + +```text +nbdkit -r -U - vddk ... --run \ + 'qemu-img convert -f raw -O qcow2 "$uri" ""' +``` + +Ceph/RBD primary storage writes a raw RBD image directly: + +```text +nbdkit -r -U - vddk ... --run \ + 'qemu-img convert -f raw -O raw "$uri" "rbd:/:..."' +``` + +This produces a source-equivalent replica on the selected primary storage. It +is not yet a CloudStack-imported VM disk. It is a replication target that can +safely receive raw changed blocks from later CBT cycles. + +Delta sync also does not run `virt-v2v`. Management creates a VMware snapshot, +queries changed disk areas using VMware CBT metadata, and dispatches changed +byte ranges to the KVM agent. The agent opens the snapshot through `nbdkit`, +parses the temporary nbdkit Unix socket from the NBD URI, uses `qemu-img +convert --image-opts` to materialize bounded raw chunks for only the changed +ranges, and patches the replica with `qemu-io`. File targets use `-f qcow2`; +RBD targets use `-f raw` against the qemu RBD URL for the target image. + +Final cutover is where `virt-v2v` runs. After the source VM is powered off, the +manager runs one final CBT delta cycle so the replica is current. Then the agent +runs one of the finalization paths: + +- `virt-v2v-in-place`, when available. +- `virt-v2v --in-place`, when the installed `virt-v2v` supports it. +- Regular `virt-v2v -o local`, only when the non-in-place fallback is explicitly + enabled and the target is a QCOW2 file target. + +Ceph/RBD targets require one of the in-place finalization paths. They do not +allow non-in-place fallback because fallback creates local QCOW2 output rather +than modifying the raw RBD image that CloudStack will import. + +So there is not a second full VMware read during finalization. The final +`virt-v2v` step is local work against the up-to-date KVM-side replica. It may +still require local storage reads and writes, especially in fallback mode, but +it is not another VDDK full copy from vCenter. + +Important caveat: the current initial full sync exposes the VMware snapshot as a +raw NBD source and lets `qemu-img convert` read it. In practice the initial +baseline reads the full logical VMware disk range: a linked clone, a full clone, +or even a mostly empty 5 GiB disk can still transfer roughly 5 GiB from VMware +during the first sync. This is expected VMware/VDDK baseline behavior and does +not mean the resulting target disk is thick. QCOW2 file targets and raw RBD +targets can still stay sparse or thin on the destination backend when zeroed +regions are written efficiently. Delta cycles are CBT-range based and copy only +changed ranges; the baseline copy is the part that still needs sparse/extent +optimization. + +This design avoids applying future CBT raw block deltas to a disk that has +already been modified by `virt-v2v`. Applying VMware CBT ranges to a converted +guest disk would be unsafe because the layout no longer represents the source +VMware disk byte-for-byte. + +## API Changes + +All commands are admin-only. + +### `checkVmwareCbtMigrationPrerequisites` + +Synchronous preflight command. + +Required: + +- `zoneid` +- `clusterid` +- `sourcevmname` + +Source vCenter selection: + +- Existing registered vCenter: `existingvcenterid` +- External vCenter: `vcenter`, `datacentername`, `username`, `password` + +Optional: + +- `hostip` +- `clustername` +- `convertinstancehostid` +- `convertinstancestoragepoolid` +- `serviceofferingid` +- `details` + +The command has `requestHasSensitiveInfo=true` because external vCenter +credentials can be provided. + +### `startVmwareCbtMigration` + +Async command. Starts the migration and initial full sync. + +Required: + +- `zoneid` +- `clusterid` +- `serviceofferingid` +- `sourcevmname` + +Source vCenter selection: + +- Existing registered vCenter: `existingvcenterid` +- External vCenter: `vcenter`, `datacentername`, `username`, `password` + +Optional import inputs: + +- `displayname` +- `hostname` +- `account` +- `domainid` +- `projectid` +- `templateid` +- `osid` +- `datadiskofferinglist` +- `nicnetworklist` +- `nicipaddresslist` +- `forced` + +Optional conversion inputs: + +- `convertinstancehostid` +- `convertinstancestoragepoolid` +- `details` + +If `convertinstancestoragepoolid` is omitted, CloudStack auto-selects the only +CBT-compatible primary storage pool in the destination cluster/zone. Supported +target pool types are `NetworkFilesystem`, `Filesystem`, `SharedMountPoint`, +and `RBD`. If more than one compatible pool is available, the operator must +provide the pool explicitly. + +Useful `details` keys: + +- `vddk.lib.dir` +- `vddk.transports` +- `vddk.thumbprint` +- `cpuNumber` +- `cpuSpeed` +- `memory` + +The command has `requestHasSensitiveInfo=true` and +`responseHasSensitiveInfo=false`. + +### `listVmwareCbtMigrations` + +Synchronous status command and the main progress source for UI and automation. + +Filters: + +- `id` +- `zoneid` +- `accountid` +- `vcenter` +- `sourcevmname` +- `state` + +The response includes migration-level state, current step, current step +duration, last error, cycle counters, changed byte totals, disks, and cycles. +`currentstepduration` is populated for non-terminal migrations and is intended +for the UI table marker similar to Import VM Tasks. + +### `syncVmwareCbtMigration` + +Async command. Runs one delta sync cycle. + +Required: + +- `id` + +Optional: + +- `username` +- `password` + +For an external vCenter migration, stored credentials are used when available. +If override credentials are supplied, both username and password must be +provided. Overrides are stored back to the migration so a UI operator can return +later and continue syncing. + +### `cutoverVmwareCbtMigration` + +Async command. Runs final powered-off sync, finalizes disks, and imports the VM. + +Required: + +- `id` + +Optional: + +- `username` +- `password` + +The command fails early if the source VMware VM is not powered off. + +### `cancelVmwareCbtMigration` + +Synchronous command. + +Required: + +- `id` + +Cancels a non-terminal migration, attempts cleanup, and clears stored external +source credentials. + +### `deleteVmwareCbtMigration` + +Synchronous command. + +Required: + +- `id` + +Optional: + +- `cleanup` (default `true`) + +Allowed for `Failed`, `Cancelled`, and `Completed` migrations. Completed +migration deletes are record-only and never remove the imported CloudStack VM, +volumes, finalized target disks, or primary-storage files. + +## Configuration And Cutover Policy + +The cutover recommendation is based on global settings: + +| Global setting | Default | Meaning | +| --- | ---: | --- | +| `vmware.cbt.migration.min.cycles` | `1` | Minimum number of manual/API delta cycles that must complete before quiet-cycle readiness can make the migration eligible for cutover. | +| `vmware.cbt.migration.max.cycles` | `5` | Maximum number of manual/API delta cycles before CloudStack marks the migration ready for cutover even if the quiet-cycle thresholds were not met. | +| `vmware.cbt.migration.quiet.cycles` | `2` | Number of consecutive quiet cycles required before CloudStack marks the migration ready for cutover. | +| `vmware.cbt.migration.quiet.bytes` | `1073741824` | Maximum changed bytes in one cycle for that cycle to be considered quiet. The default is 1 GiB. | +| `vmware.cbt.migration.quiet.dirty.rate` | `16777216` | Maximum changed bytes per second for that cycle to be considered quiet. The default is 16 MiB/s. | +| `vmware.cbt.migration.agent.command.timeout` | `86400` | Timeout in seconds for long-running VMware CBT data-plane commands on the KVM agent. This covers initial full sync, regular delta sync, final delta sync, and cutover finalization. The default is 24 hours. | + +The manager tracks: + +- Number of completed delta cycles. +- Number of consecutive quiet cycles. +- Last changed bytes. +- Last dirty rate. +- Total changed bytes. + +A cycle is quiet when its changed bytes and dirty rate are below the configured +thresholds. Both thresholds must pass. For example, with defaults, a cycle that +copies 1.2 GiB at 10 MiB/s is not quiet because the changed-byte threshold is +exceeded even though the dirty-rate threshold passed. + +Once enough quiet cycles have accumulated, or once the maximum manual/API +delta-cycle count is reached, the migration moves to `ReadyForCutover`. + +The operator can still manually run sync cycles while the migration is in +`Replicating` or `ReadyForCutover`. + +The UI intentionally exposes the Cutover button only when the server-side +migration state is exactly `ReadyForCutover`. Before that point the UI exposes +Sync delta, because the server has not yet recommended final cutover. If a +cutover is started, CloudStack creates one additional final CBT delta cycle +inside the cutover operation. Therefore the final displayed cycle count can be +one higher than `vmware.cbt.migration.max.cycles`: for example, five ordinary +delta cycles can make the migration ready, and cutover can then record cycle 6 +as the final synchronization cycle. + +### Timeouts And Large VMs + +The long-running CBT work is done by KVM agent commands. CloudStack sets the +agent command `wait` value from +`vmware.cbt.migration.agent.command.timeout`, and the KVM wrappers use the same +value as the timeout for child processes such as `nbdkit`, `qemu-img`, and +`virt-v2v`. + +The timeout is finite by design. The shared CloudStack `Script` helper treats a +zero timeout as its default one-hour timeout, so `0` is not a safe way to mean +"run forever". For large source disks, tune the timeout above the expected +worst-case duration. A rough estimate is: + +```text +timeout_seconds >= source_bytes / expected_bytes_per_second * safety_factor +``` + +For example, a 1 TiB initial sync at 100 MiB/s is roughly 3 hours before +overhead; at 25 MiB/s it is roughly 12 hours. The default 24-hour timeout is +intended to be safe for large lab and production migrations while still +protecting the agent from permanently stuck child processes. + +Related import settings that operators often notice are: + +| Global setting | Applies to CBT? | Default | Meaning | +| --- | --- | ---: | --- | +| `convert.vmware.instance.to.kvm.timeout` | No | `3` | OVF/VDDK import timeout, in hours, for the `ConvertInstanceCommand` virt-v2v path. CBT cutover has its own timeout because it uses CBT-specific agent commands. | +| `remote.kvm.instance.disks.copy.timeout` | No | `30` | Remote KVM unmanaged import disk-copy timeout, in minutes. It is unrelated to VMware CBT. | + +If a CBT command exceeds `vmware.cbt.migration.agent.command.timeout`, the +migration is marked failed with the agent error details in `last_error`. +Failed or cancelled migration records can be deleted with cleanup enabled to +remove CloudStack-owned `cloudstack-cbt/` working files. + +## Database Changes + +The feature targets the Apache CloudStack 4.24.0.0 release, so the schema changes +are delivered through the 4.24.0.0 upgrade path: + +- `engine/schema/src/main/resources/META-INF/db/schema-42300to42400.sql` +- `engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42300to42400.java` + +> Until `main` opens `4.24.0.0-SNAPSHOT`, the DDL rides in the current +> in-development `schema-42210to42300.sql`; it is moved to the 4.24.0.0 upgrade +> file before merge. + +### `cloud.vmware_cbt_migration` + +One row per migration session. + +Important columns: + +- `uuid` +- `zone_id` +- `account_id` +- `user_id` +- `vm_id` +- `existing_vcenter_id` +- `source_username` +- `source_password` +- `destination_cluster_id` +- `convert_host_id` +- `storage_pool_id` +- `display_name` +- `host_name` +- `template_id` +- `service_offering_id` +- `guest_os_id` +- `data_disk_offering_map` +- `nic_network_map` +- `nic_ip_address_map` +- `import_details` +- `forced` +- `vcenter` +- `datacenter` +- `source_host` +- `source_cluster` +- `source_vm_name` +- `vddk_lib_dir` +- `vddk_transports` +- `vddk_thumbprint` +- `state` +- `current_step` +- `last_error` +- `completed_cycles` +- `quiet_cycles` +- `total_changed_bytes` +- `last_changed_bytes` +- `last_dirty_rate` +- `created` +- `updated` +- `removed` + +`source_password` is annotated with `@Encrypt` in `VmwareCbtMigrationVO`, using +the same CloudStack encrypted DAO field mechanism used by VMware datacenter +credentials and VM VNC passwords. + +### `cloud.vmware_cbt_migration_disk` + +One row per source VMware disk. + +Important columns: + +- `migration_id` +- `source_disk_id` +- `source_disk_device_key` +- `source_disk_path` +- `datastore_name` +- `capacity_bytes` +- `target_path` +- `target_format` +- `change_id` +- `snapshot_moref` +- `state` + +Disk states: + +- `Created` +- `Prepared` +- `Syncing` +- `Ready` +- `Failed` + +### `cloud.vmware_cbt_migration_cycle` + +One row per delta cycle. + +Important columns: + +- `migration_id` +- `cycle_number` +- `snapshot_moref` +- `changed_bytes` +- `dirty_rate` +- `duration` +- `state` +- `description` + +Cycle states: + +- `Created` +- `QueryingChangedAreas` +- `CopyingChangedBlocks` +- `Completed` +- `Failed` + +## Migration State Machine + +Migration states: + +- `Created` +- `InitialSync` +- `Replicating` +- `ReadyForCutover` +- `CuttingOver` +- `ReadyForImport` +- `Completed` +- `Failed` +- `Cancelled` + +Terminal states: + +- `Completed` +- `Failed` +- `Cancelled` + +Normal successful path: + +```text +Created + -> InitialSync + -> Replicating + -> ReadyForCutover + -> CuttingOver + -> ReadyForImport + -> Completed +``` + +`ReadyForImport` is intentionally retryable. If the final `virt-v2v` +finalization succeeded but CloudStack VM import failed, the next cutover command +does not rerun the final CBT copy and finalization. It retries the VM import +against the already finalized disk paths. + +## Storage Target And Finalization + +`VmwareCbtStorageTarget` maps primary storage to a target writer: + +- `NetworkFilesystem`, `Filesystem`, and `SharedMountPoint` use `QCOW2_FILE`. +- No explicit pool also uses a filesystem QCOW2 target under the default local + CBT path. +- `RBD` maps to `RBD_RAW`. Initial sync writes a raw RBD image directly with + `qemu-img convert -O raw`, delta sync writes changed ranges with `qemu-io -f + raw`, and cutover finalization is in-place only. +- `Linstor` maps to `RAW_BLOCK_DEVICE`. The agent pre-creates each target + volume at source capacity through the Linstor storage adaptor (qemu cannot + create DRBD devices the way it creates RBD images), then the initial sync + writes the local DRBD device with `qemu-img convert -n -O raw`, delta sync + patches changed ranges with `qemu-io -f raw` against the device path, and + cutover finalization is in-place only, using a plain `` + libvirt XML (no qemu-nbd bridges are needed for local block devices). + Target names are `cbt--` where `mig8` is the first eight + characters of the migration UUID: LINSTOR resource names ("cs-" + name) are + capped at 48 characters, so the RBD-style naming with the full migration + UUID does not fit; the short marker still guards cleanup on both the + management server and the agent. The conversion host must be a LINSTOR + satellite connected to the pool; host selection and the preflight storage + probe (create a 4 MiB volume, qemu-io write/read on the device, delete) + enforce this. +- Other primary storage types are unsupported. + +For QCOW2 file targets, the initial replica is stored under the selected +primary storage: + +```text +/mnt//cloudstack-cbt//.qcow2 +``` + +If no storage pool is selected, the fallback path is: + +```text +/var/lib/libvirt/images/cloudstack-cbt//.qcow2 +``` + +For Ceph/RBD targets, the initial replica is a raw RBD image in the selected +pool: + +```text +/cloudstack-cbt--- +``` + +Only the image name is persisted as the migration disk target path. The agent +reconstructs the qemu RBD URL from the selected storage pool when writing +initial and delta data. During in-place `virt-v2v` finalization, the agent +starts a temporary localhost `qemu-nbd` bridge for each RBD image and gives +`virt-v2v` a libvirt XML disk that points to that local NBD endpoint. This keeps +RBD credentials and monitor options in the qemu layer while avoiding the +libguestfs/RBD XML path that failed to expose usable disks in testing. + +The preferred cutover finalization is in-place: + +- `virt-v2v-in-place`, if present. +- `virt-v2v --in-place`, if the installed `virt-v2v` supports it. + +If neither in-place method is available, QCOW2 file targets can use regular +`virt-v2v -o local` only when this global configuration is enabled: + +```text +vmware.cbt.allow.non.inplace.finalization=true +``` + +Default: + +```text +false +``` + +When fallback is enabled: + +- The fallback is only allowed for supported QCOW2 file targets. +- The agent stages both `TMPDIR` and `virt-v2v` output under the current + migration directory on the selected primary storage. +- The agent validates free space before running fallback finalization. +- Required free space is conservatively estimated as two times the summed disk + capacity. +- After successful fallback finalization, source replica disks are deleted. + +Fallback is never allowed for RBD targets. RBD migrations require +`virt-v2v-in-place` or `virt-v2v --in-place` support on the selected conversion +host. + +Example fallback output path: + +```text +/mnt//cloudstack-cbt//virt-v2v-output-/-sda +``` + +After successful finalization, both in-place and fallback paths are normalized +to the same final layout used by the OVF/VDDK import flow: the KVM agent moves +each finalized QCOW2 file to the selected primary storage root and gives it a +generated UUID filename: + +```text +/mnt// +``` + +The agent returns the relocated target paths in the cutover answer. The +management server persists them in `vmware_cbt_migration_disk.target_path` +through the normal disk-result update path, so no manual database update or +schema change is required for this relocation. CloudStack then imports the VM +using the flat root-level file name, matching the final placement used by +regular OVF/VDDK migrations. + +For RBD targets, successful in-place finalization keeps the raw RBD image in +place and CloudStack imports it by image name from the selected RBD storage +pool. No post-finalization file move is performed, and completed migration +deletion remains record-only. + +RBD finalization creates only temporary localhost `qemu-nbd` processes and +temporary XML/script files on the conversion host. The wrapper installs a shell +trap to stop those bridge processes and remove the PID files after `virt-v2v` +finishes or fails. The persistent storage artifact remains the raw RBD image +whose name contains the `cloudstack-cbt--` marker until +CloudStack imports it or cleanup removes it for a failed/cancelled migration. + +CloudStack imports finalized CBT disks with the KVM `virtio` root disk +controller. The conversion host must have `virtio-win` available for Windows +guests so that `virt-v2v` can enable the matching boot driver before the VM is +started under KVM. This keeps the persisted `rootDiskController` detail aligned +with the controller model used by the converted guest. + +## Ceph/RBD Conversion Host Requirements + +Ceph/RBD support relies on the same KVM host plumbing that normal CloudStack RBD +primary storage uses, plus the CBT-specific VMware conversion tools. The +selected conversion host must be able to access the destination RBD pool from +both Java/libvirt storage code and the qemu command-line tools used by the CBT +wrappers. + +Required conversion-host capabilities: + +- CloudStack KVM agent with the normal RBD primary-storage dependencies. +- The selected KVM/conversion host must already be able to use the destination + CloudStack RBD primary storage pool. +- `librados` and `librbd` client libraries compatible with the Ceph cluster. +- Java RADOS/RBD bindings used by CloudStack's RBD storage adaptor. +- qemu RBD block driver support so `qemu-img` and `qemu-io` can open + `rbd:/:...` URLs. +- VDDK and `nbdkit-vddk` for VMware source reads. +- `virt-v2v-in-place` or `virt-v2v --in-place` for RBD cutover finalization. +- `virtio-win` or equivalent VirtIO driver media/packages for Windows guests, + same as the existing VMware import requirements. + +CloudStack RBD primary-storage configuration must be valid on the selected +KVM/conversion host. For CBT-to-RBD, CloudStack uses the existing KVM +storage-pool metadata to build qemu RBD URLs, including monitor addresses, +authentication mode, Ceph user, and secret. The CBT qemu path therefore does not +rely on `/etc/ceph/ceph.conf` or a local keyring as its primary credential source +when CloudStack supplies explicit RBD URL options. + +A local Ceph configuration and keyring can still be useful for operator +diagnostics and site-local tooling, for example running `ceph -s`, +`rbd ls `, or similar commands without manually passing monitor and +authentication options each time. Sites may choose to install those files for +operational convenience, but they should not be documented as the required +credential source for CBT RBD writes. + +The Ceph client packages on every KVM/conversion host should match the Ceph +cluster major release, or at least be explicitly supported by that cluster +release. Avoid relying on the distribution's stock Ceph packages if they are +older than the deployed Ceph cluster. Ancient `librbd`, `librados`, qemu RBD +block-driver, or Java binding packages can fail in ways that look like CBT +errors even though the actual problem is a client/cluster compatibility mismatch. + +Practical guidance: + +- If the Ceph cluster is deployed from upstream Ceph packages, install the + corresponding upstream Ceph client repository on all KVM/conversion hosts. +- If the Ceph cluster is deployed from a vendor repository, use that vendor's + matching client packages on the KVM/conversion hosts. +- Keep `ceph-common`, `librados`, `librbd`, qemu RBD block-driver packages, and + Java RADOS/RBD bindings aligned as a set. Do not mix a modern Ceph cluster + with old OS-default client libraries unless the Ceph vendor explicitly + supports that combination. +- Verify the KVM host can already use the selected RBD primary storage for + ordinary CloudStack VM volumes before enabling CBT migrations to the same + pool. +- Verify that qemu tools can see RBD support. Package names vary by OS, but on + many EL-like distributions this is provided by a qemu RBD block-driver package + such as `qemu-kvm-block-rbd`; on Debian/Ubuntu-like systems it is commonly + part of qemu block extras. + +CBT preflight and `startVmwareCbtMigration` actively probe RBD access when the +selected destination pool is RBD. The management server sends the selected +conversion host a small probe command using a temporary image named +`cloudstack-cbt-probe-`. The KVM agent builds the qemu RBD URL from the +existing CloudStack storage-pool metadata, runs `qemu-img create`, writes and +reads a 4 KiB pattern with `qemu-io`, and removes the temporary image through +CloudStack's existing RBD storage adaptor. This catches qemu RBD driver issues, +Ceph monitor/authentication problems, and Java RADOS/RBD cleanup failures before +the long-running VMware copy starts. + +Useful host checks: + +```bash +ceph -s +ceph osd lspools +rbd ls +qemu-img --help | grep -i rbd +qemu-io --help | grep -i rbd +``` + +Useful package/version checks: + +```bash +ceph --version +rbd --version +ldconfig -p | egrep 'librbd|librados' +java -version +``` + +Useful CloudStack database check after the host reconnects: + +```sql +SELECT h.id, h.name, hd.name, hd.value +FROM cloud.host h +JOIN cloud.host_details hd ON hd.host_id = h.id +WHERE h.name = '' + AND hd.name IN ( + 'host.vddk.blockcopy.support', + 'host.vddk.blockcopy.inplace.finalization.support', + 'host.vddk.blockcopy.rbd.support', + 'host.vddk.support', + 'host.vddk.version', + 'host.virtv2v.inplace.version', + 'vddk.lib.dir' + ) +ORDER BY hd.name; +``` + +An RBD-capable conversion host should already report VMware CBT capability as +`true` before a migration starts, and the selected destination storage pool must +be the RBD pool that the host can access. If these host details are stale after +installing packages, restart `cloudstack-agent`, ensure the management server is +running, and wait for host details to be refreshed. + +## Host Capability Reporting + +The agent checks these on startup/ready and sends them as host details. Management then persists them into `cloud.host_details`. + +| Host detail | How agent checks it | +| --- | --- | +| `host.virtv2v.version` | Runs `virt-v2v --version`. On Ubuntu/Debian it also checks `dpkg -l nbdkit`. Version is parsed from `virt-v2v --version`. | +| `vddk.lib.dir` | Reads `vddk.lib.dir` from `agent.properties`; if blank/invalid, auto-detects with `find / -type d -name 'vmware-vix-disklib-distrib' 2>/dev/null \| head -n 1`. Valid means `/lib64/libvixDiskLib.so*` exists. | +| `host.vddk.version` | Runs `nbdkit vddk --dump-plugin libdir=` and parses `vddk_library_version=`. | +| `host.vddk.support` | True only if `virt-v2v`/`nbdkit` support is present, VDDK dir is valid, and `nbdkit-vddk-plugin` can load VDDK via `nbdkit vddk --dump-plugin libdir=`. | +| `host.qemu.img.version` | Runs `qemu-img --version`, parses first non-empty line. | +| `host.qemu.nbd.version` | Runs `qemu-nbd --version`, parses first non-empty line. | +| `host.qemu.io.version` | Runs `qemu-io --version`, parses first non-empty line. | +| `host.vddk.blockcopy.support` | True only if `host.vddk.support` is true and `qemu-img --version`, `qemu-nbd --version`, and `qemu-io --version` all exit `0`. | +| `host.vddk.blockcopy.inplace.finalization.support` | True only if CBT support is true and either `virt-v2v-in-place --version` works, or `virt-v2v --help 2>&1 \| grep -q -- '--in-place'` succeeds. | +| `host.virtv2v.inplace.version` | If `virt-v2v-in-place` exists, parses `virt-v2v-in-place --version`. If only `virt-v2v --in-place` exists, reports the normal `virt-v2v` version. | +| `host.vddk.blockcopy.rbd.support` | True only if in-place finalization support is true and `qemu-img --help` advertises the `rbd` block driver. | + +Important nuance: `host.vddk.blockcopy.rbd.support` is only the coarse host capability. For an actual selected RBD pool, preflight/start also runs an active probe: create a temporary `cloudstack-cbt-probe-` RBD image, write/read 4 KiB using `qemu-io`, then delete it through CloudStack's RBD storage adapter. That catches Ceph auth, monitor, qemu RBD, librados/librbd, and Java binding problems. + +Windows source VMs also require `virtio-win` on the selected KVM conversion +host. CBT preflight and `startVmwareCbtMigration` use the same +`CheckConvertInstanceCommand` path as OVF/VDDK import with Windows guest +conversion enabled, so a missing `virtio-win` package is reported before the +initial sync starts. + +VDDK detection uses either agent configuration or auto-detection. The agent +validates the VDDK library by checking that `lib64/libvixDiskLib.so*` exists +and that `nbdkit vddk --dump-plugin libdir=` reports a library version. + +## VDDK Details + +VDDK options can be provided as API `details` at migration start: + +- `vddk.lib.dir` +- `vddk.transports` +- `vddk.thumbprint` + +If a value is not provided by the API, the agent uses its configured or detected +value. If `vddk.thumbprint` is not provided, the agent attempts to fetch the +vCenter SHA1 certificate thumbprint with `openssl`. + +Lab note: the OL8 validation environment required the Linux VDDK package and a +VDDK build compatible with the host OpenSSL libraries. The implementation does +not hard-code one VDDK version; it relies on the agent capability check. + +## Credential Handling + +There are two source credential modes: + +- Existing registered vCenter: credentials are read from the registered + `vmware_data_center` record. +- External vCenter: credentials are supplied to start/preflight and stored on + the migration record so later UI sync/cutover actions can run without asking + again. + +External credential behavior: + +- `source_password` is stored through the CloudStack encrypted DAO field + mechanism via `@Encrypt`. +- Start, preflight, sync, and cutover commands that can carry credentials are + marked `requestHasSensitiveInfo=true`. +- API responses are marked `responseHasSensitiveInfo=false`. +- Agent-side VDDK commands pass the password through a temporary password file + and `nbdkit` `password=+`, not by embedding the clear-text password in + the command line. +- Management sanitizes returned error messages by replacing the resolved source + password with `******` before storing `last_error` or cycle descriptions. +- Stored external credentials are cleared when the migration completes, + when it is cancelled, and before deletion. + +The implementation avoids logging clear-text passwords. As with other +CloudStack secret handling, this also depends on callers not placing secrets in +unrelated free-form log messages. + +## Compute Offering And Preflight Validation + +CBT migration uses the same eventual import path as the existing OVF/VDDK VM +import code. The final CloudStack VM sizing still comes from the selected +service offering and any dynamic offering details. + +The CBT-specific difference is timing: CBT validates obvious sizing problems +early, before the long initial full sync begins. This is meant to avoid spending +hours copying disks only to fail final VM import because the selected offering +is too small. + +The source VM sizing captured during preflight/start includes: + +- `sourcecpunumber` +- `sourcecpuspeed` +- `sourcememory` + +Validation rule: + +- If the source value and requested value are both known and greater than zero, + the requested value must be greater than or equal to the source value. +- Unknown or zero source values are not used to reject the offering. + +Validated fields: + +- CPU number +- CPU speed +- Memory + +Fixed offerings: + +- CPU number comes from `service_offering.cpu`. +- CPU speed comes from `service_offering.speed`. +- Memory comes from `service_offering.ram_size`. + +Dynamic/custom offerings: + +- Caller details can provide `cpuNumber`, `cpuSpeed`, and `memory`. +- If caller details are missing, offering minimums such as `mincpunumber` and + `minmemory` can be used where applicable. +- This mirrors the shared import behavior in + `UnmanagedVMsManagerImpl.addServiceOfferingDetailsToParams`. + +Example custom offering details: + +```text +details[0].key=cpuNumber +details[0].value=4 +details[1].key=cpuSpeed +details[1].value=2100 +details[2].key=memory +details[2].value=4096 +``` + +This early validation is not intended to be stricter than OVF/VDDK import. It +is intended to fail earlier for the same class of sizing mismatch. + +## UI Changes + +The CBT UI is integrated into the Import/Export Instances area. + +Implemented UI behavior: + +- Start migration uses the async `startVmwareCbtMigration` job. +- Sync delta uses the async `syncVmwareCbtMigration` job. +- Cutover uses the async `cutoverVmwareCbtMigration` job. +- Each async action uses CloudStack job polling. +- The CBT migration table also calls `listVmwareCbtMigrations` as the detailed + status source. +- The table auto-refreshes while the CBT tab is active and any migration is in + an active state or a CBT action job is still running. + +Active states for UI refresh: + +- `Created` +- `InitialSync` +- `Replicating` +- `CuttingOver` + +The table exposes: + +- Migration state. +- Current step. +- Completed cycles. +- Quiet cycles. +- Last changed bytes. +- Last dirty rate. +- Total changed bytes. +- Last error. +- Per-disk target path, target format, change ID, snapshot MOR, and state. +- Per-cycle state, changed bytes, dirty rate, duration, description, created, + and updated timestamps. + +Initial sync progress is currently coarse. While `qemu-img convert` runs on the +agent, management can show the migration state, current step, target path, and +disk state, but not a continuously updated percentage. Delta syncs have better +post-cycle metrics because changed bytes and dirty rate are returned by the +cycle result. + +## Failure And Retry Semantics + +Initial full sync failure: + +- Migration moves to `Failed`. +- Created or syncing disks are marked `Failed`. +- Last error is stored on the migration. +- Delete with cleanup can remove the migration directory. + +Delta sync failure: + +- The cycle is marked `Failed`. +- The migration moves to `Failed`. +- Last error and cycle description are stored. +- VMware cycle snapshot removal is still attempted. + +Cutover finalization failure: + +- Migration moves to `Failed`. +- The source VM remains under operator control in vCenter. +- Delete with cleanup can remove replicated/finalized target directories if the + paths are under the migration directory. + +Import failure after successful finalization: + +- Migration remains `ReadyForImport`. +- Target disk paths point at the finalized disk files. +- Retrying cutover retries the import step. + +Cancel: + +- Best-effort cleanup. +- State becomes `Cancelled`. +- Stored external credentials are cleared. + +Delete: + +- `Failed`, `Cancelled`, or `Completed`. +- Optional cleanup, default `true`, applies only to `Failed` and `Cancelled`. +- `Completed` deletion is record-only and intentionally preserves the imported + VM and all destination storage artifacts. +- Child rows and parent row are removed. + +## Security And Logging Considerations + +Important security choices: + +- Credential-bearing commands are marked sensitive. +- External vCenter password storage uses encrypted DAO fields. +- Passwords are passed to VDDK through temp files instead of clear-text command + arguments. +- Password temp files are owner-readable and owner-writable where POSIX + permissions are supported. +- Returned failure messages are sanitized against the active source password + before persistence. +- CBT delta and cutover agent wrappers include the last useful command output + line in returned failure details when available. This keeps management server + and UI errors actionable for `qemu-nbd`, `qemu-io`, `nbdkit`, and `virt-v2v` + failures without logging or returning clear-text passwords. + +Operational caution: + +- `source_username` is not encrypted. +- vCenter host, datacenter, VM name, datastore names, disk paths, change IDs, + and snapshot MORs are not treated as secrets and can appear in API responses + and logs. +- Operators should still avoid manually pasting credentials into log-visible + free-form fields. + +## Testing Strategy + +Relevant unit coverage includes: + +- `VmwareCbtMigrationOfferingValidationTest` +- `VmwareCbtMigrationDeletePolicyTest` +- `VmwareCbtStorageTargetTest` +- `VmwareCbtMigrationCutoverPolicyTest` +- `LibvirtVmwareCbtSyncCommandWrapperTest` +- `VmwareCbtSyncPlanTest` +- `LibvirtVmwareCbtCutoverCommandWrapperTest` +- `LibvirtVmwareCbtCleanupCommandWrapperTest` +- `LibvirtVmwareCbtRbdProbeCommandWrapperTest` +- `LibvirtStoragePoolTest` + +The tests cover: + +- Fixed offering validation. +- Dynamic/custom offering detail handling. +- Storage target classification. +- Non-in-place finalization gating. +- Cutover policy decisions. +- Delta copy script planning. +- Chunked changed-range reads from the nbdkit Unix socket. +- RBD probe create/write/read/delete command planning. +- RBD finalization through temporary localhost `qemu-nbd` bridges. +- Fallback `virt-v2v` output path handling. +- Cleanup scoping to migration directories. +- Cleanup scoping to migration-owned RBD image names. +- Nested relative KVM volume path resolution under filesystem pools. + +## Operational Notes For Lab Validation + +Useful host capability check: + +```bash +cmk list hosts name= details=all | egrep \ +'host.vddk.blockcopy.support|host.vddk.blockcopy.inplace.finalization.support|host.vddk.blockcopy.rbd.support|host.vddk.support|host.vddk.version|host.virtv2v.version|host.virtv2v.inplace.version|host.qemu.img.version|host.qemu.nbd.version|host.qemu.io.version|vddk.lib.dir' +``` + +Useful VDDK plugin check on the KVM host: + +```bash +nbdkit vddk --dump-plugin libdir=/opt/vmware-vix-disklib-distrib | \ + egrep 'vddk_library_version|vddk_dll|vddk_transport_modes' +``` + +Useful process check during initial sync: + +```bash +pgrep -af 'nbdkit|qemu-img' +``` + +Useful target disk inspection: + +```bash +qemu-img info /mnt//cloudstack-cbt//.qcow2 +``` + +Manual SQL for labs that do not run the 4.24.0.0 upgrade path: + +```sql +INSERT INTO `cloud`.`configuration` + (`category`, `instance`, `component`, `name`, `value`, `description`, + `default_value`, `updated`, `scope`, `is_dynamic`) +VALUES + ('Advanced', 'DEFAULT', 'VmwareCbtMigrationManagerImpl', + 'vmware.cbt.allow.non.inplace.finalization', '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.', + 'false', NOW(), 1, 1) +ON DUPLICATE KEY UPDATE + `category` = VALUES(`category`), + `component` = VALUES(`component`), + `description` = VALUES(`description`), + `default_value` = VALUES(`default_value`), + `updated` = NOW(), + `scope` = VALUES(`scope`), + `is_dynamic` = VALUES(`is_dynamic`); + +INSERT INTO `cloud`.`configuration` + (`category`, `instance`, `component`, `name`, `value`, `description`, + `default_value`, `updated`, `scope`, `is_dynamic`) +VALUES + ('Advanced', 'DEFAULT', 'VmwareCbtMigrationManagerImpl', + 'vmware.cbt.migration.agent.command.timeout', '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.', + '86400', NOW(), 1, 1) +ON DUPLICATE KEY UPDATE + `category` = VALUES(`category`), + `component` = VALUES(`component`), + `description` = VALUES(`description`), + `default_value` = VALUES(`default_value`), + `updated` = NOW(), + `scope` = VALUES(`scope`), + `is_dynamic` = VALUES(`is_dynamic`); +``` + +To allow regular `virt-v2v` fallback for QCOW2 file targets where in-place +finalization is unavailable: + +```sql +UPDATE `cloud`.`configuration` +SET `value` = 'true' +WHERE `name` = 'vmware.cbt.allow.non.inplace.finalization'; +``` + +## Next Phase Manifest + +This section is the proposed follow-up roadmap for the next development cycle. +It is intentionally written as a working manifest so implementation decisions can +be checked against it during review. + +### Non-Negotiable Guardrails + +- Source VM shutdown remains operator-controlled by default. +- Any CloudStack-initiated source shutdown must be explicit, audited, and + opt-in per cutover request or per migration policy. +- Deleting a completed CBT migration record must remain record-only and must + never delete the imported VM, CloudStack volumes, finalized target disks, or + primary-storage files. +- Raw VMware CBT deltas must only be applied to a source-equivalent replica. + They must not be applied after `virt-v2v` has transformed the disk. +- Credential handling must continue to avoid clear-text passwords in command + arguments, persisted logs, and returned API errors. +- In-place finalization remains preferred. Non-in-place `virt-v2v` fallback must + stay explicitly gated by configuration because it needs more temporary storage. + +### P0: Operator-Grade Progress Reporting + +Add a real progress model for long-running CBT work. + +Planned behavior: + +- Track `currentsteppercent` when the agent can derive it. +- Track `currentstepbytesdone` and `currentstepbytestotal` where byte totals are + known. +- Track `lastprogressupdated` so the UI can distinguish "still running" from + "possibly stalled". +- Parse or capture progress from: + - `qemu-img convert -p` during initial full sync. + - Delta sync range execution, using completed changed ranges and copied bytes. + - `virt-v2v` and `virt-v2v-in-place` output during finalization. +- Show progress consistently in the VMware CBT Migrations table and expanded + row, similar to Import VM Tasks but with CBT-specific byte/range context. + +Expected API/data model additions: + +- `currentsteppercent` +- `currentstepbytesdone` +- `currentstepbytestotal` +- `lastprogressupdated` +- optional per-disk progress fields for multi-disk migrations + +### P0: Automated Delta Sync Policies + +Add server-side policy support so operators do not have to manually click Sync +delta for every migration. + +Planned behavior: + +- Per-migration mode: + - `manual` + - `auto-until-ready` + - `scheduled` +- Configurable interval, for example every 5, 15, or 30 minutes. +- Optional quiet-window behavior: keep syncing until the existing cutover policy + marks the migration `ReadyForCutover`. +- Pause and resume automatic sync without deleting migration state. +- Respect per-host, per-cluster, and per-vCenter concurrency limits. + +Guardrail: + +- Automatic sync must not automatically perform final cutover unless a later + explicit cutover policy is added and enabled. + +### P0: Cutover Controls And Optional Graceful Shutdown + +Keep the current safe default, but offer an explicit controlled shutdown option. + +Planned behavior: + +- Default cutover behavior remains unchanged: reject cutover when the VMware + source VM is still powered on. +- Add an explicit cutover option such as: + - `shutdownsource=true` + - `shutdownmode=guest` + - `shutdownwaitseconds=` +- Guest shutdown uses VMware Tools / vCenter guest shutdown where available. +- If graceful shutdown fails or times out, cutover fails cleanly unless an + explicit future force-off option is added. +- All CloudStack-initiated source shutdown attempts are logged and visible in the + migration event/audit trail. + +Non-goal for the next phase: + +- Silent source VM shutdown. +- Default force power-off. + +### P1: Bulk Operations And Retention + +Make CBT usable for tens or hundreds of migrations. + +Planned behavior: + +- Bulk actions: + - sync selected migrations + - pause/resume automatic sync + - cancel selected migrations + - delete completed records +- Completed migration retention policy: + - keep forever + - delete records after N days + - delete records immediately after successful import, if configured +- Retention cleanup for completed migrations remains record-only. +- Failed/cancelled cleanup continues to remove only CloudStack-owned + `cloudstack-cbt/` working directories. + +### P1: Preflight And UI Guardrails + +Make unsafe choices visible before an operator starts a long migration. + +Planned behavior: + +- Host capability matrix in the UI: + - VDDK support and version + - `qemu-img`, `qemu-nbd`, `qemu-io`, and `nbdkit` versions + - `virt-v2v` availability + - `virt-v2v-in-place` / `--in-place` support + - `virtio-win` availability for Windows guest conversion +- Grey out incompatible compute offerings in the migration form when CPU cores, + CPU speed, or memory are below source requirements. +- Keep server-side offering validation as the source of truth. +- Warn when VMware configured guest OS differs from VMware Tools runtime guest + OS, when both values are available. +- Require or strongly prompt for explicit target Guest OS selection when source + mapping is generic, such as `Other (64-bit)` or `ubuntu64Guest`. + +### P1: Failure Taxonomy And Retry Semantics + +Make retries predictable and explainable. + +Planned behavior: + +- Classify failures by phase: + - preflight + - initial sync + - delta sync + - final delta + - finalization + - import + - cleanup +- Preserve the current `ReadyForImport` retry behavior. +- Make retry affordances explicit in the UI. +- Return the last useful sanitized agent output line in API/UI errors. +- Track whether a retry will repeat VMware reads, local finalization, or import + only. + +### P2: Baseline Transfer Optimization Research + +Investigate whether the initial full logical VMware read can be reduced. + +Research questions: + +- Validate VDDK allocation/extents for VMware snapshots in a way usable by the + current NBD/qemu path. +- Can `qemu-img map`, NBD allocation metadata, or VDDK APIs distinguish + genuinely unallocated regions from allocated-zero regions for linked clones + and full clones? +- Can a safe sparse baseline mode skip network transfer of known-zero or + unallocated ranges without corrupting guest-visible disk contents? +- Is behavior different across OL8, OL9, Ubuntu 24.04, qemu versions, VDDK + versions, VMFS/NFS/vSAN datastore types, linked clones, and full clones? +- Specifically compare NFS-backed VMware datastores with VMFS6-backed + datastores. NFS testing showed the initial baseline can appear fully + allocated to the NBD/qemu path, causing a full logical disk read. VMFS6 + testing after guest space reclamation showed sparse `data` and `hole,zero` + extents through `nbdinfo --map`, and observed lower transfer on the KVM host. +- Keep the manual probe aligned with the production CBT path: pass `vm=moref`, + the exact datastore VMDK path, VDDK `libdir`, `transports`, and the vCenter + SHA1 `thumbprint`. Missing `thumbprint` can make the probe fail before any + useful extent map is returned. +- Test whether VDDK transport choice changes extent behavior. In particular, + compare `nbd`, `nbdssl`, and any available SAN/hotadd-compatible path where + the lab environment can support it. + +Possible outcomes: + +- Implement sparse/extent-aware baseline copy. +- Keep the current full-logical-baseline behavior and document it as a VMware + VDDK/NBD limitation. +- Offer an experimental mode only when the source stack proves allocation + metadata is trustworthy. + +### P2: Scheduling And Cutover Windows + +Add change-window-aware workflows for production migrations. + +Planned behavior: + +- Allow migrations to sync automatically during business hours but cut over only + inside an approved window. +- Add policy fields such as: + - sync interval + - cutover window start/end + - timezone + - maximum concurrent cutovers +- The UI should explain why a migration is waiting: not quiet yet, outside the + cutover window, source VM still powered on, or host concurrency limit reached. + +### P2: API And Documentation Hardening + +Keep public surfaces coherent as features are added. + +Planned behavior: + +- Add API documentation for policy fields and progress fields. +- Add cloudmonkey examples for: + - manual mode + - automatic sync mode + - scheduled mode + - explicit graceful shutdown cutover + - record-only completed migration deletion +- Add upgrade notes for new configuration keys and schema changes. +- Keep CWIKI, Markdown README, UI labels, and API descriptions aligned. + +## References + +- Apache CloudStack 4.24.0.0 Design Documents CWIKI page. +- Multiple CD-ROM / ISO Support Per VM design page. +- CloudStack Veeam KVM Integration design page. +- DNS Framework and Plugins design page. +- Error Message Consistency, Customization, and Localization Framework design + page. +- KVM Backup on Secondary Storage (KBOSS) design page. diff --git a/docs/vmware-cbt/architecture.md b/docs/vmware-cbt/architecture.md new file mode 100644 index 000000000000..376627ba503a --- /dev/null +++ b/docs/vmware-cbt/architecture.md @@ -0,0 +1,889 @@ + + +# VMware CBT incremental migration architecture for Apache CloudStack + +## Goal + +CloudStack now has VMware-to-KVM migration based on full disk copy through VDDK and +virt-v2v. That is a good foundation, but it still copies the whole VMware disk for +every migration attempt. The next step is a CBT-based replication mode: + +1. Create an initial KVM-side disk replica. +2. Use VMware Changed Block Tracking (CBT) to query only changed extents. +3. Read those extents from VMware through VDDK. +4. Apply them to a KVM-side replica. +5. Do a short final sync at cutover and boot the VM on KVM. + +The practical target is warm migration and replication-assisted cutover. True +near-live behavior is feasible for byte replication, but the remaining virt-v2v +guest conversion step must be handled carefully because virt-v2v modifies guest +disk contents, not only the container format. + +## Existing CloudStack architecture + +The merged VDDK work in Apache CloudStack PR +[#12970](https://github.com/apache/cloudstack/pull/12970) extends the existing +VMware-to-KVM import path rather than creating a new subsystem. + +Current relevant flow: + +- API/UI entrypoint: `ImportVmCmd` / `ImportUnmanagedInstanceCmd`. +- Server orchestration: `UnmanagedVMsManagerImpl`. +- Transport objects: `RemoteInstanceTO`, `ConvertInstanceCommand`, + `CheckConvertInstanceCommand`. +- KVM agent implementation: + - `LibvirtComputingResource` + - `LibvirtCheckConvertInstanceCommandWrapper` + - `LibvirtConvertInstanceCommandWrapper` + - `LibvirtReadyCommandWrapper` +- Host capability details: + - `host.instance.conversion` + - `host.vddk.support` + - `vddk.lib.dir` + - `host.vddk.version` +- Agent properties: + - `vddk.lib.dir` + - `vddk.transports` + - `vddk.thumbprint` + - `libguestfs.backend` + +The VDDK path currently delegates to `virt-v2v -it vddk`, producing qcow2 output +on the KVM conversion host. CloudStack then imports the converted disks into the +destination KVM cluster. This should be treated as the bootstrap path for CBT: +the new feature should reuse host selection, credential handling, import task +tracking, temporary storage selection, and converted-volume import. + +For Windows guests, CBT should also reuse the existing import dependency check +instead of inventing a parallel probe. The selected KVM conversion host is +validated with `CheckConvertInstanceCommand` in the same way as OVF/VDDK import +with Windows guest conversion enabled. The CBT source preflight therefore carries +the VMware guest OS identifier and guest OS name only so the manager can decide +whether this Windows-specific `virtio-win` dependency check applies. + +## Important design constraint: raw replication vs virt-v2v conversion + +CBT extents describe byte ranges changed on the original VMware guest disk. They +can be applied safely to a target disk only if that target is a byte-equivalent +replica of the VMware disk at the same logical offsets. + +That is not always true after virt-v2v. Virt-v2v may inject virtio drivers, +rewrite boot configuration, adjust initramfs, install services, and otherwise +modify guest disk contents. Applying later VMware CBT deltas directly on top of a +virt-v2v-converted disk can overwrite some of those modifications or produce a +mixed state that never existed on either source or target. + +Recommended architecture: + +- Maintain a source-equivalent replica during incremental synchronization. +- Apply VMware CBT deltas only to that replica. +- At cutover, after the final delta is applied, run local virt-v2v conversion + from the up-to-date replica into the CloudStack import image. + +This reduces WAN/vCenter bandwidth and source read time substantially, but the +cutover still includes local conversion time. To reach very low downtime, a later +phase can add a conversion-cache strategy, but the first correct design should +not mix raw source deltas into a post-conversion image. + +Possible exception: Linux guests that are already KVM-ready, or source VMs where +virtio drivers are preinstalled and no guest rewrite is required, can use direct +CBT-to-final-disk mode. That should be an explicit advanced mode, not the default. + +## High-level architecture + +Add a new "VMware CBT migration session" concept beside the existing one-shot +`importVm` flow. + +```text +CloudStack management server + | + | 1. API orchestration, state, vCenter snapshots, CBT metadata + v +KVM conversion/replication host agent + | + | 2. VDDK or nbdkit-vddk reads changed VMware extents + | 3. qemu-nbd/libnbd writes changed bytes to KVM-side replica + v +Primary/temporary storage + | + | 4. Final local virt-v2v conversion/import + v +CloudStack-managed KVM VM +``` + +Recommended separation: + +- Management server owns durable state, CloudStack API jobs, vCenter inventory, + snapshot orchestration, and final VM registration/import. +- KVM agent owns heavy data movement, local file/block operations, VDDK access, + qemu-nbd lifecycle, target writes, checksums, and progress reporting. +- A small native helper on the agent should do the block streaming. Java should + orchestrate it rather than bind directly to VDDK C APIs. + +## New CloudStack components + +Server-side services/classes: + +- `VmwareCbtMigrationManager` + - Main orchestration service. + - Starts, syncs, cuts over, cancels, and cleans migration sessions. +- `VmwareCbtSnapshotOrchestrator` + - Enables CBT if needed. + - Creates/removes VMware snapshots. + - Reads per-disk `changeId` values. + - Calls `QueryChangedDiskAreas`. +- `VmwareCbtDiskMapper` + - Maps VMware `deviceKey`, controller/unit, VMDK path, and capacity to + CloudStack volume records and target file paths. +- `VmwareCbtPlanner` + - Chooses KVM conversion host, target pool, disk format, chunk size, and + parallelism. +- `VmwareCbtValidationService` + - Rejects unsupported conditions: independent disks, disk resize during + migration, missing CBT support, unsupported datastore behavior, mismatched + disk capacity, inaccessible snapshots. +- `VmwareCbtMigrationDao` +- `VmwareCbtMigrationDiskDao` +- `VmwareCbtSyncRunDao` + +Agent-side classes: + +- `VmwareCbtPrepareCommand` +- `VmwareCbtSyncCommand` +- `VmwareCbtFinalizeCommand` +- `VmwareCbtCleanupCommand` +- `LibvirtVmwareCbtPrepareCommandWrapper` +- `LibvirtVmwareCbtSyncCommandWrapper` +- `LibvirtVmwareCbtFinalizeCommandWrapper` +- `LibvirtVmwareCbtCleanupCommandWrapper` +- `VmwareCbtSyncHelper` + - Java wrapper around a native executable such as + `cloudstack-vmware-cbt-sync`. + +Native helper: + +- `cloudstack-vmware-cbt-sync` + - Inputs: JSON manifest, vCenter connection parameters, snapshot moref, + disk paths/device keys, changed extents, target image path. + - Outputs: progress JSON lines, bytes copied, failed extent, final checksum + samples, exit code. + - Implementation candidates: C/C++ with VDDK and libnbd, Rust with C FFI, or + Go with cgo. For a PoC, Python/pyvmomi plus nbdkit-vddk/libnbd is acceptable. + +## Database/state tracking + +Add durable state so a management-server restart does not leave unknown VMware +snapshots or half-written replicas. + +`vmware_cbt_migration`: + +- `id`, `uuid` +- `account_id`, `domain_id`, `project_id` +- `zone_id`, `destination_cluster_id` +- `source_vcenter_id` or external vCenter endpoint reference +- `source_vm_moref`, `source_instance_uuid`, `source_vm_name` +- `convert_host_id`, `import_host_id` +- `target_pool_id`, `temporary_pool_id` +- `state`: `PREPARING`, `FULL_SYNCING`, `READY_FOR_DELTA`, + `DELTA_SYNCING`, `READY_FOR_CUTOVER`, `FINAL_SYNCING`, `CONVERTING`, + `IMPORTING`, `COMPLETED`, `FAILED`, `CANCELLED`, `CLEANUP_REQUIRED` +- `mode`: `REPLICA_THEN_CONVERT`, `DIRECT_TO_KVM_DISK` +- `sync_policy`: `MANUAL`, `SCHEDULED`, `ADAPTIVE` +- `sync_interval_seconds` +- `min_delta_cycles` +- `max_delta_cycles` +- `target_downtime_seconds` +- `target_final_delta_bytes` +- `target_final_delta_seconds` +- `non_convergence_threshold_percent` +- `quiesce_snapshots` +- `latest_dirty_rate_bytes_per_second` +- `latest_copy_rate_bytes_per_second` +- `estimated_final_delta_seconds` +- `estimated_final_cutover_seconds` +- `readiness`: `NOT_READY`, `READY`, `NON_CONVERGING`, `NEEDS_OPERATOR` +- `last_error` +- `created`, `updated`, `completed` + +`vmware_cbt_migration_disk`: + +- `id`, `migration_id` +- `source_device_key` +- `source_controller_key`, `source_unit_number` +- `source_vmdk_path` +- `source_capacity_bytes` +- `source_backing_uuid` / `contentId` when available +- `target_replica_path` +- `target_final_path` +- `target_format`: `raw`, `qcow2`, or block-device-backed raw +- `current_change_id` +- `current_change_uuid` +- `last_snapshot_moref` +- `bytes_full_synced` +- `bytes_delta_synced` +- `last_synced_at` +- `state` + +`vmware_cbt_sync_run`: + +- `id`, `migration_id`, `generation` +- `snapshot_moref` +- `start_change_id` +- `end_change_id` +- `state` +- `bytes_planned`, `bytes_copied` +- `extents_planned`, `extents_completed` +- `query_seconds`, `read_seconds`, `write_seconds`, `flush_seconds` +- `snapshot_create_seconds`, `snapshot_remove_seconds` +- `dirty_rate_bytes_per_second` +- `copy_rate_bytes_per_second` +- `estimated_remaining_delta_seconds` +- `started`, `finished`, `error` + +Avoid storing every CBT extent in the database for large VMs. Store the current +sync run and a manifest file path. If a run fails, repeat the whole generation +from the previous durable `changeId`. + +## Snapshot orchestration flow + +### Preparation + +1. Discover the VMware VM and all disks. +2. Validate that the disk set will not change during migration. +3. Enable CBT if disabled. +4. Record VM hardware metadata needed by CloudStack import. +5. Select a KVM replication/conversion host with VDDK, nbdkit, qemu-img, + qemu-nbd, and libnbd support. +6. Create target replica disks with exact virtual sizes. + +### Initial full sync + +Preferred correctness-first flow: + +1. Create VMware snapshot `cloudstack-cbt-baseline-N`. +2. Read each disk through VDDK at that snapshot. +3. Write a source-equivalent raw/qcow2 replica on KVM storage. +4. Extract each disk's `changeId` from the snapshot backing info. +5. Remove the snapshot after the full copy succeeds. +6. Persist `current_change_id` per disk. + +The initial full sync can use VDDK directly, nbdkit-vddk, or a local VDDK helper. +It should not use virt-v2v as the canonical replica if future CBT deltas will be +applied to the same disk. + +### Repeated delta sync + +For each sync generation: + +1. Create VMware snapshot `cloudstack-cbt-delta-N`. +2. For each disk, call `QueryChangedDiskAreas(snapshot, deviceKey, startOffset, + previousChangeId)` until the whole virtual disk is covered. +3. Coalesce adjacent or near-adjacent extents. +4. Agent reads those byte ranges from the snapshot via VDDK. +5. Agent writes those byte ranges to the KVM-side replica at the same offsets. +6. Agent flushes target writes. +7. Server extracts and persists the new snapshot `changeId` for the next + generation. +8. Remove the VMware snapshot. + +### Final cutover + +1. Require the operator to power off the VMware source VM. +2. Because the VM is now powered off, use current disk state as the final CBT end + point when supported; otherwise create a final snapshot. +3. Query changed extents from the last persisted `changeId`. +4. Apply final changed extents to the replica. +5. Run local virt-v2v conversion from the final source-equivalent replica to + CloudStack KVM disk images. +6. Import/register the VM in CloudStack using the existing import pipeline. +7. Boot the KVM VM. +8. Keep the VMware VM powered off and renamed/tagged until operator acceptance. +9. Cleanup old VMware snapshots and temporary replica/conversion artifacts. + +## Delta synchronization pipeline + +Pipeline per disk: + +```text +QueryChangedDiskAreas + -> extent normalization + -> chunk planner + -> VDDK read + -> optional checksum/sample verification + -> qemu-nbd/libnbd write + -> flush + -> progress update +``` + +Mapping rules: + +- VMware CBT returns byte offsets and lengths. +- VDDK reads sectors; VDDK sector size is 512 bytes, so reads must be aligned to + sector boundaries. +- Target writes use the same logical byte offsets. +- For raw files or block devices, `pwrite` is sufficient if CloudStack controls + exclusive access. +- For qcow2, prefer qemu-nbd plus libnbd so writes go through QEMU's qcow2 block + driver rather than corrupting qcow2 metadata. +- Coalesce small extents into larger chunks, for example 4 MiB to 64 MiB, while + preserving sector alignment. +- Always flush after a disk generation and before persisting the new `changeId`. + +## Delta cycle policy and cutover readiness + +The number of delta cycles should not be hard-coded. CloudStack should support +manual, scheduled, and adaptive policies. + +Manual policy: + +- Operator starts the initial full sync. +- Operator clicks `sync now` one or more times. +- CloudStack reports dirty rate, copy throughput, and estimated cutover time. +- Operator decides when to cut over. + +Scheduled policy: + +- CloudStack runs delta sync every configured interval. +- The session remains operator-driven for final cutover. +- This is useful when teams want the VM kept warm before a maintenance window. + +Adaptive policy: + +- CloudStack runs at least `min_delta_cycles`. +- After every cycle it recomputes readiness. +- It stops or marks `READY_FOR_CUTOVER` when estimated final downtime fits the + requested target. +- It marks `NON_CONVERGING` when the VM writes faster than CloudStack can copy, + or when repeated cycles do not materially reduce the estimated final delta. + +Readiness formula: + +```text +estimated_cutover_time = + estimated_final_delta_sync_time + + estimated_local_virt_v2v_time + + estimated_import_registration_time + + configured_safety_margin +``` + +Important metrics per cycle: + +- `changed_bytes` +- `elapsed_since_previous_sync` +- `dirty_rate = changed_bytes / elapsed_since_previous_sync` +- `copy_rate = bytes_copied / delta_sync_elapsed_seconds` +- `snapshot_overhead = snapshot_create + snapshot_remove` +- `replication_margin = copy_rate - dirty_rate` +- `estimated_final_delta_sync_time = latest_changed_bytes / copy_rate` + +The cutover decision should be configurable but metric-based: + +- `target_downtime_seconds`, for example 1800 seconds +- `target_final_delta_bytes`, for example 2 GiB +- `target_final_delta_seconds`, for example 300 seconds +- `min_delta_cycles`, for example 2 +- `max_delta_cycles`, for example 10 +- `non_convergence_threshold_percent`, for example dirty rate above 80 percent + of sustained copy rate + +Current implementation settings: + +- `vmware.cbt.migration.min.cycles` +- `vmware.cbt.migration.max.cycles` +- `vmware.cbt.migration.quiet.cycles` +- `vmware.cbt.migration.quiet.bytes` +- `vmware.cbt.migration.quiet.dirty.rate` +- `vmware.cbt.migration.agent.command.timeout` + +`vmware.cbt.migration.agent.command.timeout` is the long-running KVM agent +command timeout in seconds. It applies to initial full sync, regular delta +sync, final delta sync, and cutover finalization. The default is 86400 seconds. + +For quiet VMs, one or two delta cycles may be enough. For write-heavy database +VMs, CloudStack should not keep creating endless snapshots. It should report that +the VM is not converging and recommend operator action: choose a quieter window, +stop services, throttle writes, accept longer downtime, or perform a final +powered-off sync. + +Only one CloudStack-owned VMware snapshot should be active per cycle. The system +should persist the new per-disk `changeId` after each successful cycle and then +remove the snapshot. A long snapshot chain is not the state model. + +## Tooling choices + +### qemu-nbd + +Useful to expose a target qcow2/raw image as an NBD export for writes. The agent +can start qemu-nbd with `--cache=none`, a Unix socket, explicit format, and a +single client. This avoids direct qcow2 file mutation. + +### libnbd + +Best low-level writer for the helper. It supports offset writes and flushes +cleanly and avoids shelling out for each extent. + +### qcow2 dirty bitmaps + +Not required for VMware-source replication, because VMware CBT is the source of +truth before cutover. They become useful in two cases: + +- test-booting a KVM candidate with an overlay and tracking target-side writes; +- future reverse replication or post-cutover backup integration. + +Persistent bitmaps require qcow2; raw images can use transient QEMU bitmaps only +while a QEMU process is alive. + +### qemu-img + +Useful for create, resize, check, convert, and final image validation. It is not +the right primitive for applying many small changed ranges. + +### libvirt blockcopy APIs + +Useful after the VM is on KVM, for CloudStack-side storage movement or if a +staging KVM domain is introduced. It does not solve reading changed blocks from +VMware. If used, prefer blockcopy flags that force convergence/synchronous write +propagation where appropriate. + +### nbdkit-vddk + +Good PoC option because it can expose VMware disks through VDDK as NBD and +already understands vCenter, VM morefs, snapshot morefs, thumbprints, and VDDK +transport selection. Production still needs strict lifecycle handling and tests +against snapshot chains. + +## Consistency concerns + +- Running Linux imports are generally crash-consistent unless a quiesced VMware + snapshot is used. +- Windows should use VSS/quiesced snapshots where possible and final graceful + shutdown for correctness. +- Final cutover should prefer powered-off final sync. This gives the cleanest + current-disk end point and avoids writes racing with the last delta. +- Guest-level iSCSI or application-managed remote disks are invisible to VMware + CBT because those writes are not processed as virtual disk writes by ESXi. +- VMware snapshot creation can stun the VM; snapshot deletion/consolidation can + create backend load and should be rate-limited. +- If the source disk is resized, added, removed, or reconfigured during a + session, fail the session and require a new full sync. +- If the CBT `changeId` UUID changes, assume the CBT chain is invalid and require + a full resync. + +## Snapshot cleanup + +CloudStack must own all snapshots it creates with unique names and metadata: + +- `cloudstack-cbt--baseline` +- `cloudstack-cbt--delta-` +- `cloudstack-cbt--final` + +Cleanup rules: + +- Remove a snapshot only after all disk writes for that generation have flushed + and the new per-disk `changeId` values are persisted. +- On failure, mark `CLEANUP_REQUIRED` and expose a retry cleanup API. +- At startup, the management server should scan for CloudStack-owned CBT + snapshots and reconcile them with DB state. +- Never delete snapshots not matching the migration UUID. + +## Rollback scenarios + +- Before final cutover: source VM remains authoritative. The KVM replica can be + discarded and rebuilt. +- During final sync failure: leave source VM powered off if final consistency is + unknown; operator can either retry final sync or explicitly power source back + on. +- After KVM boot failure: keep source VM powered off by default to avoid + split-brain. Allow controlled rollback by destroying the KVM VM and powering + VMware back on. +- After successful cutover: keep VMware VM renamed/tagged for a retention period, + then delete or archive by operator action. + +## Performance bottlenecks + +- `QueryChangedDiskAreas` may return many small extents. Coalesce them. +- NBD/NBDSSL VDDK transport can saturate ESXi management networking or CPU. +- Snapshot consolidation can become the dominant cost on busy VMs. +- qcow2 random writes are slower than raw/block device writes. +- Multi-disk VMs need parallelism, but per-vCenter and per-host concurrency must + be capped. +- Full local virt-v2v conversion after final delta may dominate downtime for + complex Windows VMs. + +Mitigations: + +- Parallelize across disks, not unlimited extents. +- Use larger read/write chunks. +- Prefer raw/block target replicas for the sync phase. +- Keep one active VMware snapshot per VM only as long as needed. +- Expose dirty-rate estimates to operators before cutover. + +## VMware CBT limitations + +Known practical limitations to design around: + +- CBT must be enabled before the snapshot/change interval that will be queried. +- Existing snapshots created before CBT was enabled may not have usable + `changeId` values. +- `QueryChangedDiskAreas("*")` reports allocated areas for initial discovery, not + necessarily application-level changed data. +- CBT can fail or return overly broad ranges on unsupported datastore scenarios. +- VMware documentation warns that CBT is meaningful primarily when the ESXi + storage stack can track the virtual disk writes. +- Broadcom documents cases where `QueryChangedDiskAreas` can return `FileFault` + and require CBT reset. +- Broadcom also documents an ESXi 8.0U2 issue where CBT could return incorrect + sectors after hot-extending a VMDK; the safe policy is to reject disk resize + during migration and require full resync after resize. + +## UI integration strategy + +CBT should start inside the existing VMware-to-KVM import experience, but it +should not disrupt the existing import form. The current UI already has many +operator controls for conversion host, import host, staging storage, direct +storage-pool conversion, Management Server OVF download, guest OS, compute +offering, network mapping, and MAC behavior. CBT should build on that entrypoint +because operators are already choosing the same source vCenter, source VM, +destination cluster, service offering, disk offerings, networks, and storage. + +The existing VDDK work already adds `Use VDDK` to +`ui/src/views/tools/ImportUnmanagedInstance.vue` and exposes host VDDK support in +`HostInfo.vue`. The safest UI evolution is to make the migration method explicit +near the current `Use VDDK` area, while preserving the existing advanced controls +and their conditional behavior. + +Recommended first UI design: + +- Add a compact migration method control in the existing import form, near the + current `Use VDDK` toggle: + - `OVF / ovftool full copy` + - `VDDK full copy` + - `CBT warm migration` +- Do not create a separate CBT wizard for the initial implementation. +- Preserve existing conversion/import host selection and storage selection + controls. +- Keep `Enable to force OVF Download via Management Server` visible only for the + OVF method. +- Keep guest OS, compute offering, network mapping, duplicate MAC, and new MAC + behavior unchanged across all methods. +- Preserve the current VDDK behavior where direct storage-pool conversion is + automatically enabled because VDDK writes directly to the selected destination + storage instead of using temporary OVF staging. +- When `CBT warm migration` is selected, show CBT-specific settings: + - sync policy: manual, scheduled, adaptive + - sync interval + - target downtime + - minimum and maximum delta cycles + - quiesced snapshot preference + - final cutover shutdown policy +- Submitting the wizard should create a CBT migration session, not immediately + block the wizard until final import completes. + +Suggested method-to-parameter mapping: + +- `OVF / ovftool full copy` + - `usevddk=false` + - `forcemstoimportvmfiles` remains available + - conversion host, import host, and staging storage remain optional as today +- `VDDK full copy` + - `usevddk=true` + - `forceconverttopool=true` + - `forcemstoimportvmfiles=false` + - import host selection is hidden or ignored when the conversion host is also + the destination writer +- `CBT warm migration` + - `usevddk=true` + - `forceconverttopool=true` + - creates a `VmwareCbtMigration` session instead of running one-shot import + - selected storage becomes the replica/final destination storage + - final import still runs local virt-v2v at cutover + +Recommended session UI: + +- Add a dedicated view under `Tools > Migration` or `Tools > VMware CBT + migrations`. +- The view lists active and completed sessions with state, source VM, target + cluster, last sync time, dirty rate, copied bytes, estimated downtime, and + readiness. +- The session detail page provides actions: + - `Sync now` + - `Pause schedule` + - `Resume schedule` + - `Cut over` + - `Cancel` + - `Cleanup` +- The detail page should show per-disk progress and warnings, especially + snapshot cleanup failures, CBT reset requirements, and non-convergence. + +Suggested UI modules: + +- Extend `ui/src/views/tools/ImportUnmanagedInstance.vue` with an inline + migration method control and CBT policy fields. +- Add `ui/src/views/tools/VmwareCbtMigrations.vue` for the session list. +- Add `ui/src/views/tools/VmwareCbtMigrationDetail.vue` for progress, metrics, + actions, and warnings. +- Add API metadata and labels for CBT policy fields, readiness, dirty rate, copy + rate, estimated cutover time, and non-convergence. +- Extend host details display to show CBT helper capability after the KVM agent + reports it, similar to the current VDDK support display. +- Disable or clearly reject KVM conversion hosts that do not fully support the + selected method. For VDDK/CBT, `host.vddk.support=false` should not be + silently selectable even if a related VDDK or nbdkit version string is present. +- Keep unsupported hosts visible only if that helps operators understand why + auto-selection is not available; otherwise filter them out of the selectable + list. + +This hybrid approach is better than hiding CBT entirely inside the current import +form. The form is still the right place to define migration intent, but a +long-running replication session needs its own operational surface. It also keeps +room for future bulk migration, scheduled cutover windows, and retry/cleanup +workflows. + +## Suggested API contracts + +### Start session + +`startVmwareCbtMigration` + +Inputs: + +- `zoneid` +- `destinationclusterid` +- `sourcevcenterid` or external vCenter connection details +- `sourcevmmoref` or `name` +- `serviceofferingid` +- `datadiskofferinglist` +- `networkmapping` +- `targetpoolid` +- `migrationmethod`: `ovfFullCopy`, `vddkFullCopy`, or `cbtWarmMigration` +- `mode`: `replicaThenConvert` or `directToKvmDisk` +- `quiesce`: boolean +- `syncintervalminutes` optional +- `syncpolicy`: `manual`, `scheduled`, or `adaptive` +- `targetdowntimeseconds` +- `mindeltacycles` +- `maxdeltacycles` +- `targetfinaldeltabytes` +- `targetfinaldeltaseconds` +- `details`: `vddk.lib.dir`, `vddk.transports`, `vddk.thumbprint`, chunk size, + parallelism + +Output: + +- `migrationid` +- selected host/pool +- disk map +- initial state +- readiness estimate + +### Sync now + +`syncVmwareCbtMigration` + +Inputs: + +- `migrationid` +- `generation` optional + +Output: + +- bytes planned/copied +- extents planned/copied +- new lag estimate +- dirty rate +- copy rate +- estimated final delta time +- readiness +- state + +### Update policy + +`updateVmwareCbtMigrationPolicy` + +Inputs: + +- `migrationid` +- `syncpolicy` +- `syncintervalminutes` +- `targetdowntimeseconds` +- `mindeltacycles` +- `maxdeltacycles` +- `targetfinaldeltabytes` +- `targetfinaldeltaseconds` + +Output: + +- updated policy +- updated readiness estimate + +### Cutover + +`cutoverVmwareCbtMigration` + +Inputs: + +- `migrationid` +- `shutdownpolicy`: `guestShutdown`, `powerOff`, `manualAlreadyStopped` +- `timeout` +- `bootafterimport` +- `rollbackretentionhours` + +Output: + +- CloudStack VM ID +- final sync stats +- conversion stats +- cleanup state + +### Cancel/cleanup + +`cancelVmwareCbtMigration` + +Inputs: + +- `migrationid` +- `deleteReplica` +- `removeSnapshots` + +## Suggested agent command contract + +`VmwareCbtPrepareCommand`: + +- create target replica files/devices +- validate qemu-nbd/libnbd/VDDK availability +- return helper versions and target paths + +`VmwareCbtSyncCommand`: + +- vCenter endpoint and credential token/password file reference +- VM moref +- snapshot moref or current-disk mode +- per-disk manifest: source VMDK path/device key, target path, extents +- target format +- chunk size and concurrency + +`VmwareCbtSyncAnswer`: + +- success/failure +- per-disk bytes copied +- failed disk/extent +- elapsed time +- helper logs path +- checksum samples if enabled + +`VmwareCbtFinalizeCommand`: + +- run local virt-v2v from replica into CloudStack import location, or skip if + direct-to-KVM mode was selected +- return converted disk paths and metadata for existing import code + +## Phased implementation plan + +### Phase 0: standalone PoC + +- Build an external helper that can: + - create a VMware snapshot; + - call `QueryChangedDiskAreas`; + - read changed blocks via VDDK or nbdkit-vddk; + - write to a local raw file; + - repeat a delta sync; + - verify selected block hashes. +- Use local config only. Do not integrate with CloudStack DB yet. +- Validate with one Linux VM and one Windows VM. + +### Phase 1: CloudStack-managed full replica + +- Add migration session DB tables. +- Add API start/list/cancel/sync. +- Add agent prepare/sync commands. +- Implement initial full sync and repeated delta sync to raw replica. +- Extend the existing import form with the inline migration method control and + CBT policy fields. +- Add basic session list/detail UI with `sync now` and `cancel`. +- No final CloudStack import yet. + +### Phase 2: cutover and import + +- Add final powered-off sync. +- Run local virt-v2v from the synced replica. +- Reuse the existing converted-disk import path in `UnmanagedVMsManagerImpl`. +- Add `cut over`, rollback visibility, and cleanup actions to the session UI. +- Implement rollback and cleanup. + +### Phase 3: operational hardening + +- Add adaptive sync policy, dirty-rate estimate, cutover readiness, and + non-convergence warnings. +- Add snapshot reconciliation after management-server restart. +- Add per-vCenter concurrency limits. +- Add automatic fallback to full resync on invalid CBT chain. +- Add Marvin/integration tests for failure paths. + +### Phase 4: near-live optimization + +- Explore direct-to-KVM mode for KVM-ready guests. +- Explore preinstalling virtio drivers/tools before migration. +- Explore conversion-cache/overlay strategies to reduce final local virt-v2v + time. +- Explore target-side qcow2 dirty bitmaps for isolated test boot and reverse + replication. + +## Practical PoC recommendation + +Start outside CloudStack with a helper and one test VM: + +1. Enable CBT and verify the VM has no old snapshots. +2. Create baseline snapshot. +3. Full-copy disk through nbdkit-vddk to raw replica. +4. Store baseline `changeId`. +5. Generate guest writes. +6. Create delta snapshot. +7. Query changed areas from baseline `changeId`. +8. Read only changed extents and patch the raw replica. +9. Compare random block hashes between VMware snapshot and replica. +10. Power off source, final sync, then run local virt-v2v from replica to qcow2. +11. Boot converted VM on isolated KVM network. + +This proves the hardest part first: CBT extent correctness and target patching. +CloudStack integration should come after that works repeatably. + +## References + +- Apache CloudStack VDDK PR: + +- Apache CloudStack VMware-to-KVM import documentation: + +- Broadcom vSphere `QueryChangedDiskAreas` API: + +- Broadcom Virtual Disk API disk operations: + +- Broadcom VDDK CBT notes and limitations: + +- QEMU dirty bitmaps: + +- QEMU qemu-nbd: + +- libvirt incremental backup internals: + +- libvirt backup XML: + +- libvirt checkpoint XML: + +- nbdkit VDDK plugin: + diff --git a/docs/vmware-linstor-migration.md b/docs/vmware-linstor-migration.md new file mode 100644 index 000000000000..8221457e541c --- /dev/null +++ b/docs/vmware-linstor-migration.md @@ -0,0 +1,238 @@ + + +# Linstor primary storage as a destination for VMware-to-KVM migration + +This document describes the Linstor-destination portion of the VMware-to-KVM +migration work, in enough detail for LINSTOR/DRBD maintainers to review the +design choices and advise on Linstor-side behavior. + +## Summary + +This change lets Linstor (DRBD) primary storage be the **destination** for +VMware-to-KVM instance imports, across all three migration modes CloudStack +offers for Ceph/RBD: + +- **Cold, staged** — convert with virt-v2v to a temporary NFS location, then + copy the finalized disks into Linstor. +- **Cold, direct (VDDK)** — read each disk over nbdkit/VDDK straight into the + DRBD device and finalize it in place, with no NFS staging. +- **Warm (CBT)** — replicate the running VM with an initial full sync plus + incremental changed-block cycles into the DRBD device, then finalize in + place at cutover. + +Before this change Linstor was blocked everywhere in the import/conversion +path by NFS-only (and, more recently, RBD-only) assumptions. The work +generalizes those code paths rather than special-casing Linstor: a new +`RAW_BLOCK_DEVICE` target type covers "write RAW into a host-local block +device provided by the storage adaptor", which also lays the groundwork for +other block backends (PowerFlex, FiberChannel, StorPool) later. + +## Background and motivation + +CloudStack can import VMware VMs onto KVM by converting them with virt-v2v +(optionally reading disks over the VMware VDDK). The converted disks have to +land on KVM primary storage. That path historically supported only +filesystem-style pools (NFS/Filesystem/SharedMountPoint); the companion +commits in this series add Ceph/RBD as a destination and a full VMware +**CBT warm-migration** framework. Linstor — a very common CloudStack block +backend — was still unsupported as a conversion destination. + +Linstor differs from RBD in ways that shaped the design: + +- A Linstor volume is a **local block device** (`/dev/drbd/by-res//0`), + which is the universal abstraction the whole toolchain (qemu-img, qemu-io, + virt-v2v-in-place) already consumes — so finalization is actually *simpler* + than RBD (no qemu-nbd credential bridge needed; a plain + `` works). +- Unlike RBD, `qemu-img convert` **cannot create** the device — the resource + must be pre-created at the source capacity through the storage adaptor, + then written with `qemu-img convert -n` (no-create). +- LINSTOR resource names are capped at ~48 characters and cannot be renamed, + so the RBD-style naming carrying a full migration UUID does not fit; short, + deterministic names are used and imported volumes keep the name as their + recorded volume path. + +## What this change does + +### New generic block-device target type +`VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE`, alongside the existing +`QCOW2_FILE` and `RBD_RAW`. Linstor pools classify to it and require in-place +finalization (the qcow2 fallback cannot write to a device). + +### Server-side (orchestration) +- Linstor added to the staged-conversion destination pool types, the direct + VDDK-convert allow-list, and the CBT-compatible pool types, for both + explicit and implicit destination-pool selection. +- Conversion/import host selection validates that the host is a LINSTOR + satellite connected to the pool (in addition to the VDDK / in-place + virt-v2v capability checks), for auto-selection and explicit host IDs. +- `CheckConvertInstanceCommand` carries a new in-place-finalization capability + check so unsupported hosts fail fast with a clear message. +- The powered-off, non-cloned source requirement (already enforced for direct + RBD) now applies to any direct block-storage import. + +### KVM agent-side (data plane) +- **Staged**: the converted-disk move already dispatches per pool type to + `LinstorStorageAdaptor.copyPhysicalDisk` (qemu-img into the DRBD device); + converted-disk metadata for Linstor reports the pool UUID and volume name + instead of parsing an NFS mount. +- **Direct VDDK**: pre-creates each resource at source capacity via the + adaptor, then `nbdkit vddk --run 'qemu-img convert -n ... '`, and + finalizes with `virt-v2v-in-place` fed a `` domain XML. +- **Warm CBT**: initial sync pre-creates the device and `qemu-img convert -n` + into it; delta cycles patch changed extents; cutover finalizes in place + with block-device XML (no qemu-nbd bridges). Cleanup deletes only + marker-guarded volumes. +- `LinstorStorageAdaptor.listPhysicalDisks` is implemented (resource + definitions filtered by the pool's resource group), replacing the previous + `UnsupportedOperationException`, so the forced-conversion import short + circuit works for Linstor. + +### Performance: nbdcopy for full-disk copies to block devices +The cold direct-VDDK import and the CBT initial full sync copy an entire disk +from the nbdkit/VDDK source into the target. For a local raw block-device +target (Linstor/DRBD), this now uses `nbdcopy` (libnbd) when the host has it, +falling back to `qemu-img convert`. nbdcopy keeps many requests in flight +(it is what virt-v2v itself uses for this step) and is typically faster than a +single-connection `qemu-img convert`. Availability is probed locally on the +agent; it is a pure optimization with automatic fallback, so no server-side +gating is needed. Only block-device targets are affected — the RBD URI target +and qcow2 file targets keep qemu-img convert (nbdcopy is raw-only and cannot +address an rbd: URI without a bridge). On a 16 GiB disk over network NBD +transport it measured ~20% faster than qemu-img convert in the lab, and the +result was byte-identical to a full VDDK read of the source. + +### Performance: direct CBT delta copy for block devices +Previously a CBT delta copied each changed extent in two hops — qemu-img the +nbd source window into a temporary raw file, then qemu-io write that file into +the target. For a raw block-device target that doubles local I/O and needs +scratch space. This change streams each changed extent from the nbd source +window straight into the same device window in a single +`qemu-img convert -n -S 0 --image-opts --target-image-opts`. `-S 0` disables +zero/sparse skipping so blocks the source cleared to zero are actually +overwritten in the target — a full copy can skip zeros because the target +starts zeroed, but a delta into an already-populated device cannot. The +qcow2-file and RBD paths are unchanged. + +## Migration support matrix (Linstor destination) + +| Mode | Trigger | Source VM state | Data movement | Guest downtime | +|---|---|---|---|---| +| Cold, staged | `importVm` (no `forceconverttopool`) | Running (auto-cloned on vSphere) or stopped | virt-v2v -> qcow2 on NFS temp -> qemu-img copy to DRBD device | Offline copy (running source undisturbed via clone) | +| Cold, direct VDDK | `importVm usevddk=true forceconverttopool=true` -> Linstor pool | Stopped only (non-cloned) | nbdkit/VDDK -> qemu-img convert -n into DRBD device -> virt-v2v-in-place | Whole migration (VM is off) | +| Warm, CBT | `startVmwareCbtMigration` -> sync cycles -> `cutoverVmwareCbtMigration` | Running | Initial full sync + incremental CBT deltas into DRBD device; final delta + in-place finalize at cutover | Minimal (only the final cutover delta) | + +Notes: +- Warm CBT does **not** power off the source automatically. The operator + gracefully shuts down the VMware VM, then triggers cutover; the code + rejects cutover on a running VM. This is deliberate — never auto-kill a + production VM. +- Direct VDDK requires a powered-off, non-cloned source. +- Imported Linstor volumes are recorded QCOW2 in the DB (Linstor convention; + RAW on the device). + +## How to use + +Cold, direct VDDK into Linstor (source powered off): + + import vm zoneid= clusterid= \ + importsource=vmware hypervisor=KVM \ + vcenter= datacentername= username= password=

\ + clustername= host= \ + name= displayname= serviceofferingid= \ + usevddk=true forceconverttopool=true \ + convertinstancepoolid= \ + nicnetworklist[0].nic="Network adapter 1" nicnetworklist[0].network= \ + datadiskofferinglist[0].disk= datadiskofferinglist[0].diskOffering= + +Warm CBT into Linstor: `startVmwareCbtMigration` with the Linstor pool as +`storagepoolid`, let the delta cycles run, power off the source, then +`cutoverVmwareCbtMigration`. + +(The web import wizard fills the NIC/disk mappings from the discovered VM, so +the UI is the easiest way to drive it.) + +## Host prerequisites + +For any Linstor destination the conversion host must be a LINSTOR satellite +connected to the pool (its `hostname` must match its LINSTOR node name). +Direct-VDDK and warm-CBT additionally need VDDK, an nbdkit VDDK plugin, and +in-place virt-v2v (`virt-v2v-in-place` or `virt-v2v --in-place`). No extra +qemu block-driver is needed (writes go to a local device). Prefer a diskful +satellite as the conversion host so writes are local rather than traversing +the DRBD replication network. + +## Backwards compatibility + +- No behavior change for existing NFS or RBD destinations; Linstor is added + to the same gate lists and dispatch branches. +- The CBT-delta optimization only affects `RAW_BLOCK_DEVICE` targets; qcow2 + and RBD keep their existing code paths. +- One host-capability check flag was added to `CheckConvertInstanceCommand` + (old constructor preserved, null-tolerant). + +## Testing + +- **Unit tests**: full server + KVM-plugin suites pass, including new + Linstor-specific tests across the gate lists, host selection, the + convert/import/CBT wrappers, `listPhysicalDisks`, and the block-device + delta script. +- **Data plane, real 3-node LINSTOR/DRBD lab, real VMware source**: + - Cold direct VDDK: a real VM's disks read over VDDK into DRBD devices + + in-place finalized; `qemu-img compare` against the source = identical. + - Warm CBT: baseline + delta + power-off + final delta + finalize; + integrity verified (file checksum inside the migrated volume matched the + source, and `qemu-img compare` of a delta-applied device against a full + read of the snapshot = identical). + - CBT-delta optimization: 144 real changed extents applied via the new + direct-copy script; `qemu-img compare` = images identical. +- **Full end-to-end through CloudStack**: `importVm` (usevddk + + forceconverttopool) against a real vCenter produced a Stopped CloudStack VM + with its root and data disks as Linstor volumes on the pool, each backed by + a DRBD resource UpToDate on all three nodes. + +## Known limitations / possible follow-ups + +- Full-disk copies into block devices use nbdcopy when present (see above). + RBD URI and qcow2 file targets still use qemu-img convert; extending + nbdcopy to RBD would need a qemu-nbd bridge (nbdcopy is raw-device/file + only), which is a possible follow-up. +- The conversion host is usually not ESXi-adjacent, so VDDK typically uses + network (NBD/NBDSSL) transport rather than the faster HotAdd; largely + inherent to the KVM conversion-host model. +- A diskful conversion host is recommended but not enforced; a diskless + satellite makes every write traverse the replication network. +- The staged path keeps its extra NFS hop (needed for running-VM clones and + hosts without in-place support). + +## Relationship to the RBD series + +The Linstor support is layered on the VMware-to-KVM RBD series in this same +change set (direct VDDK-to-RBD imports, the CBT warm-migration framework, +importVm RBD adoption, and the consolidated host-capability probe names). +The Linstor-specific commits, in order: + +- KVM: allow staged VMware instance imports into Linstor primary storage +- KVM: support direct VDDK VMware imports into Linstor primary storage +- VMware CBT warm migration to KVM with Linstor destination storage +- Document Linstor destination support for VMware-to-KVM imports +- Drop unsupported -O option from virt-v2v-in-place invocations +- KVM: stream VMware CBT block-device deltas directly, without a temp file +- KVM: use nbdcopy for full-disk copies into Linstor block devices when available diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index 1215829d92f8..32a5c9d9a071 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java @@ -808,8 +808,20 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi String vddkSupport = detailsMap.get(Host.HOST_VDDK_SUPPORT); String vddkLibDir = detailsMap.get(Host.HOST_VDDK_LIB_DIR); String vddkVersion = detailsMap.get(Host.HOST_VDDK_VERSION); + String vmwareCbtSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_SUPPORT); + String vmwareCbtInPlaceFinalizationSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT); + String vmwareCbtRbdSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT); + String qemuImgVersion = detailsMap.get(Host.HOST_QEMU_IMG_VERSION); + String qemuNbdVersion = detailsMap.get(Host.HOST_QEMU_NBD_VERSION); + String qemuIoVersion = detailsMap.get(Host.HOST_QEMU_IO_VERSION); + String virtV2vInPlaceVersion = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_VERSION); + String virtv2vInPlaceSupport = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_SUPPORT); + String qemuImgRbdSupport = detailsMap.get(Host.HOST_QEMU_RBD_SUPPORT); + String vddkRbdDirectImportSupport = detailsMap.get(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT); logger.debug("Got HOST_UEFI_ENABLE [{}] for host [{}]:", uefiEnabled, host); - if (ObjectUtils.anyNotNull(uefiEnabled, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion)) { + if (ObjectUtils.anyNotNull(uefiEnabled, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion, + vmwareCbtSupport, vmwareCbtInPlaceFinalizationSupport, vmwareCbtRbdSupport, qemuImgVersion, qemuNbdVersion, qemuIoVersion, virtV2vInPlaceVersion, + virtv2vInPlaceSupport, qemuImgRbdSupport, vddkRbdDirectImportSupport)) { _hostDao.loadDetails(host); boolean updateNeeded = false; if (StringUtils.isNotBlank(uefiEnabled) && !uefiEnabled.equals(host.getDetails().get(Host.HOST_UEFI_ENABLE))) { @@ -844,6 +856,64 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi } updateNeeded = true; } + if (StringUtils.isNotBlank(vmwareCbtSupport) && !vmwareCbtSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_SUPPORT, vmwareCbtSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtInPlaceFinalizationSupport) && + !vmwareCbtInPlaceFinalizationSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, vmwareCbtInPlaceFinalizationSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtRbdSupport) && + !vmwareCbtRbdSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, vmwareCbtRbdSupport); + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuImgVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_IMG_VERSION)))) { + if (StringUtils.isBlank(qemuImgVersion)) { + host.getDetails().remove(Host.HOST_QEMU_IMG_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_IMG_VERSION, qemuImgVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuNbdVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_NBD_VERSION)))) { + if (StringUtils.isBlank(qemuNbdVersion)) { + host.getDetails().remove(Host.HOST_QEMU_NBD_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_NBD_VERSION, qemuNbdVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuIoVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_IO_VERSION)))) { + if (StringUtils.isBlank(qemuIoVersion)) { + host.getDetails().remove(Host.HOST_QEMU_IO_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_IO_VERSION, qemuIoVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(virtV2vInPlaceVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_VIRTV2V_INPLACE_VERSION)))) { + if (StringUtils.isBlank(virtV2vInPlaceVersion)) { + host.getDetails().remove(Host.HOST_VIRTV2V_INPLACE_VERSION); + } else { + host.getDetails().put(Host.HOST_VIRTV2V_INPLACE_VERSION, virtV2vInPlaceVersion); + } + updateNeeded = true; + } + if (StringUtils.isNotBlank(virtv2vInPlaceSupport) && !virtv2vInPlaceSupport.equals(host.getDetails().get(Host.HOST_VIRTV2V_INPLACE_SUPPORT))) { + host.getDetails().put(Host.HOST_VIRTV2V_INPLACE_SUPPORT, virtv2vInPlaceSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(qemuImgRbdSupport) && !qemuImgRbdSupport.equals(host.getDetails().get(Host.HOST_QEMU_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_QEMU_RBD_SUPPORT, qemuImgRbdSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vddkRbdDirectImportSupport) && !vddkRbdDirectImportSupport.equals(host.getDetails().get(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, vddkRbdDirectImportSupport); + updateNeeded = true; + } if (updateNeeded) { _hostDao.saveDetails(host); } diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index f4198819dd16..55bfa91f0376 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -1335,6 +1335,19 @@ private ImageFormat getSupportedImageFormatForCluster(HypervisorType hyperType) } } + /** + * Returns the image format for a volume taking the underlying storage pool type into account. + * On KVM the default cluster format is QCOW2, but block-based pools such as RBD (Ceph) store + * volumes as RAW images. This is relevant when importing/adopting an existing volume so the + * recorded format matches what is actually on the pool. + */ + protected ImageFormat getSupportedImageFormatForCluster(HypervisorType hyperType, Storage.StoragePoolType poolType) { + if (hyperType == HypervisorType.KVM && poolType == Storage.StoragePoolType.RBD) { + return ImageFormat.RAW; + } + return getSupportedImageFormatForCluster(hyperType); + } + private boolean isSupportedImageFormatForCluster(VolumeInfo volume, HypervisorType rootDiskHyperType) { ImageFormat volumeFormat = volume.getFormat(); if (rootDiskHyperType == HypervisorType.Hyperv) { @@ -2654,7 +2667,7 @@ public DiskProfile importVolume(Type type, String name, DiskOffering offering, L vol.setDisplayVolume(userVm.isDisplayVm()); } - vol.setFormat(getSupportedImageFormatForCluster(hypervisorType)); + vol.setFormat(getSupportedImageFormatForCluster(hypervisorType, poolType)); vol.setPoolId(poolId); vol.setPoolType(poolType); vol.setPath(path); @@ -2698,7 +2711,7 @@ public DiskProfile updateImportedVolume(Type type, DiskOffering offering, Virtua vol.setDisplayVolume(userVm.isDisplayVm()); } - vol.setFormat(getSupportedImageFormatForCluster(vm.getHypervisorType())); + vol.setFormat(getSupportedImageFormatForCluster(vm.getHypervisorType(), poolType)); vol.setPoolId(poolId); vol.setPoolType(poolType); vol.setPath(path); diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java index d61caf16ae5d..4c2693df4f01 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java @@ -178,6 +178,28 @@ public void testCheckAndUpdateVolumeAccountResourceCountLessSize() { runCheckAndUpdateVolumeAccountResourceCountTest(20L, 10L); } + @Test + public void testGetSupportedImageFormatForClusterKvmRbdIsRaw() { + Assert.assertEquals(Storage.ImageFormat.RAW, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.RBD)); + } + + @Test + public void testGetSupportedImageFormatForClusterKvmNonRbdIsQcow2() { + Assert.assertEquals(Storage.ImageFormat.QCOW2, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.NetworkFilesystem)); + Assert.assertEquals(Storage.ImageFormat.QCOW2, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.Filesystem)); + } + + @Test + public void testGetSupportedImageFormatForClusterNonKvmIgnoresPoolType() { + Assert.assertEquals(Storage.ImageFormat.VHD, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.XenServer, Storage.StoragePoolType.RBD)); + Assert.assertEquals(Storage.ImageFormat.OVA, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.VMware, Storage.StoragePoolType.RBD)); + } + @Test public void testGrantVolumeAccessToHostIfNeededDriverNoNeed() { PrimaryDataStore store = Mockito.mock(PrimaryDataStore.class); diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java new file mode 100644 index 000000000000..78e492a434d9 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigrationCycle; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "vmware_cbt_migration_cycle") +public class VmwareCbtMigrationCycleVO implements VmwareCbtMigrationCycle { + + public VmwareCbtMigrationCycleVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationCycleVO(long migrationId, int cycleNumber) { + this(); + this.migrationId = migrationId; + this.cycleNumber = cycleNumber; + this.state = State.Created; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "migration_id") + private long migrationId; + + @Column(name = "cycle_number") + private int cycleNumber; + + @Column(name = "snapshot_moref") + private String snapshotMor; + + @Column(name = "changed_bytes") + private Long changedBytes; + + @Column(name = "dirty_rate") + private Long dirtyRate; + + @Column(name = "duration") + private Long duration; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "description") + private String description; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getMigrationId() { + return migrationId; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public Long getChangedBytes() { + return changedBytes; + } + + public void setChangedBytes(Long changedBytes) { + this.changedBytes = changedBytes; + } + + public Long getDirtyRate() { + return dirtyRate; + } + + public void setDirtyRate(Long dirtyRate) { + this.dirtyRate = dirtyRate; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java new file mode 100644 index 000000000000..62069cfbcd97 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigrationDisk; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "vmware_cbt_migration_disk") +public class VmwareCbtMigrationDiskVO implements VmwareCbtMigrationDisk { + + public VmwareCbtMigrationDiskVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationDiskVO(long migrationId, String sourceDiskId, Integer sourceDiskDeviceKey, + String sourceDiskPath, String datastoreName, Long capacityBytes) { + this(); + this.migrationId = migrationId; + this.sourceDiskId = sourceDiskId; + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.capacityBytes = capacityBytes; + this.state = State.Created; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "migration_id") + private long migrationId; + + @Column(name = "source_disk_id") + private String sourceDiskId; + + @Column(name = "source_disk_device_key") + private Integer sourceDiskDeviceKey; + + @Column(name = "source_disk_path") + private String sourceDiskPath; + + @Column(name = "datastore_name") + private String datastoreName; + + @Column(name = "capacity_bytes") + private Long capacityBytes; + + @Column(name = "target_path") + private String targetPath; + + @Column(name = "target_format") + private String targetFormat; + + @Column(name = "change_id") + private String changeId; + + @Column(name = "snapshot_moref") + private String snapshotMor; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getMigrationId() { + return migrationId; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public Long getCapacityBytes() { + return capacityBytes; + } + + public String getTargetPath() { + return targetPath; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public void setChangeId(String changeId) { + this.changeId = changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java new file mode 100644 index 000000000000..24ad5b1dd552 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java @@ -0,0 +1,465 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +import com.cloud.utils.db.Encrypt; + +@Entity +@Table(name = "vmware_cbt_migration") +public class VmwareCbtMigrationVO implements VmwareCbtMigration { + + public VmwareCbtMigrationVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationVO(long zoneId, long accountId, long userId, long destinationClusterId, + String displayName, String vcenter, String datacenter, String sourceHost, + String sourceCluster, String sourceVmName) { + this(); + this.zoneId = zoneId; + this.accountId = accountId; + this.userId = userId; + this.destinationClusterId = destinationClusterId; + this.displayName = displayName; + this.vcenter = vcenter; + this.datacenter = datacenter; + this.sourceHost = sourceHost; + this.sourceCluster = sourceCluster; + this.sourceVmName = sourceVmName; + this.state = State.Created; + this.currentStep = "Created"; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "user_id") + private long userId; + + @Column(name = "vm_id") + private Long vmId; + + @Column(name = "existing_vcenter_id") + private Long existingVcenterId; + + @Column(name = "source_username") + private String sourceUsername; + + @Encrypt + @Column(name = "source_password") + private String sourcePassword; + + @Column(name = "destination_cluster_id") + private long destinationClusterId; + + @Column(name = "convert_host_id") + private Long convertHostId; + + @Column(name = "storage_pool_id") + private Long storagePoolId; + + @Column(name = "display_name") + private String displayName; + + @Column(name = "host_name") + private String hostName; + + @Column(name = "template_id") + private Long templateId; + + @Column(name = "service_offering_id") + private Long serviceOfferingId; + + @Column(name = "guest_os_id") + private Long guestOsId; + + @Column(name = "data_disk_offering_map") + private String dataDiskOfferingMap; + + @Column(name = "nic_network_map") + private String nicNetworkMap; + + @Column(name = "nic_ip_address_map") + private String nicIpAddressMap; + + @Column(name = "import_details") + private String importDetails; + + @Column(name = "forced") + private boolean forced; + + @Column(name = "vcenter") + private String vcenter; + + @Column(name = "datacenter") + private String datacenter; + + @Column(name = "source_host") + private String sourceHost; + + @Column(name = "source_cluster") + private String sourceCluster; + + @Column(name = "source_vm_name") + private String sourceVmName; + + @Column(name = "vddk_lib_dir") + private String vddkLibDir; + + @Column(name = "vddk_transports") + private String vddkTransports; + + @Column(name = "vddk_thumbprint") + private String vddkThumbprint; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "current_step") + private String currentStep; + + @Column(name = "last_error") + private String lastError; + + @Column(name = "completed_cycles") + private int completedCycles; + + @Column(name = "quiet_cycles") + private int quietCycles; + + @Column(name = "total_changed_bytes") + private long totalChangedBytes; + + @Column(name = "last_changed_bytes") + private Long lastChangedBytes; + + @Column(name = "last_dirty_rate") + private Long lastDirtyRate; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getZoneId() { + return zoneId; + } + + public long getAccountId() { + return accountId; + } + + public long getUserId() { + return userId; + } + + public Long getVmId() { + return vmId; + } + + public void setVmId(Long vmId) { + this.vmId = vmId; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public void setExistingVcenterId(Long existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + + public String getSourceUsername() { + return sourceUsername; + } + + public void setSourceUsername(String sourceUsername) { + this.sourceUsername = sourceUsername; + } + + public String getSourcePassword() { + return sourcePassword; + } + + public void setSourcePassword(String sourcePassword) { + this.sourcePassword = sourcePassword; + } + + public long getDestinationClusterId() { + return destinationClusterId; + } + + public Long getConvertHostId() { + return convertHostId; + } + + public void setConvertHostId(Long convertHostId) { + this.convertHostId = convertHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + public void setStoragePoolId(Long storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public String getDisplayName() { + return displayName; + } + + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + public void setServiceOfferingId(Long serviceOfferingId) { + this.serviceOfferingId = serviceOfferingId; + } + + public Long getGuestOsId() { + return guestOsId; + } + + public void setGuestOsId(Long guestOsId) { + this.guestOsId = guestOsId; + } + + public String getDataDiskOfferingMap() { + return dataDiskOfferingMap; + } + + public void setDataDiskOfferingMap(String dataDiskOfferingMap) { + this.dataDiskOfferingMap = dataDiskOfferingMap; + } + + public String getNicNetworkMap() { + return nicNetworkMap; + } + + public void setNicNetworkMap(String nicNetworkMap) { + this.nicNetworkMap = nicNetworkMap; + } + + public String getNicIpAddressMap() { + return nicIpAddressMap; + } + + public void setNicIpAddressMap(String nicIpAddressMap) { + this.nicIpAddressMap = nicIpAddressMap; + } + + public String getImportDetails() { + return importDetails; + } + + public void setImportDetails(String importDetails) { + this.importDetails = importDetails; + } + + public boolean isForced() { + return forced; + } + + public void setForced(boolean forced) { + this.forced = forced; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenter() { + return datacenter; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public String getCurrentStep() { + return currentStep; + } + + public void setCurrentStep(String currentStep) { + this.currentStep = currentStep; + } + + public String getLastError() { + return lastError; + } + + public void setLastError(String lastError) { + this.lastError = lastError; + } + + public int getCompletedCycles() { + return completedCycles; + } + + public void setCompletedCycles(int completedCycles) { + this.completedCycles = completedCycles; + } + + public int getQuietCycles() { + return quietCycles; + } + + public void setQuietCycles(int quietCycles) { + this.quietCycles = quietCycles; + } + + public long getTotalChangedBytes() { + return totalChangedBytes; + } + + public void setTotalChangedBytes(long totalChangedBytes) { + this.totalChangedBytes = totalChangedBytes; + } + + public Long getLastChangedBytes() { + return lastChangedBytes; + } + + public void setLastChangedBytes(Long lastChangedBytes) { + this.lastChangedBytes = lastChangedBytes; + } + + public Long getLastDirtyRate() { + return lastDirtyRate; + } + + public void setLastDirtyRate(Long lastDirtyRate) { + this.lastDirtyRate = lastDirtyRate; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public Date getRemoved() { + return removed; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java new file mode 100644 index 000000000000..25debad70156 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationCycleVO; + +import java.util.List; + +public interface VmwareCbtMigrationCycleDao extends GenericDao { + List listByMigrationId(long migrationId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java new file mode 100644 index 000000000000..0d823a29287b --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationCycleVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VmwareCbtMigrationCycleDaoImpl extends GenericDaoBase implements VmwareCbtMigrationCycleDao { + + private final SearchBuilder migrationSearch; + + public VmwareCbtMigrationCycleDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("migrationId", migrationSearch.entity().getMigrationId(), SearchCriteria.Op.EQ); + migrationSearch.done(); + } + + @Override + public List listByMigrationId(long migrationId) { + SearchCriteria sc = migrationSearch.create(); + sc.setParameters("migrationId", migrationId); + Filter filter = new Filter(VmwareCbtMigrationCycleVO.class, "cycleNumber", true, null, null); + return listBy(sc, filter); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java new file mode 100644 index 000000000000..e5a715c9e189 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationVO; +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import java.util.List; + +public interface VmwareCbtMigrationDao extends GenericDao { + Pair, Integer> listMigrations(Long id, Long zoneId, Long accountId, String vcenter, + String sourceVmName, VmwareCbtMigration.State state, + Long startIndex, Long pageSizeVal); + + List listByConvertHostId(Long convertHostId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java new file mode 100644 index 000000000000..0a3599936ed7 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationVO; +import org.apache.cloudstack.vm.VmwareCbtMigration; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VmwareCbtMigrationDaoImpl extends GenericDaoBase implements VmwareCbtMigrationDao { + + private final SearchBuilder migrationSearch; + private final SearchBuilder convertHostSearch; + + public VmwareCbtMigrationDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("id", migrationSearch.entity().getId(), SearchCriteria.Op.EQ); + migrationSearch.and("zoneId", migrationSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + migrationSearch.and("accountId", migrationSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + migrationSearch.and("vcenter", migrationSearch.entity().getVcenter(), SearchCriteria.Op.EQ); + migrationSearch.and("sourceVmName", migrationSearch.entity().getSourceVmName(), SearchCriteria.Op.EQ); + migrationSearch.and("state", migrationSearch.entity().getState(), SearchCriteria.Op.EQ); + migrationSearch.done(); + + convertHostSearch = createSearchBuilder(); + convertHostSearch.and("convertHostId", convertHostSearch.entity().getConvertHostId(), SearchCriteria.Op.EQ); + convertHostSearch.done(); + } + + @Override + public Pair, Integer> listMigrations(Long id, Long zoneId, Long accountId, String vcenter, + String sourceVmName, VmwareCbtMigration.State state, + Long startIndex, Long pageSizeVal) { + SearchCriteria sc = migrationSearch.create(); + if (id != null) { + sc.setParameters("id", id); + } + if (zoneId != null) { + sc.setParameters("zoneId", zoneId); + } + if (accountId != null) { + sc.setParameters("accountId", accountId); + } + if (StringUtils.isNotBlank(vcenter)) { + sc.setParameters("vcenter", vcenter); + } + if (StringUtils.isNotBlank(sourceVmName)) { + sc.setParameters("sourceVmName", sourceVmName); + } + if (state != null) { + sc.setParameters("state", state); + } + Filter filter = new Filter(VmwareCbtMigrationVO.class, "created", false, startIndex, pageSizeVal); + return searchAndCount(sc, filter); + } + + @Override + public List listByConvertHostId(Long convertHostId) { + SearchCriteria sc = convertHostSearch.create(); + sc.setParameters("convertHostId", convertHostId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java new file mode 100644 index 000000000000..60a3f13355c6 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationDiskVO; + +import java.util.List; + +public interface VmwareCbtMigrationDiskDao extends GenericDao { + List listByMigrationId(long migrationId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java new file mode 100644 index 000000000000..55463f249366 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VmwareCbtMigrationDiskDaoImpl extends GenericDaoBase implements VmwareCbtMigrationDiskDao { + + private final SearchBuilder migrationSearch; + + public VmwareCbtMigrationDiskDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("migrationId", migrationSearch.entity().getMigrationId(), SearchCriteria.Op.EQ); + migrationSearch.done(); + } + + @Override + public List listByMigrationId(long migrationId) { + SearchCriteria sc = migrationSearch.create(); + sc.setParameters("migrationId", migrationId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index 932db538f30b..09039e3bdc31 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -316,6 +316,9 @@ + + + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index ab5ac7b2b875..e104a371b30e 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -646,3 +646,129 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_schedule', 'isolated', 'TINYI UPDATE `cloud`.`configuration` SET `value`=CONCAT(`value`, ', backupValidationCommandTimeout, backupValidationScreenshotWait, backupValidationBootTimeout') WHERE `name`='user.vm.readonly.details' AND `value` IS NOT NULL; +-- VMware CBT warm migration session state +CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `zone_id` bigint unsigned NOT NULL COMMENT 'Zone ID', + `account_id` bigint unsigned NOT NULL COMMENT 'Account ID', + `user_id` bigint unsigned NOT NULL COMMENT 'User ID', + `vm_id` bigint unsigned COMMENT 'Imported VM ID after cutover', + `existing_vcenter_id` bigint unsigned COMMENT 'Linked VMware datacenter ID when the source vCenter is registered', + `source_username` varchar(255) COMMENT 'Stored source vCenter username for external VMware CBT migrations', + `source_password` varchar(1024) COMMENT 'Encrypted stored source vCenter password for external VMware CBT migrations', + `destination_cluster_id` bigint unsigned NOT NULL COMMENT 'Destination KVM cluster ID', + `convert_host_id` bigint unsigned COMMENT 'KVM host used for conversion and synchronization', + `storage_pool_id` bigint unsigned COMMENT 'Destination primary storage pool ID', + `display_name` varchar(255) COMMENT 'Target VM display name', + `host_name` varchar(255) COMMENT 'Target VM host name', + `template_id` bigint unsigned COMMENT 'Template ID for CloudStack VM import', + `service_offering_id` bigint unsigned COMMENT 'Service offering ID for CloudStack VM import', + `guest_os_id` bigint unsigned COMMENT 'Guest OS ID for CloudStack VM import', + `data_disk_offering_map` text COMMENT 'JSON data disk to disk offering ID map for CloudStack VM import', + `nic_network_map` text COMMENT 'JSON NIC to network ID map for CloudStack VM import', + `nic_ip_address_map` text COMMENT 'JSON NIC to IPv4 address map for CloudStack VM import', + `import_details` text COMMENT 'JSON details map for CloudStack VM import', + `forced` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Whether duplicate NIC MAC addresses are allowed during CloudStack VM import', + `vcenter` varchar(255) COMMENT 'Source vCenter', + `datacenter` varchar(255) COMMENT 'Source vCenter datacenter name', + `source_host` varchar(255) COMMENT 'Source VMware host name or IP', + `source_cluster` varchar(255) COMMENT 'Source VMware cluster name', + `source_vm_name` varchar(255) NOT NULL COMMENT 'Source VM name on vCenter', + `vddk_lib_dir` varchar(1024) COMMENT 'Optional VDDK library directory override', + `vddk_transports` varchar(255) COMMENT 'Optional VDDK transport list override', + `vddk_thumbprint` varchar(255) COMMENT 'Optional vCenter TLS thumbprint for VDDK connections', + `state` varchar(32) NOT NULL COMMENT 'Migration state', + `current_step` varchar(255) COMMENT 'Current migration step', + `last_error` varchar(1024) COMMENT 'Last error message', + `completed_cycles` int unsigned NOT NULL DEFAULT 0 COMMENT 'Completed CBT delta cycles', + `quiet_cycles` int unsigned NOT NULL DEFAULT 0 COMMENT 'Consecutive quiet CBT delta cycles', + `total_changed_bytes` bigint unsigned NOT NULL DEFAULT 0 COMMENT 'Total changed bytes copied across delta cycles', + `last_changed_bytes` bigint unsigned COMMENT 'Changed bytes copied in the latest delta cycle', + `last_dirty_rate` bigint unsigned COMMENT 'Changed bytes per second in the latest delta cycle', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_vmware_cbt_migration__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__user_id` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__vm_id` FOREIGN KEY (`vm_id`) REFERENCES `vm_instance`(`id`) ON DELETE SET NULL, + CONSTRAINT `fk_vmware_cbt_migration__existing_vcenter_id` FOREIGN KEY (`existing_vcenter_id`) REFERENCES `vmware_data_center`(`id`) ON DELETE SET NULL, + CONSTRAINT `fk_vmware_cbt_migration__destination_cluster_id` FOREIGN KEY (`destination_cluster_id`) REFERENCES `cluster`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__convert_host_id` FOREIGN KEY (`convert_host_id`) REFERENCES `host`(`id`) ON DELETE SET NULL, + CONSTRAINT `fk_vmware_cbt_migration__storage_pool_id` FOREIGN KEY (`storage_pool_id`) REFERENCES `storage_pool`(`id`) ON DELETE SET NULL, + INDEX `i_vmware_cbt_migration__zone_id` (`zone_id`), + INDEX `i_vmware_cbt_migration__state` (`state`), + INDEX `i_vmware_cbt_migration__source_vm_name` (`source_vm_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'host_name', 'varchar(255) COMMENT "Target VM host name"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'source_username', 'varchar(255) COMMENT "Stored source vCenter username for external VMware CBT migrations"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'source_password', 'varchar(1024) COMMENT "Encrypted stored source vCenter password for external VMware CBT migrations"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'template_id', 'bigint unsigned COMMENT "Template ID for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'service_offering_id', 'bigint unsigned COMMENT "Service offering ID for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'guest_os_id', 'bigint unsigned COMMENT "Guest OS ID for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'data_disk_offering_map', 'text COMMENT "JSON data disk to disk offering ID map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'nic_network_map', 'text COMMENT "JSON NIC to network ID map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'nic_ip_address_map', 'text COMMENT "JSON NIC to IPv4 address map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'import_details', 'text COMMENT "JSON details map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'forced', 'tinyint(1) NOT NULL DEFAULT 0 COMMENT "Whether duplicate NIC MAC addresses are allowed during CloudStack VM import"'); + +INSERT INTO `cloud`.`configuration` (`category`, `instance`, `component`, `name`, `value`, `description`, `default_value`, `updated`, `scope`, `is_dynamic`) +VALUES ('Advanced', 'DEFAULT', 'VmwareCbtMigrationManagerImpl', 'vmware.cbt.allow.non.inplace.finalization', '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.', + 'false', NOW(), 1, 1) +ON DUPLICATE KEY UPDATE `category` = VALUES(`category`), `component` = VALUES(`component`), + `description` = VALUES(`description`), `default_value` = VALUES(`default_value`), + `updated` = NOW(), `scope` = VALUES(`scope`), `is_dynamic` = VALUES(`is_dynamic`); + +INSERT INTO `cloud`.`configuration` (`category`, `instance`, `component`, `name`, `value`, `description`, `default_value`, `updated`, `scope`, `is_dynamic`) +VALUES ('Advanced', 'DEFAULT', 'VmwareCbtMigrationManagerImpl', 'vmware.cbt.migration.agent.command.timeout', '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.', + '86400', NOW(), 1, 1) +ON DUPLICATE KEY UPDATE `category` = VALUES(`category`), `component` = VALUES(`component`), + `description` = VALUES(`description`), `default_value` = VALUES(`default_value`), + `updated` = NOW(), `scope` = VALUES(`scope`), `is_dynamic` = VALUES(`is_dynamic`); + +CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration_disk` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `migration_id` bigint unsigned NOT NULL COMMENT 'VMware CBT migration ID', + `source_disk_id` varchar(255) COMMENT 'Source VMware disk key or label', + `source_disk_device_key` int COMMENT 'Source VMware virtual disk device key for QueryChangedDiskAreas', + `source_disk_path` varchar(1024) COMMENT 'Source VMware disk path', + `datastore_name` varchar(255) COMMENT 'Source VMware datastore name', + `capacity_bytes` bigint unsigned COMMENT 'Source disk capacity in bytes', + `target_path` varchar(1024) COMMENT 'Target KVM disk path', + `target_format` varchar(32) COMMENT 'Target KVM disk format', + `change_id` varchar(255) COMMENT 'Latest VMware CBT change ID', + `snapshot_moref` varchar(255) COMMENT 'Latest VMware snapshot managed object reference', + `state` varchar(32) NOT NULL COMMENT 'Disk synchronization state', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_vmware_cbt_migration_disk__migration_id` FOREIGN KEY (`migration_id`) REFERENCES `vmware_cbt_migration`(`id`) ON DELETE CASCADE, + INDEX `i_vmware_cbt_migration_disk__migration_id` (`migration_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration_cycle` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `migration_id` bigint unsigned NOT NULL COMMENT 'VMware CBT migration ID', + `cycle_number` int unsigned NOT NULL COMMENT 'CBT delta cycle number', + `snapshot_moref` varchar(255) COMMENT 'VMware snapshot managed object reference used for the cycle', + `changed_bytes` bigint unsigned COMMENT 'Changed bytes copied in this cycle', + `dirty_rate` bigint unsigned COMMENT 'Changed bytes per second in this cycle', + `duration` bigint unsigned COMMENT 'Cycle duration in milliseconds', + `state` varchar(32) NOT NULL COMMENT 'CBT delta cycle state', + `description` varchar(1024) COMMENT 'Cycle description or error message', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_vmware_cbt_migration_cycle__migration_id` FOREIGN KEY (`migration_id`) REFERENCES `vmware_cbt_migration`(`id`) ON DELETE CASCADE, + UNIQUE KEY `uc_vmware_cbt_migration_cycle__migration_id__cycle_number` (`migration_id`, `cycle_number`), + INDEX `i_vmware_cbt_migration_cycle__migration_id` (`migration_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index 4281036d9456..d370c6c55083 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -19,10 +19,20 @@ import static com.cloud.host.Host.HOST_CDROM_MAX_COUNT; import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION; import static com.cloud.host.Host.HOST_OVFTOOL_VERSION; +import static com.cloud.host.Host.HOST_QEMU_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_QEMU_IMG_VERSION; +import static com.cloud.host.Host.HOST_QEMU_IO_VERSION; +import static com.cloud.host.Host.HOST_QEMU_NBD_VERSION; import static com.cloud.host.Host.HOST_VDDK_LIB_DIR; +import static com.cloud.host.Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_VERSION; +import static com.cloud.host.Host.HOST_VIRTV2V_INPLACE_SUPPORT; +import static com.cloud.host.Host.HOST_VIRTV2V_INPLACE_VERSION; import static com.cloud.host.Host.HOST_VIRTV2V_VERSION; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_SUPPORT; import static com.cloud.host.Host.HOST_VOLUME_ENCRYPTION; import static org.apache.cloudstack.utils.linux.KVMHostInfo.isHostS390x; @@ -52,6 +62,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -368,6 +379,8 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final String INSTANCE_CONVERSION_SUPPORTED_CHECK_CMD = "virt-v2v --version"; // virt-v2v --version => sample output: virt-v2v 1.42.0rhel=8,release=22.module+el8.10.0+1590+a67ab969 + public static final String INSTANCE_CONVERSION_IN_PLACE_SUPPORTED_CHECK_CMD = "virt-v2v-in-place --version"; + public static final String INSTANCE_CONVERSION_IN_PLACE_OPTION_SUPPORTED_CHECK_CMD = "virt-v2v --help 2>&1 | grep -q -- '--in-place'"; public static final String OVF_EXPORT_SUPPORTED_CHECK_CMD = "ovftool --version"; // ovftool --version => sample output: VMware ovftool 4.6.0 (build-21452615) public static final String OVF_EXPORT_TOOl_GET_VERSION_CMD = "ovftool --version | awk '{print $3}'"; @@ -376,6 +389,12 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final String UBUNTU_WINDOWS_GUEST_CONVERSION_SUPPORTED_CHECK_CMD = "dpkg -l virtio-win"; public static final String UBUNTU_NBDKIT_PKG_CHECK_CMD = "dpkg -l nbdkit"; public static final String VDDK_AUTODETECT_PATH_CMD = "find / -type d -name 'vmware-vix-disklib-distrib' 2>/dev/null | head -n 1"; + public static final String NBDKIT_VDDK_DUMP_PLUGIN_CMD = "nbdkit vddk --dump-plugin"; + public static final String QEMU_IMG_SUPPORTED_CHECK_CMD = "qemu-img --version"; + public static final String QEMU_NBD_SUPPORTED_CHECK_CMD = "qemu-nbd --version"; + public static final String QEMU_IO_SUPPORTED_CHECK_CMD = "qemu-io --version"; + public static final String QEMU_IMG_RBD_SUPPORTED_CHECK_CMD = "qemu-img --help 2>&1 | grep -Eq '(^|[[:space:]])rbd([[:space:]]|$)'"; + public static final String NBDCOPY_SUPPORTED_CHECK_CMD = "nbdcopy --version"; public static final int LIBVIRT_CGROUP_CPU_SHARES_MIN = 2; public static final int LIBVIRT_CGROUP_CPU_SHARES_MAX = 262144; @@ -1266,9 +1285,9 @@ public boolean configure(final String name, final Map params) th LOGGER.warn("Could not detect a valid VDDK library dir; VDDK conversion will be unavailable"); } - vddkVersion = detectVddkVersion(); + vddkVersion = detectVddkVersion(vddkLibDir); if (StringUtils.isNotBlank(vddkVersion)) { - LOGGER.info("Detected nbdkit VDDK plugin version: {}", vddkVersion); + LOGGER.info("Detected usable VMware VDDK library version: {}", vddkVersion); } vddkTransports = StringUtils.trimToNull( @@ -4415,15 +4434,37 @@ public StartupCommand[] initialize() { cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION, String.valueOf(instanceConversionSupported)); cmd.getHostDetails().put(HOST_VDDK_SUPPORT, String.valueOf(hostSupportsVddk())); cmd.getHostDetails().put(HOST_CDROM_MAX_COUNT, String.valueOf(LibvirtVMDef.MAX_CDROMS_PER_VM)); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_SUPPORT, String.valueOf(hostSupportsVddkBlockCopy())); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, String.valueOf(hostSupportsVddkBlockCopyInPlaceFinalization())); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, String.valueOf(hostSupportsVddkBlockCopyRbd())); + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_SUPPORT, String.valueOf(hostSupportsVirtV2vInPlace())); + cmd.getHostDetails().put(HOST_QEMU_RBD_SUPPORT, String.valueOf(hostSupportsQemuRbd())); + cmd.getHostDetails().put(HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, String.valueOf(hostSupportsVddkRbdDirectImport())); if (StringUtils.isNotBlank(vddkLibDir)) { cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir); } if (StringUtils.isNotBlank(vddkVersion)) { cmd.getHostDetails().put(HOST_VDDK_VERSION, vddkVersion); } + String qemuImgVersion = getQemuImgVersion(); + if (StringUtils.isNotBlank(qemuImgVersion)) { + cmd.getHostDetails().put(HOST_QEMU_IMG_VERSION, qemuImgVersion); + } + String qemuNbdVersion = getQemuNbdVersion(); + if (StringUtils.isNotBlank(qemuNbdVersion)) { + cmd.getHostDetails().put(HOST_QEMU_NBD_VERSION, qemuNbdVersion); + } + String qemuIoVersion = getQemuIoVersion(); + if (StringUtils.isNotBlank(qemuIoVersion)) { + cmd.getHostDetails().put(HOST_QEMU_IO_VERSION, qemuIoVersion); + } if (instanceConversionSupported) { cmd.getHostDetails().put(HOST_VIRTV2V_VERSION, getHostVirtV2vVersion()); } + String virtV2vInPlaceVersion = getHostVirtV2vInPlaceVersion(); + if (StringUtils.isNotBlank(virtV2vInPlaceVersion)) { + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_VERSION, virtV2vInPlaceVersion); + } if (hostSupportsOvfExport()) { cmd.getHostDetails().put(HOST_OVFTOOL_VERSION, getHostOvfToolVersion()); } @@ -6241,14 +6282,118 @@ public boolean hostSupportsVddk() { } public boolean hostSupportsVddk(String overriddenVddkLibDir) { - String effectiveVddkLibDir = StringUtils.trimToNull(overriddenVddkLibDir); - if (StringUtils.isBlank(effectiveVddkLibDir)) { - effectiveVddkLibDir = StringUtils.trimToNull(vddkLibDir); + String effectiveVddkLibDir = resolveVddkLibDir(overriddenVddkLibDir); + return hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && isNbdkitVddkPluginUsable(effectiveVddkLibDir); + } + + public boolean hostSupportsVddkBlockCopy() { + return hostSupportsVddkBlockCopy(null); + } + + public boolean hostSupportsVddkBlockCopy(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_NBD_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_IO_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVddkBlockCopyInPlaceFinalization() { + return hostSupportsVddkBlockCopy() && hostSupportsVirtV2vInPlace(); + } + + public boolean hostSupportsVddkBlockCopyRbd() { + return hostSupportsVddkBlockCopyInPlaceFinalization() + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public String getQemuImgVersion() { + return detectFirstLineVersion("qemu-img", "--version"); + } + + public String getQemuNbdVersion() { + return detectFirstLineVersion("qemu-nbd", "--version"); + } + + public String getQemuIoVersion() { + return detectFirstLineVersion("qemu-io", "--version"); + } + + public boolean hostSupportsVirtV2vInPlace() { + return hostSupportsVirtV2vInPlaceBinary() || hostSupportsVirtV2vInPlaceOption(); + } + + /** + * nbdcopy (from libnbd) is an optional accelerator for full-disk copies from an + * nbdkit/VDDK source into a local raw block device: it uses multiple in-flight + * requests and connections, so it is typically faster than a single-connection + * qemu-img convert. It is a pure optimization - callers fall back to + * qemu-img convert when it is absent. + */ + public boolean hostSupportsNbdcopy() { + return Script.runSimpleBashScriptForExitValue(NBDCOPY_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVirtV2vInPlaceBinary() { + return Script.runSimpleBashScriptForExitValue(INSTANCE_CONVERSION_IN_PLACE_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVirtV2vInPlaceOption() { + return Script.runSimpleBashScriptForExitValue(INSTANCE_CONVERSION_IN_PLACE_OPTION_SUPPORTED_CHECK_CMD) == 0; + } + + public String getHostVirtV2vInPlaceVersion() { + if (!hostSupportsVirtV2vInPlace()) { + return ""; } - if (StringUtils.isBlank(effectiveVddkLibDir) || !isVddkLibDirValid(effectiveVddkLibDir)) { - effectiveVddkLibDir = detectVddkLibDir(); + if (hostSupportsVirtV2vInPlaceOption() && !hostSupportsVirtV2vInPlaceBinary()) { + return getHostVirtV2vVersion(); } - return hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && StringUtils.isNotBlank(detectVddkVersion()); + String cmd = String.format("%s | awk '{print $2}'", INSTANCE_CONVERSION_IN_PLACE_SUPPORTED_CHECK_CMD); + String version = Script.runSimpleBashScript(cmd); + return StringUtils.isNotBlank(version) ? version.split(",")[0] : ""; + } + + protected String detectFirstLineVersion(String... command) { + try { + ProcessBuilder pb = new ProcessBuilder(command); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + process.waitFor(); + + for (String line : output.split("\\R")) { + String trimmed = StringUtils.trimToNull(line); + if (StringUtils.isNotBlank(trimmed)) { + return parseVersionToken(trimmed); + } + } + } catch (Exception e) { + LOGGER.debug("Failed to detect version for command {}: {}", String.join(" ", command), e.getMessage()); + } + return null; + } + + protected String parseVersionToken(String versionLine) { + String versionMarker = " version "; + int markerIndex = versionLine.indexOf(versionMarker); + if (markerIndex < 0) { + return versionLine; + } + String value = versionLine.substring(markerIndex + versionMarker.length()); + String[] parts = value.split("\\s+", 2); + return parts.length > 0 ? parts[0] : versionLine; + } + + public boolean hostSupportsQemuRbd() { + return Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVddkRbdDirectImport() { + return hostSupportsVddkRbdDirectImport(null); + } + + public boolean hostSupportsVddkRbdDirectImport(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) && hostSupportsQemuRbd() && hostSupportsVirtV2vInPlace(); } protected boolean isVddkLibDirValid(String path) { @@ -6263,6 +6408,17 @@ protected boolean isVddkLibDirValid(String path) { return libs != null && libs.length > 0; } + protected String resolveVddkLibDir(String overriddenVddkLibDir) { + String effectiveVddkLibDir = StringUtils.trimToNull(overriddenVddkLibDir); + if (StringUtils.isBlank(effectiveVddkLibDir)) { + effectiveVddkLibDir = StringUtils.trimToNull(vddkLibDir); + } + if (StringUtils.isBlank(effectiveVddkLibDir) || !isVddkLibDirValid(effectiveVddkLibDir)) { + effectiveVddkLibDir = detectVddkLibDir(); + } + return effectiveVddkLibDir; + } + protected String detectVddkLibDir() { String detectedPath = StringUtils.trimToNull(Script.runSimpleBashScript(VDDK_AUTODETECT_PATH_CMD)); if (StringUtils.isNotBlank(detectedPath) && isVddkLibDirValid(detectedPath)) { @@ -6272,28 +6428,73 @@ protected String detectVddkLibDir() { } protected String detectVddkVersion() { + return detectVddkVersion(null); + } + + protected String detectVddkVersion(String overriddenVddkLibDir) { + String effectiveVddkLibDir = resolveVddkLibDir(overriddenVddkLibDir); + if (!isVddkLibDirValid(effectiveVddkLibDir)) { + return null; + } + return parseVddkLibraryVersionFromDumpPluginOutput(runNbdkitVddkDumpPlugin(effectiveVddkLibDir)); + } + + protected boolean isNbdkitVddkPluginUsable(String vddkLibDir) { + if (!isVddkLibDirValid(vddkLibDir)) { + return false; + } + String dumpPluginOutput = runNbdkitVddkDumpPlugin(vddkLibDir); + if (StringUtils.isBlank(parseVddkLibraryVersionFromDumpPluginOutput(dumpPluginOutput))) { + LOGGER.warn("nbdkit-vddk-plugin could not load VMware VDDK from [{}]", vddkLibDir); + return false; + } + return true; + } + + protected String runNbdkitVddkDumpPlugin(String vddkLibDir) { try { - ProcessBuilder pb = new ProcessBuilder("nbdkit", "vddk", "--version"); + ProcessBuilder pb = new ProcessBuilder("nbdkit", "vddk", "--dump-plugin", String.format("libdir=%s", vddkLibDir)); + pb.redirectErrorStream(true); Process process = pb.start(); + boolean completed = process.waitFor(10, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } String output = new String(process.getInputStream().readAllBytes()); - process.waitFor(); - - if (StringUtils.isBlank(output)) { - return null; + if (!completed) { + LOGGER.warn("Timed out while checking nbdkit-vddk-plugin with libdir [{}]", vddkLibDir); + return output; + } + if (process.exitValue() != 0) { + LOGGER.warn("nbdkit-vddk-plugin check failed for libdir [{}]: {}", vddkLibDir, StringUtils.trimToEmpty(output)); + return output; } + return output; + } catch (Exception e) { + LOGGER.error("Failed to check nbdkit-vddk-plugin with libdir [{}]: {}", vddkLibDir, e.getMessage()); + return null; + } + } + protected String parseVddkLibraryVersionFromDumpPluginOutput(String output) { + if (StringUtils.isBlank(output)) { + return null; + } + String libraryVersionPrefix = "vddk_library_version="; + try { for (String line : output.split("\\R")) { String trimmed = StringUtils.trimToEmpty(line); - if (trimmed.startsWith("vddk ")) { - return StringUtils.trimToNull(trimmed.substring("vddk ".length())); + if (trimmed.startsWith(libraryVersionPrefix)) { + return StringUtils.trimToNull(trimmed.substring(libraryVersionPrefix.length())); } } - return null; } catch (Exception e) { - LOGGER.error("Failed to detect vddk version: {}", e.getMessage()); + LOGGER.error("Failed to parse nbdkit-vddk-plugin output: {}", e.getMessage()); return null; } + return null; } public boolean hostSupportsWindowsGuestConversion() { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java index dc34a4cb62d8..7bb525ba196f 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java @@ -128,6 +128,13 @@ protected void sanitizeDisksPath(List disks) { protected List moveTemporaryDisksToDestination(List temporaryDisks, List destinationStoragePools, KVMStoragePoolManager storagePoolMgr) { + return moveTemporaryDisksToDestination(temporaryDisks, destinationStoragePools, null, storagePoolMgr); + } + + protected List moveTemporaryDisksToDestination(List temporaryDisks, + List destinationStoragePools, + List destinationStoragePoolTypes, + KVMStoragePoolManager storagePoolMgr) { List targetDisks = new ArrayList<>(); if (temporaryDisks.size() != destinationStoragePools.size()) { String warn = String.format("Discrepancy between the converted instance disks (%s) " + @@ -136,17 +143,13 @@ protected List moveTemporaryDisksToDestination(List moveTemporaryDisksToDestination(List moveTemporaryDisksToDestination(List destinationStoragePoolTypes, int index) { + if (CollectionUtils.isEmpty(destinationStoragePoolTypes) || destinationStoragePoolTypes.size() <= index || destinationStoragePoolTypes.get(index) == null) { + return Storage.StoragePoolType.NetworkFilesystem; + } + return destinationStoragePoolTypes.get(index); + } + + private void probeRbdQemuAccess(KVMStoragePool pool, String destinationName) { + String probeName = destinationName + "-probe"; + String rbdImagePath = pool.getSourceDir() + "/" + probeName; + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(pool, rbdImagePath); + try { + Script qemuImg = new Script("qemu-img", 120000, logger); + qemuImg.add("create", "-f", "raw", qemuRbdPath, "4194304"); + qemuImg.execute(); + if (qemuImg.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-img could not create RBD probe image %s", rbdImagePath)); + } + + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", qemuRbdPath); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify RBD probe image %s", rbdImagePath)); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete RBD probe image {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + private void cleanupMovedDisksOnDestinationPool(List targetDisks) { if (CollectionUtils.isEmpty(targetDisks)) { return; @@ -220,9 +262,20 @@ protected List getUnmanagedInstanceDisks(List storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); - disk.setDatastoreHost(storagePoolHostAndPath.first()); - disk.setDatastorePath(storagePoolHostAndPath.second()); + if (storagePool.getType() == Storage.StoragePoolType.RBD) { + disk.setDatastoreHost(storagePool.getSourceHost()); + disk.setDatastorePath(storagePool.getSourceDir()); + disk.setImagePath(physicalDisk.getName()); + } else if (storagePool.getType() == Storage.StoragePoolType.Linstor) { + // Linstor pools expose no local mount path; the management server resolves the + // pool by its UUID (datastore name), and the volume by its disk name (path). + disk.setDatastoreHost(storagePool.getSourceHost()); + disk.setImagePath(physicalDisk.getName()); + } else { + Pair storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); + disk.setDatastoreHost(storagePoolHostAndPath.first()); + disk.setDatastorePath(storagePoolHostAndPath.second()); + } disk.setDatastoreName(storagePool.getUuid()); disk.setDatastoreType(storagePool.getType().name()); disk.setCapacity(physicalDisk.getVirtualSize()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java index de9341715f02..0eb728d5d37e 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java @@ -31,6 +31,21 @@ public class LibvirtCheckConvertInstanceCommandWrapper extends CommandWrapper STORAGE_POOL_TYPES_SUPPORTED = Arrays.asList( Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.NetworkFilesystem, - Storage.StoragePoolType.SharedMountPoint); + Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.RBD); @Override public Answer execute(final CheckVolumeCommand command, final LibvirtComputingResource libvirtComputingResource) { @@ -63,6 +64,9 @@ public Answer execute(final CheckVolumeCommand command, final LibvirtComputingRe try { if (STORAGE_POOL_TYPES_SUPPORTED.contains(storageFilerTO.getType())) { final KVMPhysicalDisk vol = pool.getPhysicalDisk(srcFile); + if (Storage.StoragePoolType.RBD.equals(storageFilerTO.getType())) { + return checkRbdVolume(command, pool, vol); + } final String path = vol.getPath(); try { KVMPhysicalDisk.checkQcow2File(path); @@ -81,6 +85,21 @@ public Answer execute(final CheckVolumeCommand command, final LibvirtComputingRe } } + /** + * RBD (Ceph) volumes are raw images that cannot be inspected through direct file reads + * (checkQcow2File / getVirtualSizeFromFile operate on a local path). Instead the volume is + * inspected through the RBD URI via qemu-img (see {@link #getDiskFileInfo}). The virtual size + * is taken from the disk reported by the storage pool, and the encrypted/backing-file/locked + * details are still collected so the management server can apply its import validations. + */ + private Answer checkRbdVolume(final CheckVolumeCommand command, final KVMStoragePool pool, final KVMPhysicalDisk disk) { + Map volumeDetails = getVolumeDetails(pool, disk); + if (volumeDetails == null) { + return new CheckVolumeAnswer(command, false, "", 0, null); + } + return new CheckVolumeAnswer(command, true, "", disk.getVirtualSize(), volumeDetails); + } + private Map getVolumeDetails(KVMStoragePool pool, KVMPhysicalDisk disk) { Map info = getDiskFileInfo(pool, disk, true); if (MapUtils.isEmpty(info)) { @@ -122,6 +141,10 @@ private Map getDiskFileInfo(KVMStoragePool pool, KVMPhysicalDisk try { QemuImg qemu = new QemuImg(0); QemuImgFile qemuFile = new QemuImgFile(disk.getPath(), disk.getFormat()); + if (Storage.StoragePoolType.RBD.equals(pool.getType())) { + String rbdDestFile = KVMPhysicalDisk.RBDStringBuilder(pool, disk.getPath()); + qemuFile = new QemuImgFile(rbdDestFile, disk.getFormat()); + } return qemu.info(qemuFile, secure); } catch (QemuImgException | LibvirtException ex) { logger.error("Failed to get info of disk file: " + ex.getMessage()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java index f28b5eda4a63..6015047fff8b 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java @@ -23,6 +23,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; import java.util.Locale; import java.util.Arrays; import java.util.List; @@ -33,6 +34,7 @@ import java.util.regex.Pattern; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.utils.qemu.QemuImg; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -42,14 +44,18 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.NfsTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareVddkSourceDiskTO; import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; import com.cloud.utils.FileUtil; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.script.OutputInterpreter; import com.cloud.utils.script.Script; @@ -89,6 +95,8 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); KVMStoragePool temporaryStoragePool = getTemporaryStoragePool(conversionTemporaryLocation, storagePoolMgr); + boolean directRbdVddkImport = isDirectRbdVddkImport(cmd); + boolean directLinstorVddkImport = isDirectLinstorVddkImport(cmd); logger.info(String.format("(%s) Attempting to convert the instance %s from %s to KVM", originalVMName, sourceInstanceName, sourceHypervisorType)); @@ -114,9 +122,27 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve String vddkTransports = resolveVddkSetting(cmd.getVddkTransports(), serverResource.getVddkTransports()); String configuredVddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); String passwordOption = serverResource.getDetectedPasswordFileOption(); - result = performInstanceConversionUsingVddk(sourceInstance, originalVMName, temporaryConvertPath, - vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, - timeout, verboseModeEnabled, extraParams, temporaryConvertUuid, passwordOption); + if (directRbdVddkImport || directLinstorVddkImport) { + if (directRbdVddkImport && !serverResource.hostSupportsVddkRbdDirectImport(cmd.getVddkLibDir())) { + String err = String.format("Direct RBD VDDK import requires VDDK, qemu-img RBD support, and virt-v2v in-place support on host %s. " + + "Use staged import with temporary conversion storage or select a newer conversion host.", serverResource.getPrivateIp()); + logger.error("({}) {}", originalVMName, err); + return new Answer(cmd, false, err); + } + if (directLinstorVddkImport && !serverResource.hostSupportsVirtV2vInPlace()) { + String err = String.format("Direct Linstor VDDK import requires virt-v2v in-place support on host %s. " + + "Use staged import with temporary conversion storage or select a newer conversion host.", serverResource.getPrivateIp()); + logger.error("({}) {}", originalVMName, err); + return new Answer(cmd, false, err); + } + result = performInstanceConversionUsingVddkDirectToPool(cmd, temporaryStoragePool, originalVMName, + vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, + timeout, verboseModeEnabled, temporaryConvertUuid, serverResource); + } else { + result = performInstanceConversionUsingVddk(sourceInstance, originalVMName, temporaryConvertPath, + vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, + timeout, verboseModeEnabled, extraParams, temporaryConvertUuid, passwordOption); + } } else { logger.info("({}) Using OVF-based conversion (export + local convert)", originalVMName); String sourceOVFDirPath; @@ -177,6 +203,20 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve } } + private boolean isDirectRbdVddkImport(ConvertInstanceCommand cmd) { + return isDirectVddkImportToPoolType(cmd, Storage.StoragePoolType.RBD); + } + + private boolean isDirectLinstorVddkImport(ConvertInstanceCommand cmd) { + return isDirectVddkImportToPoolType(cmd, Storage.StoragePoolType.Linstor); + } + + private boolean isDirectVddkImportToPoolType(ConvertInstanceCommand cmd, Storage.StoragePoolType poolType) { + DataStoreTO conversionTemporaryLocation = cmd.getConversionTemporaryLocation(); + return cmd.isUseVddk() && conversionTemporaryLocation instanceof PrimaryDataStoreTO && + ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType() == poolType; + } + protected KVMStoragePool getTemporaryStoragePool(DataStoreTO conversionTemporaryLocation, KVMStoragePoolManager storagePoolMgr) { if (conversionTemporaryLocation instanceof NfsTO) { NfsTO nfsTO = (NfsTO) conversionTemporaryLocation; @@ -399,6 +439,371 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst } } + protected boolean performInstanceConversionUsingVddkDirectToPool(ConvertInstanceCommand cmd, + KVMStoragePool targetPool, + String originalVMName, + String vddkLibDir, + String libguestfsBackend, + String vddkTransports, + String configuredVddkThumbprint, + long timeout, + boolean verboseModeEnabled, + String temporaryConvertUuid, + LibvirtComputingResource serverResource) { + RemoteInstanceTO vmwareInstance = cmd.getSourceInstance(); + List sourceDisks = cmd.getVmwareVddkSourceDisks(); + final boolean linstorTarget = targetPool.getType() == Storage.StoragePoolType.Linstor; + if (sourceDisks == null || sourceDisks.isEmpty()) { + logger.error("({}) Direct {} VDDK import requires VMware source disk metadata", originalVMName, targetPool.getType()); + return false; + } + + if (linstorTarget) { + probeLinstorQemuAccess(targetPool, temporaryConvertUuid); + } else { + probeRbdQemuAccess(targetPool, temporaryConvertUuid); + } + + String vcenterPassword = vmwareInstance.getVcenterPassword(); + if (StringUtils.isBlank(vcenterPassword)) { + logger.error("({}) Could not determine vCenter password for {}", originalVMName, vmwareInstance.getVcenterHost()); + return false; + } + + String vddkThumbprint = StringUtils.trimToNull(configuredVddkThumbprint); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(vmwareInstance.getVcenterHost(), timeout, originalVMName); + } + if (StringUtils.isBlank(vddkThumbprint)) { + logger.error("({}) Could not determine vCenter thumbprint for {}", originalVMName, vmwareInstance.getVcenterHost()); + return false; + } + + String passwordFilePath = String.format("/tmp/v2v.rbd.pass.cloud.%s.%s", + StringUtils.defaultIfBlank(vmwareInstance.getVcenterHost(), "unknown"), + UUID.randomUUID()); + List createdImages = new ArrayList<>(); + List blockDevicePaths = new ArrayList<>(); + try { + Files.writeString(Path.of(passwordFilePath), vcenterPassword); + Files.setPosixFilePermissions(Path.of(passwordFilePath), Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + + for (int i = 0; i < sourceDisks.size(); i++) { + VmwareVddkSourceDiskTO sourceDisk = sourceDisks.get(i); + if (linstorTarget) { + String diskName = buildLinstorDiskName(temporaryConvertUuid, i); + createdImages.add(diskName); + String devicePath = copyVddkSourceDiskToLinstor(vmwareInstance, sourceDisk, targetPool, diskName, passwordFilePath, + vddkLibDir, vddkTransports, vddkThumbprint, timeout, originalVMName, serverResource.hostSupportsNbdcopy()); + blockDevicePaths.add(devicePath); + } else { + String imageName = buildRbdImageName(temporaryConvertUuid, i); + createdImages.add(imageName); + copyVddkSourceDiskToRbd(vmwareInstance, sourceDisk, targetPool, imageName, passwordFilePath, + vddkLibDir, vddkTransports, vddkThumbprint, timeout, originalVMName); + } + } + + Path inputXml = Files.createTempFile("cloudstack-vddk-direct-" + temporaryConvertUuid, ".xml"); + String domainXml = linstorTarget + ? buildDirectBlockDeviceLibvirtXml(temporaryConvertUuid, blockDevicePaths) + : buildDirectRbdLibvirtXml(temporaryConvertUuid, targetPool, createdImages); + Files.writeString(inputXml, domainXml); + + boolean finalized = runInPlaceFinalization(inputXml, libguestfsBackend, timeout, verboseModeEnabled, originalVMName, serverResource); + if (!finalized) { + cleanupDirectImportDisks(targetPool, createdImages, originalVMName); + } + Files.deleteIfExists(inputXml); + return finalized; + } catch (Exception e) { + logger.error("({}) Direct {} VDDK import failed: {}", originalVMName, targetPool.getType(), e.getMessage(), e); + cleanupDirectImportDisks(targetPool, createdImages, originalVMName); + return false; + } finally { + try { + Files.deleteIfExists(Path.of(passwordFilePath)); + } catch (Exception e) { + logger.warn("({}) Failed to delete password file {}: {}", originalVMName, passwordFilePath, e.getMessage()); + } + } + } + + private void copyVddkSourceDiskToRbd(RemoteInstanceTO vmwareInstance, VmwareVddkSourceDiskTO sourceDisk, + KVMStoragePool targetPool, String imageName, String passwordFilePath, + String vddkLibDir, String vddkTransports, String vddkThumbprint, + long timeout, String originalVMName) { + if (StringUtils.isBlank(sourceDisk.getSourceDiskPath())) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a VMDK path", sourceDisk.getDiskId())); + } + String rbdImagePath = targetPool.getSourceDir() + "/" + imageName; + String qemuRbdTarget = KVMPhysicalDisk.RBDStringBuilder(targetPool, rbdImagePath); + StringBuilder command = new StringBuilder(); + command.append("nbdkit -r -U - vddk "); + command.append("file=").append(shellQuote(sourceDisk.getSourceDiskPath())).append(" "); + command.append("server=").append(shellQuote(vmwareInstance.getVcenterHost())).append(" "); + command.append("user=").append(shellQuote(vmwareInstance.getVcenterUsername())).append(" "); + command.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + if (StringUtils.isNotBlank(vmwareInstance.getVmwareMoref())) { + command.append("vm=").append(shellQuote("moref=" + vmwareInstance.getVmwareMoref())).append(" "); + } else { + command.append("vm=").append(shellQuote(vmwareInstance.getInstanceName())).append(" "); + } + command.append("libdir=").append(shellQuote(vddkLibDir)).append(" "); + command.append("thumbprint=").append(shellQuote(vddkThumbprint)).append(" "); + if (StringUtils.isNotBlank(vddkTransports)) { + command.append("transports=").append(shellQuote(vddkTransports)).append(" "); + } + String runCommand = "qemu-img convert -f raw -O raw \"$uri\" " + shellQuote(qemuRbdTarget); + command.append("--run ").append(shellQuote(runCommand)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command.toString()); + String logPrefix = String.format("(%s) vddk to rbd disk %s", originalVMName, sourceDisk.getDiskId()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + logger.info("({}) Copying VMware disk {} to RBD image {}", originalVMName, sourceDisk.getSourceDiskPath(), rbdImagePath); + script.execute(outputLogger); + if (script.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("Failed to copy VMware disk %s to RBD image %s", sourceDisk.getSourceDiskPath(), rbdImagePath)); + } + } + + private String copyVddkSourceDiskToLinstor(RemoteInstanceTO vmwareInstance, VmwareVddkSourceDiskTO sourceDisk, + KVMStoragePool targetPool, String diskName, String passwordFilePath, + String vddkLibDir, String vddkTransports, String vddkThumbprint, + long timeout, String originalVMName, boolean useNbdcopy) { + if (StringUtils.isBlank(sourceDisk.getSourceDiskPath())) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a VMDK path", sourceDisk.getDiskId())); + } + if (sourceDisk.getCapacityBytes() <= 0) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a valid capacity, " + + "which is required to pre-create the Linstor target volume", sourceDisk.getDiskId())); + } + KVMPhysicalDisk targetDisk = targetPool.createPhysicalDisk(diskName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, sourceDisk.getCapacityBytes(), null); + String devicePath = targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new CloudRuntimeException(String.format("Could not resolve a local device path for Linstor volume %s", diskName)); + } + StringBuilder command = new StringBuilder(); + command.append("nbdkit -r -U - vddk "); + command.append("file=").append(shellQuote(sourceDisk.getSourceDiskPath())).append(" "); + command.append("server=").append(shellQuote(vmwareInstance.getVcenterHost())).append(" "); + command.append("user=").append(shellQuote(vmwareInstance.getVcenterUsername())).append(" "); + command.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + if (StringUtils.isNotBlank(vmwareInstance.getVmwareMoref())) { + command.append("vm=").append(shellQuote("moref=" + vmwareInstance.getVmwareMoref())).append(" "); + } else { + command.append("vm=").append(shellQuote(vmwareInstance.getInstanceName())).append(" "); + } + command.append("libdir=").append(shellQuote(vddkLibDir)).append(" "); + command.append("thumbprint=").append(shellQuote(vddkThumbprint)).append(" "); + if (StringUtils.isNotBlank(vddkTransports)) { + command.append("transports=").append(shellQuote(vddkTransports)).append(" "); + } + // Full-disk copy into the local raw device: prefer nbdcopy (libnbd) when present - + // it pipelines many in-flight requests and is typically faster than a single-connection + // qemu-img convert - and fall back to qemu-img convert otherwise. Both skip source holes, + // which is correct because the freshly spawned thin device reads back as zeros. + String runCommand = useNbdcopy + ? "nbdcopy \"$uri\" " + shellQuote(devicePath) + : "qemu-img convert -n -f raw -O raw \"$uri\" " + shellQuote(devicePath); + command.append("--run ").append(shellQuote(runCommand)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command.toString()); + String logPrefix = String.format("(%s) %s to linstor disk %s", originalVMName, + useNbdcopy ? "vddk(nbdcopy)" : "vddk", sourceDisk.getDiskId()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + logger.info("({}) Copying VMware disk {} to Linstor device {}", originalVMName, sourceDisk.getSourceDiskPath(), devicePath); + script.execute(outputLogger); + if (script.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("Failed to copy VMware disk %s to Linstor device %s", sourceDisk.getSourceDiskPath(), devicePath)); + } + return devicePath; + } + + private boolean runInPlaceFinalization(Path inputXml, String libguestfsBackend, long timeout, + boolean verboseModeEnabled, String originalVMName, + LibvirtComputingResource serverResource) { + StringBuilder command = new StringBuilder(); + command.append("export LIBGUESTFS_BACKEND=").append(shellQuote(libguestfsBackend)).append(" && "); + if (serverResource.hostSupportsVirtV2vInPlaceBinary()) { + // No -O (write updated output XML): nothing consumes it and the option only + // exists from virt-v2v 2.5 on, so passing it breaks otherwise capable hosts + // such as Ubuntu 24.04 with virt-v2v-in-place 2.4. + command.append("virt-v2v-in-place --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" "); + } else if (serverResource.hostSupportsVirtV2vInPlaceOption()) { + command.append("virt-v2v --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" --in-place "); + } else { + logger.error("({}) No virt-v2v in-place finalization method is available", originalVMName); + return false; + } + if (verboseModeEnabled) { + command.append("-v "); + } + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command.toString()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, + String.format("(%s) virt-v2v rbd in-place", originalVMName)); + script.execute(outputLogger); + return script.getExitValue() == 0; + } + + private String buildDirectRbdLibvirtXml(String temporaryConvertUuid, KVMStoragePool targetPool, List imageNames) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(xmlEscape("cloudstack-vddk-rbd-" + temporaryConvertUuid)).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" hvm\n"); + xml.append(" \n"); + for (int i = 0; i < imageNames.size(); i++) { + String imageName = imageNames.get(i); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + for (String sourceHost : StringUtils.split(StringUtils.defaultString(targetPool.getSourceHost()), ",")) { + xml.append(" \n"); + } + xml.append(" \n"); + if (StringUtils.isNotBlank(targetPool.getAuthUserName())) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private String buildDirectBlockDeviceLibvirtXml(String temporaryConvertUuid, List devicePaths) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(xmlEscape("cloudstack-vddk-direct-" + temporaryConvertUuid)).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" hvm\n"); + xml.append(" \n"); + for (int i = 0; i < devicePaths.size(); i++) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private String buildRbdImageName(String temporaryConvertUuid, int position) { + return String.format("%s-disk-%03d", temporaryConvertUuid, position); + } + + /** + * Linstor disk names use a shorter position suffix than the RBD naming scheme: + * LINSTOR resource names are limited to 48 characters and the "cs-" prefix plus + * a 36-character UUID leaves little room ("-d%02d" keeps the name at 43). + */ + private String buildLinstorDiskName(String temporaryConvertUuid, int position) { + return String.format("%s-d%02d", temporaryConvertUuid, position); + } + + private String diskTargetName(int index) { + StringBuilder target = new StringBuilder("sd"); + int value = index; + do { + target.insert(2, (char) ('a' + (value % 26))); + value = value / 26 - 1; + } while (value >= 0); + return target.toString(); + } + + private void probeRbdQemuAccess(KVMStoragePool pool, String temporaryConvertUuid) { + String probeName = temporaryConvertUuid + "-probe-" + UUID.randomUUID(); + String rbdImagePath = pool.getSourceDir() + "/" + probeName; + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(pool, rbdImagePath); + try { + Script qemuImg = new Script("qemu-img", 120000, logger); + qemuImg.add("create", "-f", "raw", qemuRbdPath, "4194304"); + qemuImg.execute(); + if (qemuImg.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-img could not create RBD probe image %s", rbdImagePath)); + } + + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", qemuRbdPath); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify RBD probe image %s", rbdImagePath)); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete RBD probe image {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + + private void probeLinstorQemuAccess(KVMStoragePool pool, String temporaryConvertUuid) { + String probeName = temporaryConvertUuid.substring(0, 8) + "-probe"; + try { + KVMPhysicalDisk probeDisk = pool.createPhysicalDisk(probeName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, 4194304L, null); + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", probeDisk.getPath()); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify Linstor probe volume %s on pool %s", probeName, pool.getUuid())); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete Linstor probe volume {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + + private void cleanupDirectImportDisks(KVMStoragePool pool, List imageNames, String originalVMName) { + for (String imageName : imageNames) { + try { + logger.info("({}) Cleaning up disk {} after failed direct import", originalVMName, imageName); + pool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("({}) Failed to delete disk {} from pool {}: {}", originalVMName, imageName, pool.getUuid(), e.getMessage()); + } + } + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String xmlEscape(String value) { + return StringUtils.defaultString(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + protected String getVcenterThumbprint(String vcenterHost, long timeout, String originalVMName) { if (StringUtils.isBlank(vcenterHost)) { return null; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java index 28e24a9e0f2d..fb432cd71e66 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java @@ -20,7 +20,9 @@ import java.util.List; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.commons.collections4.CollectionUtils; import com.cloud.agent.api.Answer; import com.cloud.agent.api.ImportConvertedInstanceAnswer; @@ -35,7 +37,7 @@ import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.ResourceWrapper; -import org.apache.commons.collections4.CollectionUtils; +import com.cloud.storage.Storage; @ResourceWrapper(handles = ImportConvertedInstanceCommand.class) public class LibvirtImportConvertedInstanceCommandWrapper extends LibvirtBaseConvertCommandWrapper { @@ -46,6 +48,7 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour Hypervisor.HypervisorType sourceHypervisorType = sourceInstance.getHypervisorType(); String sourceInstanceName = sourceInstance.getInstanceName(); List destinationStoragePools = cmd.getDestinationStoragePools(); + List destinationStoragePoolTypes = cmd.getDestinationStoragePoolTypes(); DataStoreTO conversionTemporaryLocation = cmd.getConversionTemporaryLocation(); final String temporaryConvertUuid = cmd.getTemporaryConvertUuid(); final boolean forceConvertToPool = cmd.isForceConvertToPool(); @@ -55,6 +58,12 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour final String temporaryConvertPath = temporaryStoragePool.getLocalPath(); try { + if (isForcedBlockStorageConversion(conversionTemporaryLocation, forceConvertToPool)) { + List disks = getTemporaryDisksWithPrefixFromTemporaryPool(temporaryStoragePool, temporaryConvertPath, temporaryConvertUuid); + UnmanagedInstanceTO convertedInstanceTO = getConvertedUnmanagedInstance(temporaryConvertUuid, disks, null); + return new ImportConvertedInstanceAnswer(cmd, convertedInstanceTO); + } + String convertedBasePath = String.format("%s/%s", temporaryConvertPath, temporaryConvertUuid); LibvirtDomainXMLParser xmlParser = parseMigratedVMXmlDomain(convertedBasePath); @@ -68,7 +77,7 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour disks = temporaryDisks; } else { disks = moveTemporaryDisksToDestination(temporaryDisks, - destinationStoragePools, storagePoolMgr); + destinationStoragePools, destinationStoragePoolTypes, storagePoolMgr); cleanupDisksAndDomainFromTemporaryLocation(temporaryDisks, temporaryStoragePool, temporaryConvertUuid, true); } @@ -93,4 +102,12 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour } } } + + private boolean isForcedBlockStorageConversion(DataStoreTO conversionTemporaryLocation, boolean forceConvertToPool) { + if (!forceConvertToPool || !(conversionTemporaryLocation instanceof PrimaryDataStoreTO)) { + return false; + } + Storage.StoragePoolType poolType = ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType(); + return poolType == Storage.StoragePoolType.RBD || poolType == Storage.StoragePoolType.Linstor; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java index 5a7d6d2c203a..356836237edd 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java @@ -51,9 +51,19 @@ public Answer execute(final ReadyCommand command, final LibvirtComputingResource if (libvirtComputingResource.hostSupportsInstanceConversion()) { hostDetails.put(Host.HOST_VIRTV2V_VERSION, libvirtComputingResource.getHostVirtV2vVersion()); } + hostDetails.put(Host.HOST_VIRTV2V_INPLACE_VERSION, libvirtComputingResource.getHostVirtV2vInPlaceVersion()); hostDetails.put(Host.HOST_VDDK_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddk())); hostDetails.put(Host.HOST_VDDK_LIB_DIR, StringUtils.defaultString(libvirtComputingResource.getVddkLibDir())); hostDetails.put(Host.HOST_VDDK_VERSION, StringUtils.defaultString(libvirtComputingResource.getVddkVersion())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopy())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopyInPlaceFinalization())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopyRbd())); + hostDetails.put(Host.HOST_QEMU_IMG_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuImgVersion())); + hostDetails.put(Host.HOST_QEMU_NBD_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuNbdVersion())); + hostDetails.put(Host.HOST_QEMU_IO_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuIoVersion())); + hostDetails.put(Host.HOST_VIRTV2V_INPLACE_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVirtV2vInPlace())); + hostDetails.put(Host.HOST_QEMU_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsQemuRbd())); + hostDetails.put(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkRbdDirectImport())); if (libvirtComputingResource.hostSupportsOvfExport()) { hostDetails.put(Host.HOST_OVFTOOL_VERSION, libvirtComputingResource.getHostOvfToolVersion()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java new file mode 100644 index 000000000000..b9bd788fe29f --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java @@ -0,0 +1,244 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCleanupCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; + +@ResourceWrapper(handles = VmwareCbtCleanupCommand.class) +public class LibvirtVmwareCbtCleanupCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(VmwareCbtCleanupCommand cmd, LibvirtComputingResource serverResource) { + if (!cmd.getRemovePartialTargetDisks()) { + String msg = String.format("VMware CBT cleanup for migration %s skipped target disk cleanup by command policy.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + try { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + int removedImages = deleteRbdTargetImages(cmd, serverResource.getStoragePoolMgr()); + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated RBD target image(s).", + cmd.getMigrationUuid(), removedImages); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + int removedVolumes = deleteBlockDeviceTargetVolumes(cmd, serverResource.getStoragePoolMgr()); + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated block device target volume(s).", + cmd.getMigrationUuid(), removedVolumes); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + Set migrationDirectories = getMigrationDirectories(cmd); + int removedDirectories = 0; + for (Path migrationDirectory : migrationDirectories) { + if (deleteMigrationDirectory(migrationDirectory)) { + removedDirectories++; + } + } + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated target directorie(s).", + cmd.getMigrationUuid(), removedDirectories); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } catch (Exception e) { + String msg = String.format("Unable to clean up VMware CBT migration %s on host %s: %s", + cmd.getMigrationUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + } + + private int deleteRbdTargetImages(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = getTargetStoragePool(cmd, storagePoolMgr); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD cleanup", + cmd.getMigrationUuid())); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return 0; + } + + int removedImages = 0; + List failedImages = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + String imageName = getRbdCleanupImageName(cmd.getMigrationUuid(), disk.getTargetPath()); + if (StringUtils.isBlank(imageName)) { + continue; + } + try { + if (targetPool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW)) { + removedImages++; + } else { + failedImages.add(imageName); + } + } catch (RuntimeException e) { + logger.warn("Unable to delete VMware CBT RBD image {} for migration {}: {}", + imageName, cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + failedImages.add(imageName); + } + } + if (CollectionUtils.isNotEmpty(failedImages)) { + throw new IllegalStateException(String.format("Unable to delete VMware CBT RBD target image(s) %s from storage pool %s", + StringUtils.join(failedImages, ", "), targetPool.getUuid())); + } + return removedImages; + } + + private int deleteBlockDeviceTargetVolumes(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = getTargetStoragePool(cmd, storagePoolMgr); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device cleanup", + cmd.getMigrationUuid())); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return 0; + } + + int removedVolumes = 0; + List failedVolumes = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + String volumeName = getBlockDeviceCleanupVolumeName(cmd.getMigrationUuid(), disk.getTargetPath()); + if (StringUtils.isBlank(volumeName)) { + continue; + } + try { + if (targetPool.deletePhysicalDisk(volumeName, Storage.ImageFormat.RAW)) { + removedVolumes++; + } else { + failedVolumes.add(volumeName); + } + } catch (RuntimeException e) { + logger.warn("Unable to delete VMware CBT block device volume {} for migration {}: {}", + volumeName, cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + failedVolumes.add(volumeName); + } + } + if (CollectionUtils.isNotEmpty(failedVolumes)) { + throw new IllegalStateException(String.format("Unable to delete VMware CBT block device target volume(s) %s from storage pool %s", + StringUtils.join(failedVolumes, ", "), targetPool.getUuid())); + } + return removedVolumes; + } + + /** + * Block device target names carry a short migration marker ("cbt-" plus the first + * eight non-dash characters of the migration UUID) instead of the full RBD-style + * marker, because LINSTOR resource names are limited to 48 characters. + */ + private String getBlockDeviceCleanupVolumeName(String migrationUuid, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + String marker = String.format("cbt-%s-", StringUtils.defaultString(migrationUuid).replace("-", "").substring(0, 8)); + if (!StringUtils.startsWith(volumeName, marker)) { + logger.warn("Skipping VMware CBT block device cleanup target {} because it does not start with marker {}", targetPath, marker); + return null; + } + return volumeName; + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + return storagePoolMgr.getStoragePool(poolType, poolUuid); + } + return null; + } + + private String getRbdCleanupImageName(String migrationUuid, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + String marker = String.format("cloudstack-cbt-%s-", migrationUuid); + if (!StringUtils.contains(imageName, marker)) { + logger.warn("Skipping VMware CBT RBD cleanup target {} because it does not contain marker {}", targetPath, marker); + return null; + } + return imageName; + } + + private Set getMigrationDirectories(VmwareCbtCleanupCommand cmd) { + Set migrationDirectories = new HashSet<>(); + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return migrationDirectories; + } + + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + Path migrationDirectory = getMigrationDirectory(cmd.getMigrationUuid(), disk.getTargetPath()); + if (migrationDirectory != null) { + migrationDirectories.add(migrationDirectory); + } + } + return migrationDirectories; + } + + private Path getMigrationDirectory(String migrationUuid, String targetPath) { + if (StringUtils.isAnyBlank(migrationUuid, targetPath)) { + return null; + } + + Path normalizedTargetPath = Path.of(targetPath).normalize(); + String normalized = normalizedTargetPath.toString().replace('\\', '/'); + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalized.indexOf(marker); + if (markerIndex < 0) { + logger.warn("Skipping VMware CBT cleanup target {} because it is not under {}", targetPath, marker); + return null; + } + String rootPath = normalized.substring(0, markerIndex + marker.length() - 1); + return Path.of(rootPath).normalize(); + } + + private boolean deleteMigrationDirectory(Path migrationDirectory) throws Exception { + if (!Files.isDirectory(migrationDirectory)) { + return false; + } + try (Stream stream = Files.walk(migrationDirectory)) { + for (Path path : stream.sorted(Comparator.reverseOrder()).collect(Collectors.toList())) { + Files.deleteIfExists(path); + } + } + return true; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java new file mode 100644 index 000000000000..6d097fe5b0a3 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java @@ -0,0 +1,743 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtCutoverCommand.class) +public class LibvirtVmwareCbtCutoverCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(VmwareCbtCutoverCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot cut over VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + if (!cmd.getRunVirtV2vFinalization()) { + String msg = String.format("VMware CBT cutover finalization for migration %s was skipped by command policy.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, 0, true, getDiskResults(cmd.getDisks(), 0, msg)); + } + + long startTime = System.currentTimeMillis(); + Path sourceXmlPath = null; + Path fallbackOutputDir = null; + Path fallbackStagingDir = null; + Path rbdNbdBridgeScriptPath = null; + boolean keepFallbackOutputDir = false; + try { + validateCutoverCommand(cmd); + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } + + VirtV2vFinalizationMode finalizationMode = getVirtV2vFinalizationMode(serverResource); + boolean inPlaceFinalization = finalizationMode.isInPlace(); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW && !inPlaceFinalization) { + throw new IllegalArgumentException("RBD target finalization requires virt-v2v in-place support on the selected KVM host; non-in-place fallback finalization cannot write directly back to RBD targets."); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE && !inPlaceFinalization) { + throw new IllegalArgumentException("Block device target finalization requires virt-v2v in-place support on the selected KVM host; non-in-place fallback finalization cannot write directly back to block device targets."); + } + List rbdNbdBridges = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW ? + createRbdNbdBridges(cmd, targetPool) : List.of(); + sourceXmlPath = writeSourceXml(cmd, rbdNbdBridges, targetPool); + + String command; + String logSuffix; + if (inPlaceFinalization) { + command = buildVirtV2vInPlaceCommand(sourceXmlPath, serverResource, finalizationMode); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + rbdNbdBridgeScriptPath = writeRbdNbdBridgeScript(cmd, rbdNbdBridges, command); + command = String.format("bash %s", shellQuote(rbdNbdBridgeScriptPath.toString())); + } + logSuffix = String.format("%s finalization", finalizationMode.getDisplayName()); + } else { + if (!cmd.getAllowNonInPlaceFinalization()) { + throw new IllegalArgumentException("Selected KVM host cannot finalize VMware CBT migration in-place. Enable virt-v2v in-place support or explicitly allow non-in-place fallback finalization."); + } + validateFallbackCapacity(cmd); + fallbackOutputDir = getFallbackOutputDir(cmd); + fallbackStagingDir = getFallbackStagingDir(cmd); + command = buildVirtV2vFallbackCommand(sourceXmlPath, fallbackOutputDir, fallbackStagingDir, serverResource); + logSuffix = String.format("%s finalization", finalizationMode.getDisplayName()); + } + + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT %s", cmd.getMigrationUuid(), logSuffix)); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String msg = String.format("%s failed for VMware CBT migration %s with exit code %s.", + finalizationMode.getDisplayName(), cmd.getMigrationUuid(), commandResult.getExitValue()); + msg = commandResult.appendLastCommandOutput(msg); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, durationSeconds, false, null); + } + + List diskResults = inPlaceFinalization ? + getDiskResults(cmd.getDisks(), durationSeconds, + String.format("Final %s conversion completed", finalizationMode.getDisplayName())) : + getFallbackDiskResults(cmd, fallbackOutputDir, durationSeconds); + diskResults = relocateFinalizedDiskResultsToStorageRoot(cmd.getMigrationUuid(), diskResults); + if (!inPlaceFinalization) { + deleteFallbackSourceDisks(cmd); + keepFallbackOutputDir = containsDiskResultUnderDirectory(diskResults, fallbackOutputDir); + } + String msg = String.format("Final %s conversion completed for VMware CBT migration %s.", + finalizationMode.getDisplayName(), cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, durationSeconds, true, diskResults); + } catch (IllegalArgumentException e) { + String msg = String.format("Cannot cut over VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L), false, null); + } catch (Exception e) { + String msg = String.format("Cannot cut over VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L), false, null); + } finally { + deleteTempFile(sourceXmlPath); + deleteTempFile(rbdNbdBridgeScriptPath); + deleteTempTree(fallbackStagingDir); + if (!keepFallbackOutputDir) { + deleteTempTree(fallbackOutputDir); + } + } + } + + protected VirtV2vFinalizationMode getVirtV2vFinalizationMode(LibvirtComputingResource serverResource) { + if (serverResource.hostSupportsVirtV2vInPlaceBinary()) { + return VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_BINARY; + } + if (serverResource.hostSupportsVirtV2vInPlaceOption()) { + return VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION; + } + return VirtV2vFinalizationMode.VIRT_V2V_FALLBACK; + } + + private void validateCutoverCommand(VmwareCbtCutoverCommand cmd) { + if (StringUtils.isBlank(cmd.getMigrationUuid())) { + throw new IllegalArgumentException("migration UUID is missing"); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + throw new IllegalArgumentException("no target disks were provided for final conversion"); + } + + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + validateDisk(cmd, disk); + } + } + + private void validateDisk(VmwareCbtCutoverCommand cmd, VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("target disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("target disk ID is missing"); + } + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new IllegalArgumentException(String.format("target path is missing for disk %s", disk.getDiskId())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + String targetFormat = StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw").toLowerCase(Locale.ROOT); + if (!StringUtils.equals(targetFormat, "raw")) { + throw new IllegalArgumentException(String.format("target disk %s uses unsupported finalization format %s; only raw targets are supported for %s finalization", + disk.getDiskId(), targetFormat, cmd.getTargetStorageType())); + } + return; + } + if (!Files.isRegularFile(Path.of(disk.getTargetPath()))) { + throw new IllegalArgumentException(String.format("target disk %s does not exist at %s", + disk.getDiskId(), disk.getTargetPath())); + } + String targetFormat = StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2").toLowerCase(Locale.ROOT); + if (!StringUtils.equals(targetFormat, "qcow2")) { + throw new IllegalArgumentException(String.format("target disk %s uses unsupported finalization format %s; only qcow2 file targets are currently supported", + disk.getDiskId(), targetFormat)); + } + } + + private Path writeSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, KVMStoragePool targetPool) throws Exception { + Path sourceXmlPath = Files.createTempFile(String.format("vmware-cbt-%s-v2v-in-place-source-", + sanitizeFileName(cmd.getMigrationUuid())), ".xml"); + Files.writeString(sourceXmlPath, buildSourceXml(cmd, rbdNbdBridges, targetPool)); + return sourceXmlPath; + } + + private String buildSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, KVMStoragePool targetPool) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(escapeXml(StringUtils.defaultIfBlank(cmd.getMigrationUuid(), "vmware-cbt-migration"))).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" \n"); + xml.append(" hvm\n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + for (int index = 0; index < cmd.getDisks().size(); index++) { + VmwareCbtDiskTO disk = cmd.getDisks().get(index); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + appendRbdNbdDiskXml(xml, disk, rbdNbdBridges.get(index), index); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + appendBlockDeviceDiskXml(xml, disk, targetPool, index); + } else { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private void appendRbdNbdDiskXml(StringBuilder xml, VmwareCbtDiskTO disk, RbdNbdBridge rbdNbdBridge, int index) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + logger.info("Prepared temporary localhost NBD bridge on port {} for VMware CBT RBD target disk {}", + rbdNbdBridge.port, disk.getDiskId()); + } + + private void appendBlockDeviceDiskXml(StringBuilder xml, VmwareCbtDiskTO disk, KVMStoragePool targetPool, int index) { + String devicePath = getBlockDevicePath(targetPool, disk); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + logger.info("Prepared block device {} for VMware CBT target disk {} finalization", devicePath, disk.getDiskId()); + } + + private String getBlockDevicePath(KVMStoragePool targetPool, VmwareCbtDiskTO disk) { + String normalizedTargetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? + StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk targetDisk = targetPool.getPhysicalDisk(volumeName); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s of disk %s", + volumeName, disk.getDiskId())); + } + return devicePath; + } + + private void validateBlockDeviceTargetPool(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target finalization", + cmd.getMigrationUuid())); + } + } + + private List createRbdNbdBridges(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) throws Exception { + List bridges = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + int port = allocateLocalhostPort(); + Path pidFile = Files.createTempFile(String.format("vmware-cbt-%s-qemu-nbd-", + sanitizeFileName(cmd.getMigrationUuid())), ".pid"); + Files.deleteIfExists(pidFile); + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, disk.getTargetPath())); + bridges.add(new RbdNbdBridge(port, pidFile, qemuRbdPath)); + } + return bridges; + } + + private int allocateLocalhostPort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(false); + return socket.getLocalPort(); + } + } + + private Path writeRbdNbdBridgeScript(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, + String virtV2vCommand) throws Exception { + Path scriptPath = Files.createTempFile(String.format("vmware-cbt-%s-rbd-finalize-", + sanitizeFileName(cmd.getMigrationUuid())), ".sh"); + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("cleanup() {\n"); + script.append(" set +e\n"); + script.append(" for pid_file in"); + for (RbdNbdBridge bridge : rbdNbdBridges) { + script.append(" ").append(shellQuote(bridge.pidFile.toString())); + } + script.append("; do\n"); + script.append(" if [[ -s \"$pid_file\" ]]; then\n"); + script.append(" pid=$(cat \"$pid_file\")\n"); + script.append(" kill \"$pid\" >/dev/null 2>&1 || true\n"); + script.append(" for attempt in {1..20}; do\n"); + script.append(" kill -0 \"$pid\" >/dev/null 2>&1 || break\n"); + script.append(" sleep 0.1\n"); + script.append(" done\n"); + script.append(" fi\n"); + script.append(" rm -f \"$pid_file\"\n"); + script.append(" done\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + for (RbdNbdBridge bridge : rbdNbdBridges) { + script.append("qemu-nbd --fork --persistent --shared=1 --format=raw --bind=127.0.0.1 --port=") + .append(bridge.port) + .append(" --pid-file=").append(shellQuote(bridge.pidFile.toString())) + .append(" ").append(shellQuote(bridge.qemuRbdPath)).append("\n"); + } + script.append(virtV2vCommand).append("\n"); + Files.writeString(scriptPath, script.toString()); + setPosixFilePermissionsIfSupported(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + return scriptPath; + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX file permissions are not supported for {}", path); + } + } + + private static class RbdNbdBridge { + private final int port; + private final Path pidFile; + private final String qemuRbdPath; + + private RbdNbdBridge(int port, Path pidFile, String qemuRbdPath) { + this.port = port; + this.pidFile = pidFile; + this.qemuRbdPath = qemuRbdPath; + } + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtCutoverCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target finalization", + cmd.getMigrationUuid())); + } + } + + private String getRbdImagePath(KVMStoragePool targetPool, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private String getDiskDeviceName(int index) { + StringBuilder suffix = new StringBuilder(); + int value = index; + do { + suffix.insert(0, (char)('a' + (value % 26))); + value = (value / 26) - 1; + } while (value >= 0); + return "sd" + suffix; + } + + private String buildVirtV2vInPlaceCommand(Path sourceXmlPath, LibvirtComputingResource serverResource, + VirtV2vFinalizationMode finalizationMode) { + StringBuilder command = new StringBuilder(); + appendLibguestfsBackend(command, serverResource); + if (finalizationMode == VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION) { + command.append("virt-v2v --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("--in-place -v"); + } else { + // No -O (write updated output XML): nothing consumes it and the option only + // exists from virt-v2v 2.5 on, so passing it breaks otherwise capable hosts + // such as Ubuntu 24.04 with virt-v2v-in-place 2.4. + command.append("virt-v2v-in-place --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-v"); + } + return command.toString(); + } + + private String buildVirtV2vFallbackCommand(Path sourceXmlPath, Path outputDir, Path stagingDir, + LibvirtComputingResource serverResource) { + StringBuilder command = new StringBuilder(); + appendLibguestfsBackend(command, serverResource); + command.append("export TMPDIR=").append(shellQuote(stagingDir.toString())).append(" && "); + command.append("virt-v2v --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-o local -os ").append(shellQuote(outputDir.toString())).append(" "); + command.append("-of qcow2 -v"); + return command.toString(); + } + + private void appendLibguestfsBackend(StringBuilder command, LibvirtComputingResource serverResource) { + String libguestfsBackend = StringUtils.trimToNull(serverResource.getLibguestfsBackend()); + if (StringUtils.isNotBlank(libguestfsBackend)) { + command.append("export LIBGUESTFS_BACKEND=").append(shellQuote(libguestfsBackend)).append(" && "); + } + } + + private void validateFallbackCapacity(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + long requiredBytes = saturatingMultiply(sumDiskCapacityBytes(cmd), 2L); + long availableBytes = Files.getFileStore(targetBasePath).getUsableSpace(); + if (availableBytes < requiredBytes) { + throw new IllegalArgumentException(String.format("Non-in-place fallback finalization requires additional free space on target primary storage. Required: %s bytes, available: %s bytes.", + requiredBytes, availableBytes)); + } + } + + private long sumDiskCapacityBytes(VmwareCbtCutoverCommand cmd) throws Exception { + long total = 0L; + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + long capacity = disk.getCapacityBytes(); + if (capacity <= 0) { + capacity = Files.size(Path.of(disk.getTargetPath())); + } + total = saturatingAdd(total, capacity); + } + return total; + } + + private long saturatingAdd(long left, long right) { + if (Long.MAX_VALUE - left < right) { + return Long.MAX_VALUE; + } + return left + right; + } + + private long saturatingMultiply(long value, long multiplier) { + if (value > 0 && multiplier > Long.MAX_VALUE / value) { + return Long.MAX_VALUE; + } + return value * multiplier; + } + + private Path getFallbackOutputDir(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + Path outputDir = Files.createTempDirectory(targetBasePath, "virt-v2v-output-").normalize(); + if (!outputDir.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved fallback output path %s is outside %s", + outputDir, targetBasePath)); + } + return outputDir; + } + + private Path getFallbackStagingDir(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + Path stagingDir = Files.createTempDirectory(targetBasePath, "virt-v2v-tmp-").normalize(); + if (!stagingDir.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved fallback staging path %s is outside %s", + stagingDir, targetBasePath)); + } + return stagingDir; + } + + private Path getTargetBasePath(VmwareCbtCutoverCommand cmd) { + Path firstDiskPath = Path.of(cmd.getDisks().get(0).getTargetPath()).normalize(); + Path targetBasePath = firstDiskPath.getParent(); + if (targetBasePath == null) { + throw new IllegalArgumentException("unable to determine VMware CBT target disk directory"); + } + return targetBasePath; + } + + private List getFallbackDiskResults(VmwareCbtCutoverCommand cmd, Path fallbackOutputDir, + long durationSeconds) throws Exception { + List outputFiles; + try (Stream stream = Files.list(fallbackOutputDir)) { + outputFiles = stream + .filter(Files::isRegularFile) + .filter(path -> !StringUtils.endsWithIgnoreCase(path.getFileName().toString(), ".xml")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .collect(Collectors.toList()); + } + if (outputFiles.size() < cmd.getDisks().size()) { + throw new IllegalArgumentException(String.format("virt-v2v fallback produced %s disk file(s), expected at least %s", + outputFiles.size(), cmd.getDisks().size())); + } + List results = new ArrayList<>(); + for (int index = 0; index < cmd.getDisks().size(); index++) { + VmwareCbtDiskTO disk = cmd.getDisks().get(index); + results.add(new VmwareCbtDiskSyncResultTO(disk.getDiskId(), outputFiles.get(index).toString(), + disk.getChangeId(), disk.getSnapshotMor(), 0, durationSeconds, true, + "Final virt-v2v fallback conversion completed")); + } + return results; + } + + protected List relocateFinalizedDiskResultsToStorageRoot(String migrationUuid, + List diskResults) throws Exception { + if (CollectionUtils.isEmpty(diskResults)) { + return diskResults; + } + + List relocatedResults = new ArrayList<>(); + Set usedDestinations = new HashSet<>(); + List completedMoves = new ArrayList<>(); + try { + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + if (diskResult == null || !diskResult.getResult() || StringUtils.isBlank(diskResult.getTargetPath())) { + relocatedResults.add(diskResult); + continue; + } + + Path sourcePath = Path.of(diskResult.getTargetPath()).normalize(); + Path storageRoot = getStorageRootForCbtPath(migrationUuid, sourcePath); + if (storageRoot == null) { + relocatedResults.add(diskResult); + continue; + } + + Path destinationPath = getAvailableRootDiskPath(storageRoot, usedDestinations); + logger.info("Relocating finalized VMware CBT disk {} to primary storage root {}", sourcePath, destinationPath); + Files.move(sourcePath, destinationPath); + completedMoves.add(new FinalizedDiskMove(sourcePath, destinationPath)); + relocatedResults.add(new VmwareCbtDiskSyncResultTO(diskResult.getDiskId(), destinationPath.toString(), + diskResult.getChangeId(), diskResult.getSnapshotMor(), diskResult.getChangedBytes(), + diskResult.getDurationSeconds(), diskResult.getResult(), diskResult.getDetails())); + } + } catch (Exception e) { + rollbackFinalizedDiskMoves(completedMoves); + throw e; + } + return relocatedResults; + } + + private Path getStorageRootForCbtPath(String migrationUuid, Path targetPath) { + if (StringUtils.isBlank(migrationUuid) || targetPath == null) { + return null; + } + String normalizedPath = targetPath.normalize().toString().replace('\\', '/'); + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalizedPath.indexOf(marker); + if (markerIndex < 0) { + return null; + } + String storageRoot = StringUtils.trimToNull(normalizedPath.substring(0, markerIndex)); + return storageRoot == null ? null : Path.of(storageRoot).normalize(); + } + + private Path getAvailableRootDiskPath(Path storageRoot, Set usedDestinations) { + while (true) { + Path candidate = storageRoot.resolve(UUID.randomUUID().toString()).normalize(); + if (!Files.exists(candidate) && usedDestinations.add(candidate)) { + return candidate; + } + } + } + + private boolean containsDiskResultUnderDirectory(List diskResults, Path directory) { + if (CollectionUtils.isEmpty(diskResults) || directory == null) { + return false; + } + Path normalizedDirectory = directory.normalize(); + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + if (diskResult != null && StringUtils.isNotBlank(diskResult.getTargetPath()) && + Path.of(diskResult.getTargetPath()).normalize().startsWith(normalizedDirectory)) { + return true; + } + } + return false; + } + + private void rollbackFinalizedDiskMoves(List completedMoves) { + if (CollectionUtils.isEmpty(completedMoves)) { + return; + } + for (int index = completedMoves.size() - 1; index >= 0; index--) { + FinalizedDiskMove move = completedMoves.get(index); + try { + if (Files.exists(move.destinationPath) && !Files.exists(move.sourcePath)) { + Files.move(move.destinationPath, move.sourcePath); + } + } catch (Exception rollbackException) { + logger.warn("Failed to roll back finalized VMware CBT disk relocation from {} to {}", + move.destinationPath, move.sourcePath, rollbackException); + } + } + } + + private static class FinalizedDiskMove { + private final Path sourcePath; + private final Path destinationPath; + + private FinalizedDiskMove(Path sourcePath, Path destinationPath) { + this.sourcePath = sourcePath; + this.destinationPath = destinationPath; + } + } + + private void deleteFallbackSourceDisks(VmwareCbtCutoverCommand cmd) { + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + deleteTempFile(Path.of(disk.getTargetPath())); + } + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + private long getTimeout(VmwareCbtCutoverCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private List getDiskResults(List disks, long durationSeconds, + String details) { + List results = new ArrayList<>(); + if (CollectionUtils.isEmpty(disks)) { + return results; + } + for (VmwareCbtDiskTO disk : disks) { + results.add(new VmwareCbtDiskSyncResultTO(disk.getDiskId(), disk.getTargetPath(), + disk.getChangeId(), disk.getSnapshotMor(), 0, durationSeconds, true, details)); + } + return results; + } + + private void deleteTempFile(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (Exception e) { + logger.debug("Unable to delete temporary VMware CBT cutover file {}: {}", path, e.getMessage()); + } + } + + private void deleteTempTree(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream stream = Files.walk(path)) { + List paths = stream.sorted(Comparator.reverseOrder()).collect(Collectors.toList()); + for (Path entry : paths) { + Files.deleteIfExists(entry); + } + } catch (Exception e) { + logger.debug("Unable to delete temporary VMware CBT cutover directory {}: {}", path, e.getMessage()); + } + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String escapeXml(String value) { + return StringUtils.defaultString(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "migration").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "migration"); + } + + protected enum VirtV2vFinalizationMode { + VIRT_V2V_IN_PLACE_BINARY("virt-v2v-in-place", true), + VIRT_V2V_IN_PLACE_OPTION("virt-v2v --in-place", true), + VIRT_V2V_FALLBACK("virt-v2v fallback", false); + + private final String displayName; + private final boolean inPlace; + + VirtV2vFinalizationMode(String displayName, boolean inPlace) { + this.displayName = displayName; + this.inPlace = inPlace; + } + + public String getDisplayName() { + return displayName; + } + + public boolean isInPlace() { + return inPlace; + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java new file mode 100644 index 000000000000..c14cdd110caa --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java @@ -0,0 +1,444 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtPrepareCommand; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.utils.qemu.QemuImg; + +@ResourceWrapper(handles = VmwareCbtPrepareCommand.class) +public class LibvirtVmwareCbtPrepareCommandWrapper extends CommandWrapper { + + private static final String DEFAULT_CBT_DISK_BASE_PATH = "/var/lib/libvirt/images/cloudstack-cbt"; + private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + + @Override + public Answer execute(VmwareCbtPrepareCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot prepare VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + try { + validatePrepareCommand(cmd); + String vddkLibDir = resolveVddkSetting(cmd.getVddkLibDir(), serverResource.getVddkLibDir()); + if (StringUtils.isBlank(vddkLibDir)) { + String msg = String.format("Cannot prepare VMware CBT migration %s because no VDDK library directory is configured or detected.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String vddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(sourceInstance.getVcenterHost(), getTimeout(cmd), sourceInstance.getInstanceName()); + } + if (StringUtils.isBlank(vddkThumbprint)) { + String msg = String.format("Cannot prepare VMware CBT migration %s because the vCenter SSL thumbprint could not be determined.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + Path targetBasePath = null; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } else { + targetBasePath = getTargetBasePath(cmd, targetPool); + Files.createDirectories(targetBasePath); + } + + String passwordFilePath = writePasswordFile(cmd); + try { + List diskResults = new ArrayList<>(); + long startTime = System.currentTimeMillis(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + diskResults.add(copyDiskFromVmwareSnapshot(cmd, disk, targetPool, targetBasePath, passwordFilePath, + vddkLibDir, vddkThumbprint, serverResource)); + } + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + String msg = String.format("Initial VDDK full sync for VMware CBT migration %s completed for %s disk(s).", + cmd.getMigrationUuid(), diskResults.size()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), 0, + 0, 0, durationSeconds, false, diskResults); + } finally { + Files.deleteIfExists(Path.of(passwordFilePath)); + } + } catch (Exception e) { + String msg = String.format("Cannot prepare VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + } + + private void validatePrepareCommand(VmwareCbtPrepareCommand cmd) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + if (sourceInstance == null) { + throw new IllegalArgumentException("source VMware instance information is missing"); + } + if (StringUtils.isAnyBlank(sourceInstance.getVcenterHost(), sourceInstance.getVcenterUsername(), + sourceInstance.getVcenterPassword(), sourceInstance.getInstanceName())) { + throw new IllegalArgumentException("source vCenter host, username, password and VM name are required"); + } + if (StringUtils.isBlank(sourceInstance.getVmwareMoref())) { + throw new IllegalArgumentException("source VMware VM managed object reference is missing"); + } + if (StringUtils.isBlank(cmd.getBaselineSnapshotMor())) { + throw new IllegalArgumentException("baseline VMware snapshot managed object reference is missing"); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + throw new IllegalArgumentException("no source disks were provided for initial full sync"); + } + } + + private long getTimeout(VmwareCbtPrepareCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtPrepareCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target writing", + cmd.getMigrationUuid())); + } + } + + private void validateBlockDeviceTargetPool(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target writing", + cmd.getMigrationUuid())); + } + } + + private Path getTargetBasePath(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool != null) { + return Path.of(targetPool.getLocalPath(), "cloudstack-cbt", cmd.getMigrationUuid()).normalize(); + } + return Path.of(DEFAULT_CBT_DISK_BASE_PATH, cmd.getMigrationUuid()).normalize(); + } + + private String writePasswordFile(VmwareCbtPrepareCommand cmd) throws Exception { + Path passwordFile = Files.createTempFile(String.format("vmware-cbt-%s-", sanitizeFileName(cmd.getMigrationUuid())), + ".pass"); + Files.writeString(passwordFile, cmd.getSourceInstance().getVcenterPassword()); + setPosixFilePermissionsIfSupported(passwordFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + return passwordFile.toString(); + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX permissions are not supported for temporary VMware CBT password file {}", path); + } + } + + private VmwareCbtDiskSyncResultTO copyDiskFromVmwareSnapshot(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + KVMStoragePool targetPool, Path targetBasePath, + String passwordFilePath, + String vddkLibDir, String vddkThumbprint, + LibvirtComputingResource serverResource) throws Exception { + validateDisk(disk); + long startTime = System.currentTimeMillis(); + String targetFormat = getTargetFormat(cmd, disk); + String targetPath; + String qemuTargetPath; + boolean preCreatedTarget = false; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + targetPath = getRbdTargetImageName(cmd, disk); + deleteRbdTargetIfExists(targetPool, targetPath); + qemuTargetPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, targetPath)); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + targetPath = getBlockDeviceTargetName(disk); + qemuTargetPath = createBlockDeviceTarget(targetPool, targetPath, disk); + preCreatedTarget = true; + } else { + Path fileTargetPath = getTargetPath(disk, targetBasePath, targetFormat); + if (!fileTargetPath.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved target path %s is outside %s", fileTargetPath, targetBasePath)); + } + Files.deleteIfExists(fileTargetPath); + targetPath = fileTargetPath.toString(); + qemuTargetPath = targetPath; + } + + // For a local raw block-device target (preCreatedTarget), use nbdcopy for the full copy + // when it is available - it is typically faster than a single-connection qemu-img convert. + // The RBD URI and qcow2 file targets keep qemu-img convert. + boolean useNbdcopy = preCreatedTarget && serverResource.hostSupportsNbdcopy(); + String command = buildNbdkitFullCopyCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()), + qemuTargetPath, targetFormat, preCreatedTarget, useNbdcopy); + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT initial full sync disk %s", cmd.getMigrationUuid(), disk.getDiskId())); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String details = String.format("qemu-img conversion from VMware VDDK snapshot failed for source disk %s with exit code %s", + disk.getDiskId(), commandResult.getExitValue()); + throw new IllegalStateException(commandResult.appendLastCommandOutput(details)); + } + + return new VmwareCbtDiskSyncResultTO(disk.getDiskId(), targetPath, disk.getChangeId(), + cmd.getBaselineSnapshotMor(), disk.getCapacityBytes(), durationSeconds, true, + "Initial VDDK full sync completed"); + } + + private String getTargetFormat(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + private String getBlockDeviceTargetName(VmwareCbtDiskTO disk) { + String targetPath = StringUtils.trimToNull(disk.getTargetPath()); + if (targetPath == null) { + throw new IllegalArgumentException(String.format("no block device target name was assigned for source disk %s", disk.getDiskId())); + } + String normalizedTargetPath = targetPath.replace('\\', '/'); + return StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + } + + private String createBlockDeviceTarget(KVMStoragePool targetPool, String targetName, VmwareCbtDiskTO disk) { + if (disk.getCapacityBytes() <= 0) { + throw new IllegalArgumentException(String.format("source disk %s does not have a valid capacity, " + + "which is required to pre-create the block device target volume", disk.getDiskId())); + } + deleteRbdTargetIfExists(targetPool, targetName); + KVMPhysicalDisk targetDisk = targetPool.createPhysicalDisk(targetName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, disk.getCapacityBytes(), null); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s", targetName)); + } + return devicePath; + } + + private String getRbdTargetImageName(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk) { + String targetPath = StringUtils.trimToNull(disk.getTargetPath()); + if (targetPath != null) { + String normalizedTargetPath = targetPath.replace('\\', '/'); + return StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + } + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getDiskId()), "."); + return String.format("cloudstack-cbt-%s-%s-%s", sanitizeFileName(cmd.getMigrationUuid()), + sanitizeFileName(disk.getDiskId()), sanitizeFileName(sourceName)); + } + + private String getRbdImagePath(KVMStoragePool targetPool, String imageName) { + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private void deleteRbdTargetIfExists(KVMStoragePool targetPool, String imageName) { + try { + targetPool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (RuntimeException e) { + logger.debug("No existing RBD image {} was removed before VMware CBT initial sync, or removal was not required: {}", + imageName, e.getMessage()); + } + } + + private Path getTargetPath(VmwareCbtDiskTO disk, Path targetBasePath, String targetFormat) { + String targetPathValue = StringUtils.trimToNull(disk.getTargetPath()); + Path targetPath = targetPathValue == null ? + targetBasePath.resolve(getTargetDiskFileName(disk, targetFormat)) : + Path.of(targetPathValue); + if (!targetPath.isAbsolute()) { + targetPath = targetBasePath.resolve(targetPath); + } + return targetPath.normalize(); + } + + private void validateDisk(VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("source disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("source disk ID is missing"); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new IllegalArgumentException(String.format("source disk path is missing for disk %s", disk.getDiskId())); + } + } + + private String buildNbdkitFullCopyCommand(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String targetPath, String targetFormat, + boolean preCreatedTarget, boolean useNbdcopy) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String sourceVmMoref = getMorefValue(sourceInstance.getVmwareMoref()); + String snapshotMoref = getMorefValue(cmd.getBaselineSnapshotMor()); + StringBuilder nbdkit = new StringBuilder("nbdkit -r -U - vddk "); + appendPluginParameter(nbdkit, "file", disk.getSourceDiskPath()); + appendPluginParameter(nbdkit, "server", sourceInstance.getVcenterHost()); + appendPluginParameter(nbdkit, "user", sourceInstance.getVcenterUsername()); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + nbdkit.append("vm=moref=").append(shellQuote(sourceVmMoref)).append(" "); + appendPluginParameter(nbdkit, "snapshot", snapshotMoref); + appendPluginParameter(nbdkit, "libdir", vddkLibDir); + appendPluginParameter(nbdkit, "thumbprint", vddkThumbprint); + if (StringUtils.isNotBlank(vddkTransports)) { + appendPluginParameter(nbdkit, "transports", vddkTransports); + } + + String runCommand = useNbdcopy + ? String.format("nbdcopy \"$uri\" %s", shellQuote(targetPath)) + : String.format("qemu-img convert %s-f raw -O %s \"$uri\" %s", + preCreatedTarget ? "-n " : "", shellQuote(targetFormat), shellQuote(targetPath)); + return nbdkit.append("--run ").append(shellQuote(runCommand)).toString(); + } + + private void appendPluginParameter(StringBuilder command, String key, String value) { + command.append(key).append("=").append(shellQuote(value)).append(" "); + } + + private String getTargetDiskFileName(VmwareCbtDiskTO disk, String targetFormat) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getDiskId()), "."); + return String.format("%s-%s.%s", sanitizeFileName(disk.getDiskId()), sanitizeFileName(sourceName), + sanitizeFileName(targetFormat)); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private String getMorefValue(String moref) { + String value = StringUtils.trimToNull(moref); + if (value == null) { + return null; + } + return value.contains(":") ? StringUtils.substringAfter(value, ":") : value; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String resolveVddkSetting(String commandValue, String agentValue) { + return StringUtils.defaultIfBlank(StringUtils.trimToNull(commandValue), StringUtils.trimToNull(agentValue)); + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + private String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + if (StringUtils.isBlank(vcenterHost)) { + return null; + } + String endpoint = String.format("%s:443", vcenterHost); + String command = String.format("openssl s_client -connect %s /dev/null | " + + "openssl x509 -fingerprint -sha1 -noout", shellQuote(endpoint)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser); + + String output = parser.getLines(); + if (script.getExitValue() != 0) { + logger.error("({}) Failed to fetch vCenter thumbprint for {}", sourceVmName, vcenterHost); + return null; + } + + return extractSha1Fingerprint(output); + } + + private String extractSha1Fingerprint(String output) { + String parsedOutput = StringUtils.trimToEmpty(output); + if (StringUtils.isBlank(parsedOutput)) { + return null; + } + + for (String line : parsedOutput.split("\\R")) { + String trimmedLine = StringUtils.trimToEmpty(line); + if (StringUtils.isBlank(trimmedLine)) { + continue; + } + + Matcher matcher = SHA1_FINGERPRINT_PATTERN.matcher(trimmedLine); + if (matcher.find()) { + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + if (trimmedLine.matches("(?i)[0-9a-f]{2}(:[0-9a-f]{2})+")) { + return trimmedLine.toUpperCase(Locale.ROOT); + } + } + return null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java new file mode 100644 index 000000000000..23f76b1619fb --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java @@ -0,0 +1,218 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtRbdProbeCommand; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtRbdProbeCommand.class) +public class LibvirtVmwareCbtRbdProbeCommandWrapper extends CommandWrapper { + + static final String PROBE_IMAGE_PREFIX = "cloudstack-cbt-probe-"; + static final String BLOCK_DEVICE_PROBE_PREFIX = "cbt-probe-"; + private static final Pattern PROBE_IMAGE_NAME_PATTERN = Pattern.compile("^" + PROBE_IMAGE_PREFIX + + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private static final Pattern BLOCK_DEVICE_PROBE_NAME_PATTERN = Pattern.compile("^" + BLOCK_DEVICE_PROBE_PREFIX + + "[0-9a-fA-F]{8}$"); + private static final long PROBE_SIZE_BYTES = 4L * 1024L * 1024L; + + @Override + public Answer execute(VmwareCbtRbdProbeCommand cmd, LibvirtComputingResource serverResource) { + String probeImageName = StringUtils.trimToNull(cmd.getProbeImageName()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return executeBlockDeviceProbe(cmd, probeImageName, serverResource); + } + try { + validateCommand(cmd, probeImageName); + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, probeImageName)); + + Exception probeFailure = null; + try { + executeProbeCommand(buildCreateCommand(qemuRbdPath), getTimeout(cmd), "create", targetPool, probeImageName); + executeProbeCommand(buildWriteReadCommand(qemuRbdPath), getTimeout(cmd), "write/read", targetPool, probeImageName); + } catch (Exception e) { + probeFailure = e; + throw e; + } finally { + try { + cleanupProbeImage(targetPool, probeImageName); + } catch (RuntimeException e) { + if (probeFailure == null) { + throw e; + } + logger.warn("Unable to clean up RBD probe image {} after a failed probe: {}", probeImageName, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + } + + String message = String.format("Selected KVM host can create, write, read and delete temporary RBD image %s in storage pool %s.", + probeImageName, cmd.getDestinationStoragePoolUuid()); + logger.info(message); + return new Answer(cmd, true, message); + } catch (Exception e) { + String message = String.format("Cannot verify VMware CBT access to selected RBD primary storage pool %s on host %s: %s", + cmd.getDestinationStoragePoolUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.warn(message, e); + return new Answer(cmd, false, message); + } + } + + private Answer executeBlockDeviceProbe(VmwareCbtRbdProbeCommand cmd, String probeVolumeName, + LibvirtComputingResource serverResource) { + try { + validateBlockDeviceCommand(cmd, probeVolumeName); + KVMStoragePool targetPool = getBlockDeviceTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + + Exception probeFailure = null; + try { + KVMPhysicalDisk probeDisk = targetPool.createPhysicalDisk(probeVolumeName, + org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, PROBE_SIZE_BYTES, null); + String devicePath = probeDisk == null ? null : probeDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for probe volume %s", probeVolumeName)); + } + executeProbeCommand(buildWriteReadCommand(devicePath), getTimeout(cmd), "write/read", targetPool, probeVolumeName); + } catch (Exception e) { + probeFailure = e; + throw e; + } finally { + try { + cleanupProbeImage(targetPool, probeVolumeName); + } catch (RuntimeException e) { + if (probeFailure == null) { + throw e; + } + logger.warn("Unable to clean up block device probe volume {} after a failed probe: {}", probeVolumeName, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + } + + String message = String.format("Selected KVM host can create, write, read and delete temporary block device volume %s in storage pool %s.", + probeVolumeName, cmd.getDestinationStoragePoolUuid()); + logger.info(message); + return new Answer(cmd, true, message); + } catch (Exception e) { + String message = String.format("Cannot verify VMware CBT access to selected block device primary storage pool %s on host %s: %s", + cmd.getDestinationStoragePoolUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.warn(message, e); + return new Answer(cmd, false, message); + } + } + + private void validateBlockDeviceCommand(VmwareCbtRbdProbeCommand cmd, String probeVolumeName) { + if (cmd.getDestinationStoragePoolType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException("VMware CBT block device probe requires a Linstor destination storage pool"); + } + if (StringUtils.isBlank(cmd.getDestinationStoragePoolUuid())) { + throw new IllegalArgumentException("destination storage pool UUID is required"); + } + if (StringUtils.isBlank(probeVolumeName) || !BLOCK_DEVICE_PROBE_NAME_PATTERN.matcher(probeVolumeName).matches()) { + throw new IllegalArgumentException(String.format("probe volume name must match %s<8 hex characters>", BLOCK_DEVICE_PROBE_PREFIX)); + } + } + + private KVMStoragePool getBlockDeviceTargetStoragePool(VmwareCbtRbdProbeCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = storagePoolMgr.getStoragePool(cmd.getDestinationStoragePoolType(), cmd.getDestinationStoragePoolUuid()); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("selected Linstor storage pool %s is not available on this KVM host", + cmd.getDestinationStoragePoolUuid())); + } + return targetPool; + } + + private void validateCommand(VmwareCbtRbdProbeCommand cmd, String probeImageName) { + if (cmd.getDestinationStoragePoolType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException("VMware CBT RBD probe requires an RBD destination storage pool"); + } + if (StringUtils.isBlank(cmd.getDestinationStoragePoolUuid())) { + throw new IllegalArgumentException("destination RBD storage pool UUID is required"); + } + if (StringUtils.isBlank(probeImageName) || !PROBE_IMAGE_NAME_PATTERN.matcher(probeImageName).matches()) { + throw new IllegalArgumentException(String.format("probe image name must match %s", PROBE_IMAGE_PREFIX)); + } + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtRbdProbeCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = storagePoolMgr.getStoragePool(cmd.getDestinationStoragePoolType(), cmd.getDestinationStoragePoolUuid()); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("selected RBD storage pool %s is not available on this KVM host", + cmd.getDestinationStoragePoolUuid())); + } + return targetPool; + } + + private String getRbdImagePath(KVMStoragePool targetPool, String imageName) { + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private String buildCreateCommand(String qemuRbdPath) { + return String.format("qemu-img create -f raw %s %s", shellQuote(qemuRbdPath), PROBE_SIZE_BYTES); + } + + private String buildWriteReadCommand(String qemuRbdPath) { + return String.format("qemu-io -f raw -c %s -c %s %s", + shellQuote("write -P 0x5a 0 4k"), shellQuote("read -P 0x5a 0 4k"), shellQuote(qemuRbdPath)); + } + + protected void executeProbeCommand(String command, long timeout, String operation, KVMStoragePool targetPool, + String probeImageName) { + int exitValue = runBashCommand(command, timeout); + if (exitValue != 0) { + throw new IllegalStateException(String.format("qemu RBD probe %s failed with exit code %s. Verify CloudStack RBD primary-storage configuration, Ceph monitor connectivity, qemu RBD block-driver support, librados/librbd client libraries, Java RADOS/RBD bindings and Ceph authentication for pool %s.", + operation, exitValue, targetPool.getUuid())); + } + } + + protected int runBashCommand(String command, long timeout) { + int boundedTimeout = (int)Math.min(Integer.MAX_VALUE, Math.max(0L, timeout)); + return Script.runSimpleBashScriptForExitValue(command, boundedTimeout, true); + } + + protected void cleanupProbeImage(KVMStoragePool targetPool, String probeImageName) { + try { + targetPool.deletePhysicalDisk(probeImageName, Storage.ImageFormat.RAW); + } catch (RuntimeException e) { + throw new IllegalStateException(String.format("RBD probe cleanup failed for temporary image %s. Verify Java RADOS/RBD bindings and Ceph authentication for pool %s. Manual cleanup may be required.", + probeImageName, targetPool.getUuid()), e); + } + } + + private long getTimeout(VmwareCbtRbdProbeCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java new file mode 100644 index 000000000000..ed9a6a992c40 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java @@ -0,0 +1,489 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +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.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtSyncCommand.class) +public class LibvirtVmwareCbtSyncCommandWrapper extends CommandWrapper { + + private static final long DEFAULT_MAX_COPY_CHUNK_BYTES = 256L * 1024L * 1024L; + private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + + @Override + public Answer execute(VmwareCbtSyncCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot synchronize VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, 0, false, null); + } + + long startTime = System.currentTimeMillis(); + List changedBlocks = cmd.getChangedBlocks(); + if (changedBlocks == null || changedBlocks.isEmpty()) { + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + String msg = String.format("VMware CBT cycle %s for migration %s completed with no changed blocks.", + cmd.getCycleNumber(), cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, durationSeconds, true, null); + } + + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(cmd.getDisks(), changedBlocks); + if (!syncPlan.isValid()) { + String msg = String.format("Cannot synchronize VMware CBT cycle %s for migration %s: %s", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getValidationError()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, 0, false, null); + } + + try { + validateSyncCommand(cmd); + String vddkLibDir = resolveVddkSetting(cmd.getVddkLibDir(), serverResource.getVddkLibDir()); + if (StringUtils.isBlank(vddkLibDir)) { + String msg = String.format("Cannot synchronize VMware CBT migration %s because no VDDK library directory is configured or detected.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String vddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(sourceInstance.getVcenterHost(), getTimeout(cmd), + sourceInstance.getInstanceName()); + } + if (StringUtils.isBlank(vddkThumbprint)) { + String msg = String.format("Cannot synchronize VMware CBT migration %s because the vCenter SSL thumbprint could not be determined.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } + + String passwordFilePath = writePasswordFile(cmd); + try { + List diskResults = new ArrayList<>(); + for (VmwareCbtSyncPlan.DiskPlan diskPlan : syncPlan.getDiskPlans()) { + diskResults.add(syncDiskChangedBlocks(cmd, diskPlan, targetPool, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()))); + } + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + long dirtyRate = getDirtyRateBytesPerSecond(syncPlan.getChangedBytes(), durationSeconds); + String msg = String.format("VMware CBT cycle %s for migration %s copied %s changed block range(s), " + + "coalesced into %s copy range(s) across %s disk(s), totaling %s bytes.", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getChangedRangeCount(), + syncPlan.getCopyRangeCount(), syncPlan.getDiskPlans().size(), syncPlan.getChangedBytes()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), dirtyRate, durationSeconds, false, diskResults); + } finally { + Files.deleteIfExists(Path.of(passwordFilePath)); + } + } catch (Exception e) { + String msg = String.format("Cannot synchronize VMware CBT cycle %s for migration %s: %s", + cmd.getCycleNumber(), cmd.getMigrationUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + } + + private void validateSyncCommand(VmwareCbtSyncCommand cmd) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + if (sourceInstance == null) { + throw new IllegalArgumentException("source VMware instance information is missing"); + } + if (StringUtils.isAnyBlank(sourceInstance.getVcenterHost(), sourceInstance.getVcenterUsername(), + sourceInstance.getVcenterPassword(), sourceInstance.getInstanceName())) { + throw new IllegalArgumentException("source vCenter host, username, password and VM name are required"); + } + if (StringUtils.isBlank(sourceInstance.getVmwareMoref())) { + throw new IllegalArgumentException("source VMware VM managed object reference is missing"); + } + if (StringUtils.isBlank(cmd.getSnapshotMor())) { + throw new IllegalArgumentException("VMware snapshot managed object reference is missing"); + } + } + + private VmwareCbtDiskSyncResultTO syncDiskChangedBlocks(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool, String passwordFilePath, String vddkLibDir, + String vddkThumbprint, String vddkTransports) throws Exception { + VmwareCbtDiskTO disk = diskPlan.getDisk(); + validateDisk(cmd, disk); + long startTime = System.currentTimeMillis(); + String scriptPath = writeDiskSyncScript(cmd, diskPlan, targetPool); + try { + String command = buildNbdkitDeltaSyncCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + vddkTransports, scriptPath); + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT delta sync disk %s", cmd.getMigrationUuid(), disk.getDiskId())); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String details = String.format("changed-block copy from VMware VDDK snapshot failed for source disk %s with exit code %s", + disk.getDiskId(), commandResult.getExitValue()); + throw new IllegalStateException(commandResult.appendLastCommandOutput(details)); + } + return new VmwareCbtDiskSyncResultTO(disk.getDiskId(), disk.getTargetPath(), disk.getChangeId(), + cmd.getSnapshotMor(), diskPlan.getChangedBytes(), durationSeconds, true, + String.format("Copied %s changed bytes in %s range(s)", diskPlan.getChangedBytes(), + diskPlan.getChangedBlocks().size())); + } finally { + Files.deleteIfExists(Path.of(scriptPath)); + } + } + + private void validateDisk(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("source disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("source disk ID is missing"); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new IllegalArgumentException(String.format("source disk path is missing for disk %s", disk.getDiskId())); + } + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new IllegalArgumentException(String.format("target disk path is missing for disk %s", disk.getDiskId())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw"), "raw")) { + throw new IllegalArgumentException(String.format("only raw target disks are supported for VMware CBT delta sync to %s; disk %s uses %s", + cmd.getTargetStorageType(), disk.getDiskId(), disk.getTargetFormat())); + } + return; + } + if (!Files.isRegularFile(Path.of(disk.getTargetPath()))) { + throw new IllegalArgumentException(String.format("target disk path %s for disk %s does not exist or is not a regular file", + disk.getTargetPath(), disk.getDiskId())); + } + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"), "qcow2")) { + throw new IllegalArgumentException(String.format("only qcow2 target disks are supported for VMware CBT delta sync; disk %s uses %s", + disk.getDiskId(), disk.getTargetFormat())); + } + } + + private String writePasswordFile(VmwareCbtSyncCommand cmd) throws Exception { + Path passwordFile = Files.createTempFile(String.format("vmware-cbt-%s-", sanitizeFileName(cmd.getMigrationUuid())), + ".pass"); + Files.writeString(passwordFile, cmd.getSourceInstance().getVcenterPassword()); + setPosixFilePermissionsIfSupported(passwordFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + return passwordFile.toString(); + } + + protected String writeDiskSyncScript(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool) throws Exception { + VmwareCbtDiskTO disk = diskPlan.getDisk(); + boolean directBlockTarget = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE; + Path scriptPath = Files.createTempFile(String.format("vmware-cbt-%s-%s-", sanitizeFileName(cmd.getMigrationUuid()), + sanitizeFileName(disk.getDiskId())), ".sh"); + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("target_path=").append(shellQuote(getQemuTargetPath(cmd, disk, targetPool))).append("\n"); + script.append("target_format=").append(shellQuote(getTargetFormat(cmd, disk))).append("\n"); + script.append("get_nbd_socket_path() {\n"); + script.append(" local socket_path=\"${uri#*socket=}\"\n"); + script.append(" socket_path=\"${socket_path%%&*}\"\n"); + script.append(" if [[ \"$socket_path\" == \"$uri\" || -z \"$socket_path\" ]]; then\n"); + script.append(" echo \"Cannot parse nbdkit unix socket from uri: $uri\" >&2\n"); + script.append(" exit 1\n"); + script.append(" fi\n"); + script.append(" echo \"$socket_path\"\n"); + script.append("}\n"); + script.append("nbd_socket_path=$(get_nbd_socket_path)\n"); + if (directBlockTarget) { + // Local raw block device (e.g. Linstor/DRBD): copy each changed extent straight from the + // nbdkit source window into the same target window in a single qemu-img convert, avoiding + // the temporary-file round-trip the qemu-io path needs. -S 0 disables zero/sparse skipping + // so blocks the source cleared to zero are actually overwritten in the target (delta + // correctness); -n keeps the pre-created device. + script.append("copy_range() {\n"); + script.append(" local range_start=\"$1\"\n"); + script.append(" local range_length=\"$2\"\n"); + script.append(" local source_opts=\"driver=raw,offset=$range_start,size=$range_length,file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path\"\n"); + script.append(" local target_opts=\"driver=raw,offset=$range_start,size=$range_length,file.driver=host_device,file.filename=$target_path\"\n"); + script.append(" qemu-img convert -n -S 0 --image-opts --target-image-opts \"$source_opts\" \"$target_opts\"\n"); + script.append("}\n"); + } else { + script.append("max_chunk_bytes=").append(DEFAULT_MAX_COPY_CHUNK_BYTES).append("\n"); + script.append("tmp_dir=$(mktemp -d /var/tmp/cloudstack-cbt-").append(sanitizeFileName(cmd.getMigrationUuid())) + .append("-").append(sanitizeFileName(disk.getDiskId())).append("-XXXXXX)\n"); + script.append("cleanup() {\n"); + script.append(" rm -rf \"$tmp_dir\"\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + script.append("copy_range() {\n"); + script.append(" local range_start=\"$1\"\n"); + script.append(" local range_length=\"$2\"\n"); + script.append(" local current_start=\"$range_start\"\n"); + script.append(" local remaining=\"$range_length\"\n"); + script.append(" while (( remaining > 0 )); do\n"); + script.append(" local chunk_length=\"$remaining\"\n"); + script.append(" if (( chunk_length > max_chunk_bytes )); then\n"); + script.append(" chunk_length=\"$max_chunk_bytes\"\n"); + script.append(" fi\n"); + script.append(" local chunk_file=\"$tmp_dir/range-${current_start}-${chunk_length}.raw\"\n"); + script.append(" local source_opts=\"driver=raw,offset=$current_start,size=$chunk_length,file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path\"\n"); + script.append(" qemu-img convert --image-opts -O raw \"$source_opts\" \"$chunk_file\" >/dev/null\n"); + script.append(" qemu-io -f \"$target_format\" -c \"write -s $chunk_file $current_start $chunk_length\" \"$target_path\"\n"); + script.append(" rm -f \"$chunk_file\"\n"); + script.append(" remaining=$((remaining - chunk_length))\n"); + script.append(" current_start=$((current_start + chunk_length))\n"); + script.append(" done\n"); + script.append("}\n"); + } + for (VmwareCbtChangedBlockRangeTO changedBlock : diskPlan.getChangedBlocks()) { + script.append("copy_range ").append(changedBlock.getStartOffset()).append(" ") + .append(changedBlock.getLength()).append("\n"); + } + Files.writeString(scriptPath, script.toString()); + setPosixFilePermissionsIfSupported(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + return scriptPath.toString(); + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtSyncCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtSyncCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target writing", + cmd.getMigrationUuid())); + } + } + + private void validateBlockDeviceTargetPool(VmwareCbtSyncCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target writing", + cmd.getMigrationUuid())); + } + } + + private String getQemuTargetPath(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, KVMStoragePool targetPool) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + return KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, disk.getTargetPath())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return getBlockDevicePath(targetPool, disk); + } + return disk.getTargetPath(); + } + + private String getBlockDevicePath(KVMStoragePool targetPool, VmwareCbtDiskTO disk) { + String normalizedTargetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? + StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + KVMPhysicalDisk targetDisk = targetPool.getPhysicalDisk(volumeName); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s of disk %s", + volumeName, disk.getDiskId())); + } + return devicePath; + } + + private String getTargetFormat(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + private String getRbdImagePath(KVMStoragePool targetPool, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX file permissions are not supported for {}", path); + } + } + + private String buildNbdkitDeltaSyncCommand(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String scriptPath) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String sourceVmMoref = getMorefValue(sourceInstance.getVmwareMoref()); + String snapshotMoref = getMorefValue(cmd.getSnapshotMor()); + StringBuilder nbdkit = new StringBuilder("nbdkit -r -U - vddk "); + appendPluginParameter(nbdkit, "file", disk.getSourceDiskPath()); + appendPluginParameter(nbdkit, "server", sourceInstance.getVcenterHost()); + appendPluginParameter(nbdkit, "user", sourceInstance.getVcenterUsername()); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + nbdkit.append("vm=moref=").append(shellQuote(sourceVmMoref)).append(" "); + appendPluginParameter(nbdkit, "snapshot", snapshotMoref); + appendPluginParameter(nbdkit, "libdir", vddkLibDir); + appendPluginParameter(nbdkit, "thumbprint", vddkThumbprint); + if (StringUtils.isNotBlank(vddkTransports)) { + appendPluginParameter(nbdkit, "transports", vddkTransports); + } + String runCommand = String.format("export uri; bash %s", shellQuote(scriptPath)); + return nbdkit.append("--run ").append(shellQuote(runCommand)).toString(); + } + + private void appendPluginParameter(StringBuilder command, String key, String value) { + command.append(key).append("=").append(shellQuote(value)).append(" "); + } + + private long getDirtyRateBytesPerSecond(long changedBytes, long durationSeconds) { + if (durationSeconds <= 0) { + return changedBytes; + } + return changedBytes / durationSeconds; + } + + private long getTimeout(VmwareCbtSyncCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private String getMorefValue(String moref) { + String value = StringUtils.trimToNull(moref); + if (value == null) { + return null; + } + return value.contains(":") ? StringUtils.substringAfter(value, ":") : value; + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String resolveVddkSetting(String commandValue, String agentValue) { + return StringUtils.defaultIfBlank(StringUtils.trimToNull(commandValue), StringUtils.trimToNull(agentValue)); + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + protected String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + if (StringUtils.isBlank(vcenterHost)) { + return null; + } + String endpoint = String.format("%s:443", vcenterHost); + String command = String.format("openssl s_client -connect %s /dev/null | " + + "openssl x509 -fingerprint -sha1 -noout", shellQuote(endpoint)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser); + + String output = parser.getLines(); + if (script.getExitValue() != 0) { + logger.error("({}) Failed to fetch vCenter thumbprint for {}", sourceVmName, vcenterHost); + return null; + } + + return extractSha1Fingerprint(output); + } + + private String extractSha1Fingerprint(String output) { + String parsedOutput = StringUtils.trimToEmpty(output); + if (StringUtils.isBlank(parsedOutput)) { + return null; + } + + for (String line : parsedOutput.split("\\R")) { + String trimmedLine = StringUtils.trimToEmpty(line); + if (StringUtils.isBlank(trimmedLine)) { + continue; + } + + Matcher matcher = SHA1_FINGERPRINT_PATTERN.matcher(trimmedLine); + if (matcher.find()) { + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + if (trimmedLine.matches("(?i)[0-9a-f]{2}(:[0-9a-f]{2})+")) { + return trimmedLine.toUpperCase(Locale.ROOT); + } + } + return null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java new file mode 100644 index 000000000000..2825e9c52a19 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; + +import com.cloud.utils.script.OutputInterpreter; + +class VmwareCbtCommandOutputLogger extends OutputInterpreter { + + private static final Pattern PASSWORD_ARGUMENT_PATTERN = Pattern.compile("(?i)(password=)([^\\s]+)"); + + private final Logger logger; + private final String logPrefix; + private String lastOutputLine; + private String lastImportantOutputLine; + + VmwareCbtCommandOutputLogger(Logger logger, String logPrefix) { + this.logger = logger; + this.logPrefix = logPrefix; + } + + @Override + public boolean drain() { + return true; + } + + @Override + public String interpret(BufferedReader reader) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + String safeLine = sanitize(line); + logger.info(StringUtils.isNotBlank(logPrefix) ? String.format("%s %s", logPrefix, safeLine) : safeLine); + captureOutputLine(safeLine); + } + return null; + } + + String getLastRelevantOutputLine() { + return StringUtils.defaultIfBlank(lastImportantOutputLine, lastOutputLine); + } + + private void captureOutputLine(String line) { + String trimmedLine = StringUtils.trimToNull(line); + if (trimmedLine == null) { + return; + } + lastOutputLine = trimmedLine; + if (isImportantOutputLine(trimmedLine)) { + lastImportantOutputLine = trimmedLine; + } + } + + private boolean isImportantOutputLine(String line) { + String lowerCaseLine = StringUtils.lowerCase(line); + return StringUtils.containsAny(lowerCaseLine, + "error", "failed", "unable", "cannot", "permission denied", "no such file", + "not found", "file is empty", "traceback", "exception"); + } + + private String sanitize(String line) { + return PASSWORD_ARGUMENT_PATTERN.matcher(StringUtils.defaultString(line)).replaceAll("$1******"); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java new file mode 100644 index 000000000000..184c6f3111f9 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import org.apache.commons.lang3.StringUtils; + +class VmwareCbtCommandResult { + + private final int exitValue; + private final String lastCommandOutput; + + VmwareCbtCommandResult(int exitValue, String lastCommandOutput) { + this.exitValue = exitValue; + this.lastCommandOutput = lastCommandOutput; + } + + int getExitValue() { + return exitValue; + } + + String appendLastCommandOutput(String details) { + if (StringUtils.isBlank(lastCommandOutput) || StringUtils.contains(details, lastCommandOutput)) { + return details; + } + return String.format("%s Last command output: %s", details, lastCommandOutput); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java new file mode 100644 index 000000000000..458b5e6bba60 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java @@ -0,0 +1,273 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; + +class VmwareCbtSyncPlan { + + private final boolean valid; + private final String validationError; + private final List diskPlans; + private final int changedRangeCount; + private final int copyRangeCount; + private final long changedBytes; + + private VmwareCbtSyncPlan(boolean valid, String validationError, List diskPlans, + int changedRangeCount, int copyRangeCount, long changedBytes) { + this.valid = valid; + this.validationError = validationError; + this.diskPlans = diskPlans; + this.changedRangeCount = changedRangeCount; + this.copyRangeCount = copyRangeCount; + this.changedBytes = changedBytes; + } + + static VmwareCbtSyncPlan create(List disks, List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return new VmwareCbtSyncPlan(true, null, Collections.emptyList(), 0, 0, 0); + } + + Map diskPlansById = getDiskPlansById(disks); + int changedRangeCount = 0; + + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + ValidationResult validationResult = validateChangedBlock(changedBlock, diskPlansById); + if (!validationResult.valid) { + return invalid(validationResult.error); + } + + DiskPlanBuilder diskPlan = diskPlansById.get(changedBlock.getDiskId()); + long rangeEnd = changedBlock.getStartOffset() + changedBlock.getLength(); + if (rangeEnd < changedBlock.getStartOffset()) { + return invalid(String.format("Changed block range for disk %s overflows the virtual disk address space.", + changedBlock.getDiskId())); + } + if (diskPlan.disk.getCapacityBytes() > 0 && rangeEnd > diskPlan.disk.getCapacityBytes()) { + return invalid(String.format("Changed block range for disk %s exceeds disk capacity.", changedBlock.getDiskId())); + } + + diskPlan.addChangedBlock(changedBlock); + changedRangeCount++; + } + + List diskPlans = getPopulatedDiskPlans(diskPlansById); + long changedBytes = 0; + int copyRangeCount = 0; + for (DiskPlan diskPlan : diskPlans) { + changedBytes += diskPlan.getChangedBytes(); + copyRangeCount += diskPlan.getChangedBlocks().size(); + } + + return new VmwareCbtSyncPlan(true, null, diskPlans, changedRangeCount, copyRangeCount, changedBytes); + } + + private static Map getDiskPlansById(List disks) { + Map diskPlansById = new LinkedHashMap<>(); + if (CollectionUtils.isEmpty(disks)) { + return diskPlansById; + } + + for (VmwareCbtDiskTO disk : disks) { + if (disk != null && StringUtils.isNotBlank(disk.getDiskId())) { + diskPlansById.put(disk.getDiskId(), new DiskPlanBuilder(disk)); + } + } + return diskPlansById; + } + + private static ValidationResult validateChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock, + Map diskPlansById) { + if (changedBlock == null) { + return ValidationResult.invalid("Changed block range cannot be null."); + } + if (StringUtils.isBlank(changedBlock.getDiskId())) { + return ValidationResult.invalid("Changed block range is missing a disk id."); + } + + DiskPlanBuilder diskPlan = diskPlansById.get(changedBlock.getDiskId()); + if (diskPlan == null) { + return ValidationResult.invalid(String.format("Changed block range references unknown disk %s.", changedBlock.getDiskId())); + } + if (StringUtils.isBlank(diskPlan.disk.getTargetPath())) { + return ValidationResult.invalid(String.format("Changed block range references disk %s, but no target path is known. " + + "The initial full sync must create and persist the KVM target disk before CBT delta sync can run.", + changedBlock.getDiskId())); + } + if (changedBlock.getStartOffset() < 0) { + return ValidationResult.invalid(String.format("Changed block range for disk %s has a negative start offset.", + changedBlock.getDiskId())); + } + if (changedBlock.getLength() <= 0) { + return ValidationResult.invalid(String.format("Changed block range for disk %s has a non-positive length.", + changedBlock.getDiskId())); + } + + return ValidationResult.valid(); + } + + private static List getPopulatedDiskPlans(Map diskPlansById) { + List diskPlans = new ArrayList<>(); + for (DiskPlanBuilder diskPlan : diskPlansById.values()) { + if (CollectionUtils.isNotEmpty(diskPlan.changedBlocks)) { + diskPlans.add(diskPlan.build()); + } + } + return diskPlans; + } + + private static VmwareCbtSyncPlan invalid(String validationError) { + return new VmwareCbtSyncPlan(false, validationError, Collections.emptyList(), 0, 0, 0); + } + + boolean isValid() { + return valid; + } + + String getValidationError() { + return validationError; + } + + List getDiskPlans() { + return diskPlans; + } + + int getChangedRangeCount() { + return changedRangeCount; + } + + int getCopyRangeCount() { + return copyRangeCount; + } + + long getChangedBytes() { + return changedBytes; + } + + static class DiskPlan { + + private final VmwareCbtDiskTO disk; + private final List changedBlocks; + private final long changedBytes; + + DiskPlan(VmwareCbtDiskTO disk, List changedBlocks, long changedBytes) { + this.disk = disk; + this.changedBlocks = Collections.unmodifiableList(changedBlocks); + this.changedBytes = changedBytes; + } + + VmwareCbtDiskTO getDisk() { + return disk; + } + + List getChangedBlocks() { + return changedBlocks; + } + + long getChangedBytes() { + return changedBytes; + } + } + + private static class DiskPlanBuilder { + + private final VmwareCbtDiskTO disk; + private final List changedBlocks = new ArrayList<>(); + + DiskPlanBuilder(VmwareCbtDiskTO disk) { + this.disk = disk; + } + + void addChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock) { + changedBlocks.add(changedBlock); + } + + DiskPlan build() { + List copyBlocks = coalesceChangedBlocks(changedBlocks); + return new DiskPlan(disk, copyBlocks, sumChangedBytes(copyBlocks)); + } + + private List coalesceChangedBlocks(List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return Collections.emptyList(); + } + + List sortedBlocks = new ArrayList<>(changedBlocks); + sortedBlocks.sort(Comparator.comparingLong(VmwareCbtChangedBlockRangeTO::getStartOffset) + .thenComparingLong(VmwareCbtChangedBlockRangeTO::getLength)); + + List copyBlocks = new ArrayList<>(); + String diskId = disk.getDiskId(); + long currentStart = sortedBlocks.get(0).getStartOffset(); + long currentEnd = currentStart + sortedBlocks.get(0).getLength(); + + for (int i = 1; i < sortedBlocks.size(); i++) { + VmwareCbtChangedBlockRangeTO changedBlock = sortedBlocks.get(i); + long blockStart = changedBlock.getStartOffset(); + long blockEnd = blockStart + changedBlock.getLength(); + if (blockStart <= currentEnd) { + currentEnd = Math.max(currentEnd, blockEnd); + continue; + } + + copyBlocks.add(new VmwareCbtChangedBlockRangeTO(diskId, currentStart, currentEnd - currentStart)); + currentStart = blockStart; + currentEnd = blockEnd; + } + copyBlocks.add(new VmwareCbtChangedBlockRangeTO(diskId, currentStart, currentEnd - currentStart)); + return copyBlocks; + } + + private long sumChangedBytes(List changedBlocks) { + long changedBytes = 0; + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + changedBytes += changedBlock.getLength(); + } + return changedBytes; + } + } + + private static class ValidationResult { + + private final boolean valid; + private final String error; + + private ValidationResult(boolean valid, String error) { + this.valid = valid; + this.error = error; + } + + static ValidationResult valid() { + return new ValidationResult(true, null); + } + + static ValidationResult invalid(String error) { + return new ValidationResult(false, error); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java index a8c32baa6ef3..7a70f2497cda 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java @@ -17,6 +17,7 @@ package com.cloud.hypervisor.kvm.storage; import java.io.File; +import java.io.IOException; import java.util.List; import java.util.Map; @@ -173,9 +174,9 @@ public KVMPhysicalDisk getPhysicalDisk(String volumeUid) { logger.debug("find volume bypass libvirt volumeUid " + volumeUid); //For network file system or file system, try to use java file to find the volume, instead of through libvirt. BUG:CLOUDSTACK-4459 String localPoolPath = this.getLocalPath(); - File f = new File(localPoolPath + File.separator + volumeUuid); + File f = resolvePhysicalDiskFile(localPoolPath, volumeUid, volumeUuid); if (!f.exists()) { - logger.debug("volume: " + volumeUuid + " not exist on storage pool"); + logger.debug("volume: " + volumeUid + " not exist on storage pool"); throw new CloudRuntimeException("Can't find volume:" + volumeUuid); } disk = new KVMPhysicalDisk(f.getPath(), volumeUuid, this); @@ -186,6 +187,32 @@ public KVMPhysicalDisk getPhysicalDisk(String volumeUid) { return disk; } + File resolvePhysicalDiskFile(String localPoolPath, String volumeUid, String volumeUuid) { + File poolPath = new File(localPoolPath); + if (volumeUid.contains("/") && !new File(volumeUid).isAbsolute()) { + File nestedVolume = new File(poolPath, volumeUid); + if (isFileInStoragePool(poolPath, nestedVolume)) { + return nestedVolume; + } + logger.warn(String.format("Relative volume path [%s] resolves outside storage pool [%s]. Falling back to volume name lookup.", + volumeUid, localPoolPath)); + } + return new File(poolPath, volumeUuid); + } + + private boolean isFileInStoragePool(File poolPath, File volumePath) { + try { + String canonicalPoolPath = poolPath.getCanonicalPath(); + String canonicalVolumePath = volumePath.getCanonicalPath(); + return canonicalVolumePath.equals(canonicalPoolPath) || + canonicalVolumePath.startsWith(canonicalPoolPath + File.separator); + } catch (IOException e) { + logger.warn(String.format("Unable to resolve storage pool path [%s] or volume path [%s].", + poolPath, volumePath), e); + return false; + } + } + @Override public boolean connectPhysicalDisk(String name, Map details) { return true; diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java index 639f1dc2894c..d46aa9c63138 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java @@ -7185,6 +7185,38 @@ public void parseCpuFeaturesTestReturnListOfCpuFeaturesAndIgnoreMultipleWhitespa Assert.assertEquals("hle", cpuFeatures.get(3)); } + @Test + public void parseVddkLibraryVersionFromDumpPluginOutputReturnsLibraryVersion() { + String output = "vddk_default_libdir=/usr/lib64/vmware-vix-disklib\n" + + "vddk_library_version=8\n" + + "vddk_transport_modes=file:san:hotadd:nbdssl:nbd\n"; + + Assert.assertEquals("8", libvirtComputingResourceSpy.parseVddkLibraryVersionFromDumpPluginOutput(output)); + } + + @Test + public void parseVddkLibraryVersionFromDumpPluginOutputIgnoresPluginVersionWithoutLoadedLibrary() { + String output = "nbdkit 1.24.0\n" + + "vddk 1.24.0\n" + + "vddk_default_libdir=/usr/lib64/vmware-vix-disklib\n"; + + Assert.assertNull(libvirtComputingResourceSpy.parseVddkLibraryVersionFromDumpPluginOutput(output)); + } + + @Test + public void isNbdkitVddkPluginUsableRequiresLoadedVddkLibrary() throws IOException { + Path vddkDir = Files.createTempDirectory("vddk-test"); + Files.createDirectories(vddkDir.resolve("lib64")); + Files.createFile(vddkDir.resolve("lib64/libvixDiskLib.so.8")); + Mockito.doReturn("vddk_library_version=8\n").when(libvirtComputingResourceSpy).runNbdkitVddkDumpPlugin(vddkDir.toString()); + + Assert.assertTrue(libvirtComputingResourceSpy.isNbdkitVddkPluginUsable(vddkDir.toString())); + + Mockito.doReturn("nbdkit: error: libssl.so.3: cannot open shared object file\n").when(libvirtComputingResourceSpy).runNbdkitVddkDumpPlugin(vddkDir.toString()); + + Assert.assertFalse(libvirtComputingResourceSpy.isNbdkitVddkPluginUsable(vddkDir.toString())); + } + @Test public void defineDiskForDefaultPoolTypeSkipsForceDiskController() { Map details = new HashMap<>(); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java index 74090723331a..f918d85b2a06 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java @@ -78,6 +78,57 @@ public void testCheckInstanceCommand_vddkSuccess() { assertTrue(answer.getResult()); } + @Test + public void testCheckInstanceCommand_vddkDirectRbdSuccess() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkRbdDirectImportSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertTrue(answer.getResult()); + } + + @Test + public void testCheckInstanceCommand_vddkDirectRbdFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkRbdDirectImportSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(StringUtils.isNotBlank(answer.getDetails())); + } + + @Test + public void testCheckInstanceCommand_vddkInPlaceFinalizationSuccess() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkInPlaceFinalizationSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVirtV2vInPlace()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertTrue(answer.getResult()); + } + + @Test + public void testCheckInstanceCommand_vddkInPlaceFinalizationFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkInPlaceFinalizationSupport()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVirtV2vInPlace()).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(StringUtils.isNotBlank(answer.getDetails())); + } + @Test public void testCheckInstanceCommand_vddkFailure() { Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); @@ -89,4 +140,18 @@ public void testCheckInstanceCommand_vddkFailure() { assertFalse(answer.getResult()); assertTrue(StringUtils.isNotBlank(answer.getDetails())); } + + @Test + public void testCheckInstanceCommand_windowsGuestConversionFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(checkConvertInstanceCommandMock.getCheckWindowsGuestConversionSupport()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsWindowsGuestConversion()).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("virtio-win")); + } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java new file mode 100644 index 000000000000..7d5a775452be --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java @@ -0,0 +1,189 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CheckVolumeAnswer; +import com.cloud.agent.api.CheckVolumeCommand; +import com.cloud.agent.api.to.StorageFilerTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; +import org.apache.cloudstack.storage.volume.VolumeOnStorageTO; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.HashMap; +import java.util.Map; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtCheckVolumeCommandWrapperTest { + + @Mock + LibvirtComputingResource libvirtComputingResource; + @Mock + KVMStoragePoolManager storagePoolMgr; + @Mock + KVMStoragePool storagePool; + @Mock + StorageFilerTO storageFilerTO; + @Mock + KVMPhysicalDisk disk; + + @Spy + LibvirtCheckVolumeCommandWrapper wrapper = new LibvirtCheckVolumeCommandWrapper(); + + MockedConstruction qemuImg; + + private final String poolUuid = "pool-uuid"; + private final String srcFile = "rbd-volume-uuid"; + private final long virtualSize = 21474836480L; // 20 GiB + + private Map qemuInfo; + + @Before + public void setUp() { + qemuInfo = new HashMap<>(); + qemuInfo.put(QemuImg.FILE_FORMAT, "raw"); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolMgr); + } + + @After + public void tearDown() { + if (qemuImg != null) { + qemuImg.close(); + } + } + + private void mockRbdPool() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + Mockito.when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.RBD, poolUuid)).thenReturn(storagePool); + Mockito.when(storagePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storagePool.getPhysicalDisk(srcFile)).thenReturn(disk); + Mockito.when(disk.getPath()).thenReturn(srcFile); + Mockito.when(disk.getFormat()).thenReturn(QemuImg.PhysicalDiskFormat.RAW); + // values consumed by KVMPhysicalDisk.RBDStringBuilder when building the RBD URI + Mockito.when(storagePool.getSourceHost()).thenReturn("10.0.0.10"); + Mockito.when(storagePool.getSourcePort()).thenReturn(6789); + Mockito.when(storagePool.getAuthUserName()).thenReturn(null); + Mockito.when(storagePool.getDetails()).thenReturn(null); + } + + private CheckVolumeCommand buildCommand() { + CheckVolumeCommand command = new CheckVolumeCommand(); + command.setSrcFile(srcFile); + command.setStorageFilerTO(storageFilerTO); + return command; + } + + @Test + public void testRbdVolumeReturnsSuccessWithVirtualSizeAndDetails() { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue(answer.getResult()); + CheckVolumeAnswer checkVolumeAnswer = (CheckVolumeAnswer) answer; + Assert.assertEquals(virtualSize, checkVolumeAnswer.getSize()); + Map details = checkVolumeAnswer.getVolumeDetails(); + Assert.assertNotNull(details); + Assert.assertEquals("raw", details.get(VolumeOnStorageTO.Detail.FILE_FORMAT)); + Assert.assertEquals("false", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeInspectedThroughRbdUri() throws Exception { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + wrapper.execute(buildCommand(), libvirtComputingResource); + + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(QemuImgFile.class); + Mockito.verify(qemuImg.constructed().get(0), Mockito.atLeastOnce()) + .info(fileCaptor.capture(), Mockito.anyBoolean()); + Assert.assertTrue("qemu-img should be pointed at the RBD URI", + fileCaptor.getValue().getFileName().startsWith("rbd:" + srcFile)); + } + + @Test + public void testRbdVolumeReportsLockedWhenInfoProbeFails() { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> { + // secure=true uses force-share and succeeds; secure=false is the lock probe + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.eq(true))).thenReturn(qemuInfo); + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.eq(false))).thenReturn(null); + }); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue(answer.getResult()); + Map details = ((CheckVolumeAnswer) answer).getVolumeDetails(); + Assert.assertEquals("true", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeWithoutInfoReturnsInvalid() { + mockRbdPool(); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(null)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertFalse(answer.getResult()); + CheckVolumeAnswer checkVolumeAnswer = (CheckVolumeAnswer) answer; + Assert.assertEquals(0, checkVolumeAnswer.getSize()); + Assert.assertNull(checkVolumeAnswer.getVolumeDetails()); + } + + @Test + public void testUnsupportedStoragePoolReturnsPlainAnswer() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.PowerFlex); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertFalse(answer instanceof CheckVolumeAnswer); + Assert.assertFalse(answer.getResult()); + Assert.assertEquals("Unsupported Storage Pool", answer.getDetails()); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java index c30fa2f49482..dc74d4dd5e5f 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java @@ -44,6 +44,7 @@ import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; import com.cloud.utils.script.Script; @RunWith(MockitoJUnitRunner.class) @@ -151,6 +152,26 @@ public void testExecuteConvertUnsupportedHypervisors() { Assert.assertFalse(answer.getResult()); } + @Test + public void testExecuteDirectRbdVddkFailsWhenHostLacksDirectRbdSupport() { + RemoteInstanceTO remoteInstanceTO = getRemoteInstanceTO(Hypervisor.HypervisorType.VMware); + ConvertInstanceCommand cmd = getConvertInstanceCommand(remoteInstanceTO, Hypervisor.HypervisorType.KVM, false); + Mockito.when(cmd.isUseVddk()).thenReturn(true); + Mockito.when(cmd.getVddkLibDir()).thenReturn("/opt/vddk"); + Mockito.when(cmd.getConversionTemporaryLocation()).thenReturn(primaryDataStore); + Mockito.when(primaryDataStore.getPoolType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(primaryDataStore.getUuid()).thenReturn("rbd-pool-uuid"); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(destinationPool); + Mockito.when(destinationPool.getLocalPath()).thenReturn("/rbd"); + Mockito.when(libvirtComputingResourceMock.getVddkLibDir()).thenReturn("/opt/vddk"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vddk")).thenReturn(false); + + Answer answer = convertInstanceCommandWrapper.execute(cmd, libvirtComputingResourceMock); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("Direct RBD VDDK import requires")); + } + @Test public void testExecuteConvertFailure() { RemoteInstanceTO remoteInstanceTO = getRemoteInstanceTO(Hypervisor.HypervisorType.VMware); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapperTest.java index a30168266c0c..9635243a21de 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapperTest.java @@ -39,6 +39,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.Spy; @@ -167,6 +168,66 @@ public void testMoveTemporaryDisksToDestination() { Assert.assertEquals("xyz", movedDisks.get(0).getPath()); } + @Test + public void testMoveTemporaryDisksToRbdDestinationUsesDestinationPoolType() { + KVMPhysicalDisk sourceDisk = Mockito.mock(KVMPhysicalDisk.class); + Mockito.lenient().when(sourceDisk.getPool()).thenReturn(temporaryPool); + List disks = List.of(sourceDisk); + String destinationPoolUuid = UUID.randomUUID().toString(); + List destinationPools = List.of(destinationPoolUuid); + List destinationPoolTypes = List.of(Storage.StoragePoolType.RBD); + + KVMPhysicalDisk destDisk = Mockito.mock(KVMPhysicalDisk.class); + Mockito.when(destDisk.getPath()).thenReturn("rbd/image"); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, destinationPoolUuid)) + .thenReturn(destinationPool); + Mockito.when(destinationPool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(destinationPool.getSourceDir()).thenReturn("cloudstack"); + Mockito.when(destinationPool.getSourceHost()).thenReturn("10.0.0.1,10.0.0.2"); + Mockito.when(destinationPool.getSourcePort()).thenReturn(6789); + Mockito.when(storagePoolManager.copyPhysicalDisk(Mockito.eq(sourceDisk), Mockito.anyString(), Mockito.eq(destinationPool), Mockito.anyInt())) + .thenReturn(destDisk); + + try (MockedConstruction + + diff --git a/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java b/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java index 89f6d7abd7d0..8c67b7e692f3 100644 --- a/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java +++ b/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java @@ -806,6 +806,7 @@ public static UnmanagedInstanceTO getUnmanagedInstance(VmwareHypervisorHost hype instance.setName(vmMo.getVmName()); instance.setInternalCSName(vmMo.getInternalCSName()); instance.setPath((vmMo.getPath())); + instance.setVmwareMoref(vmMo.getMor().getValue()); instance.setCpuCoresPerSocket(vmMo.getCoresPerSocket()); instance.setOperatingSystemId(vmMo.getVmGuestInfo().getGuestId()); VirtualMachineConfigSummary configSummary = vmMo.getConfigSummary();