From 44a8713671d3a8830342762e88975ad3fd3426c7 Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Mon, 28 Sep 2026 21:15:16 +0200 Subject: [PATCH 1/5] Restore VMware-to-KVM migration changes from #13656 Reverts 510d0ec3785efe3cce65ccd1247682b91f4492d0 and restores the exact content merged as 0a5bf30af32bdea5a209f3f993cbd6a43301d0f9. No functional changes are added. Signed-off-by: andrijapanicsb --- PendingReleaseNotes | 19 + .../agent/properties/AgentProperties.java | 13 + .../cloud/agent/api/to/RemoteInstanceTO.java | 14 + .../api/to/VmwareCbtChangedBlockRangeTO.java | 47 + .../api/to/VmwareCbtDiskSyncResultTO.java | 78 + .../cloud/agent/api/to/VmwareCbtDiskTO.java | 85 + .../agent/api/to/VmwareVddkSourceDiskTO.java | 53 + .../main/java/com/cloud/event/EventTypes.java | 3 + api/src/main/java/com/cloud/host/Host.java | 10 + .../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 | 36 +- .../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 ++ .../volume/VolumeImportUnmanageService.java | 3 +- .../cloudstack/vm/UnmanagedInstanceTO.java | 43 + .../cloudstack/vm/UnmanagedVMsManager.java | 50 + .../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 | 138 + .../vm/VmwareCbtPreflightNicInfo.java | 87 + .../cloudstack/vm/VmwareCbtSnapshotInfo.java | 46 + .../api/CheckConvertInstanceCommand.java | 18 + .../agent/api/ConvertInstanceCommand.java | 21 + .../api/ImportConvertedInstanceCommand.java | 15 + .../agent/api/VmwareCbtCleanupCommand.java | 100 + .../agent/api/VmwareCbtCutoverCommand.java | 133 + .../agent/api/VmwareCbtMigrationAnswer.java | 82 + .../agent/api/VmwareCbtPrepareCommand.java | 110 + .../agent/api/VmwareCbtRbdProbeCommand.java | 63 + .../cloud/agent/api/VmwareCbtSyncCommand.java | 138 + .../api/to/VmwareCbtTargetStorageType.java | 28 + docs/vmware-cbt-migration.md | 457 +++ docs/vmware-cbt/README.md | 1550 +++++++++ docs/vmware-cbt/architecture.md | 266 ++ docs/vmware-linstor-migration.md | 238 ++ .../cloud/agent/manager/AgentManagerImpl.java | 73 +- .../orchestration/VolumeOrchestrator.java | 18 +- .../orchestration/VolumeOrchestratorTest.java | 25 + .../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 | 62 + .../vm/dao/VmwareCbtMigrationDaoImpl.java | 129 + .../vm/dao/VmwareCbtMigrationDiskDao.java | 26 + .../vm/dao/VmwareCbtMigrationDiskDaoImpl.java | 44 + ...spring-engine-schema-core-daos-context.xml | 3 + .../META-INF/db/schema-42300to2400.sql | 127 + .../resource/LibvirtComputingResource.java | 261 +- .../LibvirtBaseConvertCommandWrapper.java | 73 +- ...irtCheckConvertInstanceCommandWrapper.java | 15 + .../LibvirtCheckVolumeCommandWrapper.java | 64 +- .../LibvirtConvertInstanceCommandWrapper.java | 748 ++++- ...virtGetVolumesOnStorageCommandWrapper.java | 33 +- ...ImportConvertedInstanceCommandWrapper.java | 21 +- .../wrapper/LibvirtReadyCommandWrapper.java | 10 + ...LibvirtVmwareCbtCleanupCommandWrapper.java | 339 ++ ...LibvirtVmwareCbtCutoverCommandWrapper.java | 794 +++++ ...LibvirtVmwareCbtPrepareCommandWrapper.java | 506 +++ ...ibvirtVmwareCbtRbdProbeCommandWrapper.java | 218 ++ .../LibvirtVmwareCbtSyncCommandWrapper.java | 489 +++ .../wrapper/VmwareCbtCommandOutputLogger.java | 83 + .../wrapper/VmwareCbtCommandResult.java | 41 + .../resource/wrapper/VmwareCbtSyncPlan.java | 273 ++ .../kvm/storage/KVMStoragePool.java | 37 + .../kvm/storage/LibvirtStoragePool.java | 31 +- .../apache/cloudstack/utils/qemu/QemuImg.java | 48 +- .../LibvirtComputingResourceTest.java | 32 + ...heckConvertInstanceCommandWrapperTest.java | 65 + .../LibvirtCheckVolumeCommandWrapperTest.java | 242 ++ ...virtConvertInstanceCommandWrapperTest.java | 123 +- ...rtConvertedInstanceCommandWrapperTest.java | 110 + ...irtVmwareCbtCleanupCommandWrapperTest.java | 239 ++ ...irtVmwareCbtCutoverCommandWrapperTest.java | 412 +++ ...irtVmwareCbtPrepareCommandWrapperTest.java | 254 ++ ...rtVmwareCbtRbdProbeCommandWrapperTest.java | 167 + ...ibvirtVmwareCbtSyncCommandWrapperTest.java | 246 ++ .../wrapper/VmwareCbtSyncPlanTest.java | 98 + .../kvm/storage/LibvirtStoragePoolTest.java | 56 + .../VmwareCbtMigrationServiceImpl.java | 587 ++++ .../core/spring-vmware-core-context.xml | 2 + .../kvm/storage/LinstorStorageAdaptor.java | 25 +- .../kvm/storage/LinstorStoragePool.java | 22 + .../storage/datastore/util/LinstorUtil.java | 85 +- .../cloud/server/ManagementServerImpl.java | 7 +- .../vm/UnmanagedVMsManagerImpl.java | 642 +++- .../vm/VmwareCbtMigrationCutoverPolicy.java | 81 + .../vm/VmwareCbtMigrationManagerImpl.java | 2978 +++++++++++++++++ .../cloudstack/vm/VmwareCbtStorageTarget.java | 89 + .../spring-server-compute-context.xml | 2 + .../VolumeImportUnmanageManagerImplTest.java | 49 + .../vm/UnmanagedVMsManagerImplTest.java | 388 +++ .../VmwareCbtMigrationCutoverPolicyTest.java | 75 + .../VmwareCbtMigrationDeletePolicyTest.java | 60 + .../vm/VmwareCbtMigrationImportTest.java | 126 + .../VmwareCbtMigrationNicValidationTest.java | 144 + ...areCbtMigrationOfferingValidationTest.java | 135 + .../vm/VmwareCbtStorageTargetTest.java | 205 ++ ui/public/locales/en.json | 47 +- ui/public/locales/pt_BR.json | 8 +- ui/public/locales/te.json | 8 +- ui/src/config/section/tools.js | 17 +- ui/src/utils/vmware.js | 41 + ui/src/views/infra/HostInfo.vue | 88 +- .../views/tools/ImportUnmanagedInstance.vue | 346 +- ui/src/views/tools/ManageInstances.vue | 416 ++- ui/src/views/tools/SelectVmwareVcenter.vue | 4 +- ui/src/views/tools/VmwareCbtMigrations.vue | 493 +++ ui/tests/unit/utils/vmware.spec.js | 51 + .../hypervisor/vmware/util/VmwareHelper.java | 59 + 129 files changed, 20982 insertions(+), 230 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/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.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/VmwareCbtPreflightNicInfo.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 docs/vmware-linstor-migration.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/LibvirtCheckVolumeCommandWrapperTest.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/VmwareCbtMigrationNicValidationTest.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/utils/vmware.js create mode 100644 ui/src/views/tools/VmwareCbtMigrations.vue create mode 100644 ui/tests/unit/utils/vmware.spec.js diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 44923495b23f..bc6068fa3310 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -55,6 +55,25 @@ example.ver.1 > example.ver.2: continue to snapshot live. 4.23.0.0 > 24.0.0: + * VMware-to-KVM import using VDDK can import powered-off VMware VMs directly + into Ceph RBD primary storage when forceconverttopool is enabled and the + selected KVM conversion host supports qemu RBD access and in-place virt-v2v + finalization. Hosts without in-place finalization support can still use the + staged VDDK flow, converting to temporary qcow2 storage before copying the + finalized disks into RBD. + + * Linstor primary storage can now be the destination for VMware-to-KVM + imports in all three modes. Staged imports (virt-v2v to a temporary NFS + location) copy the converted disks into pre-created DRBD block devices + through the Linstor storage adaptor; the import host must be a LINSTOR + satellite connected to the destination pool. Direct VDDK imports with + forceconverttopool write each powered-off VMware disk over nbdkit/VDDK + straight into the DRBD device and finalize it with virt-v2v in place. + VMware CBT warm migrations replicate the initial full copy and all + incremental changed-block cycles directly into Linstor volumes and + finalize in place at cutover; the conversion host requires VDDK, + in-place virt-v2v support and access to the Linstor pool. + * KVM/Ceph: RBD volumes can now be encrypted at rest using native librbd LUKS2 (), for both data disks and root disks. Encryption is transparent to the guest and reuses the diff --git a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java index e4775188d0ca..f27f7d89e5bc 100644 --- a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java +++ b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java @@ -848,6 +848,19 @@ public Property getWorkers() { */ public static final Property VDDK_TRANSPORTS = new Property<>("vddk.transports", null, String.class); + /** + * NBD-transport compression used between the VDDK and the VMware server for the + * agent-driven nbdkit full-disk copies (cold direct import and CBT initial full + * sync). Passed to the nbdkit vddk plugin as compression=<value>. + * Valid values: none, zlib, fastlz, + * skipz (nbdkit ≥ 1.24, vSphere ≥ 6.5). fastlz + * compresses both zero regions and real data at low CPU cost; skipz + * only elides zero blocks. Does not affect the staged virt-v2v conversion path. + * Data type: String.
+ * Default value: fastlz + */ + public static final Property VDDK_NBD_COMPRESSION = new Property<>("vddk.nbd.compression", "fastlz", String.class); + /** * vCenter TLS certificate thumbprint used by virt-v2v VDDK mode, passed as -io vddk-thumbprint=<value>. * If unset, the KVM host computes it at runtime from the vCenter endpoint. diff --git a/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java b/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java index 7daeb9649177..8aec16d4b101 100644 --- a/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java +++ b/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java @@ -38,6 +38,7 @@ public class RemoteInstanceTO implements Serializable { private String datacenterName; private String clusterName; private String hostName; + private String vmwareMoref; public RemoteInstanceTO() { } @@ -65,6 +66,11 @@ public RemoteInstanceTO(String instanceName, String instancePath, String vcenter this.hostName = hostName; } + public RemoteInstanceTO(String instanceName, String instancePath, String vcenterHost, String vcenterUsername, String vcenterPassword, String datacenterName, String clusterName, String hostName, String vmwareMoref) { + this(instanceName, instancePath, vcenterHost, vcenterUsername, vcenterPassword, datacenterName, clusterName, hostName); + this.vmwareMoref = vmwareMoref; + } + public Hypervisor.HypervisorType getHypervisorType() { return this.hypervisorType; } @@ -100,4 +106,12 @@ public String getClusterName() { public String getHostName() { return hostName; } + + public String getVmwareMoref() { + return vmwareMoref; + } + + public void setVmwareMoref(String vmwareMoref) { + this.vmwareMoref = vmwareMoref; + } } diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java new file mode 100644 index 000000000000..ee8fcc59a290 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtChangedBlockRangeTO implements Serializable { + + private String diskId; + private long startOffset; + private long length; + + public VmwareCbtChangedBlockRangeTO() { + } + + public VmwareCbtChangedBlockRangeTO(String diskId, long startOffset, long length) { + this.diskId = diskId; + this.startOffset = startOffset; + this.length = length; + } + + public String getDiskId() { + return diskId; + } + + public long getStartOffset() { + return startOffset; + } + + public long getLength() { + return length; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java new file mode 100644 index 000000000000..c22ea0bb24c0 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtDiskSyncResultTO implements Serializable { + + private String diskId; + private String targetPath; + private String changeId; + private String snapshotMor; + private long changedBytes; + private long durationSeconds; + private boolean result; + private String details; + + public VmwareCbtDiskSyncResultTO() { + } + + public VmwareCbtDiskSyncResultTO(String diskId, String targetPath, String changeId, String snapshotMor, + long changedBytes, long durationSeconds, boolean result, String details) { + this.diskId = diskId; + this.targetPath = targetPath; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + this.changedBytes = changedBytes; + this.durationSeconds = durationSeconds; + this.result = result; + this.details = details; + } + + public String getDiskId() { + return diskId; + } + + public String getTargetPath() { + return targetPath; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public long getChangedBytes() { + return changedBytes; + } + + public long getDurationSeconds() { + return durationSeconds; + } + + public boolean getResult() { + return result; + } + + public String getDetails() { + return details; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java new file mode 100644 index 000000000000..e6dcf1bfbca6 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtDiskTO implements Serializable { + + private String diskId; + private Integer diskDeviceKey; + private String sourceDiskPath; + private String datastoreName; + private String targetPath; + private String targetFormat; + private String changeId; + private String snapshotMor; + private long capacityBytes; + + public VmwareCbtDiskTO() { + } + + public VmwareCbtDiskTO(String diskId, Integer diskDeviceKey, String sourceDiskPath, String datastoreName, + String targetPath, String targetFormat, String changeId, String snapshotMor, + long capacityBytes) { + this.diskId = diskId; + this.diskDeviceKey = diskDeviceKey; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.targetPath = targetPath; + this.targetFormat = targetFormat; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + this.capacityBytes = capacityBytes; + } + + public String getDiskId() { + return diskId; + } + + public Integer getDiskDeviceKey() { + return diskDeviceKey; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public String getTargetPath() { + return targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public long getCapacityBytes() { + return capacityBytes; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java new file mode 100644 index 000000000000..b980c611abc9 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareVddkSourceDiskTO implements Serializable { + + private String diskId; + private String sourceDiskPath; + private long capacityBytes; + private Integer position; + + public VmwareVddkSourceDiskTO() { + } + + public VmwareVddkSourceDiskTO(String diskId, String sourceDiskPath, long capacityBytes, Integer position) { + this.diskId = diskId; + this.sourceDiskPath = sourceDiskPath; + this.capacityBytes = capacityBytes; + this.position = position; + } + + public String getDiskId() { + return diskId; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public long getCapacityBytes() { + return capacityBytes; + } + + public Integer getPosition() { + return position; + } +} diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index f7d13343d469..47a0bda3b1d2 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -127,6 +127,9 @@ public class EventTypes { public static final String EVENT_VM_RESTORE = "VM.RESTORE"; public static final String EVENT_VM_EXPUNGE = "VM.EXPUNGE"; public static final String EVENT_VM_IMPORT = "VM.IMPORT"; + public static final String EVENT_VMWARE_CBT_MIGRATION_START = "VMWARE.CBT.MIGRATION.START"; + public static final String EVENT_VMWARE_CBT_MIGRATION_SYNC = "VMWARE.CBT.MIGRATION.SYNC"; + public static final String EVENT_VMWARE_CBT_MIGRATION_CUTOVER = "VMWARE.CBT.MIGRATION.CUTOVER"; public static final String EVENT_VM_UNMANAGE = "VM.UNMANAGE"; public static final String EVENT_VM_RECOVER = "VM.RECOVER"; diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index 79bec7a5871a..cca250c2a90f 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -62,8 +62,18 @@ public static String[] toStrings(Host.Type... types) { String HOST_VDDK_SUPPORT = "host.vddk.support"; String HOST_VDDK_LIB_DIR = "vddk.lib.dir"; String HOST_VDDK_VERSION = "host.vddk.version"; + String HOST_VDDK_BLOCKCOPY_SUPPORT = "host.vddk.blockcopy.support"; + String HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT = "host.vddk.blockcopy.inplace.finalization.support"; + String HOST_VDDK_BLOCKCOPY_RBD_SUPPORT = "host.vddk.blockcopy.rbd.support"; + String HOST_QEMU_IMG_VERSION = "host.qemu.img.version"; + String HOST_QEMU_NBD_VERSION = "host.qemu.nbd.version"; + String HOST_QEMU_IO_VERSION = "host.qemu.io.version"; String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; + String HOST_VIRTV2V_INPLACE_VERSION = "host.virtv2v.inplace.version"; + String HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT = "host.vddk.rbd.direct.import.support"; + String HOST_VIRTV2V_INPLACE_SUPPORT = "host.virtv2v.inplace.support"; + String HOST_QEMU_RBD_SUPPORT = "host.qemu.rbd.support"; String HOST_SSH_PORT = "host.ssh.port"; String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count"; String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index f74c46161180..3e308af89a61 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -653,6 +653,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..fa4fc4f7bb6f --- /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.24.0") +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..cbbadc090ce5 --- /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.24.0") +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..61aedf31f4a2 --- /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.24.0") +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..67163814b23a --- /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.24.0") +public class DeleteVmwareCbtMigrationCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.CLEANUP, type = CommandType.BOOLEAN, + description = "If true, clean up replicated target disks before removing a failed or cancelled migration record. True by default. Ignored for completed migrations.") + private Boolean cleanup; + + public Long getId() { + return id; + } + + public boolean getCleanup() { + return cleanup == null || cleanup; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + boolean result = vmwareCbtMigrationManager.deleteVmwareCbtMigration(this); + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setSuccess(result); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java index db7dcc3fb44f..94249dd92e9b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java @@ -57,6 +57,24 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { @Inject public VmImportService vmImportService; + public enum VmwareMigrationMode { + OVF, + VDDK, + CBT; + + public static VmwareMigrationMode fromValue(String value, boolean useVddkFallback) { + if (StringUtils.isBlank(value)) { + return useVddkFallback ? VDDK : OVF; + } + for (VmwareMigrationMode mode : values()) { + if (mode.name().equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException(String.format("Unsupported VMware migration mode: %s", value)); + } + } + ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// ///////////////////////////////////////////////////// @@ -153,7 +171,8 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { private Long importInstanceHostId; @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, - description = "(only for importing VMs from VMware to KVM) optional - the temporary storage pool to perform the virt-v2v migration from VMware to KVM.") + description = "(only for importing VMs from VMware to KVM) optional - the temporary storage pool to perform the virt-v2v migration from VMware to KVM. " + + "When " + ApiConstants.USE_VDDK + " and " + ApiConstants.FORCE_CONVERT_TO_POOL + " are true, this can be an RBD primary storage pool for direct RBD import.") private Long convertStoragePoolId; @Parameter(name = ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES, type = CommandType.BOOLEAN, @@ -183,9 +202,18 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { type = CommandType.BOOLEAN, since = "4.22.1", description = "(only for importing VMs from VMware to KVM) optional - if true, uses VDDK on the KVM conversion host for converting the VM. " + - "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ".") + "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ". " + + "With " + ApiConstants.FORCE_CONVERT_TO_POOL + "=true and an RBD conversion pool, the source VMware VM must be powered off and " + + "the conversion host must support qemu RBD access and in-place virt-v2v finalization.") private Boolean useVddk; + @Parameter(name = ApiConstants.VMWARE_MIGRATION_MODE, + type = CommandType.STRING, + since = "4.22.1", + description = "(only for importing VMs from VMware to KVM) optional - migration mode to use. Valid values are OVF, VDDK, and CBT. " + + "When omitted, CloudStack preserves the existing usevddk behavior.") + private String vmwareMigrationMode; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// @@ -267,6 +295,10 @@ public boolean getUseVddk() { return BooleanUtils.toBooleanDefaultIfNull(useVddk, true); } + public String getVmwareMigrationMode() { + return vmwareMigrationMode; + } + public String getTmpPath() { return tmpPath; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java new file mode 100644 index 000000000000..3ea5f53970cf --- /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.24.0") +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..0746e40c34ca --- /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.24.0") +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..7589360564a5 --- /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.24.0") +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/storage/volume/VolumeImportUnmanageService.java b/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java index 13f01b4944ad..709ad691971a 100644 --- a/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java +++ b/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java @@ -37,7 +37,8 @@ public interface VolumeImportUnmanageService extends PluggableService, Configura Arrays.asList(Hypervisor.HypervisorType.KVM, Hypervisor.HypervisorType.VMware); List SUPPORTED_STORAGE_POOL_TYPES_FOR_KVM = Arrays.asList(Storage.StoragePoolType.NetworkFilesystem, - Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.RBD, Storage.StoragePoolType.SharedMountPoint); + Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.RBD, Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.Linstor); ConfigKey AllowImportVolumeWithBackingFile = new ConfigKey<>(Boolean.class, "allow.import.volume.with.backing.file", 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..63ef8892d2c6 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; @@ -400,6 +409,16 @@ public static class Nic { private List ipAddress; + // Guest network facts captured from VMware Tools while the source runs, used to preserve + // static configuration across the migration: IPv4 addresses in CIDR form, the default + // gateway serving this NIC's subnet, and the guest's DNS servers. Null when Tools is not + // running or the guest reports nothing for this NIC. + private List ipv4Cidrs; + + private String ipv4Gateway; + + private List dnsServers; + private String pciSlot; public String getNicId() { @@ -466,6 +485,30 @@ public void setIpAddress(List ipAddress) { this.ipAddress = ipAddress; } + public List getIpv4Cidrs() { + return ipv4Cidrs; + } + + public void setIpv4Cidrs(List ipv4Cidrs) { + this.ipv4Cidrs = ipv4Cidrs; + } + + public String getIpv4Gateway() { + return ipv4Gateway; + } + + public void setIpv4Gateway(String ipv4Gateway) { + this.ipv4Gateway = ipv4Gateway; + } + + public List getDnsServers() { + return dnsServers; + } + + public void setDnsServers(List dnsServers) { + this.dnsServers = dnsServers; + } + public String getPciSlot() { return pciSlot; } 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..ec7128caa7fc 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,20 @@ package org.apache.cloudstack.vm; +import java.util.List; +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 +85,44 @@ 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); + + /** + * Maps a VMware guest identity (config.guestFullName / config.guestId, available regardless of + * the source VM's power state) to a CloudStack guest OS id, so an imported VM does not inherit + * the import dummy template's generic OS type. Returns null when no reasonable match exists; + * callers then keep their previous behaviour. + */ + Long resolveGuestOsIdForVmwareImport(String osName, String osId); + + /** + * Fills in the imported VM's hardware details from the VMware source: UEFI boot (with SECURE + * when the source has secure boot enabled) plus the q35 machine type when the source firmware + * is UEFI, and a vga console for Windows guests. Only keys the caller did not provide are + * added, so explicit user choices always win. + */ + Map applyVmwareImportHardwareDetails(Map details, String bootType, String bootMode, String osName); + + /** + * Preserves static source IPs across the migration: for every NIC that is mapped to a + * CloudStack network but has no caller-provided IP, requests the guest's own IPv4 address + * (captured from VMware Tools while the source runs) when that address fits the target + * network's CIDR, is not the network's gateway and is not already in use. Caller-provided + * entries always win; NICs whose source address does not fit fall back to normal allocation. + * + * @param nicIdToIpv4Cidrs source NIC id to its captured IPv4 addresses in CIDR form + * @return the effective NIC-to-IP map to use for the import + */ + Map autoFillStaticNicIpAddresses(Map nicNetworkMap, + Map nicIpAddressMap, Map> nicIdToIpv4Cidrs); } 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..bf76922926e0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.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 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 List nics; + private final Integer cpuCores; + private final Integer cpuSpeed; + private final Integer memoryMb; + private final String operatingSystemId; + private final String operatingSystem; + private String bootType; + private String bootMode; + + 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, null, cpuCores, cpuSpeed, memoryMb, null, null); + } + + public VmwareCbtPreflightInfo(String sourceVmName, String sourceVmMor, + Boolean changeTrackingSupported, Boolean changeTrackingEnabled, + Boolean consolidationNeeded, Integer existingSnapshotCount, + List disks, + List nics, + 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.nics = nics == null ? Collections.emptyList() : Collections.unmodifiableList(nics); + 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 getNics() { + return nics; + } + + 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; + } + + public String getBootType() { + return bootType; + } + + public void setBootType(String bootType) { + this.bootType = bootType; + } + + public String getBootMode() { + return bootMode; + } + + public void setBootMode(String bootMode) { + this.bootMode = bootMode; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightNicInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightNicInfo.java new file mode 100644 index 000000000000..d22d61235d7a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightNicInfo.java @@ -0,0 +1,87 @@ +// 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; + +/** + * A source VM NIC discovered during VMware CBT preflight, so NIC-to-network + * mappings can be validated when the migration is started instead of failing + * at import time, after replication has already run. + */ +public class VmwareCbtPreflightNicInfo { + + private final String sourceNicId; + private final String adapterType; + private final String macAddress; + private final Integer vlan; + + // Guest network facts from VMware Tools (available while the source runs): the IPv4 + // addresses in CIDR form, the default gateway of this NIC's subnet, and the guest's DNS + // servers. Null when Tools is not running or reports nothing for this NIC. Used to + // preserve a static source IP across the migration when it fits the target network. + private List ipv4Cidrs; + private String ipv4Gateway; + private List dnsServers; + + public VmwareCbtPreflightNicInfo(String sourceNicId, String adapterType, String macAddress, Integer vlan) { + this.sourceNicId = sourceNicId; + this.adapterType = adapterType; + this.macAddress = macAddress; + this.vlan = vlan; + } + + public String getSourceNicId() { + return sourceNicId; + } + + public String getAdapterType() { + return adapterType; + } + + public String getMacAddress() { + return macAddress; + } + + public Integer getVlan() { + return vlan; + } + + public List getIpv4Cidrs() { + return ipv4Cidrs; + } + + public void setIpv4Cidrs(List ipv4Cidrs) { + this.ipv4Cidrs = ipv4Cidrs; + } + + public String getIpv4Gateway() { + return ipv4Gateway; + } + + public void setIpv4Gateway(String ipv4Gateway) { + this.ipv4Gateway = ipv4Gateway; + } + + public List getDnsServers() { + return dnsServers; + } + + public void setDnsServers(List dnsServers) { + this.dnsServers = dnsServers; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java new file mode 100644 index 000000000000..c8bfe3edbe84 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtSnapshotInfo { + + private final String snapshotName; + private final String snapshotMor; + private final String sourceVmMor; + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor) { + this(snapshotName, snapshotMor, null); + } + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor, String sourceVmMor) { + this.snapshotName = snapshotName; + this.snapshotMor = snapshotMor; + this.sourceVmMor = sourceVmMor; + } + + public String getSnapshotName() { + return snapshotName; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public String getSourceVmMor() { + return sourceVmMor; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java index c46fb697a3c1..9bd5bf168a3d 100644 --- a/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java @@ -19,6 +19,8 @@ public class CheckConvertInstanceCommand extends Command { boolean checkWindowsGuestConversionSupport = false; boolean useVddk = false; + boolean checkVddkRbdDirectImportSupport = false; + boolean checkVddkInPlaceFinalizationSupport = false; String vddkLibDir; public CheckConvertInstanceCommand() { @@ -50,6 +52,22 @@ public void setUseVddk(boolean useVddk) { this.useVddk = useVddk; } + public boolean isCheckVddkRbdDirectImportSupport() { + return checkVddkRbdDirectImportSupport; + } + + public void setCheckVddkRbdDirectImportSupport(boolean checkVddkRbdDirectImportSupport) { + this.checkVddkRbdDirectImportSupport = checkVddkRbdDirectImportSupport; + } + + public boolean isCheckVddkInPlaceFinalizationSupport() { + return checkVddkInPlaceFinalizationSupport; + } + + public void setCheckVddkInPlaceFinalizationSupport(boolean checkVddkInPlaceFinalizationSupport) { + this.checkVddkInPlaceFinalizationSupport = checkVddkInPlaceFinalizationSupport; + } + public String getVddkLibDir() { return vddkLibDir; } diff --git a/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java index 38e0dca7736c..8a8e194ee220 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,8 @@ public class ConvertInstanceCommand extends Command { private String vddkLibDir; private String vddkTransports; private String vddkThumbprint; + private List vmwareVddkSourceDisks; + private boolean windowsGuest; public ConvertInstanceCommand() { } @@ -126,6 +131,22 @@ public void setVddkThumbprint(String vddkThumbprint) { this.vddkThumbprint = vddkThumbprint; } + public List getVmwareVddkSourceDisks() { + return vmwareVddkSourceDisks; + } + + public void setVmwareVddkSourceDisks(List vmwareVddkSourceDisks) { + this.vmwareVddkSourceDisks = vmwareVddkSourceDisks; + } + + public boolean isWindowsGuest() { + return windowsGuest; + } + + public void setWindowsGuest(boolean windowsGuest) { + this.windowsGuest = windowsGuest; + } + @Override public boolean executeInSequence() { return false; diff --git a/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java index eadfa6556f86..e5b970814fab 100644 --- a/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java @@ -18,6 +18,7 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.storage.Storage; import java.util.List; @@ -25,6 +26,7 @@ public class ImportConvertedInstanceCommand extends Command { private RemoteInstanceTO sourceInstance; private List destinationStoragePools; + private List destinationStoragePoolTypes; private DataStoreTO conversionTemporaryLocation; private String temporaryConvertUuid; private boolean forceConvertToPool; @@ -34,15 +36,24 @@ public ImportConvertedInstanceCommand() { public ImportConvertedInstanceCommand(RemoteInstanceTO sourceInstance, List destinationStoragePools, + List destinationStoragePoolTypes, DataStoreTO conversionTemporaryLocation, String temporaryConvertUuid, boolean forceConvertToPool) { this.sourceInstance = sourceInstance; this.destinationStoragePools = destinationStoragePools; + this.destinationStoragePoolTypes = destinationStoragePoolTypes; this.conversionTemporaryLocation = conversionTemporaryLocation; this.temporaryConvertUuid = temporaryConvertUuid; this.forceConvertToPool = forceConvertToPool; } + public ImportConvertedInstanceCommand(RemoteInstanceTO sourceInstance, + List destinationStoragePools, + DataStoreTO conversionTemporaryLocation, String temporaryConvertUuid, + boolean forceConvertToPool) { + this(sourceInstance, destinationStoragePools, null, conversionTemporaryLocation, temporaryConvertUuid, forceConvertToPool); + } + public RemoteInstanceTO getSourceInstance() { return sourceInstance; } @@ -51,6 +62,10 @@ public List getDestinationStoragePools() { return destinationStoragePools; } + public List getDestinationStoragePoolTypes() { + return destinationStoragePoolTypes; + } + public DataStoreTO getConversionTemporaryLocation() { return conversionTemporaryLocation; } diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java new file mode 100644 index 000000000000..65ae402e1140 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java @@ -0,0 +1,100 @@ +// 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() { + // Cancellation must be able to interrupt a long-running prepare/sync command on the + // same host. If cleanup is queued behind that command, the VDDK reader keeps the source + // snapshot open until the entire copy finishes and the cleanup request can time out + // before it ever reaches the agent. + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java new file mode 100644 index 000000000000..24d82c434afe --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtCutoverCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private int finalCycleNumber; + private boolean runVirtV2vFinalization; + private boolean allowNonInPlaceFinalization; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtCutoverCommand() { + } + + public VmwareCbtCutoverCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + int finalCycleNumber, boolean runVirtV2vFinalization) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.finalCycleNumber = finalCycleNumber; + this.runVirtV2vFinalization = runVirtV2vFinalization; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public int getFinalCycleNumber() { + return finalCycleNumber; + } + + public boolean getRunVirtV2vFinalization() { + return runVirtV2vFinalization; + } + + public boolean getAllowNonInPlaceFinalization() { + return allowNonInPlaceFinalization; + } + + public void setAllowNonInPlaceFinalization(boolean allowNonInPlaceFinalization) { + this.allowNonInPlaceFinalization = allowNonInPlaceFinalization; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java new file mode 100644 index 000000000000..2a0435639418 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; + +public class VmwareCbtMigrationAnswer extends Answer { + + private String migrationUuid; + private int cycleNumber; + private long changedBytes; + private long dirtyRateBytesPerSecond; + private long durationSeconds; + private boolean readyForCutover; + private List diskResults; + + public VmwareCbtMigrationAnswer() { + super(); + } + + public VmwareCbtMigrationAnswer(Command command, boolean result, String details, String migrationUuid) { + super(command, result, details); + this.migrationUuid = migrationUuid; + } + + public VmwareCbtMigrationAnswer(Command command, boolean result, String details, String migrationUuid, int cycleNumber, + long changedBytes, long dirtyRateBytesPerSecond, long durationSeconds, + boolean readyForCutover, List diskResults) { + super(command, result, details); + this.migrationUuid = migrationUuid; + this.cycleNumber = cycleNumber; + this.changedBytes = changedBytes; + this.dirtyRateBytesPerSecond = dirtyRateBytesPerSecond; + this.durationSeconds = durationSeconds; + this.readyForCutover = readyForCutover; + this.diskResults = diskResults; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public long getChangedBytes() { + return changedBytes; + } + + public long getDirtyRateBytesPerSecond() { + return dirtyRateBytesPerSecond; + } + + public long getDurationSeconds() { + return durationSeconds; + } + + public boolean getReadyForCutover() { + return readyForCutover; + } + + public List getDiskResults() { + return diskResults; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java new file mode 100644 index 000000000000..0791d3b06a6f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtPrepareCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String baselineSnapshotMor; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtPrepareCommand() { + } + + public VmwareCbtPrepareCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + Storage.StoragePoolType destinationStoragePoolType, String destinationStoragePoolUuid, + VmwareCbtTargetStorageType targetStorageType, String baselineSnapshotMor) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.destinationStoragePoolType = destinationStoragePoolType; + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + this.targetStorageType = targetStorageType; + this.baselineSnapshotMor = baselineSnapshotMor; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public String getBaselineSnapshotMor() { + return baselineSnapshotMor; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java new file mode 100644 index 000000000000..73b532bfff71 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java @@ -0,0 +1,63 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtRbdProbeCommand extends Command { + + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private String probeImageName; + private VmwareCbtTargetStorageType targetStorageType; + + public VmwareCbtRbdProbeCommand() { + } + + public VmwareCbtRbdProbeCommand(Storage.StoragePoolType destinationStoragePoolType, + String destinationStoragePoolUuid, String probeImageName) { + this.destinationStoragePoolType = destinationStoragePoolType; + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + this.probeImageName = probeImageName; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public String getProbeImageName() { + return probeImageName; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java new file mode 100644 index 000000000000..a58c0029c43f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java @@ -0,0 +1,138 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtSyncCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private List changedBlocks; + private int cycleNumber; + private String snapshotMor; + private boolean finalSync; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtSyncCommand() { + } + + public VmwareCbtSyncCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + List changedBlocks, int cycleNumber, String snapshotMor, + boolean finalSync) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.changedBlocks = changedBlocks; + this.cycleNumber = cycleNumber; + this.snapshotMor = snapshotMor; + this.finalSync = finalSync; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public List getChangedBlocks() { + return changedBlocks; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public boolean getFinalSync() { + return finalSync; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java new file mode 100644 index 000000000000..3707ea498744 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +public enum VmwareCbtTargetStorageType { + QCOW2_FILE, + RBD_RAW, + /** + * Raw writes into a host-local block device provided by the destination + * primary storage (e.g. a Linstor/DRBD volume). The device is pre-created + * at source capacity through the storage adaptor before any data copy. + */ + RAW_BLOCK_DEVICE +} diff --git a/docs/vmware-cbt-migration.md b/docs/vmware-cbt-migration.md new file mode 100644 index 000000000000..cc33fa2a99df --- /dev/null +++ b/docs/vmware-cbt-migration.md @@ -0,0 +1,457 @@ + + +# VMware CBT migration to KVM + +This document describes the implemented 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. + +## Implementation status + +The 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 KVM agent exposes each VMware snapshot disk through `nbdkit-vddk` and + copies it to a source-equivalent target on KVM primary storage. It uses + `qemu-img convert` for the general path and can use `nbdcopy` as an optional + accelerator for supported targets. `virt-v2v` does not run in this phase. + +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, runs `virt-v2v` locally against the current KVM-side replica, + 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.vddk.blockcopy.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`, `qemu-nbd`, and +`qemu-io` checks. + +Related host detail keys: + +```text +host.vddk.support +vddk.lib.dir +host.vddk.version +host.vddk.blockcopy.support +host.vddk.blockcopy.inplace.finalization.support +host.vddk.blockcopy.rbd.support +host.qemu.img.version +host.qemu.nbd.version +host.qemu.io.version +host.virtv2v.version +host.virtv2v.inplace.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 final cutover conversion. +* `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. +* `qemu-io` for applying and verifying changed block ranges. +* Optional `nbdcopy` from libnbd to accelerate supported initial full-copy + paths. CloudStack falls back to `qemu-img` when it is unavailable. +* Network access from the KVM host to vCenter and ESXi hosts. +* Access to the destination KVM primary storage pool. +* `virtio-win` drivers when migrating Windows guests. + +Destination-specific requirements: + +* Ceph/RBD destination pools additionally require qemu built with the RBD + block driver on the conversion host, plus in-place `virt-v2v` support + (the `virt-v2v-in-place` binary or the `virt-v2v --in-place` option). +* Linstor destination pools additionally require in-place `virt-v2v` support, + and the conversion host must be a LINSTOR satellite connected to the + destination pool (the CloudStack primary storage must be attached to the + host, and the host's `hostname` must match its LINSTOR node name). Target + volumes are pre-created through the Linstor storage adaptor and written as + raw data to the local DRBD device path, so no extra qemu block driver is + needed. DRBD permits only one writer: make sure no other node holds the + target resource Primary while a migration writes to it. + +You do not have to install these packages on every KVM host in the zone unless +you want every KVM host to be eligible for VMware CBT migration. A common +deployment model is to prepare a smaller set of conversion-capable KVM hosts and +explicitly select one of them for migration work. + +The KVM agent checks these commands: + +```bash +virt-v2v --version +nbdkit vddk --dump-plugin libdir= +qemu-img --version +qemu-nbd --version +qemu-io --version +``` + +On Ubuntu and Debian hosts the 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 libnbd +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 +qemu-io --version +nbdkit vddk --dump-plugin libdir=/opt/vmware/vmware-vix-disklib-distrib +``` + +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. + +Distribution `virtio-win` packages can lag the upstream project and omit +drivers for recent Windows releases. `virt-v2v` treats missing drivers as a +non-fatal warning and would deliver a guest that cannot boot from its VirtIO +disk, so both the cold import and the CBT cutover conversion **fail fast +instead**. When `virt-v2v` reports that no VirtIO drivers are available, the +operation is aborted and identifies the conversion host's +`/usr/share/virtio-win/virtio-win.iso` as the file to update. Install the +upstream `latest-virtio` ISO on every conversion host: + +```bash +curl -L -o /usr/share/virtio-win/virtio-win.iso \ + https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso +``` + +Windows Server 2025 additionally rejects the optional `smbus` driver's +signature at first boot, which loops the virt-v2v firstboot driver +installation forever; rebuild the ISO without `smbus.inf`/`smbus.cat` before +converting Windows Server 2025 guests (the admin guide page "Importing VMware +VMs into KVM" carries the exact commands). Older Windows releases work with +the unmodified ISO. + +### Guest CPU model for Windows 11 and Windows Server 2025 + +Those releases require CPU features that the KVM agent's default guest CPU +model (`qemu64`) does not expose. The conversion itself is unaffected — the +disks convert, the drivers install, the imported instance starts and reaches +its UEFI firmware — but the guest then drops into *Preparing Automatic Repair* +instead of the login screen, on every boot. Nothing in the migration output +indicates a problem, so it reads like a corrupt image. + +Set a modern named model on every KVM host that will run such guests, in +`/etc/cloudstack/agent/agent.properties`, and restart the agent: + +``` +guest.cpu.mode=custom +guest.cpu.model= +``` + +The model has to be one the host can actually provide, and that differs by +processor vendor and generation — `Broadwell-noTSX-IBRS` or newer on Intel, +`EPYC-IBPB` or newer on AMD. Check before setting it: + +```bash +virsh domcapabilities | grep "usable='yes'" +``` + +A model reported as `usable='no'` is refused at domain start with *"the CPU is +incompatible with host CPU: Host CPU does not provide required features"*, and +the instance never launches — including system VMs, if the host is the one +running them. In a cluster, pick a model every host reports as usable so live +migration stays possible. + +`guest.cpu.mode=host-model` and `host-passthrough` also work and need no such +check, but they tie the instance to the host's CPU and prevent live migration +between hosts with different processors. + +This applies equally to instances deployed natively on KVM — it is a host +prerequisite for the guest OS, not a property of the migration. + +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 libnbd-bin +``` + +Then install or build the nbdkit VDDK plugin and verify: + +```bash +nbdkit vddk --dump-plugin libdir=/opt/vmware/vmware-vix-disklib-distrib +``` + +`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 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. The upstream-ISO, `smbus` and +guest CPU model notes above apply here as well. + +## 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. + +## Guest identity, firmware and network defaults derived from the source + +Both the cold import and the CBT warm migration derive sensible defaults from +the source VM instead of falling back to generic import defaults. Everything +below applies only when the caller did not specify the value explicitly - +operator-provided parameters always win. + +* **Guest OS type** is resolved from the source's configured identity + (`config.guestFullName` / `config.guestId`, available regardless of power + state). Windows releases newer than the guest OS catalog map to the nearest + older entry of the same family (for example Windows Server 2025 maps to the + Windows Server 2022 type until the catalog catches up); the mapped type + keeps Windows clock and Hyper-V enlightenment semantics correct on KVM. +* **Firmware** follows the source: a UEFI source gets the `UEFI` setting + (`SECURE` when the source has secure boot enabled) and the `q35` machine + type. Previously every import defaulted to BIOS, which leaves a UEFI + source's GPT/EFI disk unbootable. +* **Console video** for Windows guests is set to `vga`; the libvirt default + (legacy cirrus) renders a blank console on Windows Server 2025 Server Core. +* **Static IPv4 preservation**: while the source runs, per-NIC addresses, + the subnet's default gateway and the DNS servers are captured from VMware + Tools. A NIC that is mapped to a CloudStack network without an explicit + `nicipaddresslist` entry keeps its guest-configured address when that + address fits the target network's CIDR, is not the gateway and is free; + otherwise the NIC falls back to normal allocation. DHCP NICs are untouched + and pick up the virtual router's lease after migration. Capture requires + VMware Tools running in the source, which the warm flow guarantees (the + source is live when the migration starts). + +## Validation checklist + +Run this checklist on every candidate KVM migration host: + +```bash +virt-v2v --version +qemu-img --version +qemu-nbd --version +qemu-io --version +nbdkit vddk --dump-plugin libdir=/opt/vmware/vmware-vix-disklib-distrib +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.vddk.blockcopy.support=true +host.vddk.blockcopy.inplace.finalization.support= +host.vddk.blockcopy.rbd.support= +host.qemu.img.version= +host.qemu.nbd.version= +host.qemu.io.version= +host.virtv2v.version= +``` + +If `host.vddk.blockcopy.support` is false, check the agent log on the KVM host +and validate each command above manually. In-place and RBD capability can be +false on hosts intended only for QCOW2 fallback, but those hosts are not +eligible for RBD or LINSTOR cutover. + +## Operational notes + +* Prefer `nbdssl` or another encrypted VDDK transport where supported. +* Run migrations from hosts with fast access to destination primary storage. +* Keep enough temporary space for virt-v2v and libguestfs scratch data; use + `convert.instance.env.tmpdir` and `convert.instance.env.virtv2v.tmpdir` when + the default temporary filesystem is too small. +* Use the current quiet-cycle settings to avoid cutting over while the source + VM is still producing a high dirty rate. +* Increase `vmware.cbt.migration.agent.command.timeout` before migrating very + large VMs if the baseline copy or cutover finalization can exceed 24 hours. +* Always clean up VMware snapshots after failed or cancelled cycles. +* Treat CBT change IDs as per-disk state. If any disk loses valid CBT state, + restart that disk from a full sync. +* On Linstor destinations, prefer a conversion host that holds a diskful + replica of the target resources; writes from a diskless satellite traverse + the DRBD replication network. DRBD rounds volume sizes up to its extent + granularity, so target devices can be slightly larger than the source + disks; qemu writes only within the source capacity. +* Linstor storage-pool heartbeats can fence (reboot) an HA-enabled KVM host if + the LINSTOR controller becomes unreachable. Prefer conversion hosts that do + not run HA-protected guests, or ensure controller availability for the whole + duration of long migrations. +* Migrated Linstor volumes keep their migration-time names (the LINSTOR + resource is `cs-` plus the recorded volume path, e.g. `cs-cbt--` + for warm migrations). This is expected and does not affect volume lifecycle + operations, which address volumes by their recorded path. diff --git a/docs/vmware-cbt/README.md b/docs/vmware-cbt/README.md new file mode 100644 index 000000000000..b9d745750e1a --- /dev/null +++ b/docs/vmware-cbt/README.md @@ -0,0 +1,1550 @@ + + +# VMware CBT Migration Implementation Notes + +## Introduction + +This document describes the VMware Changed Block Tracking (CBT) migration design +and implementation for Apache CloudStack 24.0.0. It is written as source material +for an Apache CloudStack CWIKI architecture page and should be treated as an +implementation guide, not only as a design sketch. + +The feature provides a warm VMware-to-KVM migration flow. CloudStack creates a +KVM-side source-equivalent disk replica from a VMware VDDK snapshot, keeps that +replica current with one or more VMware CBT delta cycles, requires the operator +to power off the VMware source VM, runs one final CBT delta cycle, finalizes the +replica with `virt-v2v`, and imports the finalized disk(s) as a regular +CloudStack KVM VM. + +The essential architectural point is that VMware CBT migration is a two-phase +disk strategy: + +- Phase 1 keeps a KVM-side source-equivalent replica current using VDDK and + VMware CBT. +- Phase 2 finalizes the fully synchronized replica with `virt-v2v` only after + the source VM is powered off. + +This gives CloudStack warm migration behavior without trying to apply VMware +changed blocks to a disk that has already been transformed for KVM, and without +requiring a second full VMware read at cutover time. + +## Problem Statement + +The existing VMware import paths are useful for one-time imports, but they do +not provide a warm migration loop where the target disk is kept current while +the source VM continues to run on VMware. Large VMs therefore either require a +longer downtime window or repeated full-copy style attempts. + +VMware CBT provides the changed-block metadata needed for a replication-style +workflow, but the design must avoid a common trap: once `virt-v2v` modifies a +disk for KVM guest bootability, VMware CBT byte ranges can no longer be safely +applied to that transformed disk. The implementation must therefore keep the +replication target source-equivalent until final cutover, then run guest +conversion only after the final delta is applied. + +## Goals + +- Provide an operator-driven warm migration flow from VMware to CloudStack KVM. +- Support both existing registered vCenter records and external vCenter details + supplied during import. +- Run the initial full copy once through VDDK and `qemu-img`. +- Apply subsequent VMware CBT delta cycles to the KVM-side replica. +- Make long-running start, sync, and cutover operations asynchronous. +- Keep migration progress visible through list APIs and the UI migration table. +- Validate destination storage, host capabilities, and compute offering sizing + before expensive work starts. +- Persist external vCenter credentials only through CloudStack encrypted DAO + fields and never expose passwords in API responses. +- Reuse the existing CloudStack converted-VM import path after cutover + finalization. + +## Scope + +Current implementation scope: + +- Target hypervisor: KVM. +- Source hypervisor: VMware through VDDK and VMware CBT. +- Admin API surface for preflight, start, list, sync, cutover, cancel, and + delete. +- Async API execution for long-running start, sync, and cutover operations. +- Management-server state model for migrations, disks, and delta cycles. +- External vCenter credential persistence for long-lived UI sessions, encrypted + with CloudStack DAO encryption. +- KVM agent wrappers for initial VDDK full sync, delta block copy, cutover + finalization, and cleanup. +- Filesystem-like primary storage target support using QCOW2 files. +- Ceph/RBD primary storage target support using raw RBD images. +- Non-in-place `virt-v2v` fallback for QCOW2 file targets, gated by global + configuration. +- Early compute-offering validation against discovered VMware VM CPU and memory. +- Dynamic/custom service offering support through the same `details` keys used + by the existing OVF/VDDK import flow. +- UI integration in the Import/Export Instances area, including job polling and + table auto-refresh while CBT migrations are active. + +## Non-Goals + +Not implemented or intentionally blocked: + +- Non-in-place `virt-v2v` fallback for Ceph/RBD targets. RBD targets require + in-place finalization because fallback produces local QCOW2 files. +- Automatic source VM shutdown during cutover. +- Continuous byte-level progress from `qemu-img convert`. The UI currently + receives state transitions, disk target paths, delta cycle byte counts, dirty + rates, and cycle descriptions through the list API. +- Applying CBT deltas to a guest-converted disk. +- Replacing the existing OVF/VDDK one-time import flows. + +## Key Principles + +- CloudStack does not power off the VMware source VM automatically. The + operator must shut it down before final cutover. +- CloudStack does not store vCenter passwords in clear text. +- Initial sync and delta sync keep the target disk source-equivalent. +- `virt-v2v` runs only at finalization, after the final CBT delta cycle. +- The final `virt-v2v` work is local to the KVM-side replica and does not + perform a second full VMware read. +- The detailed status source is `listVmwareCbtMigrations`, not only the async + job result. +- Failed final import after successful finalization is retryable through the + `ReadyForImport` state. + +## High-Level Architecture + +The implementation has four main layers: + +- CloudStack management-server APIs and orchestration. +- CloudStack database state for migration sessions, source disks, and delta + cycles. +- KVM agent commands that perform VDDK reads, QCOW2 or raw RBD writes, + `virt-v2v` finalization, and cleanup. +- UI workflow in the Import/Export Instances area. + +Management-server API and orchestration: + +- `StartVmwareCbtMigrationCmd` +- `CheckVmwareCbtMigrationPrerequisitesCmd` +- `ListVmwareCbtMigrationsCmd` +- `SyncVmwareCbtMigrationCmd` +- `CutoverVmwareCbtMigrationCmd` +- `CancelVmwareCbtMigrationCmd` +- `DeleteVmwareCbtMigrationCmd` +- `VmwareCbtMigrationManagerImpl` +- `VmwareCbtStorageTarget` +- `VmwareCbtMigrationCutoverPolicy` + +Persistence: + +- `cloud.vmware_cbt_migration` +- `cloud.vmware_cbt_migration_disk` +- `cloud.vmware_cbt_migration_cycle` + +KVM agent wrappers: + +- `LibvirtVmwareCbtPrepareCommandWrapper` +- `LibvirtVmwareCbtSyncCommandWrapper` +- `LibvirtVmwareCbtCutoverCommandWrapper` +- `LibvirtVmwareCbtCleanupCommandWrapper` + +UI: + +- `ui/src/views/tools/ImportUnmanagedInstance.vue` +- `ui/src/views/tools/ManageInstances.vue` +- `ui/src/views/tools/VmwareCbtMigrations.vue` + +## Architecture Diagrams + +The diagrams below are intentionally plain text so they can be pasted directly +into Apache CWIKI source and remain editable. + +### End-To-End CBT Flow + +```text +VMware source VM + | + | VMware snapshot + CBT metadata + v +VDDK / nbdkit-vddk on KVM conversion host + | + | Initial baseline: full logical disk read + v +CloudStack primary storage + | + | Source-equivalent replica + | - qcow2 file on filesystem-like storage + | - raw RBD image on Ceph/RBD storage + v +Delta sync cycles + | + | CBT changed ranges only + v +Operator powers off source VM + | + | Cutover: final delta + virt-v2v finalization + v +CloudStack KVM VM +``` + +### Initial Sync Transfer Behavior + +VMware CBT initial sync is not guest-filesystem-used-space-oriented. CloudStack +does not inspect the guest filesystem to decide which files are in use; it reads +the disk image exposed by VDDK/NBD and writes the target replica. + +The amount of data transferred depends on what VDDK exposes for the source disk +and datastore. Some source paths expose the baseline as one allocated logical +range, while others expose sparse `hole,zero` extents. The destination can remain +thin or sparse when the source and copy path expose zero ranges correctly, but +operators must size the initial-copy window for the full logical disk capacity. + +The KVM agent uses `qemu-img convert` for the general full-copy path. When +`nbdcopy` is installed, it can accelerate supported pre-created raw targets and +RBD targets served through a local `qemu-nbd` bridge. `nbdcopy` is optional; +capability and correctness do not depend on it. + +```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. + +Possible outcome: full logical transfer or sparse-aware transfer +Target allocation: backend- and extent-dependent +``` + +### 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. Filesystem-like primary storage +writes QCOW2 with `qemu-img convert`: + +```text +nbdkit -r -U - vddk ... --run \ + 'qemu-img convert -f raw -O qcow2 "$uri" ""' +``` + +Ceph/RBD primary storage is a raw RBD image. When `nbdcopy` is available, the +agent pre-creates the image, exposes it through a temporary local `qemu-nbd` +bridge, and copies NBD to NBD. Otherwise it uses `qemu-img convert` directly +against the qemu RBD URL. LINSTOR similarly uses `nbdcopy` when available for +its pre-created raw DRBD device and falls back to `qemu-img convert`. + +Example RBD fallback path: + +```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. + +The initial baseline may read up to the full logical VMware disk range, +regardless of guest filesystem usage. This does not imply thick allocation on +the destination: QCOW2, RBD, and LINSTOR can remain sparse or thin when the +source and copy path expose zero ranges correctly. Delta cycles are CBT-range +based and copy only changed ranges. + +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 migrations while still +protecting the agent from permanently stuck child processes. + +Related import settings that operators often notice are: + +| Global setting | Applies to CBT? | Default | Meaning | +| --- | --- | ---: | --- | +| `convert.vmware.instance.to.kvm.timeout` | No | `3` | OVF/VDDK import timeout, in hours, for the `ConvertInstanceCommand` virt-v2v path. CBT cutover has its own timeout because it uses CBT-specific agent commands. | +| `remote.kvm.instance.disks.copy.timeout` | No | `30` | Remote KVM unmanaged import disk-copy timeout, in minutes. It is unrelated to VMware CBT. | + +If a CBT command exceeds `vmware.cbt.migration.agent.command.timeout`, the +migration is marked failed with the agent error details in `last_error`. +Failed or cancelled migration records can be deleted with cleanup enabled to +remove CloudStack-owned `cloudstack-cbt/` working files. + +## Database Changes + +The feature targets the Apache CloudStack 24.0.0 release, so the schema changes +are delivered through the canonical 4.23.0.0 to 24.0.0 upgrade path: + +- `engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql` +- `engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42300to2400.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 with `nbdcopy` + through a local NBD bridge when available, otherwise with + `qemu-img convert -O raw`. Delta sync writes changed ranges with `qemu-io -f + raw`, and cutover finalization is in-place only. +- `Linstor` maps to `RAW_BLOCK_DEVICE`. The agent pre-creates each target + volume at source capacity through the Linstor storage adaptor (qemu cannot + create DRBD devices the way it creates RBD images), then the initial sync + writes the local DRBD device with `nbdcopy` when available or + `qemu-img convert -n -O raw` otherwise. Delta sync patches changed ranges + with `qemu-io -f raw` against the device path, and cutover finalization is + in-place only, using a plain `` libvirt XML (no qemu-nbd + bridges are needed for local block devices). + Target names are `cbt--` where `mig8` is the first eight + characters of the migration UUID: LINSTOR resource names ("cs-" + name) are + capped at 48 characters, so the RBD-style naming with the full migration + UUID does not fit; the short marker still guards cleanup on both the + management server and the agent. The conversion host must be a LINSTOR + satellite connected to the pool; host selection and the preflight storage + probe (create a 4 MiB volume, qemu-io write/read on the device, delete) + enforce this. +- Other primary storage types are unsupported. + +For QCOW2 file targets, the initial replica is stored under the selected +primary storage: + +```text +/mnt//cloudstack-cbt//.qcow2 +``` + +If no storage pool is selected, the fallback path is: + +```text +/var/lib/libvirt/images/cloudstack-cbt//.qcow2 +``` + +For Ceph/RBD targets, the initial replica is a raw RBD image in the selected +pool: + +```text +/cloudstack-cbt--- +``` + +Only the image name is persisted as the migration disk target path. The agent +reconstructs the qemu RBD URL from the selected storage pool when writing +initial and delta data. During in-place `virt-v2v` finalization, the agent +starts a temporary localhost `qemu-nbd` bridge for each RBD image and gives +`virt-v2v` a libvirt XML disk that points to that local NBD endpoint. This keeps +RBD credentials and monitor options in the qemu layer and presents a local NBD +endpoint to the conversion appliance. + +The preferred cutover finalization is in-place: + +- `virt-v2v-in-place`, if present. +- `virt-v2v --in-place`, if the installed `virt-v2v` supports it. + +If neither in-place method is available, QCOW2 file targets can use regular +`virt-v2v -o local` only when this global configuration is enabled: + +```text +vmware.cbt.allow.non.inplace.finalization=true +``` + +Default: + +```text +false +``` + +When fallback is enabled: + +- The fallback is only allowed for supported QCOW2 file targets. +- The agent stages both `TMPDIR` and `virt-v2v` output under the current + migration directory on the selected primary storage. +- The agent validates free space before running fallback finalization. +- Required free space is conservatively estimated as two times the summed disk + capacity. +- After successful fallback finalization, source replica disks are deleted. + +Fallback is never allowed for RBD targets. RBD migrations require +`virt-v2v-in-place` or `virt-v2v --in-place` support on the selected conversion +host. + +Example fallback output path: + +```text +/mnt//cloudstack-cbt//virt-v2v-output-/-sda +``` + +After successful finalization, both in-place and fallback paths are normalized +to the same final layout used by the OVF/VDDK import flow: the KVM agent moves +each finalized QCOW2 file to the selected primary storage root and gives it a +generated UUID filename: + +```text +/mnt// +``` + +The agent returns the relocated target paths in the cutover answer. The +management server persists them in `vmware_cbt_migration_disk.target_path` +through the normal disk-result update path, so no manual database update or +schema change is required for this relocation. CloudStack then imports the VM +using the flat root-level file name, matching the final placement used by +regular OVF/VDDK migrations. + +For RBD targets, successful in-place finalization keeps the raw RBD image in +place and CloudStack imports it by image name from the selected RBD storage +pool. No post-finalization file move is performed, and completed migration +deletion remains record-only. + +RBD finalization creates only temporary localhost `qemu-nbd` processes and +temporary XML/script files on the conversion host. The wrapper installs a shell +trap to stop those bridge processes and remove the PID files after `virt-v2v` +finishes or fails. The persistent storage artifact remains the raw RBD image +whose name contains the `cloudstack-cbt--` marker until +CloudStack imports it or cleanup removes it for a failed/cancelled migration. + +CloudStack imports finalized CBT disks with the KVM `virtio` root disk +controller. The conversion host must have `virtio-win` available for Windows +guests so that `virt-v2v` can enable the matching boot driver before the VM is +started under KVM. This keeps the persisted `rootDiskController` detail aligned +with the controller model used by the converted guest. + +## Ceph/RBD Conversion Host Requirements + +Ceph/RBD support relies on the same KVM host plumbing that normal CloudStack RBD +primary storage uses, plus the CBT-specific VMware conversion tools. The +selected conversion host must be able to access the destination RBD pool from +both Java/libvirt storage code and the qemu command-line tools used by the CBT +wrappers. + +Required conversion-host capabilities: + +- CloudStack KVM agent with the normal RBD primary-storage dependencies. +- The selected KVM/conversion host must already be able to use the destination + CloudStack RBD primary storage pool. +- `librados` and `librbd` client libraries compatible with the Ceph cluster. +- Java RADOS/RBD bindings used by CloudStack's RBD storage adaptor. +- qemu RBD block driver support so `qemu-img` and `qemu-io` can open + `rbd:/:...` URLs. +- VDDK and `nbdkit-vddk` for VMware source reads. +- `virt-v2v-in-place` or `virt-v2v --in-place` for RBD cutover finalization. +- `virtio-win` or equivalent VirtIO driver media/packages for Windows guests, + same as the existing VMware import requirements. + +CloudStack RBD primary-storage configuration must be valid on the selected +KVM/conversion host. For CBT-to-RBD, CloudStack uses the existing KVM +storage-pool metadata to build qemu RBD URLs, including monitor addresses, +authentication mode, Ceph user, and secret. The CBT qemu path therefore does not +rely on `/etc/ceph/ceph.conf` or a local keyring as its primary credential source +when CloudStack supplies explicit RBD URL options. + +A local Ceph configuration and keyring can still be useful for operator +diagnostics and site-local tooling, for example running `ceph -s`, +`rbd ls `, or similar commands without manually passing monitor and +authentication options each time. Sites may choose to install those files for +operational convenience, but they should not be documented as the required +credential source for CBT RBD writes. + +The Ceph client packages on every KVM/conversion host should match the Ceph +cluster major release, or at least be explicitly supported by that cluster +release. Avoid relying on the distribution's stock Ceph packages if they are +older than the deployed Ceph cluster. Ancient `librbd`, `librados`, qemu RBD +block-driver, or Java binding packages can fail in ways that look like CBT +errors even though the actual problem is a client/cluster compatibility mismatch. + +Practical guidance: + +- If the Ceph cluster is deployed from upstream Ceph packages, install the + corresponding upstream Ceph client repository on all KVM/conversion hosts. +- If the Ceph cluster is deployed from a vendor repository, use that vendor's + matching client packages on the KVM/conversion hosts. +- Keep `ceph-common`, `librados`, `librbd`, qemu RBD block-driver packages, and + Java RADOS/RBD bindings aligned as a set. Do not mix a modern Ceph cluster + with old OS-default client libraries unless the Ceph vendor explicitly + supports that combination. +- Verify the KVM host can already use the selected RBD primary storage for + ordinary CloudStack VM volumes before enabling CBT migrations to the same + pool. +- Verify that qemu tools can see RBD support. Package names vary by OS, but on + many EL-like distributions this is provided by a qemu RBD block-driver package + such as `qemu-kvm-block-rbd`; on Debian/Ubuntu-like systems it is commonly + part of qemu block extras. + +CBT preflight and `startVmwareCbtMigration` actively probe RBD access when the +selected destination pool is RBD. The management server sends the selected +conversion host a small probe command using a temporary image named +`cloudstack-cbt-probe-`. The KVM agent builds the qemu RBD URL from the +existing CloudStack storage-pool metadata, runs `qemu-img create`, writes and +reads a 4 KiB pattern with `qemu-io`, and removes the temporary image through +CloudStack's existing RBD storage adaptor. This catches qemu RBD driver issues, +Ceph monitor/authentication problems, and Java RADOS/RBD cleanup failures before +the long-running VMware copy starts. + +Useful host checks: + +```bash +ceph -s +ceph osd lspools +rbd ls +qemu-img --help | grep -i rbd +qemu-io --help | grep -i rbd +``` + +Useful package/version checks: + +```bash +ceph --version +rbd --version +ldconfig -p | egrep 'librbd|librados' +java -version +``` + +Useful CloudStack database check after the host reconnects: + +```sql +SELECT h.id, h.name, hd.name, hd.value +FROM cloud.host h +JOIN cloud.host_details hd ON hd.host_id = h.id +WHERE h.name = '' + AND hd.name IN ( + 'host.vddk.blockcopy.support', + 'host.vddk.blockcopy.inplace.finalization.support', + 'host.vddk.blockcopy.rbd.support', + 'host.vddk.support', + 'host.vddk.version', + 'host.virtv2v.inplace.version', + 'vddk.lib.dir' + ) +ORDER BY hd.name; +``` + +An RBD-capable conversion host should already report VMware CBT capability as +`true` before a migration starts, and the selected destination storage pool must +be the RBD pool that the host can access. If these host details are stale after +installing packages, restart `cloudstack-agent`, ensure the management server is +running, and wait for host details to be refreshed. + +## Host Capability Reporting + +The agent checks these on startup/ready and sends them as host details. Management then persists them into `cloud.host_details`. + +| Host detail | How agent checks it | +| --- | --- | +| `host.virtv2v.version` | Runs `virt-v2v --version`. On Ubuntu/Debian it also checks `dpkg -l nbdkit`. Version is parsed from `virt-v2v --version`. | +| `vddk.lib.dir` | Reads `vddk.lib.dir` from `agent.properties`; if blank/invalid, auto-detects with `find / -type d -name 'vmware-vix-disklib-distrib' 2>/dev/null \| head -n 1`. Valid means `/lib64/libvixDiskLib.so*` exists. | +| `host.vddk.version` | Runs `nbdkit vddk --dump-plugin libdir=` and parses `vddk_library_version=`. | +| `host.vddk.support` | True only if `virt-v2v`/`nbdkit` support is present, VDDK dir is valid, and `nbdkit-vddk-plugin` can load VDDK via `nbdkit vddk --dump-plugin libdir=`. | +| `host.qemu.img.version` | Runs `qemu-img --version`, parses first non-empty line. | +| `host.qemu.nbd.version` | Runs `qemu-nbd --version`, parses first non-empty line. | +| `host.qemu.io.version` | Runs `qemu-io --version`, parses first non-empty line. | +| `host.vddk.blockcopy.support` | True only if `host.vddk.support` is true and `qemu-img --version`, `qemu-nbd --version`, and `qemu-io --version` all exit `0`. | +| `host.vddk.blockcopy.inplace.finalization.support` | True only if CBT support is true and either `virt-v2v-in-place --version` works, or `virt-v2v --help 2>&1 \| grep -q -- '--in-place'` succeeds. | +| `host.virtv2v.inplace.version` | If `virt-v2v-in-place` exists, parses `virt-v2v-in-place --version`. If only `virt-v2v --in-place` exists, reports the normal `virt-v2v` version. | +| `host.vddk.blockcopy.rbd.support` | True only if in-place finalization support is true and `qemu-img --help` advertises the `rbd` block driver. | + +Important nuance: `host.vddk.blockcopy.rbd.support` is only the coarse host capability. For an actual selected RBD pool, preflight/start also runs an active probe: create a temporary `cloudstack-cbt-probe-` RBD image, write/read 4 KiB using `qemu-io`, then delete it through CloudStack's RBD storage adapter. That catches Ceph auth, monitor, qemu RBD, librados/librbd, and Java binding problems. + +Windows source VMs also require `virtio-win` on the selected KVM conversion +host. CBT preflight and `startVmwareCbtMigration` use the same +`CheckConvertInstanceCommand` path as OVF/VDDK import with Windows guest +conversion enabled, so a missing `virtio-win` package is reported before the +initial sync starts. + +VDDK detection uses either agent configuration or auto-detection. The agent +validates the VDDK library by checking that `lib64/libvixDiskLib.so*` exists +and that `nbdkit vddk --dump-plugin libdir=` reports a library version. + +## VDDK Details + +VDDK options can be provided as API `details` at migration start: + +- `vddk.lib.dir` +- `vddk.transports` +- `vddk.thumbprint` + +If a value is not provided by the API, the agent uses its configured or detected +value. If `vddk.thumbprint` is not provided, the agent attempts to fetch the +vCenter SHA1 certificate thumbprint with `openssl`. + +The implementation does not hard-code one VDDK version. The installed VDDK +build must be compatible with the host libraries and pass 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 Verification + +Useful host capability check: + +```bash +cmk list hosts name= details=all | egrep \ +'host.vddk.blockcopy.support|host.vddk.blockcopy.inplace.finalization.support|host.vddk.blockcopy.rbd.support|host.vddk.support|host.vddk.version|host.virtv2v.version|host.virtv2v.inplace.version|host.qemu.img.version|host.qemu.nbd.version|host.qemu.io.version|vddk.lib.dir' +``` + +Useful VDDK plugin check on the KVM host: + +```bash +nbdkit vddk --dump-plugin libdir=/opt/vmware-vix-disklib-distrib | \ + egrep 'vddk_library_version|vddk_dll|vddk_transport_modes' +``` + +Useful process check during initial sync: + +```bash +pgrep -af 'nbdkit|qemu-img|qemu-nbd|nbdcopy' +``` + +Useful target disk inspection: + +```bash +qemu-img info /mnt//cloudstack-cbt//.qcow2 +``` + +## References + +- [Apache CloudStack: Importing VMware VMs into KVM](https://docs.cloudstack.apache.org/en/latest/adminguide/virtual_machines/importing_vmware_vms_into_kvm.html) +- [Broadcom vSphere `QueryChangedDiskAreas` API](https://developer.broadcom.com/xapis/virtual-infrastructure-json-api/latest/sdk/vim25/release/VirtualMachine/moId/QueryChangedDiskAreas/post/) +- [Broadcom Virtual Disk API disk operations](https://developer.broadcom.com/xapis/virtual-disk-api/latest/vddkFunctions.6.4.html) +- [Broadcom VDDK CBT notes and limitations](https://developer.broadcom.com/xapis/virtual-disk-api/latest/vddkBkupVadp.9.5.html) +- [nbdkit VDDK plugin](https://libguestfs.org/nbdkit-vddk-plugin.1.html) diff --git a/docs/vmware-cbt/architecture.md b/docs/vmware-cbt/architecture.md new file mode 100644 index 000000000000..299a0d769afe --- /dev/null +++ b/docs/vmware-cbt/architecture.md @@ -0,0 +1,266 @@ + + +# VMware CBT migration architecture + +This document describes the implemented Apache CloudStack architecture for +warm VMware-to-KVM migration based on VMware Changed Block Tracking (CBT). +Detailed API, database, configuration, and operational information is in +[`README.md`](README.md). KVM host preparation is documented in +[`../vmware-cbt-migration.md`](../vmware-cbt-migration.md). + +## Design invariant + +The KVM-side replica remains byte-compatible with the VMware source disk until +the final CBT cycle has completed. Raw VMware CBT ranges are never applied to a +disk after guest conversion has changed its layout. + +The migration therefore has two distinct phases: + +1. Replication: copy the initial source-equivalent baseline and apply CBT delta + ranges to it. +2. Finalization: after the source VM is powered off, apply one final delta and + run `virt-v2v` locally against the current KVM-side replica. + +This avoids a second full VMware read during cutover. CloudStack does not power +off the source VM; shutdown remains an explicit operator action. + +## Components + +### Management server + +The API commands are: + +- `checkVmwareCbtMigrationPrerequisites` +- `startVmwareCbtMigration` +- `listVmwareCbtMigrations` +- `syncVmwareCbtMigration` +- `cutoverVmwareCbtMigration` +- `cancelVmwareCbtMigration` +- `deleteVmwareCbtMigration` + +`VmwareCbtMigrationManagerImpl` owns migration orchestration, host and storage +validation, state transitions, cutover policy, agent command dispatch, import, +and cleanup. `VmwareCbtMigrationServiceImpl` performs VMware inventory, +snapshot, power-state, CBT enablement, and changed-area operations. + +`VmwareCbtStorageTarget` classifies the selected primary storage and determines +the target representation and finalization constraints. + +### Persistence + +The feature persists its state in three CloudStack tables: + +- `cloud.vmware_cbt_migration`: one row per migration session. +- `cloud.vmware_cbt_migration_disk`: source and target metadata for each disk, + including the current per-disk VMware `changeId`. +- `cloud.vmware_cbt_migration_cycle`: metrics and status for each delta cycle. + +The schema and configuration entries are delivered through the standard +4.23.0.0-to-24.0.0 database upgrade path: + +- `engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql` +- `engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42300to2400.java` + +### KVM agent + +The management server sends these commands to the selected conversion host: + +- `VmwareCbtPrepareCommand`: create or resolve target disks and perform the + initial VDDK full copy. +- `VmwareCbtSyncCommand`: read and apply VMware CBT ranges. +- `VmwareCbtCutoverCommand`: finalize the current replica with `virt-v2v`. +- `VmwareCbtCleanupCommand`: terminate migration-owned processes and remove + migration-owned temporary artifacts. +- `VmwareCbtRbdProbeCommand`: actively validate RBD create/write/read/delete + access for the selected pool. + +The corresponding KVM wrappers are +`LibvirtVmwareCbtPrepareCommandWrapper`, +`LibvirtVmwareCbtSyncCommandWrapper`, +`LibvirtVmwareCbtCutoverCommandWrapper`, +`LibvirtVmwareCbtCleanupCommandWrapper`, and +`LibvirtVmwareCbtRbdProbeCommandWrapper`. + +### User interface + +The workflow is integrated into Import/Export Instances. The UI starts async +operations through the API and uses `listVmwareCbtMigrations` as the detailed +source for migration, disk, and cycle state. + +## End-to-end flow + +```text +VMware source VM + | + | preflight and source discovery + v +baseline VMware snapshot + | + | nbdkit-vddk full logical disk read + v +source-equivalent replica on KVM primary storage + | + | repeated VMware snapshots + QueryChangedDiskAreas + | changed ranges copied through VDDK and patched in place + v +ReadyForCutover + | + | operator powers off the VMware VM + | final CBT delta + | local virt-v2v finalization + v +CloudStack KVM import +``` + +### Preflight + +Preflight resolves the source, destination cluster, conversion host, and +primary storage. It validates source CBT state, source disks, snapshots, +storage support, host block-copy and in-place-finalization capabilities, RBD or +LINSTOR access where applicable, Windows conversion dependencies, and optional +compute-offering sizing. + +The selected conversion host reports capability through host details. The +principal gates are: + +- `host.vddk.blockcopy.support` +- `host.vddk.blockcopy.inplace.finalization.support` +- `host.vddk.blockcopy.rbd.support` +- `vddk.lib.dir` +- `host.vddk.version` +- qemu and `virt-v2v` version details + +### Initial synchronization + +Start creates the migration and disk records, enables CBT when required, +creates a baseline VMware snapshot, prepares destination targets, and dispatches +`VmwareCbtPrepareCommand`. + +The KVM agent exposes each snapshot disk with `nbdkit-vddk`. It uses +`qemu-img convert` for the general copy path and can use `nbdcopy` as an +optional accelerator for supported pre-created or NBD-bridged targets. The +result remains source-equivalent; `virt-v2v` is not run during initial sync. + +After a successful copy, CloudStack records the baseline per-disk `changeId` +and removes the baseline snapshot when possible. + +### Delta synchronization + +Each synchronization cycle: + +1. Creates a VMware snapshot. +2. Calls VMware `QueryChangedDiskAreas` from the previous per-disk `changeId`. +3. Persists the changed ranges and cycle state. +4. Opens the snapshot disk through `nbdkit-vddk` on the KVM host. +5. Copies only the changed ranges and writes them at their original byte + offsets with `qemu-io`. +6. Records changed bytes, duration, dirty rate, and the new `changeId`. +7. Removes the cycle snapshot when possible. + +`VmwareCbtMigrationCutoverPolicy` evaluates the configured minimum, maximum, +and quiet-cycle thresholds and moves the migration to `ReadyForCutover` when +the policy boundary is reached. + +### Cutover + +Cutover is accepted only when the source VM is powered off. CloudStack runs one +final CBT cycle, then finalizes the current KVM-side replica with one of these +paths: + +- `virt-v2v-in-place`; +- `virt-v2v --in-place`; or +- regular `virt-v2v -o local` for QCOW2 file targets only, when the explicit + non-in-place fallback configuration is enabled. + +After finalization, CloudStack imports the disks through the shared external +KVM import path. A successful import sets the migration to `Completed`. If +finalization succeeds but import fails, the migration remains `ReadyForImport` +and a retry repeats only the import stage. + +## Storage targets + +| Primary storage | Replica | Initial copy | Delta writes | Finalization | +| --- | --- | --- | --- | --- | +| NFS, filesystem, shared mount | QCOW2 file | `qemu-img` | `qemu-io -f qcow2` | In-place preferred; explicitly gated local fallback supported | +| Ceph/RBD | Raw RBD image | Direct qemu RBD path or `nbdcopy` through a local NBD bridge | `qemu-io -f raw` | In-place only | +| LINSTOR | Pre-created raw DRBD block device | `nbdcopy` when available, otherwise `qemu-img` | `qemu-io -f raw` | In-place only | + +For filesystem targets, temporary replicas are stored under a migration-owned +`cloudstack-cbt//` directory. RBD and LINSTOR resources use +CloudStack-owned migration markers. Cleanup is restricted to these owned paths +and resource names. + +## State model + +Migration states: + +```text +Created + -> InitialSync + -> Replicating + -> ReadyForCutover + -> CuttingOver + -> ReadyForImport + -> Completed +``` + +`Failed` and `Cancelled` are terminal states. `ReadyForImport` is deliberately +retryable. + +Disk states are `Created`, `Prepared`, `Syncing`, `Ready`, and `Failed`. +Cycle states are `Created`, `QueryingChangedAreas`, `CopyingChangedBlocks`, +`Completed`, and `Failed`. + +## Concurrency and cleanup + +Sync and cutover commands are serialized by migration ID. Cancellation sends a +best-effort agent cleanup command before marking a non-terminal migration as +cancelled. Failed and cancelled migrations can delete their migration-owned +temporary replicas when cleanup is requested. + +Deleting a completed migration is record-only. It removes CBT bookkeeping but +does not delete the imported VM, its CloudStack volumes, or finalized primary +storage artifacts. + +VMware snapshots are removed after initial and delta attempts when possible. +Snapshot identifiers and cleanup errors remain visible in migration state so +operators can identify VMware-side cleanup work. + +## Credential handling + +Existing registered vCenter credentials are resolved from CloudStack's VMware +datacenter record. External-source credentials are accepted by sensitive API +commands and the password is stored through CloudStack's encrypted DAO field +handling until the migration no longer needs it. + +On the KVM host, the password is written to a temporary owner-only file and +passed to `nbdkit` with `password=+`. It is not placed directly in the +process command line. Returned errors are sanitized before persistence. + +## Operational boundaries + +- The operator is responsible for shutting down the source VM before cutover. +- Initial baseline transfer size depends on the extents exposed by VMware VDDK + and the source datastore; CBT optimization applies to subsequent cycles. +- Invalidated VMware CBT state requires a new baseline migration. +- RBD and LINSTOR require in-place finalization and active backend access from + the selected conversion host. +- Windows guests require a usable `virtio-win` driver bundle on that host. +- The source VMware VM remains authoritative until cutover succeeds. diff --git a/docs/vmware-linstor-migration.md b/docs/vmware-linstor-migration.md new file mode 100644 index 000000000000..bf3f8b69b20e --- /dev/null +++ b/docs/vmware-linstor-migration.md @@ -0,0 +1,238 @@ + + +# Linstor primary storage as a destination for VMware-to-KVM migration + +This document describes the Linstor-destination portion of the VMware-to-KVM +migration work, in enough detail for LINSTOR/DRBD maintainers to review the +design choices and advise on Linstor-side behavior. + +## Summary + +This change lets Linstor (DRBD) primary storage be the **destination** for +VMware-to-KVM instance imports, across all three migration modes CloudStack +offers for Ceph/RBD: + +- **Cold, staged** — convert with virt-v2v to a temporary NFS location, then + copy the finalized disks into Linstor. +- **Cold, direct (VDDK)** — read each disk over nbdkit/VDDK straight into the + DRBD device and finalize it in place, with no NFS staging. +- **Warm (CBT)** — replicate the running VM with an initial full sync plus + incremental changed-block cycles into the DRBD device, then finalize in + place at cutover. + +Before this change Linstor was blocked everywhere in the import/conversion +path by NFS-only (and, more recently, RBD-only) assumptions. The work +generalizes those code paths rather than special-casing Linstor: a new +`RAW_BLOCK_DEVICE` target type covers "write RAW into a host-local block +device provided by the storage adaptor", which also lays the groundwork for +other block backends (PowerFlex, FiberChannel, StorPool) later. + +## Background and motivation + +CloudStack can import VMware VMs onto KVM by converting them with virt-v2v +(optionally reading disks over the VMware VDDK). The converted disks have to +land on KVM primary storage. That path historically supported only +filesystem-style pools (NFS/Filesystem/SharedMountPoint); the companion +commits in this series add Ceph/RBD as a destination and a full VMware +**CBT warm-migration** framework. Linstor — a very common CloudStack block +backend — was still unsupported as a conversion destination. + +Linstor differs from RBD in ways that shaped the design: + +- A Linstor volume is a **local block device** (`/dev/drbd/by-res//0`), + which is the universal abstraction the whole toolchain (qemu-img, qemu-io, + virt-v2v-in-place) already consumes — so finalization is actually *simpler* + than RBD (no qemu-nbd credential bridge needed; a plain + `` works). +- Unlike RBD, `qemu-img convert` **cannot create** the device — the resource + must be pre-created at the source capacity through the storage adaptor, + then written with `qemu-img convert -n` (no-create). +- LINSTOR resource names are capped at ~48 characters and cannot be renamed, + so the RBD-style naming carrying a full migration UUID does not fit; short, + deterministic names are used and imported volumes keep the name as their + recorded volume path. + +## What this change does + +### New generic block-device target type +`VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE`, alongside the existing +`QCOW2_FILE` and `RBD_RAW`. Linstor pools classify to it and require in-place +finalization (the qcow2 fallback cannot write to a device). + +### Server-side (orchestration) +- Linstor added to the staged-conversion destination pool types, the direct + VDDK-convert allow-list, and the CBT-compatible pool types, for both + explicit and implicit destination-pool selection. +- Conversion/import host selection validates that the host is a LINSTOR + satellite connected to the pool (in addition to the VDDK / in-place + virt-v2v capability checks), for auto-selection and explicit host IDs. +- `CheckConvertInstanceCommand` carries a new in-place-finalization capability + check so unsupported hosts fail fast with a clear message. +- The powered-off, non-cloned source requirement (already enforced for direct + RBD) now applies to any direct block-storage import. + +### KVM agent-side (data plane) +- **Staged**: the converted-disk move already dispatches per pool type to + `LinstorStorageAdaptor.copyPhysicalDisk` (qemu-img into the DRBD device); + converted-disk metadata for Linstor reports the pool UUID and volume name + instead of parsing an NFS mount. +- **Direct VDDK**: pre-creates each resource at source capacity via the + adaptor, then `nbdkit vddk --run 'qemu-img convert -n ... '`, and + finalizes with `virt-v2v-in-place` fed a `` domain XML. +- **Warm CBT**: initial sync pre-creates the device and `qemu-img convert -n` + into it; delta cycles patch changed extents; cutover finalizes in place + with block-device XML (no qemu-nbd bridges). Cleanup deletes only + marker-guarded volumes. +- `LinstorStorageAdaptor.listPhysicalDisks` is implemented (resource + definitions filtered by the pool's resource group), replacing the previous + `UnsupportedOperationException`, so the forced-conversion import short + circuit works for Linstor. + +### Performance: nbdcopy for full-disk copies to block devices +The cold direct-VDDK import and the CBT initial full sync copy an entire disk +from the nbdkit/VDDK source into the target. For a local raw block-device +target (Linstor/DRBD), this now uses `nbdcopy` (libnbd) when the host has it, +falling back to `qemu-img convert`. nbdcopy keeps many requests in flight +(it is what virt-v2v itself uses for this step) and is typically faster than a +single-connection `qemu-img convert`. Availability is probed locally on the +agent; it is a pure optimization with automatic fallback, so no server-side +gating is needed. Only block-device targets are affected — the RBD URI target +and qcow2 file targets keep qemu-img convert (nbdcopy is raw-only and cannot +address an rbd: URI without a bridge). On a 16 GiB disk over network NBD +transport it measured ~20% faster than qemu-img convert in the lab, and the +result was byte-identical to a full VDDK read of the source. + +### Performance: direct CBT delta copy for block devices +Previously a CBT delta copied each changed extent in two hops — qemu-img the +nbd source window into a temporary raw file, then qemu-io write that file into +the target. For a raw block-device target that doubles local I/O and needs +scratch space. This change streams each changed extent from the nbd source +window straight into the same device window in a single +`qemu-img convert -n -S 0 --image-opts --target-image-opts`. `-S 0` disables +zero/sparse skipping so blocks the source cleared to zero are actually +overwritten in the target — a full copy can skip zeros because the target +starts zeroed, but a delta into an already-populated device cannot. The +qcow2-file and RBD paths are unchanged. + +## Migration support matrix (Linstor destination) + +| Mode | Trigger | Source VM state | Data movement | Guest downtime | +| --- | --- | --- | --- | --- | +| Cold, staged | `importVm` (no `forceconverttopool`) | Running (auto-cloned on vSphere) or stopped | virt-v2v -> qcow2 on NFS temp -> qemu-img copy to DRBD device | Offline copy (running source undisturbed via clone) | +| Cold, direct VDDK | `importVm usevddk=true forceconverttopool=true` -> Linstor pool | Stopped only (non-cloned) | nbdkit/VDDK -> qemu-img convert -n into DRBD device -> virt-v2v-in-place | Whole migration (VM is off) | +| Warm, CBT | `startVmwareCbtMigration` -> sync cycles -> `cutoverVmwareCbtMigration` | Running | Initial full sync + incremental CBT deltas into DRBD device; final delta + in-place finalize at cutover | Minimal (only the final cutover delta) | + +Notes: +- Warm CBT does **not** power off the source automatically. The operator + gracefully shuts down the VMware VM, then triggers cutover; the code + rejects cutover on a running VM. This is deliberate — never auto-kill a + production VM. +- Direct VDDK requires a powered-off, non-cloned source. +- Imported Linstor volumes are recorded QCOW2 in the DB (Linstor convention; + RAW on the device). + +## How to use + +Cold, direct VDDK into Linstor (source powered off): + + import vm zoneid= clusterid= \ + importsource=vmware hypervisor=KVM \ + vcenter= datacentername= username= password=

\ + clustername= host= \ + name= displayname= serviceofferingid= \ + usevddk=true forceconverttopool=true \ + convertinstancepoolid= \ + nicnetworklist[0].nic="Network adapter 1" nicnetworklist[0].network= \ + datadiskofferinglist[0].disk= datadiskofferinglist[0].diskOffering= + +Warm CBT into Linstor: `startVmwareCbtMigration` with the Linstor pool as +`storagepoolid`, let the delta cycles run, power off the source, then +`cutoverVmwareCbtMigration`. + +(The web import wizard fills the NIC/disk mappings from the discovered VM, so +the UI is the easiest way to drive it.) + +## Host prerequisites + +For any Linstor destination the conversion host must be a LINSTOR satellite +connected to the pool (its `hostname` must match its LINSTOR node name). +Direct-VDDK and warm-CBT additionally need VDDK, an nbdkit VDDK plugin, and +in-place virt-v2v (`virt-v2v-in-place` or `virt-v2v --in-place`). No extra +qemu block-driver is needed (writes go to a local device). Prefer a diskful +satellite as the conversion host so writes are local rather than traversing +the DRBD replication network. + +## Backwards compatibility + +- No behavior change for existing NFS or RBD destinations; Linstor is added + to the same gate lists and dispatch branches. +- The CBT-delta optimization only affects `RAW_BLOCK_DEVICE` targets; qcow2 + and RBD keep their existing code paths. +- One host-capability check flag was added to `CheckConvertInstanceCommand` + (old constructor preserved, null-tolerant). + +## Testing + +- **Unit tests**: full server + KVM-plugin suites pass, including new + Linstor-specific tests across the gate lists, host selection, the + convert/import/CBT wrappers, `listPhysicalDisks`, and the block-device + delta script. +- **Data plane, real 3-node LINSTOR/DRBD lab, real VMware source**: + - Cold direct VDDK: a real VM's disks read over VDDK into DRBD devices + + in-place finalized; `qemu-img compare` against the source = identical. + - Warm CBT: baseline + delta + power-off + final delta + finalize; + integrity verified (file checksum inside the migrated volume matched the + source, and `qemu-img compare` of a delta-applied device against a full + read of the snapshot = identical). + - CBT-delta optimization: 144 real changed extents applied via the new + direct-copy script; `qemu-img compare` = images identical. +- **Full end-to-end through CloudStack**: `importVm` (usevddk + + forceconverttopool) against a real vCenter produced a Stopped CloudStack VM + with its root and data disks as Linstor volumes on the pool, each backed by + a DRBD resource UpToDate on all three nodes. + +## Known limitations / possible follow-ups + +- Full-disk copies into block devices use nbdcopy when present (see above). + RBD URI and qcow2 file targets still use qemu-img convert; extending + nbdcopy to RBD would need a qemu-nbd bridge (nbdcopy is raw-device/file + only), which is a possible follow-up. +- The conversion host is usually not ESXi-adjacent, so VDDK typically uses + network (NBD/NBDSSL) transport rather than the faster HotAdd; largely + inherent to the KVM conversion-host model. +- A diskful conversion host is recommended but not enforced; a diskless + satellite makes every write traverse the replication network. +- The staged path keeps its extra NFS hop (needed for running-VM clones and + hosts without in-place support). + +## Relationship to the RBD series + +The Linstor support is layered on the VMware-to-KVM RBD series in this same +change set (direct VDDK-to-RBD imports, the CBT warm-migration framework, +importVm RBD adoption, and the consolidated host-capability probe names). +The Linstor-specific commits, in order: + +- KVM: allow staged VMware instance imports into Linstor primary storage +- KVM: support direct VDDK VMware imports into Linstor primary storage +- VMware CBT warm migration to KVM with Linstor destination storage +- Document Linstor destination support for VMware-to-KVM imports +- Drop unsupported -O option from virt-v2v-in-place invocations +- KVM: stream VMware CBT block-device deltas directly, without a temp file +- KVM: use nbdcopy for full-disk copies into Linstor block devices when available diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index d3225f2fda5e..9eab15214aeb 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 @@ -817,8 +817,21 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi String vddkSupport = detailsMap.get(Host.HOST_VDDK_SUPPORT); String vddkLibDir = detailsMap.get(Host.HOST_VDDK_LIB_DIR); String vddkVersion = detailsMap.get(Host.HOST_VDDK_VERSION); + String vmwareCbtSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_SUPPORT); + String vmwareCbtInPlaceFinalizationSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT); + String vmwareCbtRbdSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT); + String qemuImgVersion = detailsMap.get(Host.HOST_QEMU_IMG_VERSION); + String qemuNbdVersion = detailsMap.get(Host.HOST_QEMU_NBD_VERSION); + String qemuIoVersion = detailsMap.get(Host.HOST_QEMU_IO_VERSION); + String virtV2vInPlaceVersion = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_VERSION); + String virtv2vInPlaceSupport = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_SUPPORT); + String qemuImgRbdSupport = detailsMap.get(Host.HOST_QEMU_RBD_SUPPORT); + String vddkRbdDirectImportSupport = detailsMap.get(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT); logger.debug("Got HOST_UEFI_ENABLE [{}] for host [{}]:", uefiEnabled, host); - if (ObjectUtils.anyNotNull(uefiEnabled, diskOnlyVmSnapshotNvramSupport, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion)) { + if (ObjectUtils.anyNotNull(uefiEnabled, diskOnlyVmSnapshotNvramSupport, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion, + vmwareCbtSupport, vmwareCbtInPlaceFinalizationSupport, vmwareCbtRbdSupport, qemuImgVersion, qemuNbdVersion, qemuIoVersion, virtV2vInPlaceVersion, + virtv2vInPlaceSupport, qemuImgRbdSupport, vddkRbdDirectImportSupport)) { + _hostDao.loadDetails(host); boolean updateNeeded = false; if (syncBooleanHostCapability(host, Host.HOST_UEFI_ENABLE, uefiEnabled)) { updateNeeded = true; @@ -854,6 +867,64 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi } updateNeeded = true; } + if (StringUtils.isNotBlank(vmwareCbtSupport) && !vmwareCbtSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_SUPPORT, vmwareCbtSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtInPlaceFinalizationSupport) && + !vmwareCbtInPlaceFinalizationSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, vmwareCbtInPlaceFinalizationSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtRbdSupport) && + !vmwareCbtRbdSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, vmwareCbtRbdSupport); + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuImgVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_IMG_VERSION)))) { + if (StringUtils.isBlank(qemuImgVersion)) { + host.getDetails().remove(Host.HOST_QEMU_IMG_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_IMG_VERSION, qemuImgVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuNbdVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_NBD_VERSION)))) { + if (StringUtils.isBlank(qemuNbdVersion)) { + host.getDetails().remove(Host.HOST_QEMU_NBD_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_NBD_VERSION, qemuNbdVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuIoVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_IO_VERSION)))) { + if (StringUtils.isBlank(qemuIoVersion)) { + host.getDetails().remove(Host.HOST_QEMU_IO_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_IO_VERSION, qemuIoVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(virtV2vInPlaceVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_VIRTV2V_INPLACE_VERSION)))) { + if (StringUtils.isBlank(virtV2vInPlaceVersion)) { + host.getDetails().remove(Host.HOST_VIRTV2V_INPLACE_VERSION); + } else { + host.getDetails().put(Host.HOST_VIRTV2V_INPLACE_VERSION, virtV2vInPlaceVersion); + } + updateNeeded = true; + } + if (StringUtils.isNotBlank(virtv2vInPlaceSupport) && !virtv2vInPlaceSupport.equals(host.getDetails().get(Host.HOST_VIRTV2V_INPLACE_SUPPORT))) { + host.getDetails().put(Host.HOST_VIRTV2V_INPLACE_SUPPORT, virtv2vInPlaceSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(qemuImgRbdSupport) && !qemuImgRbdSupport.equals(host.getDetails().get(Host.HOST_QEMU_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_QEMU_RBD_SUPPORT, qemuImgRbdSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vddkRbdDirectImportSupport) && !vddkRbdDirectImportSupport.equals(host.getDetails().get(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, vddkRbdDirectImportSupport); + updateNeeded = true; + } if (updateNeeded) { _hostDao.saveDetails(host); } diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index 2fad96ec1da2..c4fadc552857 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -1335,6 +1335,20 @@ private ImageFormat getSupportedImageFormatForCluster(HypervisorType hyperType) } } + /** + * Returns the image format for a volume taking the underlying storage pool type into account. + * On KVM the default cluster format is QCOW2, but block-based pools such as RBD (Ceph) and + * Linstor (DRBD) store volumes as RAW images. This is relevant when importing/adopting an + * existing volume so the recorded format matches what is actually on the pool. + */ + protected ImageFormat getSupportedImageFormatForCluster(HypervisorType hyperType, Storage.StoragePoolType poolType) { + if (hyperType == HypervisorType.KVM + && (poolType == Storage.StoragePoolType.RBD || poolType == Storage.StoragePoolType.Linstor)) { + return ImageFormat.RAW; + } + return getSupportedImageFormatForCluster(hyperType); + } + private boolean isSupportedImageFormatForCluster(VolumeInfo volume, HypervisorType rootDiskHyperType) { ImageFormat volumeFormat = volume.getFormat(); if (rootDiskHyperType == HypervisorType.Hyperv) { @@ -2665,7 +2679,7 @@ public DiskProfile importVolume(Type type, String name, DiskOffering offering, L vol.setDisplayVolume(userVm.isDisplayVm()); } - vol.setFormat(getSupportedImageFormatForCluster(hypervisorType)); + vol.setFormat(getSupportedImageFormatForCluster(hypervisorType, poolType)); vol.setPoolId(poolId); vol.setPoolType(poolType); vol.setPath(path); @@ -2709,7 +2723,7 @@ public DiskProfile updateImportedVolume(Type type, DiskOffering offering, Virtua vol.setDisplayVolume(userVm.isDisplayVm()); } - vol.setFormat(getSupportedImageFormatForCluster(vm.getHypervisorType())); + vol.setFormat(getSupportedImageFormatForCluster(vm.getHypervisorType(), poolType)); vol.setPoolId(poolId); vol.setPoolType(poolType); vol.setPath(path); diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java index d61caf16ae5d..41f71b1b4773 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java @@ -178,6 +178,31 @@ public void testCheckAndUpdateVolumeAccountResourceCountLessSize() { runCheckAndUpdateVolumeAccountResourceCountTest(20L, 10L); } + @Test + public void testGetSupportedImageFormatForClusterKvmRbdIsRaw() { + Assert.assertEquals(Storage.ImageFormat.RAW, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.RBD)); + // Linstor (DRBD) volumes are also raw block devices. + Assert.assertEquals(Storage.ImageFormat.RAW, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.Linstor)); + } + + @Test + public void testGetSupportedImageFormatForClusterKvmNonRbdIsQcow2() { + Assert.assertEquals(Storage.ImageFormat.QCOW2, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.NetworkFilesystem)); + Assert.assertEquals(Storage.ImageFormat.QCOW2, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.Filesystem)); + } + + @Test + public void testGetSupportedImageFormatForClusterNonKvmIgnoresPoolType() { + Assert.assertEquals(Storage.ImageFormat.VHD, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.XenServer, Storage.StoragePoolType.RBD)); + Assert.assertEquals(Storage.ImageFormat.OVA, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.VMware, Storage.StoragePoolType.RBD)); + } + @Test public void testGrantVolumeAccessToHostIfNeededDriverNoNeed() { PrimaryDataStore store = Mockito.mock(PrimaryDataStore.class); diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java new file mode 100644 index 000000000000..78e492a434d9 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigrationCycle; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "vmware_cbt_migration_cycle") +public class VmwareCbtMigrationCycleVO implements VmwareCbtMigrationCycle { + + public VmwareCbtMigrationCycleVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationCycleVO(long migrationId, int cycleNumber) { + this(); + this.migrationId = migrationId; + this.cycleNumber = cycleNumber; + this.state = State.Created; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "migration_id") + private long migrationId; + + @Column(name = "cycle_number") + private int cycleNumber; + + @Column(name = "snapshot_moref") + private String snapshotMor; + + @Column(name = "changed_bytes") + private Long changedBytes; + + @Column(name = "dirty_rate") + private Long dirtyRate; + + @Column(name = "duration") + private Long duration; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "description") + private String description; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getMigrationId() { + return migrationId; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public Long getChangedBytes() { + return changedBytes; + } + + public void setChangedBytes(Long changedBytes) { + this.changedBytes = changedBytes; + } + + public Long getDirtyRate() { + return dirtyRate; + } + + public void setDirtyRate(Long dirtyRate) { + this.dirtyRate = dirtyRate; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java new file mode 100644 index 000000000000..62069cfbcd97 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigrationDisk; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "vmware_cbt_migration_disk") +public class VmwareCbtMigrationDiskVO implements VmwareCbtMigrationDisk { + + public VmwareCbtMigrationDiskVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationDiskVO(long migrationId, String sourceDiskId, Integer sourceDiskDeviceKey, + String sourceDiskPath, String datastoreName, Long capacityBytes) { + this(); + this.migrationId = migrationId; + this.sourceDiskId = sourceDiskId; + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.capacityBytes = capacityBytes; + this.state = State.Created; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "migration_id") + private long migrationId; + + @Column(name = "source_disk_id") + private String sourceDiskId; + + @Column(name = "source_disk_device_key") + private Integer sourceDiskDeviceKey; + + @Column(name = "source_disk_path") + private String sourceDiskPath; + + @Column(name = "datastore_name") + private String datastoreName; + + @Column(name = "capacity_bytes") + private Long capacityBytes; + + @Column(name = "target_path") + private String targetPath; + + @Column(name = "target_format") + private String targetFormat; + + @Column(name = "change_id") + private String changeId; + + @Column(name = "snapshot_moref") + private String snapshotMor; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getMigrationId() { + return migrationId; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public Long getCapacityBytes() { + return capacityBytes; + } + + public String getTargetPath() { + return targetPath; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public void setChangeId(String changeId) { + this.changeId = changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java new file mode 100644 index 000000000000..24ad5b1dd552 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java @@ -0,0 +1,465 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +import com.cloud.utils.db.Encrypt; + +@Entity +@Table(name = "vmware_cbt_migration") +public class VmwareCbtMigrationVO implements VmwareCbtMigration { + + public VmwareCbtMigrationVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationVO(long zoneId, long accountId, long userId, long destinationClusterId, + String displayName, String vcenter, String datacenter, String sourceHost, + String sourceCluster, String sourceVmName) { + this(); + this.zoneId = zoneId; + this.accountId = accountId; + this.userId = userId; + this.destinationClusterId = destinationClusterId; + this.displayName = displayName; + this.vcenter = vcenter; + this.datacenter = datacenter; + this.sourceHost = sourceHost; + this.sourceCluster = sourceCluster; + this.sourceVmName = sourceVmName; + this.state = State.Created; + this.currentStep = "Created"; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "user_id") + private long userId; + + @Column(name = "vm_id") + private Long vmId; + + @Column(name = "existing_vcenter_id") + private Long existingVcenterId; + + @Column(name = "source_username") + private String sourceUsername; + + @Encrypt + @Column(name = "source_password") + private String sourcePassword; + + @Column(name = "destination_cluster_id") + private long destinationClusterId; + + @Column(name = "convert_host_id") + private Long convertHostId; + + @Column(name = "storage_pool_id") + private Long storagePoolId; + + @Column(name = "display_name") + private String displayName; + + @Column(name = "host_name") + private String hostName; + + @Column(name = "template_id") + private Long templateId; + + @Column(name = "service_offering_id") + private Long serviceOfferingId; + + @Column(name = "guest_os_id") + private Long guestOsId; + + @Column(name = "data_disk_offering_map") + private String dataDiskOfferingMap; + + @Column(name = "nic_network_map") + private String nicNetworkMap; + + @Column(name = "nic_ip_address_map") + private String nicIpAddressMap; + + @Column(name = "import_details") + private String importDetails; + + @Column(name = "forced") + private boolean forced; + + @Column(name = "vcenter") + private String vcenter; + + @Column(name = "datacenter") + private String datacenter; + + @Column(name = "source_host") + private String sourceHost; + + @Column(name = "source_cluster") + private String sourceCluster; + + @Column(name = "source_vm_name") + private String sourceVmName; + + @Column(name = "vddk_lib_dir") + private String vddkLibDir; + + @Column(name = "vddk_transports") + private String vddkTransports; + + @Column(name = "vddk_thumbprint") + private String vddkThumbprint; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "current_step") + private String currentStep; + + @Column(name = "last_error") + private String lastError; + + @Column(name = "completed_cycles") + private int completedCycles; + + @Column(name = "quiet_cycles") + private int quietCycles; + + @Column(name = "total_changed_bytes") + private long totalChangedBytes; + + @Column(name = "last_changed_bytes") + private Long lastChangedBytes; + + @Column(name = "last_dirty_rate") + private Long lastDirtyRate; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getZoneId() { + return zoneId; + } + + public long getAccountId() { + return accountId; + } + + public long getUserId() { + return userId; + } + + public Long getVmId() { + return vmId; + } + + public void setVmId(Long vmId) { + this.vmId = vmId; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public void setExistingVcenterId(Long existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + + public String getSourceUsername() { + return sourceUsername; + } + + public void setSourceUsername(String sourceUsername) { + this.sourceUsername = sourceUsername; + } + + public String getSourcePassword() { + return sourcePassword; + } + + public void setSourcePassword(String sourcePassword) { + this.sourcePassword = sourcePassword; + } + + public long getDestinationClusterId() { + return destinationClusterId; + } + + public Long getConvertHostId() { + return convertHostId; + } + + public void setConvertHostId(Long convertHostId) { + this.convertHostId = convertHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + public void setStoragePoolId(Long storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public String getDisplayName() { + return displayName; + } + + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + public void setServiceOfferingId(Long serviceOfferingId) { + this.serviceOfferingId = serviceOfferingId; + } + + public Long getGuestOsId() { + return guestOsId; + } + + public void setGuestOsId(Long guestOsId) { + this.guestOsId = guestOsId; + } + + public String getDataDiskOfferingMap() { + return dataDiskOfferingMap; + } + + public void setDataDiskOfferingMap(String dataDiskOfferingMap) { + this.dataDiskOfferingMap = dataDiskOfferingMap; + } + + public String getNicNetworkMap() { + return nicNetworkMap; + } + + public void setNicNetworkMap(String nicNetworkMap) { + this.nicNetworkMap = nicNetworkMap; + } + + public String getNicIpAddressMap() { + return nicIpAddressMap; + } + + public void setNicIpAddressMap(String nicIpAddressMap) { + this.nicIpAddressMap = nicIpAddressMap; + } + + public String getImportDetails() { + return importDetails; + } + + public void setImportDetails(String importDetails) { + this.importDetails = importDetails; + } + + public boolean isForced() { + return forced; + } + + public void setForced(boolean forced) { + this.forced = forced; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenter() { + return datacenter; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public String getCurrentStep() { + return currentStep; + } + + public void setCurrentStep(String currentStep) { + this.currentStep = currentStep; + } + + public String getLastError() { + return lastError; + } + + public void setLastError(String lastError) { + this.lastError = lastError; + } + + public int getCompletedCycles() { + return completedCycles; + } + + public void setCompletedCycles(int completedCycles) { + this.completedCycles = completedCycles; + } + + public int getQuietCycles() { + return quietCycles; + } + + public void setQuietCycles(int quietCycles) { + this.quietCycles = quietCycles; + } + + public long getTotalChangedBytes() { + return totalChangedBytes; + } + + public void setTotalChangedBytes(long totalChangedBytes) { + this.totalChangedBytes = totalChangedBytes; + } + + public Long getLastChangedBytes() { + return lastChangedBytes; + } + + public void setLastChangedBytes(Long lastChangedBytes) { + this.lastChangedBytes = lastChangedBytes; + } + + public Long getLastDirtyRate() { + return lastDirtyRate; + } + + public void setLastDirtyRate(Long lastDirtyRate) { + this.lastDirtyRate = lastDirtyRate; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public Date getRemoved() { + return removed; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java new file mode 100644 index 000000000000..25debad70156 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationCycleVO; + +import java.util.List; + +public interface VmwareCbtMigrationCycleDao extends GenericDao { + List listByMigrationId(long migrationId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java new file mode 100644 index 000000000000..0d823a29287b --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationCycleVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VmwareCbtMigrationCycleDaoImpl extends GenericDaoBase implements VmwareCbtMigrationCycleDao { + + private final SearchBuilder migrationSearch; + + public VmwareCbtMigrationCycleDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("migrationId", migrationSearch.entity().getMigrationId(), SearchCriteria.Op.EQ); + migrationSearch.done(); + } + + @Override + public List listByMigrationId(long migrationId) { + SearchCriteria sc = migrationSearch.create(); + sc.setParameters("migrationId", migrationId); + Filter filter = new Filter(VmwareCbtMigrationCycleVO.class, "cycleNumber", true, null, null); + return listBy(sc, filter); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java new file mode 100644 index 000000000000..09ee0e6367c3 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java @@ -0,0 +1,62 @@ +// 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.Date; +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); + + /** + * Persists the given migration only while the stored record has not reached a terminal + * state (Completed, Failed or Cancelled), as a single conditional statement. + * + * Long-running replication and cutover jobs use this instead of a plain update so a + * migration that an operator cancelled mid-flight is not resurrected by a job that started + * before the cancellation: cancelling removes the target disks, so a later blind write of a + * non-terminal state would leave a record claiming to replicate onto disks that no longer + * exist, and which can no longer be deleted because it is not terminal. + * + * @return true if the record was still non-terminal and was updated, false if it had + * already reached a terminal state and was therefore left untouched. + */ + boolean updateIfNotTerminal(VmwareCbtMigrationVO migration); + + /** + * Atomically records a successful CloudStack VM import while the migration is still + * non-terminal. The VM reference, terminal state and credential removal must be one + * conditional update so a concurrent cancellation cannot be overwritten. + */ + boolean completeImportIfNotTerminal(long migrationId, long vmId, Date updated); + + /** + * Records the VM created by an import that raced with a terminal state without changing that + * terminal state. This keeps the imported VM discoverable for operator reconciliation and + * removes stored source credentials. + */ + boolean recordImportedVmAndClearCredentials(long migrationId, long vmId, Date updated); +} 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..3bcb0a087b39 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java @@ -0,0 +1,129 @@ +// 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.Date; +import java.util.List; + +@Component +public class VmwareCbtMigrationDaoImpl extends GenericDaoBase implements VmwareCbtMigrationDao { + + private static final VmwareCbtMigration.State[] TERMINAL_STATES = { + VmwareCbtMigration.State.Completed, + VmwareCbtMigration.State.Failed, + VmwareCbtMigration.State.Cancelled + }; + + private final SearchBuilder migrationSearch; + private final SearchBuilder convertHostSearch; + private final SearchBuilder nonTerminalSearch; + + 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(); + + nonTerminalSearch = createSearchBuilder(); + nonTerminalSearch.and("id", nonTerminalSearch.entity().getId(), SearchCriteria.Op.EQ); + nonTerminalSearch.and("state", nonTerminalSearch.entity().getState(), SearchCriteria.Op.NOTIN); + nonTerminalSearch.done(); + } + + @Override + public boolean updateIfNotTerminal(VmwareCbtMigrationVO migration) { + SearchCriteria sc = nonTerminalSearch.create(); + sc.setParameters("id", migration.getId()); + sc.setParameters("state", (Object[]) TERMINAL_STATES); + return update(migration, sc) > 0; + } + + @Override + public boolean completeImportIfNotTerminal(long migrationId, long vmId, Date updated) { + VmwareCbtMigrationVO migration = createImportedVmUpdate(migrationId, vmId, updated); + migration.setState(VmwareCbtMigration.State.Completed); + migration.setCurrentStep("CloudStack VM import completed"); + migration.setLastError(null); + return updateIfNotTerminal(migration); + } + + @Override + public boolean recordImportedVmAndClearCredentials(long migrationId, long vmId, Date updated) { + return update(migrationId, createImportedVmUpdate(migrationId, vmId, updated)); + } + + private VmwareCbtMigrationVO createImportedVmUpdate(long migrationId, long vmId, Date updated) { + VmwareCbtMigrationVO migration = createForUpdate(migrationId); + migration.setVmId(vmId); + migration.setSourceUsername(null); + migration.setSourcePassword(null); + migration.setUpdated(updated); + return migration; + } + + @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-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql index 7c11013a17d2..3861beb225be 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql @@ -18,3 +18,130 @@ --; -- Schema upgrade from 4.23.0.0 to 24.0.0 --; + +-- 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 9009ec629ca3..7c3f826be171 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 @@ -20,11 +20,21 @@ import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION; import static com.cloud.host.Host.HOST_KVM_DISK_ONLY_VM_SNAPSHOT_NVRAM; import static com.cloud.host.Host.HOST_OVFTOOL_VERSION; +import static com.cloud.host.Host.HOST_QEMU_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_QEMU_IMG_VERSION; +import static com.cloud.host.Host.HOST_QEMU_IO_VERSION; +import static com.cloud.host.Host.HOST_QEMU_NBD_VERSION; import static com.cloud.host.Host.HOST_RBD_VOLUME_ENCRYPTION; import static com.cloud.host.Host.HOST_VDDK_LIB_DIR; +import static com.cloud.host.Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_VERSION; +import static com.cloud.host.Host.HOST_VIRTV2V_INPLACE_SUPPORT; +import static com.cloud.host.Host.HOST_VIRTV2V_INPLACE_VERSION; import static com.cloud.host.Host.HOST_VIRTV2V_VERSION; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_SUPPORT; import static com.cloud.host.Host.HOST_VOLUME_ENCRYPTION; import static org.apache.cloudstack.utils.linux.KVMHostInfo.isHostS390x; @@ -54,6 +64,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; @@ -373,6 +384,10 @@ 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"; + // EL9-family distributions install virt-v2v-in-place in libexecdir, outside $PATH + public static final String VIRT_V2V_IN_PLACE_LIBEXEC_PATH = "/usr/libexec/virt-v2v-in-place"; + 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}'"; @@ -381,6 +396,12 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final String UBUNTU_WINDOWS_GUEST_CONVERSION_SUPPORTED_CHECK_CMD = "dpkg -l virtio-win"; public static final String UBUNTU_NBDKIT_PKG_CHECK_CMD = "dpkg -l nbdkit"; public static final String VDDK_AUTODETECT_PATH_CMD = "find / -type d -name 'vmware-vix-disklib-distrib' 2>/dev/null | head -n 1"; + public static final String NBDKIT_VDDK_DUMP_PLUGIN_CMD = "nbdkit vddk --dump-plugin"; + public static final String QEMU_IMG_SUPPORTED_CHECK_CMD = "qemu-img --version"; + public static final String QEMU_NBD_SUPPORTED_CHECK_CMD = "qemu-nbd --version"; + public static final String QEMU_IO_SUPPORTED_CHECK_CMD = "qemu-io --version"; + public static final String QEMU_IMG_RBD_SUPPORTED_CHECK_CMD = "qemu-img --help 2>&1 | grep -Eq '(^|[[:space:]])rbd([[:space:]]|$)'"; + public static final String NBDCOPY_SUPPORTED_CHECK_CMD = "nbdcopy --version"; public static final int LIBVIRT_CGROUP_CPU_SHARES_MIN = 2; public static final int LIBVIRT_CGROUP_CPU_SHARES_MAX = 262144; @@ -961,6 +982,7 @@ public String toString() { protected String directDownloadTemporaryDownloadPath; protected String cachePath; private String vddkTransports = null; + private String vddkNbdCompression = null; private String vddkThumbprint = null; private String vddkVersion = null; private String detectedPasswordFileOption = null; @@ -1040,6 +1062,10 @@ public String getVddkTransports() { return vddkTransports; } + public String getVddkNbdCompression() { + return vddkNbdCompression; + } + public String getVddkThumbprint() { return vddkThumbprint; } @@ -1271,13 +1297,15 @@ 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( AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VDDK_TRANSPORTS)); + vddkNbdCompression = StringUtils.trimToNull( + AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VDDK_NBD_COMPRESSION)); vddkThumbprint = StringUtils.trimToNull( AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VDDK_THUMBPRINT)); @@ -4437,15 +4465,37 @@ public StartupCommand[] initialize() { cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION, String.valueOf(instanceConversionSupported)); cmd.getHostDetails().put(HOST_VDDK_SUPPORT, String.valueOf(hostSupportsVddk())); cmd.getHostDetails().put(HOST_CDROM_MAX_COUNT, String.valueOf(LibvirtVMDef.MAX_CDROMS_PER_VM)); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_SUPPORT, String.valueOf(hostSupportsVddkBlockCopy())); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, String.valueOf(hostSupportsVddkBlockCopyInPlaceFinalization())); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, String.valueOf(hostSupportsVddkBlockCopyRbd())); + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_SUPPORT, String.valueOf(hostSupportsVirtV2vInPlace())); + cmd.getHostDetails().put(HOST_QEMU_RBD_SUPPORT, String.valueOf(hostSupportsQemuRbd())); + cmd.getHostDetails().put(HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, String.valueOf(hostSupportsVddkRbdDirectImport())); if (StringUtils.isNotBlank(vddkLibDir)) { cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir); } if (StringUtils.isNotBlank(vddkVersion)) { cmd.getHostDetails().put(HOST_VDDK_VERSION, vddkVersion); } + String qemuImgVersion = getQemuImgVersion(); + if (StringUtils.isNotBlank(qemuImgVersion)) { + cmd.getHostDetails().put(HOST_QEMU_IMG_VERSION, qemuImgVersion); + } + String qemuNbdVersion = getQemuNbdVersion(); + if (StringUtils.isNotBlank(qemuNbdVersion)) { + cmd.getHostDetails().put(HOST_QEMU_NBD_VERSION, qemuNbdVersion); + } + String qemuIoVersion = getQemuIoVersion(); + if (StringUtils.isNotBlank(qemuIoVersion)) { + cmd.getHostDetails().put(HOST_QEMU_IO_VERSION, qemuIoVersion); + } if (instanceConversionSupported) { cmd.getHostDetails().put(HOST_VIRTV2V_VERSION, getHostVirtV2vVersion()); } + String virtV2vInPlaceVersion = getHostVirtV2vInPlaceVersion(); + if (StringUtils.isNotBlank(virtV2vInPlaceVersion)) { + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_VERSION, virtV2vInPlaceVersion); + } if (hostSupportsOvfExport()) { cmd.getHostDetails().put(HOST_OVFTOOL_VERSION, getHostOvfToolVersion()); } @@ -6273,14 +6323,135 @@ public boolean hostSupportsVddk() { } public boolean hostSupportsVddk(String overriddenVddkLibDir) { - String effectiveVddkLibDir = StringUtils.trimToNull(overriddenVddkLibDir); - if (StringUtils.isBlank(effectiveVddkLibDir)) { - effectiveVddkLibDir = StringUtils.trimToNull(vddkLibDir); + String effectiveVddkLibDir = resolveVddkLibDir(overriddenVddkLibDir); + return hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && isNbdkitVddkPluginUsable(effectiveVddkLibDir); + } + + public boolean hostSupportsVddkBlockCopy() { + return hostSupportsVddkBlockCopy(null); + } + + public boolean hostSupportsVddkBlockCopy(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_NBD_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_IO_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVddkBlockCopyInPlaceFinalization() { + return hostSupportsVddkBlockCopy() && hostSupportsVirtV2vInPlace(); + } + + public boolean hostSupportsVddkBlockCopyRbd() { + return hostSupportsVddkBlockCopyInPlaceFinalization() + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public String getQemuImgVersion() { + return detectFirstLineVersion("qemu-img", "--version"); + } + + public String getQemuNbdVersion() { + return detectFirstLineVersion("qemu-nbd", "--version"); + } + + public String getQemuIoVersion() { + return detectFirstLineVersion("qemu-io", "--version"); + } + + public boolean hostSupportsVirtV2vInPlace() { + return hostSupportsVirtV2vInPlaceBinary() || hostSupportsVirtV2vInPlaceOption(); + } + + /** + * nbdcopy (from libnbd) is an optional accelerator for full-disk copies from an + * nbdkit/VDDK source into a local raw block device: it uses multiple in-flight + * requests and connections, so it is typically faster than a single-connection + * qemu-img convert. It is a pure optimization - callers fall back to + * qemu-img convert when it is absent. + */ + public boolean hostSupportsNbdcopy() { + return Script.runSimpleBashScriptForExitValue(NBDCOPY_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVirtV2vInPlaceBinary() { + return getVirtV2vInPlaceBinary() != null; + } + + /** + * Resolves the virt-v2v-in-place executable. Ubuntu/Debian install it on $PATH, + * but EL9-family distributions ship it in /usr/libexec, so probing the bare name + * alone would (wrongly) disable in-place finalization there. Returns the + * invocable binary (name or absolute path), or null when neither works. + */ + public String getVirtV2vInPlaceBinary() { + if (Script.runSimpleBashScriptForExitValue(INSTANCE_CONVERSION_IN_PLACE_SUPPORTED_CHECK_CMD) == 0) { + return "virt-v2v-in-place"; } - if (StringUtils.isBlank(effectiveVddkLibDir) || !isVddkLibDirValid(effectiveVddkLibDir)) { - effectiveVddkLibDir = detectVddkLibDir(); + if (Script.runSimpleBashScriptForExitValue(VIRT_V2V_IN_PLACE_LIBEXEC_PATH + " --version") == 0) { + return VIRT_V2V_IN_PLACE_LIBEXEC_PATH; + } + return null; + } + + public boolean hostSupportsVirtV2vInPlaceOption() { + return Script.runSimpleBashScriptForExitValue(INSTANCE_CONVERSION_IN_PLACE_OPTION_SUPPORTED_CHECK_CMD) == 0; + } + + public String getHostVirtV2vInPlaceVersion() { + if (!hostSupportsVirtV2vInPlace()) { + return ""; + } + String inPlaceBinary = getVirtV2vInPlaceBinary(); + if (inPlaceBinary == null) { + return getHostVirtV2vVersion(); + } + String cmd = String.format("%s --version | awk '{print $2}'", inPlaceBinary); + 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 hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && StringUtils.isNotBlank(detectVddkVersion()); + return null; + } + + protected String parseVersionToken(String versionLine) { + String versionMarker = " version "; + int markerIndex = versionLine.indexOf(versionMarker); + if (markerIndex < 0) { + return versionLine; + } + String value = versionLine.substring(markerIndex + versionMarker.length()); + String[] parts = value.split("\\s+", 2); + return parts.length > 0 ? parts[0] : versionLine; + } + + public boolean hostSupportsQemuRbd() { + return Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVddkRbdDirectImport() { + return hostSupportsVddkRbdDirectImport(null); + } + + public boolean hostSupportsVddkRbdDirectImport(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) && hostSupportsQemuRbd() && hostSupportsVirtV2vInPlace(); } protected boolean isVddkLibDirValid(String path) { @@ -6295,6 +6466,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)) { @@ -6304,28 +6486,73 @@ protected String detectVddkLibDir() { } protected String detectVddkVersion() { + return detectVddkVersion(null); + } + + protected String detectVddkVersion(String overriddenVddkLibDir) { + String effectiveVddkLibDir = resolveVddkLibDir(overriddenVddkLibDir); + if (!isVddkLibDirValid(effectiveVddkLibDir)) { + return null; + } + return parseVddkLibraryVersionFromDumpPluginOutput(runNbdkitVddkDumpPlugin(effectiveVddkLibDir)); + } + + protected boolean isNbdkitVddkPluginUsable(String vddkLibDir) { + if (!isVddkLibDirValid(vddkLibDir)) { + return false; + } + String dumpPluginOutput = runNbdkitVddkDumpPlugin(vddkLibDir); + if (StringUtils.isBlank(parseVddkLibraryVersionFromDumpPluginOutput(dumpPluginOutput))) { + LOGGER.warn("nbdkit-vddk-plugin could not load VMware VDDK from [{}]", vddkLibDir); + return false; + } + return true; + } + + protected String runNbdkitVddkDumpPlugin(String vddkLibDir) { try { - ProcessBuilder pb = new ProcessBuilder("nbdkit", "vddk", "--version"); + ProcessBuilder pb = new ProcessBuilder("nbdkit", "vddk", "--dump-plugin", String.format("libdir=%s", vddkLibDir)); + pb.redirectErrorStream(true); Process process = pb.start(); + boolean completed = process.waitFor(10, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } String output = new String(process.getInputStream().readAllBytes()); - process.waitFor(); - - if (StringUtils.isBlank(output)) { - return null; + if (!completed) { + LOGGER.warn("Timed out while checking nbdkit-vddk-plugin with libdir [{}]", vddkLibDir); + return output; + } + if (process.exitValue() != 0) { + LOGGER.warn("nbdkit-vddk-plugin check failed for libdir [{}]: {}", vddkLibDir, StringUtils.trimToEmpty(output)); + return output; } + return output; + } catch (Exception e) { + LOGGER.error("Failed to check nbdkit-vddk-plugin with libdir [{}]: {}", vddkLibDir, e.getMessage()); + return null; + } + } + protected String parseVddkLibraryVersionFromDumpPluginOutput(String output) { + if (StringUtils.isBlank(output)) { + return null; + } + String libraryVersionPrefix = "vddk_library_version="; + try { for (String line : output.split("\\R")) { String trimmed = StringUtils.trimToEmpty(line); - if (trimmed.startsWith("vddk ")) { - return StringUtils.trimToNull(trimmed.substring("vddk ".length())); + if (trimmed.startsWith(libraryVersionPrefix)) { + return StringUtils.trimToNull(trimmed.substring(libraryVersionPrefix.length())); } } - return null; } catch (Exception e) { - LOGGER.error("Failed to detect vddk version: {}", e.getMessage()); + LOGGER.error("Failed to parse nbdkit-vddk-plugin output: {}", e.getMessage()); return null; } + return null; } public boolean hostSupportsWindowsGuestConversion() { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java index dc34a4cb62d8..7bb525ba196f 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java @@ -128,6 +128,13 @@ protected void sanitizeDisksPath(List disks) { protected List moveTemporaryDisksToDestination(List temporaryDisks, List destinationStoragePools, KVMStoragePoolManager storagePoolMgr) { + return moveTemporaryDisksToDestination(temporaryDisks, destinationStoragePools, null, storagePoolMgr); + } + + protected List moveTemporaryDisksToDestination(List temporaryDisks, + List destinationStoragePools, + List destinationStoragePoolTypes, + KVMStoragePoolManager storagePoolMgr) { List targetDisks = new ArrayList<>(); if (temporaryDisks.size() != destinationStoragePools.size()) { String warn = String.format("Discrepancy between the converted instance disks (%s) " + @@ -136,17 +143,13 @@ protected List moveTemporaryDisksToDestination(List moveTemporaryDisksToDestination(List moveTemporaryDisksToDestination(List destinationStoragePoolTypes, int index) { + if (CollectionUtils.isEmpty(destinationStoragePoolTypes) || destinationStoragePoolTypes.size() <= index || destinationStoragePoolTypes.get(index) == null) { + return Storage.StoragePoolType.NetworkFilesystem; + } + return destinationStoragePoolTypes.get(index); + } + + private void probeRbdQemuAccess(KVMStoragePool pool, String destinationName) { + String probeName = destinationName + "-probe"; + String rbdImagePath = pool.getSourceDir() + "/" + probeName; + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(pool, rbdImagePath); + try { + Script qemuImg = new Script("qemu-img", 120000, logger); + qemuImg.add("create", "-f", "raw", qemuRbdPath, "4194304"); + qemuImg.execute(); + if (qemuImg.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-img could not create RBD probe image %s", rbdImagePath)); + } + + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", qemuRbdPath); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify RBD probe image %s", rbdImagePath)); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete RBD probe image {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + private void cleanupMovedDisksOnDestinationPool(List targetDisks) { if (CollectionUtils.isEmpty(targetDisks)) { return; @@ -220,9 +262,20 @@ protected List getUnmanagedInstanceDisks(List storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); - disk.setDatastoreHost(storagePoolHostAndPath.first()); - disk.setDatastorePath(storagePoolHostAndPath.second()); + if (storagePool.getType() == Storage.StoragePoolType.RBD) { + disk.setDatastoreHost(storagePool.getSourceHost()); + disk.setDatastorePath(storagePool.getSourceDir()); + disk.setImagePath(physicalDisk.getName()); + } else if (storagePool.getType() == Storage.StoragePoolType.Linstor) { + // Linstor pools expose no local mount path; the management server resolves the + // pool by its UUID (datastore name), and the volume by its disk name (path). + disk.setDatastoreHost(storagePool.getSourceHost()); + disk.setImagePath(physicalDisk.getName()); + } else { + Pair storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); + disk.setDatastoreHost(storagePoolHostAndPath.first()); + disk.setDatastorePath(storagePoolHostAndPath.second()); + } disk.setDatastoreName(storagePool.getUuid()); disk.setDatastoreType(storagePool.getType().name()); disk.setCapacity(physicalDisk.getVirtualSize()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java index de9341715f02..0eb728d5d37e 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java @@ -31,6 +31,21 @@ public class LibvirtCheckConvertInstanceCommandWrapper extends CommandWrapper STORAGE_POOL_TYPES_SUPPORTED = Arrays.asList( Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.NetworkFilesystem, - Storage.StoragePoolType.SharedMountPoint); + Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.RBD, + Storage.StoragePoolType.Linstor); @Override public Answer execute(final CheckVolumeCommand command, final LibvirtComputingResource libvirtComputingResource) { @@ -63,6 +65,31 @@ public Answer execute(final CheckVolumeCommand command, final LibvirtComputingRe try { if (STORAGE_POOL_TYPES_SUPPORTED.contains(storageFilerTO.getType())) { final KVMPhysicalDisk vol = pool.getPhysicalDisk(srcFile); + if (Storage.StoragePoolType.RBD.equals(storageFilerTO.getType()) + || Storage.StoragePoolType.Linstor.equals(storageFilerTO.getType())) { + // RBD and Linstor volumes are raw block devices, not local qcow2 files: + // inspect them through qemu-img (RBD by its rbd: URI, Linstor by its + // /dev/drbd device path) rather than checkQcow2File, which would reject + // a raw device. + // + // A Linstor volume only materialises as a local /dev/drbd device once the + // resource is made available on THIS host (a diskless DRBD assignment). RBD + // needs no such step — qemu-img reaches it over the network by its rbd: URI. + // So for Linstor we connect the resource here before qemu-img inspects it and + // release the diskless assignment afterwards; the replicated data on the + // storage nodes is untouched (disconnect only drops a local diskless copy). + boolean linstorConnected = false; + if (Storage.StoragePoolType.Linstor.equals(storageFilerTO.getType())) { + linstorConnected = poolMgr.connectPhysicalDisk(storageFilerTO.getType(), storageFilerTO.getUuid(), srcFile, null); + } + try { + return checkRbdVolume(command, pool, vol); + } finally { + if (linstorConnected) { + poolMgr.disconnectPhysicalDisk(storageFilerTO.getType(), storageFilerTO.getUuid(), srcFile); + } + } + } final String path = vol.getPath(); try { KVMPhysicalDisk.checkQcow2File(path); @@ -81,6 +108,21 @@ public Answer execute(final CheckVolumeCommand command, final LibvirtComputingRe } } + /** + * RBD (Ceph) volumes are raw images that cannot be inspected through direct file reads + * (checkQcow2File / getVirtualSizeFromFile operate on a local path). Instead the volume is + * inspected through the RBD URI via qemu-img (see {@link #getDiskFileInfo}). The virtual size + * is taken from the disk reported by the storage pool, and the encrypted/backing-file/locked + * details are still collected so the management server can apply its import validations. + */ + private Answer checkRbdVolume(final CheckVolumeCommand command, final KVMStoragePool pool, final KVMPhysicalDisk disk) { + Map volumeDetails = getVolumeDetails(pool, disk); + if (volumeDetails == null) { + return new CheckVolumeAnswer(command, false, "", 0, null); + } + return new CheckVolumeAnswer(command, true, "", disk.getVirtualSize(), volumeDetails); + } + private Map getVolumeDetails(KVMStoragePool pool, KVMPhysicalDisk disk) { Map info = getDiskFileInfo(pool, disk, true); if (MapUtils.isEmpty(info)) { @@ -109,7 +151,21 @@ private Map getVolumeDetails(KVMStoragePool po if (StringUtils.isNotBlank(encrypted) && encrypted.equalsIgnoreCase("yes")) { volumeDetails.put(VolumeOnStorageTO.Detail.IS_ENCRYPTED, String.valueOf(Boolean.TRUE)); } - Boolean isLocked = isDiskFileLocked(pool, disk); + boolean isLocked = isDiskFileLocked(pool, disk); + if (!isLocked) { + // For clustered block storage (Linstor/DRBD) the host-local qemu-img lock is not + // authoritative: a volume can be attached to a running VM on another node. Consult + // the backend's cluster-wide in-use state so such a volume is not adopted. + String inUseNode = pool.getVolumeInUseNode(disk.getName()); + if (KVMStoragePool.IN_USE_NODE_UNKNOWN.equals(inUseNode)) { + logger.warn("Could not determine cluster-wide in-use state of volume {}; marking as locked", + disk.getName()); + isLocked = true; + } else if (inUseNode != null) { + logger.info("Volume {} is in use on node {}; marking as locked", disk.getName(), inUseNode); + isLocked = true; + } + } volumeDetails.put(VolumeOnStorageTO.Detail.IS_LOCKED, String.valueOf(isLocked)); return volumeDetails; @@ -122,6 +178,10 @@ private Map getDiskFileInfo(KVMStoragePool pool, KVMPhysicalDisk try { QemuImg qemu = new QemuImg(0); QemuImgFile qemuFile = new QemuImgFile(disk.getPath(), disk.getFormat()); + if (Storage.StoragePoolType.RBD.equals(pool.getType())) { + String rbdDestFile = KVMPhysicalDisk.RBDStringBuilder(pool, disk.getPath()); + qemuFile = new QemuImgFile(rbdDestFile, disk.getFormat()); + } return qemu.info(qemuFile, secure); } catch (QemuImgException | LibvirtException ex) { logger.error("Failed to get info of disk file: " + ex.getMessage()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java index f28b5eda4a63..0979b10d5186 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java @@ -18,11 +18,13 @@ // package com.cloud.hypervisor.kvm.resource.wrapper; +import java.net.ServerSocket; import java.net.URLEncoder; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; import java.util.Locale; import java.util.Arrays; import java.util.List; @@ -33,6 +35,7 @@ import java.util.regex.Pattern; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.utils.qemu.QemuImg; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -42,14 +45,18 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.NfsTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareVddkSourceDiskTO; import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; import com.cloud.utils.FileUtil; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.script.OutputInterpreter; import com.cloud.utils.script.Script; @@ -59,6 +66,55 @@ public class LibvirtConvertInstanceCommandWrapper extends CommandWrapper supportedInstanceConvertSourceHypervisors = List.of(Hypervisor.HypervisorType.VMware); private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + // virt-v2v's exact wording when the host's virtio-win driver set has no match for the guest. + private static final String NO_VIRTIO_DRIVERS_MARKER = "there are no virtio drivers available"; + + /** + * Logs virt-v2v output like {@link OutputInterpreter.LineByLineOutputLogger} while watching for + * the "no virtio drivers available" warning. virt-v2v treats it as non-fatal and finishes the + * conversion, but a Windows guest converted without a virtio storage driver cannot boot from + * the virtio disk the import gives it - the failure only shows up later as an unbootable VM, + * so the conversion itself must refuse instead. + */ + private static class VirtioDriverOutputWatcher extends OutputInterpreter { + private final org.apache.logging.log4j.Logger watcherLogger; + private final String logPrefix; + private boolean noVirtioDriversReported; + + VirtioDriverOutputWatcher(org.apache.logging.log4j.Logger logger, String logPrefix) { + this.watcherLogger = logger; + this.logPrefix = logPrefix; + } + + @Override + public boolean drain() { + return true; + } + + @Override + public String interpret(java.io.BufferedReader reader) throws java.io.IOException { + String line; + while ((line = reader.readLine()) != null) { + watcherLogger.info("({}) {}", logPrefix, line); + if (line.toLowerCase().contains(NO_VIRTIO_DRIVERS_MARKER)) { + noVirtioDriversReported = true; + } + } + return null; + } + + boolean isNoVirtioDriversReported() { + return noVirtioDriversReported; + } + } + + private void failWindowsConversionWithoutVirtioDrivers(String originalVMName) { + throw new CloudRuntimeException(String.format( + "Conversion of Windows VM %s finished without virtio drivers: the conversion host's virtio-win driver set " + + "(/usr/share/virtio-win/virtio-win.iso) has no drivers for this Windows version, and the imported VM would not " + + "boot from its virtio disk. Update virtio-win on the conversion host to the upstream latest-virtio ISO and retry.", + originalVMName)); + } @Override public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serverResource) { @@ -70,6 +126,7 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve long timeout = (long) cmd.getWait() * 1000; String extraParams = cmd.getExtraParams(); boolean useVddk = cmd.isUseVddk(); + boolean windowsGuest = cmd.isWindowsGuest(); String originalVMName = cmd.getOriginalVMName(); if (cmd.getCheckConversionSupport() && !serverResource.hostSupportsInstanceConversion()) { @@ -89,6 +146,8 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); KVMStoragePool temporaryStoragePool = getTemporaryStoragePool(conversionTemporaryLocation, storagePoolMgr); + boolean directRbdVddkImport = isDirectRbdVddkImport(cmd); + boolean directLinstorVddkImport = isDirectLinstorVddkImport(cmd); logger.info(String.format("(%s) Attempting to convert the instance %s from %s to KVM", originalVMName, sourceInstanceName, sourceHypervisorType)); @@ -114,9 +173,27 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve String vddkTransports = resolveVddkSetting(cmd.getVddkTransports(), serverResource.getVddkTransports()); String configuredVddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); String passwordOption = serverResource.getDetectedPasswordFileOption(); - result = performInstanceConversionUsingVddk(sourceInstance, originalVMName, temporaryConvertPath, - vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, - timeout, verboseModeEnabled, extraParams, temporaryConvertUuid, passwordOption); + if (directRbdVddkImport || directLinstorVddkImport) { + if (directRbdVddkImport && !serverResource.hostSupportsVddkRbdDirectImport(cmd.getVddkLibDir())) { + String err = String.format("Direct RBD VDDK import requires VDDK, qemu-img RBD support, and virt-v2v in-place support on host %s. " + + "Use staged import with temporary conversion storage or select a newer conversion host.", serverResource.getPrivateIp()); + logger.error("({}) {}", originalVMName, err); + return new Answer(cmd, false, err); + } + if (directLinstorVddkImport && !serverResource.hostSupportsVirtV2vInPlace()) { + String err = String.format("Direct Linstor VDDK import requires virt-v2v in-place support on host %s. " + + "Use staged import with temporary conversion storage or select a newer conversion host.", serverResource.getPrivateIp()); + logger.error("({}) {}", originalVMName, err); + return new Answer(cmd, false, err); + } + result = performInstanceConversionUsingVddkDirectToPool(cmd, temporaryStoragePool, originalVMName, + vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, + timeout, verboseModeEnabled, temporaryConvertUuid, serverResource); + } else { + result = performInstanceConversionUsingVddk(sourceInstance, originalVMName, temporaryConvertPath, + vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, + timeout, verboseModeEnabled, extraParams, temporaryConvertUuid, passwordOption, windowsGuest); + } } else { logger.info("({}) Using OVF-based conversion (export + local convert)", originalVMName); String sourceOVFDirPath; @@ -150,7 +227,7 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve } result = performInstanceConversion(originalVMName, sourceOVFDirPath, temporaryConvertPath, temporaryConvertUuid, - timeout, verboseModeEnabled, extraParams, serverResource); + timeout, verboseModeEnabled, extraParams, serverResource, windowsGuest); } if (!result) { @@ -177,6 +254,20 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve } } + private boolean isDirectRbdVddkImport(ConvertInstanceCommand cmd) { + return isDirectVddkImportToPoolType(cmd, Storage.StoragePoolType.RBD); + } + + private boolean isDirectLinstorVddkImport(ConvertInstanceCommand cmd) { + return isDirectVddkImportToPoolType(cmd, Storage.StoragePoolType.Linstor); + } + + private boolean isDirectVddkImportToPoolType(ConvertInstanceCommand cmd, Storage.StoragePoolType poolType) { + DataStoreTO conversionTemporaryLocation = cmd.getConversionTemporaryLocation(); + return cmd.isUseVddk() && conversionTemporaryLocation instanceof PrimaryDataStoreTO && + ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType() == poolType; + } + protected KVMStoragePool getTemporaryStoragePool(DataStoreTO conversionTemporaryLocation, KVMStoragePoolManager storagePoolMgr) { if (conversionTemporaryLocation instanceof NfsTO) { NfsTO nfsTO = (NfsTO) conversionTemporaryLocation; @@ -251,7 +342,7 @@ protected boolean performInstanceConversion(String originalVMName, String source String temporaryConvertFolder, String temporaryConvertUuid, long timeout, boolean verboseModeEnabled, String extraParams, - LibvirtComputingResource serverResource) { + LibvirtComputingResource serverResource, boolean windowsGuest) { Script script = new Script("virt-v2v", timeout, logger); script.add("--root", "first"); script.add("-i", "ova"); @@ -266,17 +357,28 @@ protected boolean performInstanceConversion(String originalVMName, String source if (StringUtils.isNotBlank(extraParams)) { addExtraParamsToScript(extraParams, script); } + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; + if (firstbootScript != null) { + script.add("--firstboot", firstbootScript.toString()); + } - String logPrefix = String.format("(%s) virt-v2v ovf source: %s progress", originalVMName, sourceOVFDirPath); - OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); - Map convertInstanceEnv = serverResource.getConvertInstanceEnv(); - if (MapUtils.isEmpty(convertInstanceEnv)) { - script.execute(outputLogger); - } else { - script.execute(outputLogger, convertInstanceEnv); + try { + String logPrefix = String.format("(%s) virt-v2v ovf source: %s progress", originalVMName, sourceOVFDirPath); + VirtioDriverOutputWatcher outputLogger = new VirtioDriverOutputWatcher(logger, logPrefix); + Map convertInstanceEnv = serverResource.getConvertInstanceEnv(); + if (MapUtils.isEmpty(convertInstanceEnv)) { + script.execute(outputLogger); + } else { + script.execute(outputLogger, convertInstanceEnv); + } + if (windowsGuest && outputLogger.isNoVirtioDriversReported()) { + failWindowsConversionWithoutVirtioDrivers(originalVMName); + } + int exitValue = script.getExitValue(); + return exitValue == 0; + } finally { + deleteFirstbootScriptQuietly(firstbootScript, originalVMName); } - int exitValue = script.getExitValue(); - return exitValue == 0; } protected void addExtraParamsToScript(String extraParams, Script script) { @@ -308,7 +410,7 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst String libguestfsBackend, String vddkTransports, String configuredVddkThumbprint, long timeout, boolean verboseModeEnabled, String extraParams, - String temporaryConvertUuid, String passwordOption) { + String temporaryConvertUuid, String passwordOption, boolean windowsGuest) { String vcenterPassword = vmwareInstance.getVcenterPassword(); if (StringUtils.isBlank(vcenterPassword)) { @@ -328,6 +430,7 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst return false; } + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; try { String vpxUrl = buildVpxUrl(vmwareInstance); @@ -372,6 +475,10 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst cmd.append(extraParams).append(" "); } + if (firstbootScript != null) { + cmd.append("--firstboot ").append(shellQuote(firstbootScript.toString())).append(" "); + } + Script script = new Script("/bin/bash", timeout, logger); script.add("-c"); script.add(cmd.toString()); @@ -396,9 +503,620 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst } catch (Exception e) { logger.warn("({}) Failed to delete password file {}: {}", originalVMName, passwordFilePath, e.getMessage()); } + deleteFirstbootScriptQuietly(firstbootScript, originalVMName); + } + } + + protected boolean performInstanceConversionUsingVddkDirectToPool(ConvertInstanceCommand cmd, + KVMStoragePool targetPool, + String originalVMName, + String vddkLibDir, + String libguestfsBackend, + String vddkTransports, + String configuredVddkThumbprint, + long timeout, + boolean verboseModeEnabled, + String temporaryConvertUuid, + LibvirtComputingResource serverResource) { + RemoteInstanceTO vmwareInstance = cmd.getSourceInstance(); + List sourceDisks = cmd.getVmwareVddkSourceDisks(); + final boolean linstorTarget = targetPool.getType() == Storage.StoragePoolType.Linstor; + final boolean windowsGuest = cmd.isWindowsGuest(); + if (sourceDisks == null || sourceDisks.isEmpty()) { + logger.error("({}) Direct {} VDDK import requires VMware source disk metadata", originalVMName, targetPool.getType()); + return false; + } + + if (linstorTarget) { + probeLinstorQemuAccess(targetPool, temporaryConvertUuid); + } else { + probeRbdQemuAccess(targetPool, temporaryConvertUuid); + } + + String vcenterPassword = vmwareInstance.getVcenterPassword(); + if (StringUtils.isBlank(vcenterPassword)) { + logger.error("({}) Could not determine vCenter password for {}", originalVMName, vmwareInstance.getVcenterHost()); + return false; + } + + String vddkThumbprint = StringUtils.trimToNull(configuredVddkThumbprint); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(vmwareInstance.getVcenterHost(), timeout, originalVMName); + } + if (StringUtils.isBlank(vddkThumbprint)) { + logger.error("({}) Could not determine vCenter thumbprint for {}", originalVMName, vmwareInstance.getVcenterHost()); + return false; + } + + String passwordFilePath = String.format("/tmp/v2v.rbd.pass.cloud.%s.%s", + StringUtils.defaultIfBlank(vmwareInstance.getVcenterHost(), "unknown"), + UUID.randomUUID()); + List createdImages = new ArrayList<>(); + List blockDevicePaths = new ArrayList<>(); + try { + Files.writeString(Path.of(passwordFilePath), vcenterPassword); + Files.setPosixFilePermissions(Path.of(passwordFilePath), Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + + String vddkNbdCompression = serverResource.getVddkNbdCompression(); + for (int i = 0; i < sourceDisks.size(); i++) { + VmwareVddkSourceDiskTO sourceDisk = sourceDisks.get(i); + if (linstorTarget) { + String diskName = buildLinstorDiskName(temporaryConvertUuid, i); + createdImages.add(diskName); + String devicePath = copyVddkSourceDiskToLinstor(vmwareInstance, sourceDisk, targetPool, diskName, passwordFilePath, + vddkLibDir, vddkTransports, vddkNbdCompression, vddkThumbprint, timeout, originalVMName, serverResource.hostSupportsNbdcopy()); + blockDevicePaths.add(devicePath); + } else { + String imageName = buildRbdImageName(temporaryConvertUuid, i); + createdImages.add(imageName); + copyVddkSourceDiskToRbd(vmwareInstance, sourceDisk, targetPool, imageName, passwordFilePath, + vddkLibDir, vddkTransports, vddkNbdCompression, vddkThumbprint, timeout, originalVMName, + serverResource.hostSupportsNbdcopy()); + } + } + + boolean finalized; + if (linstorTarget) { + Path inputXml = Files.createTempFile("cloudstack-vddk-direct-" + temporaryConvertUuid, ".xml"); + Files.writeString(inputXml, buildDirectBlockDeviceLibvirtXml(temporaryConvertUuid, blockDevicePaths)); + finalized = runInPlaceFinalization(inputXml, libguestfsBackend, timeout, verboseModeEnabled, originalVMName, serverResource, windowsGuest); + Files.deleteIfExists(inputXml); + } else { + finalized = runRbdInPlaceFinalizationOverNbdBridges(targetPool, createdImages, temporaryConvertUuid, + libguestfsBackend, timeout, verboseModeEnabled, originalVMName, serverResource, windowsGuest); + } + if (!finalized) { + cleanupDirectImportDisks(targetPool, createdImages, originalVMName); + } + return finalized; + } catch (Exception e) { + logger.error("({}) Direct {} VDDK import failed: {}", originalVMName, targetPool.getType(), e.getMessage(), e); + cleanupDirectImportDisks(targetPool, createdImages, originalVMName); + return false; + } finally { + try { + Files.deleteIfExists(Path.of(passwordFilePath)); + } catch (Exception e) { + logger.warn("({}) Failed to delete password file {}: {}", originalVMName, passwordFilePath, e.getMessage()); + } + } + } + + private void copyVddkSourceDiskToRbd(RemoteInstanceTO vmwareInstance, VmwareVddkSourceDiskTO sourceDisk, + KVMStoragePool targetPool, String imageName, String passwordFilePath, + String vddkLibDir, String vddkTransports, String vddkNbdCompression, + String vddkThumbprint, long timeout, String originalVMName, boolean useNbdcopy) { + if (StringUtils.isBlank(sourceDisk.getSourceDiskPath())) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a VMDK path", sourceDisk.getDiskId())); + } + String rbdImagePath = targetPool.getSourceDir() + "/" + imageName; + String qemuRbdTarget = KVMPhysicalDisk.RBDStringBuilder(targetPool, rbdImagePath); + + StringBuilder nbdkit = new StringBuilder(); + nbdkit.append("nbdkit -r -U - vddk "); + nbdkit.append("file=").append(shellQuote(sourceDisk.getSourceDiskPath())).append(" "); + nbdkit.append("server=").append(shellQuote(vmwareInstance.getVcenterHost())).append(" "); + nbdkit.append("user=").append(shellQuote(vmwareInstance.getVcenterUsername())).append(" "); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + if (StringUtils.isNotBlank(vmwareInstance.getVmwareMoref())) { + nbdkit.append("vm=").append(shellQuote("moref=" + vmwareInstance.getVmwareMoref())).append(" "); + } else { + nbdkit.append("vm=").append(shellQuote(vmwareInstance.getInstanceName())).append(" "); + } + nbdkit.append("libdir=").append(shellQuote(vddkLibDir)).append(" "); + nbdkit.append("thumbprint=").append(shellQuote(vddkThumbprint)).append(" "); + if (StringUtils.isNotBlank(vddkTransports)) { + nbdkit.append("transports=").append(shellQuote(vddkTransports)).append(" "); + } + if (StringUtils.isNotBlank(vddkNbdCompression)) { + nbdkit.append("compression=").append(shellQuote(vddkNbdCompression)).append(" "); + } + + boolean bridgeCopy = useNbdcopy && sourceDisk.getCapacityBytes() > 0; + Path scriptFile = null; + try { + Script script; + if (bridgeCopy) { + // Multi-connection nbdcopy is typically faster than a single-stream qemu-img + // convert, but nbdcopy needs both ends as NBD. qemu-img cannot create the + // image here (nbdcopy does not), so pre-create it, then serve it over a + // localhost qemu-nbd bridge and copy NBD-to-NBD. The fresh raw image reads + // as zeros, so --destination-is-zero keeps it sparse. + int port = allocateLocalhostPort(); + Path pidFile = Files.createTempFile("cloudstack-vddk-rbd-nbd-" + imageName + "-", ".pid"); + Files.deleteIfExists(pidFile); + nbdkit.append("--run ").append(shellQuote( + "nbdcopy --destination-is-zero \"$uri\" " + String.format("nbd://localhost:%d", port))); + + StringBuilder scriptBody = new StringBuilder(); + scriptBody.append("#!/bin/bash\n"); + scriptBody.append("set -euo pipefail\n"); + scriptBody.append("cleanup() {\n"); + scriptBody.append(" set +e\n"); + scriptBody.append(" if [[ -s ").append(shellQuote(pidFile.toString())).append(" ]]; then\n"); + scriptBody.append(" pid=$(cat ").append(shellQuote(pidFile.toString())).append(")\n"); + scriptBody.append(" kill \"$pid\" >/dev/null 2>&1 || true\n"); + scriptBody.append(" for attempt in {1..20}; do kill -0 \"$pid\" >/dev/null 2>&1 || break; sleep 0.1; done\n"); + scriptBody.append(" fi\n"); + scriptBody.append(" rm -f ").append(shellQuote(pidFile.toString())).append("\n"); + scriptBody.append("}\n"); + scriptBody.append("trap cleanup EXIT\n"); + scriptBody.append("qemu-img create -f raw ").append(shellQuote(qemuRbdTarget)).append(" ") + .append(sourceDisk.getCapacityBytes()).append("\n"); + scriptBody.append("qemu-nbd --fork --persistent --shared=8 --format=raw --bind=127.0.0.1 --port=") + .append(port).append(" --pid-file=").append(shellQuote(pidFile.toString())) + .append(" ").append(shellQuote(qemuRbdTarget)).append("\n"); + scriptBody.append(nbdkit).append("\n"); + + scriptFile = Files.createTempFile("cloudstack-vddk-rbd-copy-" + imageName + "-", ".sh"); + Files.writeString(scriptFile, scriptBody.toString()); + Files.setPosixFilePermissions(scriptFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + + script = new Script("/bin/bash", timeout, logger); + script.add(scriptFile.toString()); + } else { + // qemu-img convert creates and writes the RBD image in one step (fallback when + // nbdcopy is unavailable or the source capacity is unknown). + nbdkit.append("--run ").append(shellQuote("qemu-img convert -f raw -O raw \"$uri\" " + shellQuote(qemuRbdTarget))); + script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(nbdkit.toString()); + } + + String logPrefix = String.format("(%s) vddk%s to rbd disk %s", originalVMName, + bridgeCopy ? "(nbdcopy)" : "", sourceDisk.getDiskId()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + logger.info("({}) Copying VMware disk {} to RBD image {}{}", originalVMName, sourceDisk.getSourceDiskPath(), + rbdImagePath, bridgeCopy ? " via nbdcopy over qemu-nbd bridge" : ""); + 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)); + } + } catch (java.io.IOException e) { + throw new CloudRuntimeException(String.format("Failed to prepare RBD copy for VMware disk %s: %s", + sourceDisk.getDiskId(), e.getMessage()), e); + } finally { + deleteQuietly(scriptFile); + } + } + + private String copyVddkSourceDiskToLinstor(RemoteInstanceTO vmwareInstance, VmwareVddkSourceDiskTO sourceDisk, + KVMStoragePool targetPool, String diskName, String passwordFilePath, + String vddkLibDir, String vddkTransports, String vddkNbdCompression, + String vddkThumbprint, long timeout, String originalVMName, boolean useNbdcopy) { + if (StringUtils.isBlank(sourceDisk.getSourceDiskPath())) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a VMDK path", sourceDisk.getDiskId())); + } + if (sourceDisk.getCapacityBytes() <= 0) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a valid capacity, " + + "which is required to pre-create the Linstor target volume", sourceDisk.getDiskId())); + } + KVMPhysicalDisk targetDisk = targetPool.createPhysicalDisk(diskName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, sourceDisk.getCapacityBytes(), null); + String devicePath = targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new CloudRuntimeException(String.format("Could not resolve a local device path for Linstor volume %s", diskName)); + } + StringBuilder command = new StringBuilder(); + command.append("nbdkit -r -U - vddk "); + command.append("file=").append(shellQuote(sourceDisk.getSourceDiskPath())).append(" "); + command.append("server=").append(shellQuote(vmwareInstance.getVcenterHost())).append(" "); + command.append("user=").append(shellQuote(vmwareInstance.getVcenterUsername())).append(" "); + command.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + if (StringUtils.isNotBlank(vmwareInstance.getVmwareMoref())) { + command.append("vm=").append(shellQuote("moref=" + vmwareInstance.getVmwareMoref())).append(" "); + } else { + command.append("vm=").append(shellQuote(vmwareInstance.getInstanceName())).append(" "); + } + command.append("libdir=").append(shellQuote(vddkLibDir)).append(" "); + command.append("thumbprint=").append(shellQuote(vddkThumbprint)).append(" "); + if (StringUtils.isNotBlank(vddkTransports)) { + command.append("transports=").append(shellQuote(vddkTransports)).append(" "); + } + if (StringUtils.isNotBlank(vddkNbdCompression)) { + command.append("compression=").append(shellQuote(vddkNbdCompression)).append(" "); + } + // Full-disk copy into the local raw device: prefer nbdcopy (libnbd) when present - + // it pipelines many in-flight requests and is typically faster than a single-connection + // qemu-img convert - and fall back to qemu-img convert otherwise. Skip writing zero + // blocks (nbdcopy --destination-is-zero / qemu-img --target-is-zero) only when the + // backend guarantees a freshly created volume reads back as zeros (thin providers); + // on backends that do not (e.g. LVM-thick) the zeros must be written or stale data from + // a previously deleted volume would leak into the unwritten regions. + boolean targetIsZero = targetPool.isVolumeZeroInitialized(diskName); + String runCommand = useNbdcopy + ? "nbdcopy " + (targetIsZero ? "--destination-is-zero " : "") + "\"$uri\" " + shellQuote(devicePath) + : "qemu-img convert -n " + (targetIsZero ? "--target-is-zero " : "") + "-f raw -O raw \"$uri\" " + shellQuote(devicePath); + command.append("--run ").append(shellQuote(runCommand)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command.toString()); + String logPrefix = String.format("(%s) %s to linstor disk %s", originalVMName, + useNbdcopy ? "vddk(nbdcopy)" : "vddk", sourceDisk.getDiskId()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + logger.info("({}) Copying VMware disk {} to Linstor device {}", originalVMName, sourceDisk.getSourceDiskPath(), devicePath); + script.execute(outputLogger); + if (script.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("Failed to copy VMware disk %s to Linstor device %s", sourceDisk.getSourceDiskPath(), devicePath)); + } + return devicePath; + } + + private boolean runInPlaceFinalization(Path inputXml, String libguestfsBackend, long timeout, + boolean verboseModeEnabled, String originalVMName, + LibvirtComputingResource serverResource, boolean windowsGuest) { + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; + try { + String inPlaceCommand = buildInPlaceCommand(inputXml, libguestfsBackend, verboseModeEnabled, serverResource, firstbootScript); + if (inPlaceCommand == null) { + logger.error("({}) No virt-v2v in-place finalization method is available", originalVMName); + return false; + } + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(inPlaceCommand); + VirtioDriverOutputWatcher outputLogger = new VirtioDriverOutputWatcher(logger, + String.format("(%s) virt-v2v in-place", originalVMName)); + script.execute(outputLogger); + if (windowsGuest && outputLogger.isNoVirtioDriversReported()) { + failWindowsConversionWithoutVirtioDrivers(originalVMName); + } + return script.getExitValue() == 0; + } finally { + deleteFirstbootScriptQuietly(firstbootScript, originalVMName); + } + } + + /** + * Builds the in-place finalization command, resolving the virt-v2v-in-place + * binary through the server resource (EL9 ships it in /usr/libexec, outside + * $PATH) and falling back to "virt-v2v --in-place". Returns null when the host + * has no in-place method. + */ + private String buildInPlaceCommand(Path inputXml, String libguestfsBackend, boolean verboseModeEnabled, + LibvirtComputingResource serverResource, Path firstbootScript) { + StringBuilder command = new StringBuilder(); + command.append("export LIBGUESTFS_BACKEND=").append(shellQuote(libguestfsBackend)).append(" && "); + String inPlaceBinary = serverResource.getVirtV2vInPlaceBinary(); + if (inPlaceBinary != null) { + // No -O (write updated output XML): nothing consumes it and the option only + // exists from virt-v2v 2.5 on, so passing it breaks otherwise capable hosts + // such as Ubuntu 24.04 with virt-v2v-in-place 2.4. + command.append(inPlaceBinary).append(" --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" "); + } else if (serverResource.hostSupportsVirtV2vInPlaceOption()) { + command.append("virt-v2v --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" --in-place "); + } else { + return null; + } + if (verboseModeEnabled) { + command.append("-v "); + } + if (firstbootScript != null) { + command.append("--firstboot ").append(shellQuote(firstbootScript.toString())).append(" "); + } + return command.toString(); + } + + /** + * Finalizes direct-RBD import disks with in-place virt-v2v over temporary + * localhost qemu-nbd bridges (one per image), mirroring the CBT cutover + * approach. Native {@code } sources cannot + * be used here: their cephx {@code } element references a libvirt secret, + * and virt-v2v's {@code -i libvirtxml} input runs without a libvirt connection, + * so the secret cannot be resolved and no drives are attached (observed as + * "you must call guestfs_add_drive before guestfs_launch" on EL9). + */ + protected boolean runRbdInPlaceFinalizationOverNbdBridges(KVMStoragePool targetPool, List imageNames, + String temporaryConvertUuid, String libguestfsBackend, + long timeout, boolean verboseModeEnabled, + String originalVMName, LibvirtComputingResource serverResource, + boolean windowsGuest) { + List ports = new ArrayList<>(); + List pidFiles = new ArrayList<>(); + Path inputXml = null; + Path scriptPath = null; + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; + try { + for (int i = 0; i < imageNames.size(); i++) { + ports.add(allocateLocalhostPort()); + Path pidFile = Files.createTempFile("cloudstack-vddk-rbd-nbd-" + temporaryConvertUuid + "-", ".pid"); + Files.deleteIfExists(pidFile); + pidFiles.add(pidFile); + } + + inputXml = Files.createTempFile("cloudstack-vddk-rbd-" + temporaryConvertUuid, ".xml"); + Files.writeString(inputXml, buildDirectRbdNbdLibvirtXml(temporaryConvertUuid, ports)); + + String inPlaceCommand = buildInPlaceCommand(inputXml, libguestfsBackend, verboseModeEnabled, serverResource, firstbootScript); + if (inPlaceCommand == null) { + logger.error("({}) No virt-v2v in-place finalization method is available", originalVMName); + return false; + } + + 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 (Path pidFile : pidFiles) { + script.append(" ").append(shellQuote(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 (int i = 0; i < imageNames.size(); i++) { + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, + targetPool.getSourceDir() + "/" + imageNames.get(i)); + script.append("qemu-nbd --fork --persistent --shared=1 --format=raw --bind=127.0.0.1 --port=") + .append(ports.get(i)) + .append(" --pid-file=").append(shellQuote(pidFiles.get(i).toString())) + .append(" ").append(shellQuote(qemuRbdPath)).append("\n"); + } + script.append(inPlaceCommand).append("\n"); + + scriptPath = Files.createTempFile("cloudstack-vddk-rbd-finalize-" + temporaryConvertUuid + "-", ".sh"); + Files.writeString(scriptPath, script.toString()); + Files.setPosixFilePermissions(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + + Script runner = new Script("/bin/bash", timeout, logger); + runner.add(scriptPath.toString()); + VirtioDriverOutputWatcher outputLogger = new VirtioDriverOutputWatcher(logger, + String.format("(%s) virt-v2v rbd in-place", originalVMName)); + runner.execute(outputLogger); + if (windowsGuest && outputLogger.isNoVirtioDriversReported()) { + failWindowsConversionWithoutVirtioDrivers(originalVMName); + } + return runner.getExitValue() == 0; + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + logger.error("({}) RBD in-place finalization over NBD bridges failed: {}", originalVMName, e.getMessage(), e); + return false; + } finally { + deleteQuietly(inputXml); + deleteQuietly(scriptPath); + deleteQuietly(firstbootScript); + for (Path pidFile : pidFiles) { + deleteQuietly(pidFile); + } + } + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (Exception e) { + logger.debug("Unable to delete temporary file {}: {}", path, e.getMessage()); + } + } + + private int allocateLocalhostPort() throws java.io.IOException { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(false); + return socket.getLocalPort(); + } + } + + protected String buildDirectRbdNbdLibvirtXml(String temporaryConvertUuid, List ports) { + 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 < ports.size(); i++) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private String buildDirectBlockDeviceLibvirtXml(String temporaryConvertUuid, List devicePaths) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(xmlEscape("cloudstack-vddk-direct-" + temporaryConvertUuid)).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" hvm\n"); + xml.append(" \n"); + for (int i = 0; i < devicePaths.size(); i++) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private String buildRbdImageName(String temporaryConvertUuid, int position) { + return String.format("%s-disk-%03d", temporaryConvertUuid, position); + } + + /** + * Linstor disk names use a shorter position suffix than the RBD naming scheme: + * LINSTOR resource names are limited to 48 characters and the "cs-" prefix plus + * a 36-character UUID leaves little room ("-d%02d" keeps the name at 43). + */ + private String buildLinstorDiskName(String temporaryConvertUuid, int position) { + return String.format("%s-d%02d", temporaryConvertUuid, position); + } + + private String diskTargetName(int index) { + StringBuilder target = new StringBuilder("sd"); + int value = index; + do { + target.insert(2, (char) ('a' + (value % 26))); + value = value / 26 - 1; + } while (value >= 0); + return target.toString(); + } + + private void probeRbdQemuAccess(KVMStoragePool pool, String temporaryConvertUuid) { + String probeName = temporaryConvertUuid + "-probe-" + UUID.randomUUID(); + String rbdImagePath = pool.getSourceDir() + "/" + probeName; + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(pool, rbdImagePath); + try { + Script qemuImg = new Script("qemu-img", 120000, logger); + qemuImg.add("create", "-f", "raw", qemuRbdPath, "4194304"); + qemuImg.execute(); + if (qemuImg.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-img could not create RBD probe image %s", rbdImagePath)); + } + + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", qemuRbdPath); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify RBD probe image %s", rbdImagePath)); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete RBD probe image {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } } } + /** + * First-boot batch script injected into converted Windows guests. virt-v2v runs it once on the + * guest's first boot. It brings migrated data disks online and read-write and sets the SAN policy to + * OnlineAll, working around the common post-v2v symptom where secondary disks stay Offline/Read-only + * under the default Windows SAN policy. The Storage cmdlets used exist on Windows Server 2012+. + */ + protected static final String WINDOWS_ONLINE_DISKS_FIRSTBOOT = + "@echo off\r\n" + + "rem CloudStack post-migration: online offline disks, clear read-only, set SAN policy OnlineAll\r\n" + + "set CSLOG=%SystemDrive%\\cloudstack-firstboot.log\r\n" + + "echo [%DATE% %TIME%] CloudStack firstboot: bringing migrated disks online >> \"%CSLOG%\"\r\n" + + "powershell -NonInteractive -ExecutionPolicy Bypass -Command \"try { Set-StorageSetting -NewDiskPolicy OnlineAll -ErrorAction SilentlyContinue } catch {}; Get-Disk | Where-Object { $_.IsOffline } | ForEach-Object { Set-Disk -Number $_.Number -IsOffline $false }; Get-Disk | Where-Object { $_.IsReadOnly } | ForEach-Object { Set-Disk -Number $_.Number -IsReadOnly $false }\" >> \"%CSLOG%\" 2>&1\r\n" + + "exit /b 0\r\n"; + + /** + * Writes the Windows online-disks first-boot batch script to a temporary file so it can be passed to + * virt-v2v / virt-v2v-in-place via {@code --firstboot}. Returns the path, or {@code null} if it could + * not be written (conversion then proceeds without the first-boot fix rather than failing). + */ + protected Path writeWindowsOnlineDisksFirstbootScript(String originalVMName) { + try { + Path firstbootScript = Files.createTempFile("cloudstack-firstboot-online-disks-", ".bat"); + Files.writeString(firstbootScript, WINDOWS_ONLINE_DISKS_FIRSTBOOT); + logger.info("({}) Windows guest detected; injecting first-boot script to online migrated disks: {}", + originalVMName, firstbootScript); + return firstbootScript; + } catch (Exception e) { + logger.warn("({}) Failed to write Windows online-disks first-boot script; conversion will proceed without it: {}", + originalVMName, e.getMessage()); + return null; + } + } + + private void deleteFirstbootScriptQuietly(Path firstbootScript, String originalVMName) { + if (firstbootScript == null) { + return; + } + try { + Files.deleteIfExists(firstbootScript); + } catch (Exception e) { + logger.warn("({}) Failed to delete first-boot script {}: {}", originalVMName, firstbootScript, e.getMessage()); + } + } + + private void probeLinstorQemuAccess(KVMStoragePool pool, String temporaryConvertUuid) { + String probeName = temporaryConvertUuid.substring(0, 8) + "-probe"; + try { + KVMPhysicalDisk probeDisk = pool.createPhysicalDisk(probeName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, 4194304L, null); + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", probeDisk.getPath()); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify Linstor probe volume %s on pool %s", probeName, pool.getUuid())); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete Linstor probe volume {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + + private void cleanupDirectImportDisks(KVMStoragePool pool, List imageNames, String originalVMName) { + for (String imageName : imageNames) { + try { + logger.info("({}) Cleaning up disk {} after failed direct import", originalVMName, imageName); + pool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("({}) Failed to delete disk {} from pool {}: {}", originalVMName, imageName, pool.getUuid(), e.getMessage()); + } + } + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String xmlEscape(String value) { + return StringUtils.defaultString(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + protected String getVcenterThumbprint(String vcenterHost, long timeout, String originalVMName) { if (StringUtils.isBlank(vcenterHost)) { return null; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java index 1109d240a035..ff835eb55f2a 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java @@ -52,7 +52,7 @@ public final class LibvirtGetVolumesOnStorageCommandWrapper extends CommandWrapper { static final List STORAGE_POOL_TYPES_SUPPORTED_BY_QEMU_IMG = Arrays.asList(StoragePoolType.NetworkFilesystem, - StoragePoolType.Filesystem, StoragePoolType.RBD, StoragePoolType.SharedMountPoint); + StoragePoolType.Filesystem, StoragePoolType.RBD, StoragePoolType.SharedMountPoint, StoragePoolType.Linstor); @Override public Answer execute(final GetVolumesOnStorageCommand command, final LibvirtComputingResource libvirtComputingResource) { @@ -65,7 +65,21 @@ public Answer execute(final GetVolumesOnStorageCommand command, final LibvirtCom final KVMStoragePool storagePool = storagePoolMgr.getStoragePool(pool.getType(), pool.getUuid(), true, true); if (StringUtils.isNotBlank(volumePath)) { - return addVolumeByVolumePath(command, storagePool, volumePath); + // A Linstor volume is a DRBD device that only appears on this host once its resource + // is made available here (a diskless assignment); connect it before qemu-img inspects + // the device and release the diskless assignment afterwards (the replicated data on + // the storage nodes is untouched). RBD needs no such step. + boolean linstorConnected = false; + if (StoragePoolType.Linstor.equals(pool.getType())) { + linstorConnected = storagePoolMgr.connectPhysicalDisk(pool.getType(), pool.getUuid(), volumePath, null); + } + try { + return addVolumeByVolumePath(command, storagePool, volumePath); + } finally { + if (linstorConnected) { + storagePoolMgr.disconnectPhysicalDisk(pool.getType(), pool.getUuid(), volumePath); + } + } } else { return addAllVolumes(command, storagePool, keyword); } @@ -128,7 +142,20 @@ private void addDetailsToVolumeOnStorageTO(VolumeOnStorageTO volumeOnStorageTO, if (StringUtils.isNotBlank(encrypted) && encrypted.equalsIgnoreCase("yes")) { volumeOnStorageTO.addDetail(VolumeOnStorageTO.Detail.IS_ENCRYPTED, String.valueOf(Boolean.TRUE)); } - Boolean isLocked = isDiskFileLocked(storagePool, disk); + boolean isLocked = isDiskFileLocked(storagePool, disk); + if (!isLocked) { + // Clustered block storage (Linstor/DRBD): the host-local qemu-img lock cannot see a + // volume in use by a running VM on another node — consult the cluster-wide state. + String inUseNode = storagePool.getVolumeInUseNode(disk.getName()); + if (KVMStoragePool.IN_USE_NODE_UNKNOWN.equals(inUseNode)) { + logger.warn("Could not determine cluster-wide in-use state of volume {}; marking as locked", + disk.getName()); + isLocked = true; + } else if (inUseNode != null) { + logger.info("Volume {} is in use on node {}; marking as locked", disk.getName(), inUseNode); + isLocked = true; + } + } volumeOnStorageTO.addDetail(VolumeOnStorageTO.Detail.IS_LOCKED, String.valueOf(isLocked)); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java index 28e24a9e0f2d..fb432cd71e66 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java @@ -20,7 +20,9 @@ import java.util.List; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.commons.collections4.CollectionUtils; import com.cloud.agent.api.Answer; import com.cloud.agent.api.ImportConvertedInstanceAnswer; @@ -35,7 +37,7 @@ import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.ResourceWrapper; -import org.apache.commons.collections4.CollectionUtils; +import com.cloud.storage.Storage; @ResourceWrapper(handles = ImportConvertedInstanceCommand.class) public class LibvirtImportConvertedInstanceCommandWrapper extends LibvirtBaseConvertCommandWrapper { @@ -46,6 +48,7 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour Hypervisor.HypervisorType sourceHypervisorType = sourceInstance.getHypervisorType(); String sourceInstanceName = sourceInstance.getInstanceName(); List destinationStoragePools = cmd.getDestinationStoragePools(); + List destinationStoragePoolTypes = cmd.getDestinationStoragePoolTypes(); DataStoreTO conversionTemporaryLocation = cmd.getConversionTemporaryLocation(); final String temporaryConvertUuid = cmd.getTemporaryConvertUuid(); final boolean forceConvertToPool = cmd.isForceConvertToPool(); @@ -55,6 +58,12 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour final String temporaryConvertPath = temporaryStoragePool.getLocalPath(); try { + if (isForcedBlockStorageConversion(conversionTemporaryLocation, forceConvertToPool)) { + List disks = getTemporaryDisksWithPrefixFromTemporaryPool(temporaryStoragePool, temporaryConvertPath, temporaryConvertUuid); + UnmanagedInstanceTO convertedInstanceTO = getConvertedUnmanagedInstance(temporaryConvertUuid, disks, null); + return new ImportConvertedInstanceAnswer(cmd, convertedInstanceTO); + } + String convertedBasePath = String.format("%s/%s", temporaryConvertPath, temporaryConvertUuid); LibvirtDomainXMLParser xmlParser = parseMigratedVMXmlDomain(convertedBasePath); @@ -68,7 +77,7 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour disks = temporaryDisks; } else { disks = moveTemporaryDisksToDestination(temporaryDisks, - destinationStoragePools, storagePoolMgr); + destinationStoragePools, destinationStoragePoolTypes, storagePoolMgr); cleanupDisksAndDomainFromTemporaryLocation(temporaryDisks, temporaryStoragePool, temporaryConvertUuid, true); } @@ -93,4 +102,12 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour } } } + + private boolean isForcedBlockStorageConversion(DataStoreTO conversionTemporaryLocation, boolean forceConvertToPool) { + if (!forceConvertToPool || !(conversionTemporaryLocation instanceof PrimaryDataStoreTO)) { + return false; + } + Storage.StoragePoolType poolType = ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType(); + return poolType == Storage.StoragePoolType.RBD || poolType == Storage.StoragePoolType.Linstor; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java index 1ff6d7851f20..72d9c3c84c46 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 @@ -52,9 +52,19 @@ public Answer execute(final ReadyCommand command, final LibvirtComputingResource if (libvirtComputingResource.hostSupportsInstanceConversion()) { hostDetails.put(Host.HOST_VIRTV2V_VERSION, libvirtComputingResource.getHostVirtV2vVersion()); } + hostDetails.put(Host.HOST_VIRTV2V_INPLACE_VERSION, libvirtComputingResource.getHostVirtV2vInPlaceVersion()); hostDetails.put(Host.HOST_VDDK_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddk())); hostDetails.put(Host.HOST_VDDK_LIB_DIR, StringUtils.defaultString(libvirtComputingResource.getVddkLibDir())); hostDetails.put(Host.HOST_VDDK_VERSION, StringUtils.defaultString(libvirtComputingResource.getVddkVersion())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopy())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopyInPlaceFinalization())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopyRbd())); + hostDetails.put(Host.HOST_QEMU_IMG_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuImgVersion())); + hostDetails.put(Host.HOST_QEMU_NBD_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuNbdVersion())); + hostDetails.put(Host.HOST_QEMU_IO_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuIoVersion())); + hostDetails.put(Host.HOST_VIRTV2V_INPLACE_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVirtV2vInPlace())); + hostDetails.put(Host.HOST_QEMU_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsQemuRbd())); + hostDetails.put(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkRbdDirectImport())); if (libvirtComputingResource.hostSupportsOvfExport()) { hostDetails.put(Host.HOST_OVFTOOL_VERSION, libvirtComputingResource.getHostOvfToolVersion()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java new file mode 100644 index 000000000000..3db111fa56a2 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java @@ -0,0 +1,339 @@ +// 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.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 = VmwareCbtCleanupCommand.class) +public class LibvirtVmwareCbtCleanupCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(VmwareCbtCleanupCommand cmd, LibvirtComputingResource serverResource) { + if (!cmd.getRemovePartialTargetDisks()) { + String msg = String.format("VMware CBT cleanup for migration %s skipped target disk cleanup by command policy.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + try { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + int removedImages = deleteRbdTargetImages(cmd, serverResource.getStoragePoolMgr()); + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated RBD target image(s).", + cmd.getMigrationUuid(), removedImages); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + int removedVolumes = deleteBlockDeviceTargetVolumes(cmd, serverResource.getStoragePoolMgr()); + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated block device target volume(s).", + cmd.getMigrationUuid(), removedVolumes); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + killInFlightCopyProcesses(String.format("/cloudstack-cbt/%s/", cmd.getMigrationUuid()), + 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; + } + + killInFlightCopyProcesses(String.format("cloudstack-cbt-%s-", cmd.getMigrationUuid()), cmd.getMigrationUuid()); + + int removedImages = 0; + List failedImages = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + String imageName = getRbdCleanupImageName(cmd.getMigrationUuid(), disk.getTargetPath()); + if (StringUtils.isBlank(imageName)) { + continue; + } + if (deletePhysicalDiskWithRetries(targetPool, imageName, cmd.getMigrationUuid())) { + removedImages++; + } else { + failedImages.add(imageName); + } + } + if (CollectionUtils.isNotEmpty(failedImages)) { + throw new IllegalStateException(String.format("Unable to delete VMware CBT RBD target image(s) %s from storage pool %s", + StringUtils.join(failedImages, ", "), targetPool.getUuid())); + } + return removedImages; + } + + private int deleteBlockDeviceTargetVolumes(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = getTargetStoragePool(cmd, storagePoolMgr); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device cleanup", + cmd.getMigrationUuid())); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return 0; + } + + killInFlightCopyProcesses(String.format("cbt-%s-", StringUtils.defaultString(cmd.getMigrationUuid()).replace("-", "").substring(0, 8)), + cmd.getMigrationUuid()); + + int removedVolumes = 0; + List failedVolumes = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + String volumeName = getBlockDeviceCleanupVolumeName(cmd.getMigrationUuid(), disk.getTargetPath()); + if (StringUtils.isBlank(volumeName)) { + continue; + } + if (deletePhysicalDiskWithRetries(targetPool, volumeName, cmd.getMigrationUuid())) { + removedVolumes++; + } else { + failedVolumes.add(volumeName); + } + } + if (CollectionUtils.isNotEmpty(failedVolumes)) { + throw new IllegalStateException(String.format("Unable to delete VMware CBT block device target volume(s) %s from storage pool %s", + StringUtils.join(failedVolumes, ", "), targetPool.getUuid())); + } + return removedVolumes; + } + + /** + * Block device target names carry a short migration marker ("cbt-" plus the first + * eight non-dash characters of the migration UUID) instead of the full RBD-style + * marker, because LINSTOR resource names are limited to 48 characters. + */ + private String getBlockDeviceCleanupVolumeName(String migrationUuid, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + String marker = String.format("cbt-%s-", StringUtils.defaultString(migrationUuid).replace("-", "").substring(0, 8)); + if (!StringUtils.startsWith(volumeName, marker)) { + logger.warn("Skipping VMware CBT block device cleanup target {} because it does not start with marker {}", targetPath, marker); + return null; + } + return volumeName; + } + + /** + * A cancelled migration's initial/delta sync may still be copying into the + * target volumes when this cleanup runs (the data-plane copy is not aborted by + * the cancel itself). Any surviving copy keeps the target device/image busy - + * volume deletion then fails and the copy keeps burning VDDK/network/storage on + * a dead migration. Kill processes whose command line references this + * migration's marker-guarded target names before deleting them: this matches + * the nbdcopy / qemu-img convert / qemu-io processes (their arguments carry the + * marker-named target); the nbdkit --run parent exits with its child. + */ + protected void killInFlightCopyProcesses(String marker, String migrationUuid) { + if (StringUtils.isBlank(marker)) { + return; + } + // Make the first character a bracket expression. The regex still matches the literal + // target marker, but it does not match the bash command line running pkill itself. + String processMatchPattern = String.format("[%s]%s", marker.charAt(0), marker.substring(1)); + int exitValue = Script.runSimpleBashScriptForExitValue(String.format("pkill -f -- %s", + quoteShellArgument(processMatchPattern))); + if (exitValue == 0) { + logger.info("Killed in-flight copy process(es) matching marker {} for VMware CBT migration {}", marker, migrationUuid); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Retries volume deletion briefly: right after in-flight copies are killed the + * device may still be held open (DRBD demotion, qemu teardown) for a moment. + */ + protected boolean deletePhysicalDiskWithRetries(KVMStoragePool targetPool, String volumeName, String migrationUuid) { + final int attempts = 3; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + if (targetPool.deletePhysicalDisk(volumeName, Storage.ImageFormat.RAW)) { + return true; + } + logger.warn("Attempt {}/{} to delete VMware CBT target {} for migration {} returned false", + attempt, attempts, volumeName, migrationUuid); + } catch (RuntimeException e) { + logger.warn("Attempt {}/{} to delete VMware CBT target {} for migration {} failed: {}", + attempt, attempts, volumeName, migrationUuid, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + if (isTargetAlreadyAbsent(targetPool, volumeName, migrationUuid)) { + return true; + } + if (attempt < attempts) { + try { + Thread.sleep(2000L * attempt); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return isTargetAlreadyAbsent(targetPool, volumeName, migrationUuid); + } + + /** + * Target cleanup has to be idempotent. A cancelled migration has already had its targets + * removed by the cancel itself, and deleting the migration afterwards runs cleanup again + * (the delete API defaults to cleanup=true). Without this check that second pass fails on + * targets that are legitimately gone, and the migration record can never be removed. + *

+ * Absence is only reported when it can be positively confirmed by listing the pool; if the + * pool cannot be inspected the target is assumed to still exist, so a genuine failure is + * still surfaced rather than being masked. + */ + protected boolean isTargetAlreadyAbsent(KVMStoragePool targetPool, String volumeName, String migrationUuid) { + try { + List disks = targetPool.listPhysicalDisks(); + if (disks == null) { + return false; + } + for (KVMPhysicalDisk disk : disks) { + if (disk != null && StringUtils.equals(disk.getName(), volumeName)) { + return false; + } + } + logger.info("VMware CBT target {} for migration {} is no longer present on storage pool {}; treating cleanup as complete", + volumeName, migrationUuid, targetPool.getUuid()); + return true; + } catch (RuntimeException e) { + logger.warn("Unable to confirm whether VMware CBT target {} for migration {} still exists on storage pool {}: {}", + volumeName, migrationUuid, targetPool.getUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + return false; + } + } + + private String quoteShellArgument(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + 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..b59aa1e0b1e6 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java @@ -0,0 +1,794 @@ +// 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.net.ServerSocket; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtCutoverCommand.class) +public class LibvirtVmwareCbtCutoverCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(VmwareCbtCutoverCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot cut over VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + if (!cmd.getRunVirtV2vFinalization()) { + String msg = String.format("VMware CBT cutover finalization for migration %s was skipped by command policy.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, 0, true, getDiskResults(cmd.getDisks(), 0, msg)); + } + + long startTime = System.currentTimeMillis(); + Path sourceXmlPath = null; + Path fallbackOutputDir = null; + Path fallbackStagingDir = null; + Path rbdNbdBridgeScriptPath = null; + boolean keepFallbackOutputDir = false; + boolean cutoverFinalizationCompleted = false; + try { + validateCutoverCommand(cmd); + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } + + VirtV2vFinalizationMode finalizationMode = getVirtV2vFinalizationMode(serverResource); + boolean inPlaceFinalization = finalizationMode.isInPlace(); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW && !inPlaceFinalization) { + throw new IllegalArgumentException("RBD target finalization requires virt-v2v in-place support on the selected KVM host; non-in-place fallback finalization cannot write directly back to RBD targets."); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE && !inPlaceFinalization) { + throw new IllegalArgumentException("Block device target finalization requires virt-v2v in-place support on the selected KVM host; non-in-place fallback finalization cannot write directly back to block device targets."); + } + List rbdNbdBridges = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW ? + createRbdNbdBridges(cmd, targetPool) : List.of(); + sourceXmlPath = writeSourceXml(cmd, rbdNbdBridges, targetPool); + + String command; + String logSuffix; + if (inPlaceFinalization) { + command = buildVirtV2vInPlaceCommand(sourceXmlPath, serverResource, finalizationMode); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + rbdNbdBridgeScriptPath = writeRbdNbdBridgeScript(cmd, rbdNbdBridges, command); + command = String.format("bash %s", shellQuote(rbdNbdBridgeScriptPath.toString())); + } + logSuffix = String.format("%s finalization", finalizationMode.getDisplayName()); + } else { + if (!cmd.getAllowNonInPlaceFinalization()) { + throw new IllegalArgumentException("Selected KVM host cannot finalize VMware CBT migration in-place. Enable virt-v2v in-place support or explicitly allow non-in-place fallback finalization."); + } + validateFallbackCapacity(cmd); + fallbackOutputDir = getFallbackOutputDir(cmd); + fallbackStagingDir = getFallbackStagingDir(cmd); + command = buildVirtV2vFallbackCommand(sourceXmlPath, fallbackOutputDir, fallbackStagingDir, serverResource); + logSuffix = String.format("%s finalization", finalizationMode.getDisplayName()); + } + + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT %s", cmd.getMigrationUuid(), logSuffix)); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String msg = String.format("%s failed for VMware CBT migration %s with exit code %s.", + finalizationMode.getDisplayName(), cmd.getMigrationUuid(), commandResult.getExitValue()); + msg = commandResult.appendLastCommandOutput(msg); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, durationSeconds, false, null); + } + + List diskResults = inPlaceFinalization ? + getDiskResults(cmd.getDisks(), durationSeconds, + String.format("Final %s conversion completed", finalizationMode.getDisplayName())) : + getFallbackDiskResults(cmd, fallbackOutputDir, durationSeconds); + diskResults = relocateFinalizedDiskResultsToStorageRoot(cmd.getMigrationUuid(), diskResults); + if (!inPlaceFinalization) { + deleteFallbackSourceDisks(cmd); + keepFallbackOutputDir = containsDiskResultUnderDirectory(diskResults, fallbackOutputDir); + } + cutoverFinalizationCompleted = true; + String msg = String.format("Final %s conversion completed for VMware CBT migration %s.", + finalizationMode.getDisplayName(), cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, durationSeconds, true, diskResults); + } catch (IllegalArgumentException e) { + String msg = String.format("Cannot cut over VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L), false, null); + } catch (Exception e) { + String msg = String.format("Cannot cut over VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L), false, null); + } finally { + deleteTempFile(sourceXmlPath); + deleteTempFile(rbdNbdBridgeScriptPath); + deleteTempTree(fallbackStagingDir); + if (!keepFallbackOutputDir) { + deleteTempTree(fallbackOutputDir); + } + if (cutoverFinalizationCompleted) { + deleteEmptyCbtMigrationDirectories(cmd); + } + } + } + + protected VirtV2vFinalizationMode getVirtV2vFinalizationMode(LibvirtComputingResource serverResource) { + if (serverResource.hostSupportsVirtV2vInPlaceBinary()) { + return VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_BINARY; + } + if (serverResource.hostSupportsVirtV2vInPlaceOption()) { + return VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION; + } + return VirtV2vFinalizationMode.VIRT_V2V_FALLBACK; + } + + private void validateCutoverCommand(VmwareCbtCutoverCommand cmd) { + if (StringUtils.isBlank(cmd.getMigrationUuid())) { + throw new IllegalArgumentException("migration UUID is missing"); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + throw new IllegalArgumentException("no target disks were provided for final conversion"); + } + + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + validateDisk(cmd, disk); + } + } + + private void validateDisk(VmwareCbtCutoverCommand cmd, VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("target disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("target disk ID is missing"); + } + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new IllegalArgumentException(String.format("target path is missing for disk %s", disk.getDiskId())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + String targetFormat = StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw").toLowerCase(Locale.ROOT); + if (!StringUtils.equals(targetFormat, "raw")) { + throw new IllegalArgumentException(String.format("target disk %s uses unsupported finalization format %s; only raw targets are supported for %s finalization", + disk.getDiskId(), targetFormat, cmd.getTargetStorageType())); + } + return; + } + if (!Files.isRegularFile(Path.of(disk.getTargetPath()))) { + throw new IllegalArgumentException(String.format("target disk %s does not exist at %s", + disk.getDiskId(), disk.getTargetPath())); + } + String targetFormat = StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2").toLowerCase(Locale.ROOT); + if (!StringUtils.equals(targetFormat, "qcow2")) { + throw new IllegalArgumentException(String.format("target disk %s uses unsupported finalization format %s; only qcow2 file targets are currently supported", + disk.getDiskId(), targetFormat)); + } + } + + private Path writeSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, KVMStoragePool targetPool) throws Exception { + Path sourceXmlPath = Files.createTempFile(String.format("vmware-cbt-%s-v2v-in-place-source-", + sanitizeFileName(cmd.getMigrationUuid())), ".xml"); + Files.writeString(sourceXmlPath, buildSourceXml(cmd, rbdNbdBridges, targetPool)); + return sourceXmlPath; + } + + private String buildSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, KVMStoragePool targetPool) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(escapeXml(StringUtils.defaultIfBlank(cmd.getMigrationUuid(), "vmware-cbt-migration"))).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" \n"); + xml.append(" hvm\n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + for (int index = 0; index < cmd.getDisks().size(); index++) { + VmwareCbtDiskTO disk = cmd.getDisks().get(index); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + appendRbdNbdDiskXml(xml, disk, rbdNbdBridges.get(index), index); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + appendBlockDeviceDiskXml(xml, disk, targetPool, index); + } else { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private void appendRbdNbdDiskXml(StringBuilder xml, VmwareCbtDiskTO disk, RbdNbdBridge rbdNbdBridge, int index) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + logger.info("Prepared temporary localhost NBD bridge on port {} for VMware CBT RBD target disk {}", + rbdNbdBridge.port, disk.getDiskId()); + } + + private void appendBlockDeviceDiskXml(StringBuilder xml, VmwareCbtDiskTO disk, KVMStoragePool targetPool, int index) { + String devicePath = getBlockDevicePath(targetPool, disk); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + logger.info("Prepared block device {} for VMware CBT target disk {} finalization", devicePath, disk.getDiskId()); + } + + private String getBlockDevicePath(KVMStoragePool targetPool, VmwareCbtDiskTO disk) { + String normalizedTargetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? + StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk targetDisk = targetPool.getPhysicalDisk(volumeName); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s of disk %s", + volumeName, disk.getDiskId())); + } + return devicePath; + } + + private void validateBlockDeviceTargetPool(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target finalization", + cmd.getMigrationUuid())); + } + } + + private List createRbdNbdBridges(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) throws Exception { + List bridges = new ArrayList<>(); + // Every probe socket is held open until all ports have been chosen. Probing and closing + // one port at a time lets the kernel hand the same ephemeral port to the next probe, + // which would point two disks' bridges at a single port and silently corrupt one of them. + List portProbes = new ArrayList<>(); + try { + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + ServerSocket probe = new ServerSocket(0); + portProbes.add(probe); + int port = probe.getLocalPort(); + 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)); + } + } finally { + // Released only now, right before the ports are written into the bridge script: + // qemu-nbd cannot bind a port this process still holds. + for (ServerSocket probe : portProbes) { + try { + probe.close(); + } catch (IOException e) { + logger.debug("Failed to release probe socket for port {}: {}", probe.getLocalPort(), e.getMessage()); + } + } + } + return bridges; + } + + private Path writeRbdNbdBridgeScript(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, + String virtV2vCommand) throws Exception { + Path scriptPath = Files.createTempFile(String.format("vmware-cbt-%s-rbd-finalize-", + sanitizeFileName(cmd.getMigrationUuid())), ".sh"); + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("cleanup() {\n"); + script.append(" set +e\n"); + script.append(" for pid_file in"); + for (RbdNbdBridge bridge : rbdNbdBridges) { + script.append(" ").append(shellQuote(bridge.pidFile.toString())); + } + script.append("; do\n"); + script.append(" if [[ -s \"$pid_file\" ]]; then\n"); + script.append(" pid=$(cat \"$pid_file\")\n"); + script.append(" kill \"$pid\" >/dev/null 2>&1 || true\n"); + script.append(" for attempt in {1..20}; do\n"); + script.append(" kill -0 \"$pid\" >/dev/null 2>&1 || break\n"); + script.append(" sleep 0.1\n"); + script.append(" done\n"); + script.append(" fi\n"); + script.append(" rm -f \"$pid_file\"\n"); + script.append(" done\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + for (RbdNbdBridge bridge : rbdNbdBridges) { + script.append("qemu-nbd --fork --persistent --shared=1 --format=raw --bind=127.0.0.1 --port=") + .append(bridge.port) + .append(" --pid-file=").append(shellQuote(bridge.pidFile.toString())) + .append(" ").append(shellQuote(bridge.qemuRbdPath)).append("\n"); + } + script.append(virtV2vCommand).append("\n"); + Files.writeString(scriptPath, script.toString()); + setPosixFilePermissionsIfSupported(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + return scriptPath; + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX file permissions are not supported for {}", path); + } + } + + private static class RbdNbdBridge { + private final int port; + private final Path pidFile; + private final String qemuRbdPath; + + private RbdNbdBridge(int port, Path pidFile, String qemuRbdPath) { + this.port = port; + this.pidFile = pidFile; + this.qemuRbdPath = qemuRbdPath; + } + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtCutoverCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target finalization", + cmd.getMigrationUuid())); + } + } + + private String getRbdImagePath(KVMStoragePool targetPool, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private String getDiskDeviceName(int index) { + StringBuilder suffix = new StringBuilder(); + int value = index; + do { + suffix.insert(0, (char)('a' + (value % 26))); + value = (value / 26) - 1; + } while (value >= 0); + return "sd" + suffix; + } + + private String buildVirtV2vInPlaceCommand(Path sourceXmlPath, LibvirtComputingResource serverResource, + VirtV2vFinalizationMode finalizationMode) { + StringBuilder command = new StringBuilder(); + appendLibguestfsBackend(command, serverResource); + if (finalizationMode == VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION) { + command.append("virt-v2v --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("--in-place -v"); + } else { + // Resolve the binary through the server resource: EL9-family distributions + // install virt-v2v-in-place in /usr/libexec, outside $PATH. + String inPlaceBinary = StringUtils.defaultIfBlank(serverResource.getVirtV2vInPlaceBinary(), "virt-v2v-in-place"); + // No -O (write updated output XML): nothing consumes it and the option only + // exists from virt-v2v 2.5 on, so passing it breaks otherwise capable hosts + // such as Ubuntu 24.04 with virt-v2v-in-place 2.4. + command.append(inPlaceBinary).append(" --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-v"); + } + return command.toString(); + } + + private String buildVirtV2vFallbackCommand(Path sourceXmlPath, Path outputDir, Path stagingDir, + LibvirtComputingResource serverResource) { + StringBuilder command = new StringBuilder(); + appendLibguestfsBackend(command, serverResource); + command.append("export TMPDIR=").append(shellQuote(stagingDir.toString())).append(" && "); + command.append("virt-v2v --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-o local -os ").append(shellQuote(outputDir.toString())).append(" "); + command.append("-of qcow2 -v"); + return command.toString(); + } + + private void appendLibguestfsBackend(StringBuilder command, LibvirtComputingResource serverResource) { + String libguestfsBackend = StringUtils.trimToNull(serverResource.getLibguestfsBackend()); + if (StringUtils.isNotBlank(libguestfsBackend)) { + command.append("export LIBGUESTFS_BACKEND=").append(shellQuote(libguestfsBackend)).append(" && "); + } + } + + private void validateFallbackCapacity(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + long requiredBytes = saturatingMultiply(sumDiskCapacityBytes(cmd), 2L); + long availableBytes = Files.getFileStore(targetBasePath).getUsableSpace(); + if (availableBytes < requiredBytes) { + throw new IllegalArgumentException(String.format("Non-in-place fallback finalization requires additional free space on target primary storage. Required: %s bytes, available: %s bytes.", + requiredBytes, availableBytes)); + } + } + + private long sumDiskCapacityBytes(VmwareCbtCutoverCommand cmd) throws Exception { + long total = 0L; + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + long capacity = disk.getCapacityBytes(); + if (capacity <= 0) { + capacity = Files.size(Path.of(disk.getTargetPath())); + } + total = saturatingAdd(total, capacity); + } + return total; + } + + private long saturatingAdd(long left, long right) { + if (Long.MAX_VALUE - left < right) { + return Long.MAX_VALUE; + } + return left + right; + } + + private long saturatingMultiply(long value, long multiplier) { + if (value > 0 && multiplier > Long.MAX_VALUE / value) { + return Long.MAX_VALUE; + } + return value * multiplier; + } + + private Path getFallbackOutputDir(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + Path outputDir = Files.createTempDirectory(targetBasePath, "virt-v2v-output-").normalize(); + if (!outputDir.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved fallback output path %s is outside %s", + outputDir, targetBasePath)); + } + return outputDir; + } + + private Path getFallbackStagingDir(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + Path stagingDir = Files.createTempDirectory(targetBasePath, "virt-v2v-tmp-").normalize(); + if (!stagingDir.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved fallback staging path %s is outside %s", + stagingDir, targetBasePath)); + } + return stagingDir; + } + + private Path getTargetBasePath(VmwareCbtCutoverCommand cmd) { + Path firstDiskPath = Path.of(cmd.getDisks().get(0).getTargetPath()).normalize(); + Path targetBasePath = firstDiskPath.getParent(); + if (targetBasePath == null) { + throw new IllegalArgumentException("unable to determine VMware CBT target disk directory"); + } + return targetBasePath; + } + + private List getFallbackDiskResults(VmwareCbtCutoverCommand cmd, Path fallbackOutputDir, + long durationSeconds) throws Exception { + List outputFiles; + try (Stream stream = Files.list(fallbackOutputDir)) { + outputFiles = stream + .filter(Files::isRegularFile) + .filter(path -> !StringUtils.endsWithIgnoreCase(path.getFileName().toString(), ".xml")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .collect(Collectors.toList()); + } + if (outputFiles.size() < cmd.getDisks().size()) { + throw new IllegalArgumentException(String.format("virt-v2v fallback produced %s disk file(s), expected at least %s", + outputFiles.size(), cmd.getDisks().size())); + } + List results = new ArrayList<>(); + for (int index = 0; index < cmd.getDisks().size(); index++) { + VmwareCbtDiskTO disk = cmd.getDisks().get(index); + results.add(new VmwareCbtDiskSyncResultTO(disk.getDiskId(), outputFiles.get(index).toString(), + disk.getChangeId(), disk.getSnapshotMor(), 0, durationSeconds, true, + "Final virt-v2v fallback conversion completed")); + } + return results; + } + + protected List relocateFinalizedDiskResultsToStorageRoot(String migrationUuid, + List diskResults) throws Exception { + if (CollectionUtils.isEmpty(diskResults)) { + return diskResults; + } + + List relocatedResults = new ArrayList<>(); + Set usedDestinations = new HashSet<>(); + List completedMoves = new ArrayList<>(); + try { + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + if (diskResult == null || !diskResult.getResult() || StringUtils.isBlank(diskResult.getTargetPath())) { + relocatedResults.add(diskResult); + continue; + } + + Path sourcePath = Path.of(diskResult.getTargetPath()).normalize(); + Path storageRoot = getStorageRootForCbtPath(migrationUuid, sourcePath); + if (storageRoot == null) { + relocatedResults.add(diskResult); + continue; + } + + Path destinationPath = getAvailableRootDiskPath(storageRoot, usedDestinations); + logger.info("Relocating finalized VMware CBT disk {} to primary storage root {}", sourcePath, destinationPath); + Files.move(sourcePath, destinationPath); + completedMoves.add(new FinalizedDiskMove(sourcePath, destinationPath)); + relocatedResults.add(new VmwareCbtDiskSyncResultTO(diskResult.getDiskId(), destinationPath.toString(), + diskResult.getChangeId(), diskResult.getSnapshotMor(), diskResult.getChangedBytes(), + diskResult.getDurationSeconds(), diskResult.getResult(), diskResult.getDetails())); + } + } catch (Exception e) { + rollbackFinalizedDiskMoves(completedMoves); + throw e; + } + return relocatedResults; + } + + private Path getStorageRootForCbtPath(String migrationUuid, Path targetPath) { + if (StringUtils.isBlank(migrationUuid) || targetPath == null) { + return null; + } + String normalizedPath = targetPath.normalize().toString().replace('\\', '/'); + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalizedPath.indexOf(marker); + if (markerIndex < 0) { + return null; + } + String storageRoot = StringUtils.trimToNull(normalizedPath.substring(0, markerIndex)); + return storageRoot == null ? null : Path.of(storageRoot).normalize(); + } + + private Path getAvailableRootDiskPath(Path storageRoot, Set usedDestinations) { + while (true) { + Path candidate = storageRoot.resolve(UUID.randomUUID().toString()).normalize(); + if (!Files.exists(candidate) && usedDestinations.add(candidate)) { + return candidate; + } + } + } + + private boolean containsDiskResultUnderDirectory(List diskResults, Path directory) { + if (CollectionUtils.isEmpty(diskResults) || directory == null) { + return false; + } + Path normalizedDirectory = directory.normalize(); + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + if (diskResult != null && StringUtils.isNotBlank(diskResult.getTargetPath()) && + Path.of(diskResult.getTargetPath()).normalize().startsWith(normalizedDirectory)) { + return true; + } + } + return false; + } + + private void rollbackFinalizedDiskMoves(List completedMoves) { + if (CollectionUtils.isEmpty(completedMoves)) { + return; + } + for (int index = completedMoves.size() - 1; index >= 0; index--) { + FinalizedDiskMove move = completedMoves.get(index); + try { + if (Files.exists(move.destinationPath) && !Files.exists(move.sourcePath)) { + Files.move(move.destinationPath, move.sourcePath); + } + } catch (Exception rollbackException) { + logger.warn("Failed to roll back finalized VMware CBT disk relocation from {} to {}", + move.destinationPath, move.sourcePath, rollbackException); + } + } + } + + private static class FinalizedDiskMove { + private final Path sourcePath; + private final Path destinationPath; + + private FinalizedDiskMove(Path sourcePath, Path destinationPath) { + this.sourcePath = sourcePath; + this.destinationPath = destinationPath; + } + } + + private void deleteFallbackSourceDisks(VmwareCbtCutoverCommand cmd) { + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + deleteTempFile(Path.of(disk.getTargetPath())); + } + } + + private void deleteEmptyCbtMigrationDirectories(VmwareCbtCutoverCommand cmd) { + Set migrationDirectories = new HashSet<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + Path sourcePath = Path.of(disk.getTargetPath()).normalize(); + Path storageRoot = getStorageRootForCbtPath(cmd.getMigrationUuid(), sourcePath); + if (storageRoot == null) { + continue; + } + Path migrationDirectory = storageRoot.resolve("cloudstack-cbt").resolve(cmd.getMigrationUuid()).normalize(); + if (sourcePath.startsWith(migrationDirectory)) { + migrationDirectories.add(migrationDirectory); + } + } + + for (Path migrationDirectory : migrationDirectories) { + if (!Files.isDirectory(migrationDirectory) || Files.isSymbolicLink(migrationDirectory)) { + continue; + } + try { + Files.deleteIfExists(migrationDirectory); + } catch (DirectoryNotEmptyException e) { + logger.warn("Preserving non-empty VMware CBT migration directory {} after successful cutover", + migrationDirectory); + } catch (Exception e) { + logger.warn("Unable to remove VMware CBT migration directory {} after successful cutover: {}", + migrationDirectory, e.getMessage()); + } + } + } + + 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..1386c18ff443 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java @@ -0,0 +1,506 @@ +// 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.utils.exception.CloudRuntimeException; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.utils.qemu.QemuImg; + +@ResourceWrapper(handles = VmwareCbtPrepareCommand.class) +public class LibvirtVmwareCbtPrepareCommandWrapper extends CommandWrapper { + + private static final String DEFAULT_CBT_DISK_BASE_PATH = "/var/lib/libvirt/images/cloudstack-cbt"; + private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + + @Override + public Answer execute(VmwareCbtPrepareCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot prepare VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + try { + validatePrepareCommand(cmd); + String vddkLibDir = resolveVddkSetting(cmd.getVddkLibDir(), serverResource.getVddkLibDir()); + if (StringUtils.isBlank(vddkLibDir)) { + String msg = String.format("Cannot prepare VMware CBT migration %s because no VDDK library directory is configured or detected.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String vddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(sourceInstance.getVcenterHost(), getTimeout(cmd), sourceInstance.getInstanceName()); + } + if (StringUtils.isBlank(vddkThumbprint)) { + String msg = String.format("Cannot prepare VMware CBT migration %s because the vCenter SSL thumbprint could not be determined.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + Path targetBasePath = null; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } else { + targetBasePath = getTargetBasePath(cmd, targetPool); + Files.createDirectories(targetBasePath); + } + + String passwordFilePath = writePasswordFile(cmd); + try { + List diskResults = new ArrayList<>(); + long startTime = System.currentTimeMillis(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + diskResults.add(copyDiskFromVmwareSnapshot(cmd, disk, targetPool, targetBasePath, passwordFilePath, + vddkLibDir, vddkThumbprint, serverResource)); + } + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + String msg = String.format("Initial VDDK full sync for VMware CBT migration %s completed for %s disk(s).", + cmd.getMigrationUuid(), diskResults.size()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), 0, + 0, 0, durationSeconds, false, diskResults); + } finally { + Files.deleteIfExists(Path.of(passwordFilePath)); + } + } catch (Exception e) { + String msg = String.format("Cannot prepare VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + } + + private void validatePrepareCommand(VmwareCbtPrepareCommand cmd) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + if (sourceInstance == null) { + throw new IllegalArgumentException("source VMware instance information is missing"); + } + if (StringUtils.isAnyBlank(sourceInstance.getVcenterHost(), sourceInstance.getVcenterUsername(), + sourceInstance.getVcenterPassword(), sourceInstance.getInstanceName())) { + throw new IllegalArgumentException("source vCenter host, username, password and VM name are required"); + } + if (StringUtils.isBlank(sourceInstance.getVmwareMoref())) { + throw new IllegalArgumentException("source VMware VM managed object reference is missing"); + } + if (StringUtils.isBlank(cmd.getBaselineSnapshotMor())) { + throw new IllegalArgumentException("baseline VMware snapshot managed object reference is missing"); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + throw new IllegalArgumentException("no source disks were provided for initial full sync"); + } + } + + private long getTimeout(VmwareCbtPrepareCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtPrepareCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target writing", + cmd.getMigrationUuid())); + } + } + + private void validateBlockDeviceTargetPool(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target writing", + cmd.getMigrationUuid())); + } + } + + private Path getTargetBasePath(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool != null) { + return Path.of(targetPool.getLocalPath(), "cloudstack-cbt", cmd.getMigrationUuid()).normalize(); + } + return Path.of(DEFAULT_CBT_DISK_BASE_PATH, cmd.getMigrationUuid()).normalize(); + } + + private String writePasswordFile(VmwareCbtPrepareCommand cmd) throws Exception { + Path passwordFile = Files.createTempFile(String.format("vmware-cbt-%s-", sanitizeFileName(cmd.getMigrationUuid())), + ".pass"); + Files.writeString(passwordFile, cmd.getSourceInstance().getVcenterPassword()); + setPosixFilePermissionsIfSupported(passwordFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + return passwordFile.toString(); + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX permissions are not supported for temporary VMware CBT password file {}", path); + } + } + + private VmwareCbtDiskSyncResultTO copyDiskFromVmwareSnapshot(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + KVMStoragePool targetPool, Path targetBasePath, + String passwordFilePath, + String vddkLibDir, String vddkThumbprint, + LibvirtComputingResource serverResource) throws Exception { + validateDisk(disk); + long startTime = System.currentTimeMillis(); + String targetFormat = getTargetFormat(cmd, disk); + String targetPath; + String qemuTargetPath; + boolean preCreatedTarget = false; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + targetPath = getRbdTargetImageName(cmd, disk); + deleteRbdTargetIfExists(targetPool, targetPath); + qemuTargetPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, targetPath)); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + targetPath = getBlockDeviceTargetName(disk); + qemuTargetPath = createBlockDeviceTarget(targetPool, targetPath, disk); + preCreatedTarget = true; + } else { + Path fileTargetPath = getTargetPath(disk, targetBasePath, targetFormat); + if (!fileTargetPath.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved target path %s is outside %s", fileTargetPath, targetBasePath)); + } + Files.deleteIfExists(fileTargetPath); + targetPath = fileTargetPath.toString(); + qemuTargetPath = targetPath; + } + + // Prefer multi-connection nbdcopy over a single-stream qemu-img convert when available: + // for a pre-created local raw block-device target (Linstor) nbdcopy writes it directly; + // for an RBD target it copies over a localhost qemu-nbd bridge (see the builder). qcow2 + // file targets keep qemu-img convert. + boolean nbdcopyAvailable = serverResource.hostSupportsNbdcopy(); + // Only skip zero-writes into a pre-created block-device target when the backend + // guarantees a fresh volume reads back as zeros (thin providers); on backends that do + // not (e.g. LVM-thick) the zeros must be written or stale data leaks into the disk. + boolean targetIsZero = preCreatedTarget && targetPool.isVolumeZeroInitialized(targetPath); + String command = buildNbdkitFullCopyCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()), + serverResource.getVddkNbdCompression(), qemuTargetPath, targetFormat, preCreatedTarget, nbdcopyAvailable, targetIsZero); + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT initial full sync disk %s", cmd.getMigrationUuid(), disk.getDiskId())); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String details = String.format("qemu-img conversion from VMware VDDK snapshot failed for source disk %s with exit code %s", + disk.getDiskId(), commandResult.getExitValue()); + throw new IllegalStateException(commandResult.appendLastCommandOutput(details)); + } + + return new VmwareCbtDiskSyncResultTO(disk.getDiskId(), targetPath, disk.getChangeId(), + cmd.getBaselineSnapshotMor(), disk.getCapacityBytes(), durationSeconds, true, + "Initial VDDK full sync completed"); + } + + private String getTargetFormat(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + private String getBlockDeviceTargetName(VmwareCbtDiskTO disk) { + String targetPath = StringUtils.trimToNull(disk.getTargetPath()); + if (targetPath == null) { + throw new IllegalArgumentException(String.format("no block device target name was assigned for source disk %s", disk.getDiskId())); + } + String normalizedTargetPath = targetPath.replace('\\', '/'); + return StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + } + + private String createBlockDeviceTarget(KVMStoragePool targetPool, String targetName, VmwareCbtDiskTO disk) { + if (disk.getCapacityBytes() <= 0) { + throw new IllegalArgumentException(String.format("source disk %s does not have a valid capacity, " + + "which is required to pre-create the block device target volume", disk.getDiskId())); + } + deleteRbdTargetIfExists(targetPool, targetName); + KVMPhysicalDisk targetDisk = targetPool.createPhysicalDisk(targetName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, disk.getCapacityBytes(), null); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s", targetName)); + } + return devicePath; + } + + private String getRbdTargetImageName(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk) { + String targetPath = StringUtils.trimToNull(disk.getTargetPath()); + if (targetPath != null) { + String normalizedTargetPath = targetPath.replace('\\', '/'); + return StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + } + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getDiskId()), "."); + return String.format("cloudstack-cbt-%s-%s-%s", sanitizeFileName(cmd.getMigrationUuid()), + sanitizeFileName(disk.getDiskId()), sanitizeFileName(sourceName)); + } + + private String getRbdImagePath(KVMStoragePool targetPool, String imageName) { + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private void deleteRbdTargetIfExists(KVMStoragePool targetPool, String imageName) { + try { + targetPool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (RuntimeException e) { + logger.debug("No existing RBD image {} was removed before VMware CBT initial sync, or removal was not required: {}", + imageName, e.getMessage()); + } + } + + private Path getTargetPath(VmwareCbtDiskTO disk, Path targetBasePath, String targetFormat) { + String targetPathValue = StringUtils.trimToNull(disk.getTargetPath()); + Path targetPath = targetPathValue == null ? + targetBasePath.resolve(getTargetDiskFileName(disk, targetFormat)) : + Path.of(targetPathValue); + if (!targetPath.isAbsolute()) { + targetPath = targetBasePath.resolve(targetPath); + } + return targetPath.normalize(); + } + + private void validateDisk(VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("source disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("source disk ID is missing"); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new IllegalArgumentException(String.format("source disk path is missing for disk %s", disk.getDiskId())); + } + } + + private String buildNbdkitFullCopyCommand(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String vddkNbdCompression, String targetPath, + String targetFormat, boolean preCreatedTarget, boolean nbdcopyAvailable, + boolean targetIsZero) { + 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); + } + if (StringUtils.isNotBlank(vddkNbdCompression)) { + appendPluginParameter(nbdkit, "compression", vddkNbdCompression); + } + + boolean rbdTarget = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW; + + // RBD target with nbdcopy available: copy over a localhost qemu-nbd bridge (nbdcopy + // needs both ends as NBD, and qemu-img create - which nbdcopy cannot do - pre-creates + // the raw image). Multi-connection nbdcopy is typically faster than the single-stream + // qemu-img convert. The fresh raw image reads as zeros, so --destination-is-zero keeps + // it sparse. + if (rbdTarget && nbdcopyAvailable && disk.getCapacityBytes() > 0) { + return buildRbdNbdcopyBridgeCommand(nbdkit, targetPath, disk.getCapacityBytes()); + } + + // For a pre-created block-device target, skip writing zero blocks + // (--destination-is-zero / --target-is-zero) only when the backend guarantees the + // fresh volume reads back as zeros (targetIsZero, i.e. thin providers). On backends + // that do not (e.g. LVM-thick) the zeros must be written or stale data from a + // previously deleted volume would leak into the unwritten regions. + boolean useNbdcopy = preCreatedTarget && nbdcopyAvailable; + String nbdcopyZeroFlag = (preCreatedTarget && targetIsZero) ? "--destination-is-zero " : ""; + String qemuImgFlags = preCreatedTarget ? ("-n " + (targetIsZero ? "--target-is-zero " : "")) : ""; + String runCommand = useNbdcopy + ? String.format("nbdcopy %s\"$uri\" %s", nbdcopyZeroFlag, shellQuote(targetPath)) + : String.format("qemu-img convert %s-f raw -O %s \"$uri\" %s", + qemuImgFlags, shellQuote(targetFormat), shellQuote(targetPath)); + return nbdkit.append("--run ").append(shellQuote(runCommand)).toString(); + } + + /** + * Wraps the nbdkit VDDK source with a localhost qemu-nbd bridge over the RBD target so + * nbdcopy can copy NBD-to-NBD. The returned string is a self-contained bash script (run + * via bash -c) that pre-creates the raw RBD image, starts qemu-nbd, runs the nbdcopy, and + * tears the bridge down on exit. Package-private for unit testing. + */ + String buildRbdNbdcopyBridgeCommand(StringBuilder nbdkitSource, String qemuRbdTarget, long capacityBytes) { + int port = allocateLocalhostPort(); + nbdkitSource.append("--run ").append(shellQuote( + String.format("nbdcopy --destination-is-zero \"$uri\" nbd://localhost:%d", port))); + StringBuilder script = new StringBuilder(); + script.append("set -euo pipefail; "); + script.append("__pidf=$(mktemp); "); + script.append("cleanup() { set +e; if [[ -s \"$__pidf\" ]]; then __p=$(cat \"$__pidf\"); "); + script.append("kill \"$__p\" >/dev/null 2>&1 || true; "); + script.append("for __a in {1..20}; do kill -0 \"$__p\" >/dev/null 2>&1 || break; sleep 0.1; done; fi; "); + script.append("rm -f \"$__pidf\"; }; trap cleanup EXIT; "); + script.append("qemu-img create -f raw ").append(shellQuote(qemuRbdTarget)).append(" ").append(capacityBytes).append("; "); + script.append("qemu-nbd --fork --persistent --shared=8 --format=raw --bind=127.0.0.1 --port=").append(port) + .append(" --pid-file=\"$__pidf\" ").append(shellQuote(qemuRbdTarget)).append("; "); + script.append(nbdkitSource); + return script.toString(); + } + + private int allocateLocalhostPort() { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + socket.setReuseAddress(false); + return socket.getLocalPort(); + } catch (java.io.IOException e) { + throw new CloudRuntimeException("Unable to allocate a localhost port for the RBD qemu-nbd bridge", e); + } + } + + private void appendPluginParameter(StringBuilder command, String key, String value) { + command.append(key).append("=").append(shellQuote(value)).append(" "); + } + + private String getTargetDiskFileName(VmwareCbtDiskTO disk, String targetFormat) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getDiskId()), "."); + return String.format("%s-%s.%s", sanitizeFileName(disk.getDiskId()), sanitizeFileName(sourceName), + sanitizeFileName(targetFormat)); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private String getMorefValue(String moref) { + String value = StringUtils.trimToNull(moref); + if (value == null) { + return null; + } + return value.contains(":") ? StringUtils.substringAfter(value, ":") : value; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String resolveVddkSetting(String commandValue, String agentValue) { + return StringUtils.defaultIfBlank(StringUtils.trimToNull(commandValue), StringUtils.trimToNull(agentValue)); + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + private String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + if (StringUtils.isBlank(vcenterHost)) { + return null; + } + String endpoint = String.format("%s:443", vcenterHost); + String command = String.format("openssl s_client -connect %s /dev/null | " + + "openssl x509 -fingerprint -sha1 -noout", shellQuote(endpoint)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser); + + String output = parser.getLines(); + if (script.getExitValue() != 0) { + logger.error("({}) Failed to fetch vCenter thumbprint for {}", sourceVmName, vcenterHost); + return null; + } + + return extractSha1Fingerprint(output); + } + + private String extractSha1Fingerprint(String output) { + String parsedOutput = StringUtils.trimToEmpty(output); + if (StringUtils.isBlank(parsedOutput)) { + return null; + } + + for (String line : parsedOutput.split("\\R")) { + String trimmedLine = StringUtils.trimToEmpty(line); + if (StringUtils.isBlank(trimmedLine)) { + continue; + } + + Matcher matcher = SHA1_FINGERPRINT_PATTERN.matcher(trimmedLine); + if (matcher.find()) { + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + if (trimmedLine.matches("(?i)[0-9a-f]{2}(:[0-9a-f]{2})+")) { + return trimmedLine.toUpperCase(Locale.ROOT); + } + } + return null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java new file mode 100644 index 000000000000..23f76b1619fb --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java @@ -0,0 +1,218 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtRbdProbeCommand; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtRbdProbeCommand.class) +public class LibvirtVmwareCbtRbdProbeCommandWrapper extends CommandWrapper { + + static final String PROBE_IMAGE_PREFIX = "cloudstack-cbt-probe-"; + static final String BLOCK_DEVICE_PROBE_PREFIX = "cbt-probe-"; + private static final Pattern PROBE_IMAGE_NAME_PATTERN = Pattern.compile("^" + PROBE_IMAGE_PREFIX + + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private static final Pattern BLOCK_DEVICE_PROBE_NAME_PATTERN = Pattern.compile("^" + BLOCK_DEVICE_PROBE_PREFIX + + "[0-9a-fA-F]{8}$"); + private static final long PROBE_SIZE_BYTES = 4L * 1024L * 1024L; + + @Override + public Answer execute(VmwareCbtRbdProbeCommand cmd, LibvirtComputingResource serverResource) { + String probeImageName = StringUtils.trimToNull(cmd.getProbeImageName()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return executeBlockDeviceProbe(cmd, probeImageName, serverResource); + } + try { + validateCommand(cmd, probeImageName); + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, probeImageName)); + + Exception probeFailure = null; + try { + executeProbeCommand(buildCreateCommand(qemuRbdPath), getTimeout(cmd), "create", targetPool, probeImageName); + executeProbeCommand(buildWriteReadCommand(qemuRbdPath), getTimeout(cmd), "write/read", targetPool, probeImageName); + } catch (Exception e) { + probeFailure = e; + throw e; + } finally { + try { + cleanupProbeImage(targetPool, probeImageName); + } catch (RuntimeException e) { + if (probeFailure == null) { + throw e; + } + logger.warn("Unable to clean up RBD probe image {} after a failed probe: {}", probeImageName, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + } + + String message = String.format("Selected KVM host can create, write, read and delete temporary RBD image %s in storage pool %s.", + probeImageName, cmd.getDestinationStoragePoolUuid()); + logger.info(message); + return new Answer(cmd, true, message); + } catch (Exception e) { + String message = String.format("Cannot verify VMware CBT access to selected RBD primary storage pool %s on host %s: %s", + cmd.getDestinationStoragePoolUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.warn(message, e); + return new Answer(cmd, false, message); + } + } + + private Answer executeBlockDeviceProbe(VmwareCbtRbdProbeCommand cmd, String probeVolumeName, + LibvirtComputingResource serverResource) { + try { + validateBlockDeviceCommand(cmd, probeVolumeName); + KVMStoragePool targetPool = getBlockDeviceTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + + Exception probeFailure = null; + try { + KVMPhysicalDisk probeDisk = targetPool.createPhysicalDisk(probeVolumeName, + org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, PROBE_SIZE_BYTES, null); + String devicePath = probeDisk == null ? null : probeDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for probe volume %s", probeVolumeName)); + } + executeProbeCommand(buildWriteReadCommand(devicePath), getTimeout(cmd), "write/read", targetPool, probeVolumeName); + } catch (Exception e) { + probeFailure = e; + throw e; + } finally { + try { + cleanupProbeImage(targetPool, probeVolumeName); + } catch (RuntimeException e) { + if (probeFailure == null) { + throw e; + } + logger.warn("Unable to clean up block device probe volume {} after a failed probe: {}", probeVolumeName, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + } + + String message = String.format("Selected KVM host can create, write, read and delete temporary block device volume %s in storage pool %s.", + probeVolumeName, cmd.getDestinationStoragePoolUuid()); + logger.info(message); + return new Answer(cmd, true, message); + } catch (Exception e) { + String message = String.format("Cannot verify VMware CBT access to selected block device primary storage pool %s on host %s: %s", + cmd.getDestinationStoragePoolUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.warn(message, e); + return new Answer(cmd, false, message); + } + } + + private void validateBlockDeviceCommand(VmwareCbtRbdProbeCommand cmd, String probeVolumeName) { + if (cmd.getDestinationStoragePoolType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException("VMware CBT block device probe requires a Linstor destination storage pool"); + } + if (StringUtils.isBlank(cmd.getDestinationStoragePoolUuid())) { + throw new IllegalArgumentException("destination storage pool UUID is required"); + } + if (StringUtils.isBlank(probeVolumeName) || !BLOCK_DEVICE_PROBE_NAME_PATTERN.matcher(probeVolumeName).matches()) { + throw new IllegalArgumentException(String.format("probe volume name must match %s<8 hex characters>", BLOCK_DEVICE_PROBE_PREFIX)); + } + } + + private KVMStoragePool getBlockDeviceTargetStoragePool(VmwareCbtRbdProbeCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = storagePoolMgr.getStoragePool(cmd.getDestinationStoragePoolType(), cmd.getDestinationStoragePoolUuid()); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("selected Linstor storage pool %s is not available on this KVM host", + cmd.getDestinationStoragePoolUuid())); + } + return targetPool; + } + + private void validateCommand(VmwareCbtRbdProbeCommand cmd, String probeImageName) { + if (cmd.getDestinationStoragePoolType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException("VMware CBT RBD probe requires an RBD destination storage pool"); + } + if (StringUtils.isBlank(cmd.getDestinationStoragePoolUuid())) { + throw new IllegalArgumentException("destination RBD storage pool UUID is required"); + } + if (StringUtils.isBlank(probeImageName) || !PROBE_IMAGE_NAME_PATTERN.matcher(probeImageName).matches()) { + throw new IllegalArgumentException(String.format("probe image name must match %s", PROBE_IMAGE_PREFIX)); + } + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtRbdProbeCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = storagePoolMgr.getStoragePool(cmd.getDestinationStoragePoolType(), cmd.getDestinationStoragePoolUuid()); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("selected RBD storage pool %s is not available on this KVM host", + cmd.getDestinationStoragePoolUuid())); + } + return targetPool; + } + + private String getRbdImagePath(KVMStoragePool targetPool, String imageName) { + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private String buildCreateCommand(String qemuRbdPath) { + return String.format("qemu-img create -f raw %s %s", shellQuote(qemuRbdPath), PROBE_SIZE_BYTES); + } + + private String buildWriteReadCommand(String qemuRbdPath) { + return String.format("qemu-io -f raw -c %s -c %s %s", + shellQuote("write -P 0x5a 0 4k"), shellQuote("read -P 0x5a 0 4k"), shellQuote(qemuRbdPath)); + } + + protected void executeProbeCommand(String command, long timeout, String operation, KVMStoragePool targetPool, + String probeImageName) { + int exitValue = runBashCommand(command, timeout); + if (exitValue != 0) { + throw new IllegalStateException(String.format("qemu RBD probe %s failed with exit code %s. Verify CloudStack RBD primary-storage configuration, Ceph monitor connectivity, qemu RBD block-driver support, librados/librbd client libraries, Java RADOS/RBD bindings and Ceph authentication for pool %s.", + operation, exitValue, targetPool.getUuid())); + } + } + + protected int runBashCommand(String command, long timeout) { + int boundedTimeout = (int)Math.min(Integer.MAX_VALUE, Math.max(0L, timeout)); + return Script.runSimpleBashScriptForExitValue(command, boundedTimeout, true); + } + + protected void cleanupProbeImage(KVMStoragePool targetPool, String probeImageName) { + try { + targetPool.deletePhysicalDisk(probeImageName, Storage.ImageFormat.RAW); + } catch (RuntimeException e) { + throw new IllegalStateException(String.format("RBD probe cleanup failed for temporary image %s. Verify Java RADOS/RBD bindings and Ceph authentication for pool %s. Manual cleanup may be required.", + probeImageName, targetPool.getUuid()), e); + } + } + + private long getTimeout(VmwareCbtRbdProbeCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java new file mode 100644 index 000000000000..ed9a6a992c40 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java @@ -0,0 +1,489 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtSyncCommand; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtSyncCommand.class) +public class LibvirtVmwareCbtSyncCommandWrapper extends CommandWrapper { + + private static final long DEFAULT_MAX_COPY_CHUNK_BYTES = 256L * 1024L * 1024L; + private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + + @Override + public Answer execute(VmwareCbtSyncCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot synchronize VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, 0, false, null); + } + + long startTime = System.currentTimeMillis(); + List changedBlocks = cmd.getChangedBlocks(); + if (changedBlocks == null || changedBlocks.isEmpty()) { + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + String msg = String.format("VMware CBT cycle %s for migration %s completed with no changed blocks.", + cmd.getCycleNumber(), cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, durationSeconds, true, null); + } + + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(cmd.getDisks(), changedBlocks); + if (!syncPlan.isValid()) { + String msg = String.format("Cannot synchronize VMware CBT cycle %s for migration %s: %s", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getValidationError()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, 0, false, null); + } + + try { + validateSyncCommand(cmd); + String vddkLibDir = resolveVddkSetting(cmd.getVddkLibDir(), serverResource.getVddkLibDir()); + if (StringUtils.isBlank(vddkLibDir)) { + String msg = String.format("Cannot synchronize VMware CBT migration %s because no VDDK library directory is configured or detected.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String vddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(sourceInstance.getVcenterHost(), getTimeout(cmd), + sourceInstance.getInstanceName()); + } + if (StringUtils.isBlank(vddkThumbprint)) { + String msg = String.format("Cannot synchronize VMware CBT migration %s because the vCenter SSL thumbprint could not be determined.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } + + String passwordFilePath = writePasswordFile(cmd); + try { + List diskResults = new ArrayList<>(); + for (VmwareCbtSyncPlan.DiskPlan diskPlan : syncPlan.getDiskPlans()) { + diskResults.add(syncDiskChangedBlocks(cmd, diskPlan, targetPool, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()))); + } + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + long dirtyRate = getDirtyRateBytesPerSecond(syncPlan.getChangedBytes(), durationSeconds); + String msg = String.format("VMware CBT cycle %s for migration %s copied %s changed block range(s), " + + "coalesced into %s copy range(s) across %s disk(s), totaling %s bytes.", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getChangedRangeCount(), + syncPlan.getCopyRangeCount(), syncPlan.getDiskPlans().size(), syncPlan.getChangedBytes()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), dirtyRate, durationSeconds, false, diskResults); + } finally { + Files.deleteIfExists(Path.of(passwordFilePath)); + } + } catch (Exception e) { + String msg = String.format("Cannot synchronize VMware CBT cycle %s for migration %s: %s", + cmd.getCycleNumber(), cmd.getMigrationUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + } + + private void validateSyncCommand(VmwareCbtSyncCommand cmd) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + if (sourceInstance == null) { + throw new IllegalArgumentException("source VMware instance information is missing"); + } + if (StringUtils.isAnyBlank(sourceInstance.getVcenterHost(), sourceInstance.getVcenterUsername(), + sourceInstance.getVcenterPassword(), sourceInstance.getInstanceName())) { + throw new IllegalArgumentException("source vCenter host, username, password and VM name are required"); + } + if (StringUtils.isBlank(sourceInstance.getVmwareMoref())) { + throw new IllegalArgumentException("source VMware VM managed object reference is missing"); + } + if (StringUtils.isBlank(cmd.getSnapshotMor())) { + throw new IllegalArgumentException("VMware snapshot managed object reference is missing"); + } + } + + private VmwareCbtDiskSyncResultTO syncDiskChangedBlocks(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool, String passwordFilePath, String vddkLibDir, + String vddkThumbprint, String vddkTransports) throws Exception { + VmwareCbtDiskTO disk = diskPlan.getDisk(); + validateDisk(cmd, disk); + long startTime = System.currentTimeMillis(); + String scriptPath = writeDiskSyncScript(cmd, diskPlan, targetPool); + try { + String command = buildNbdkitDeltaSyncCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + vddkTransports, scriptPath); + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT delta sync disk %s", cmd.getMigrationUuid(), disk.getDiskId())); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String details = String.format("changed-block copy from VMware VDDK snapshot failed for source disk %s with exit code %s", + disk.getDiskId(), commandResult.getExitValue()); + throw new IllegalStateException(commandResult.appendLastCommandOutput(details)); + } + return new VmwareCbtDiskSyncResultTO(disk.getDiskId(), disk.getTargetPath(), disk.getChangeId(), + cmd.getSnapshotMor(), diskPlan.getChangedBytes(), durationSeconds, true, + String.format("Copied %s changed bytes in %s range(s)", diskPlan.getChangedBytes(), + diskPlan.getChangedBlocks().size())); + } finally { + Files.deleteIfExists(Path.of(scriptPath)); + } + } + + private void validateDisk(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("source disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("source disk ID is missing"); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new IllegalArgumentException(String.format("source disk path is missing for disk %s", disk.getDiskId())); + } + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new IllegalArgumentException(String.format("target disk path is missing for disk %s", disk.getDiskId())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw"), "raw")) { + throw new IllegalArgumentException(String.format("only raw target disks are supported for VMware CBT delta sync to %s; disk %s uses %s", + cmd.getTargetStorageType(), disk.getDiskId(), disk.getTargetFormat())); + } + return; + } + if (!Files.isRegularFile(Path.of(disk.getTargetPath()))) { + throw new IllegalArgumentException(String.format("target disk path %s for disk %s does not exist or is not a regular file", + disk.getTargetPath(), disk.getDiskId())); + } + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"), "qcow2")) { + throw new IllegalArgumentException(String.format("only qcow2 target disks are supported for VMware CBT delta sync; disk %s uses %s", + disk.getDiskId(), disk.getTargetFormat())); + } + } + + private String writePasswordFile(VmwareCbtSyncCommand cmd) throws Exception { + Path passwordFile = Files.createTempFile(String.format("vmware-cbt-%s-", sanitizeFileName(cmd.getMigrationUuid())), + ".pass"); + Files.writeString(passwordFile, cmd.getSourceInstance().getVcenterPassword()); + setPosixFilePermissionsIfSupported(passwordFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + return passwordFile.toString(); + } + + protected String writeDiskSyncScript(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool) throws Exception { + VmwareCbtDiskTO disk = diskPlan.getDisk(); + boolean directBlockTarget = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE; + Path scriptPath = Files.createTempFile(String.format("vmware-cbt-%s-%s-", sanitizeFileName(cmd.getMigrationUuid()), + sanitizeFileName(disk.getDiskId())), ".sh"); + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("target_path=").append(shellQuote(getQemuTargetPath(cmd, disk, targetPool))).append("\n"); + script.append("target_format=").append(shellQuote(getTargetFormat(cmd, disk))).append("\n"); + script.append("get_nbd_socket_path() {\n"); + script.append(" local socket_path=\"${uri#*socket=}\"\n"); + script.append(" socket_path=\"${socket_path%%&*}\"\n"); + script.append(" if [[ \"$socket_path\" == \"$uri\" || -z \"$socket_path\" ]]; then\n"); + script.append(" echo \"Cannot parse nbdkit unix socket from uri: $uri\" >&2\n"); + script.append(" exit 1\n"); + script.append(" fi\n"); + script.append(" echo \"$socket_path\"\n"); + script.append("}\n"); + script.append("nbd_socket_path=$(get_nbd_socket_path)\n"); + if (directBlockTarget) { + // Local raw block device (e.g. Linstor/DRBD): copy each changed extent straight from the + // nbdkit source window into the same target window in a single qemu-img convert, avoiding + // the temporary-file round-trip the qemu-io path needs. -S 0 disables zero/sparse skipping + // so blocks the source cleared to zero are actually overwritten in the target (delta + // correctness); -n keeps the pre-created device. + script.append("copy_range() {\n"); + script.append(" local range_start=\"$1\"\n"); + script.append(" local range_length=\"$2\"\n"); + script.append(" local source_opts=\"driver=raw,offset=$range_start,size=$range_length,file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path\"\n"); + script.append(" local target_opts=\"driver=raw,offset=$range_start,size=$range_length,file.driver=host_device,file.filename=$target_path\"\n"); + script.append(" qemu-img convert -n -S 0 --image-opts --target-image-opts \"$source_opts\" \"$target_opts\"\n"); + script.append("}\n"); + } else { + script.append("max_chunk_bytes=").append(DEFAULT_MAX_COPY_CHUNK_BYTES).append("\n"); + script.append("tmp_dir=$(mktemp -d /var/tmp/cloudstack-cbt-").append(sanitizeFileName(cmd.getMigrationUuid())) + .append("-").append(sanitizeFileName(disk.getDiskId())).append("-XXXXXX)\n"); + script.append("cleanup() {\n"); + script.append(" rm -rf \"$tmp_dir\"\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + script.append("copy_range() {\n"); + script.append(" local range_start=\"$1\"\n"); + script.append(" local range_length=\"$2\"\n"); + script.append(" local current_start=\"$range_start\"\n"); + script.append(" local remaining=\"$range_length\"\n"); + script.append(" while (( remaining > 0 )); do\n"); + script.append(" local chunk_length=\"$remaining\"\n"); + script.append(" if (( chunk_length > max_chunk_bytes )); then\n"); + script.append(" chunk_length=\"$max_chunk_bytes\"\n"); + script.append(" fi\n"); + script.append(" local chunk_file=\"$tmp_dir/range-${current_start}-${chunk_length}.raw\"\n"); + script.append(" local source_opts=\"driver=raw,offset=$current_start,size=$chunk_length,file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path\"\n"); + script.append(" qemu-img convert --image-opts -O raw \"$source_opts\" \"$chunk_file\" >/dev/null\n"); + script.append(" qemu-io -f \"$target_format\" -c \"write -s $chunk_file $current_start $chunk_length\" \"$target_path\"\n"); + script.append(" rm -f \"$chunk_file\"\n"); + script.append(" remaining=$((remaining - chunk_length))\n"); + script.append(" current_start=$((current_start + chunk_length))\n"); + script.append(" done\n"); + script.append("}\n"); + } + for (VmwareCbtChangedBlockRangeTO changedBlock : diskPlan.getChangedBlocks()) { + script.append("copy_range ").append(changedBlock.getStartOffset()).append(" ") + .append(changedBlock.getLength()).append("\n"); + } + Files.writeString(scriptPath, script.toString()); + setPosixFilePermissionsIfSupported(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + return scriptPath.toString(); + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtSyncCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtSyncCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target writing", + cmd.getMigrationUuid())); + } + } + + private void validateBlockDeviceTargetPool(VmwareCbtSyncCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target writing", + cmd.getMigrationUuid())); + } + } + + private String getQemuTargetPath(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, KVMStoragePool targetPool) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + return KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, disk.getTargetPath())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return getBlockDevicePath(targetPool, disk); + } + return disk.getTargetPath(); + } + + private String getBlockDevicePath(KVMStoragePool targetPool, VmwareCbtDiskTO disk) { + String normalizedTargetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? + StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + KVMPhysicalDisk targetDisk = targetPool.getPhysicalDisk(volumeName); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s of disk %s", + volumeName, disk.getDiskId())); + } + return devicePath; + } + + private String getTargetFormat(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + private String getRbdImagePath(KVMStoragePool targetPool, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX file permissions are not supported for {}", path); + } + } + + private String buildNbdkitDeltaSyncCommand(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String scriptPath) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String sourceVmMoref = getMorefValue(sourceInstance.getVmwareMoref()); + String snapshotMoref = getMorefValue(cmd.getSnapshotMor()); + StringBuilder nbdkit = new StringBuilder("nbdkit -r -U - vddk "); + appendPluginParameter(nbdkit, "file", disk.getSourceDiskPath()); + appendPluginParameter(nbdkit, "server", sourceInstance.getVcenterHost()); + appendPluginParameter(nbdkit, "user", sourceInstance.getVcenterUsername()); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + nbdkit.append("vm=moref=").append(shellQuote(sourceVmMoref)).append(" "); + appendPluginParameter(nbdkit, "snapshot", snapshotMoref); + appendPluginParameter(nbdkit, "libdir", vddkLibDir); + appendPluginParameter(nbdkit, "thumbprint", vddkThumbprint); + if (StringUtils.isNotBlank(vddkTransports)) { + appendPluginParameter(nbdkit, "transports", vddkTransports); + } + String runCommand = String.format("export uri; bash %s", shellQuote(scriptPath)); + return nbdkit.append("--run ").append(shellQuote(runCommand)).toString(); + } + + private void appendPluginParameter(StringBuilder command, String key, String value) { + command.append(key).append("=").append(shellQuote(value)).append(" "); + } + + private long getDirtyRateBytesPerSecond(long changedBytes, long durationSeconds) { + if (durationSeconds <= 0) { + return changedBytes; + } + return changedBytes / durationSeconds; + } + + private long getTimeout(VmwareCbtSyncCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private String getMorefValue(String moref) { + String value = StringUtils.trimToNull(moref); + if (value == null) { + return null; + } + return value.contains(":") ? StringUtils.substringAfter(value, ":") : value; + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String resolveVddkSetting(String commandValue, String agentValue) { + return StringUtils.defaultIfBlank(StringUtils.trimToNull(commandValue), StringUtils.trimToNull(agentValue)); + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + protected String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + if (StringUtils.isBlank(vcenterHost)) { + return null; + } + String endpoint = String.format("%s:443", vcenterHost); + String command = String.format("openssl s_client -connect %s /dev/null | " + + "openssl x509 -fingerprint -sha1 -noout", shellQuote(endpoint)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser); + + String output = parser.getLines(); + if (script.getExitValue() != 0) { + logger.error("({}) Failed to fetch vCenter thumbprint for {}", sourceVmName, vcenterHost); + return null; + } + + return extractSha1Fingerprint(output); + } + + private String extractSha1Fingerprint(String output) { + String parsedOutput = StringUtils.trimToEmpty(output); + if (StringUtils.isBlank(parsedOutput)) { + return null; + } + + for (String line : parsedOutput.split("\\R")) { + String trimmedLine = StringUtils.trimToEmpty(line); + if (StringUtils.isBlank(trimmedLine)) { + continue; + } + + Matcher matcher = SHA1_FINGERPRINT_PATTERN.matcher(trimmedLine); + if (matcher.find()) { + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + if (trimmedLine.matches("(?i)[0-9a-f]{2}(:[0-9a-f]{2})+")) { + return trimmedLine.toUpperCase(Locale.ROOT); + } + } + return null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java new file mode 100644 index 000000000000..2825e9c52a19 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; + +import com.cloud.utils.script.OutputInterpreter; + +class VmwareCbtCommandOutputLogger extends OutputInterpreter { + + private static final Pattern PASSWORD_ARGUMENT_PATTERN = Pattern.compile("(?i)(password=)([^\\s]+)"); + + private final Logger logger; + private final String logPrefix; + private String lastOutputLine; + private String lastImportantOutputLine; + + VmwareCbtCommandOutputLogger(Logger logger, String logPrefix) { + this.logger = logger; + this.logPrefix = logPrefix; + } + + @Override + public boolean drain() { + return true; + } + + @Override + public String interpret(BufferedReader reader) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + String safeLine = sanitize(line); + logger.info(StringUtils.isNotBlank(logPrefix) ? String.format("%s %s", logPrefix, safeLine) : safeLine); + captureOutputLine(safeLine); + } + return null; + } + + String getLastRelevantOutputLine() { + return StringUtils.defaultIfBlank(lastImportantOutputLine, lastOutputLine); + } + + private void captureOutputLine(String line) { + String trimmedLine = StringUtils.trimToNull(line); + if (trimmedLine == null) { + return; + } + lastOutputLine = trimmedLine; + if (isImportantOutputLine(trimmedLine)) { + lastImportantOutputLine = trimmedLine; + } + } + + private boolean isImportantOutputLine(String line) { + String lowerCaseLine = StringUtils.lowerCase(line); + return StringUtils.containsAny(lowerCaseLine, + "error", "failed", "unable", "cannot", "permission denied", "no such file", + "not found", "file is empty", "traceback", "exception"); + } + + private String sanitize(String line) { + return PASSWORD_ARGUMENT_PATTERN.matcher(StringUtils.defaultString(line)).replaceAll("$1******"); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java new file mode 100644 index 000000000000..184c6f3111f9 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import org.apache.commons.lang3.StringUtils; + +class VmwareCbtCommandResult { + + private final int exitValue; + private final String lastCommandOutput; + + VmwareCbtCommandResult(int exitValue, String lastCommandOutput) { + this.exitValue = exitValue; + this.lastCommandOutput = lastCommandOutput; + } + + int getExitValue() { + return exitValue; + } + + String appendLastCommandOutput(String details) { + if (StringUtils.isBlank(lastCommandOutput) || StringUtils.contains(details, lastCommandOutput)) { + return details; + } + return String.format("%s Last command output: %s", details, lastCommandOutput); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java new file mode 100644 index 000000000000..458b5e6bba60 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java @@ -0,0 +1,273 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; + +class VmwareCbtSyncPlan { + + private final boolean valid; + private final String validationError; + private final List diskPlans; + private final int changedRangeCount; + private final int copyRangeCount; + private final long changedBytes; + + private VmwareCbtSyncPlan(boolean valid, String validationError, List diskPlans, + int changedRangeCount, int copyRangeCount, long changedBytes) { + this.valid = valid; + this.validationError = validationError; + this.diskPlans = diskPlans; + this.changedRangeCount = changedRangeCount; + this.copyRangeCount = copyRangeCount; + this.changedBytes = changedBytes; + } + + static VmwareCbtSyncPlan create(List disks, List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return new VmwareCbtSyncPlan(true, null, Collections.emptyList(), 0, 0, 0); + } + + Map diskPlansById = getDiskPlansById(disks); + int changedRangeCount = 0; + + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + ValidationResult validationResult = validateChangedBlock(changedBlock, diskPlansById); + if (!validationResult.valid) { + return invalid(validationResult.error); + } + + DiskPlanBuilder diskPlan = diskPlansById.get(changedBlock.getDiskId()); + long rangeEnd = changedBlock.getStartOffset() + changedBlock.getLength(); + if (rangeEnd < changedBlock.getStartOffset()) { + return invalid(String.format("Changed block range for disk %s overflows the virtual disk address space.", + changedBlock.getDiskId())); + } + if (diskPlan.disk.getCapacityBytes() > 0 && rangeEnd > diskPlan.disk.getCapacityBytes()) { + return invalid(String.format("Changed block range for disk %s exceeds disk capacity.", changedBlock.getDiskId())); + } + + diskPlan.addChangedBlock(changedBlock); + changedRangeCount++; + } + + List diskPlans = getPopulatedDiskPlans(diskPlansById); + long changedBytes = 0; + int copyRangeCount = 0; + for (DiskPlan diskPlan : diskPlans) { + changedBytes += diskPlan.getChangedBytes(); + copyRangeCount += diskPlan.getChangedBlocks().size(); + } + + return new VmwareCbtSyncPlan(true, null, diskPlans, changedRangeCount, copyRangeCount, changedBytes); + } + + private static Map getDiskPlansById(List disks) { + Map diskPlansById = new LinkedHashMap<>(); + if (CollectionUtils.isEmpty(disks)) { + return diskPlansById; + } + + for (VmwareCbtDiskTO disk : disks) { + if (disk != null && StringUtils.isNotBlank(disk.getDiskId())) { + diskPlansById.put(disk.getDiskId(), new DiskPlanBuilder(disk)); + } + } + return diskPlansById; + } + + private static ValidationResult validateChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock, + Map diskPlansById) { + if (changedBlock == null) { + return ValidationResult.invalid("Changed block range cannot be null."); + } + if (StringUtils.isBlank(changedBlock.getDiskId())) { + return ValidationResult.invalid("Changed block range is missing a disk id."); + } + + DiskPlanBuilder diskPlan = diskPlansById.get(changedBlock.getDiskId()); + if (diskPlan == null) { + return ValidationResult.invalid(String.format("Changed block range references unknown disk %s.", changedBlock.getDiskId())); + } + if (StringUtils.isBlank(diskPlan.disk.getTargetPath())) { + return ValidationResult.invalid(String.format("Changed block range references disk %s, but no target path is known. " + + "The initial full sync must create and persist the KVM target disk before CBT delta sync can run.", + changedBlock.getDiskId())); + } + if (changedBlock.getStartOffset() < 0) { + return ValidationResult.invalid(String.format("Changed block range for disk %s has a negative start offset.", + changedBlock.getDiskId())); + } + if (changedBlock.getLength() <= 0) { + return ValidationResult.invalid(String.format("Changed block range for disk %s has a non-positive length.", + changedBlock.getDiskId())); + } + + return ValidationResult.valid(); + } + + private static List getPopulatedDiskPlans(Map diskPlansById) { + List diskPlans = new ArrayList<>(); + for (DiskPlanBuilder diskPlan : diskPlansById.values()) { + if (CollectionUtils.isNotEmpty(diskPlan.changedBlocks)) { + diskPlans.add(diskPlan.build()); + } + } + return diskPlans; + } + + private static VmwareCbtSyncPlan invalid(String validationError) { + return new VmwareCbtSyncPlan(false, validationError, Collections.emptyList(), 0, 0, 0); + } + + boolean isValid() { + return valid; + } + + String getValidationError() { + return validationError; + } + + List getDiskPlans() { + return diskPlans; + } + + int getChangedRangeCount() { + return changedRangeCount; + } + + int getCopyRangeCount() { + return copyRangeCount; + } + + long getChangedBytes() { + return changedBytes; + } + + static class DiskPlan { + + private final VmwareCbtDiskTO disk; + private final List changedBlocks; + private final long changedBytes; + + DiskPlan(VmwareCbtDiskTO disk, List changedBlocks, long changedBytes) { + this.disk = disk; + this.changedBlocks = Collections.unmodifiableList(changedBlocks); + this.changedBytes = changedBytes; + } + + VmwareCbtDiskTO getDisk() { + return disk; + } + + List getChangedBlocks() { + return changedBlocks; + } + + long getChangedBytes() { + return changedBytes; + } + } + + private static class DiskPlanBuilder { + + private final VmwareCbtDiskTO disk; + private final List changedBlocks = new ArrayList<>(); + + DiskPlanBuilder(VmwareCbtDiskTO disk) { + this.disk = disk; + } + + void addChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock) { + changedBlocks.add(changedBlock); + } + + DiskPlan build() { + List copyBlocks = coalesceChangedBlocks(changedBlocks); + return new DiskPlan(disk, copyBlocks, sumChangedBytes(copyBlocks)); + } + + private List coalesceChangedBlocks(List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return Collections.emptyList(); + } + + List sortedBlocks = new ArrayList<>(changedBlocks); + sortedBlocks.sort(Comparator.comparingLong(VmwareCbtChangedBlockRangeTO::getStartOffset) + .thenComparingLong(VmwareCbtChangedBlockRangeTO::getLength)); + + List copyBlocks = new ArrayList<>(); + String diskId = disk.getDiskId(); + long currentStart = sortedBlocks.get(0).getStartOffset(); + long currentEnd = currentStart + sortedBlocks.get(0).getLength(); + + for (int i = 1; i < sortedBlocks.size(); i++) { + VmwareCbtChangedBlockRangeTO changedBlock = sortedBlocks.get(i); + long blockStart = changedBlock.getStartOffset(); + long blockEnd = blockStart + changedBlock.getLength(); + if (blockStart <= currentEnd) { + currentEnd = Math.max(currentEnd, blockEnd); + continue; + } + + copyBlocks.add(new VmwareCbtChangedBlockRangeTO(diskId, currentStart, currentEnd - currentStart)); + currentStart = blockStart; + currentEnd = blockEnd; + } + copyBlocks.add(new VmwareCbtChangedBlockRangeTO(diskId, currentStart, currentEnd - currentStart)); + return copyBlocks; + } + + private long sumChangedBytes(List changedBlocks) { + long changedBytes = 0; + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + changedBytes += changedBlock.getLength(); + } + return changedBytes; + } + } + + private static class ValidationResult { + + private final boolean valid; + private final String error; + + private ValidationResult(boolean valid, String error) { + this.valid = valid; + this.error = error; + } + + static ValidationResult valid() { + return new ValidationResult(true, null); + } + + static ValidationResult invalid(String error) { + return new ValidationResult(false, error); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java index a8207cec3fa6..a47c6c127c1a 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java @@ -74,6 +74,43 @@ default Long getUsedIops() { boolean refresh(); + /** + * Whether a freshly created volume on this pool is guaranteed to read back as + * all zeros. Callers that skip writing zero blocks into a pre-created target + * (e.g. qemu-img convert --target-is-zero / nbdcopy --destination-is-zero) must + * only do so when this returns true; block backends that do not zero-initialize + * new volumes (e.g. LVM-thick) return false so those callers write the zeros and + * do not leak stale data from previously deleted volumes into unwritten regions. + * Defaults to false so any backend that has not opted in is treated as unsafe. + */ + default boolean isVolumeZeroInitialized(String volumeName) { + return false; + } + + /** + * Returned by {@link #getVolumeInUseNode(String)} when the backend could not tell us + * whether the volume is in use. Callers must fail closed and treat it as "in use": + * adopting a clustered volume that is actually attached elsewhere would give it two writers. + */ + String IN_USE_NODE_UNKNOWN = ""; + + /** + * For clustered block storage whose host-local file lock is not authoritative + * (e.g. Linstor/DRBD), returns the node name on which the given volume is currently + * in use — attached to a running VM anywhere in the cluster — or null if not in use. + * Adoption/import callers treat a non-null result as "locked". + * + * Implementations that cannot establish the cluster-wide state (controller unreachable, + * empty or malformed response) must return {@link #IN_USE_NODE_UNKNOWN} rather than null, + * so the ambiguity is not silently read as "free". + * + * Defaults to null so the host-local qemu-img lock remains the sole in-use signal for + * other pool types. + */ + default String getVolumeInUseNode(String volumeName) { + return null; + } + boolean isExternalSnapshot(); String getLocalPath(); 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/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java index 49f531ed7c11..655d0e19b173 100644 --- a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java +++ b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java @@ -134,28 +134,44 @@ public enum BitmapOperation { */ public QemuImg(final long timeout, final boolean skipZeroIfSupported, final boolean noCache) throws LibvirtException { if (skipZeroIfSupported) { - final Script s = new Script(_qemuImgPath, timeout); - s.add("--help"); - - final OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); - final String result = s.execute(parser); - - // Older Qemu returns output in result due to --help reporting error status - if (result != null) { - if (result.contains(TARGET_ZERO_FLAG)) { - this.skipZero = true; - } - } else { - if (parser.getLines().contains(TARGET_ZERO_FLAG)) { - this.skipZero = true; - } - } + this.skipZero = isTargetZeroFlagSupported(timeout); } this.timeout = timeout; this.noCache = noCache; this.version = LibvirtConnection.getConnection().getVersion(); } + /** + * Detects support for the convert --target-is-zero flag. The flag used to be + * discovered via "qemu-img --help", but newer qemu (observed with 10.x on EL9) + * no longer lists it in the help text while still supporting it - which + * silently disabled zero-skipping and fully allocated thin block-device + * targets. Probe the option directly instead: a recognized option fails with + * "--target-is-zero requires use of -n flag", an unknown one with + * "unrecognized option". + */ + private boolean isTargetZeroFlagSupported(final long timeout) { + // Fast path for older qemu that documents the flag in --help. + final Script help = new Script(_qemuImgPath, timeout); + help.add("--help"); + final OutputInterpreter.AllLinesParser helpParser = new OutputInterpreter.AllLinesParser(); + final String helpResult = help.execute(helpParser); + final String helpOutput = (helpResult != null ? helpResult : "") + StringUtils.defaultString(helpParser.getLines()); + if (helpOutput.contains(TARGET_ZERO_FLAG)) { + return true; + } + + final Script probe = new Script(_qemuImgPath, timeout); + probe.add("convert"); + probe.add(TARGET_ZERO_FLAG); + final OutputInterpreter.AllLinesParser probeParser = new OutputInterpreter.AllLinesParser(); + final String probeResult = probe.execute(probeParser); + final String probeOutput = (probeResult != null ? probeResult : "") + StringUtils.defaultString(probeParser.getLines()); + return probeOutput.contains(TARGET_ZERO_FLAG) + && !probeOutput.contains("unrecognized option") + && !probeOutput.contains("invalid option"); + } + /** * Creates a QemuImg object. * 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 008d9444fe5f..33fb377bc90e 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 @@ -7244,6 +7244,38 @@ public void parseCpuFeaturesTestReturnListOfCpuFeaturesAndIgnoreMultipleWhitespa Assert.assertEquals("hle", cpuFeatures.get(3)); } + @Test + public void parseVddkLibraryVersionFromDumpPluginOutputReturnsLibraryVersion() { + String output = "vddk_default_libdir=/usr/lib64/vmware-vix-disklib\n" + + "vddk_library_version=8\n" + + "vddk_transport_modes=file:san:hotadd:nbdssl:nbd\n"; + + Assert.assertEquals("8", libvirtComputingResourceSpy.parseVddkLibraryVersionFromDumpPluginOutput(output)); + } + + @Test + public void parseVddkLibraryVersionFromDumpPluginOutputIgnoresPluginVersionWithoutLoadedLibrary() { + String output = "nbdkit 1.24.0\n" + + "vddk 1.24.0\n" + + "vddk_default_libdir=/usr/lib64/vmware-vix-disklib\n"; + + Assert.assertNull(libvirtComputingResourceSpy.parseVddkLibraryVersionFromDumpPluginOutput(output)); + } + + @Test + public void isNbdkitVddkPluginUsableRequiresLoadedVddkLibrary() throws IOException { + Path vddkDir = Files.createTempDirectory("vddk-test"); + Files.createDirectories(vddkDir.resolve("lib64")); + Files.createFile(vddkDir.resolve("lib64/libvixDiskLib.so.8")); + Mockito.doReturn("vddk_library_version=8\n").when(libvirtComputingResourceSpy).runNbdkitVddkDumpPlugin(vddkDir.toString()); + + Assert.assertTrue(libvirtComputingResourceSpy.isNbdkitVddkPluginUsable(vddkDir.toString())); + + Mockito.doReturn("nbdkit: error: libssl.so.3: cannot open shared object file\n").when(libvirtComputingResourceSpy).runNbdkitVddkDumpPlugin(vddkDir.toString()); + + Assert.assertFalse(libvirtComputingResourceSpy.isNbdkitVddkPluginUsable(vddkDir.toString())); + } + @Test public void defineDiskForDefaultPoolTypeSkipsForceDiskController() { Map details = new HashMap<>(); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java index 74090723331a..f918d85b2a06 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java @@ -78,6 +78,57 @@ public void testCheckInstanceCommand_vddkSuccess() { assertTrue(answer.getResult()); } + @Test + public void testCheckInstanceCommand_vddkDirectRbdSuccess() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkRbdDirectImportSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertTrue(answer.getResult()); + } + + @Test + public void testCheckInstanceCommand_vddkDirectRbdFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkRbdDirectImportSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(StringUtils.isNotBlank(answer.getDetails())); + } + + @Test + public void testCheckInstanceCommand_vddkInPlaceFinalizationSuccess() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkInPlaceFinalizationSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVirtV2vInPlace()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertTrue(answer.getResult()); + } + + @Test + public void testCheckInstanceCommand_vddkInPlaceFinalizationFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkInPlaceFinalizationSupport()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVirtV2vInPlace()).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(StringUtils.isNotBlank(answer.getDetails())); + } + @Test public void testCheckInstanceCommand_vddkFailure() { Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); @@ -89,4 +140,18 @@ public void testCheckInstanceCommand_vddkFailure() { assertFalse(answer.getResult()); assertTrue(StringUtils.isNotBlank(answer.getDetails())); } + + @Test + public void testCheckInstanceCommand_windowsGuestConversionFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(checkConvertInstanceCommandMock.getCheckWindowsGuestConversionSupport()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsWindowsGuestConversion()).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("virtio-win")); + } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java new file mode 100644 index 000000000000..78442f8a6cf6 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java @@ -0,0 +1,242 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CheckVolumeAnswer; +import com.cloud.agent.api.CheckVolumeCommand; +import com.cloud.agent.api.to.StorageFilerTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; +import org.apache.cloudstack.storage.volume.VolumeOnStorageTO; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.HashMap; +import java.util.Map; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtCheckVolumeCommandWrapperTest { + + @Mock + LibvirtComputingResource libvirtComputingResource; + @Mock + KVMStoragePoolManager storagePoolMgr; + @Mock + KVMStoragePool storagePool; + @Mock + StorageFilerTO storageFilerTO; + @Mock + KVMPhysicalDisk disk; + + @Spy + LibvirtCheckVolumeCommandWrapper wrapper = new LibvirtCheckVolumeCommandWrapper(); + + MockedConstruction qemuImg; + + private final String poolUuid = "pool-uuid"; + private final String srcFile = "rbd-volume-uuid"; + private final long virtualSize = 21474836480L; // 20 GiB + + private Map qemuInfo; + + @Before + public void setUp() { + qemuInfo = new HashMap<>(); + qemuInfo.put(QemuImg.FILE_FORMAT, "raw"); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolMgr); + } + + @After + public void tearDown() { + if (qemuImg != null) { + qemuImg.close(); + } + } + + private void mockRbdPool() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + Mockito.when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.RBD, poolUuid)).thenReturn(storagePool); + Mockito.when(storagePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storagePool.getPhysicalDisk(srcFile)).thenReturn(disk); + Mockito.when(disk.getPath()).thenReturn(srcFile); + Mockito.when(disk.getFormat()).thenReturn(QemuImg.PhysicalDiskFormat.RAW); + // values consumed by KVMPhysicalDisk.RBDStringBuilder when building the RBD URI + Mockito.when(storagePool.getSourceHost()).thenReturn("10.0.0.10"); + Mockito.when(storagePool.getSourcePort()).thenReturn(6789); + Mockito.when(storagePool.getAuthUserName()).thenReturn(null); + Mockito.when(storagePool.getDetails()).thenReturn(null); + } + + private void mockLinstorPool() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.Linstor); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + Mockito.when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.Linstor, poolUuid)).thenReturn(storagePool); + Mockito.when(storagePool.getType()).thenReturn(Storage.StoragePoolType.Linstor); + Mockito.when(storagePool.getPhysicalDisk(srcFile)).thenReturn(disk); + // Linstor resolves the resource to a local /dev/drbd block device path. + Mockito.when(disk.getPath()).thenReturn("/dev/drbd/by-res/" + srcFile + "/0"); + Mockito.when(disk.getFormat()).thenReturn(QemuImg.PhysicalDiskFormat.RAW); + } + + private CheckVolumeCommand buildCommand() { + CheckVolumeCommand command = new CheckVolumeCommand(); + command.setSrcFile(srcFile); + command.setStorageFilerTO(storageFilerTO); + return command; + } + + @Test + public void testRbdVolumeReturnsSuccessWithVirtualSizeAndDetails() { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue(answer.getResult()); + CheckVolumeAnswer checkVolumeAnswer = (CheckVolumeAnswer) answer; + Assert.assertEquals(virtualSize, checkVolumeAnswer.getSize()); + Map details = checkVolumeAnswer.getVolumeDetails(); + Assert.assertNotNull(details); + Assert.assertEquals("raw", details.get(VolumeOnStorageTO.Detail.FILE_FORMAT)); + Assert.assertEquals("false", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeInspectedThroughRbdUri() throws Exception { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + wrapper.execute(buildCommand(), libvirtComputingResource); + + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(QemuImgFile.class); + Mockito.verify(qemuImg.constructed().get(0), Mockito.atLeastOnce()) + .info(fileCaptor.capture(), Mockito.anyBoolean()); + Assert.assertTrue("qemu-img should be pointed at the RBD URI", + fileCaptor.getValue().getFileName().startsWith("rbd:" + srcFile)); + } + + @Test + public void testLinstorVolumeReturnsSuccessInspectedViaDevicePath() throws Exception { + mockLinstorPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue("Linstor raw volume should validate (not rejected as non-qcow2)", answer.getResult()); + Assert.assertEquals(virtualSize, ((CheckVolumeAnswer) answer).getSize()); + // A Linstor volume is a local block device: qemu-img must inspect the + // /dev/drbd device path directly, never build an rbd: URI. + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(QemuImgFile.class); + Mockito.verify(qemuImg.constructed().get(0), Mockito.atLeastOnce()) + .info(fileCaptor.capture(), Mockito.anyBoolean()); + String fileName = fileCaptor.getValue().getFileName(); + Assert.assertTrue("qemu-img should point at the /dev/drbd device path, got " + fileName, + fileName.startsWith("/dev/drbd/")); + Assert.assertFalse("Linstor must not build an rbd: URI", fileName.startsWith("rbd:")); + } + + @Test + public void testLinstorVolumeInUseOnAnotherNodeIsReportedLocked() throws Exception { + mockLinstorPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + Mockito.when(disk.getName()).thenReturn(srcFile); + // qemu-img info succeeds, so the host-local file lock is clear ... + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + // ... but Linstor reports the DRBD resource in use on another node. + Mockito.when(storagePool.getVolumeInUseNode(srcFile)).thenReturn("node-2"); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Map details = ((CheckVolumeAnswer) answer).getVolumeDetails(); + Assert.assertEquals("cluster-wide in-use must mark the volume locked so adoption refuses it", + "true", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeReportsLockedWhenInfoProbeFails() { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> { + // secure=true uses force-share and succeeds; secure=false is the lock probe + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.eq(true))).thenReturn(qemuInfo); + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.eq(false))).thenReturn(null); + }); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue(answer.getResult()); + Map details = ((CheckVolumeAnswer) answer).getVolumeDetails(); + Assert.assertEquals("true", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeWithoutInfoReturnsInvalid() { + mockRbdPool(); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(null)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertFalse(answer.getResult()); + CheckVolumeAnswer checkVolumeAnswer = (CheckVolumeAnswer) answer; + Assert.assertEquals(0, checkVolumeAnswer.getSize()); + Assert.assertNull(checkVolumeAnswer.getVolumeDetails()); + } + + @Test + public void testUnsupportedStoragePoolReturnsPlainAnswer() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.PowerFlex); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertFalse(answer instanceof CheckVolumeAnswer); + Assert.assertFalse(answer.getResult()); + Assert.assertEquals("Unsupported Storage Pool", answer.getDetails()); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java index c30fa2f49482..810d3028ea97 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 @@ -19,6 +19,7 @@ package com.cloud.hypervisor.kvm.resource.wrapper; import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.UUID; @@ -44,6 +45,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 +153,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); @@ -169,7 +191,7 @@ public void testExecuteConvertFailure() { Answer answer = convertInstanceCommandWrapper.execute(cmd, libvirtComputingResourceMock); Assert.assertFalse(answer.getResult()); Mockito.verify(convertInstanceCommandWrapper).performInstanceConversion(Mockito.anyString(), Mockito.anyString(), - Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), Mockito.anyBoolean(), Mockito.nullable(String.class), Mockito.any(LibvirtComputingResource.class)); + Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), Mockito.anyBoolean(), Mockito.nullable(String.class), Mockito.any(LibvirtComputingResource.class), Mockito.anyBoolean()); } } @@ -214,7 +236,7 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredLibguestfsBacken .thenReturn(true); boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( - remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "libvirt", null, null, 1000L, false, null, "tmp-uuid", "-ip"); + remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "libvirt", null, null, 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertTrue(result); Script scriptMock = ignored.constructed().get(0); @@ -249,7 +271,7 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredTransportsOrder( .thenReturn(true); boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( - remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", "nbd:nbdssl", null, 1000L, false, null, "tmp-uuid", "-ip"); + remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", "nbd:nbdssl", null, 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertTrue(result); Script scriptMock = ignored.constructed().get(0); @@ -276,7 +298,7 @@ public void testPerformInstanceConversionUsingVddkFailsWhenThumbprintUnavailable .thenReturn(true); boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( - remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", null, null, 1000L, false, null, "tmp-uuid", "-ip"); + remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", null, null, 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertFalse(result); } @@ -304,7 +326,7 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredThumbprintFromAg boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", null, - "AA:BB:CC:DD:EE", 1000L, false, null, "tmp-uuid", "-ip"); + "AA:BB:CC:DD:EE", 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertTrue(result); Script scriptMock = ignored.constructed().get(0); @@ -313,4 +335,95 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredThumbprintFromAg .getVcenterThumbprint(Mockito.anyString(), Mockito.anyLong(), Mockito.anyString()); } } + + @Test + public void testBuildDirectRbdNbdLibvirtXmlUsesLocalhostNbdSourcesWithoutSecrets() { + // virt-v2v's "-i libvirtxml" input runs without a libvirt connection: a native + // rbd network disk with a auth reference attaches zero drives there, + // so the finalization XML must use localhost qemu-nbd bridge endpoints. + String xml = convertInstanceCommandWrapper.buildDirectRbdNbdLibvirtXml("tmp-uuid", List.of(10809, 10810)); + + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertFalse(xml, xml.contains("protocol='rbd'")); + Assert.assertFalse(xml, xml.contains(" filesMock = Mockito.mockStatic(Files.class); + MockedConstruction + + diff --git a/ui/tests/unit/utils/vmware.spec.js b/ui/tests/unit/utils/vmware.spec.js new file mode 100644 index 000000000000..a576e9f2658c --- /dev/null +++ b/ui/tests/unit/utils/vmware.spec.js @@ -0,0 +1,51 @@ +// 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. + +import { formatKvmConversionHostName } from '@/utils/vmware' + +const host = { + name: 'qa1-vmwarelab-576a1b2a-mix-host2', + podname: 'Pod', + clustername: 'KVM-Cluster', + instanceconversionsupported: true, + details: { + 'host.virtv2v.version': '2.7.1rhel=9', + 'host.vddk.support': 'true', + 'host.vddk.version': '8', + 'host.vddk.blockcopy.support': 'true', + 'host.qemu.img.version': '7.0.0', + 'host.qemu.nbd.version': 'qemu-nbd 7.0.0 (qemu-kvm-7.0.0-13.el9)' + } +} + +describe('utils/vmware', () => { + describe('formatKvmConversionHostName()', () => { + it('shows a concise CBT conversion host label', () => { + expect(formatKvmConversionHostName(host, 'cbt', 'Yes', 'No')).toBe( + 'qa1-vmwarelab-576a1b2a-mix-host2 [KVM-Cluster] — virt-v2v=2.7.1rhel=9 / VDDK=8 / CBT=Yes') + }) + + it('does not expose low-level QEMU or Pod details in the selector', () => { + const label = formatKvmConversionHostName(host, 'cbt', 'Yes', 'No') + + expect(label).not.toContain('qemu-img') + expect(label).not.toContain('qemu-nbd') + expect(label).not.toContain('Pod') + expect(label).not.toContain('Supported') + }) + }) +}) diff --git a/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java b/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java index 89f6d7abd7d0..2a42d49898fd 100644 --- a/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java +++ b/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java @@ -55,6 +55,9 @@ import com.vmware.vim25.DistributedVirtualSwitchPortCriteria; import com.vmware.vim25.GuestInfo; import com.vmware.vim25.GuestNicInfo; +import com.vmware.vim25.GuestStackInfo; +import com.vmware.vim25.NetIpConfigInfoIpAddress; +import com.vmware.vim25.NetIpRouteConfigInfoIpRoute; import com.vmware.vim25.HostPortGroupSpec; import com.vmware.vim25.NasDatastoreInfo; import com.vmware.vim25.VMwareDVSPortSetting; @@ -806,6 +809,7 @@ public static UnmanagedInstanceTO getUnmanagedInstance(VmwareHypervisorHost hype instance.setName(vmMo.getVmName()); instance.setInternalCSName(vmMo.getInternalCSName()); instance.setPath((vmMo.getPath())); + instance.setVmwareMoref(vmMo.getMor().getValue()); instance.setCpuCoresPerSocket(vmMo.getCoresPerSocket()); instance.setOperatingSystemId(vmMo.getVmGuestInfo().getGuestId()); VirtualMachineConfigSummary configSummary = vmMo.getConfigSummary(); @@ -962,6 +966,9 @@ private static List getUnmanageInstanceNics(VmwareHyper List instanceNics = new ArrayList<>(); HashMap> guestNicMacIPAddressMap = new HashMap<>(); + HashMap> guestNicMacIpv4CidrMap = new HashMap<>(); + List guestDnsServers = new ArrayList<>(); + List guestDefaultGateways = new ArrayList<>(); try { GuestInfo guestInfo = vmMo.getGuestInfo(); if (guestInfo.getToolsStatus() == VirtualMachineToolsStatus.TOOLS_OK) { @@ -975,6 +982,42 @@ private static List getUnmanageInstanceNics(VmwareHyper } guestNicMacIPAddressMap.put(nicInfo.getMacAddress(), ipAddresses); } + // The plain ipAddress list carries no prefix; ipConfig has address plus + // prefix length, which static-IP preservation needs for the subnet. + if (nicInfo.getIpConfig() != null && CollectionUtils.isNotEmpty(nicInfo.getIpConfig().getIpAddress())) { + List cidrs = new ArrayList<>(); + for (NetIpConfigInfoIpAddress configuredAddress : nicInfo.getIpConfig().getIpAddress()) { + if (configuredAddress.getIpAddress() != null && NetUtils.isValidIp4(configuredAddress.getIpAddress())) { + cidrs.add(configuredAddress.getIpAddress() + "/" + configuredAddress.getPrefixLength()); + } + } + if (!cidrs.isEmpty()) { + guestNicMacIpv4CidrMap.put(nicInfo.getMacAddress(), cidrs); + } + } + } + // Default gateways and DNS servers are reported per IP stack rather than per + // NIC; each NIC gets the gateway inside one of its subnets further below. + if (CollectionUtils.isNotEmpty(guestInfo.getIpStack())) { + for (GuestStackInfo stackInfo : guestInfo.getIpStack()) { + if (stackInfo.getIpRouteConfig() != null && CollectionUtils.isNotEmpty(stackInfo.getIpRouteConfig().getIpRoute())) { + for (NetIpRouteConfigInfoIpRoute route : stackInfo.getIpRouteConfig().getIpRoute()) { + if ("0.0.0.0".equals(route.getNetwork()) && route.getPrefixLength() == 0 + && route.getGateway() != null && route.getGateway().getIpAddress() != null + && NetUtils.isValidIp4(route.getGateway().getIpAddress()) + && !guestDefaultGateways.contains(route.getGateway().getIpAddress())) { + guestDefaultGateways.add(route.getGateway().getIpAddress()); + } + } + } + if (stackInfo.getDnsConfig() != null && CollectionUtils.isNotEmpty(stackInfo.getDnsConfig().getIpAddress())) { + for (String dnsServer : stackInfo.getDnsConfig().getIpAddress()) { + if (NetUtils.isValidIp4(dnsServer) && !guestDnsServers.contains(dnsServer)) { + guestDnsServers.add(dnsServer); + } + } + } + } } } else { LOGGER.info(String.format("Unable to retrieve guest nics for instance: %s from VMware tools as tools status: %s", vmMo.getName(), guestInfo.getToolsStatus().toString())); @@ -1008,6 +1051,22 @@ private static List getUnmanageInstanceNics(VmwareHyper if (guestNicMacIPAddressMap.containsKey(instanceNic.getMacAddress())) { instanceNic.setIpAddress(guestNicMacIPAddressMap.get(instanceNic.getMacAddress())); } + if (guestNicMacIpv4CidrMap.containsKey(instanceNic.getMacAddress())) { + List nicCidrs = guestNicMacIpv4CidrMap.get(instanceNic.getMacAddress()); + instanceNic.setIpv4Cidrs(nicCidrs); + // Attach the default gateway that lives inside one of this NIC's subnets; + // gateways of other NICs' subnets do not apply to this NIC. + for (String gateway : guestDefaultGateways) { + boolean matchesNicSubnet = nicCidrs.stream().anyMatch(cidr -> NetUtils.isIpWithInCidrRange(gateway, cidr)); + if (matchesNicSubnet) { + instanceNic.setIpv4Gateway(gateway); + break; + } + } + if (!guestDnsServers.isEmpty()) { + instanceNic.setDnsServers(new ArrayList<>(guestDnsServers)); + } + } if (ethCardDevice.getSlotInfo() != null) { instanceNic.setPciSlot(ethCardDevice.getSlotInfo().toString()); } From a5578d6f53b51e2b67c7c537d3b7217c6d44eb3d Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Tue, 29 Sep 2026 18:33:40 +0200 Subject: [PATCH 2/5] Fix VMware CBT change ID checkpoint between delta cycles Signed-off-by: andrijapanicsb --- .../VmwareCbtMigrationServiceImpl.java | 37 +++++++--- .../VmwareCbtMigrationServiceImplTest.java | 70 +++++++++++++++++++ .../vm/VmwareCbtMigrationCheckpointTest.java | 52 ++++++++++++++ 3 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImplTest.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCheckpointTest.java 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 index 601f3eb71ad6..0a164a2af6bb 100644 --- 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 @@ -188,8 +188,12 @@ public List queryChangedDiskAreas(String vcenter, Stri if (CollectionUtils.isEmpty(disks)) { return changedDisks; } + List snapshotDevices = context.getVimClient().getDynamicProperty(snapshot, + "config.hardware.device"); for (VmwareCbtDiskInfo disk : disks) { - changedDisks.add(queryChangedDiskAreas(context, lookup.vmMO, snapshot, disk)); + // DiskChangeInfo has no change ID; the next checkpoint is on this snapshot's disk backing. + String nextChangeId = getSnapshotDiskChangeId(snapshotDevices, disk); + changedDisks.add(queryChangedDiskAreas(context, lookup.vmMO, snapshot, disk, nextChangeId)); } return changedDisks; } catch (Exception e) { @@ -410,7 +414,8 @@ private UnmanagedInstanceTO.PowerState toUnmanagedPowerState(VirtualMachinePower } private VmwareCbtChangedDiskInfo queryChangedDiskAreas(VmwareContext context, VirtualMachineMO vmMO, - ManagedObjectReference snapshot, VmwareCbtDiskInfo disk) + ManagedObjectReference snapshot, VmwareCbtDiskInfo disk, + String nextChangeId) throws ReflectiveOperationException { if (disk.getSourceDiskDeviceKey() == null) { throw new CloudRuntimeException(String.format("VMware disk device key is missing for source disk %s", @@ -424,12 +429,8 @@ private VmwareCbtChangedDiskInfo queryChangedDiskAreas(VmwareContext context, Vi 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"); @@ -449,6 +450,25 @@ private VmwareCbtChangedDiskInfo queryChangedDiskAreas(VmwareContext context, Vi return new VmwareCbtChangedDiskInfo(disk.getSourceDiskId(), nextChangeId, changedBlocks); } + String getSnapshotDiskChangeId(List snapshotDevices, VmwareCbtDiskInfo disk) { + if (disk.getSourceDiskDeviceKey() == null || snapshotDevices == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware CBT snapshot disk %s", + disk.getSourceDiskId())); + } + for (VirtualDevice device : snapshotDevices) { + if (device instanceof VirtualDisk && disk.getSourceDiskDeviceKey().equals(device.getKey())) { + String changeId = getBackingStringValue(((VirtualDisk) device).getBacking(), "getChangeId"); + if (StringUtils.isBlank(changeId)) { + throw new CloudRuntimeException(String.format("VMware CBT snapshot change ID is missing for source disk %s", + disk.getSourceDiskId())); + } + return changeId; + } + } + throw new CloudRuntimeException(String.format("Unable to find VMware CBT snapshot disk %s", + disk.getSourceDiskId())); + } + private Object invokeQueryChangedDiskAreas(VmwareContext context, VirtualMachineMO vmMO, ManagedObjectReference snapshot, VmwareCbtDiskInfo disk, long startOffset) throws ReflectiveOperationException { @@ -520,11 +540,6 @@ private String getBackingStringValue(Object backing, String methodName) { } } - 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) { diff --git a/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImplTest.java b/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImplTest.java new file mode 100644 index 000000000000..bc4e37212de3 --- /dev/null +++ b/plugins/hypervisors/vmware/src/test/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImplTest.java @@ -0,0 +1,70 @@ +// 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.util.Arrays; +import java.util.Collections; + +import org.apache.cloudstack.vm.VmwareCbtDiskInfo; +import org.junit.Assert; +import org.junit.Test; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.vmware.vim25.VirtualDevice; +import com.vmware.vim25.VirtualDisk; +import com.vmware.vim25.VirtualDiskFlatVer2BackingInfo; + +public class VmwareCbtMigrationServiceImplTest { + + private final VmwareCbtMigrationServiceImpl service = new VmwareCbtMigrationServiceImpl(); + + @Test + public void getSnapshotDiskChangeIdUsesMatchingDiskBacking() { + VirtualDisk otherDisk = createDisk(2001, "other-snapshot"); + VirtualDisk sourceDisk = createDisk(2000, "cycle-2-snapshot"); + VmwareCbtDiskInfo diskInfo = createDiskInfo(2000); + + String changeId = service.getSnapshotDiskChangeId(Arrays.asList(otherDisk, sourceDisk), diskInfo); + + Assert.assertEquals("cycle-2-snapshot", changeId); + Assert.assertNotEquals(diskInfo.getChangeId(), changeId); + } + + @Test(expected = CloudRuntimeException.class) + public void getSnapshotDiskChangeIdRejectsMissingDisk() { + service.getSnapshotDiskChangeId(Collections.singletonList(createDisk(2001, "other-snapshot")), + createDiskInfo(2000)); + } + + @Test(expected = CloudRuntimeException.class) + public void getSnapshotDiskChangeIdRejectsBlankChangeId() { + service.getSnapshotDiskChangeId(Collections.singletonList(createDisk(2000, null)), createDiskInfo(2000)); + } + + private VmwareCbtDiskInfo createDiskInfo(int key) { + return new VmwareCbtDiskInfo("disk-1", key, null, null, null, 1024L, "baseline"); + } + + private VirtualDisk createDisk(int key, String changeId) { + VirtualDisk disk = new VirtualDisk(); + disk.setKey(key); + VirtualDiskFlatVer2BackingInfo backing = new VirtualDiskFlatVer2BackingInfo(); + backing.setChangeId(changeId); + disk.setBacking(backing); + return disk; + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCheckpointTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCheckpointTest.java new file mode 100644 index 000000000000..ebfbc984e801 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCheckpointTest.java @@ -0,0 +1,52 @@ +// 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.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import com.cloud.vm.VmwareCbtMigrationVO; +import com.cloud.vm.dao.VmwareCbtMigrationDiskDao; + +public class VmwareCbtMigrationCheckpointTest { + + @Test + public void successfulCyclePersistsSnapshotChangeIdForNextCycle() { + VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO migration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(migration.getId()).thenReturn(42L); + VmwareCbtMigrationDiskVO disk = new VmwareCbtMigrationDiskVO(42L, "disk-1", 2000, + "[datastore] vm/disk.vmdk", "datastore", 1024L); + disk.setChangeId("baseline"); + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + Mockito.when(diskDao.listByMigrationId(42L)).thenReturn(Collections.singletonList(disk)); + ReflectionTestUtils.setField(manager, "vmwareCbtMigrationDiskDao", diskDao); + + VmwareCbtChangedDiskInfo changedDisk = new VmwareCbtChangedDiskInfo("disk-1", "cycle-1-snapshot", + Collections.emptyList()); + ReflectionTestUtils.invokeMethod(manager, "updateDiskChangeIds", migration, + Collections.singletonList(changedDisk)); + + Assert.assertEquals("cycle-1-snapshot", disk.getChangeId()); + Mockito.verify(diskDao).update(disk.getId(), disk); + } +} From 6fa7d30b47ea1741f2b42fa73eb6df664a361c4f Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Tue, 29 Sep 2026 20:05:18 +0200 Subject: [PATCH 3/5] Handle VMware CBT cutover failure paths Signed-off-by: andrijapanicsb --- .../vm/VmwareCbtMigrationManagerImpl.java | 20 ++++- .../VmwareCbtMigrationCutoverFailureTest.java | 78 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverFailureTest.java diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java index 01008414e2d9..ad08d778d7c8 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -697,7 +697,7 @@ public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigr cutoverCommand.setAllowNonInPlaceFinalization(isNonInPlaceFinalizationFallbackAllowed(storageTarget)); cutoverCommand.setWait(getVmwareCbtMigrationAgentCommandTimeout()); - VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, cutoverCommand, "cut over", migration.getUuid()); + VmwareCbtMigrationAnswer answer = sendFinalCutoverCommand(cbtHost, cutoverCommand, migration, source); if (!answer.getResult()) { markMigrationFailed(migration, "CBT cutover failed", sanitizeSensitiveMessage(answer.getDetails(), source)); return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); @@ -870,10 +870,12 @@ private boolean runFinalDeltaSync(VmwareSource source, VmwareCbtMigrationVO migr 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; + boolean cyclePersisted = false; try { + cycle = vmwareCbtMigrationCycleDao.persist(cycle); + cyclePersisted = true; snapshot = createDeltaSnapshot(source, migration, cycleNumber); cycle.setState(VmwareCbtMigrationCycle.State.QueryingChangedAreas); cycle.setSnapshotMor(snapshot.getSnapshotMor()); @@ -931,7 +933,9 @@ private boolean runFinalDeltaSync(VmwareSource source, VmwareCbtMigrationVO migr return true; } catch (RuntimeException e) { String error = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); - markCycleFailed(cycle, error); + if (cyclePersisted) { + markCycleFailed(cycle, error); + } markMigrationFailed(migration, "Final CBT delta synchronization failed", error); return false; } finally { @@ -2444,6 +2448,16 @@ private List getDiskTransferObjects(VmwareCbtMigrationVO migrat return diskTOs; } + private VmwareCbtMigrationAnswer sendFinalCutoverCommand(HostVO host, VmwareCbtCutoverCommand command, + VmwareCbtMigrationVO migration, VmwareSource source) { + try { + return sendVmwareCbtCommand(host, command, "cut over", migration.getUuid()); + } catch (RuntimeException e) { + String details = sanitizeSensitiveMessage(StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()), source); + return new VmwareCbtMigrationAnswer(command, false, details, migration.getUuid()); + } + } + private VmwareCbtMigrationAnswer sendVmwareCbtCommand(HostVO host, Command command, String action, String migrationUuid) { try { Answer answer = agentManager.send(host.getId(), command); diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverFailureTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverFailureTest.java new file mode 100644 index 000000000000..a13780b4b6f3 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverFailureTest.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 org.apache.cloudstack.vm; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.host.HostVO; +import com.cloud.vm.VmwareCbtMigrationCycleVO; +import com.cloud.vm.VmwareCbtMigrationVO; +import com.cloud.vm.dao.VmwareCbtMigrationCycleDao; +import com.cloud.vm.dao.VmwareCbtMigrationDao; + +public class VmwareCbtMigrationCutoverFailureTest { + + @Test + public void finalCutoverAgentExceptionUsesFailureAnswerPath() throws Exception { + VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO migration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(migration.getUuid()).thenReturn("migration-1"); + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getId()).thenReturn(7L); + VmwareCbtCutoverCommand command = Mockito.mock(VmwareCbtCutoverCommand.class); + AgentManager agentManager = Mockito.mock(AgentManager.class); + Mockito.when(agentManager.send(7L, command)).thenThrow(new IllegalStateException("agent disconnected")); + ReflectionTestUtils.setField(manager, "agentManager", agentManager); + + VmwareCbtMigrationAnswer answer = ReflectionTestUtils.invokeMethod(manager, + "sendFinalCutoverCommand", host, command, migration, null); + + Assert.assertNotNull(answer); + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("agent disconnected")); + } + + @Test + public void finalCycleInsertFailureDoesNotTryToUpdateMissingCycle() { + VmwareCbtMigrationManagerImpl manager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO migration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(migration.getId()).thenReturn(42L); + VmwareCbtMigrationCycleDao cycleDao = Mockito.mock(VmwareCbtMigrationCycleDao.class); + Mockito.when(cycleDao.persist(Mockito.any(VmwareCbtMigrationCycleVO.class))) + .thenThrow(new IllegalStateException("cycle insert failed")); + VmwareCbtMigrationDao migrationDao = Mockito.mock(VmwareCbtMigrationDao.class); + Mockito.when(migrationDao.updateIfNotTerminal(migration)).thenReturn(true); + ReflectionTestUtils.setField(manager, "vmwareCbtMigrationCycleDao", cycleDao); + ReflectionTestUtils.setField(manager, "vmwareCbtMigrationDao", migrationDao); + + Boolean result = ReflectionTestUtils.invokeMethod(manager, "runFinalDeltaSync", + null, migration, null, 3); + + Assert.assertNotNull(result); + Assert.assertFalse(result); + Mockito.verify(migration).setState(VmwareCbtMigration.State.Failed); + Mockito.verify(migration).setLastError("cycle insert failed"); + Mockito.verify(migrationDao).updateIfNotTerminal(migration); + Mockito.verify(cycleDao, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } +} From 2a3598ad6ae43f7551cae5aba92634526675b81d Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Thu, 1 Oct 2026 13:28:20 +0200 Subject: [PATCH 4/5] Fix importVM with soft-deleted dummy template Port the production fix from apache/cloudstack PR #14195, source commit 0cb24ba8967e14e9577dde7754eeb4a93e6e23e7 (merged into 4.22 as 273b2ea32bfa24396431b9bb0df055e0ac6c1c02). Reuse the supplied template and load its details without a findById lookup that filters removed records. Preserve template CPU-detail inheritance and explicit VM overrides. Add six regression tests for dummy and active templates, non-VO templates, CPU override precedence and missing templates. Co-authored-by: Abhisar Sinha <63767682+abh1sar@users.noreply.github.com> --- .../java/com/cloud/vm/UserVmManagerImpl.java | 11 +- .../cloud/vm/UserVmImportTemplateTest.java | 206 ++++++++++++++++++ 2 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 server/src/test/java/com/cloud/vm/UserVmImportTemplateTest.java diff --git a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java index 9bbbdc503508..85f3482512ee 100644 --- a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java +++ b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java @@ -9888,11 +9888,10 @@ public UserVm importVM(final DataCenter zone, final Host host, final VirtualMach } // Ensure template details are loaded so that commitUserVm can copy them into the VM's details map - VMTemplateVO vmTemplateVO = _templateDao.findById(template.getId()); - if (vmTemplateVO == null) { - throw new InvalidParameterValueException("Unable to find template with id " + template.getId() + " for virtual machine import"); + if (template instanceof VMTemplateVO) { + VMTemplateVO vmTemplateVO = (VMTemplateVO) template; + _templateDao.loadDetails(vmTemplateVO); } - _templateDao.loadDetails(vmTemplateVO); final long id = _vmDao.getNextInSequence(Long.class, "id"); String instanceName = StringUtils.isBlank(instanceNameInternal) ? @@ -9906,8 +9905,8 @@ public UserVm importVM(final DataCenter zone, final Host host, final VirtualMach final String uuidName = _uuidMgr.generateUuid(UserVm.class, null); final Host lastHost = powerState != VirtualMachine.PowerState.PowerOn ? host : null; - final boolean dynamicScalingEnabled = checkIfDynamicScalingCanBeEnabled(null, serviceOffering, vmTemplateVO, zone.getId()); - return commitUserVm(true, zone, host, lastHost, vmTemplateVO, hostName, displayName, owner, + final boolean dynamicScalingEnabled = checkIfDynamicScalingCanBeEnabled(null, serviceOffering, template, zone.getId()); + return commitUserVm(true, zone, host, lastHost, template, hostName, displayName, owner, null, null, userData, null, null, isDisplayVm, keyboard, accountId, userId, serviceOffering, template.getFormat().equals(ImageFormat.ISO), guestOsId, sshPublicKeys, networkNicMap, id, instanceName, uuidName, hypervisorType, customParameters, diff --git a/server/src/test/java/com/cloud/vm/UserVmImportTemplateTest.java b/server/src/test/java/com/cloud/vm/UserVmImportTemplateTest.java new file mode 100644 index 000000000000..ef545d31e593 --- /dev/null +++ b/server/src/test/java/com/cloud/vm/UserVmImportTemplateTest.java @@ -0,0 +1,206 @@ +// 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.cloudstack.context.CallContext; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.dc.DataCenter; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.offering.ServiceOffering; +import com.cloud.storage.GuestOSCategoryVO; +import com.cloud.storage.GuestOSVO; +import com.cloud.storage.Storage.ImageFormat; +import com.cloud.storage.VMTemplateVO; +import com.cloud.storage.dao.GuestOSCategoryDao; +import com.cloud.storage.dao.GuestOSDao; +import com.cloud.storage.dao.VMTemplateDao; +import com.cloud.template.VirtualMachineTemplate; +import com.cloud.user.Account; +import com.cloud.uservm.UserVm; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallbackWithException; +import com.cloud.utils.db.UUIDManager; +import com.cloud.vm.dao.UserVmDao; + +public class UserVmImportTemplateTest { + private final UserVmManagerImpl manager = new UserVmManagerImpl(); + private final VMTemplateDao templateDao = mock(VMTemplateDao.class); + private final UserVmDao vmDao = mock(UserVmDao.class); + private final DataCenter zone = mock(DataCenter.class); + private final Account owner = mock(Account.class); + private final ServiceOffering offering = mock(ServiceOffering.class); + + @Before + public void setUp() { + UUIDManager uuidManager = mock(UUIDManager.class); + GuestOSDao guestOSDao = mock(GuestOSDao.class); + GuestOSCategoryDao categoryDao = mock(GuestOSCategoryDao.class); + GuestOSVO guestOS = mock(GuestOSVO.class); + ReflectionTestUtils.setField(manager, "_templateDao", templateDao); + ReflectionTestUtils.setField(manager, "_vmDao", vmDao); + ReflectionTestUtils.setField(manager, "_uuidMgr", uuidManager); + ReflectionTestUtils.setField(manager, "_itMgr", mock(VirtualMachineManager.class)); + ReflectionTestUtils.setField(manager, "_guestOSDao", guestOSDao); + ReflectionTestUtils.setField(manager, "_guestOSCategoryDao", categoryDao); + when(zone.getId()).thenReturn(1L); + when(owner.getId()).thenReturn(10L); + when(owner.getDomainId()).thenReturn(11L); + when(offering.getId()).thenReturn(12L); + when(vmDao.getNextInSequence(Long.class, "id")).thenReturn(42L); + when(uuidManager.generateUuid(UserVm.class, null)).thenReturn("b90b3f01-bbbd-4129-af59-d628c92d935f"); + when(guestOSDao.findById(5L)).thenReturn(guestOS); + when(guestOS.getCategoryId()).thenReturn(6L); + when(categoryDao.findById(6L)).thenReturn(mock(GuestOSCategoryVO.class)); + } + + @Test + public void importsSoftDeletedDummyTemplateWithoutReloadingIt() throws InsufficientCapacityException { + VMTemplateVO template = createTemplate(); + template.setRemoved(new Date()); + template.setState(VirtualMachineTemplate.State.Inactive); + doAnswer(invocation -> { + template.setDetails(new HashMap<>()); + return null; + }).when(templateDao).loadDetails(template); + + UserVmVO vm = importTemplate(template); + + assertNotNull(vm); + assertEquals(203L, vm.getTemplateId()); + assertNotNull(template.getRemoved()); + verify(templateDao).loadDetails(template); + verifyNoMoreInteractions(templateDao); + verify(vmDao).persist(vm); + } + + @Test + public void copiesLoadedCpuModeFromActiveTemplateIntoImportedVm() throws InsufficientCapacityException { + VMTemplateVO template = createTemplate(); + template.setState(VirtualMachineTemplate.State.Active); + doAnswer(invocation -> { + template.setDetails(Collections.singletonMap("guest.cpu.mode", "host-passthrough")); + return null; + }).when(templateDao).loadDetails(template); + + UserVmVO vm = importTemplate(template); + + assertEquals("host-passthrough", vm.getDetail("guest.cpu.mode")); + verify(templateDao).loadDetails(template); + verifyNoMoreInteractions(templateDao); + verify(vmDao).saveDetails(vm, Collections.emptyList()); + } + + @Test + public void importsNonVoTemplateUsingItsExistingDetails() throws InsufficientCapacityException { + VirtualMachineTemplate template = mock(VirtualMachineTemplate.class); + when(template.getId()).thenReturn(204L); + when(template.getFormat()).thenReturn(ImageFormat.QCOW2); + when(template.getDetails()).thenReturn(Collections.singletonMap("guest.cpu.mode", "host-model")); + + UserVmVO vm = importTemplate(template); + + assertEquals(204L, vm.getTemplateId()); + assertEquals("host-model", vm.getDetail("guest.cpu.mode")); + verifyNoInteractions(templateDao); + } + + @Test + public void explicitVmCpuDetailsOverrideTemplateDefaults() throws InsufficientCapacityException { + VMTemplateVO template = createTemplate(); + doAnswer(invocation -> { + template.setDetails(Map.of(VmDetailConstants.GUEST_CPU_MODE, "custom", + VmDetailConstants.GUEST_CPU_MODEL, "Skylake-Client")); + return null; + }).when(templateDao).loadDetails(template); + Map vmDetails = Map.of(VmDetailConstants.GUEST_CPU_MODE, "custom", + VmDetailConstants.GUEST_CPU_MODEL, "EPYC"); + + UserVmVO vm = importTemplate(template, vmDetails); + + assertEquals("custom", vm.getDetail(VmDetailConstants.GUEST_CPU_MODE)); + assertEquals("EPYC", vm.getDetail(VmDetailConstants.GUEST_CPU_MODEL)); + assertEquals("Skylake-Client", template.getDetails().get(VmDetailConstants.GUEST_CPU_MODEL)); + } + + @Test + public void explicitVmCpuModeOverridesTemplateMode() throws InsufficientCapacityException { + VMTemplateVO template = createTemplate(); + doAnswer(invocation -> { + template.setDetails(Collections.singletonMap(VmDetailConstants.GUEST_CPU_MODE, "host-passthrough")); + return null; + }).when(templateDao).loadDetails(template); + + UserVmVO vm = importTemplate(template, Collections.singletonMap(VmDetailConstants.GUEST_CPU_MODE, "host-model")); + + assertEquals("host-model", vm.getDetail(VmDetailConstants.GUEST_CPU_MODE)); + } + + @Test + public void stillRejectsMissingTemplate() { + InvalidParameterValueException exception = assertThrows(InvalidParameterValueException.class, () -> importTemplate(null)); + + assertEquals("Unable to import virtual machine without a template", exception.getMessage()); + verifyNoInteractions(templateDao, vmDao); + } + + private VMTemplateVO createTemplate() { + return VMTemplateVO.createSystemIso(203L, "import-test-template", "import-test-template", false, + "", true, 64, Account.ACCOUNT_ID_SYSTEM, "", "Import test template", false, 5L); + } + + private UserVmVO importTemplate(VirtualMachineTemplate template) throws InsufficientCapacityException { + return importTemplate(template, new HashMap<>()); + } + + private UserVmVO importTemplate(VirtualMachineTemplate template, Map vmDetails) throws InsufficientCapacityException { + try (MockedStatic transaction = mockStatic(Transaction.class); + MockedStatic context = mockStatic(CallContext.class)) { + transaction.when(() -> Transaction.execute(any(TransactionCallbackWithException.class))).thenAnswer(invocation -> { + TransactionCallbackWithException callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + context.when(CallContext::current).thenReturn(mock(CallContext.class)); + return (UserVmVO) manager.importVM(zone, null, template, "import-test-vm", "Import test VM", owner, + null, owner, true, null, 10L, 20L, offering, null, 5L, null, HypervisorType.KVM, + vmDetails, VirtualMachine.PowerState.PowerOff, new LinkedHashMap<>()); + } + } +} From fd1673629ddbd85776a5a4470720e70afe3811d1 Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Sun, 4 Oct 2026 05:01:39 +0200 Subject: [PATCH 5/5] Protect imported VM disks from VMware CBT migration cleanup Skip target cleanup on cancel and delete when an imported VM is recorded, including imports that completed after cancellation. Re-read the migration before cleanup to protect VM references recorded since the caller loaded it. Keep source snapshot cleanup and migration record deletion independent. Add regression coverage for both cleanup entry points, all migration states, stale caller records, and record-only deletion with source cleanup. This guard does not resolve imports racing after the final read. Signed-off-by: andrijapanicsb --- .../vm/VmwareCbtMigrationManagerImpl.java | 16 +++ .../VmwareCbtMigrationDeletePolicyTest.java | 119 ++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java index ad08d778d7c8..e148081e72ef 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -2695,6 +2695,22 @@ private boolean hasActiveMigrationOnSameConvertHost(VmwareCbtMigrationVO migrati } private void sendCleanupCommand(VmwareCbtMigrationVO migration, boolean failOnCleanupError, int waitSeconds) { + // Import may finish after cancellation and record the VM without changing the Cancelled state. + // Its target disks now belong to that VM, so neither cancel nor delete may remove them. + // This does not prevent source snapshot cleanup or deletion of the migration record. + Long importedVmId = migration.getVmId(); + if (importedVmId == null) { + // Re-read before cleanup: an import may have recorded its VM since this caller loaded the migration. + VmwareCbtMigrationVO storedMigration = vmwareCbtMigrationDao.findById(migration.getId()); + if (storedMigration != null) { + importedVmId = storedMigration.getVmId(); + } + } + if (importedVmId != null) { + LOGGER.info("Skipping target disk cleanup for VMware CBT migration {} because it references imported VM {}.", + migration.getUuid(), importedVmId); + return; + } if (!hasCleanupTargetDisks(migration)) { return; } diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java index 0b13f83b4558..fa1ae59c102f 100644 --- a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationDeletePolicyTest.java @@ -16,8 +16,21 @@ // under the License. package org.apache.cloudstack.vm; +import java.util.Collections; + +import org.apache.cloudstack.api.command.admin.vm.DeleteVmwareCbtMigrationCmd; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.junit.Assert; import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.agent.AgentManager; +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import com.cloud.vm.VmwareCbtMigrationVO; +import com.cloud.vm.dao.VmwareCbtMigrationCycleDao; +import com.cloud.vm.dao.VmwareCbtMigrationDao; +import com.cloud.vm.dao.VmwareCbtMigrationDiskDao; public class VmwareCbtMigrationDeletePolicyTest { @@ -49,6 +62,112 @@ public void testActiveMigrationsCannotBeDeleted() { Assert.assertFalse(manager.canDeleteMigrationState(VmwareCbtMigration.State.ReadyForImport)); } + @Test + public void testCancelAndDeleteCleanupProtectRecordedImportedVmInEveryState() { + VmwareCbtMigrationManagerImpl cleanupManager = new VmwareCbtMigrationManagerImpl(); + AgentManager agentManager = Mockito.mock(AgentManager.class); + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + PrimaryDataStoreDao poolDao = Mockito.mock(PrimaryDataStoreDao.class); + ReflectionTestUtils.setField(cleanupManager, "agentManager", agentManager); + ReflectionTestUtils.setField(cleanupManager, "vmwareCbtMigrationDiskDao", diskDao); + ReflectionTestUtils.setField(cleanupManager, "primaryDataStoreDao", poolDao); + + for (VmwareCbtMigration.State state : VmwareCbtMigration.State.values()) { + VmwareCbtMigrationVO migration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(migration.getState()).thenReturn(state); + Mockito.when(migration.getVmId()).thenReturn(73L); + Mockito.when(migration.getConvertHostId()).thenReturn(null); + + ReflectionTestUtils.invokeMethod(cleanupManager, "sendCleanupCommandIfPossible", migration); + ReflectionTestUtils.invokeMethod(cleanupManager, "sendCleanupCommandForDelete", migration); + } + + Mockito.verifyNoInteractions(agentManager, diskDao, poolDao); + } + + @Test + public void testRecordedImportedVmDoesNotPreventMigrationRecordDeletionOrSourceCleanup() { + for (VmwareCbtMigration.State state : new VmwareCbtMigration.State[] { + VmwareCbtMigration.State.Cancelled, VmwareCbtMigration.State.Failed, VmwareCbtMigration.State.Completed }) { + VmwareCbtMigrationManagerImpl deleteManager = Mockito.spy(new VmwareCbtMigrationManagerImpl()); + VmwareCbtMigrationVO migration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(migration.getId()).thenReturn(41L); + Mockito.when(migration.getState()).thenReturn(state); + Mockito.when(migration.getVmId()).thenReturn(73L); + Mockito.when(migration.getConvertHostId()).thenReturn(null); + VmwareCbtMigrationDao migrationDao = Mockito.mock(VmwareCbtMigrationDao.class); + Mockito.when(migrationDao.findById(41L)).thenReturn(migration); + Mockito.when(migrationDao.remove(41L)).thenReturn(true); + VmwareCbtMigrationCycleDao cycleDao = Mockito.mock(VmwareCbtMigrationCycleDao.class); + Mockito.when(cycleDao.listByMigrationId(41L)).thenReturn(Collections.emptyList()); + VmwareCbtMigrationDiskVO disk = Mockito.mock(VmwareCbtMigrationDiskVO.class); + Mockito.when(disk.getId()).thenReturn(17L); + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + Mockito.when(diskDao.listByMigrationId(41L)).thenReturn(Collections.singletonList(disk)); + AgentManager agentManager = Mockito.mock(AgentManager.class); + ReflectionTestUtils.setField(deleteManager, "vmwareCbtMigrationDao", migrationDao); + ReflectionTestUtils.setField(deleteManager, "vmwareCbtMigrationCycleDao", cycleDao); + ReflectionTestUtils.setField(deleteManager, "vmwareCbtMigrationDiskDao", diskDao); + ReflectionTestUtils.setField(deleteManager, "agentManager", agentManager); + Mockito.doNothing().when(deleteManager).removeLingeringSourceSnapshots(migration); + DeleteVmwareCbtMigrationCmd cmd = Mockito.mock(DeleteVmwareCbtMigrationCmd.class); + Mockito.when(cmd.getId()).thenReturn(41L); + Mockito.when(cmd.getCleanup()).thenReturn(true); + + Assert.assertTrue(deleteManager.deleteVmwareCbtMigration(cmd)); + + Mockito.verifyNoInteractions(agentManager); + Mockito.verify(diskDao, Mockito.times(1)).listByMigrationId(41L); + Mockito.verify(diskDao).remove(17L); + Mockito.verify(migrationDao).remove(41L); + Mockito.verify(deleteManager, Mockito.times(state == VmwareCbtMigration.State.Completed ? 0 : 1)) + .removeLingeringSourceSnapshots(migration); + } + } + + @Test + public void testMigrationWithoutImportedVmStillExaminesCleanupTargets() { + VmwareCbtMigrationManagerImpl cleanupManager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO migration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(migration.getId()).thenReturn(41L); + Mockito.when(migration.getVmId()).thenReturn(null); + VmwareCbtMigrationDao migrationDao = Mockito.mock(VmwareCbtMigrationDao.class); + Mockito.when(migrationDao.findById(41L)).thenReturn(migration); + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + Mockito.when(diskDao.listByMigrationId(41L)).thenReturn(Collections.emptyList()); + ReflectionTestUtils.setField(cleanupManager, "vmwareCbtMigrationDiskDao", diskDao); + ReflectionTestUtils.setField(cleanupManager, "vmwareCbtMigrationDao", migrationDao); + ReflectionTestUtils.setField(cleanupManager, "primaryDataStoreDao", Mockito.mock(PrimaryDataStoreDao.class)); + + ReflectionTestUtils.invokeMethod(cleanupManager, "sendCleanupCommandIfPossible", migration); + + Mockito.verify(diskDao).listByMigrationId(41L); + } + + @Test + public void testCleanupProtectsImportedVmRecordedAfterCallerLoadedMigration() { + VmwareCbtMigrationManagerImpl cleanupManager = new VmwareCbtMigrationManagerImpl(); + VmwareCbtMigrationVO staleMigration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(staleMigration.getId()).thenReturn(41L); + Mockito.when(staleMigration.getVmId()).thenReturn(null); + Mockito.when(staleMigration.getConvertHostId()).thenReturn(null); + VmwareCbtMigrationVO storedMigration = Mockito.mock(VmwareCbtMigrationVO.class); + Mockito.when(storedMigration.getVmId()).thenReturn(73L); + VmwareCbtMigrationDao migrationDao = Mockito.mock(VmwareCbtMigrationDao.class); + Mockito.when(migrationDao.findById(41L)).thenReturn(storedMigration); + VmwareCbtMigrationDiskDao diskDao = Mockito.mock(VmwareCbtMigrationDiskDao.class); + AgentManager agentManager = Mockito.mock(AgentManager.class); + ReflectionTestUtils.setField(cleanupManager, "vmwareCbtMigrationDao", migrationDao); + ReflectionTestUtils.setField(cleanupManager, "vmwareCbtMigrationDiskDao", diskDao); + ReflectionTestUtils.setField(cleanupManager, "agentManager", agentManager); + + ReflectionTestUtils.invokeMethod(cleanupManager, "sendCleanupCommandIfPossible", staleMigration); + ReflectionTestUtils.invokeMethod(cleanupManager, "sendCleanupCommandForDelete", staleMigration); + + Mockito.verify(migrationDao, Mockito.times(2)).findById(41L); + Mockito.verifyNoInteractions(diskDao, agentManager); + } + @Test public void testCurrentStepDurationFormattingMatchesImportTaskStyle() { Assert.assertEquals("0 secs", manager.formatDuration(0));