From 3552a28192b3ddc50edd359abd3c26a89babaaa9 Mon Sep 17 00:00:00 2001 From: Andrija Panic Date: Mon, 6 Jul 2026 01:10:43 +0200 Subject: [PATCH 01/15] VMware CBT native RBD warm migration to KVM Operator-driven warm VMware-to-KVM migration built on VDDK and VMware Changed Block Tracking (CBT). Keeps a source-equivalent KVM-side replica current through delta cycles, then finalizes with virt-v2v in-place at cutover. Supports both filesystem (qcow2) and native Ceph/RBD (raw image) primary storage targets, with preflight validation, async lifecycle operations, persisted migration state, and Import/Export UI integration. Signed-off-by: Andrija Panic --- .../cloud/agent/api/to/RemoteInstanceTO.java | 10 + .../api/to/VmwareCbtChangedBlockRangeTO.java | 47 + .../api/to/VmwareCbtDiskSyncResultTO.java | 78 + .../cloud/agent/api/to/VmwareCbtDiskTO.java | 85 + .../main/java/com/cloud/event/EventTypes.java | 3 + api/src/main/java/com/cloud/host/Host.java | 7 + .../apache/cloudstack/api/ApiConstants.java | 23 + .../admin/vm/CancelVmwareCbtMigrationCmd.java | 73 + ...eckVmwareCbtMigrationPrerequisitesCmd.java | 204 ++ .../vm/CutoverVmwareCbtMigrationCmd.java | 110 + .../admin/vm/DeleteVmwareCbtMigrationCmd.java | 81 + .../api/command/admin/vm/ImportVmCmd.java | 29 + .../admin/vm/ListVmwareCbtMigrationsCmd.java | 109 + .../admin/vm/StartVmwareCbtMigrationCmd.java | 361 +++ .../admin/vm/SyncVmwareCbtMigrationCmd.java | 110 + .../VmwareCbtMigrationCycleResponse.java | 111 + .../VmwareCbtMigrationDiskResponse.java | 117 + ...wareCbtMigrationPreflightDiskResponse.java | 114 + ...eCbtMigrationPreflightFindingResponse.java | 55 + .../VmwareCbtMigrationPreflightResponse.java | 284 ++ .../response/VmwareCbtMigrationResponse.java | 288 ++ .../cloudstack/vm/UnmanagedVMsManager.java | 20 + .../vm/VmwareCbtChangedBlockInfo.java | 36 + .../vm/VmwareCbtChangedDiskInfo.java | 46 + .../cloudstack/vm/VmwareCbtDiskInfo.java | 67 + .../cloudstack/vm/VmwareCbtMigration.java | 47 + .../vm/VmwareCbtMigrationCycle.java | 30 + .../cloudstack/vm/VmwareCbtMigrationDisk.java | 30 + .../vm/VmwareCbtMigrationManager.java | 46 + .../vm/VmwareCbtMigrationService.java | 47 + .../vm/VmwareCbtPreflightDiskInfo.java | 59 + .../cloudstack/vm/VmwareCbtPreflightInfo.java | 113 + .../cloudstack/vm/VmwareCbtSnapshotInfo.java | 46 + .../agent/api/VmwareCbtCleanupCommand.java | 96 + .../agent/api/VmwareCbtCutoverCommand.java | 133 + .../agent/api/VmwareCbtMigrationAnswer.java | 82 + .../agent/api/VmwareCbtPrepareCommand.java | 110 + .../agent/api/VmwareCbtRbdProbeCommand.java | 53 + .../cloud/agent/api/VmwareCbtSyncCommand.java | 138 + .../api/to/VmwareCbtTargetStorageType.java | 22 + docs/vmware-cbt-migration.md | 324 ++ docs/vmware-cbt/README.md | 1790 ++++++++++++ docs/vmware-cbt/architecture.md | 870 ++++++ .../cloud/agent/manager/AgentManagerImpl.java | 56 +- .../cloud/vm/VmwareCbtMigrationCycleVO.java | 171 ++ .../cloud/vm/VmwareCbtMigrationDiskVO.java | 197 ++ .../com/cloud/vm/VmwareCbtMigrationVO.java | 465 +++ .../vm/dao/VmwareCbtMigrationCycleDao.java | 26 + .../dao/VmwareCbtMigrationCycleDaoImpl.java | 46 + .../cloud/vm/dao/VmwareCbtMigrationDao.java | 32 + .../vm/dao/VmwareCbtMigrationDaoImpl.java | 85 + .../vm/dao/VmwareCbtMigrationDiskDao.java | 26 + .../vm/dao/VmwareCbtMigrationDiskDaoImpl.java | 44 + ...spring-engine-schema-core-daos-context.xml | 3 + .../META-INF/db/schema-42210to42300.sql | 126 + .../resource/LibvirtComputingResource.java | 205 +- .../wrapper/LibvirtReadyCommandWrapper.java | 7 + ...LibvirtVmwareCbtCleanupCommandWrapper.java | 184 ++ ...LibvirtVmwareCbtCutoverCommandWrapper.java | 709 +++++ ...LibvirtVmwareCbtPrepareCommandWrapper.java | 397 +++ ...ibvirtVmwareCbtRbdProbeCommandWrapper.java | 146 + .../LibvirtVmwareCbtSyncCommandWrapper.java | 446 +++ .../wrapper/VmwareCbtCommandOutputLogger.java | 83 + .../wrapper/VmwareCbtCommandResult.java | 41 + .../resource/wrapper/VmwareCbtSyncPlan.java | 273 ++ .../kvm/storage/LibvirtStoragePool.java | 31 +- .../LibvirtComputingResourceTest.java | 32 + ...heckConvertInstanceCommandWrapperTest.java | 14 + ...irtVmwareCbtCleanupCommandWrapperTest.java | 137 + ...irtVmwareCbtCutoverCommandWrapperTest.java | 344 +++ ...irtVmwareCbtPrepareCommandWrapperTest.java | 134 + ...rtVmwareCbtRbdProbeCommandWrapperTest.java | 131 + ...ibvirtVmwareCbtSyncCommandWrapperTest.java | 215 ++ .../wrapper/VmwareCbtSyncPlanTest.java | 98 + .../kvm/storage/LibvirtStoragePoolTest.java | 56 + .../VmwareCbtMigrationServiceImpl.java | 561 ++++ .../core/spring-vmware-core-context.xml | 2 + .../cloud/server/ManagementServerImpl.java | 7 +- .../vm/UnmanagedVMsManagerImpl.java | 147 +- .../vm/VmwareCbtMigrationCutoverPolicy.java | 81 + .../vm/VmwareCbtMigrationManagerImpl.java | 2599 +++++++++++++++++ .../cloudstack/vm/VmwareCbtStorageTarget.java | 85 + .../spring-server-compute-context.xml | 2 + .../vm/UnmanagedVMsManagerImplTest.java | 44 + .../VmwareCbtMigrationCutoverPolicyTest.java | 75 + .../VmwareCbtMigrationDeletePolicyTest.java | 60 + .../vm/VmwareCbtMigrationImportTest.java | 88 + ...areCbtMigrationOfferingValidationTest.java | 135 + .../vm/VmwareCbtStorageTargetTest.java | 171 ++ ui/public/locales/en.json | 29 + ui/src/config/section/tools.js | 17 +- .../views/tools/ImportUnmanagedInstance.vue | 322 +- ui/src/views/tools/ManageInstances.vue | 412 ++- ui/src/views/tools/SelectVmwareVcenter.vue | 4 +- ui/src/views/tools/VmwareCbtMigrations.vue | 493 ++++ 95 files changed, 16706 insertions(+), 92 deletions(-) create mode 100644 api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java create mode 100644 api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java create mode 100644 api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java create mode 100644 core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java create mode 100644 core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java create mode 100644 core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java create mode 100644 docs/vmware-cbt-migration.md create mode 100644 docs/vmware-cbt/README.md create mode 100644 docs/vmware-cbt/architecture.md create mode 100644 engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java create mode 100644 engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java create mode 100644 plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java create mode 100644 server/src/main/java/org/apache/cloudstack/vm/VmwareCbtStorageTarget.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationImportTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationOfferingValidationTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtStorageTargetTest.java create mode 100644 ui/src/views/tools/VmwareCbtMigrations.vue 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..1daddd9a9412 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,8 @@ public String getClusterName() { public String getHostName() { return hostName; } + + public String getVmwareMoref() { + return 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/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..fb5c32e21d91 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -60,8 +60,15 @@ 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_VMWARE_CBT_SUPPORT = "host.vmware.cbt.support"; + String HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT = "host.vmware.cbt.in.place.finalization.support"; + String HOST_VMWARE_CBT_RBD_SUPPORT = "host.vmware.cbt.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_IN_PLACE_VERSION = "host.virtv2v.in.place.version"; String HOST_SSH_PORT = "host.ssh.port"; String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count"; String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; 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..b547346a8011 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 ///////////////////// ///////////////////////////////////////////////////// @@ -186,6 +204,13 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ".") 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 +292,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/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/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..c3add83c83dd --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.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; + +import com.cloud.storage.Storage; + +public class VmwareCbtRbdProbeCommand extends Command { + + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private String probeImageName; + + 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; + } + + @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..7f7f01cafd56 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java @@ -0,0 +1,22 @@ +// 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 +} diff --git a/docs/vmware-cbt-migration.md b/docs/vmware-cbt-migration.md new file mode 100644 index 000000000000..f66cf293aaca --- /dev/null +++ b/docs/vmware-cbt-migration.md @@ -0,0 +1,324 @@ + + +# 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. + +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. diff --git a/docs/vmware-cbt/README.md b/docs/vmware-cbt/README.md new file mode 100644 index 000000000000..5757dc9563ad --- /dev/null +++ b/docs/vmware-cbt/README.md @@ -0,0 +1,1790 @@ +# VMware CBT Migration Implementation Notes + +## Introduction + +This document describes the VMware Changed Block Tracking (CBT) migration design +and implementation for Apache CloudStack 4.23. 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 schema upgrade path is in: + +- `engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql` +- `engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42210to42300.java` + +### `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. +- 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.vmware.cbt.support', + 'host.vmware.cbt.in.place.finalization.support', + 'host.vmware.cbt.rbd.support', + 'host.vddk.support', + 'host.vddk.version', + 'host.virtv2v.in.place.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.vmware.cbt.support` | True only if `host.vddk.support` is true and `qemu-img --version`, `qemu-nbd --version`, and `qemu-io --version` all exit `0`. | +| `host.vmware.cbt.in.place.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.in.place.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.vmware.cbt.rbd.support` | True only if in-place finalization support is true and `qemu-img --help` advertises the `rbd` block driver. | + +Important nuance: `host.vmware.cbt.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.vmware.cbt.support|host.vmware.cbt.in.place.finalization.support|host.vmware.cbt.rbd.support|host.vddk.support|host.vddk.version|host.virtv2v.version|host.virtv2v.in.place.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.22.1-to-4.23 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.23 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..0f734f8e668b --- /dev/null +++ b/docs/vmware-cbt/architecture.md @@ -0,0 +1,870 @@ +# 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/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..08dbde33b975 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,16 @@ 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_VMWARE_CBT_SUPPORT); + String vmwareCbtInPlaceFinalizationSupport = detailsMap.get(Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT); + String vmwareCbtRbdSupport = detailsMap.get(Host.HOST_VMWARE_CBT_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_IN_PLACE_VERSION); 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)) { _hostDao.loadDetails(host); boolean updateNeeded = false; if (StringUtils.isNotBlank(uefiEnabled) && !uefiEnabled.equals(host.getDetails().get(Host.HOST_UEFI_ENABLE))) { @@ -844,6 +852,52 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi } updateNeeded = true; } + if (StringUtils.isNotBlank(vmwareCbtSupport) && !vmwareCbtSupport.equals(host.getDetails().get(Host.HOST_VMWARE_CBT_SUPPORT))) { + host.getDetails().put(Host.HOST_VMWARE_CBT_SUPPORT, vmwareCbtSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtInPlaceFinalizationSupport) && + !vmwareCbtInPlaceFinalizationSupport.equals(host.getDetails().get(Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT))) { + host.getDetails().put(Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT, vmwareCbtInPlaceFinalizationSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtRbdSupport) && + !vmwareCbtRbdSupport.equals(host.getDetails().get(Host.HOST_VMWARE_CBT_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_VMWARE_CBT_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_IN_PLACE_VERSION)))) { + if (StringUtils.isBlank(virtV2vInPlaceVersion)) { + host.getDetails().remove(Host.HOST_VIRTV2V_IN_PLACE_VERSION); + } else { + host.getDetails().put(Host.HOST_VIRTV2V_IN_PLACE_VERSION, virtV2vInPlaceVersion); + } + updateNeeded = true; + } if (updateNeeded) { _hostDao.saveDetails(host); } 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..4ff85fd88152 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,17 @@ 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_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_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_VERSION; +import static com.cloud.host.Host.HOST_VIRTV2V_IN_PLACE_VERSION; import static com.cloud.host.Host.HOST_VIRTV2V_VERSION; +import static com.cloud.host.Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT; +import static com.cloud.host.Host.HOST_VMWARE_CBT_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_VMWARE_CBT_SUPPORT; import static com.cloud.host.Host.HOST_VOLUME_ENCRYPTION; import static org.apache.cloudstack.utils.linux.KVMHostInfo.isHostS390x; @@ -52,6 +59,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 +376,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 +386,11 @@ 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 int LIBVIRT_CGROUP_CPU_SHARES_MIN = 2; public static final int LIBVIRT_CGROUP_CPU_SHARES_MAX = 262144; @@ -1266,9 +1281,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 +4430,34 @@ 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_VMWARE_CBT_SUPPORT, String.valueOf(hostSupportsVmwareCbtMigration())); + cmd.getHostDetails().put(HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT, String.valueOf(hostSupportsVmwareCbtInPlaceFinalization())); + cmd.getHostDetails().put(HOST_VMWARE_CBT_RBD_SUPPORT, String.valueOf(hostSupportsVmwareCbtRbd())); 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_IN_PLACE_VERSION, virtV2vInPlaceVersion); + } if (hostSupportsOvfExport()) { cmd.getHostDetails().put(HOST_OVFTOOL_VERSION, getHostOvfToolVersion()); } @@ -6241,14 +6275,95 @@ 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 hostSupportsVmwareCbtMigration() { + return hostSupportsVmwareCbtMigration(null); + } + + public boolean hostSupportsVmwareCbtMigration(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 hostSupportsVmwareCbtInPlaceFinalization() { + return hostSupportsVmwareCbtMigration() && hostSupportsVirtV2vInPlace(); + } + + public boolean hostSupportsVmwareCbtRbd() { + return hostSupportsVmwareCbtInPlaceFinalization() + && 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(); + } + + 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; } protected boolean isVddkLibDirValid(String path) { @@ -6263,6 +6378,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 +6398,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/LibvirtReadyCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java index 5a7d6d2c203a..cb1f3d40c229 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,16 @@ public Answer execute(final ReadyCommand command, final LibvirtComputingResource if (libvirtComputingResource.hostSupportsInstanceConversion()) { hostDetails.put(Host.HOST_VIRTV2V_VERSION, libvirtComputingResource.getHostVirtV2vVersion()); } + hostDetails.put(Host.HOST_VIRTV2V_IN_PLACE_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_VMWARE_CBT_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVmwareCbtMigration())); + hostDetails.put(Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVmwareCbtInPlaceFinalization())); + hostDetails.put(Host.HOST_VMWARE_CBT_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVmwareCbtRbd())); + 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())); 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..66a37318a93f --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java @@ -0,0 +1,184 @@ +// 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()); + } + + 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 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..8413dacca727 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java @@ -0,0 +1,709 @@ +// 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.hostSupportsVmwareCbtMigration(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 outputXmlPath = 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); + } + + 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."); + } + List rbdNbdBridges = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW ? + createRbdNbdBridges(cmd, targetPool) : List.of(); + sourceXmlPath = writeSourceXml(cmd, rbdNbdBridges); + + String command; + String logSuffix; + if (inPlaceFinalization) { + if (finalizationMode == VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_BINARY) { + outputXmlPath = Files.createTempFile(String.format("vmware-cbt-%s-v2v-in-place-output-", + sanitizeFileName(cmd.getMigrationUuid())), ".xml"); + } + command = buildVirtV2vInPlaceCommand(sourceXmlPath, outputXmlPath, 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(outputXmlPath); + 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) { + 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 RBD targets are supported for RBD finalization", + disk.getDiskId(), targetFormat)); + } + 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) 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)); + return sourceXmlPath; + } + + private String buildSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges) { + 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 { + 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 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, Path outputXmlPath, 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 { + command.append("virt-v2v-in-place --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-O ").append(shellQuote(outputXmlPath.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..ee8130663cca --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java @@ -0,0 +1,397 @@ +// 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; + +@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.hostSupportsVmwareCbtMigration(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 { + 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 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; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + targetPath = getRbdTargetImageName(cmd, disk); + deleteRbdTargetIfExists(targetPool, targetPath); + qemuTargetPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, targetPath)); + } 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; + } + + String command = buildNbdkitQemuImgCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()), + qemuTargetPath, targetFormat); + 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) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + 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 buildNbdkitQemuImgCommand(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String targetPath, String targetFormat) { + 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 qemuImg = String.format("qemu-img convert -f raw -O %s \"$uri\" %s", + shellQuote(targetFormat), shellQuote(targetPath)); + return nbdkit.append("--run ").append(shellQuote(qemuImg)).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..74653e037d60 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java @@ -0,0 +1,146 @@ +// 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.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-"; + 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 long PROBE_SIZE_BYTES = 4L * 1024L * 1024L; + + @Override + public Answer execute(VmwareCbtRbdProbeCommand cmd, LibvirtComputingResource serverResource) { + String probeImageName = StringUtils.trimToNull(cmd.getProbeImageName()); + 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 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..2152838448b3 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java @@ -0,0 +1,446 @@ +// 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.hostSupportsVmwareCbtMigration(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); + } + + 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) { + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw"), "raw")) { + throw new IllegalArgumentException(String.format("only raw RBD target disks are supported for VMware CBT delta sync; disk %s uses %s", + 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(); + 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("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("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"); + 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 String getQemuTargetPath(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, KVMStoragePool targetPool) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + return KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, disk.getTargetPath())); + } + return disk.getTargetPath(); + } + + private String getTargetFormat(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + 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..3c96ad8a029a 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 @@ -89,4 +89,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/LibvirtVmwareCbtCleanupCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapperTest.java new file mode 100644 index 000000000000..37703c456bf8 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapperTest.java @@ -0,0 +1,137 @@ +// 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.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCleanupCommand; +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.storage.Storage; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtVmwareCbtCleanupCommandWrapperTest { + + private static final String MIGRATION_UUID = "migration-uuid"; + + private final LibvirtVmwareCbtCleanupCommandWrapper wrapper = new LibvirtVmwareCbtCleanupCommandWrapper(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private KVMStoragePoolManager storagePoolManager; + @Mock + private KVMStoragePool rbdStoragePool; + + @Test + public void testExecuteDeletesOnlyMigrationDirectory() throws IOException { + Path root = temporaryFolder.newFolder("primary").toPath(); + Path migrationDirectory = root.resolve("cloudstack-cbt").resolve(MIGRATION_UUID); + Path targetDisk = migrationDirectory.resolve("disk.qcow2"); + Path siblingDisk = root.resolve("cloudstack-cbt").resolve("other-migration").resolve("disk.qcow2"); + Files.createDirectories(targetDisk.getParent()); + Files.createDirectories(siblingDisk.getParent()); + Files.writeString(targetDisk, "partial target"); + Files.writeString(siblingDisk, "do not delete"); + + Answer answer = wrapper.execute(createCommand(targetDisk.toString()), libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertFalse(Files.exists(migrationDirectory)); + Assert.assertTrue(Files.exists(siblingDisk)); + } + + @Test + public void testExecuteSkipsPathsOutsideMigrationDirectory() throws IOException { + Path targetDisk = temporaryFolder.newFile("disk.qcow2").toPath(); + + Answer answer = wrapper.execute(createCommand(targetDisk.toString()), libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(Files.exists(targetDisk)); + } + + @Test + public void testExecuteDeletesOnlyMarkedRbdTargetImages() { + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(rbdStoragePool); + Mockito.when(rbdStoragePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(rbdStoragePool.deletePhysicalDisk(Mockito.eq("cloudstack-cbt-migration-uuid-disk-1"), Mockito.eq(Storage.ImageFormat.RAW))).thenReturn(true); + VmwareCbtCleanupCommand command = new VmwareCbtCleanupCommand(MIGRATION_UUID, + List.of(new VmwareCbtDiskTO("disk-1", 2000, "[datastore] vm/disk.vmdk", "datastore", + "cloudstack-cbt-migration-uuid-disk-1", "raw", null, null, 8192), + new VmwareCbtDiskTO("disk-2", 2001, "[datastore] vm/disk2.vmdk", "datastore", + "live-volume-that-must-not-be-removed", "raw", null, null, 8192)), + true, true, true); + command.setTargetStorageType(VmwareCbtTargetStorageType.RBD_RAW); + command.setDestinationStoragePoolType(Storage.StoragePoolType.RBD); + command.setDestinationStoragePoolUuid("rbd-pool-uuid"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Mockito.verify(rbdStoragePool).deletePhysicalDisk("cloudstack-cbt-migration-uuid-disk-1", Storage.ImageFormat.RAW); + Mockito.verify(rbdStoragePool, Mockito.never()).deletePhysicalDisk("live-volume-that-must-not-be-removed", Storage.ImageFormat.RAW); + } + + @Test + public void testExecuteFailsWhenMarkedRbdTargetImageCannotBeDeleted() { + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(rbdStoragePool); + Mockito.when(rbdStoragePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(rbdStoragePool.getUuid()).thenReturn("rbd-pool-uuid"); + Mockito.when(rbdStoragePool.deletePhysicalDisk(Mockito.eq("cloudstack-cbt-migration-uuid-disk-1"), Mockito.eq(Storage.ImageFormat.RAW))).thenReturn(false); + VmwareCbtCleanupCommand command = new VmwareCbtCleanupCommand(MIGRATION_UUID, + List.of(new VmwareCbtDiskTO("disk-1", 2000, "[datastore] vm/disk.vmdk", "datastore", + "cloudstack-cbt-migration-uuid-disk-1", "raw", null, null, 8192)), + true, true, true); + command.setTargetStorageType(VmwareCbtTargetStorageType.RBD_RAW); + command.setDestinationStoragePoolType(Storage.StoragePoolType.RBD); + command.setDestinationStoragePoolUuid("rbd-pool-uuid"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("Unable to clean up VMware CBT migration")); + } + + private VmwareCbtCleanupCommand createCommand(String targetPath) { + return new VmwareCbtCleanupCommand(MIGRATION_UUID, + List.of(new VmwareCbtDiskTO("disk-1", 2000, "[datastore] vm/disk.vmdk", "datastore", + targetPath, "qcow2", null, null, 8192)), + true, true, true); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapperTest.java new file mode 100644 index 000000000000..7ab6d2f46d97 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapperTest.java @@ -0,0 +1,344 @@ +// 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.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.to.RemoteInstanceTO; +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.storage.Storage; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtVmwareCbtCutoverCommandWrapperTest { + + private final TestLibvirtVmwareCbtCutoverCommandWrapper wrapper = new TestLibvirtVmwareCbtCutoverCommandWrapper(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private KVMStoragePoolManager storagePoolManager; + @Mock + private KVMStoragePool rbdStoragePool; + + @Before + public void setUp() { + Mockito.when(libvirtComputingResource.hostSupportsVmwareCbtMigration(Mockito.nullable(String.class))).thenReturn(true); + Mockito.when(libvirtComputingResource.getLibguestfsBackend()).thenReturn("direct"); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(rbdStoragePool); + Mockito.when(rbdStoragePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(rbdStoragePool.getSourceDir()).thenReturn("cloudstack"); + Mockito.when(rbdStoragePool.getSourceHost()).thenReturn("10.0.0.10,10.0.0.11"); + Mockito.when(rbdStoragePool.getSourcePort()).thenReturn(6789); + Mockito.when(rbdStoragePool.getAuthUserName()).thenReturn("client.admin"); + } + + @Test + public void testExecuteRunsVirtV2vInPlaceForSyncedQcow2Disks() throws IOException { + VmwareCbtCutoverCommand command = createCommand(List.of( + createDisk("disk-1", temporaryFolder.newFile("disk-1.qcow2").getAbsolutePath()), + createDisk("disk-2", temporaryFolder.newFile("disk-2.qcow2").getAbsolutePath()))); + command.setWait(1); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("virt-v2v-in-place conversion completed")); + Assert.assertTrue(wrapper.lastCommand.contains("virt-v2v-in-place --root first -i libvirtxml")); + Assert.assertTrue(wrapper.lastCommand.contains("export LIBGUESTFS_BACKEND='direct'")); + Assert.assertTrue(wrapper.lastSourceXml.contains("")); + Assert.assertTrue(wrapper.lastSourceXml.contains("")); + Assert.assertTrue(wrapper.lastSourceXml.contains("")); + Assert.assertEquals(2, ((VmwareCbtMigrationAnswer) answer).getDiskResults().size()); + } + + @Test + public void testExecuteRelocatesInPlaceFinalizedDiskToStoragePoolRoot() throws IOException { + Path poolRoot = temporaryFolder.newFolder("pool-root").toPath(); + Path migrationDir = poolRoot.resolve("cloudstack-cbt").resolve("migration-uuid"); + Files.createDirectories(migrationDir); + Path sourceDisk = migrationDir.resolve("disk-1.qcow2"); + Files.writeString(sourceDisk, "synced disk"); + VmwareCbtCutoverCommand command = createCommand(List.of(createDisk("disk-1", sourceDisk.toString()))); + command.setWait(1); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + VmwareCbtMigrationAnswer migrationAnswer = (VmwareCbtMigrationAnswer) answer; + Path relocatedPath = Path.of(migrationAnswer.getDiskResults().get(0).getTargetPath()); + Assert.assertEquals(poolRoot, relocatedPath.getParent()); + Assert.assertTrue(Files.exists(relocatedPath)); + Assert.assertFalse(Files.exists(sourceDisk)); + } + + @Test + public void testExecuteRunsVirtV2vCoreInPlaceOptionForSyncedQcow2Disks() throws IOException { + wrapper.finalizationMode = LibvirtVmwareCbtCutoverCommandWrapper.VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION; + VmwareCbtCutoverCommand command = createCommand(List.of(createDisk("disk-1", + temporaryFolder.newFile("disk-1.qcow2").getAbsolutePath()))); + command.setWait(1); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("virt-v2v --in-place conversion completed")); + Assert.assertTrue(wrapper.lastCommand.contains("virt-v2v --root first -i libvirtxml")); + Assert.assertTrue(wrapper.lastCommand.contains("--in-place -v")); + Assert.assertFalse(wrapper.lastCommand.contains("virt-v2v-in-place")); + Assert.assertFalse(wrapper.lastCommand.contains("-O ")); + Assert.assertEquals(1, ((VmwareCbtMigrationAnswer) answer).getDiskResults().size()); + } + + @Test + public void testExecuteRunsVirtV2vInPlaceForRawRbdDisks() { + VmwareCbtDiskTO disk = createDisk("disk-1", "cloudstack-cbt-migration-uuid-disk-1-disk-1", "raw"); + VmwareCbtCutoverCommand command = createCommand(List.of(disk)); + command.setTargetStorageType(VmwareCbtTargetStorageType.RBD_RAW); + command.setDestinationStoragePoolType(Storage.StoragePoolType.RBD); + command.setDestinationStoragePoolUuid("rbd-pool-uuid"); + command.setWait(1); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(wrapper.lastCommand.startsWith("bash '")); + Assert.assertTrue(wrapper.lastScript.contains("qemu-nbd --fork --persistent --shared=1 --format=raw")); + Assert.assertTrue(wrapper.lastSourceXml.contains("")); + Assert.assertTrue(wrapper.lastSourceXml.contains("")); + Assert.assertTrue(wrapper.lastSourceXml.contains("")); + Assert.assertTrue(wrapper.lastSourceXml.contains(" disks) { + return new VmwareCbtCutoverCommand("migration-uuid", createRemoteInstance(), disks, 2, true); + } + + private RemoteInstanceTO createRemoteInstance() { + return new RemoteInstanceTO("source-vm", null, "vcenter.example.com", "administrator@vsphere.local", + "password", "Datacenter", "Cluster", "esxi.example.com", "VirtualMachine:vm-1"); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath) { + return createDisk(diskId, targetPath, "qcow2"); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath, String targetFormat) { + return new VmwareCbtDiskTO(diskId, 2000, String.format("[%s] vm/%s.vmdk", diskId, diskId), + "datastore1", targetPath, targetFormat, "*", null, 8192); + } + + private static class TestLibvirtVmwareCbtCutoverCommandWrapper extends LibvirtVmwareCbtCutoverCommandWrapper { + private VirtV2vFinalizationMode finalizationMode = VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_BINARY; + private String lastCommand; + private String lastScript = ""; + private String lastSourceXml; + private int exitValue; + private String lastCommandOutput; + + @Override + protected VirtV2vFinalizationMode getVirtV2vFinalizationMode(LibvirtComputingResource serverResource) { + return finalizationMode; + } + + @Override + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + lastCommand = command; + lastScript = readScript(command); + String commandToInspect = lastScript.isEmpty() ? command : lastScript; + lastSourceXml = readSourceXml(commandToInspect); + if (exitValue == 0 && commandToInspect.contains("virt-v2v --root first")) { + createFallbackOutputDisk(commandToInspect); + } + return new VmwareCbtCommandResult(exitValue, lastCommandOutput); + } + + private void createFallbackOutputDisk(String command) { + String marker = "-os '"; + int start = command.indexOf(marker); + if (start < 0) { + return; + } + start += marker.length(); + int end = command.indexOf("'", start); + if (end < 0) { + return; + } + try { + Path outputDir = Path.of(command.substring(start, end)); + Files.createDirectories(outputDir); + Files.writeString(outputDir.resolve("migration-uuid-sda"), "converted disk"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String readSourceXml(String command) { + String marker = "-i libvirtxml '"; + int start = command.indexOf(marker); + if (start < 0) { + return ""; + } + start += marker.length(); + int end = command.indexOf("'", start); + if (end < 0) { + return ""; + } + try { + return Files.readString(Path.of(command.substring(start, end))); + } catch (IOException e) { + return ""; + } + } + + private String readScript(String command) { + String marker = "bash '"; + int start = command.indexOf(marker); + if (start < 0) { + return ""; + } + start += marker.length(); + int end = command.indexOf("'", start); + if (end < 0) { + return ""; + } + try { + return Files.readString(Path.of(command.substring(start, end))); + } catch (IOException e) { + return ""; + } + } + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapperTest.java new file mode 100644 index 000000000000..aeec9ae80fd2 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapperTest.java @@ -0,0 +1,134 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtPrepareCommand; +import com.cloud.agent.api.to.RemoteInstanceTO; +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.storage.Storage; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtVmwareCbtPrepareCommandWrapperTest { + + private static final String MIGRATION_UUID = "migration-uuid"; + + private final TestLibvirtVmwareCbtPrepareCommandWrapper wrapper = new TestLibvirtVmwareCbtPrepareCommandWrapper(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private KVMStoragePoolManager storagePoolManager; + @Mock + private KVMStoragePool storagePool; + @Mock + private KVMStoragePool rbdStoragePool; + + @Before + public void setUp() { + Mockito.when(libvirtComputingResource.hostSupportsVmwareCbtMigration(Mockito.nullable(String.class))).thenReturn(true); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.NetworkFilesystem, "pool-uuid")).thenReturn(storagePool); + Mockito.when(storagePool.getLocalPath()).thenReturn(temporaryFolder.getRoot().getAbsolutePath()); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(rbdStoragePool); + Mockito.when(rbdStoragePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(rbdStoragePool.getSourceDir()).thenReturn("cloudstack"); + Mockito.when(rbdStoragePool.getSourceHost()).thenReturn("10.0.0.10,10.0.0.11"); + Mockito.when(rbdStoragePool.getSourcePort()).thenReturn(6789); + } + + @Test + public void testExecuteReturnsLastCommandOutputOnInitialSyncFailure() { + wrapper.exitValue = 1; + wrapper.lastCommandOutput = "qemu-img: nbd+unix://?socket=/tmp/nbdkit/socket: error while reading at byte 1048576"; + VmwareCbtPrepareCommand command = createCommand(); + command.setVddkLibDir("/opt/vmware-vddk"); + command.setVddkThumbprint("AA:BB:CC"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("Last command output: qemu-img")); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("error while reading")); + } + + @Test + public void testExecuteCopiesInitialSyncDirectlyToRbdRawImage() { + VmwareCbtPrepareCommand command = createRbdCommand(); + command.setVddkLibDir("/opt/vmware-vddk"); + command.setVddkThumbprint("AA:BB:CC"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(wrapper.lastCommand, wrapper.lastCommand.matches("(?s).*qemu-img convert -f raw -O .*raw.*\\\"\\$uri\\\".*")); + Assert.assertTrue(wrapper.lastCommand.contains("rbd:cloudstack/cloudstack-cbt-migration-uuid-disk-1-disk-1")); + Assert.assertFalse(wrapper.lastCommand.contains("-O qcow2")); + } + + private VmwareCbtPrepareCommand createCommand() { + VmwareCbtDiskTO disk = new VmwareCbtDiskTO("disk-1", 2000, "[datastore1] vm/disk-1.vmdk", + "datastore1", null, "qcow2", "*", null, 8192); + return new VmwareCbtPrepareCommand(MIGRATION_UUID, createRemoteInstance(), List.of(disk), + Storage.StoragePoolType.NetworkFilesystem, "pool-uuid", VmwareCbtTargetStorageType.QCOW2_FILE, + "VirtualMachineSnapshot:snapshot-1"); + } + + private VmwareCbtPrepareCommand createRbdCommand() { + VmwareCbtDiskTO disk = new VmwareCbtDiskTO("disk-1", 2000, "[datastore1] vm/disk-1.vmdk", + "datastore1", null, "raw", "*", null, 8192); + return new VmwareCbtPrepareCommand(MIGRATION_UUID, createRemoteInstance(), List.of(disk), + Storage.StoragePoolType.RBD, "rbd-pool-uuid", VmwareCbtTargetStorageType.RBD_RAW, + "VirtualMachineSnapshot:snapshot-1"); + } + + private RemoteInstanceTO createRemoteInstance() { + return new RemoteInstanceTO("source-vm", null, "vcenter.example.com", "administrator@vsphere.local", + "password", "Datacenter", "Cluster", "esxi.example.com", "VirtualMachine:vm-1"); + } + + private static class TestLibvirtVmwareCbtPrepareCommandWrapper extends LibvirtVmwareCbtPrepareCommandWrapper { + private int exitValue; + private String lastCommandOutput; + private String lastCommand; + + @Override + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + lastCommand = command; + return new VmwareCbtCommandResult(exitValue, lastCommandOutput); + } + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapperTest.java new file mode 100644 index 000000000000..606d3e7ef287 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapperTest.java @@ -0,0 +1,131 @@ +// 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.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtRbdProbeCommand; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtVmwareCbtRbdProbeCommandWrapperTest { + + private static final String POOL_UUID = "rbd-pool-uuid"; + private static final String PROBE_IMAGE = "cloudstack-cbt-probe-11111111-2222-3333-4444-555555555555"; + + private final TestLibvirtVmwareCbtRbdProbeCommandWrapper wrapper = new TestLibvirtVmwareCbtRbdProbeCommandWrapper(); + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private KVMStoragePoolManager storagePoolManager; + @Mock + private KVMStoragePool rbdStoragePool; + + @Before + public void setUp() { + Mockito.when(libvirtComputingResource.getPrivateIp()).thenReturn("10.0.0.10"); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, POOL_UUID)).thenReturn(rbdStoragePool); + Mockito.when(rbdStoragePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(rbdStoragePool.getUuid()).thenReturn(POOL_UUID); + Mockito.when(rbdStoragePool.getSourceDir()).thenReturn("cloudstack"); + Mockito.when(rbdStoragePool.getSourceHost()).thenReturn("10.0.0.20,10.0.0.21"); + Mockito.when(rbdStoragePool.getSourcePort()).thenReturn(6789); + Mockito.when(rbdStoragePool.getAuthUserName()).thenReturn("cloudstack"); + Mockito.when(rbdStoragePool.getAuthSecret()).thenReturn("secret"); + } + + @Test + public void testExecuteCreatesWritesReadsAndCleansTemporaryRbdImage() { + Answer answer = wrapper.execute(createCommand(), libvirtComputingResource); + + Assert.assertTrue(answer.getDetails(), answer.getResult()); + Assert.assertEquals(2, wrapper.commands.size()); + Assert.assertTrue(wrapper.commands.get(0), wrapper.commands.get(0).contains("qemu-img create -f raw")); + Assert.assertTrue(wrapper.commands.get(0), wrapper.commands.get(0).contains("rbd:cloudstack/" + PROBE_IMAGE)); + Assert.assertTrue(wrapper.commands.get(1), wrapper.commands.get(1).contains("qemu-io -f raw")); + Assert.assertTrue(wrapper.cleanedImages.contains(PROBE_IMAGE)); + } + + @Test + public void testExecuteFailsForNonRbdCommand() { + VmwareCbtRbdProbeCommand command = new VmwareCbtRbdProbeCommand(Storage.StoragePoolType.NetworkFilesystem, + POOL_UUID, PROBE_IMAGE); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("requires an RBD destination storage pool")); + } + + @Test + public void testExecuteFailsForUnsafeProbeImageName() { + VmwareCbtRbdProbeCommand command = new VmwareCbtRbdProbeCommand(Storage.StoragePoolType.RBD, + POOL_UUID, "not-cloudstack-owned"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("probe image name must match")); + } + + @Test + public void testExecuteReportsQemuFailureAndStillAttemptsCleanup() { + wrapper.exitValue = 1; + + Answer answer = wrapper.execute(createCommand(), libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("qemu RBD probe create failed")); + Assert.assertTrue(wrapper.cleanedImages.contains(PROBE_IMAGE)); + } + + private VmwareCbtRbdProbeCommand createCommand() { + return new VmwareCbtRbdProbeCommand(Storage.StoragePoolType.RBD, POOL_UUID, PROBE_IMAGE); + } + + private static class TestLibvirtVmwareCbtRbdProbeCommandWrapper extends LibvirtVmwareCbtRbdProbeCommandWrapper { + private final List commands = new ArrayList<>(); + private final List cleanedImages = new ArrayList<>(); + private int exitValue; + + @Override + protected int runBashCommand(String command, long timeout) { + commands.add(command); + return exitValue; + } + + @Override + protected void cleanupProbeImage(KVMStoragePool targetPool, String probeImageName) { + cleanedImages.add(probeImageName); + } + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java new file mode 100644 index 000000000000..21b98a0a83b0 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java @@ -0,0 +1,215 @@ +// 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.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +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.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.storage.Storage; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtVmwareCbtSyncCommandWrapperTest { + + private final TestLibvirtVmwareCbtSyncCommandWrapper wrapper = new TestLibvirtVmwareCbtSyncCommandWrapper(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private KVMStoragePoolManager storagePoolManager; + @Mock + private KVMStoragePool rbdStoragePool; + + @Before + public void setUp() { + Mockito.when(libvirtComputingResource.hostSupportsVmwareCbtMigration(Mockito.nullable(String.class))).thenReturn(true); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolManager); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(rbdStoragePool); + Mockito.when(rbdStoragePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(rbdStoragePool.getSourceDir()).thenReturn("cloudstack"); + Mockito.when(rbdStoragePool.getSourceHost()).thenReturn("10.0.0.10"); + Mockito.when(rbdStoragePool.getSourcePort()).thenReturn(6789); + } + + @Test + public void testExecuteNoChangedBlocksReturnsReadyForCutover() { + VmwareCbtSyncCommand command = createCommand(Collections.emptyList(), Collections.emptyList()); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(((VmwareCbtMigrationAnswer) answer).getReadyForCutover()); + } + + @Test + public void testExecuteRejectsChangedBlocksWithoutTargetPath() { + VmwareCbtDiskTO disk = createDisk("disk-1", null, 8192); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("no target path")); + } + + @Test + public void testExecuteCopiesValidatedChangedBlocks() throws IOException { + VmwareCbtDiskTO disk = createDisk("disk-1", temporaryFolder.newFile("disk-1.qcow2").getAbsolutePath(), 8192); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + command.setVddkLibDir("/opt/vmware-vddk"); + command.setVddkThumbprint("AA:BB:CC"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertEquals(1024, ((VmwareCbtMigrationAnswer) answer).getChangedBytes()); + Assert.assertTrue(answer.getDetails().contains("copied 1 changed block range")); + Assert.assertTrue(wrapper.lastCommand.contains("nbdkit -r -U - vddk")); + Assert.assertTrue(wrapper.lastCommand.contains("export uri; bash ")); + Assert.assertTrue(wrapper.lastDiskSyncScript.contains("qemu-img convert --image-opts -O raw")); + Assert.assertTrue(wrapper.lastDiskSyncScript.contains("file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path")); + Assert.assertFalse(wrapper.lastDiskSyncScript.contains("qemu-img dd")); + Assert.assertFalse(wrapper.lastDiskSyncScript.contains("dd if=\"$source_nbd\"")); + } + + @Test + public void testExecuteWritesChangedBlocksToRawRbdTarget() { + VmwareCbtDiskTO disk = createDisk("disk-1", "cloudstack-cbt-migration-uuid-disk-1-disk-1", 8192, "raw"); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + command.setTargetStorageType(VmwareCbtTargetStorageType.RBD_RAW); + command.setDestinationStoragePoolType(Storage.StoragePoolType.RBD); + command.setDestinationStoragePoolUuid("rbd-pool-uuid"); + command.setVddkLibDir("/opt/vmware-vddk"); + command.setVddkThumbprint("AA:BB:CC"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(wrapper.lastDiskSyncScript.contains("target_format='raw'")); + Assert.assertTrue(wrapper.lastDiskSyncScript.contains("rbd:cloudstack/cloudstack-cbt-migration-uuid-disk-1-disk-1")); + Assert.assertTrue(wrapper.lastDiskSyncScript.contains("qemu-img convert --image-opts -O raw")); + Assert.assertTrue(wrapper.lastDiskSyncScript.contains("qemu-io -f \"$target_format\"")); + } + + @Test + public void testExecuteUsesCommandVddkLibDirOverrideForSupportCheck() throws IOException { + VmwareCbtDiskTO disk = createDisk("disk-1", temporaryFolder.newFile("disk-1.qcow2").getAbsolutePath(), 8192); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + command.setVddkLibDir("/opt/vmware-vddk/override"); + command.setVddkThumbprint("AA:BB:CC"); + Mockito.when(libvirtComputingResource.hostSupportsVmwareCbtMigration("/opt/vmware-vddk/override")) + .thenReturn(true); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("copied 1 changed block range")); + Mockito.verify(libvirtComputingResource).hostSupportsVmwareCbtMigration("/opt/vmware-vddk/override"); + } + + @Test + public void testExecuteReturnsLastCommandOutputOnDeltaFailure() throws IOException { + wrapper.exitValue = 1; + wrapper.lastCommandOutput = "qemu-nbd: Failed to blk_new_open 'nbd+unix://?socket=/tmp/nbdkit/socket': Could not open image: Permission denied"; + VmwareCbtDiskTO disk = createDisk("disk-1", temporaryFolder.newFile("disk-1.qcow2").getAbsolutePath(), 8192); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + command.setVddkLibDir("/opt/vmware-vddk"); + command.setVddkThumbprint("AA:BB:CC"); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("Last command output: qemu-nbd")); + Assert.assertTrue(answer.getDetails(), answer.getDetails().contains("Permission denied")); + } + + private VmwareCbtSyncCommand createCommand(List disks, List changedBlocks) { + return new VmwareCbtSyncCommand("migration-uuid", createRemoteInstance(), disks, changedBlocks, 1, + "VirtualMachineSnapshot:snapshot-1", false); + } + + private RemoteInstanceTO createRemoteInstance() { + return new RemoteInstanceTO("source-vm", null, "vcenter.example.com", "administrator@vsphere.local", + "password", "Datacenter", "Cluster", "esxi.example.com", "VirtualMachine:vm-1"); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath, long capacityBytes) { + return createDisk(diskId, targetPath, capacityBytes, "qcow2"); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath, long capacityBytes, String targetFormat) { + return new VmwareCbtDiskTO(diskId, 2000, String.format("[%s] vm/%s.vmdk", diskId, diskId), + "datastore1", targetPath, targetFormat, "*", null, capacityBytes); + } + + private static class TestLibvirtVmwareCbtSyncCommandWrapper extends LibvirtVmwareCbtSyncCommandWrapper { + private String lastCommand; + private String lastDiskSyncScript; + private int exitValue; + private String lastCommandOutput; + + @Override + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + lastCommand = command; + return new VmwareCbtCommandResult(exitValue, lastCommandOutput); + } + + @Override + protected String writeDiskSyncScript(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool) throws Exception { + String scriptPath = super.writeDiskSyncScript(cmd, diskPlan, targetPool); + lastDiskSyncScript = Files.readString(Path.of(scriptPath)); + return scriptPath; + } + + @Override + protected String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + return "AA:BB:CC"; + } + + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java new file mode 100644 index 000000000000..16cc6c4d2530 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java @@ -0,0 +1,98 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; + +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; + +public class VmwareCbtSyncPlanTest { + + @Test + public void testCreateGroupsChangedBlocksByDisk() { + VmwareCbtDiskTO disk1 = createDisk("disk-1", "/var/lib/libvirt/images/disk-1.qcow2", 8192); + VmwareCbtDiskTO disk2 = createDisk("disk-2", "/var/lib/libvirt/images/disk-2.qcow2", 8192); + + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(disk1, disk2), List.of( + new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024), + new VmwareCbtChangedBlockRangeTO("disk-1", 4096, 512), + new VmwareCbtChangedBlockRangeTO("disk-2", 2048, 2048))); + + Assert.assertTrue(syncPlan.isValid()); + Assert.assertEquals(3, syncPlan.getChangedRangeCount()); + Assert.assertEquals(3, syncPlan.getCopyRangeCount()); + Assert.assertEquals(3584, syncPlan.getChangedBytes()); + Assert.assertEquals(2, syncPlan.getDiskPlans().size()); + Assert.assertEquals(1536, syncPlan.getDiskPlans().get(0).getChangedBytes()); + Assert.assertEquals(2048, syncPlan.getDiskPlans().get(1).getChangedBytes()); + } + + @Test + public void testCreateCoalescesAdjacentAndOverlappingRanges() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", "/target", 8192)), List.of( + new VmwareCbtChangedBlockRangeTO("disk-1", 4096, 512), + new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024), + new VmwareCbtChangedBlockRangeTO("disk-1", 1024, 1024), + new VmwareCbtChangedBlockRangeTO("disk-1", 4352, 1024))); + + Assert.assertTrue(syncPlan.isValid()); + Assert.assertEquals(4, syncPlan.getChangedRangeCount()); + Assert.assertEquals(2, syncPlan.getCopyRangeCount()); + Assert.assertEquals(3328, syncPlan.getChangedBytes()); + Assert.assertEquals(2, syncPlan.getDiskPlans().get(0).getChangedBlocks().size()); + Assert.assertEquals(0, syncPlan.getDiskPlans().get(0).getChangedBlocks().get(0).getStartOffset()); + Assert.assertEquals(2048, syncPlan.getDiskPlans().get(0).getChangedBlocks().get(0).getLength()); + Assert.assertEquals(4096, syncPlan.getDiskPlans().get(0).getChangedBlocks().get(1).getStartOffset()); + Assert.assertEquals(1280, syncPlan.getDiskPlans().get(0).getChangedBlocks().get(1).getLength()); + } + + @Test + public void testCreateRejectsUnknownDisk() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", "/target", 8192)), + List.of(new VmwareCbtChangedBlockRangeTO("disk-2", 0, 1024))); + + Assert.assertFalse(syncPlan.isValid()); + Assert.assertTrue(syncPlan.getValidationError().contains("unknown disk disk-2")); + } + + @Test + public void testCreateRejectsMissingTargetPath() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", null, 8192)), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + + Assert.assertFalse(syncPlan.isValid()); + Assert.assertTrue(syncPlan.getValidationError().contains("no target path")); + } + + @Test + public void testCreateRejectsOutOfBoundsRange() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", "/target", 1024)), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 512, 1024))); + + Assert.assertFalse(syncPlan.isValid()); + Assert.assertTrue(syncPlan.getValidationError().contains("exceeds disk capacity")); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath, long capacityBytes) { + return new VmwareCbtDiskTO(diskId, 2000, String.format("[%s] vm/%s.vmdk", diskId, diskId), + "datastore1", targetPath, "qcow2", "*", null, capacityBytes); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePoolTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePoolTest.java index 88d4daa2dabc..ea5df8e741c4 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePoolTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePoolTest.java @@ -16,6 +16,9 @@ // under the License. package com.cloud.hypervisor.kvm.storage; +import java.io.File; +import java.nio.file.Files; + import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat; import org.junit.Test; import org.junit.runner.RunWith; @@ -98,4 +101,57 @@ public void testExternalSnapshot() { LibvirtStoragePool clvmPool = new LibvirtStoragePool(uuid, name, StoragePoolType.CLVM, adapter, storage); assertTrue(clvmPool.isExternalSnapshot()); } + + @Test + public void testResolvePhysicalDiskFileUsesNestedRelativePathInsidePool() throws Exception { + File poolPath = Files.createTempDirectory("libvirt-storage-pool").toFile(); + try { + LibvirtStoragePool pool = createNetworkFilesystemPool(); + String volumeUid = "cloudstack-cbt/migration/virt-v2v-output/disk.qcow2"; + File expectedVolume = new File(poolPath, volumeUid); + assertTrue(expectedVolume.getParentFile().mkdirs()); + assertTrue(expectedVolume.createNewFile()); + + File resolvedVolume = pool.resolvePhysicalDiskFile(poolPath.getAbsolutePath(), volumeUid, "disk.qcow2"); + + assertEquals(expectedVolume.getCanonicalPath(), resolvedVolume.getCanonicalPath()); + } finally { + deleteRecursively(poolPath); + } + } + + @Test + public void testResolvePhysicalDiskFileRejectsRelativePathOutsidePool() throws Exception { + File poolPath = Files.createTempDirectory("libvirt-storage-pool").toFile(); + try { + LibvirtStoragePool pool = createNetworkFilesystemPool(); + + File resolvedVolume = pool.resolvePhysicalDiskFile(poolPath.getAbsolutePath(), "../disk.qcow2", "disk.qcow2"); + + assertEquals(new File(poolPath, "disk.qcow2").getCanonicalPath(), resolvedVolume.getCanonicalPath()); + } finally { + deleteRecursively(poolPath); + } + } + + private LibvirtStoragePool createNetworkFilesystemPool() { + StorageAdaptor adapter = Mockito.mock(LibvirtStorageAdaptor.class); + StoragePool storage = Mockito.mock(StoragePool.class); + return new LibvirtStoragePool("pool-uuid", "pool-name", StoragePoolType.NetworkFilesystem, adapter, storage); + } + + private void deleteRecursively(File file) { + if (file == null || !file.exists()) { + return; + } + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + file.delete(); + } } diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java new file mode 100644 index 000000000000..f1ae96e34923 --- /dev/null +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java @@ -0,0 +1,561 @@ +// 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.vmware.manager; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.cloudstack.vm.VmwareCbtChangedBlockInfo; +import org.apache.cloudstack.vm.VmwareCbtChangedDiskInfo; +import org.apache.cloudstack.vm.VmwareCbtDiskInfo; +import org.apache.cloudstack.vm.VmwareCbtMigrationService; +import org.apache.cloudstack.vm.VmwareCbtPreflightDiskInfo; +import org.apache.cloudstack.vm.VmwareCbtPreflightInfo; +import org.apache.cloudstack.vm.VmwareCbtSnapshotInfo; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.hypervisor.vmware.mo.DatacenterMO; +import com.cloud.hypervisor.vmware.mo.HostMO; +import com.cloud.hypervisor.vmware.mo.VirtualMachineMO; +import com.cloud.hypervisor.vmware.mo.VmwareHypervisorHost; +import com.cloud.hypervisor.vmware.resource.VmwareContextFactory; +import com.cloud.hypervisor.vmware.util.VmwareContext; +import com.cloud.hypervisor.vmware.util.VmwareHelper; +import com.cloud.utils.exception.CloudRuntimeException; +import com.vmware.vim25.ManagedObjectReference; +import com.vmware.vim25.VirtualDevice; +import com.vmware.vim25.VirtualDeviceBackingInfo; +import com.vmware.vim25.VirtualDisk; +import com.vmware.vim25.VirtualMachineCapability; +import com.vmware.vim25.VirtualMachineConfigInfo; +import com.vmware.vim25.VirtualMachineConfigSpec; +import com.vmware.vim25.VirtualMachineConfigSummary; +import com.vmware.vim25.VirtualMachinePowerState; +import com.vmware.vim25.VirtualMachineRuntimeInfo; +import com.vmware.vim25.VirtualMachineSnapshotInfo; +import com.vmware.vim25.VirtualMachineSnapshotTree; + +public class VmwareCbtMigrationServiceImpl implements VmwareCbtMigrationService { + + private static final Logger LOGGER = LogManager.getLogger(VmwareCbtMigrationServiceImpl.class); + + @Override + public VmwareCbtPreflightInfo getPreflightInfo(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + VirtualMachineCapability capability = (VirtualMachineCapability)context.getVimClient() + .getDynamicProperty(lookup.vmMO.getMor(), "capability"); + VirtualMachineConfigInfo configInfo = lookup.vmMO.getConfigInfo(); + VirtualMachineConfigSummary configSummary = lookup.vmMO.getConfigSummary(); + VirtualMachineRuntimeInfo runtimeInfo = lookup.vmMO.getRuntimeInfo(); + VirtualMachineSnapshotInfo snapshotInfo = lookup.vmMO.getSnapshotInfo(); + UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(lookup.hyperHost, lookup.vmMO); + + return new VmwareCbtPreflightInfo(sourceVmName, getManagedObjectReferenceValue(lookup.vmMO.getMor()), + capability == null ? null : capability.isChangeTrackingSupported(), + configInfo == null ? null : configInfo.isChangeTrackingEnabled(), + runtimeInfo == null ? null : runtimeInfo.isConsolidationNeeded(), + countSnapshots(snapshotInfo), + toVmwareCbtPreflightDiskInfo(unmanagedInstance, collectDiskDeviceInfo(lookup.vmMO)), + configSummary == null ? null : configSummary.getNumCpu(), + configSummary == null ? null : configSummary.getCpuReservation(), + configSummary == null ? null : configSummary.getMemorySizeMB(), + unmanagedInstance == null ? null : unmanagedInstance.getOperatingSystemId(), + unmanagedInstance == null ? null : unmanagedInstance.getOperatingSystem()); + } catch (Exception e) { + String message = String.format("Unable to check VMware CBT prerequisites for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public List listSourceDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(lookup.hyperHost, lookup.vmMO); + return toVmwareCbtDiskInfo(unmanagedInstance, collectDiskDeviceInfo(lookup.vmMO)); + } catch (Exception e) { + String message = String.format("Unable to discover VMware CBT source disks for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public List listSnapshotDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotMor) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(lookup.hyperHost, lookup.vmMO); + ManagedObjectReference snapshot = toManagedObjectReference("VirtualMachineSnapshot", snapshotMor); + List snapshotDevices = context.getVimClient().getDynamicProperty(snapshot, + "config.hardware.device"); + return toVmwareCbtDiskInfo(unmanagedInstance, collectDiskDeviceInfo(snapshotDevices)); + } catch (Exception e) { + String message = String.format("Unable to discover VMware CBT snapshot disks for VM %s snapshot %s in vCenter %s: %s", + sourceVmName, snapshotMor, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public void ensureChangeTrackingEnabled(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ensureChangeTrackingEnabled(context, lookup.vmMO, sourceVmName); + } catch (Exception e) { + String message = String.format("Unable to enable VMware CBT for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public VmwareCbtSnapshotInfo createSnapshot(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotName, + String snapshotDescription, boolean quiesce) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ensureChangeTrackingEnabled(context, lookup.vmMO, sourceVmName); + ManagedObjectReference snapshot = lookup.vmMO.createSnapshotGetReference(snapshotName, + snapshotDescription, false, quiesce); + if (snapshot == null) { + throw new CloudRuntimeException(String.format("Unable to create VMware snapshot %s for VM %s", + snapshotName, sourceVmName)); + } + return new VmwareCbtSnapshotInfo(snapshotName, formatManagedObjectReference(snapshot), + getManagedObjectReferenceValue(lookup.vmMO.getMor())); + } catch (Exception e) { + String message = String.format("Unable to create VMware CBT snapshot for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public List queryChangedDiskAreas(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName, + List disks, String snapshotMor) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ManagedObjectReference snapshot = toManagedObjectReference("VirtualMachineSnapshot", snapshotMor); + List changedDisks = new ArrayList<>(); + if (CollectionUtils.isEmpty(disks)) { + return changedDisks; + } + for (VmwareCbtDiskInfo disk : disks) { + changedDisks.add(queryChangedDiskAreas(context, lookup.vmMO, snapshot, disk)); + } + return changedDisks; + } catch (Exception e) { + String message = String.format("Unable to query VMware CBT changed areas for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public UnmanagedInstanceTO.PowerState getPowerState(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + return toUnmanagedPowerState(lookup.vmMO.getPowerState()); + } catch (Exception e) { + String message = String.format("Unable to check VMware power state for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + @Override + public void removeSnapshot(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotMor) { + if (StringUtils.isBlank(snapshotMor)) { + return; + } + + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ManagedObjectReference snapshot = toManagedObjectReference("VirtualMachineSnapshot", snapshotMor); + ManagedObjectReference task = context.getService().removeSnapshotTask(snapshot, false, true); + if (!context.getVimClient().waitForTask(task)) { + throw new CloudRuntimeException(String.format("Unable to remove VMware snapshot %s for VM %s", + snapshotMor, sourceVmName)); + } + context.waitForTaskProgressDone(task); + } catch (Exception e) { + String message = String.format("Unable to remove VMware CBT snapshot %s for VM %s in vCenter %s: %s", + snapshotMor, sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + private VmwareVmLookup lookupVirtualMachine(VmwareContext context, String vcenter, String datacenterName, + String sourceHost, String sourceVmName) throws Exception { + DatacenterMO datacenterMO = new DatacenterMO(context, datacenterName); + if (datacenterMO.getMor() == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware datacenter %s in vCenter %s", + datacenterName, vcenter)); + } + + VmwareHypervisorHost hyperHost; + VirtualMachineMO vmMO; + if (StringUtils.isNotBlank(sourceHost)) { + ManagedObjectReference hostMor = datacenterMO.findHost(sourceHost); + if (hostMor == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware host %s in vCenter %s", + sourceHost, vcenter)); + } + HostMO hostMO = new HostMO(context, hostMor); + vmMO = hostMO.findVmOnHyperHost(sourceVmName); + hyperHost = hostMO; + } else { + vmMO = datacenterMO.findVm(sourceVmName); + hyperHost = vmMO != null ? vmMO.getRunningHost() : null; + } + + if (vmMO == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware VM %s in datacenter %s", + sourceVmName, datacenterName)); + } + return new VmwareVmLookup(hyperHost, vmMO); + } + + private List toVmwareCbtDiskInfo(UnmanagedInstanceTO unmanagedInstance, + Map diskDeviceInfo) { + List disks = new ArrayList<>(); + if (unmanagedInstance == null || CollectionUtils.isEmpty(unmanagedInstance.getDisks())) { + return disks; + } + + for (UnmanagedInstanceTO.Disk disk : unmanagedInstance.getDisks()) { + String sourceDiskId = StringUtils.defaultIfBlank(disk.getDiskId(), disk.getLabel()); + String sourceDiskPath = StringUtils.defaultIfBlank(disk.getImagePath(), disk.getFileBaseName()); + DiskDeviceInfo deviceInfo = diskDeviceInfo.get(sourceDiskId); + if (deviceInfo == null) { + deviceInfo = diskDeviceInfo.get(sourceDiskPath); + } + if (deviceInfo == null) { + deviceInfo = diskDeviceInfo.get(disk.getLabel()); + } + disks.add(new VmwareCbtDiskInfo(sourceDiskId, deviceInfo != null ? deviceInfo.deviceKey : null, + disk.getLabel(), sourceDiskPath, disk.getDatastoreName(), disk.getCapacity(), + deviceInfo != null ? deviceInfo.changeId : null)); + } + return disks; + } + + private List toVmwareCbtPreflightDiskInfo(UnmanagedInstanceTO unmanagedInstance, + Map diskDeviceInfo) { + List disks = new ArrayList<>(); + if (unmanagedInstance == null || CollectionUtils.isEmpty(unmanagedInstance.getDisks())) { + return disks; + } + + for (UnmanagedInstanceTO.Disk disk : unmanagedInstance.getDisks()) { + String sourceDiskId = StringUtils.defaultIfBlank(disk.getDiskId(), disk.getLabel()); + String sourceDiskPath = StringUtils.defaultIfBlank(disk.getImagePath(), disk.getFileBaseName()); + DiskDeviceInfo deviceInfo = diskDeviceInfo.get(sourceDiskId); + if (deviceInfo == null) { + deviceInfo = diskDeviceInfo.get(sourceDiskPath); + } + if (deviceInfo == null) { + deviceInfo = diskDeviceInfo.get(disk.getLabel()); + } + disks.add(new VmwareCbtPreflightDiskInfo(sourceDiskId, deviceInfo != null ? deviceInfo.deviceKey : null, + disk.getLabel(), sourceDiskPath, disk.getDatastoreName(), disk.getCapacity(), + deviceInfo != null ? deviceInfo.changeId : null, + deviceInfo != null ? deviceInfo.backingType : null, + deviceInfo != null ? deviceInfo.diskMode : null, + deviceInfo != null ? deviceInfo.rdmCompatibilityMode : null, + deviceInfo != null && deviceInfo.independentDisk, + deviceInfo != null && deviceInfo.physicalRdm)); + } + return disks; + } + + private Map collectDiskDeviceInfo(VirtualMachineMO vmMO) throws Exception { + VirtualDisk[] disks = vmMO.getAllDiskDevice(); + return collectDiskDeviceInfo(disks == null ? null : Arrays.asList(disks)); + } + + private Map collectDiskDeviceInfo(List devices) { + Map diskDeviceInfo = new HashMap<>(); + if (devices == null) { + return diskDeviceInfo; + } + + for (VirtualDevice device : devices) { + if (!(device instanceof VirtualDisk)) { + continue; + } + VirtualDisk disk = (VirtualDisk) device; + VirtualDeviceBackingInfo backing = disk.getBacking(); + String diskMode = getBackingStringValue(backing, "getDiskMode"); + String rdmCompatibilityMode = getBackingStringValue(backing, "getCompatibilityMode"); + DiskDeviceInfo deviceInfo = new DiskDeviceInfo(disk.getKey(), getBackingStringValue(backing, "getChangeId"), + backing == null ? null : backing.getClass().getSimpleName(), diskMode, rdmCompatibilityMode, + StringUtils.containsIgnoreCase(diskMode, "independent"), + StringUtils.containsIgnoreCase(rdmCompatibilityMode, "physical")); + if (StringUtils.isNotBlank(disk.getDiskObjectId())) { + diskDeviceInfo.put(disk.getDiskObjectId(), deviceInfo); + } + diskDeviceInfo.put(String.valueOf(disk.getKey()), deviceInfo); + if (disk.getDeviceInfo() != null && StringUtils.isNotBlank(disk.getDeviceInfo().getLabel())) { + diskDeviceInfo.put(disk.getDeviceInfo().getLabel(), deviceInfo); + } + String fileName = getBackingStringValue(backing, "getFileName"); + if (StringUtils.isNotBlank(fileName)) { + diskDeviceInfo.put(fileName, deviceInfo); + } + } + return diskDeviceInfo; + } + + private int countSnapshots(VirtualMachineSnapshotInfo snapshotInfo) { + if (snapshotInfo == null || CollectionUtils.isEmpty(snapshotInfo.getRootSnapshotList())) { + return 0; + } + return countSnapshotTrees(snapshotInfo.getRootSnapshotList()); + } + + private int countSnapshotTrees(List snapshotTrees) { + int count = 0; + for (VirtualMachineSnapshotTree snapshotTree : snapshotTrees) { + count++; + if (CollectionUtils.isNotEmpty(snapshotTree.getChildSnapshotList())) { + count += countSnapshotTrees(snapshotTree.getChildSnapshotList()); + } + } + return count; + } + + private UnmanagedInstanceTO.PowerState toUnmanagedPowerState(VirtualMachinePowerState powerState) { + if (powerState == VirtualMachinePowerState.POWERED_OFF) { + return UnmanagedInstanceTO.PowerState.PowerOff; + } + if (powerState == VirtualMachinePowerState.POWERED_ON) { + return UnmanagedInstanceTO.PowerState.PowerOn; + } + return UnmanagedInstanceTO.PowerState.PowerUnknown; + } + + private VmwareCbtChangedDiskInfo queryChangedDiskAreas(VmwareContext context, VirtualMachineMO vmMO, + ManagedObjectReference snapshot, VmwareCbtDiskInfo disk) + throws ReflectiveOperationException { + if (disk.getSourceDiskDeviceKey() == null) { + throw new CloudRuntimeException(String.format("VMware disk device key is missing for source disk %s", + disk.getSourceDiskId())); + } + if (StringUtils.isBlank(disk.getChangeId())) { + throw new CloudRuntimeException(String.format("VMware CBT change ID is missing for source disk %s", + disk.getSourceDiskId())); + } + + List changedBlocks = new ArrayList<>(); + long startOffset = 0L; + long capacityBytes = disk.getCapacityBytes() == null ? 0L : disk.getCapacityBytes(); + String nextChangeId = null; + + do { + Object diskChangeInfo = invokeQueryChangedDiskAreas(context, vmMO, snapshot, disk, startOffset); + nextChangeId = StringUtils.defaultIfBlank(getObjectStringValue(diskChangeInfo, "getChangeId"), + nextChangeId); + for (Object changedArea : getObjectListValue(diskChangeInfo, "getChangedArea")) { + Long areaStart = getObjectLongValue(changedArea, "getStart"); + Long areaLength = getObjectLongValue(changedArea, "getLength"); + if (areaStart != null && areaLength != null && areaLength > 0L) { + changedBlocks.add(new VmwareCbtChangedBlockInfo(areaStart, areaLength)); + } + } + + Long responseStart = getObjectLongValue(diskChangeInfo, "getStartOffset"); + Long responseLength = getObjectLongValue(diskChangeInfo, "getLength"); + if (responseStart == null || responseLength == null || responseLength <= 0L) { + break; + } + startOffset = responseStart + responseLength; + } while (capacityBytes > 0L && startOffset < capacityBytes); + + return new VmwareCbtChangedDiskInfo(disk.getSourceDiskId(), nextChangeId, changedBlocks); + } + + private Object invokeQueryChangedDiskAreas(VmwareContext context, VirtualMachineMO vmMO, + ManagedObjectReference snapshot, VmwareCbtDiskInfo disk, + long startOffset) throws ReflectiveOperationException { + Method method = context.getService().getClass().getMethod("queryChangedDiskAreas", + ManagedObjectReference.class, ManagedObjectReference.class, int.class, long.class, String.class); + return method.invoke(context.getService(), vmMO.getMor(), snapshot, disk.getSourceDiskDeviceKey(), + startOffset, disk.getChangeId()); + } + + private void ensureChangeTrackingEnabled(VmwareContext context, VirtualMachineMO vmMO, String sourceVmName) + throws Exception { + VirtualMachineCapability capability = (VirtualMachineCapability)context.getVimClient() + .getDynamicProperty(vmMO.getMor(), "capability"); + if (capability != null && BooleanUtils.isFalse(capability.isChangeTrackingSupported())) { + throw new CloudRuntimeException(String.format("VMware CBT is not supported for VM %s", sourceVmName)); + } + + VirtualMachineConfigInfo configInfo = vmMO.getConfigInfo(); + if (configInfo != null && BooleanUtils.isTrue(configInfo.isChangeTrackingEnabled())) { + return; + } + + VirtualMachineConfigSpec configSpec = new VirtualMachineConfigSpec(); + configSpec.setChangeTrackingEnabled(true); + if (!vmMO.configureVm(configSpec)) { + throw new CloudRuntimeException(String.format("Unable to enable VMware CBT for VM %s", sourceVmName)); + } + } + + private ManagedObjectReference toManagedObjectReference(String defaultType, String mor) { + if (StringUtils.isBlank(mor)) { + return null; + } + + ManagedObjectReference reference = new ManagedObjectReference(); + if (mor.contains(":")) { + String[] parts = mor.split(":", 2); + reference.setType(parts[0]); + reference.setValue(parts[1]); + } else { + reference.setType(defaultType); + reference.setValue(mor); + } + return reference; + } + + private String formatManagedObjectReference(ManagedObjectReference mor) { + if (mor == null) { + return null; + } + return String.format("%s:%s", mor.getType(), mor.getValue()); + } + + private String getManagedObjectReferenceValue(ManagedObjectReference mor) { + return mor == null ? null : mor.getValue(); + } + + private String getBackingStringValue(Object backing, String methodName) { + if (backing == null) { + return null; + } + + try { + Method method = backing.getClass().getMethod(methodName); + Object value = method.invoke(backing); + return value != null ? value.toString() : null; + } catch (ReflectiveOperationException e) { + return null; + } + } + + private String getObjectStringValue(Object object, String methodName) { + Object value = invokeGetter(object, methodName); + return value != null ? value.toString() : null; + } + + private Long getObjectLongValue(Object object, String methodName) { + Object value = invokeGetter(object, methodName); + if (value instanceof Number) { + return ((Number)value).longValue(); + } + return null; + } + + private List getObjectListValue(Object object, String methodName) { + Object value = invokeGetter(object, methodName); + if (value instanceof List) { + return (List)value; + } + return new ArrayList<>(); + } + + private Object invokeGetter(Object object, String methodName) { + if (object == null) { + return null; + } + + try { + Method method = object.getClass().getMethod(methodName); + return method.invoke(object); + } catch (ReflectiveOperationException e) { + return null; + } + } + + private static class DiskDeviceInfo { + private final Integer deviceKey; + private final String changeId; + private final String backingType; + private final String diskMode; + private final String rdmCompatibilityMode; + private final boolean independentDisk; + private final boolean physicalRdm; + + private DiskDeviceInfo(Integer deviceKey, String changeId, String backingType, String diskMode, + String rdmCompatibilityMode, boolean independentDisk, boolean physicalRdm) { + this.deviceKey = deviceKey; + this.changeId = changeId; + this.backingType = backingType; + this.diskMode = diskMode; + this.rdmCompatibilityMode = rdmCompatibilityMode; + this.independentDisk = independentDisk; + this.physicalRdm = physicalRdm; + } + } + + private static class VmwareVmLookup { + private final VmwareHypervisorHost hyperHost; + private final VirtualMachineMO vmMO; + + private VmwareVmLookup(VmwareHypervisorHost hyperHost, VirtualMachineMO vmMO) { + this.hyperHost = hyperHost; + this.vmMO = vmMO; + } + } +} diff --git a/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml b/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml index d955ede30254..28825e987dd8 100644 --- a/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml +++ b/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml @@ -29,6 +29,8 @@ + , Integer> listGuestOSMappingByCrit if (osDisplayName != null) { List guestOSVOS = _guestOSDao.listLikeDisplayName(osDisplayName); - if (CollectionUtils.isNotEmpty(guestOSVOS)) { - List guestOSids = guestOSVOS.stream().map(mo -> mo.getId()).collect(Collectors.toList()); - sc.addAnd(guestOsId, SearchCriteria.Op.IN, guestOSids.toArray()); + if (CollectionUtils.isEmpty(guestOSVOS)) { + return new Pair<>(Collections.emptyList(), 0); } + List guestOSids = guestOSVOS.stream().map(mo -> mo.getId()).collect(Collectors.toList()); + sc.addAnd(guestOsId, SearchCriteria.Op.IN, guestOSids.toArray()); } final Pair, Integer> result = _guestOSHypervisorDao.searchAndCount(sc, searchFilter); diff --git a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java index 840a1de45962..01864dfdd920 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java @@ -515,6 +515,27 @@ private Map getNicIpAddresses(final List> getRootAn private void checkUnmanagedDiskAndOfferingForImport(String instanceName, UnmanagedInstanceTO.Disk disk, DiskOffering diskOffering, ServiceOffering serviceOffering, final Account owner, final DataCenter zone, final Cluster cluster, final boolean migrateAllowed, List reservations) throws ServerApiException, PermissionDeniedException, ResourceAllocationException { + checkUnmanagedDiskAndOfferingForImport(instanceName, disk, diskOffering, serviceOffering, owner, zone, cluster, migrateAllowed, reservations, null); + } + + private void checkUnmanagedDiskAndOfferingForImport(String instanceName, UnmanagedInstanceTO.Disk disk, DiskOffering diskOffering, ServiceOffering serviceOffering, final Account owner, final DataCenter zone, final Cluster cluster, final boolean migrateAllowed, List reservations, Long storagePoolId) + throws ServerApiException, PermissionDeniedException, ResourceAllocationException { if (serviceOffering == null && diskOffering == null) { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, String.format("Disk offering for disk ID [%s] not found during VM [%s] import.", disk.getDiskId(), instanceName)); } @@ -602,7 +628,7 @@ private void checkUnmanagedDiskAndOfferingForImport(String instanceName, Unmanag throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, String.format("Size of disk offering(ID: %s) %dGB is found less than the size of disk(ID: %s) %dGB during VM import", diskOffering.getUuid(), (diskOffering.getDiskSize() / Resource.ResourceType.bytesToGiB), disk.getDiskId(), (disk.getCapacity() / (Resource.ResourceType.bytesToGiB)))); } diskOffering = diskOffering != null ? diskOffering : diskOfferingDao.findById(serviceOffering.getDiskOfferingId()); - StoragePool storagePool = getStoragePool(disk, zone, cluster, diskOffering); + StoragePool storagePool = getStoragePool(disk, zone, cluster, diskOffering, storagePoolId); if (diskOffering != null && !migrateAllowed && !storagePoolSupportsDiskOffering(storagePool, diskOffering)) { throw new InvalidParameterValueException(String.format("Disk offering: %s is not compatible with storage pool: %s of unmanaged disk: %s", diskOffering.getUuid(), storagePool.getUuid(), disk.getDiskId())); } @@ -611,6 +637,11 @@ private void checkUnmanagedDiskAndOfferingForImport(String instanceName, Unmanag private void checkUnmanagedDiskAndOfferingForImport(String intanceName, List disks, final Map diskOfferingMap, final Account owner, final DataCenter zone, final Cluster cluster, final boolean migrateAllowed, List reservations) throws ServerApiException, PermissionDeniedException, ResourceAllocationException { + checkUnmanagedDiskAndOfferingForImport(intanceName, disks, diskOfferingMap, owner, zone, cluster, migrateAllowed, reservations, null); + } + + private void checkUnmanagedDiskAndOfferingForImport(String intanceName, List disks, final Map diskOfferingMap, final Account owner, final DataCenter zone, final Cluster cluster, final boolean migrateAllowed, List reservations, Long storagePoolId) + throws ServerApiException, PermissionDeniedException, ResourceAllocationException { String diskController = null; for (UnmanagedInstanceTO.Disk disk : disks) { if (disk == null) { @@ -626,7 +657,7 @@ private void checkUnmanagedDiskAndOfferingForImport(String intanceName, List importKVMSharedDisk(VirtualMachine vm, Di private Pair importDisk(UnmanagedInstanceTO.Disk disk, VirtualMachine vm, Cluster cluster, DiskOffering diskOffering, Volume.Type type, String name, Long diskSize, Long minIops, Long maxIops, VirtualMachineTemplate template, Account owner, Long deviceId) { + return importDisk(disk, vm, cluster, diskOffering, type, name, diskSize, minIops, maxIops, template, owner, deviceId, null); + } + + private Pair importDisk(UnmanagedInstanceTO.Disk disk, VirtualMachine vm, Cluster cluster, DiskOffering diskOffering, + Volume.Type type, String name, Long diskSize, Long minIops, Long maxIops, VirtualMachineTemplate template, + Account owner, Long deviceId, Long storagePoolId) { final DataCenter zone = dataCenterDao.findById(vm.getDataCenterId()); final String path = StringUtils.isEmpty(disk.getFileBaseName()) ? disk.getImagePath() : disk.getFileBaseName(); String chainInfo = disk.getChainInfo(); @@ -841,7 +878,7 @@ private Pair importDisk(UnmanagedInstanceTO.Disk disk, diskInfo.setDiskChain(new String[]{disk.getImagePath()}); chainInfo = gson.toJson(diskInfo); } - StoragePool storagePool = getStoragePool(disk, zone, cluster, diskOffering); + StoragePool storagePool = getStoragePool(disk, zone, cluster, diskOffering, storagePoolId); DiskProfile profile = volumeManager.importVolume(type, name, diskOffering, diskSize, minIops, maxIops, vm.getDataCenterId(), vm.getHypervisorType(), vm, template, owner, deviceId, storagePool.getId(), storagePool.getPoolType(), path, chainInfo); @@ -1058,6 +1095,17 @@ private UserVm importVirtualMachineInternal(final UnmanagedInstanceTO unmanagedI final ServiceOfferingVO serviceOffering, final Map dataDiskOfferingMap, final Map nicNetworkMap, final Map callerNicIpAddressMap, final Long guestOsId, final Map details, final boolean migrateAllowed, final boolean forced, final boolean isImportUnmanagedFromSameHypervisor) { + return importVirtualMachineInternal(unmanagedInstance, instanceNameInternal, zone, cluster, host, template, displayName, hostName, caller, owner, + userId, serviceOffering, dataDiskOfferingMap, nicNetworkMap, callerNicIpAddressMap, guestOsId, details, migrateAllowed, + forced, isImportUnmanagedFromSameHypervisor, null); + } + + private UserVm importVirtualMachineInternal(final UnmanagedInstanceTO unmanagedInstance, final String instanceNameInternal, final DataCenter zone, final Cluster cluster, final HostVO host, + final VirtualMachineTemplate template, final String displayName, final String hostName, final Account caller, final Account owner, final Long userId, + final ServiceOfferingVO serviceOffering, final Map dataDiskOfferingMap, + final Map nicNetworkMap, final Map callerNicIpAddressMap, final Long guestOsId, + final Map details, final boolean migrateAllowed, final boolean forced, final boolean isImportUnmanagedFromSameHypervisor, + final Long storagePoolId) { logger.debug(LogUtils.logGsonWithoutException("Trying to import VM [%s] with name [%s], in zone [%s], cluster [%s], and host [%s], using template [%s], service offering [%s], disks map [%s], NICs map [%s] and details [%s].", unmanagedInstance, displayName, zone, cluster, host, template, serviceOffering, dataDiskOfferingMap, nicNetworkMap, details)); UserVm userVm = null; @@ -1112,9 +1160,9 @@ private UserVm importVirtualMachineInternal(final UnmanagedInstanceTO unmanagedI List reservations = new ArrayList<>(); try { - checkUnmanagedDiskAndOfferingForImport(unmanagedInstance.getName(), rootDisk, null, validatedServiceOffering, owner, zone, cluster, migrateAllowed, reservations); + checkUnmanagedDiskAndOfferingForImport(unmanagedInstance.getName(), rootDisk, null, validatedServiceOffering, owner, zone, cluster, migrateAllowed, reservations, storagePoolId); if (CollectionUtils.isNotEmpty(dataDisks)) { // Data disk(s) present - checkUnmanagedDiskAndOfferingForImport(unmanagedInstance.getName(), dataDisks, dataDiskOfferingMap, owner, zone, cluster, migrateAllowed, reservations); + checkUnmanagedDiskAndOfferingForImport(unmanagedInstance.getName(), dataDisks, dataDiskOfferingMap, owner, zone, cluster, migrateAllowed, reservations, storagePoolId); allDetails.put(VmDetailConstants.DATA_DISK_CONTROLLER, dataDisks.get(0).getController()); } @@ -1165,7 +1213,7 @@ private UserVm importVirtualMachineInternal(final UnmanagedInstanceTO unmanagedI } DiskOfferingVO diskOffering = diskOfferingDao.findById(serviceOffering.getDiskOfferingId()); diskProfileStoragePoolList.add(importDisk(rootDisk, userVm, cluster, diskOffering, Volume.Type.ROOT, String.format("ROOT-%d", userVm.getId()), - rootDisk.getCapacity(), minIops, maxIops, template, owner, null)); + rootDisk.getCapacity(), minIops, maxIops, template, owner, null, storagePoolId)); long deviceId = 1L; for (UnmanagedInstanceTO.Disk disk : dataDisks) { if (disk.getCapacity() == null || disk.getCapacity() == 0) { @@ -1174,7 +1222,7 @@ private UserVm importVirtualMachineInternal(final UnmanagedInstanceTO unmanagedI DiskOffering offering = diskOfferingDao.findById(dataDiskOfferingMap.get(disk.getDiskId())); diskProfileStoragePoolList.add(importDisk(disk, userVm, cluster, offering, Volume.Type.DATADISK, String.format("DATA-%d-%s", userVm.getId(), disk.getDiskId()), disk.getCapacity(), offering.getMinIops(), offering.getMaxIops(), - template, owner, deviceId)); + template, owner, deviceId, storagePoolId)); deviceId++; } } catch (Exception e) { @@ -1595,12 +1643,13 @@ protected void checkVmResourceLimitsForUnmanagedInstanceImport(Account owner, Un private Pair getSourceVmwareUnmanagedInstance(String vcenter, String datacenterName, String username, String password, String clusterName, String sourceHostName, - String sourceVM, ServiceOfferingVO serviceOffering) { + String sourceVM, ServiceOfferingVO serviceOffering, + Map details) { HypervisorGuru vmwareGuru = hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.VMware); Map params = createParamsForTemplateFromVmwareVmMigration(vcenter, datacenterName, username, password, clusterName, sourceHostName, sourceVM); - addServiceOfferingDetailsToParams(params, serviceOffering); + addServiceOfferingDetailsToParams(params, serviceOffering, details); return vmwareGuru.getHypervisorVMOutOfBandAndCloneIfRequired(sourceHostName, sourceVM, params); } @@ -1611,22 +1660,33 @@ private Pair getSourceVmwareUnmanagedInstance(Stri * @param serviceOffering service offering for the converted VM */ protected void addServiceOfferingDetailsToParams(Map params, ServiceOfferingVO serviceOffering) { + addServiceOfferingDetailsToParams(params, serviceOffering, null); + } + + protected void addServiceOfferingDetailsToParams(Map params, ServiceOfferingVO serviceOffering, Map callerDetails) { if (serviceOffering != null) { serviceOfferingDao.loadDetails(serviceOffering); Map serviceOfferingDetails = serviceOffering.getDetails(); + Map details = MapUtils.isEmpty(callerDetails) ? new HashMap<>() : callerDetails; if (serviceOffering.getCpu() != null) { params.put(VmDetailConstants.CPU_NUMBER, String.valueOf(serviceOffering.getCpu())); + } else if (details.containsKey(VmDetailConstants.CPU_NUMBER)) { + params.put(VmDetailConstants.CPU_NUMBER, details.get(VmDetailConstants.CPU_NUMBER)); } else if (MapUtils.isNotEmpty(serviceOfferingDetails) && serviceOfferingDetails.containsKey(ApiConstants.MIN_CPU_NUMBER)) { params.put(VmDetailConstants.CPU_NUMBER, serviceOfferingDetails.get(ApiConstants.MIN_CPU_NUMBER)); } if (serviceOffering.getSpeed() != null) { params.put(VmDetailConstants.CPU_SPEED, String.valueOf(serviceOffering.getSpeed())); + } else if (details.containsKey(VmDetailConstants.CPU_SPEED)) { + params.put(VmDetailConstants.CPU_SPEED, details.get(VmDetailConstants.CPU_SPEED)); } if (serviceOffering.getRamSize() != null) { params.put(VmDetailConstants.MEMORY, String.valueOf(serviceOffering.getRamSize())); + } else if (details.containsKey(VmDetailConstants.MEMORY)) { + params.put(VmDetailConstants.MEMORY, details.get(VmDetailConstants.MEMORY)); } else if (MapUtils.isNotEmpty(serviceOfferingDetails) && serviceOfferingDetails.containsKey(ApiConstants.MIN_MEMORY)) { params.put(VmDetailConstants.MEMORY, serviceOfferingDetails.get(ApiConstants.MIN_MEMORY)); } @@ -1664,7 +1724,12 @@ protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster boolean forceConvertToPool = cmd.getForceConvertToPool(); Long guestOsId = cmd.getGuestOsId(); boolean forceMsToImportVmFiles = Boolean.TRUE.equals(cmd.getForceMsToImportVmFiles()); - boolean useVddk = cmd.getUseVddk(); + ImportVmCmd.VmwareMigrationMode vmwareMigrationMode = getVmwareMigrationMode(cmd, cmd.getUseVddk()); + if (ImportVmCmd.VmwareMigrationMode.CBT == vmwareMigrationMode) { + throw new ServerApiException(ApiErrorCode.UNSUPPORTED_ACTION_ERROR, + "VMware CBT warm migration is not executable yet. Use OVF or VDDK migration mode until CBT replication support is implemented."); + } + boolean useVddk = ImportVmCmd.VmwareMigrationMode.VDDK == vmwareMigrationMode; if ((existingVcenterId == null && vcenter == null) || (existingVcenterId != null && vcenter != null)) { throw new ServerApiException(ApiErrorCode.PARAM_ERROR, @@ -1731,7 +1796,7 @@ protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster // sourceVMwareInstance could be a cloned instance from sourceVMName, of the sourceVMName itself if its powered off. // isClonedInstance indicates if the VM is a clone of sourceVMName - Pair sourceInstanceDetails = getSourceVmwareUnmanagedInstance(vcenter, datacenterName, username, password, clusterName, sourceHostName, sourceVMName, serviceOffering); + Pair sourceInstanceDetails = getSourceVmwareUnmanagedInstance(vcenter, datacenterName, username, password, clusterName, sourceHostName, sourceVMName, serviceOffering, details); sourceVMwareInstance = sourceInstanceDetails.first(); isClonedInstance = sourceInstanceDetails.second(); @@ -1779,7 +1844,9 @@ protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster return userVm; } catch (CloudRuntimeException e) { logger.error(String.format("Error importing VM: %s", e.getMessage()), e); - importVmTasksManager.updateImportVMTaskErrorState(importVMTask, ImportVmTask.TaskState.Failed, e.getMessage()); + if (importVMTask != null) { + importVmTasksManager.updateImportVMTaskErrorState(importVMTask, ImportVmTask.TaskState.Failed, e.getMessage()); + } ActionEventUtils.onCompletedActionEvent(userId, owner.getId(), EventVO.LEVEL_ERROR, EventTypes.EVENT_VM_IMPORT, cmd.getEventDescription(), null, null, 0); throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); @@ -1794,6 +1861,62 @@ protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster } } + @Override + public 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) { + VMTemplateVO template = getTemplateForImportInstance(templateId, Hypervisor.HypervisorType.KVM); + ServiceOfferingVO serviceOffering = getServiceOfferingForImportInstance(serviceOfferingId, owner, zone); + String resolvedDisplayName = getDisplayNameForImportInstance(displayName, sourceVMName); + String resolvedHostName = getHostNameForImportInstance(hostName, Hypervisor.HypervisorType.KVM, + sourceVMName, resolvedDisplayName); + checkVmwareInstanceNameForImportInstance(Hypervisor.HypervisorType.KVM, sourceVMName, resolvedHostName, zone); + + boolean isClonedInstance = false; + UnmanagedInstanceTO sourceVMwareInstance = null; + List reservations = new ArrayList<>(); + try { + Pair sourceInstanceDetails = getSourceVmwareUnmanagedInstance(vcenter, datacenterName, + username, password, clusterName, sourceHostName, sourceVMName, serviceOffering, details); + sourceVMwareInstance = sourceInstanceDetails.first(); + isClonedInstance = sourceInstanceDetails.second(); + + checkVmResourceLimitsForUnmanagedInstanceImport(owner, sourceVMwareInstance, serviceOffering, template, reservations); + checkNetworkingBeforeConvertingVmwareInstance(zone, owner, resolvedDisplayName, resolvedHostName, + sourceVMwareInstance, nicNetworkMap, nicIpAddressMap, forced); + + sanitizeConvertedInstance(convertedInstance, sourceVMwareInstance); + return importVirtualMachineInternal(convertedInstance, null, zone, destinationCluster, null, template, + resolvedDisplayName, resolvedHostName, caller, owner, userId, serviceOffering, dataDiskOfferingMap, + nicNetworkMap, nicIpAddressMap, guestOsId, details, false, forced, false, storagePoolId); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } catch (ResourceAllocationException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, StringUtils.defaultString(e.getMessage())); + } finally { + if (isClonedInstance && sourceVMwareInstance != null) { + removeClonedInstance(vcenter, datacenterName, username, password, sourceHostName, + sourceVMwareInstance.getName(), sourceVMName); + } + ReservationHelper.closeAll(reservations); + } + } + + protected ImportVmCmd.VmwareMigrationMode getVmwareMigrationMode(ImportVmCmd cmd, boolean useVddkFallback) { + try { + return ImportVmCmd.VmwareMigrationMode.fromValue(cmd.getVmwareMigrationMode(), useVddkFallback); + } catch (IllegalArgumentException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } + } + /** * Check whether the conversion storage pool exists and is suitable for the conversion or not. * Secondary storage is only allowed when forceConvertToPool is false. diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java new file mode 100644 index 000000000000..f6fd7d5c7ff7 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.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.vm; + +public class VmwareCbtMigrationCutoverPolicy { + + public enum Decision { + CONTINUE, + READY_FOR_CUTOVER, + READY_FOR_CUTOVER_MAX_CYCLES + } + + private final int minCycles; + private final int maxCycles; + private final int quietCyclesRequired; + private final long quietDirtyBytesThreshold; + private final long quietDirtyRateBytesPerSecondThreshold; + + public VmwareCbtMigrationCutoverPolicy(int minCycles, int maxCycles, int quietCyclesRequired, + long quietDirtyBytesThreshold, long quietDirtyRateBytesPerSecondThreshold) { + if (minCycles < 1) { + throw new IllegalArgumentException("Minimum CBT migration cycles must be at least 1"); + } + if (maxCycles < minCycles) { + throw new IllegalArgumentException("Maximum CBT migration cycles must be greater than or equal to minimum cycles"); + } + if (quietCyclesRequired < 1) { + throw new IllegalArgumentException("Required quiet CBT migration cycles must be at least 1"); + } + this.minCycles = minCycles; + this.maxCycles = maxCycles; + this.quietCyclesRequired = quietCyclesRequired; + this.quietDirtyBytesThreshold = quietDirtyBytesThreshold; + this.quietDirtyRateBytesPerSecondThreshold = quietDirtyRateBytesPerSecondThreshold; + } + + public Decision decide(int completedCycles, int quietCycles, long lastChangedBytes, long lastCycleDurationSeconds) { + if (completedCycles >= maxCycles) { + return Decision.READY_FOR_CUTOVER_MAX_CYCLES; + } + if (completedCycles < minCycles) { + return Decision.CONTINUE; + } + if (lastChangedBytes == 0) { + return Decision.READY_FOR_CUTOVER; + } + int updatedQuietCycles = isQuietCycle(lastChangedBytes, lastCycleDurationSeconds) ? quietCycles + 1 : 0; + if (updatedQuietCycles >= quietCyclesRequired) { + return Decision.READY_FOR_CUTOVER; + } + return Decision.CONTINUE; + } + + public boolean isQuietCycle(long changedBytes, long cycleDurationSeconds) { + boolean withinChangedBytes = quietDirtyBytesThreshold <= 0 || changedBytes <= quietDirtyBytesThreshold; + boolean withinDirtyRate = quietDirtyRateBytesPerSecondThreshold <= 0 || + getDirtyRateBytesPerSecond(changedBytes, cycleDurationSeconds) <= quietDirtyRateBytesPerSecondThreshold; + return withinChangedBytes && withinDirtyRate; + } + + protected long getDirtyRateBytesPerSecond(long changedBytes, long cycleDurationSeconds) { + if (cycleDurationSeconds <= 0) { + return changedBytes; + } + return changedBytes / cycleDurationSeconds; + } +} diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java new file mode 100644 index 000000000000..d86e90b7496b --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -0,0 +1,2599 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.admin.vm.CancelVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.CheckVmwareCbtMigrationPrerequisitesCmd; +import org.apache.cloudstack.api.command.admin.vm.CutoverVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.DeleteVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.ListVmwareCbtMigrationsCmd; +import org.apache.cloudstack.api.command.admin.vm.StartVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.SyncVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightDiskResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightFindingResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationCycleResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationDiskResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CheckConvertInstanceAnswer; +import com.cloud.agent.api.CheckConvertInstanceCommand; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.VmwareCbtCleanupCommand; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtPrepareCommand; +import com.cloud.agent.api.VmwareCbtRbdProbeCommand; +import com.cloud.agent.api.VmwareCbtSyncCommand; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.VmwareDatacenterVO; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.VmwareDatacenterDao; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.ResourceState; +import com.cloud.network.Network; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.Storage; +import com.cloud.user.Account; +import com.cloud.user.AccountService; +import com.cloud.user.UserVO; +import com.cloud.utils.Pair; +import com.cloud.vm.VmDetailConstants; +import com.cloud.user.dao.UserDao; +import com.cloud.uservm.UserVm; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VmwareCbtMigrationCycleVO; +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import com.cloud.vm.VmwareCbtMigrationVO; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VmwareCbtMigrationCycleDao; +import com.cloud.vm.dao.VmwareCbtMigrationDao; +import com.cloud.vm.dao.VmwareCbtMigrationDiskDao; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +public class VmwareCbtMigrationManagerImpl implements VmwareCbtMigrationManager, Configurable { + + private static final String OBJECT_NAME = "vmwarecbtmigration"; + private static final String DETAIL_VDDK_TRANSPORTS = "vddk.transports"; + private static final String DETAIL_VDDK_THUMBPRINT = "vddk.thumbprint"; + private static final String REDACTED_SECRET = "******"; + private static final String DEFAULT_CBT_DISK_BASE_PATH = "/var/lib/libvirt/images/cloudstack-cbt"; + private static final String KVM_STORAGE_POOL_MOUNT_BASE_PATH = "/mnt"; + static final String CBT_FINALIZED_DISK_CONTROLLER = "virtio"; + private static final String RBD_PROBE_IMAGE_PREFIX = "cloudstack-cbt-probe-"; + private static final List CBT_COMPATIBLE_STORAGE_POOL_TYPES = Arrays.asList( + Storage.StoragePoolType.NetworkFilesystem, + Storage.StoragePoolType.Filesystem, + Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.RBD); + private static final int CLEANUP_WAIT_SECONDS = 300; + private static final int DELETE_CLEANUP_WAIT_SECONDS = 30; + private static final Logger LOGGER = LogManager.getLogger(VmwareCbtMigrationManagerImpl.class); + private static final Gson GSON = new Gson(); + + static final ConfigKey VmwareCbtMigrationMinCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.min.cycles", + "Advanced", + "1", + "Minimum number of CBT delta synchronization cycles to run before CloudStack can recommend final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationMaxCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.max.cycles", + "Advanced", + "5", + "Maximum number of CBT delta synchronization cycles to run before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationQuietCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.quiet.cycles", + "Advanced", + "2", + "Number of consecutive quiet CBT delta synchronization cycles required before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationQuietBytes = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.bytes", + "Advanced", + "1073741824", + "Maximum changed bytes in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationQuietDirtyRate = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.dirty.rate", + "Advanced", + "16777216", + "Maximum changed bytes per second in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtAllowNonInPlaceFinalization = new ConfigKey<>(Boolean.class, + "vmware.cbt.allow.non.inplace.finalization", + "Advanced", + "false", + "If true, VMware CBT cutover may fall back to regular virt-v2v finalization for qcow2 file targets when true in-place finalization is unavailable. The fallback stages temporary data on the selected primary storage and requires additional free space.", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationAgentCommandTimeout = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.agent.command.timeout", + "Advanced", + "86400", + "Timeout in seconds for long-running VMware CBT data-plane commands dispatched to the KVM agent, including initial full sync, delta sync, final delta sync, and cutover finalization.", + true, + ConfigKey.Scope.Global, + null); + + @Inject + private VmwareCbtMigrationDao vmwareCbtMigrationDao; + @Inject + private DataCenterDao dataCenterDao; + @Inject + private ClusterDao clusterDao; + @Inject + private HostDao hostDao; + @Inject + private PrimaryDataStoreDao primaryDataStoreDao; + @Inject + private VmwareDatacenterDao vmwareDatacenterDao; + @Inject + private AccountService accountService; + @Inject + private UserDao userDao; + @Inject + private ServiceOfferingDao serviceOfferingDao; + @Inject + private UserVmDao userVmDao; + @Inject + private VmwareCbtMigrationDiskDao vmwareCbtMigrationDiskDao; + @Inject + private VmwareCbtMigrationCycleDao vmwareCbtMigrationCycleDao; + @Inject + private AgentManager agentManager; + @Inject + private UnmanagedVMsManager unmanagedVMsManager; + @Autowired(required = false) + private VmwareCbtMigrationService vmwareCbtMigrationService; + + @Override + public List> getCommands() { + final List> cmdList = new ArrayList<>(); + cmdList.add(CheckVmwareCbtMigrationPrerequisitesCmd.class); + cmdList.add(StartVmwareCbtMigrationCmd.class); + cmdList.add(ListVmwareCbtMigrationsCmd.class); + cmdList.add(SyncVmwareCbtMigrationCmd.class); + cmdList.add(CutoverVmwareCbtMigrationCmd.class); + cmdList.add(CancelVmwareCbtMigrationCmd.class); + cmdList.add(DeleteVmwareCbtMigrationCmd.class); + return cmdList; + } + + @Override + public VmwareCbtMigrationPreflightResponse checkVmwareCbtMigrationPrerequisites(CheckVmwareCbtMigrationPrerequisitesCmd cmd) { + PreflightFindingCollector findings = new PreflightFindingCollector(); + VmwareCbtMigrationPreflightResponse response = new VmwareCbtMigrationPreflightResponse(); + response.setObjectName("vmwarecbtmigrationpreflight"); + + DataCenterVO zone = getPreflightZone(cmd.getZoneId(), findings); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + + ClusterVO destinationCluster = getPreflightDestinationCluster(cmd.getClusterId(), zone, findings); + if (destinationCluster != null) { + response.setClusterId(destinationCluster.getUuid()); + response.setClusterName(destinationCluster.getName()); + } + + StoragePoolVO storagePool = getPreflightStoragePool(cmd.getStoragePoolId(), zone, destinationCluster, findings); + VmwareCbtStorageTarget storageTarget = null; + if (storagePool != null) { + storageTarget = VmwareCbtStorageTarget.forPool(storagePool); + populateStorageTargetResponse(response, storageTarget); + addStorageTargetFinding(storageTarget, findings); + } + + HostVO cbtHost = getPreflightCbtHost(cmd.getConvertInstanceHostId(), destinationCluster, storageTarget, findings); + if (cbtHost != null) { + response.setConvertInstanceHostId(cbtHost.getUuid()); + response.setConvertInstanceHostName(cbtHost.getName()); + response.setConvertInstanceHostInPlaceFinalizationSupported(hostSupportsInPlaceFinalization(cbtHost)); + } + addStorageTargetFinalizationFinding(storageTarget, cbtHost, findings); + addRbdStorageAccessFinding(storageTarget, cbtHost, findings); + + String sourceVmName = StringUtils.trimToNull(cmd.getSourceVmName()); + if (sourceVmName == null) { + findings.fail("sourceVm.name.present", "vmware", null, "Source VM name is required."); + } else { + response.setSourceVmName(sourceVmName); + } + + VmwareSource source = getPreflightVmwareSource(cmd, findings); + if (source != null) { + response.setVcenter(source.vcenter); + response.setDatacenterName(source.datacenterName); + response.setSourceHost(source.sourceHost); + } + + if (source != null && sourceVmName != null) { + populateSourceVmPreflight(response, source, sourceVmName, cbtHost, cmd.getServiceOfferingId(), + cmd.getDetails(), zone, findings); + } + + response.setFindings(findings.getFindings()); + response.setReady(!findings.hasFailures()); + return response; + } + + private VmwareCbtPreflightInfo validateSourceVmPreflightForStart(VmwareSource source, String sourceVmName) { + VmwareCbtPreflightInfo preflightInfo; + try { + preflightInfo = getVmwareCbtMigrationService().getPreflightInfo(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + } catch (RuntimeException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Unable to validate VMware CBT prerequisites for source VM %s: %s", + sourceVmName, sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source))); + } + + if (Boolean.FALSE.equals(preflightInfo.getChangeTrackingSupported())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source VM %s does not report VMware CBT support", sourceVmName)); + } + if (Boolean.TRUE.equals(preflightInfo.getConsolidationNeeded())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source VM %s reports pending disk consolidation; consolidate VMware disks before starting CBT migration", + sourceVmName)); + } + if (CollectionUtils.isEmpty(preflightInfo.getDisks())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("No source VMware disks were discovered for VM %s", sourceVmName)); + } + for (VmwareCbtPreflightDiskInfo disk : preflightInfo.getDisks()) { + validateSourceDiskPreflightForStart(disk); + } + return preflightInfo; + } + + private void ensureSourceVmChangeTrackingEnabledForStart(VmwareSource source, String sourceVmName, + VmwareCbtPreflightInfo preflightInfo) { + if (Boolean.TRUE.equals(preflightInfo.getChangeTrackingEnabled())) { + return; + } + try { + getVmwareCbtMigrationService().ensureChangeTrackingEnabled(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + } catch (RuntimeException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Unable to enable VMware CBT on source VM %s before initial full sync: %s", + sourceVmName, sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source))); + } + } + + private void validateSourceDiskPreflightForStart(VmwareCbtPreflightDiskInfo disk) { + String diskId = StringUtils.defaultIfBlank(disk.getSourceDiskId(), disk.getSourceDiskPath()); + if (disk.getSourceDiskDeviceKey() == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s does not expose a VMware device key required for CBT delta queries", + diskId)); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s does not expose a VMware backing path", diskId)); + } + if (disk.isIndependentDisk()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s uses independent disk mode %s, which is not supported for VMware CBT migration", + diskId, disk.getDiskMode())); + } + if (disk.isPhysicalRdm()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s is a physical-mode RDM, which is not supported for VMware CBT migration", + diskId)); + } + } + + private ServiceOfferingVO getServiceOfferingForVmwareCbtMigration(Long serviceOfferingId, Account owner, DataCenterVO zone) { + if (serviceOfferingId == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Service offering ID cannot be null"); + } + ServiceOfferingVO serviceOffering = serviceOfferingDao.findById(serviceOfferingId); + if (serviceOffering == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Service offering ID: %d cannot be found", serviceOfferingId)); + } + accountService.checkAccess(owner, serviceOffering, zone); + serviceOfferingDao.loadDetails(serviceOffering); + return serviceOffering; + } + + static VmwareCbtOfferingResources resolveRequestedOfferingResources(ServiceOfferingVO serviceOffering, Map details) { + if (serviceOffering == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Service offering cannot be null"); + } + Map callerDetails = details == null ? new HashMap<>() : details; + Map offeringDetails = serviceOffering.getDetails(); + Integer cpu = firstInteger(VmDetailConstants.CPU_NUMBER, callerDetails, + serviceOffering.getCpu(), ApiConstants.MIN_CPU_NUMBER, offeringDetails); + Integer cpuSpeed = firstInteger(VmDetailConstants.CPU_SPEED, callerDetails, + serviceOffering.getSpeed(), null, offeringDetails); + Integer memory = firstInteger(VmDetailConstants.MEMORY, callerDetails, + serviceOffering.getRamSize(), ApiConstants.MIN_MEMORY, offeringDetails); + return new VmwareCbtOfferingResources(cpu, cpuSpeed, memory); + } + + private static Integer firstInteger(String callerDetailKey, Map callerDetails, + Integer offeringValue, String offeringDetailKey, + Map offeringDetails) { + if (offeringValue != null) { + return offeringValue; + } + Integer callerValue = parseIntegerDetail(callerDetails, callerDetailKey); + if (callerValue != null) { + return callerValue; + } + return StringUtils.isBlank(offeringDetailKey) ? null : parseIntegerDetail(offeringDetails, offeringDetailKey); + } + + private static Integer parseIntegerDetail(Map details, String key) { + if (MapUtils.isEmpty(details) || StringUtils.isBlank(key) || StringUtils.isBlank(details.get(key))) { + return null; + } + try { + return Integer.valueOf(details.get(key)); + } catch (NumberFormatException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Please provide a valid integer value for detail '%s'.", key)); + } + } + + static void validateSelectedServiceOfferingResourcesForSourceVm(VmwareCbtPreflightInfo preflightInfo, + ServiceOfferingVO serviceOffering, + Map details) { + VmwareCbtOfferingResources requestedResources = resolveRequestedOfferingResources(serviceOffering, details); + validateRequestedResourceAtLeastSource(preflightInfo.getCpuCores(), requestedResources.cpuNumber, "CPU number"); + validateRequestedResourceAtLeastSource(preflightInfo.getCpuSpeed(), requestedResources.cpuSpeed, "CPU speed"); + validateRequestedResourceAtLeastSource(preflightInfo.getMemoryMb(), requestedResources.memoryMb, "Memory"); + } + + private static void validateRequestedResourceAtLeastSource(Integer sourceResource, Integer requestedResource, String resourceName) { + if (sourceResource == null || requestedResource == null || sourceResource <= 0 || requestedResource <= 0) { + return; + } + if (requestedResource < sourceResource) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("The requested %s (%d) is less than the source VM %s (%d)", + resourceName, requestedResource, resourceName, sourceResource)); + } + } + + @Override + public VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigrationCmd cmd) { + Account caller = CallContext.current().getCallingAccount(); + if (caller == null) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Unable to determine calling account"); + } + Account owner = accountService.getActiveAccountById(cmd.getEntityOwnerId()); + if (owner == null) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Unable to determine target account"); + } + + DataCenterVO zone = getZone(cmd.getZoneId()); + ClusterVO destinationCluster = getDestinationCluster(cmd.getClusterId(), zone.getId()); + StoragePoolVO storagePool = getStoragePool(cmd.getStoragePoolId(), zone, destinationCluster); + VmwareCbtStorageTarget storageTarget = VmwareCbtStorageTarget.forPool(storagePool); + if (!storageTarget.isSupported()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, storageTarget.getSupportMessage()); + } + HostVO convertHost = selectCbtHost(cmd.getConvertInstanceHostId(), destinationCluster, storageTarget); + validateStorageTargetFinalizationSupport(storageTarget, convertHost); + validateRbdStorageAccessForStart(storageTarget, convertHost); + + String sourceVmName = StringUtils.trimToNull(cmd.getSourceVmName()); + if (sourceVmName == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Source VM name is required"); + } + + VmwareSource source = resolveVmwareSource(cmd); + VmwareCbtPreflightInfo preflightInfo = validateSourceVmPreflightForStart(source, sourceVmName); + ServiceOfferingVO serviceOffering = getServiceOfferingForVmwareCbtMigration(cmd.getServiceOfferingId(), owner, zone); + validateSelectedServiceOfferingResourcesForSourceVm(preflightInfo, serviceOffering, cmd.getDetails()); + validateWindowsGuestConversionSupportForStart(convertHost, sourceVmName, preflightInfo, cmd.getDetails()); + ensureSourceVmChangeTrackingEnabledForStart(source, sourceVmName, preflightInfo); + List sourceDisks = discoverSourceDisks(source, sourceVmName); + String displayName = StringUtils.defaultIfBlank(cmd.getDisplayName(), sourceVmName); + + VmwareCbtMigrationVO migration = new VmwareCbtMigrationVO(zone.getId(), owner.getId(), getUserIdForOwner(owner), + destinationCluster.getId(), displayName, source.vcenter, source.datacenterName, cmd.getSourceHost(), cmd.getSourceCluster(), sourceVmName); + migration.setExistingVcenterId(source.existingVcenterId); + storeExternalVmwareSourceCredentials(migration, source); + if (convertHost != null) { + migration.setConvertHostId(convertHost.getId()); + } + if (storagePool != null) { + migration.setStoragePoolId(storagePool.getId()); + } + migration.setHostName(StringUtils.trimToNull(cmd.getHostName())); + migration.setTemplateId(cmd.getTemplateId()); + migration.setServiceOfferingId(cmd.getServiceOfferingId()); + migration.setGuestOsId(cmd.getGuestOsId()); + migration.setDataDiskOfferingMap(serializeMap(cmd.getDataDiskToDiskOfferingList())); + migration.setNicNetworkMap(serializeMap(cmd.getNicNetworkList())); + migration.setNicIpAddressMap(serializeNicIpAddressMap(cmd.getNicIpAddressList())); + migration.setImportDetails(serializeMap(cmd.getDetails())); + migration.setForced(cmd.isForced()); + applyVddkDetails(migration, cmd.getDetails()); + migration.setState(VmwareCbtMigration.State.InitialSync); + migration.setCurrentStep(String.format("Discovered %s source disk(s); preparing initial VDDK full sync", sourceDisks.size())); + migration.setUpdated(new Date()); + migration = vmwareCbtMigrationDao.persist(migration); + persistSourceDisks(migration, sourceDisks); + return runInitialFullSync(source, migration, storageTarget); + } + + @Override + public ListResponse listVmwareCbtMigrations(ListVmwareCbtMigrationsCmd cmd) { + VmwareCbtMigration.State state = parseState(cmd.getState()); + Pair, Integer> result = vmwareCbtMigrationDao.listMigrations(cmd.getId(), cmd.getZoneId(), cmd.getAccountId(), + cmd.getVcenter(), cmd.getSourceVmName(), state, cmd.getStartIndex(), cmd.getPageSizeVal()); + + List responses = new ArrayList<>(); + for (VmwareCbtMigrationVO migration : result.first()) { + responses.add(createVmwareCbtMigrationResponse(migration)); + } + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, result.second()); + return listResponse; + } + + @Override + public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + rejectTerminalMigration(migration, "synchronize"); + requireMigrationState(migration, "synchronize", VmwareCbtMigration.State.Replicating, + VmwareCbtMigration.State.ReadyForCutover); + VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); + validateInitialSyncTargetDisks(migration); + HostVO cbtHost = getCbtHostForMigration(migration); + int cycleNumber = migration.getCompletedCycles() + 1; + VmwareCbtMigrationCycleVO cycle = new VmwareCbtMigrationCycleVO(migration.getId(), cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.CopyingChangedBlocks); + cycle.setDescription("Dispatching CBT delta synchronization to KVM agent"); + cycle.setUpdated(new Date()); + cycle = vmwareCbtMigrationCycleDao.persist(cycle); + + migration.setState(VmwareCbtMigration.State.Replicating); + migration.setCurrentStep(String.format("Running CBT delta synchronization cycle %s", cycleNumber)); + migration.setLastError(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + + VmwareCbtSnapshotInfo snapshot = null; + try { + snapshot = createDeltaSnapshot(source, migration, cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.QueryingChangedAreas); + cycle.setSnapshotMor(snapshot.getSnapshotMor()); + cycle.setDescription("Querying VMware CBT changed disk areas"); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtChangedBlockQueryResult changedBlockQuery = queryChangedBlocks(source, migration, + snapshot.getSnapshotMor()); + + cycle.setState(VmwareCbtMigrationCycle.State.CopyingChangedBlocks); + cycle.setDescription(String.format("Dispatching %s VMware CBT changed block range(s) to KVM agent", + changedBlockQuery.changedBlocks.size())); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), + createRemoteInstance(source, migration, snapshot.getSourceVmMor()), getDiskTransferObjects(migration), + changedBlockQuery.changedBlocks, cycleNumber, snapshot.getSnapshotMor(), false); + applyVddkDetails(syncCommand, migration); + applyTargetStorageDetails(syncCommand, migration); + syncCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "synchronize", + migration.getUuid()); + if (!answer.getResult()) { + String details = sanitizeSensitiveMessage(answer.getDetails(), source); + markCycleFailed(cycle, details); + markMigrationFailed(migration, "CBT delta synchronization failed", details); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + applyDiskResults(migration, answer.getDiskResults()); + updateDiskChangeIds(migration, changedBlockQuery.changedDisks); + long changedBytes = answer.getChangedBytes(); + long durationSeconds = answer.getDurationSeconds(); + long dirtyRate = getDirtyRateBytesPerSecond(changedBytes, durationSeconds, answer.getDirtyRateBytesPerSecond()); + VmwareCbtMigrationCutoverPolicy cutoverPolicy = getCutoverPolicy(); + VmwareCbtMigrationCutoverPolicy.Decision cutoverDecision = cutoverPolicy.decide(cycleNumber, + migration.getQuietCycles(), changedBytes, durationSeconds); + int quietCycles = cutoverPolicy.isQuietCycle(changedBytes, durationSeconds) ? + migration.getQuietCycles() + 1 : 0; + + cycle.setState(VmwareCbtMigrationCycle.State.Completed); + cycle.setChangedBytes(changedBytes); + cycle.setDirtyRate(dirtyRate); + cycle.setDuration(durationSeconds * 1000); + cycle.setDescription(sanitizeSensitiveMessage(answer.getDetails(), source)); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + migration.setCompletedCycles(cycleNumber); + migration.setQuietCycles(quietCycles); + migration.setTotalChangedBytes(migration.getTotalChangedBytes() + changedBytes); + migration.setLastChangedBytes(changedBytes); + migration.setLastDirtyRate(dirtyRate); + migration.setState(cutoverDecision == VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE ? + VmwareCbtMigration.State.Replicating : VmwareCbtMigration.State.ReadyForCutover); + migration.setCurrentStep(getCutoverDecisionStep(cutoverDecision)); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + return createVmwareCbtMigrationResponse(migration); + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + markCycleFailed(cycle, error); + markMigrationFailed(migration, "CBT delta synchronization failed", error); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } finally { + removeDeltaSnapshotIfPossible(source, migration, snapshot); + } + } + + @Override + public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + rejectTerminalMigration(migration, "cut over"); + requireMigrationState(migration, "cut over", VmwareCbtMigration.State.ReadyForCutover, + VmwareCbtMigration.State.ReadyForImport); + VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); + validateInitialSyncTargetDisks(migration); + if (migration.getState() == VmwareCbtMigration.State.ReadyForImport) { + return importCutoverMigration(source, migration); + } + HostVO cbtHost = getCbtHostForMigration(migration); + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + validateStorageTargetFinalizationSupport(storageTarget, cbtHost); + requireSourceVmPoweredOff(source, migration); + + migration.setState(VmwareCbtMigration.State.CuttingOver); + migration.setCurrentStep("Running final CBT delta synchronization before cutover"); + migration.setLastError(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + + int finalCycleNumber = migration.getCompletedCycles() + 1; + if (!runFinalDeltaSync(source, migration, cbtHost, finalCycleNumber)) { + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + migration = vmwareCbtMigrationDao.findById(migration.getId()); + boolean inPlaceFinalization = hostSupportsInPlaceFinalization(cbtHost); + String finalizationDescription = inPlaceFinalization ? "in-place conversion" : "virt-v2v fallback conversion"; + migration.setCurrentStep(String.format("Final CBT delta synchronization completed; running %s", + finalizationDescription)); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + + VmwareCbtCutoverCommand cutoverCommand = new VmwareCbtCutoverCommand(migration.getUuid(), createRemoteInstance(source, migration), + getDiskTransferObjects(migration), finalCycleNumber, true); + applyVddkDetails(cutoverCommand, migration); + applyTargetStorageDetails(cutoverCommand, migration); + cutoverCommand.setAllowNonInPlaceFinalization(isNonInPlaceFinalizationFallbackAllowed(storageTarget)); + cutoverCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, cutoverCommand, "cut over", migration.getUuid()); + if (!answer.getResult()) { + markMigrationFailed(migration, "CBT cutover failed", sanitizeSensitiveMessage(answer.getDetails(), source)); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + applyDiskResults(migration, answer.getDiskResults()); + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setState(VmwareCbtMigration.State.ReadyForImport); + migration.setCurrentStep(String.format("Final %s completed; importing VM into CloudStack", + finalizationDescription)); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + return importCutoverMigration(source, migration); + } + + private VmwareCbtMigrationResponse importCutoverMigration(VmwareSource source, VmwareCbtMigrationVO migration) { + if (migration.getServiceOfferingId() == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Cannot import VMware CBT migration because serviceofferingid was not recorded when the migration was started"); + } + Account caller = CallContext.current().getCallingAccount(); + Account owner = accountService.getActiveAccountById(migration.getAccountId()); + if (caller == null || owner == null) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, "Unable to resolve caller or target account for VMware CBT import"); + } + try { + UserVm userVm = unmanagedVMsManager.importConvertedVmwareCbtInstanceToKvm(source.vcenter, source.datacenterName, + source.username, source.password, migration.getSourceCluster(), migration.getSourceHost(), + migration.getSourceVmName(), createConvertedInstanceForImport(migration), getZone(migration.getZoneId()), + getDestinationCluster(migration.getDestinationClusterId(), migration.getZoneId()), migration.getDisplayName(), + migration.getHostName(), caller, owner, migration.getUserId(), migration.getTemplateId(), + migration.getServiceOfferingId(), deserializeLongMap(migration.getDataDiskOfferingMap()), + deserializeLongMap(migration.getNicNetworkMap()), deserializeNicIpAddressMap(migration.getNicIpAddressMap()), + migration.getGuestOsId(), deserializeStringMap(migration.getImportDetails()), migration.isForced(), + migration.getStoragePoolId()); + + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setVmId(userVm.getId()); + migration.setState(VmwareCbtMigration.State.Completed); + migration.setCurrentStep("CloudStack VM import completed"); + migration.setLastError(null); + clearStoredSourceCredentials(migration); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setState(VmwareCbtMigration.State.ReadyForImport); + migration.setCurrentStep("CloudStack VM import failed; retry cutover to import the converted disks"); + migration.setLastError(error); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + private UnmanagedInstanceTO createConvertedInstanceForImport(VmwareCbtMigrationVO migration) { + List migrationDisks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + if (CollectionUtils.isEmpty(migrationDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No synchronized disks were found for VMware CBT migration %s", migration.getUuid())); + } + UnmanagedInstanceTO convertedInstance = new UnmanagedInstanceTO(); + convertedInstance.setName(migration.getSourceVmName()); + convertedInstance.setHypervisorType(Hypervisor.HypervisorType.KVM.name()); + convertedInstance.setPowerState(UnmanagedInstanceTO.PowerState.PowerOff); + + StoragePoolVO storagePool = primaryDataStoreDao.findById(migration.getStoragePoolId()); + Storage.StoragePoolType storagePoolType = storagePool == null ? null : storagePool.getPoolType(); + List disks = new ArrayList<>(); + int position = 0; + for (VmwareCbtMigrationDiskVO migrationDisk : migrationDisks) { + if (StringUtils.isBlank(migrationDisk.getTargetPath())) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("Target disk path is missing for source disk %s in VMware CBT migration %s", + migrationDisk.getSourceDiskId(), migration.getUuid())); + } + UnmanagedInstanceTO.Disk disk = new UnmanagedInstanceTO.Disk(); + disk.setDiskId(migrationDisk.getSourceDiskId()); + disk.setLabel(migrationDisk.getSourceDiskPath()); + disk.setCapacity(migrationDisk.getCapacityBytes()); + disk.setPosition(position); + disk.setControllerUnit(position); + disk.setController(CBT_FINALIZED_DISK_CONTROLLER); + disk.setImagePath(migrationDisk.getTargetPath()); + disk.setFileBaseName(getVolumePathForCbtTarget(migrationDisk.getTargetPath(), migration.getUuid(), storagePoolType)); + if (storagePool != null) { + disk.setDatastoreName(storagePool.getUuid()); + disk.setDatastoreType(storagePool.getPoolType().name()); + } + disks.add(disk); + position++; + } + convertedInstance.setDisks(disks); + convertedInstance.setNics(new ArrayList<>()); + return convertedInstance; + } + + private String getVolumePathForCbtTarget(String targetPath, String migrationUuid, Storage.StoragePoolType storagePoolType) { + String normalizedPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + if (storagePoolType == Storage.StoragePoolType.RBD) { + return StringUtils.contains(normalizedPath, "/") ? StringUtils.substringAfterLast(normalizedPath, "/") : normalizedPath; + } + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalizedPath.indexOf(marker); + if (markerIndex >= 0) { + return String.format("cloudstack-cbt/%s/%s", migrationUuid, + normalizedPath.substring(markerIndex + marker.length())); + } + return StringUtils.substringAfterLast(normalizedPath, "/"); + } + + private Map deserializeLongMap(String json) { + if (StringUtils.isBlank(json)) { + return new HashMap<>(); + } + try { + return GSON.fromJson(json, new TypeToken>() { + }.getType()); + } catch (RuntimeException e) { + throw new InvalidParameterValueException("Unable to parse stored VMware CBT import ID map"); + } + } + + private Map deserializeStringMap(String json) { + if (StringUtils.isBlank(json)) { + return new HashMap<>(); + } + try { + return GSON.fromJson(json, new TypeToken>() { + }.getType()); + } catch (RuntimeException e) { + throw new InvalidParameterValueException("Unable to parse stored VMware CBT import details"); + } + } + + private Map deserializeNicIpAddressMap(String json) { + Map nicIpAddressMap = new HashMap<>(); + for (Map.Entry entry : deserializeStringMap(json).entrySet()) { + nicIpAddressMap.put(entry.getKey(), new Network.IpAddresses(entry.getValue(), null)); + } + return nicIpAddressMap; + } + + private boolean runFinalDeltaSync(VmwareSource source, VmwareCbtMigrationVO migration, HostVO cbtHost, + int cycleNumber) { + VmwareCbtMigrationCycleVO cycle = new VmwareCbtMigrationCycleVO(migration.getId(), cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.Created); + cycle.setDescription("Creating final VMware CBT snapshot for cutover"); + cycle.setUpdated(new Date()); + cycle = vmwareCbtMigrationCycleDao.persist(cycle); + + VmwareCbtSnapshotInfo snapshot = null; + try { + snapshot = createDeltaSnapshot(source, migration, cycleNumber); + cycle.setState(VmwareCbtMigrationCycle.State.QueryingChangedAreas); + cycle.setSnapshotMor(snapshot.getSnapshotMor()); + cycle.setDescription("Querying final VMware CBT changed disk areas"); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtChangedBlockQueryResult changedBlockQuery = queryChangedBlocks(source, migration, + snapshot.getSnapshotMor()); + + cycle.setState(VmwareCbtMigrationCycle.State.CopyingChangedBlocks); + cycle.setDescription(String.format("Dispatching %s final VMware CBT changed block range(s) to KVM agent", + changedBlockQuery.changedBlocks.size())); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), + createRemoteInstance(source, migration, snapshot.getSourceVmMor()), getDiskTransferObjects(migration), + changedBlockQuery.changedBlocks, cycleNumber, snapshot.getSnapshotMor(), true); + applyVddkDetails(syncCommand, migration); + applyTargetStorageDetails(syncCommand, migration); + syncCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "perform final synchronization", + migration.getUuid()); + if (!answer.getResult()) { + String details = sanitizeSensitiveMessage(answer.getDetails(), source); + markCycleFailed(cycle, details); + markMigrationFailed(migration, "Final CBT delta synchronization failed", details); + return false; + } + + applyDiskResults(migration, answer.getDiskResults()); + updateDiskChangeIds(migration, changedBlockQuery.changedDisks); + long changedBytes = answer.getChangedBytes(); + long durationSeconds = answer.getDurationSeconds(); + long dirtyRate = getDirtyRateBytesPerSecond(changedBytes, durationSeconds, answer.getDirtyRateBytesPerSecond()); + + cycle.setState(VmwareCbtMigrationCycle.State.Completed); + cycle.setChangedBytes(changedBytes); + cycle.setDirtyRate(dirtyRate); + cycle.setDuration(durationSeconds * 1000); + cycle.setDescription(sanitizeSensitiveMessage(answer.getDetails(), source)); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + migration.setCompletedCycles(cycleNumber); + migration.setQuietCycles(0); + migration.setTotalChangedBytes(migration.getTotalChangedBytes() + changedBytes); + migration.setLastChangedBytes(changedBytes); + migration.setLastDirtyRate(dirtyRate); + migration.setCurrentStep("Final CBT delta synchronization completed"); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + return true; + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + markCycleFailed(cycle, error); + markMigrationFailed(migration, "Final CBT delta synchronization failed", error); + return false; + } finally { + removeDeltaSnapshotIfPossible(source, migration, snapshot); + } + } + + private void requireSourceVmPoweredOff(VmwareSource source, VmwareCbtMigrationVO migration) { + UnmanagedInstanceTO.PowerState powerState = getVmwareCbtMigrationService().getPowerState(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName()); + if (powerState != UnmanagedInstanceTO.PowerState.PowerOff) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot cut over VMware CBT migration %s while source VM %s is in power state %s. Gracefully shut down the source VM, then retry cutover.", + migration.getUuid(), migration.getSourceVmName(), powerState)); + } + } + + @Override + public VmwareCbtMigrationResponse cancelVmwareCbtMigration(CancelVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + if (!migration.getState().isTerminal()) { + sendCleanupCommandIfPossible(migration); + migration.setState(VmwareCbtMigration.State.Cancelled); + migration.setCurrentStep("Cancelled"); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + clearStoredSourceCredentials(migration); + return createVmwareCbtMigrationResponse(migration); + } + + @Override + public boolean deleteVmwareCbtMigration(DeleteVmwareCbtMigrationCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + if (!canDeleteMigrationState(migration.getState())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Only failed, cancelled or completed VMware CBT migrations can be deleted. Migration %s is in state %s.", + migration.getUuid(), migration.getState())); + } + clearStoredSourceCredentials(migration); + if (shouldCleanupDeletedMigration(migration.getState(), cmd.getCleanup())) { + sendCleanupCommandForDelete(migration); + } else if (migration.getState() == VmwareCbtMigration.State.Completed && cmd.getCleanup()) { + LOGGER.debug("Skipping target disk cleanup for completed VMware CBT migration {}. Completed migration deletion is record-only.", + migration.getUuid()); + } + for (VmwareCbtMigrationCycleVO cycle : vmwareCbtMigrationCycleDao.listByMigrationId(migration.getId())) { + vmwareCbtMigrationCycleDao.remove(cycle.getId()); + } + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + vmwareCbtMigrationDiskDao.remove(disk.getId()); + } + return vmwareCbtMigrationDao.remove(migration.getId()); + } + + boolean canDeleteMigrationState(VmwareCbtMigration.State state) { + return state == VmwareCbtMigration.State.Failed || state == VmwareCbtMigration.State.Cancelled || + state == VmwareCbtMigration.State.Completed; + } + + boolean shouldCleanupDeletedMigration(VmwareCbtMigration.State state, boolean cleanupRequested) { + return cleanupRequested && (state == VmwareCbtMigration.State.Failed || state == VmwareCbtMigration.State.Cancelled); + } + + private long getUserIdForOwner(Account owner) { + long userId = CallContext.current().getCallingUserId(); + List users = userDao.listByAccount(owner.getAccountId()); + if (CollectionUtils.isNotEmpty(users)) { + userId = users.get(0).getId(); + } + return userId; + } + + private String serializeMap(Map map) { + if (map == null || map.isEmpty()) { + return null; + } + return GSON.toJson(map); + } + + private String serializeNicIpAddressMap(Map nicIpAddressMap) { + if (nicIpAddressMap == null || nicIpAddressMap.isEmpty()) { + return null; + } + Map ip4AddressMap = new HashMap<>(); + for (Map.Entry entry : nicIpAddressMap.entrySet()) { + if (entry.getValue() != null && StringUtils.isNotBlank(entry.getValue().getIp4Address())) { + ip4AddressMap.put(entry.getKey(), entry.getValue().getIp4Address()); + } + } + return serializeMap(ip4AddressMap); + } + + private DataCenterVO getZone(Long zoneId) { + DataCenterVO zone = dataCenterDao.findById(zoneId); + if (zone == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find zone with ID %s", zoneId)); + } + return zone; + } + + private ClusterVO getDestinationCluster(Long clusterId, long zoneId) { + ClusterVO cluster = clusterDao.findById(clusterId); + if (cluster == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find cluster with ID %s", clusterId)); + } + if (cluster.getDataCenterId() != zoneId) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Destination cluster does not belong to the requested zone"); + } + if (Hypervisor.HypervisorType.KVM != cluster.getHypervisorType()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Destination cluster must be a KVM cluster for VMware CBT migration"); + } + return cluster; + } + + private HostVO selectCbtHost(Long hostId, ClusterVO destinationCluster, VmwareCbtStorageTarget storageTarget) { + boolean requireInPlaceFinalization = requiresInPlaceFinalizationSupport(storageTarget); + boolean requireRbdSupport = requiresRbdStorageSupport(storageTarget); + if (hostId == null) { + String hostCapability = getRequiredHostCapability(requireInPlaceFinalization, requireRbdSupport); + List hosts = hostDao.listByClusterHypervisorTypeAndHostCapability(destinationCluster.getId(), + destinationCluster.getHypervisorType(), hostCapability); + if (CollectionUtils.isEmpty(hosts)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + getNoSuitableCbtHostMessage(destinationCluster, requireInPlaceFinalization, requireRbdSupport)); + } + HostVO host = hosts.get(0); + hostDao.loadDetails(host); + return host; + } + HostVO host = hostDao.findById(hostId); + if (host == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find CBT migration host with ID %s", hostId)); + } + if (host.getClusterId() == null || host.getClusterId() != destinationCluster.getId()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "CBT migration host must belong to the destination KVM cluster"); + } + if (Hypervisor.HypervisorType.KVM != host.getHypervisorType()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "CBT migration host must be a KVM host"); + } + if (host.getType() != Host.Type.Routing || host.getStatus() != Status.Up || host.getResourceState() != ResourceState.Enabled) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "CBT migration host must be an enabled, running routing host"); + } + hostDao.loadDetails(host); + if (!Boolean.parseBoolean(host.getDetail(Host.HOST_VMWARE_CBT_SUPPORT))) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("CBT migration host %s does not report VMware CBT migration support", host.getName())); + } + validateStorageTargetFinalizationSupport(storageTarget, host); + validateRbdHostSupport(storageTarget, host); + return host; + } + + private String getRequiredHostCapability(boolean requireInPlaceFinalization, boolean requireRbdSupport) { + if (requireRbdSupport) { + return Host.HOST_VMWARE_CBT_RBD_SUPPORT; + } + if (requireInPlaceFinalization) { + return Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT; + } + return Host.HOST_VMWARE_CBT_SUPPORT; + } + + private String getRequiredHostCapabilityDescription(boolean requireInPlaceFinalization, boolean requireRbdSupport) { + if (requireRbdSupport) { + return "VMware CBT migration, in-place finalization and qemu RBD support"; + } + if (requireInPlaceFinalization) { + return "VMware CBT migration and in-place finalization support"; + } + return "VMware CBT migration support"; + } + + private String getNoSuitableCbtHostMessage(ClusterVO destinationCluster, boolean requireInPlaceFinalization, + boolean requireRbdSupport) { + if (requireRbdSupport) { + return String.format("Could not find an enabled KVM host in cluster %s with '%s' enabled, which is required to perform VMware CBT migration to RBD storage. " + + "This usually means the host is missing VMware CBT migration support, qemu-img RBD support, or in-place virt-v2v support through the virt-v2v-in-place binary or virt-v2v --in-place option.", + destinationCluster.getName(), Host.HOST_VMWARE_CBT_RBD_SUPPORT); + } + if (requireInPlaceFinalization) { + return String.format("Could not find an enabled KVM host in cluster %s with '%s' enabled, which is required to perform VMware CBT in-place finalization. " + + "This usually means the host is missing virt-v2v-in-place or virt-v2v --in-place support.", + destinationCluster.getName(), Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT); + } + return String.format("Could not find an enabled KVM host in cluster %s that reports %s", + destinationCluster.getName(), getRequiredHostCapabilityDescription(requireInPlaceFinalization, false)); + } + + void validateStorageTargetFinalizationSupport(VmwareCbtStorageTarget storageTarget, HostVO host) { + if (storageTarget == null || hostSupportsInPlaceFinalization(host)) { + return; + } + if (isNonInPlaceFinalizationFallbackAllowed(storageTarget)) { + return; + } + String hostName = host == null ? "selected KVM host" : host.getName(); + if (storageTarget.supportsNonInPlaceFinalizationFallback()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Selected KVM host %s cannot finalize VMware CBT migration in-place. Enable virt-v2v in-place support or explicitly allow non-in-place fallback finalization with %s.", + hostName, VmwareCbtAllowNonInPlaceFinalization.key())); + } + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Destination storage requires virt-v2v in-place finalization, but %s does not report '%s=true'. " + + "Ensure virt-v2v-in-place or virt-v2v --in-place is available on the conversion host.", + hostName, Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT)); + } + + private boolean hostSupportsInPlaceFinalization(HostVO host) { + return host != null && Boolean.parseBoolean(host.getDetail(Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT)); + } + + private boolean requiresInPlaceFinalizationSupport(VmwareCbtStorageTarget storageTarget) { + if (storageTarget == null) { + return false; + } + return storageTarget.requiresInPlaceFinalization() || !isNonInPlaceFinalizationFallbackAllowed(storageTarget); + } + + private boolean requiresRbdStorageSupport(VmwareCbtStorageTarget storageTarget) { + return storageTarget != null && storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW; + } + + private void validateRbdHostSupport(VmwareCbtStorageTarget storageTarget, HostVO host) { + if (!requiresRbdStorageSupport(storageTarget)) { + return; + } + if (host == null || !Boolean.parseBoolean(host.getDetail(Host.HOST_VMWARE_CBT_RBD_SUPPORT))) { + String hostName = host == null ? "selected KVM host" : host.getName(); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Destination RBD storage requires qemu RBD support on the conversion host, but %s does not report '%s=true'. " + + "This usually means the host is missing VMware CBT migration support, qemu-img RBD support, or in-place virt-v2v support through the virt-v2v-in-place binary or virt-v2v --in-place option.", + hostName, Host.HOST_VMWARE_CBT_RBD_SUPPORT)); + } + } + + private boolean isNonInPlaceFinalizationFallbackAllowed(VmwareCbtStorageTarget storageTarget) { + return storageTarget != null && storageTarget.supportsNonInPlaceFinalizationFallback() && + Boolean.TRUE.equals(VmwareCbtAllowNonInPlaceFinalization.value()); + } + + private StoragePoolVO getStoragePool(Long storagePoolId, DataCenterVO zone, ClusterVO destinationCluster) { + if (storagePoolId == null) { + return getImplicitStoragePool(zone, destinationCluster); + } + StoragePoolVO pool = primaryDataStoreDao.findById(storagePoolId); + if (pool == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find storage pool with ID %s", storagePoolId)); + } + if (pool.getDataCenterId() != zone.getId()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Storage pool does not belong to the requested zone"); + } + if (pool.getClusterId() != null && !pool.getClusterId().equals(destinationCluster.getId())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Cluster-scoped storage pool must belong to the destination KVM cluster"); + } + return pool; + } + + private StoragePoolVO getImplicitStoragePool(DataCenterVO zone, ClusterVO destinationCluster) { + List pools = listCbtCompatibleStoragePools(zone, destinationCluster); + if (CollectionUtils.isEmpty(pools)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find a suitable CBT-compatible primary storage pool in cluster %s for VMware CBT migration. Supported target pool types are NetworkFilesystem, Filesystem, SharedMountPoint and RBD.", + destinationCluster.getName())); + } + if (pools.size() > 1) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Multiple CBT-compatible primary storage pools are available in cluster %s. Please provide storagepoolid.", + destinationCluster.getName())); + } + return pools.get(0); + } + + List listCbtCompatibleStoragePools(DataCenterVO zone, ClusterVO destinationCluster) { + Map poolsById = new LinkedHashMap<>(); + for (Storage.StoragePoolType poolType : CBT_COMPATIBLE_STORAGE_POOL_TYPES) { + for (StoragePoolVO pool : CollectionUtils.emptyIfNull(primaryDataStoreDao.findClusterWideStoragePoolsByHypervisorAndPoolType(destinationCluster.getId(), + Hypervisor.HypervisorType.KVM, poolType))) { + poolsById.put(pool.getId(), pool); + } + for (StoragePoolVO pool : CollectionUtils.emptyIfNull(primaryDataStoreDao.findZoneWideStoragePoolsByHypervisorAndPoolType(zone.getId(), + Hypervisor.HypervisorType.KVM, poolType))) { + poolsById.put(pool.getId(), pool); + } + } + return new ArrayList<>(poolsById.values()); + } + + private DataCenterVO getPreflightZone(Long zoneId, PreflightFindingCollector findings) { + try { + DataCenterVO zone = getZone(zoneId); + findings.pass("destination.zone.exists", "cloudstack", zone.getUuid(), + String.format("Destination zone %s was found.", zone.getName())); + return zone; + } catch (ServerApiException e) { + findings.fail("destination.zone.exists", "cloudstack", String.valueOf(zoneId), e.getDescription()); + return null; + } + } + + private ClusterVO getPreflightDestinationCluster(Long clusterId, DataCenterVO zone, + PreflightFindingCollector findings) { + if (zone == null) { + return null; + } + try { + ClusterVO cluster = getDestinationCluster(clusterId, zone.getId()); + findings.pass("destination.cluster.kvm", "cloudstack", cluster.getUuid(), + String.format("Destination cluster %s is a KVM cluster in the selected zone.", cluster.getName())); + return cluster; + } catch (ServerApiException e) { + findings.fail("destination.cluster.kvm", "cloudstack", String.valueOf(clusterId), e.getDescription()); + return null; + } + } + + private HostVO getPreflightCbtHost(Long hostId, ClusterVO destinationCluster, VmwareCbtStorageTarget storageTarget, + PreflightFindingCollector findings) { + if (destinationCluster == null) { + return null; + } + try { + HostVO host = selectCbtHost(hostId, destinationCluster, storageTarget); + findings.pass("destinationHost.vmwareCbtSupport", "kvm", host.getUuid(), + String.format("KVM host %s reports VMware CBT migration support.", host.getName())); + return host; + } catch (ServerApiException e) { + findings.fail("destinationHost.vmwareCbtSupport", "kvm", + hostId == null ? destinationCluster.getUuid() : String.valueOf(hostId), e.getDescription()); + return null; + } + } + + private StoragePoolVO getPreflightStoragePool(Long storagePoolId, DataCenterVO zone, ClusterVO destinationCluster, + PreflightFindingCollector findings) { + if (zone == null || destinationCluster == null) { + return null; + } + try { + StoragePoolVO pool = getStoragePool(storagePoolId, zone, destinationCluster); + findings.pass("destinationStorage.poolSelected", "storage", pool.getUuid(), + String.format("Destination storage pool %s was selected.", pool.getName())); + return pool; + } catch (ServerApiException e) { + findings.fail("destinationStorage.poolSelected", "storage", + storagePoolId == null ? destinationCluster.getUuid() : String.valueOf(storagePoolId), + e.getDescription()); + return null; + } + } + + private VmwareSource getPreflightVmwareSource(CheckVmwareCbtMigrationPrerequisitesCmd cmd, + PreflightFindingCollector findings) { + try { + VmwareSource source = resolveVmwareSource(cmd.getExistingVcenterId(), cmd.getVcenter(), + cmd.getDatacenterName(), cmd.getUsername(), cmd.getPassword(), cmd.getSourceHost()); + findings.pass("sourceVcenter.credentialsResolved", "vmware", source.vcenter, + "Source vCenter credentials were resolved."); + return source; + } catch (ServerApiException e) { + findings.fail("sourceVcenter.credentialsResolved", "vmware", cmd.getVcenter(), e.getDescription()); + return null; + } + } + + private void populateStorageTargetResponse(VmwareCbtMigrationPreflightResponse response, + VmwareCbtStorageTarget storageTarget) { + if (storageTarget == null) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + if (pool != null) { + response.setStoragePoolId(pool.getUuid()); + response.setStoragePoolName(pool.getName()); + response.setStoragePoolType(pool.getPoolType().name()); + } + response.setStorageWriterType(storageTarget.getTargetStorageType().name()); + response.setStorageWriterSupported(storageTarget.isSupported()); + response.setStorageRequiresInPlaceFinalization(storageTarget.requiresInPlaceFinalization()); + response.setNonInPlaceFinalizationFallbackAllowed(isNonInPlaceFinalizationFallbackAllowed(storageTarget)); + response.setNonInPlaceFinalizationFallbackSupported(storageTarget.supportsNonInPlaceFinalizationFallback()); + } + + private void addStorageTargetFinding(VmwareCbtStorageTarget storageTarget, PreflightFindingCollector findings) { + if (storageTarget == null) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + String resource = pool == null ? null : pool.getUuid(); + if (storageTarget.isSupported()) { + findings.pass("destinationStorage.writerSupported", "storage", resource, storageTarget.getSupportMessage()); + } else { + findings.fail("destinationStorage.writerSupported", "storage", resource, storageTarget.getSupportMessage()); + } + } + + private void addStorageTargetFinalizationFinding(VmwareCbtStorageTarget storageTarget, HostVO cbtHost, + PreflightFindingCollector findings) { + if (storageTarget == null || !storageTarget.isSupported()) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + String resource = pool == null ? null : pool.getUuid(); + if (hostSupportsInPlaceFinalization(cbtHost)) { + findings.pass("destinationStorage.finalizationSupported", "storage", resource, + String.format("KVM host %s reports VMware CBT in-place finalization support.", + cbtHost.getName())); + return; + } + if (cbtHost == null) { + findings.fail("destinationStorage.finalizationSupported", "storage", resource, + "No suitable KVM host was selected for VMware CBT finalization."); + return; + } + if (isNonInPlaceFinalizationFallbackAllowed(storageTarget)) { + findings.pass("destinationStorage.finalizationSupported", "storage", resource, + String.format("KVM host %s does not report VMware CBT in-place finalization support, but non-in-place fallback is explicitly enabled for qcow2 file targets. CloudStack will stage fallback temporary data on the selected primary storage and verify free space before cutover.", + cbtHost.getName())); + return; + } + if (storageTarget.supportsNonInPlaceFinalizationFallback()) { + findings.fail("destinationStorage.finalizationSupported", "storage", resource, + String.format("KVM host %s does not report '%s=true'. Ensure virt-v2v-in-place or virt-v2v --in-place is available, or explicitly allow non-in-place fallback finalization with %s.", + cbtHost.getName(), Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT, + VmwareCbtAllowNonInPlaceFinalization.key())); + } else { + findings.fail("destinationStorage.finalizationSupported", "storage", resource, + String.format("Destination storage requires virt-v2v in-place finalization, but KVM host %s does not report '%s=true'. Ensure virt-v2v-in-place or virt-v2v --in-place is available on the conversion host.", + cbtHost.getName(), Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT)); + } + } + + private void addRbdStorageAccessFinding(VmwareCbtStorageTarget storageTarget, HostVO cbtHost, + PreflightFindingCollector findings) { + if (!requiresRbdStorageSupport(storageTarget)) { + return; + } + StoragePoolVO pool = storageTarget.getPool(); + String resource = pool == null ? null : pool.getUuid(); + if (cbtHost == null) { + findings.fail("destinationStorage.rbdAccess", "storage", resource, + "No suitable KVM host was selected for the RBD access probe."); + return; + } + Answer answer = probeRbdStorageAccess(storageTarget, cbtHost); + if (answer != null && answer.getResult()) { + findings.pass("destinationStorage.rbdAccess", "storage", resource, + String.format("KVM host %s can create, write, read and delete a temporary RBD image in storage pool %s.", + cbtHost.getName(), pool.getName())); + } else { + findings.fail("destinationStorage.rbdAccess", "storage", resource, getRbdProbeFailureMessage(answer, cbtHost, pool)); + } + } + + private void validateRbdStorageAccessForStart(VmwareCbtStorageTarget storageTarget, HostVO cbtHost) { + if (!requiresRbdStorageSupport(storageTarget)) { + return; + } + Answer answer = probeRbdStorageAccess(storageTarget, cbtHost); + if (answer == null || !answer.getResult()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + getRbdProbeFailureMessage(answer, cbtHost, storageTarget.getPool())); + } + } + + private Answer probeRbdStorageAccess(VmwareCbtStorageTarget storageTarget, HostVO cbtHost) { + StoragePoolVO pool = storageTarget.getPool(); + VmwareCbtRbdProbeCommand command = new VmwareCbtRbdProbeCommand(pool.getPoolType(), pool.getUuid(), + RBD_PROBE_IMAGE_PREFIX + UUID.randomUUID()); + command.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + try { + return agentManager.send(cbtHost.getId(), command); + } catch (AgentUnavailableException | OperationTimedoutException e) { + String details = String.format("Unable to run VMware CBT RBD storage probe on host %s for storage pool %s: %s", + cbtHost.getName(), pool.getName(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + LOGGER.warn(details, e); + return new Answer(command, false, details); + } + } + + private String getRbdProbeFailureMessage(Answer answer, HostVO cbtHost, StoragePoolVO pool) { + String details = answer == null ? null : answer.getDetails(); + if (StringUtils.isNotBlank(details)) { + return details; + } + String hostName = cbtHost == null ? "selected KVM host" : cbtHost.getName(); + String poolName = pool == null ? "selected RBD storage pool" : pool.getName(); + return String.format("KVM host %s cannot verify access to RBD storage pool %s. Verify CloudStack RBD primary-storage configuration, Ceph monitor connectivity, qemu RBD block-driver support, librados/librbd client library versions, Java RADOS/RBD bindings and Ceph authentication.", + hostName, poolName); + } + + private void populateSourceVmPreflight(VmwareCbtMigrationPreflightResponse response, VmwareSource source, + String sourceVmName, HostVO cbtHost, Long serviceOfferingId, + Map details, DataCenterVO zone, + PreflightFindingCollector findings) { + try { + VmwareCbtPreflightInfo preflightInfo = getVmwareCbtMigrationService().getPreflightInfo(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, sourceVmName); + response.setSourceVmMor(preflightInfo.getSourceVmMor()); + response.setChangeTrackingSupported(preflightInfo.getChangeTrackingSupported()); + response.setChangeTrackingEnabled(preflightInfo.getChangeTrackingEnabled()); + response.setConsolidationNeeded(preflightInfo.getConsolidationNeeded()); + response.setExistingSnapshotCount(preflightInfo.getExistingSnapshotCount()); + response.setSourceCpuNumber(preflightInfo.getCpuCores()); + response.setSourceCpuSpeed(preflightInfo.getCpuSpeed()); + response.setSourceMemory(preflightInfo.getMemoryMb()); + response.setSourceGuestOsId(preflightInfo.getOperatingSystemId()); + response.setSourceGuestOs(preflightInfo.getOperatingSystem()); + response.setDisks(createPreflightDiskResponses(preflightInfo.getDisks())); + addSourceVmFindings(preflightInfo, findings); + addWindowsGuestConversionSupportFinding(cbtHost, sourceVmName, preflightInfo, details, findings); + addServiceOfferingResourceFindings(preflightInfo, serviceOfferingId, details, zone, findings); + } catch (RuntimeException e) { + findings.fail("sourceVm.inspect", "vmware", sourceVmName, + sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source)); + } + } + + private void validateWindowsGuestConversionSupportForStart(HostVO cbtHost, String sourceVmName, + VmwareCbtPreflightInfo preflightInfo, + Map details) { + if (!isWindowsSourceVm(preflightInfo)) { + return; + } + CheckConvertInstanceAnswer answer = checkWindowsGuestConversionSupportOnHost(cbtHost, sourceVmName, details); + if (!answer.getResult()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + getWindowsGuestConversionSupportError(cbtHost, sourceVmName, answer)); + } + } + + private void addWindowsGuestConversionSupportFinding(HostVO cbtHost, String sourceVmName, + VmwareCbtPreflightInfo preflightInfo, + Map details, + PreflightFindingCollector findings) { + if (!isWindowsSourceVm(preflightInfo)) { + return; + } + if (cbtHost == null) { + findings.fail("destinationHost.windowsGuestConversionSupport", "host", null, + "No suitable KVM host was selected to validate Windows guest conversion support."); + return; + } + try { + CheckConvertInstanceAnswer answer = checkWindowsGuestConversionSupportOnHost(cbtHost, sourceVmName, details); + if (answer.getResult()) { + findings.pass("destinationHost.windowsGuestConversionSupport", "host", cbtHost.getUuid(), + String.format("KVM host %s reports Windows guest conversion support.", cbtHost.getName())); + return; + } + findings.fail("destinationHost.windowsGuestConversionSupport", "host", cbtHost.getUuid(), + getWindowsGuestConversionSupportError(cbtHost, sourceVmName, answer)); + } catch (ServerApiException e) { + findings.fail("destinationHost.windowsGuestConversionSupport", "host", cbtHost.getUuid(), + e.getDescription()); + } + } + + private CheckConvertInstanceAnswer checkWindowsGuestConversionSupportOnHost(HostVO cbtHost, String sourceVmName, + Map details) { + CheckConvertInstanceCommand command = new CheckConvertInstanceCommand(true, true); + if (MapUtils.isNotEmpty(details)) { + command.setVddkLibDir(StringUtils.trimToNull(details.get(Host.HOST_VDDK_LIB_DIR))); + } + command.setWait(60); + try { + return (CheckConvertInstanceAnswer) agentManager.send(cbtHost.getId(), command); + } catch (AgentUnavailableException | OperationTimedoutException e) { + String error = String.format("Failed to check windows guest conversion support on host %s for VMware CBT migration of source VM %s due to: %s", + cbtHost, sourceVmName, e.getMessage()); + LOGGER.error(error, e); + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, error); + } + } + + private String getWindowsGuestConversionSupportError(HostVO cbtHost, String sourceVmName, + CheckConvertInstanceAnswer answer) { + return String.format("The host %s doesn't support conversion of instance %s from VMware to KVM due to: %s", + cbtHost, sourceVmName, StringUtils.defaultIfBlank(answer.getDetails(), "unknown reason")); + } + + static boolean isWindowsSourceVm(VmwareCbtPreflightInfo preflightInfo) { + return preflightInfo != null && + (StringUtils.containsIgnoreCase(preflightInfo.getOperatingSystemId(), "windows") || + StringUtils.containsIgnoreCase(preflightInfo.getOperatingSystem(), "windows")); + } + + private void addSourceVmFindings(VmwareCbtPreflightInfo preflightInfo, PreflightFindingCollector findings) { + String sourceVmName = preflightInfo.getSourceVmName(); + findings.pass("sourceVm.found", "vmware", sourceVmName, "Source VM was found in vCenter."); + + if (Boolean.FALSE.equals(preflightInfo.getChangeTrackingSupported())) { + findings.fail("vmCapability.changeTrackingSupported", "vmware", sourceVmName, + "Source VM does not report VMware CBT support."); + } else if (Boolean.TRUE.equals(preflightInfo.getChangeTrackingSupported())) { + findings.pass("vmCapability.changeTrackingSupported", "vmware", sourceVmName, + "Source VM reports VMware CBT support."); + } else { + findings.warn("vmCapability.changeTrackingSupported", "vmware", sourceVmName, + "Source VM CBT capability could not be determined."); + } + + if (Boolean.TRUE.equals(preflightInfo.getChangeTrackingEnabled())) { + findings.pass("vmConfig.changeTrackingEnabled", "vmware", sourceVmName, + "CBT is already enabled on the source VM."); + } else if (Boolean.FALSE.equals(preflightInfo.getChangeTrackingEnabled())) { + findings.warn("vmConfig.changeTrackingEnabled", "vmware", sourceVmName, + "CBT is not currently enabled; startVmwareCbtMigration will enable it before creating the baseline snapshot."); + } else { + findings.warn("vmConfig.changeTrackingEnabled", "vmware", sourceVmName, + "CBT enabled state could not be determined."); + } + + if (Boolean.TRUE.equals(preflightInfo.getConsolidationNeeded())) { + findings.fail("vmRuntime.consolidationNeeded", "vmware", sourceVmName, + "Source VM reports that disk consolidation is needed."); + } else if (Boolean.FALSE.equals(preflightInfo.getConsolidationNeeded())) { + findings.pass("vmRuntime.consolidationNeeded", "vmware", sourceVmName, + "Source VM does not report pending disk consolidation."); + } else { + findings.warn("vmRuntime.consolidationNeeded", "vmware", sourceVmName, + "Source VM consolidation state could not be determined."); + } + + if (preflightInfo.getExistingSnapshotCount() != null && preflightInfo.getExistingSnapshotCount() > 0) { + findings.warn("vmSnapshot.existingSnapshots", "vmware", sourceVmName, + String.format("Source VM has %s existing VMware snapshot(s). Initial full copy may still work, but CBT migration is safer after snapshot consolidation.", + preflightInfo.getExistingSnapshotCount())); + } else { + findings.pass("vmSnapshot.existingSnapshots", "vmware", sourceVmName, + "Source VM has no existing VMware snapshots."); + } + + if (CollectionUtils.isEmpty(preflightInfo.getDisks())) { + findings.fail("sourceVm.disks.present", "vmware", sourceVmName, + "No source VMware disks were discovered."); + return; + } + for (VmwareCbtPreflightDiskInfo disk : preflightInfo.getDisks()) { + addSourceDiskFindings(disk, findings); + } + } + + private void addServiceOfferingResourceFindings(VmwareCbtPreflightInfo preflightInfo, Long serviceOfferingId, + Map details, DataCenterVO zone, + PreflightFindingCollector findings) { + if (serviceOfferingId == null) { + findings.warn("serviceOffering.present", "serviceoffering", null, + "No service offering was provided; source VM CPU and memory sizing was not validated."); + return; + } + + Account caller = CallContext.current().getCallingAccount(); + ServiceOfferingVO serviceOffering = getPreflightServiceOffering(serviceOfferingId, caller, zone, findings); + if (serviceOffering == null) { + return; + } + + VmwareCbtOfferingResources requestedResources; + try { + requestedResources = resolveRequestedOfferingResources(serviceOffering, details); + } catch (ServerApiException e) { + findings.fail("serviceOffering.resources.valid", "serviceoffering", serviceOffering.getUuid(), e.getDescription()); + return; + } + + addServiceOfferingResourceFinding(preflightInfo.getCpuCores(), requestedResources.getCpuNumber(), + "serviceOffering.cpuNumber", "CPU number", serviceOffering, findings); + addServiceOfferingResourceFinding(preflightInfo.getCpuSpeed(), requestedResources.getCpuSpeed(), + "serviceOffering.cpuSpeed", "CPU speed", serviceOffering, findings); + addServiceOfferingResourceFinding(preflightInfo.getMemoryMb(), requestedResources.getMemoryMb(), + "serviceOffering.memory", "Memory", serviceOffering, findings); + } + + private ServiceOfferingVO getPreflightServiceOffering(Long serviceOfferingId, Account caller, DataCenterVO zone, + PreflightFindingCollector findings) { + ServiceOfferingVO serviceOffering = serviceOfferingDao.findById(serviceOfferingId); + if (serviceOffering == null) { + findings.fail("serviceOffering.present", "serviceoffering", String.valueOf(serviceOfferingId), + String.format("Service offering ID %d cannot be found.", serviceOfferingId)); + return null; + } + try { + if (caller != null && zone != null) { + accountService.checkAccess(caller, serviceOffering, zone); + } + serviceOfferingDao.loadDetails(serviceOffering); + findings.pass("serviceOffering.present", "serviceoffering", serviceOffering.getUuid(), + "Service offering was found."); + return serviceOffering; + } catch (RuntimeException e) { + findings.fail("serviceOffering.access", "serviceoffering", serviceOffering.getUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + return null; + } + } + + private void addServiceOfferingResourceFinding(Integer sourceResource, Integer requestedResource, + String code, String resourceName, ServiceOfferingVO serviceOffering, + PreflightFindingCollector findings) { + if (sourceResource == null || sourceResource <= 0) { + findings.warn(code, "serviceoffering", serviceOffering.getUuid(), + String.format("Source VM %s could not be determined.", resourceName)); + return; + } + if (requestedResource == null || requestedResource <= 0) { + findings.warn(code, "serviceoffering", serviceOffering.getUuid(), + String.format("Selected service offering %s could not be determined.", resourceName)); + return; + } + if (requestedResource < sourceResource) { + findings.fail(code, "serviceoffering", serviceOffering.getUuid(), + String.format("The requested %s (%d) is less than the source VM %s (%d).", + resourceName, requestedResource, resourceName, sourceResource)); + return; + } + findings.pass(code, "serviceoffering", serviceOffering.getUuid(), + String.format("The requested %s (%d) is sufficient for the source VM %s (%d).", + resourceName, requestedResource, resourceName, sourceResource)); + } + + private void addSourceDiskFindings(VmwareCbtPreflightDiskInfo disk, PreflightFindingCollector findings) { + String diskId = StringUtils.defaultIfBlank(disk.getSourceDiskId(), disk.getSourceDiskPath()); + if (disk.getSourceDiskDeviceKey() == null) { + findings.fail("disk.deviceKey.present", "vmware", diskId, + "Source disk device key is missing; CBT changed-area queries require a disk device key."); + } else { + findings.pass("disk.deviceKey.present", "vmware", diskId, + "Source disk device key is available."); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + findings.fail("disk.backing.path.present", "vmware", diskId, + "Source disk backing path is missing."); + } else { + findings.pass("disk.backing.path.present", "vmware", diskId, + "Source disk backing path is available."); + } + if (disk.isIndependentDisk()) { + findings.fail("disk.mode.independent", "vmware", diskId, + String.format("Source disk mode %s is independent; independent disks are not supported for VMware CBT migration.", + disk.getDiskMode())); + } else { + findings.pass("disk.mode.independent", "vmware", diskId, + "Source disk is not configured as independent."); + } + if (disk.isPhysicalRdm()) { + findings.fail("disk.rdm.physical", "vmware", diskId, + "Source disk is a physical-mode RDM; physical RDM disks are not supported for VMware CBT migration."); + } else { + findings.pass("disk.rdm.physical", "vmware", diskId, + "Source disk is not a physical-mode RDM."); + } + if (StringUtils.isBlank(disk.getChangeId())) { + findings.warn("disk.changeId.present", "vmware", diskId, + "A current CBT changeId is not visible for this disk. This can be normal before CloudStack enables CBT and creates the baseline snapshot; startVmwareCbtMigration will validate the baseline snapshot changeId after the initial full sync."); + } else { + findings.pass("disk.changeId.present", "vmware", diskId, + "A current CBT changeId is visible for this disk."); + } + } + + private List createPreflightDiskResponses(List disks) { + List responses = new ArrayList<>(); + if (CollectionUtils.isEmpty(disks)) { + return responses; + } + for (VmwareCbtPreflightDiskInfo disk : disks) { + VmwareCbtMigrationPreflightDiskResponse response = new VmwareCbtMigrationPreflightDiskResponse(); + response.setObjectName("disk"); + response.setSourceDiskId(disk.getSourceDiskId()); + response.setSourceDiskDeviceKey(disk.getSourceDiskDeviceKey()); + response.setSourceDiskPath(disk.getSourceDiskPath()); + response.setDatastoreName(disk.getDatastoreName()); + response.setCapacityBytes(disk.getCapacityBytes()); + response.setBackingType(disk.getBackingType()); + response.setDiskMode(disk.getDiskMode()); + response.setRdmCompatibilityMode(disk.getRdmCompatibilityMode()); + response.setIndependentDisk(disk.isIndependentDisk()); + response.setPhysicalRdm(disk.isPhysicalRdm()); + response.setChangeIdPresent(StringUtils.isNotBlank(disk.getChangeId())); + responses.add(response); + } + return responses; + } + + private VmwareSource resolveVmwareSource(StartVmwareCbtMigrationCmd cmd) { + return resolveVmwareSource(cmd.getExistingVcenterId(), cmd.getVcenter(), cmd.getDatacenterName(), + cmd.getUsername(), cmd.getPassword(), cmd.getSourceHost()); + } + + private VmwareSource resolveVmwareSource(Long existingVcenterId, String vcenter, String datacenterName, + String username, String password, String sourceHost) { + if ((existingVcenterId == null && StringUtils.isBlank(vcenter)) || + (existingVcenterId != null && StringUtils.isNotBlank(vcenter))) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Please provide an existing vCenter ID or a vCenter IP/Name, parameters are mutually exclusive"); + } + + if (existingVcenterId != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(existingVcenterId); + if (existingDc == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find any existing VMware datacenter with ID %s", existingVcenterId)); + } + return new VmwareSource(existingDc.getId(), existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + existingDc.getUser(), existingDc.getPassword(), sourceHost); + } + + if (StringUtils.isAnyBlank(vcenter, datacenterName, username, password)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please set all the information for a vCenter IP/Name, datacenter, username and password"); + } + return new VmwareSource(null, vcenter, datacenterName, username, password, sourceHost); + } + + private VmwareSource resolveVmwareSource(VmwareCbtMigrationVO migration, String username, String password) { + if (migration.getExistingVcenterId() != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(migration.getExistingVcenterId()); + if (existingDc == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find any existing VMware datacenter with ID %s", + migration.getExistingVcenterId())); + } + return new VmwareSource(existingDc.getId(), existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + existingDc.getUser(), existingDc.getPassword(), migration.getSourceHost()); + } + + boolean usernameOverrideProvided = StringUtils.isNotBlank(username); + boolean passwordOverrideProvided = StringUtils.isNotBlank(password); + if (usernameOverrideProvided != passwordOverrideProvided) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please provide both source vCenter username and password when overriding stored VMware CBT migration credentials"); + } + + String resolvedUsername = usernameOverrideProvided ? username : migration.getSourceUsername(); + String resolvedPassword = passwordOverrideProvided ? password : migration.getSourcePassword(); + if (StringUtils.isAnyBlank(migration.getVcenter(), migration.getDatacenter(), resolvedUsername, resolvedPassword)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please provide source vCenter username and password for this VMware CBT migration"); + } + + if (usernameOverrideProvided) { + migration.setSourceUsername(resolvedUsername); + migration.setSourcePassword(resolvedPassword); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + return new VmwareSource(null, migration.getVcenter(), migration.getDatacenter(), resolvedUsername, resolvedPassword, + migration.getSourceHost()); + } + + private void storeExternalVmwareSourceCredentials(VmwareCbtMigrationVO migration, VmwareSource source) { + if (source.existingVcenterId != null) { + return; + } + migration.setSourceUsername(source.username); + migration.setSourcePassword(source.password); + } + + private void clearStoredSourceCredentials(VmwareCbtMigrationVO migration) { + if (migration == null || + (StringUtils.isBlank(migration.getSourceUsername()) && StringUtils.isBlank(migration.getSourcePassword()))) { + return; + } + migration.setSourceUsername(null); + migration.setSourcePassword(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + + private VmwareCbtMigration.State parseState(String state) { + if (StringUtils.isBlank(state)) { + return null; + } + try { + return VmwareCbtMigration.State.getValue(state); + } catch (IllegalArgumentException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } + } + + private VmwareCbtMigrationVO getMigration(Long id) { + VmwareCbtMigrationVO migration = vmwareCbtMigrationDao.findById(id); + if (migration == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find VMware CBT migration with ID %s", id)); + } + return migration; + } + + private void rejectTerminalMigration(VmwareCbtMigrationVO migration, String action) { + if (migration.getState().isTerminal()) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot %s VMware CBT migration %s because it is already in state %s", + action, migration.getUuid(), migration.getState())); + } + } + + private void requireMigrationState(VmwareCbtMigrationVO migration, String action, + VmwareCbtMigration.State... allowedStates) { + for (VmwareCbtMigration.State allowedState : allowedStates) { + if (migration.getState() == allowedState) { + return; + } + } + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot %s VMware CBT migration %s while it is in state %s. Expected state: %s", + action, migration.getUuid(), migration.getState(), StringUtils.join(allowedStates, ", "))); + } + + private List discoverSourceDisks(VmwareSource source, String sourceVmName) { + List sourceDisks = getVmwareCbtMigrationService().listSourceDisks(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + if (CollectionUtils.isEmpty(sourceDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No source disks were discovered for VMware VM %s", sourceVmName)); + } + return sourceDisks; + } + + private void persistSourceDisks(VmwareCbtMigrationVO migration, List sourceDisks) { + for (VmwareCbtDiskInfo sourceDisk : sourceDisks) { + VmwareCbtMigrationDiskVO disk = new VmwareCbtMigrationDiskVO(migration.getId(), + StringUtils.defaultIfBlank(sourceDisk.getSourceDiskId(), sourceDisk.getLabel()), + sourceDisk.getSourceDiskDeviceKey(), sourceDisk.getSourceDiskPath(), sourceDisk.getDatastoreName(), + sourceDisk.getCapacityBytes()); + disk.setChangeId(sourceDisk.getChangeId()); + disk.setTargetFormat("qcow2"); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.persist(disk); + } + } + + private void prepareInitialSyncDiskTargets(VmwareCbtMigrationVO migration, VmwareCbtStorageTarget storageTarget) { + if (storageTarget == null) { + return; + } + + String targetBasePath = storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.QCOW2_FILE ? + getQcow2InitialSyncTargetBasePath(migration, storageTarget) : null; + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + String targetFormat = getInitialSyncTargetFormat(storageTarget); + disk.setTargetFormat(targetFormat); + if (StringUtils.isBlank(disk.getTargetPath())) { + disk.setTargetPath(storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW ? + getRbdInitialSyncTargetImageName(migration, disk) : + String.format("%s/%s", targetBasePath, getTargetDiskFileName(disk, targetFormat))); + } + disk.setState(VmwareCbtMigrationDisk.State.Syncing); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + + private String getInitialSyncTargetFormat(VmwareCbtStorageTarget storageTarget) { + if (storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + return "raw"; + } + return "qcow2"; + } + + private String getQcow2InitialSyncTargetBasePath(VmwareCbtMigrationVO migration, VmwareCbtStorageTarget storageTarget) { + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + if (storagePool == null) { + return String.format("%s/%s", DEFAULT_CBT_DISK_BASE_PATH, migration.getUuid()); + } + return String.format("%s/%s/cloudstack-cbt/%s", KVM_STORAGE_POOL_MOUNT_BASE_PATH, storagePool.getUuid(), + migration.getUuid()); + } + + private String getTargetDiskFileName(VmwareCbtMigrationDiskVO disk, String targetFormat) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getSourceDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getSourceDiskId()), "."); + return String.format("%s-%s.%s", sanitizeFileName(disk.getSourceDiskId()), sanitizeFileName(sourceName), + sanitizeFileName(targetFormat)); + } + + private String getRbdInitialSyncTargetImageName(VmwareCbtMigrationVO migration, VmwareCbtMigrationDiskVO disk) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getSourceDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getSourceDiskId()), "."); + return String.format("cloudstack-cbt-%s-%s-%s", sanitizeFileName(migration.getUuid()), + sanitizeFileName(disk.getSourceDiskId()), sanitizeFileName(sourceName)); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private VmwareCbtMigrationResponse runInitialFullSync(VmwareSource source, VmwareCbtMigrationVO migration, + VmwareCbtStorageTarget storageTarget) { + VmwareCbtSnapshotInfo baselineSnapshot = null; + try { + migration.setCurrentStep("Creating VMware CBT baseline snapshot"); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + + baselineSnapshot = createBaselineSnapshot(source, migration); + prepareInitialSyncDiskTargets(migration, storageTarget); + + migration.setCurrentStep("Initial VDDK full sync is running on KVM agent"); + migration.setLastError(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + + HostVO cbtHost = getCbtHostForMigration(migration); + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + VmwareCbtPrepareCommand prepareCommand = new VmwareCbtPrepareCommand(migration.getUuid(), + createRemoteInstance(source, migration, baselineSnapshot.getSourceVmMor()), getDiskTransferObjects(migration), + storagePool == null ? null : storagePool.getPoolType(), + storagePool == null ? null : storagePool.getUuid(), + storageTarget == null ? null : storageTarget.getTargetStorageType(), + baselineSnapshot.getSnapshotMor()); + applyVddkDetails(prepareCommand, migration); + prepareCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, prepareCommand, "prepare", + migration.getUuid()); + if (!answer.getResult()) { + markInitialSyncDisksFailed(migration); + markMigrationFailed(migration, "Initial VDDK full sync failed", sanitizeSensitiveMessage(answer.getDetails(), source)); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } + + applyDiskResults(migration, answer.getDiskResults()); + migration.setCurrentStep("Recording VMware CBT baseline metadata"); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + + refreshBaselineDiskChangeIds(source, migration, baselineSnapshot.getSnapshotMor()); + validateInitialSyncTargetDisks(migration); + migration = vmwareCbtMigrationDao.findById(migration.getId()); + migration.setState(VmwareCbtMigration.State.Replicating); + migration.setCurrentStep("Initial VDDK full sync completed; ready for CBT delta synchronization"); + migration.setLastError(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + return createVmwareCbtMigrationResponse(migration); + } catch (RuntimeException e) { + String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + markInitialSyncDisksFailed(migration); + markMigrationFailed(migration, "Initial VDDK full sync failed", error); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } finally { + removeDeltaSnapshotIfPossible(source, migration, baselineSnapshot); + } + } + + private VmwareCbtSnapshotInfo createBaselineSnapshot(VmwareSource source, VmwareCbtMigrationVO migration) { + String snapshotName = String.format("cloudstack-cbt-%s-baseline", migration.getUuid()); + String description = String.format("CloudStack VMware CBT migration %s baseline", migration.getUuid()); + return getVmwareCbtMigrationService().createSnapshot(source.vcenter, source.datacenterName, source.username, + source.password, source.sourceHost, migration.getSourceVmName(), snapshotName, description, false); + } + + private void refreshBaselineDiskChangeIds(VmwareSource source, VmwareCbtMigrationVO migration, + String snapshotMor) { + List sourceDisks = getVmwareCbtMigrationService().listSnapshotDisks(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName(), snapshotMor); + if (CollectionUtils.isEmpty(sourceDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No source disks were discovered in VMware CBT baseline snapshot %s for VM %s", + snapshotMor, migration.getSourceVmName())); + } + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + VmwareCbtDiskInfo sourceDisk = findSourceDisk(sourceDisks, disk); + if (sourceDisk == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("Unable to refresh VMware CBT baseline metadata for source disk %s", + disk.getSourceDiskId())); + } + if (sourceDisk.getSourceDiskDeviceKey() != null) { + disk.setSourceDiskDeviceKey(sourceDisk.getSourceDiskDeviceKey()); + } + if (StringUtils.isNotBlank(sourceDisk.getChangeId())) { + disk.setChangeId(sourceDisk.getChangeId()); + } + disk.setSnapshotMor(snapshotMor); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + + private VmwareCbtDiskInfo findSourceDisk(List sourceDisks, VmwareCbtMigrationDiskVO disk) { + for (VmwareCbtDiskInfo sourceDisk : sourceDisks) { + if (StringUtils.equals(sourceDisk.getSourceDiskId(), disk.getSourceDiskId()) || + StringUtils.equals(sourceDisk.getSourceDiskPath(), disk.getSourceDiskPath()) || + (sourceDisk.getSourceDiskDeviceKey() != null && + sourceDisk.getSourceDiskDeviceKey().equals(disk.getSourceDiskDeviceKey()))) { + return sourceDisk; + } + } + return null; + } + + private void validateInitialSyncTargetDisks(VmwareCbtMigrationVO migration) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + if (CollectionUtils.isEmpty(disks)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s has no discovered source disks", migration.getUuid())); + } + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s cannot run delta sync before initial full sync registers target disk path for source disk %s", + migration.getUuid(), disk.getSourceDiskId())); + } + if (StringUtils.isBlank(disk.getChangeId())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s completed initial full sync but cannot run delta sync before CloudStack records a baseline change ID for source disk %s", + migration.getUuid(), disk.getSourceDiskId())); + } + } + } + + private VmwareCbtSnapshotInfo createDeltaSnapshot(VmwareSource source, VmwareCbtMigrationVO migration, + int cycleNumber) { + String snapshotName = String.format("cloudstack-cbt-%s-%s", migration.getUuid(), cycleNumber); + String description = String.format("CloudStack VMware CBT migration %s cycle %s", migration.getUuid(), + cycleNumber); + return getVmwareCbtMigrationService().createSnapshot(source.vcenter, source.datacenterName, source.username, + source.password, source.sourceHost, migration.getSourceVmName(), snapshotName, description, false); + } + + private VmwareCbtChangedBlockQueryResult queryChangedBlocks(VmwareSource source, VmwareCbtMigrationVO migration, + String snapshotMor) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + List sourceDisks = new ArrayList<>(); + for (VmwareCbtMigrationDiskVO disk : disks) { + sourceDisks.add(new VmwareCbtDiskInfo(disk.getSourceDiskId(), disk.getSourceDiskDeviceKey(), null, + disk.getSourceDiskPath(), disk.getDatastoreName(), disk.getCapacityBytes(), disk.getChangeId())); + disk.setSnapshotMor(snapshotMor); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + + List changedDisks = getVmwareCbtMigrationService().queryChangedDiskAreas(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName(), sourceDisks, snapshotMor); + List changedBlocks = new ArrayList<>(); + for (VmwareCbtChangedDiskInfo changedDisk : changedDisks) { + for (VmwareCbtChangedBlockInfo changedBlock : changedDisk.getChangedBlocks()) { + changedBlocks.add(new VmwareCbtChangedBlockRangeTO(changedDisk.getSourceDiskId(), + changedBlock.getStartOffset(), changedBlock.getLength())); + } + } + return new VmwareCbtChangedBlockQueryResult(changedBlocks, changedDisks); + } + + private void updateDiskChangeIds(VmwareCbtMigrationVO migration, List changedDisks) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + for (VmwareCbtChangedDiskInfo changedDisk : changedDisks) { + if (StringUtils.isNotBlank(changedDisk.getNextChangeId()) && + StringUtils.equals(disk.getSourceDiskId(), changedDisk.getSourceDiskId())) { + disk.setChangeId(changedDisk.getNextChangeId()); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + break; + } + } + } + } + + private VmwareCbtMigrationCutoverPolicy getCutoverPolicy() { + return new VmwareCbtMigrationCutoverPolicy(VmwareCbtMigrationMinCycles.value(), + VmwareCbtMigrationMaxCycles.value(), VmwareCbtMigrationQuietCycles.value(), + VmwareCbtMigrationQuietBytes.value(), VmwareCbtMigrationQuietDirtyRate.value()); + } + + private int getVmwareCbtMigrationAgentCommandTimeout() { + Integer timeout = VmwareCbtMigrationAgentCommandTimeout.value(); + if (timeout == null || timeout <= 0) { + return Integer.parseInt(VmwareCbtMigrationAgentCommandTimeout.defaultValue()); + } + return timeout; + } + + private long getDirtyRateBytesPerSecond(long changedBytes, long durationSeconds, long reportedDirtyRate) { + if (reportedDirtyRate > 0) { + return reportedDirtyRate; + } + if (durationSeconds <= 0) { + return changedBytes; + } + return changedBytes / durationSeconds; + } + + private String getCutoverDecisionStep(VmwareCbtMigrationCutoverPolicy.Decision cutoverDecision) { + switch (cutoverDecision) { + case READY_FOR_CUTOVER: + return "Ready for final cutover"; + case READY_FOR_CUTOVER_MAX_CYCLES: + return "Ready for final cutover after reaching maximum CBT delta cycles"; + case CONTINUE: + default: + return "CBT delta synchronization completed; continue replication cycles"; + } + } + + private void applyDiskResults(VmwareCbtMigrationVO migration, List diskResults) { + if (CollectionUtils.isEmpty(diskResults)) { + return; + } + + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.equals(disk.getSourceDiskId(), diskResult.getDiskId())) { + applyDiskResult(disk, diskResult); + break; + } + } + } + } + + private void applyDiskResult(VmwareCbtMigrationDiskVO disk, VmwareCbtDiskSyncResultTO diskResult) { + if (StringUtils.isNotBlank(diskResult.getTargetPath())) { + disk.setTargetPath(diskResult.getTargetPath()); + } + if (StringUtils.isNotBlank(diskResult.getChangeId())) { + disk.setChangeId(diskResult.getChangeId()); + } + if (StringUtils.isNotBlank(diskResult.getSnapshotMor())) { + disk.setSnapshotMor(diskResult.getSnapshotMor()); + } + disk.setState(diskResult.getResult() ? VmwareCbtMigrationDisk.State.Ready : VmwareCbtMigrationDisk.State.Failed); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + + private void removeDeltaSnapshotIfPossible(VmwareSource source, VmwareCbtMigrationVO migration, + VmwareCbtSnapshotInfo snapshot) { + if (source == null || snapshot == null || StringUtils.isBlank(snapshot.getSnapshotMor())) { + return; + } + + try { + getVmwareCbtMigrationService().removeSnapshot(source.vcenter, source.datacenterName, source.username, + source.password, source.sourceHost, migration.getSourceVmName(), snapshot.getSnapshotMor()); + clearDiskSnapshotMor(migration, snapshot.getSnapshotMor()); + } catch (RuntimeException e) { + String message = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + LOGGER.warn(String.format("Unable to remove VMware CBT snapshot %s for migration %s: %s", + snapshot.getSnapshotMor(), migration.getUuid(), message)); + } + } + + private void clearDiskSnapshotMor(VmwareCbtMigrationVO migration, String snapshotMor) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.equals(disk.getSnapshotMor(), snapshotMor)) { + disk.setSnapshotMor(null); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + } + + private VmwareCbtMigrationService getVmwareCbtMigrationService() { + if (vmwareCbtMigrationService == null) { + throw new ServerApiException(ApiErrorCode.UNSUPPORTED_ACTION_ERROR, + "VMware CBT migration service is unavailable. Please enable the VMware hypervisor plugin."); + } + return vmwareCbtMigrationService; + } + + private HostVO getCbtHostForMigration(VmwareCbtMigrationVO migration) { + ClusterVO destinationCluster = clusterDao.findById(migration.getDestinationClusterId()); + if (destinationCluster == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Unable to find destination cluster for VMware CBT migration"); + } + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + if (migration.getConvertHostId() != null) { + return selectCbtHost(migration.getConvertHostId(), destinationCluster, storageTarget); + } + return selectCbtHost(null, destinationCluster, storageTarget); + } + + private VmwareCbtStorageTarget getStorageTargetForMigration(VmwareCbtMigrationVO migration) { + StoragePoolVO storagePool = migration.getStoragePoolId() == null ? null : + primaryDataStoreDao.findById(migration.getStoragePoolId()); + return VmwareCbtStorageTarget.forPool(storagePool); + } + + private RemoteInstanceTO createRemoteInstance(VmwareSource source, VmwareCbtMigrationVO migration) { + return createRemoteInstance(source, migration, null); + } + + private RemoteInstanceTO createRemoteInstance(VmwareSource source, VmwareCbtMigrationVO migration, + String vmwareMoref) { + return new RemoteInstanceTO(migration.getSourceVmName(), null, source.vcenter, source.username, source.password, + source.datacenterName, migration.getSourceCluster(), migration.getSourceHost(), vmwareMoref); + } + + private void applyVddkDetails(VmwareCbtMigrationVO migration, Map details) { + if (details == null) { + return; + } + migration.setVddkLibDir(StringUtils.trimToNull(details.get(Host.HOST_VDDK_LIB_DIR))); + migration.setVddkTransports(StringUtils.trimToNull(details.get(DETAIL_VDDK_TRANSPORTS))); + migration.setVddkThumbprint(StringUtils.trimToNull(details.get(DETAIL_VDDK_THUMBPRINT))); + } + + private void applyVddkDetails(VmwareCbtSyncCommand command, VmwareCbtMigrationVO migration) { + command.setVddkLibDir(migration.getVddkLibDir()); + command.setVddkTransports(migration.getVddkTransports()); + command.setVddkThumbprint(migration.getVddkThumbprint()); + } + + private void applyVddkDetails(VmwareCbtPrepareCommand command, VmwareCbtMigrationVO migration) { + command.setVddkLibDir(migration.getVddkLibDir()); + command.setVddkTransports(migration.getVddkTransports()); + command.setVddkThumbprint(migration.getVddkThumbprint()); + } + + private void applyVddkDetails(VmwareCbtCutoverCommand command, VmwareCbtMigrationVO migration) { + command.setVddkLibDir(migration.getVddkLibDir()); + command.setVddkTransports(migration.getVddkTransports()); + command.setVddkThumbprint(migration.getVddkThumbprint()); + } + + private void applyTargetStorageDetails(VmwareCbtSyncCommand command, VmwareCbtMigrationVO migration) { + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + command.setDestinationStoragePoolType(storagePool == null ? null : storagePool.getPoolType()); + command.setDestinationStoragePoolUuid(storagePool == null ? null : storagePool.getUuid()); + command.setTargetStorageType(storageTarget == null ? null : storageTarget.getTargetStorageType()); + } + + private void applyTargetStorageDetails(VmwareCbtCutoverCommand command, VmwareCbtMigrationVO migration) { + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + command.setDestinationStoragePoolType(storagePool == null ? null : storagePool.getPoolType()); + command.setDestinationStoragePoolUuid(storagePool == null ? null : storagePool.getUuid()); + command.setTargetStorageType(storageTarget == null ? null : storageTarget.getTargetStorageType()); + } + + private void applyTargetStorageDetails(VmwareCbtCleanupCommand command, VmwareCbtMigrationVO migration) { + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + StoragePoolVO storagePool = storageTarget == null ? null : storageTarget.getPool(); + command.setDestinationStoragePoolType(storagePool == null ? null : storagePool.getPoolType()); + command.setDestinationStoragePoolUuid(storagePool == null ? null : storagePool.getUuid()); + command.setTargetStorageType(storageTarget == null ? null : storageTarget.getTargetStorageType()); + } + + private List getDiskTransferObjects(VmwareCbtMigrationVO migration) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + List diskTOs = new ArrayList<>(); + for (VmwareCbtMigrationDiskVO disk : disks) { + diskTOs.add(new VmwareCbtDiskTO(disk.getSourceDiskId(), disk.getSourceDiskDeviceKey(), + disk.getSourceDiskPath(), disk.getDatastoreName(), disk.getTargetPath(), disk.getTargetFormat(), + disk.getChangeId(), disk.getSnapshotMor(), disk.getCapacityBytes() == null ? 0L : disk.getCapacityBytes())); + } + return diskTOs; + } + + private VmwareCbtMigrationAnswer sendVmwareCbtCommand(HostVO host, Command command, String action, String migrationUuid) { + try { + Answer answer = agentManager.send(host.getId(), command); + if (answer instanceof VmwareCbtMigrationAnswer) { + return (VmwareCbtMigrationAnswer) answer; + } + return new VmwareCbtMigrationAnswer(command, answer.getResult(), answer.getDetails(), migrationUuid); + } catch (AgentUnavailableException | OperationTimedoutException e) { + String message = String.format("Failed to %s VMware CBT migration %s on host %s due to: %s", + action, migrationUuid, host.getName(), e.getMessage()); + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, message, e); + } + } + + private void markCycleFailed(VmwareCbtMigrationCycleVO cycle, String details) { + cycle.setState(VmwareCbtMigrationCycle.State.Failed); + cycle.setDescription(details); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + } + + private void markMigrationFailed(VmwareCbtMigrationVO migration, String currentStep, String details) { + migration.setState(VmwareCbtMigration.State.Failed); + migration.setCurrentStep(currentStep); + migration.setLastError(details); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + } + + private void markInitialSyncDisksFailed(VmwareCbtMigrationVO migration) { + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + if (disk.getState() == VmwareCbtMigrationDisk.State.Syncing || disk.getState() == VmwareCbtMigrationDisk.State.Created) { + disk.setState(VmwareCbtMigrationDisk.State.Failed); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + } + + private String sanitizeSensitiveMessage(String message, VmwareSource source) { + String sanitized = StringUtils.defaultString(message); + if (source == null) { + return sanitized; + } + return redactValue(sanitized, source.password); + } + + private String redactValue(String message, String sensitiveValue) { + if (StringUtils.isAnyBlank(message, sensitiveValue)) { + return message; + } + return StringUtils.replace(message, sensitiveValue, REDACTED_SECRET); + } + + private void sendCleanupCommandIfPossible(VmwareCbtMigrationVO migration) { + sendCleanupCommand(migration, false, CLEANUP_WAIT_SECONDS); + } + + private void sendCleanupCommandForDelete(VmwareCbtMigrationVO migration) { + if (hasActiveMigrationOnSameConvertHost(migration)) { + LOGGER.warn("Skipping immediate cleanup for VMware CBT migration {} because conversion host {} is busy with another active VMware CBT migration. " + + "The migration record will be removed; any leftover temporary files under cloudstack-cbt can be cleaned later.", + migration.getUuid(), migration.getConvertHostId()); + return; + } + sendCleanupCommand(migration, true, DELETE_CLEANUP_WAIT_SECONDS); + } + + private boolean hasActiveMigrationOnSameConvertHost(VmwareCbtMigrationVO migration) { + if (migration.getConvertHostId() == null) { + return false; + } + for (VmwareCbtMigrationVO otherMigration : vmwareCbtMigrationDao.listByConvertHostId(migration.getConvertHostId())) { + if (otherMigration.getId() != migration.getId() && !otherMigration.getState().isTerminal()) { + return true; + } + } + return false; + } + + private void sendCleanupCommand(VmwareCbtMigrationVO migration, boolean failOnCleanupError, int waitSeconds) { + if (!hasCleanupTargetDisks(migration)) { + return; + } + try { + HostVO cbtHost = getCbtHostForMigration(migration); + VmwareCbtCleanupCommand cleanupCommand = new VmwareCbtCleanupCommand(migration.getUuid(), getDiskTransferObjects(migration), + true, true, true); + applyTargetStorageDetails(cleanupCommand, migration); + cleanupCommand.setWait(waitSeconds); + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, cleanupCommand, "clean up", migration.getUuid()); + if (!answer.getResult()) { + handleCleanupFailure(migration, failOnCleanupError, answer.getDetails()); + } + } catch (ServerApiException e) { + handleCleanupFailure(migration, failOnCleanupError, e.getDescription()); + } + } + + private boolean hasCleanupTargetDisks(VmwareCbtMigrationVO migration) { + String migrationMarker = String.format("/cloudstack-cbt/%s/", migration.getUuid()); + String rbdMigrationMarker = String.format("cloudstack-cbt-%s-", migration.getUuid()); + VmwareCbtStorageTarget storageTarget = getStorageTargetForMigration(migration); + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + String targetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + if (StringUtils.contains(targetPath, migrationMarker)) { + return true; + } + if (storageTarget != null && storageTarget.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW && + StringUtils.contains(targetPath, rbdMigrationMarker)) { + return true; + } + } + return false; + } + + private void handleCleanupFailure(VmwareCbtMigrationVO migration, boolean failOnCleanupError, String details) { + String error = StringUtils.defaultIfBlank(details, + String.format("Unable to clean up VMware CBT migration %s target disks", migration.getUuid())); + migration.setLastError(error); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + if (failOnCleanupError) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, error); + } + } + + private VmwareCbtMigrationResponse createVmwareCbtMigrationResponse(VmwareCbtMigrationVO migration) { + VmwareCbtMigrationResponse response = new VmwareCbtMigrationResponse(); + response.setId(migration.getUuid()); + + DataCenterVO zone = dataCenterDao.findById(migration.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + + Account account = accountService.getAccount(migration.getAccountId()); + if (account != null) { + response.setAccountId(account.getUuid()); + response.setAccountName(account.getAccountName()); + } + + ClusterVO cluster = clusterDao.findById(migration.getDestinationClusterId()); + if (cluster != null) { + response.setClusterId(cluster.getUuid()); + response.setClusterName(cluster.getName()); + } + + if (migration.getConvertHostId() != null) { + HostVO host = hostDao.findById(migration.getConvertHostId()); + if (host != null) { + response.setConvertInstanceHostId(host.getUuid()); + response.setConvertInstanceHostName(host.getName()); + } + } + + if (migration.getStoragePoolId() != null) { + StoragePoolVO pool = primaryDataStoreDao.findById(migration.getStoragePoolId()); + if (pool != null) { + response.setStoragePoolId(pool.getUuid()); + response.setStoragePoolName(pool.getName()); + } + } + + if (migration.getVmId() != null) { + UserVmVO vm = userVmDao.findById(migration.getVmId()); + if (vm != null) { + response.setVirtualMachineId(vm.getUuid()); + } + } + + response.setDisplayName(migration.getDisplayName()); + response.setVcenter(migration.getVcenter()); + if (migration.getExistingVcenterId() != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(migration.getExistingVcenterId()); + if (existingDc != null) { + response.setExistingVcenterId(existingDc.getUuid()); + } + } + response.setDatacenterName(migration.getDatacenter()); + response.setSourceHost(migration.getSourceHost()); + response.setSourceCluster(migration.getSourceCluster()); + response.setSourceVmName(migration.getSourceVmName()); + response.setState(migration.getState().name()); + response.setCurrentStep(migration.getCurrentStep()); + response.setCurrentStepDuration(getCurrentStepDuration(migration)); + response.setLastError(migration.getLastError()); + response.setCompletedCycles(migration.getCompletedCycles()); + response.setQuietCycles(migration.getQuietCycles()); + response.setTotalChangedBytes(migration.getTotalChangedBytes()); + response.setLastChangedBytes(migration.getLastChangedBytes()); + response.setLastDirtyRate(migration.getLastDirtyRate()); + response.setDisks(createVmwareCbtMigrationDiskResponses(migration)); + response.setCycles(createVmwareCbtMigrationCycleResponses(migration)); + response.setCreated(migration.getCreated()); + response.setLastUpdated(migration.getUpdated()); + response.setObjectName(OBJECT_NAME); + return response; + } + + String getCurrentStepDuration(VmwareCbtMigrationVO migration) { + if (migration == null || migration.getUpdated() == null || migration.getState() == null || + migration.getState().isTerminal()) { + return null; + } + long elapsedSeconds = Math.max(0L, + TimeUnit.MILLISECONDS.toSeconds(new Date().getTime() - migration.getUpdated().getTime())); + return formatDuration(elapsedSeconds); + } + + String formatDuration(long totalSeconds) { + long seconds = Math.max(0L, totalSeconds); + if (seconds < 60L) { + return String.format("%s sec%s", seconds, seconds == 1L ? "" : "s"); + } + long minutes = seconds / 60L; + seconds = seconds % 60L; + if (minutes < 60L) { + return String.format("%s min %s sec%s", minutes, seconds, seconds == 1L ? "" : "s"); + } + long hours = minutes / 60L; + minutes = minutes % 60L; + return String.format("%s hr %s min %s sec%s", hours, minutes, seconds, seconds == 1L ? "" : "s"); + } + + private List createVmwareCbtMigrationDiskResponses(VmwareCbtMigrationVO migration) { + List diskResponses = new ArrayList<>(); + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + VmwareCbtMigrationDiskResponse diskResponse = new VmwareCbtMigrationDiskResponse(); + diskResponse.setId(disk.getUuid()); + diskResponse.setSourceDiskId(disk.getSourceDiskId()); + diskResponse.setSourceDiskDeviceKey(disk.getSourceDiskDeviceKey()); + diskResponse.setSourceDiskPath(disk.getSourceDiskPath()); + diskResponse.setDatastoreName(disk.getDatastoreName()); + diskResponse.setCapacityBytes(disk.getCapacityBytes()); + diskResponse.setTargetPath(disk.getTargetPath()); + diskResponse.setTargetFormat(disk.getTargetFormat()); + diskResponse.setChangeId(disk.getChangeId()); + diskResponse.setSnapshotMor(disk.getSnapshotMor()); + diskResponse.setState(disk.getState().name()); + diskResponse.setObjectName("vmwarecbtmigrationdisk"); + diskResponses.add(diskResponse); + } + return diskResponses; + } + + private List createVmwareCbtMigrationCycleResponses(VmwareCbtMigrationVO migration) { + List cycleResponses = new ArrayList<>(); + List cycles = vmwareCbtMigrationCycleDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationCycleVO cycle : cycles) { + VmwareCbtMigrationCycleResponse cycleResponse = new VmwareCbtMigrationCycleResponse(); + cycleResponse.setId(cycle.getUuid()); + cycleResponse.setCycleNumber(cycle.getCycleNumber()); + cycleResponse.setSnapshotMor(cycle.getSnapshotMor()); + cycleResponse.setChangedBytes(cycle.getChangedBytes()); + cycleResponse.setDirtyRate(cycle.getDirtyRate()); + cycleResponse.setDuration(cycle.getDuration()); + cycleResponse.setState(cycle.getState().name()); + cycleResponse.setDescription(cycle.getDescription()); + cycleResponse.setCreated(cycle.getCreated()); + cycleResponse.setLastUpdated(cycle.getUpdated()); + cycleResponse.setObjectName("vmwarecbtmigrationcycle"); + cycleResponses.add(cycleResponse); + } + return cycleResponses; + } + + @Override + public String getConfigComponentName() { + return VmwareCbtMigrationManagerImpl.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] { + VmwareCbtMigrationMinCycles, + VmwareCbtMigrationMaxCycles, + VmwareCbtMigrationQuietCycles, + VmwareCbtMigrationQuietBytes, + VmwareCbtMigrationQuietDirtyRate, + VmwareCbtAllowNonInPlaceFinalization, + VmwareCbtMigrationAgentCommandTimeout + }; + } + + private static class VmwareSource { + private final Long existingVcenterId; + private final String vcenter; + private final String datacenterName; + private final String username; + private final String password; + private final String sourceHost; + + private VmwareSource(Long existingVcenterId, String vcenter, String datacenterName, String username, + String password, String sourceHost) { + this.existingVcenterId = existingVcenterId; + this.vcenter = vcenter; + this.datacenterName = datacenterName; + this.username = username; + this.password = password; + this.sourceHost = sourceHost; + } + } + + private static class VmwareCbtChangedBlockQueryResult { + private final List changedBlocks; + private final List changedDisks; + + private VmwareCbtChangedBlockQueryResult(List changedBlocks, + List changedDisks) { + this.changedBlocks = changedBlocks; + this.changedDisks = changedDisks; + } + } + + static class VmwareCbtOfferingResources { + private final Integer cpuNumber; + private final Integer cpuSpeed; + private final Integer memoryMb; + + VmwareCbtOfferingResources(Integer cpuNumber, Integer cpuSpeed, Integer memoryMb) { + this.cpuNumber = cpuNumber; + this.cpuSpeed = cpuSpeed; + this.memoryMb = memoryMb; + } + + Integer getCpuNumber() { + return cpuNumber; + } + + Integer getCpuSpeed() { + return cpuSpeed; + } + + Integer getMemoryMb() { + return memoryMb; + } + } + + private static class PreflightFindingCollector { + private final List findings = new ArrayList<>(); + private boolean hasFailures; + + void pass(String code, String component, String resource, String message) { + add("PASS", code, component, resource, message); + } + + void warn(String code, String component, String resource, String message) { + add("WARN", code, component, resource, message); + } + + void fail(String code, String component, String resource, String message) { + hasFailures = true; + add("FAIL", code, component, resource, message); + } + + private void add(String severity, String code, String component, String resource, String message) { + findings.add(new VmwareCbtMigrationPreflightFindingResponse(severity, code, component, + resource, message)); + } + + List getFindings() { + return findings; + } + + boolean hasFailures() { + return hasFailures; + } + } +} diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtStorageTarget.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtStorageTarget.java new file mode 100644 index 000000000000..4165a7f990f9 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtStorageTarget.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 org.apache.cloudstack.vm; + +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; + +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +class VmwareCbtStorageTarget { + + private final StoragePoolVO pool; + private final VmwareCbtTargetStorageType targetStorageType; + private final boolean supported; + private final boolean requiresInPlaceFinalization; + private final String supportMessage; + + private VmwareCbtStorageTarget(StoragePoolVO pool, VmwareCbtTargetStorageType targetStorageType, + boolean supported, boolean requiresInPlaceFinalization, String supportMessage) { + this.pool = pool; + this.targetStorageType = targetStorageType; + this.supported = supported; + this.requiresInPlaceFinalization = requiresInPlaceFinalization; + this.supportMessage = supportMessage; + } + + static VmwareCbtStorageTarget forPool(StoragePoolVO pool) { + if (pool == null) { + return new VmwareCbtStorageTarget(null, VmwareCbtTargetStorageType.QCOW2_FILE, true, false, + "No explicit pool selected; CloudStack will use the default filesystem target path."); + } + + Storage.StoragePoolType poolType = pool.getPoolType(); + if (poolType == Storage.StoragePoolType.NetworkFilesystem || + poolType == Storage.StoragePoolType.Filesystem || + poolType == Storage.StoragePoolType.SharedMountPoint) { + return new VmwareCbtStorageTarget(pool, VmwareCbtTargetStorageType.QCOW2_FILE, true, false, + "Filesystem-like primary storage will use qcow2 file targets."); + } + if (poolType == Storage.StoragePoolType.RBD) { + return new VmwareCbtStorageTarget(pool, VmwareCbtTargetStorageType.RBD_RAW, true, true, + "Ceph/RBD primary storage will use raw RBD targets and requires in-place finalization."); + } + return new VmwareCbtStorageTarget(pool, VmwareCbtTargetStorageType.QCOW2_FILE, false, true, + String.format("Storage pool type %s is not supported for VMware CBT migration targets.", poolType)); + } + + StoragePoolVO getPool() { + return pool; + } + + VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + boolean isSupported() { + return supported; + } + + boolean requiresInPlaceFinalization() { + return requiresInPlaceFinalization; + } + + boolean supportsNonInPlaceFinalizationFallback() { + return supported && targetStorageType == VmwareCbtTargetStorageType.QCOW2_FILE; + } + + String getSupportMessage() { + return supportMessage; + } +} diff --git a/server/src/main/resources/META-INF/cloudstack/server-compute/spring-server-compute-context.xml b/server/src/main/resources/META-INF/cloudstack/server-compute/spring-server-compute-context.xml index 3afae7676b7b..adca6ac7b001 100644 --- a/server/src/main/resources/META-INF/cloudstack/server-compute/spring-server-compute-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/server-compute/spring-server-compute-context.xml @@ -39,4 +39,6 @@ + + diff --git a/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java index dbc05ec99f39..83ec79e5708d 100644 --- a/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java @@ -730,6 +730,27 @@ public void testGetTemplateForImportInstanceDefaultTemplate() { Assert.assertEquals(defaultTemplateName, templateForImportInstance.getName()); } + @Test + public void testGetVmwareMigrationModeFallsBackToUseVddk() { + ImportVmCmd cmd = Mockito.mock(ImportVmCmd.class); + Assert.assertEquals(ImportVmCmd.VmwareMigrationMode.OVF, unmanagedVMsManager.getVmwareMigrationMode(cmd, false)); + Assert.assertEquals(ImportVmCmd.VmwareMigrationMode.VDDK, unmanagedVMsManager.getVmwareMigrationMode(cmd, true)); + } + + @Test + public void testGetVmwareMigrationModeParsesCbt() { + ImportVmCmd cmd = Mockito.mock(ImportVmCmd.class); + when(cmd.getVmwareMigrationMode()).thenReturn("cbt"); + Assert.assertEquals(ImportVmCmd.VmwareMigrationMode.CBT, unmanagedVMsManager.getVmwareMigrationMode(cmd, false)); + } + + @Test(expected = ServerApiException.class) + public void testGetVmwareMigrationModeRejectsUnknownMode() { + ImportVmCmd cmd = Mockito.mock(ImportVmCmd.class); + when(cmd.getVmwareMigrationMode()).thenReturn("not-a-mode"); + unmanagedVMsManager.getVmwareMigrationMode(cmd, false); + } + private enum VcenterParameter { EXISTING, EXTERNAL, @@ -1499,6 +1520,29 @@ public void testAddServiceOfferingDetailsToParamsCustomConstrainedOffering() { Assert.assertEquals("1024", params.get(VmDetailConstants.MEMORY)); } + @Test + public void testAddServiceOfferingDetailsToParamsUsesCallerDetailsForCustomOffering() { + Map params = new HashMap<>(); + ServiceOfferingVO serviceOfferingVO = mock(ServiceOfferingVO.class); + Map offeringDetails = new HashMap<>(); + offeringDetails.put(ApiConstants.MIN_CPU_NUMBER, "1"); + offeringDetails.put(ApiConstants.MIN_MEMORY, "1024"); + Map callerDetails = new HashMap<>(); + callerDetails.put(VmDetailConstants.CPU_NUMBER, "4"); + callerDetails.put(VmDetailConstants.CPU_SPEED, "2200"); + callerDetails.put(VmDetailConstants.MEMORY, "8192"); + Mockito.when(serviceOfferingVO.getDetails()).thenReturn(offeringDetails); + Mockito.when(serviceOfferingVO.getCpu()).thenReturn(null); + Mockito.when(serviceOfferingVO.getSpeed()).thenReturn(null); + Mockito.when(serviceOfferingVO.getRamSize()).thenReturn(null); + + unmanagedVMsManager.addServiceOfferingDetailsToParams(params, serviceOfferingVO, callerDetails); + + Assert.assertEquals("4", params.get(VmDetailConstants.CPU_NUMBER)); + Assert.assertEquals("2200", params.get(VmDetailConstants.CPU_SPEED)); + Assert.assertEquals("8192", params.get(VmDetailConstants.MEMORY)); + } + @Test public void testAddServiceOfferingDetailsToParamsCustomUnconstrainedOffering() { Map params = new HashMap<>(); diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java new file mode 100644 index 000000000000..2bda129483e0 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java @@ -0,0 +1,75 @@ +// 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.junit.Assert; +import org.junit.Test; + +public class VmwareCbtMigrationCutoverPolicyTest { + + @Test + public void testContinuesUntilMinimumCyclesAreReached() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(2, 5, 1, 1024, 0); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE, + policy.decide(1, 0, 512, 10)); + } + + @Test + public void testReadyForCutoverAfterRequiredQuietCycles() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 2, 1024, 0); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.READY_FOR_CUTOVER, + policy.decide(3, 1, 512, 10)); + } + + @Test + public void testReadyForCutoverAfterMinimumCyclesWhenNoBlocksChanged() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(2, 5, 2, 1024, 0); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE, + policy.decide(1, 0, 0, 10)); + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.READY_FOR_CUTOVER, + policy.decide(2, 0, 0, 10)); + } + + @Test + public void testDirtyRateCanKeepReplicationRunning() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 1, 0, 1024); + + Assert.assertFalse(policy.isQuietCycle(2048, 1)); + Assert.assertTrue(policy.isQuietCycle(2048, 2)); + } + + @Test + public void testReadyForCutoverWhenMaxCyclesReached() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 2, 1024, 1024); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.READY_FOR_CUTOVER_MAX_CYCLES, + policy.decide(5, 0, 4096, 1)); + } + + @Test + public void testLongRunningAgentCommandTimeoutDefaultIsOneDay() { + Assert.assertEquals("86400", VmwareCbtMigrationManagerImpl.VmwareCbtMigrationAgentCommandTimeout.defaultValue()); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsMaxCyclesBelowMinCycles() { + new VmwareCbtMigrationCutoverPolicy(3, 2, 1, 1024, 1024); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java new file mode 100644 index 000000000000..0b13f83b4558 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java @@ -0,0 +1,60 @@ +// 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.junit.Assert; +import org.junit.Test; + +public class VmwareCbtMigrationDeletePolicyTest { + + private final VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + + @Test + public void testCompletedMigrationDeleteIsAllowedButNeverCleansTargetDisks() { + Assert.assertTrue(manager.canDeleteMigrationState(VmwareCbtMigration.State.Completed)); + Assert.assertFalse(manager.shouldCleanupDeletedMigration(VmwareCbtMigration.State.Completed, true)); + } + + @Test + public void testFailedAndCancelledMigrationsCanCleanTargetDisksWhenRequested() { + Assert.assertTrue(manager.canDeleteMigrationState(VmwareCbtMigration.State.Failed)); + Assert.assertTrue(manager.canDeleteMigrationState(VmwareCbtMigration.State.Cancelled)); + Assert.assertTrue(manager.shouldCleanupDeletedMigration(VmwareCbtMigration.State.Failed, true)); + Assert.assertTrue(manager.shouldCleanupDeletedMigration(VmwareCbtMigration.State.Cancelled, true)); + Assert.assertFalse(manager.shouldCleanupDeletedMigration(VmwareCbtMigration.State.Failed, false)); + Assert.assertFalse(manager.shouldCleanupDeletedMigration(VmwareCbtMigration.State.Cancelled, false)); + } + + @Test + public void testActiveMigrationsCannotBeDeleted() { + Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.Created)); + Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.InitialSync)); + Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.Replicating)); + Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.ReadyForCutover)); + Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.CuttingOver)); + Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.ReadyForImport)); + } + + @Test + public void testCurrentStepDurationFormattingMatchesImportTaskStyle() { + Assert.assertEquals("0 secs", manager.formatDuration(0)); + Assert.assertEquals("1 sec", manager.formatDuration(1)); + Assert.assertEquals("59 secs", manager.formatDuration(59)); + Assert.assertEquals("2 min 9 secs", manager.formatDuration(129)); + Assert.assertEquals("1 hr 1 min 1 sec", manager.formatDuration(3661)); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationImportTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationImportTest.java new file mode 100644 index 000000000000..c8d163cfc5c2 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationImportTest.java @@ -0,0 +1,88 @@ +// 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 org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.storage.Storage; +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import com.cloud.vm.VmwareCbtMigrationVO; +import com.cloud.vm.dao.VmwareCbtMigrationDiskDao; + +public class VmwareCbtMigrationImportTest { + + @Test + public void testConvertedInstanceUsesVirtioDiskControllerForImport() { + VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO migration = new VmwareCbtMigrationVO(1L, 2L, 3L, 4L, + "display-name", "vcenter", "datacenter", "source-host", "source-cluster", "source-vm"); + + VmwareCbtMigrationDiskVO disk = new VmwareCbtMigrationDiskVO(migration.getId(), "619-2000", 2000, + "[datastore] source-vm/ROOT.vmdk", "datastore", 64424509440L); + disk.setTargetPath(String.format("/mnt/pool/cloudstack-cbt/%s/virt-v2v-output-1/%s-sda", + migration.getUuid(), migration.getUuid())); + disk.setTargetFormat("qcow2"); + + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + Mockito.when(diskDao.listByMigrationId(migration.getId())).thenReturn(Collections.singletonList(disk)); + ReflectionTestUtils.setField(manager, "vmwareCbtMigrationDiskDao", diskDao); + ReflectionTestUtils.setField(manager, "primaryDataStoreDao", Mockito.mock(PrimaryDataStoreDao.class)); + + UnmanagedInstanceTO convertedInstance = ReflectionTestUtils.invokeMethod(manager, + "createConvertedInstanceForImport", migration); + + Assert.assertNotNull(convertedInstance); + Assert.assertEquals("virtio", convertedInstance.getDisks().get(0).getController()); + } + + @Test + public void testConvertedInstanceUsesRbdImageNameAsFileBaseName() { + VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO migration = new VmwareCbtMigrationVO(1L, 2L, 3L, 4L, + "display-name", "vcenter", "datacenter", "source-host", "source-cluster", "source-vm"); + + VmwareCbtMigrationDiskVO disk = new VmwareCbtMigrationDiskVO(migration.getId(), "619-2000", 2000, + "[datastore] source-vm/ROOT.vmdk", "datastore", 64424509440L); + disk.setTargetPath(String.format("cloudstack-cbt-%s-619-2000-ROOT-545", migration.getUuid())); + disk.setTargetFormat("raw"); + + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + Mockito.when(diskDao.listByMigrationId(migration.getId())).thenReturn(Collections.singletonList(disk)); + PrimaryDataStoreDao primaryDataStoreDao = Mockito.mock(PrimaryDataStoreDao.class); + StoragePoolVO storagePool = Mockito.mock(StoragePoolVO.class); + Mockito.when(primaryDataStoreDao.findById(migration.getStoragePoolId())).thenReturn(storagePool); + Mockito.when(storagePool.getUuid()).thenReturn("rbd-pool-uuid"); + Mockito.when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.RBD); + ReflectionTestUtils.setField(manager, "vmwareCbtMigrationDiskDao", diskDao); + ReflectionTestUtils.setField(manager, "primaryDataStoreDao", primaryDataStoreDao); + + UnmanagedInstanceTO convertedInstance = ReflectionTestUtils.invokeMethod(manager, + "createConvertedInstanceForImport", migration); + + Assert.assertNotNull(convertedInstance); + Assert.assertEquals(disk.getTargetPath(), convertedInstance.getDisks().get(0).getFileBaseName()); + Assert.assertEquals("RBD", convertedInstance.getDisks().get(0).getDatastoreType()); + Assert.assertEquals("rbd-pool-uuid", convertedInstance.getDisks().get(0).getDatastoreName()); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationOfferingValidationTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationOfferingValidationTest.java new file mode 100644 index 000000000000..b3e7d520fc78 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationOfferingValidationTest.java @@ -0,0 +1,135 @@ +// 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.HashMap; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ServerApiException; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import com.cloud.service.ServiceOfferingVO; +import com.cloud.vm.VmDetailConstants; + +public class VmwareCbtMigrationOfferingValidationTest { + + @Test + public void testRejectsServiceOfferingWithTooFewCpus() { + VmwareCbtPreflightInfo preflightInfo = createPreflightInfo(3, 0, 4096); + ServiceOfferingVO serviceOffering = createServiceOffering(1, 1000, 4096, Collections.emptyMap()); + + try { + VmwareCbtMigrationManagerImpl.validateSelectedServiceOfferingResourcesForSourceVm( + preflightInfo, serviceOffering, Collections.emptyMap()); + Assert.fail("Expected service offering validation to fail"); + } catch (ServerApiException e) { + Assert.assertTrue(e.getDescription().contains("The requested CPU number (1) is less than the source VM CPU number (3)")); + } + } + + @Test + public void testRejectsServiceOfferingWithTooLittleMemory() { + VmwareCbtPreflightInfo preflightInfo = createPreflightInfo(2, 0, 4096); + ServiceOfferingVO serviceOffering = createServiceOffering(2, 1000, 1024, Collections.emptyMap()); + + try { + VmwareCbtMigrationManagerImpl.validateSelectedServiceOfferingResourcesForSourceVm( + preflightInfo, serviceOffering, Collections.emptyMap()); + Assert.fail("Expected service offering validation to fail"); + } catch (ServerApiException e) { + Assert.assertTrue(e.getDescription().contains("The requested Memory (1024) is less than the source VM Memory (4096)")); + } + } + + @Test + public void testAcceptsServiceOfferingWithSufficientCpuAndMemory() { + VmwareCbtPreflightInfo preflightInfo = createPreflightInfo(2, 0, 4096); + ServiceOfferingVO serviceOffering = createServiceOffering(4, 1000, 8192, Collections.emptyMap()); + + VmwareCbtMigrationManagerImpl.validateSelectedServiceOfferingResourcesForSourceVm( + preflightInfo, serviceOffering, Collections.emptyMap()); + } + + @Test + public void testUsesCallerDetailsForCustomizedServiceOffering() { + VmwareCbtPreflightInfo preflightInfo = createPreflightInfo(3, 0, 4096); + Map offeringDetails = new HashMap<>(); + offeringDetails.put(ApiConstants.MIN_CPU_NUMBER, "1"); + offeringDetails.put(ApiConstants.MIN_MEMORY, "1024"); + ServiceOfferingVO serviceOffering = createServiceOffering(null, 1000, null, offeringDetails); + Map callerDetails = new HashMap<>(); + callerDetails.put(VmDetailConstants.CPU_NUMBER, "4"); + callerDetails.put(VmDetailConstants.MEMORY, "8192"); + + VmwareCbtMigrationManagerImpl.validateSelectedServiceOfferingResourcesForSourceVm( + preflightInfo, serviceOffering, callerDetails); + } + + @Test + public void testFallsBackToOfferingMinimumsWhenCallerDetailsAreMissing() { + Map offeringDetails = new HashMap<>(); + offeringDetails.put(ApiConstants.MIN_CPU_NUMBER, "2"); + offeringDetails.put(ApiConstants.MIN_MEMORY, "2048"); + ServiceOfferingVO serviceOffering = createServiceOffering(null, 1000, null, offeringDetails); + + VmwareCbtMigrationManagerImpl.VmwareCbtOfferingResources resources = + VmwareCbtMigrationManagerImpl.resolveRequestedOfferingResources(serviceOffering, Collections.emptyMap()); + + Assert.assertEquals(Integer.valueOf(2), resources.getCpuNumber()); + Assert.assertEquals(Integer.valueOf(1000), resources.getCpuSpeed()); + Assert.assertEquals(Integer.valueOf(2048), resources.getMemoryMb()); + } + + @Test + public void testDetectsWindowsSourceVmFromVmwareGuestOsName() { + VmwareCbtPreflightInfo preflightInfo = createPreflightInfo("windows9Server64Guest", "Microsoft Windows Server 2016 (64-bit)"); + + Assert.assertTrue(VmwareCbtMigrationManagerImpl.isWindowsSourceVm(preflightInfo)); + } + + @Test + public void testDoesNotRequireWindowsConversionSupportForLinuxSourceVm() { + VmwareCbtPreflightInfo preflightInfo = createPreflightInfo("ubuntu64Guest", "Ubuntu Linux (64-bit)"); + + Assert.assertFalse(VmwareCbtMigrationManagerImpl.isWindowsSourceVm(preflightInfo)); + } + + private VmwareCbtPreflightInfo createPreflightInfo(Integer cpuCores, Integer cpuSpeed, Integer memoryMb) { + return new VmwareCbtPreflightInfo("source-vm", "vm-1", true, true, false, 0, + Collections.emptyList(), cpuCores, cpuSpeed, memoryMb); + } + + private VmwareCbtPreflightInfo createPreflightInfo(String operatingSystemId, String operatingSystem) { + return new VmwareCbtPreflightInfo("source-vm", "vm-1", true, true, false, 0, + Collections.emptyList(), 2, 1000, 2048, operatingSystemId, operatingSystem); + } + + private ServiceOfferingVO createServiceOffering(Integer cpu, Integer cpuSpeed, Integer memory, + Map details) { + ServiceOfferingVO serviceOffering = Mockito.mock(ServiceOfferingVO.class); + Mockito.when(serviceOffering.getCpu()).thenReturn(cpu); + Mockito.when(serviceOffering.getSpeed()).thenReturn(cpuSpeed); + Mockito.when(serviceOffering.getRamSize()).thenReturn(memory); + Mockito.when(serviceOffering.getDetails()).thenReturn(details); + Mockito.when(serviceOffering.getUuid()).thenReturn("service-offering-uuid"); + return serviceOffering; + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtStorageTargetTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtStorageTargetTest.java new file mode 100644 index 000000000000..caef2a594a1f --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtStorageTargetTest.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 org.apache.cloudstack.vm; + +import java.util.List; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.DataCenterVO; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.Storage; + +public class VmwareCbtStorageTargetTest { + + private final VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + + @Test + public void testFilesystemTargetDoesNotRequireInPlaceFinalization() { + VmwareCbtStorageTarget target = VmwareCbtStorageTarget.forPool(createStoragePool(Storage.StoragePoolType.NetworkFilesystem)); + + Assert.assertTrue(target.isSupported()); + Assert.assertEquals(VmwareCbtTargetStorageType.QCOW2_FILE, target.getTargetStorageType()); + Assert.assertFalse(target.requiresInPlaceFinalization()); + Assert.assertTrue(target.supportsNonInPlaceFinalizationFallback()); + } + + @Test + public void testRbdTargetRequiresInPlaceFinalization() { + VmwareCbtStorageTarget target = VmwareCbtStorageTarget.forPool(createStoragePool(Storage.StoragePoolType.RBD)); + + Assert.assertTrue(target.isSupported()); + Assert.assertEquals(VmwareCbtTargetStorageType.RBD_RAW, target.getTargetStorageType()); + Assert.assertTrue(target.requiresInPlaceFinalization()); + Assert.assertFalse(target.supportsNonInPlaceFinalizationFallback()); + } + + @Test + public void testValidateStorageTargetFinalizationFailsWhenHostDoesNotSupportInPlace() { + VmwareCbtStorageTarget target = VmwareCbtStorageTarget.forPool(createStoragePool(Storage.StoragePoolType.RBD)); + HostVO host = createHost("kvm1", "false"); + + try { + manager.validateStorageTargetFinalizationSupport(target, host); + Assert.fail("Expected validation failure"); + } catch (ServerApiException e) { + Assert.assertTrue(e.getDescription().contains("requires virt-v2v in-place finalization")); + Assert.assertTrue(e.getDescription().contains("kvm1")); + } + } + + @Test + public void testValidateStorageTargetFinalizationPassesWhenHostSupportsInPlace() { + VmwareCbtStorageTarget target = VmwareCbtStorageTarget.forPool(createStoragePool(Storage.StoragePoolType.RBD)); + HostVO host = createHost("kvm1", "true"); + + manager.validateStorageTargetFinalizationSupport(target, host); + } + + @Test + public void testValidateFilesystemTargetFinalizationFailsWhenHostDoesNotSupportInPlaceByDefault() { + VmwareCbtStorageTarget target = VmwareCbtStorageTarget.forPool(createStoragePool(Storage.StoragePoolType.NetworkFilesystem)); + HostVO host = createHost("kvm1", "false"); + + try { + manager.validateStorageTargetFinalizationSupport(target, host); + Assert.fail("Expected validation failure"); + } catch (ServerApiException e) { + Assert.assertTrue(e.getDescription().contains("cannot finalize VMware CBT migration in-place")); + Assert.assertTrue(e.getDescription().contains("kvm1")); + } + } + + @Test + public void testValidateFilesystemTargetFinalizationPassesWhenHostSupportsInPlace() { + VmwareCbtStorageTarget target = VmwareCbtStorageTarget.forPool(createStoragePool(Storage.StoragePoolType.NetworkFilesystem)); + HostVO host = createHost("kvm1", "true"); + + manager.validateStorageTargetFinalizationSupport(target, host); + } + + @Test + public void testListCbtCompatibleStoragePoolsIncludesRbdPools() { + PrimaryDataStoreDao primaryDataStoreDao = Mockito.mock(PrimaryDataStoreDao.class); + ReflectionTestUtils.setField(manager, "primaryDataStoreDao", primaryDataStoreDao); + DataCenterVO zone = createZone(1L); + ClusterVO cluster = createCluster(2L); + StoragePoolVO rbdPool = createStoragePool(10L, Storage.StoragePoolType.RBD); + + Mockito.when(primaryDataStoreDao.findClusterWideStoragePoolsByHypervisorAndPoolType(2L, + Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.RBD)).thenReturn(List.of(rbdPool)); + + List pools = manager.listCbtCompatibleStoragePools(zone, cluster); + + Assert.assertEquals(1, pools.size()); + Assert.assertSame(rbdPool, pools.get(0)); + } + + @Test + public void testListCbtCompatibleStoragePoolsDeduplicatesClusterAndZoneScope() { + PrimaryDataStoreDao primaryDataStoreDao = Mockito.mock(PrimaryDataStoreDao.class); + ReflectionTestUtils.setField(manager, "primaryDataStoreDao", primaryDataStoreDao); + DataCenterVO zone = createZone(1L); + ClusterVO cluster = createCluster(2L); + StoragePoolVO pool = createStoragePool(10L, Storage.StoragePoolType.NetworkFilesystem); + + Mockito.when(primaryDataStoreDao.findClusterWideStoragePoolsByHypervisorAndPoolType(2L, + Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.NetworkFilesystem)).thenReturn(List.of(pool)); + Mockito.when(primaryDataStoreDao.findZoneWideStoragePoolsByHypervisorAndPoolType(1L, + Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.NetworkFilesystem)).thenReturn(List.of(pool)); + + List pools = manager.listCbtCompatibleStoragePools(zone, cluster); + + Assert.assertEquals(1, pools.size()); + Assert.assertSame(pool, pools.get(0)); + } + + private StoragePoolVO createStoragePool(Storage.StoragePoolType poolType) { + StoragePoolVO pool = Mockito.mock(StoragePoolVO.class); + Mockito.when(pool.getPoolType()).thenReturn(poolType); + return pool; + } + + private StoragePoolVO createStoragePool(long id, Storage.StoragePoolType poolType) { + StoragePoolVO pool = createStoragePool(poolType); + Mockito.when(pool.getId()).thenReturn(id); + return pool; + } + + private HostVO createHost(String name, String inPlaceSupported) { + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getName()).thenReturn(name); + Mockito.when(host.getDetail(Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT)).thenReturn(inPlaceSupported); + return host; + } + + private DataCenterVO createZone(long id) { + DataCenterVO zone = Mockito.mock(DataCenterVO.class); + Mockito.when(zone.getId()).thenReturn(id); + return zone; + } + + private ClusterVO createCluster(long id) { + ClusterVO cluster = Mockito.mock(ClusterVO.class); + Mockito.when(cluster.getId()).thenReturn(id); + return cluster; + } +} diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 775de26103a0..3993fe5f960d 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -765,6 +765,7 @@ "label.current": "Current", "label.currentstep": "Current step", "label.currentstep.duration": "Current step duration", +"label.completedcycles": "Completed cycles", "label.current.storage": "Current storage", "label.currentpassword": "Current password", "label.custom": "Custom", @@ -2115,6 +2116,7 @@ "label.quickview": "Quick view", "label.quiescevm": "Quiesce Instance", "label.quiettime": "Quiet time (in sec)", +"label.quietcycles": "Quiet cycles", "label.quota": "Quota", "label.quota.add.credits": "Add credits", "label.quota.configuration": "Quota configuration", @@ -2485,6 +2487,7 @@ "label.sourcecidr": "Source CIDR", "label.sourcecidrlist": "Source CIDR list", "label.sourcehost": "Source host", +"label.sourcecluster": "Source cluster", "label.sourceipaddress": "Source IP address", "label.sourceipaddressnetworkid": "Network ID of source IP address", "label.sourcenat": "Source NAT", @@ -2535,6 +2538,26 @@ "label.user.data": "User Data", "label.user.data.library": "User Data Library", "label.use.vddk": "Use VDDK", +"label.vmware.migration.mode": "VMware migration mode", +"label.vmware.migration.mode.ovf": "OVF", +"label.vmware.migration.mode.vddk": "VDDK", +"label.vmware.migration.mode.cbt": "CBT", +"label.vmware.cbt.migrations": "VMware CBT Migrations", +"label.sync.delta": "Sync delta", +"label.cutover": "Cutover", +"label.cyclenumber": "Cycle", +"label.changedbytes": "Changed bytes", +"label.dirtyrate": "Dirty rate", +"label.lastchangedbytes": "Last changed bytes", +"label.lastdirtyrate": "Last dirty rate", +"label.totalchangedbytes": "Total changed bytes", +"label.lasterror": "Last error", +"label.source.disk": "Source disk", +"label.source.disk.path": "Source disk path", +"label.target.path": "Target path", +"label.target.format": "Target format", +"label.change.id": "Change ID", +"label.snapshot.mor": "Snapshot MOR", "label.ssh.port": "SSH port", "label.sshkeypair": "New SSH key pair", "label.sshkeypairs": "SSH key pairs", @@ -3227,6 +3250,7 @@ "message.action.vmsnapshot.delete": "Please confirm that you want to delete this Instance Snapshot.
Please notice that the Instance will be paused before the Snapshot deletion, and resumed after deletion, if it runs on KVM.", "message.action.vmsnapshot.disk-only.delete": "Please confirm that you want to delete this Instance Snapshot.", "message.activate.project": "Are you sure you want to activate this project?", +"message.api.not.available": "API is not available.", "message.add.custom.action.parameters": "Parameters to be made available while running the custom action.", "message.add.egress.rule.failed": "Adding new egress rule failed.", "message.add.egress.rule.processing": "Adding new egress rule...", @@ -3370,6 +3394,10 @@ "message.confirm.archive.selected.alerts": "Please confirm you would like to archive the selected alerts", "message.confirm.archive.selected.events": "Please confirm you would like to archive the selected events", "message.confirm.attach.disk": "Are you sure you want to attach disk?", +"message.confirm.cancel.vmware.cbt.migration": "Please confirm that you want to cancel this VMware CBT migration.", +"message.confirm.delete.vmware.cbt.migration": "Please confirm that you want to delete this VMware CBT migration record. Failed and cancelled migrations also clean up replicated target disks; completed migrations keep the imported VM and volumes.", +"message.confirm.sync.vmware.cbt.migration": "Please confirm that you want to run the next VMware CBT delta synchronization cycle.", +"message.confirm.cutover.vmware.cbt.migration": "Before cutover, gracefully shut down the source VMware VM and confirm it is powered off in vCenter. CloudStack will not power off the source VM automatically. Continue with final cutover?", "message.confirm.change.disk.offering.for.sharedfs": "Please confirm that you want to change the disk offering for the Shared FileSystem. This might migrate the underlying volume to a different storage pool if required.", "message.confirm.change.offering.for.volume": "Please confirm that you want to change disk offering for the volume", "message.confirm.change.service.offering.for.sharedfs": "Please confirm that you want to change the service offering for the Shared FileSystem.", @@ -3772,6 +3800,7 @@ "message.host.external.datadisk": "Usage of data disks for the selected template is not applicable", "message.import.running.instance.warning": "The selected VM is powered-on on the VMware Datacenter. The recommended state to convert a VMware VM into KVM is powered-off after a graceful shutdown of the guest OS.", "message.import.vm.tasks": "Import from VMware to KVM tasks", +"message.vmware.cbt.migrations": "CBT incremental migration sessions from VMware to KVM", "message.import.volume": "Please specify the domain, account or project name.
If not set, the volume will be imported for the caller.", "message.info.cloudian.console": "Cloudian Management Console should open in another window.", "message.installwizard.cloudstack.helptext.website": " * Project website:\t ", diff --git a/ui/src/config/section/tools.js b/ui/src/config/section/tools.js index 5b7f4b9af325..a49a6b8b82f5 100644 --- a/ui/src/config/section/tools.js +++ b/ui/src/config/section/tools.js @@ -17,6 +17,20 @@ import store from '@/store' import { shallowRef, defineAsyncComponent } from 'vue' +const vmImportExportApis = [ + 'listUnmanagedInstances', + 'importUnmanagedInstance', + 'listVmwareDcVms', + 'listVmsForImport', + 'importVm', + 'listImportVmTasks', + 'listVmwareCbtMigrations', + 'startVmwareCbtMigration' +] + +const hasApi = apiName => Object.prototype.hasOwnProperty.call(store.getters.apis, apiName) +const hasAnyApi = apiNames => apiNames.some(apiName => hasApi(apiName)) + export default { name: 'tools', title: 'label.tools', @@ -75,7 +89,8 @@ export default { icon: 'interaction-outlined', docHelp: 'adminguide/virtual_machines.html#importing-and-unmanaging-virtual-machine', resourceType: 'UserVm', - permission: ['listInfrastructure', 'listUnmanagedInstances'], + show: () => hasAnyApi(vmImportExportApis), + permission: [], component: () => import('@/views/tools/ManageInstances.vue') }, { diff --git a/ui/src/views/tools/ImportUnmanagedInstance.vue b/ui/src/views/tools/ImportUnmanagedInstance.vue index ffa0d9344335..229c32dae59b 100644 --- a/ui/src/views/tools/ImportUnmanagedInstance.vue +++ b/ui/src/views/tools/ImportUnmanagedInstance.vue @@ -35,7 +35,7 @@ @finish="handleSubmit" layout="vertical"> - + - + + {{ $t('label.vmware.migration.mode.ovf') }} + {{ $t('label.vmware.migration.mode.vddk') }} + {{ $t('label.vmware.migration.mode.cbt') }} + + + From 0415dd48b96ac7cdfa6b5676ee4c565653e76b32 Mon Sep 17 00:00:00 2001 From: Andrija Panic Date: Mon, 6 Jul 2026 01:20:00 +0200 Subject: [PATCH 02/15] Support VDDK VMware imports directly into Ceph/RBD primary storage Extends the existing VDDK + virt-v2v VMware-to-KVM import path so the converted disk can be written straight into an RBD pool as a raw image, with in-place virt-v2v finalization. Adds host-capability detection for qemu-img RBD support, RBD qemu copy, virt-v2v in-place, and direct RBD import, advertised via ReadyCommand and reconciled by the agent manager. Signed-off-by: Andrija Panic --- PendingReleaseNotes | 8 + .../cloud/agent/api/to/RemoteInstanceTO.java | 4 + .../agent/api/to/VmwareVddkSourceDiskTO.java | 53 ++++ api/src/main/java/com/cloud/host/Host.java | 4 + .../api/command/admin/vm/ImportVmCmd.java | 7 +- .../cloudstack/vm/UnmanagedInstanceTO.java | 9 + .../api/CheckConvertInstanceCommand.java | 9 + .../agent/api/ConvertInstanceCommand.java | 12 + .../api/ImportConvertedInstanceCommand.java | 15 + .../cloud/agent/manager/AgentManagerImpl.java | 23 +- .../resource/LibvirtComputingResource.java | 24 ++ .../LibvirtBaseConvertCommandWrapper.java | 68 ++++- ...irtCheckConvertInstanceCommandWrapper.java | 7 + .../LibvirtConvertInstanceCommandWrapper.java | 273 +++++++++++++++++- ...ImportConvertedInstanceCommandWrapper.java | 18 +- .../wrapper/LibvirtReadyCommandWrapper.java | 4 + ...heckConvertInstanceCommandWrapperTest.java | 26 ++ ...virtConvertInstanceCommandWrapperTest.java | 21 ++ ...rtConvertedInstanceCommandWrapperTest.java | 60 ++++ .../vm/UnmanagedVMsManagerImpl.java | 163 +++++++++-- .../vm/UnmanagedVMsManagerImplTest.java | 56 ++++ .../hypervisor/vmware/util/VmwareHelper.java | 1 + 22 files changed, 827 insertions(+), 38 deletions(-) create mode 100644 api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 9670b6e7c13a..0fe17afa85f4 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -39,3 +39,11 @@ 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. 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 1daddd9a9412..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 @@ -110,4 +110,8 @@ public String getHostName() { 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/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/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index fb5c32e21d91..b64fe3ed2c47 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -69,6 +69,10 @@ public static String[] toStrings(Host.Type... types) { String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; String HOST_VIRTV2V_IN_PLACE_VERSION = "host.virtv2v.in.place.version"; + String HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT = "host.vddk.rbd.direct.import.support"; + String HOST_VIRTV2V_INPLACE_SUPPORT = "host.virt.v2v.inplace.support"; + String HOST_QEMU_IMG_RBD_SUPPORT = "host.qemu.img.rbd.support"; + String HOST_RBD_QEMU_COPY_SUPPORT = "host.rbd.qemu.copy.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/command/admin/vm/ImportVmCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java index b547346a8011..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 @@ -171,7 +171,8 @@ public static VmwareMigrationMode fromValue(String value, boolean useVddkFallbac 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, @@ -201,7 +202,9 @@ public static VmwareMigrationMode fromValue(String value, boolean useVddkFallbac 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, 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/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java index c46fb697a3c1..94318033bc1c 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,7 @@ public class CheckConvertInstanceCommand extends Command { boolean checkWindowsGuestConversionSupport = false; boolean useVddk = false; + boolean checkVddkRbdDirectImportSupport = false; String vddkLibDir; public CheckConvertInstanceCommand() { @@ -50,6 +51,14 @@ public void setUseVddk(boolean useVddk) { this.useVddk = useVddk; } + public boolean isCheckVddkRbdDirectImportSupport() { + return checkVddkRbdDirectImportSupport; + } + + public void setCheckVddkRbdDirectImportSupport(boolean checkVddkRbdDirectImportSupport) { + this.checkVddkRbdDirectImportSupport = checkVddkRbdDirectImportSupport; + } + 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/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index 08dbde33b975..abf892a148ea 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 @@ -815,9 +815,14 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi 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_IN_PLACE_VERSION); + String virtv2vInPlaceSupport = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_SUPPORT); + String qemuImgRbdSupport = detailsMap.get(Host.HOST_QEMU_IMG_RBD_SUPPORT); + String rbdQemuCopySupport = detailsMap.get(Host.HOST_RBD_QEMU_COPY_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, - vmwareCbtSupport, vmwareCbtInPlaceFinalizationSupport, vmwareCbtRbdSupport, qemuImgVersion, qemuNbdVersion, qemuIoVersion, virtV2vInPlaceVersion)) { + vmwareCbtSupport, vmwareCbtInPlaceFinalizationSupport, vmwareCbtRbdSupport, qemuImgVersion, qemuNbdVersion, qemuIoVersion, virtV2vInPlaceVersion, + virtv2vInPlaceSupport, qemuImgRbdSupport, rbdQemuCopySupport, vddkRbdDirectImportSupport)) { _hostDao.loadDetails(host); boolean updateNeeded = false; if (StringUtils.isNotBlank(uefiEnabled) && !uefiEnabled.equals(host.getDetails().get(Host.HOST_UEFI_ENABLE))) { @@ -898,6 +903,22 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi } 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_IMG_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_QEMU_IMG_RBD_SUPPORT, qemuImgRbdSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(rbdQemuCopySupport) && !rbdQemuCopySupport.equals(host.getDetails().get(Host.HOST_RBD_QEMU_COPY_SUPPORT))) { + host.getDetails().put(Host.HOST_RBD_QEMU_COPY_SUPPORT, rbdQemuCopySupport); + 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/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 4ff85fd88152..5c22c44e4c5e 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,12 +19,16 @@ 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_IMG_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_RBD_QEMU_COPY_SUPPORT; 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_IN_PLACE_VERSION; import static com.cloud.host.Host.HOST_VIRTV2V_VERSION; import static com.cloud.host.Host.HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT; @@ -4433,6 +4437,10 @@ public StartupCommand[] initialize() { cmd.getHostDetails().put(HOST_VMWARE_CBT_SUPPORT, String.valueOf(hostSupportsVmwareCbtMigration())); cmd.getHostDetails().put(HOST_VMWARE_CBT_IN_PLACE_FINALIZATION_SUPPORT, String.valueOf(hostSupportsVmwareCbtInPlaceFinalization())); cmd.getHostDetails().put(HOST_VMWARE_CBT_RBD_SUPPORT, String.valueOf(hostSupportsVmwareCbtRbd())); + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_SUPPORT, String.valueOf(hostSupportsVirtV2vInPlace())); + cmd.getHostDetails().put(HOST_QEMU_IMG_RBD_SUPPORT, String.valueOf(hostSupportsQemuImgRbd())); + cmd.getHostDetails().put(HOST_RBD_QEMU_COPY_SUPPORT, String.valueOf(hostSupportsRbdQemuCopy())); + cmd.getHostDetails().put(HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, String.valueOf(hostSupportsVddkRbdDirectImport())); if (StringUtils.isNotBlank(vddkLibDir)) { cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir); } @@ -6366,6 +6374,22 @@ protected String parseVersionToken(String versionLine) { return parts.length > 0 ? parts[0] : versionLine; } + public boolean hostSupportsQemuImgRbd() { + return Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsRbdQemuCopy() { + return hostSupportsQemuImgRbd(); + } + + public boolean hostSupportsVddkRbdDirectImport() { + return hostSupportsVddkRbdDirectImport(null); + } + + public boolean hostSupportsVddkRbdDirectImport(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) && hostSupportsQemuImgRbd() && hostSupportsVirtV2vInPlace(); + } + protected boolean isVddkLibDirValid(String path) { if (StringUtils.isBlank(path)) { return false; 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..3594ad52fe15 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,15 @@ 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 { + 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..e3f8dec19b16 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,13 @@ public class LibvirtCheckConvertInstanceCommandWrapper extends CommandWrapper sourceDisks = cmd.getVmwareVddkSourceDisks(); + if (sourceDisks == null || sourceDisks.isEmpty()) { + logger.error("({}) Direct RBD VDDK import requires VMware source disk metadata", originalVMName); + return false; + } + + 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<>(); + 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); + 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-rbd-" + temporaryConvertUuid, ".xml"); + Path outputXml = Files.createTempFile("cloudstack-vddk-rbd-" + temporaryConvertUuid + "-out", ".xml"); + Files.writeString(inputXml, buildDirectRbdLibvirtXml(temporaryConvertUuid, targetPool, createdImages)); + + boolean finalized = runInPlaceFinalization(inputXml, outputXml, libguestfsBackend, timeout, verboseModeEnabled, originalVMName, serverResource); + if (!finalized) { + cleanupRbdImages(targetPool, createdImages, originalVMName); + } + Files.deleteIfExists(inputXml); + Files.deleteIfExists(outputXml); + return finalized; + } catch (Exception e) { + logger.error("({}) Direct RBD VDDK import failed: {}", originalVMName, e.getMessage(), e); + cleanupRbdImages(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 boolean runInPlaceFinalization(Path inputXml, Path outputXml, 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()) { + command.append("virt-v2v-in-place --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" -O ") + .append(shellQuote(outputXml.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 buildRbdImageName(String temporaryConvertUuid, int position) { + return String.format("%s-disk-%03d", 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 cleanupRbdImages(KVMStoragePool pool, List imageNames, String originalVMName) { + for (String imageName : imageNames) { + try { + logger.info("({}) Cleaning up RBD image {} after failed direct import", originalVMName, imageName); + pool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("({}) Failed to delete RBD image {} 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..a62054e79ed6 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 (isForcedRbdConversion(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,9 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour } } } + + private boolean isForcedRbdConversion(DataStoreTO conversionTemporaryLocation, boolean forceConvertToPool) { + return forceConvertToPool && conversionTemporaryLocation instanceof PrimaryDataStoreTO && + ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType() == Storage.StoragePoolType.RBD; + } } 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 cb1f3d40c229..b6a3675a3d24 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 @@ -61,6 +61,10 @@ public Answer execute(final ReadyCommand command, final LibvirtComputingResource 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_IMG_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsQemuImgRbd())); + hostDetails.put(Host.HOST_RBD_QEMU_COPY_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsRbdQemuCopy())); + 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/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 3c96ad8a029a..cc6aeca4bf84 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,32 @@ 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_vddkFailure() { Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); 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..c05b429cceae 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,40 @@ 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