From 522838fd0e016f10f389090e6d93561290cf1625 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 05:08:38 +0200 Subject: [PATCH 01/29] Add VMware CBT migration entry points --- api/src/main/java/com/cloud/host/Host.java | 3 + .../apache/cloudstack/api/ApiConstants.java | 1 + .../api/command/admin/vm/ImportVmCmd.java | 29 +++++++ .../cloud/agent/manager/AgentManagerImpl.java | 26 ++++++- .../resource/LibvirtComputingResource.java | 59 ++++++++++++++ .../wrapper/LibvirtReadyCommandWrapper.java | 3 + .../vm/UnmanagedVMsManagerImpl.java | 67 +++++++++++++++- .../vm/VmwareCbtMigrationCutoverPolicy.java | 78 +++++++++++++++++++ .../vm/UnmanagedVMsManagerImplTest.java | 21 +++++ .../VmwareCbtMigrationCutoverPolicyTest.java | 60 ++++++++++++++ ui/public/locales/en.json | 4 + .../views/tools/ImportUnmanagedInstance.vue | 52 ++++++++++--- 12 files changed, 390 insertions(+), 13 deletions(-) create mode 100644 server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java create mode 100644 server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index b52348201516..9c13dd305b08 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -60,6 +60,9 @@ public static String[] toStrings(Host.Type... types) { String HOST_VDDK_SUPPORT = "host.vddk.support"; String HOST_VDDK_LIB_DIR = "vddk.lib.dir"; String HOST_VDDK_VERSION = "host.vddk.version"; + String HOST_VMWARE_CBT_SUPPORT = "host.vmware.cbt.support"; + String HOST_QEMU_IMG_VERSION = "host.qemu.img.version"; + String HOST_QEMU_NBD_VERSION = "host.qemu.nbd.version"; String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; String HOST_SSH_PORT = "host.ssh.port"; 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 4d4ead277e5d..b41ab21b9800 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -635,6 +635,7 @@ 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 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/ImportVmCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java index db7dcc3fb44f..b547346a8011 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java @@ -57,6 +57,24 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { @Inject public VmImportService vmImportService; + public enum VmwareMigrationMode { + OVF, + VDDK, + CBT; + + public static VmwareMigrationMode fromValue(String value, boolean useVddkFallback) { + if (StringUtils.isBlank(value)) { + return useVddkFallback ? VDDK : OVF; + } + for (VmwareMigrationMode mode : values()) { + if (mode.name().equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException(String.format("Unsupported VMware migration mode: %s", value)); + } + } + ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// ///////////////////////////////////////////////////// @@ -186,6 +204,13 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ".") private Boolean useVddk; + @Parameter(name = ApiConstants.VMWARE_MIGRATION_MODE, + type = CommandType.STRING, + since = "4.22.1", + description = "(only for importing VMs from VMware to KVM) optional - migration mode to use. Valid values are OVF, VDDK, and CBT. " + + "When omitted, CloudStack preserves the existing usevddk behavior.") + private String vmwareMigrationMode; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// @@ -267,6 +292,10 @@ public boolean getUseVddk() { return BooleanUtils.toBooleanDefaultIfNull(useVddk, true); } + public String getVmwareMigrationMode() { + return vmwareMigrationMode; + } + public String getTmpPath() { return tmpPath; } diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index 1215829d92f8..a1dde8fbe1ae 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java @@ -808,8 +808,12 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi String vddkSupport = detailsMap.get(Host.HOST_VDDK_SUPPORT); String vddkLibDir = detailsMap.get(Host.HOST_VDDK_LIB_DIR); String vddkVersion = detailsMap.get(Host.HOST_VDDK_VERSION); + String vmwareCbtSupport = detailsMap.get(Host.HOST_VMWARE_CBT_SUPPORT); + String qemuImgVersion = detailsMap.get(Host.HOST_QEMU_IMG_VERSION); + String qemuNbdVersion = detailsMap.get(Host.HOST_QEMU_NBD_VERSION); logger.debug("Got HOST_UEFI_ENABLE [{}] for host [{}]:", uefiEnabled, host); - if (ObjectUtils.anyNotNull(uefiEnabled, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion)) { + if (ObjectUtils.anyNotNull(uefiEnabled, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion, + vmwareCbtSupport, qemuImgVersion, qemuNbdVersion)) { _hostDao.loadDetails(host); boolean updateNeeded = false; if (StringUtils.isNotBlank(uefiEnabled) && !uefiEnabled.equals(host.getDetails().get(Host.HOST_UEFI_ENABLE))) { @@ -844,6 +848,26 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi } updateNeeded = true; } + if (StringUtils.isNotBlank(vmwareCbtSupport) && !vmwareCbtSupport.equals(host.getDetails().get(Host.HOST_VMWARE_CBT_SUPPORT))) { + host.getDetails().put(Host.HOST_VMWARE_CBT_SUPPORT, vmwareCbtSupport); + updateNeeded = true; + } + if (!StringUtils.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 (updateNeeded) { _hostDao.saveDetails(host); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index 64ec0ed95d2e..cda2401d10c7 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 @@ -18,10 +18,13 @@ import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION; import static com.cloud.host.Host.HOST_OVFTOOL_VERSION; +import static com.cloud.host.Host.HOST_QEMU_IMG_VERSION; +import static com.cloud.host.Host.HOST_QEMU_NBD_VERSION; import static com.cloud.host.Host.HOST_VDDK_LIB_DIR; import static com.cloud.host.Host.HOST_VDDK_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_VERSION; import static com.cloud.host.Host.HOST_VIRTV2V_VERSION; +import static com.cloud.host.Host.HOST_VMWARE_CBT_SUPPORT; import static com.cloud.host.Host.HOST_VOLUME_ENCRYPTION; import static org.apache.cloudstack.utils.linux.KVMHostInfo.isHostS390x; @@ -369,6 +372,8 @@ 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 QEMU_IMG_SUPPORTED_CHECK_CMD = "qemu-img --version"; + public static final String QEMU_NBD_SUPPORTED_CHECK_CMD = "qemu-nbd --version"; public static final int LIBVIRT_CGROUP_CPU_SHARES_MIN = 2; public static final int LIBVIRT_CGROUP_CPU_SHARES_MAX = 262144; @@ -4286,12 +4291,21 @@ public StartupCommand[] initialize() { boolean instanceConversionSupported = hostSupportsInstanceConversion(); cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION, String.valueOf(instanceConversionSupported)); cmd.getHostDetails().put(HOST_VDDK_SUPPORT, String.valueOf(hostSupportsVddk())); + cmd.getHostDetails().put(HOST_VMWARE_CBT_SUPPORT, String.valueOf(hostSupportsVmwareCbtMigration())); 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); + } if (instanceConversionSupported) { cmd.getHostDetails().put(HOST_VIRTV2V_VERSION, getHostVirtV2vVersion()); } @@ -6028,6 +6042,51 @@ public boolean hostSupportsVddk(String overriddenVddkLibDir) { return hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && StringUtils.isNotBlank(detectVddkVersion()); } + public boolean hostSupportsVmwareCbtMigration() { + return hostSupportsVddk() + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_NBD_SUPPORTED_CHECK_CMD) == 0; + } + + public String getQemuImgVersion() { + return detectFirstLineVersion("qemu-img", "--version"); + } + + public String getQemuNbdVersion() { + return detectFirstLineVersion("qemu-nbd", "--version"); + } + + protected String detectFirstLineVersion(String... command) { + try { + ProcessBuilder pb = new ProcessBuilder(command); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + process.waitFor(); + + for (String line : output.split("\\R")) { + String trimmed = StringUtils.trimToNull(line); + if (StringUtils.isNotBlank(trimmed)) { + return parseVersionToken(trimmed); + } + } + } catch (Exception e) { + LOGGER.debug("Failed to detect version for command {}: {}", String.join(" ", command), e.getMessage()); + } + return null; + } + + protected String parseVersionToken(String versionLine) { + String versionMarker = " version "; + int markerIndex = versionLine.indexOf(versionMarker); + if (markerIndex < 0) { + return versionLine; + } + String value = versionLine.substring(markerIndex + versionMarker.length()); + String[] parts = value.split("\\s+", 2); + return parts.length > 0 ? parts[0] : versionLine; + } + protected boolean isVddkLibDirValid(String path) { if (StringUtils.isBlank(path)) { return false; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java index 5a7d6d2c203a..a2858a172328 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 @@ -54,6 +54,9 @@ public Answer execute(final ReadyCommand command, final LibvirtComputingResource hostDetails.put(Host.HOST_VDDK_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddk())); hostDetails.put(Host.HOST_VDDK_LIB_DIR, StringUtils.defaultString(libvirtComputingResource.getVddkLibDir())); hostDetails.put(Host.HOST_VDDK_VERSION, StringUtils.defaultString(libvirtComputingResource.getVddkVersion())); + hostDetails.put(Host.HOST_VMWARE_CBT_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVmwareCbtMigration())); + hostDetails.put(Host.HOST_QEMU_IMG_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuImgVersion())); + hostDetails.put(Host.HOST_QEMU_NBD_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuNbdVersion())); if (libvirtComputingResource.hostSupportsOvfExport()) { hostDetails.put(Host.HOST_OVFTOOL_VERSION, libvirtComputingResource.getHostOvfToolVersion()); diff --git a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java index 846eab599fd1..7378b9b142ce 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java @@ -233,6 +233,51 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { ConfigKey.Kind.CSV, null); + ConfigKey VmwareCbtMigrationMinCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.min.cycles", + "Advanced", + "1", + "Minimum number of CBT delta synchronization cycles to run before CloudStack can recommend final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + ConfigKey VmwareCbtMigrationMaxCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.max.cycles", + "Advanced", + "5", + "Maximum number of CBT delta synchronization cycles to run before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + ConfigKey VmwareCbtMigrationQuietCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.quiet.cycles", + "Advanced", + "2", + "Number of consecutive quiet CBT delta synchronization cycles required before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + ConfigKey VmwareCbtMigrationQuietBytes = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.bytes", + "Advanced", + "1073741824", + "Maximum changed bytes in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + + ConfigKey VmwareCbtMigrationQuietDirtyRate = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.dirty.rate", + "Advanced", + "16777216", + "Maximum changed bytes per second in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + @Inject private AgentManager agentManager; @Inject @@ -1663,7 +1708,12 @@ protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster boolean forceConvertToPool = cmd.getForceConvertToPool(); Long guestOsId = cmd.getGuestOsId(); boolean forceMsToImportVmFiles = Boolean.TRUE.equals(cmd.getForceMsToImportVmFiles()); - boolean useVddk = cmd.getUseVddk(); + ImportVmCmd.VmwareMigrationMode vmwareMigrationMode = getVmwareMigrationMode(cmd, cmd.getUseVddk()); + if (ImportVmCmd.VmwareMigrationMode.CBT == vmwareMigrationMode) { + throw new ServerApiException(ApiErrorCode.UNSUPPORTED_ACTION_ERROR, + "VMware CBT warm migration is not executable yet. Use OVF or VDDK migration mode until CBT replication support is implemented."); + } + boolean useVddk = ImportVmCmd.VmwareMigrationMode.VDDK == vmwareMigrationMode; if ((existingVcenterId == null && vcenter == null) || (existingVcenterId != null && vcenter != null)) { throw new ServerApiException(ApiErrorCode.PARAM_ERROR, @@ -1792,6 +1842,14 @@ protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster } } + protected ImportVmCmd.VmwareMigrationMode getVmwareMigrationMode(ImportVmCmd cmd, boolean useVddkFallback) { + try { + return ImportVmCmd.VmwareMigrationMode.fromValue(cmd.getVmwareMigrationMode(), useVddkFallback); + } catch (IllegalArgumentException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } + } + /** * Check whether the conversion storage pool exists and is suitable for the conversion or not. * Secondary storage is only allowed when forceConvertToPool is false. @@ -3177,7 +3235,12 @@ public ConfigKey[] getConfigKeys() { ThreadsOnMSToImportVMwareVMFiles, ThreadsOnKVMHostToImportVMwareVMFiles, ConvertVmwareInstanceToKvmExtraParamsAllowed, - ConvertVmwareInstanceToKvmExtraParamsAllowedList + ConvertVmwareInstanceToKvmExtraParamsAllowedList, + VmwareCbtMigrationMinCycles, + VmwareCbtMigrationMaxCycles, + VmwareCbtMigrationQuietCycles, + VmwareCbtMigrationQuietBytes, + VmwareCbtMigrationQuietDirtyRate }; } } diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java new file mode 100644 index 000000000000..cb548acd0557 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.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; + +public class VmwareCbtMigrationCutoverPolicy { + + public enum Decision { + CONTINUE, + READY_FOR_CUTOVER, + READY_FOR_CUTOVER_MAX_CYCLES + } + + private final int minCycles; + private final int maxCycles; + private final int quietCyclesRequired; + private final long quietDirtyBytesThreshold; + private final long quietDirtyRateBytesPerSecondThreshold; + + public VmwareCbtMigrationCutoverPolicy(int minCycles, int maxCycles, int quietCyclesRequired, + long quietDirtyBytesThreshold, long quietDirtyRateBytesPerSecondThreshold) { + if (minCycles < 1) { + throw new IllegalArgumentException("Minimum CBT migration cycles must be at least 1"); + } + if (maxCycles < minCycles) { + throw new IllegalArgumentException("Maximum CBT migration cycles must be greater than or equal to minimum cycles"); + } + if (quietCyclesRequired < 1) { + throw new IllegalArgumentException("Required quiet CBT migration cycles must be at least 1"); + } + this.minCycles = minCycles; + this.maxCycles = maxCycles; + this.quietCyclesRequired = quietCyclesRequired; + this.quietDirtyBytesThreshold = quietDirtyBytesThreshold; + this.quietDirtyRateBytesPerSecondThreshold = quietDirtyRateBytesPerSecondThreshold; + } + + public Decision decide(int completedCycles, int quietCycles, long lastChangedBytes, long lastCycleDurationSeconds) { + if (completedCycles >= maxCycles) { + return Decision.READY_FOR_CUTOVER_MAX_CYCLES; + } + if (completedCycles < minCycles) { + return Decision.CONTINUE; + } + int updatedQuietCycles = isQuietCycle(lastChangedBytes, lastCycleDurationSeconds) ? quietCycles + 1 : 0; + if (updatedQuietCycles >= quietCyclesRequired) { + return Decision.READY_FOR_CUTOVER; + } + return Decision.CONTINUE; + } + + public boolean isQuietCycle(long changedBytes, long cycleDurationSeconds) { + boolean withinChangedBytes = quietDirtyBytesThreshold <= 0 || changedBytes <= quietDirtyBytesThreshold; + boolean withinDirtyRate = quietDirtyRateBytesPerSecondThreshold <= 0 || + getDirtyRateBytesPerSecond(changedBytes, cycleDurationSeconds) <= quietDirtyRateBytesPerSecondThreshold; + return withinChangedBytes && withinDirtyRate; + } + + protected long getDirtyRateBytesPerSecond(long changedBytes, long cycleDurationSeconds) { + if (cycleDurationSeconds <= 0) { + return changedBytes; + } + return changedBytes / cycleDurationSeconds; + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java index bee6c4ad257f..8ef067c6da07 100644 --- a/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java @@ -730,6 +730,27 @@ public void testGetTemplateForImportInstanceDefaultTemplate() { Assert.assertEquals(defaultTemplateName, templateForImportInstance.getName()); } + @Test + public void testGetVmwareMigrationModeFallsBackToUseVddk() { + ImportVmCmd cmd = Mockito.mock(ImportVmCmd.class); + Assert.assertEquals(ImportVmCmd.VmwareMigrationMode.OVF, unmanagedVMsManager.getVmwareMigrationMode(cmd, false)); + Assert.assertEquals(ImportVmCmd.VmwareMigrationMode.VDDK, unmanagedVMsManager.getVmwareMigrationMode(cmd, true)); + } + + @Test + public void testGetVmwareMigrationModeParsesCbt() { + ImportVmCmd cmd = Mockito.mock(ImportVmCmd.class); + when(cmd.getVmwareMigrationMode()).thenReturn("cbt"); + Assert.assertEquals(ImportVmCmd.VmwareMigrationMode.CBT, unmanagedVMsManager.getVmwareMigrationMode(cmd, false)); + } + + @Test(expected = ServerApiException.class) + public void testGetVmwareMigrationModeRejectsUnknownMode() { + ImportVmCmd cmd = Mockito.mock(ImportVmCmd.class); + when(cmd.getVmwareMigrationMode()).thenReturn("not-a-mode"); + unmanagedVMsManager.getVmwareMigrationMode(cmd, false); + } + private enum VcenterParameter { EXISTING, EXTERNAL, diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java new file mode 100644 index 000000000000..cd7a069eec21 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import org.junit.Assert; +import org.junit.Test; + +public class VmwareCbtMigrationCutoverPolicyTest { + + @Test + public void testContinuesUntilMinimumCyclesAreReached() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(2, 5, 1, 1024, 0); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE, + policy.decide(1, 0, 512, 10)); + } + + @Test + public void testReadyForCutoverAfterRequiredQuietCycles() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 2, 1024, 0); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.READY_FOR_CUTOVER, + policy.decide(3, 1, 512, 10)); + } + + @Test + public void testDirtyRateCanKeepReplicationRunning() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 1, 0, 1024); + + Assert.assertFalse(policy.isQuietCycle(2048, 1)); + Assert.assertTrue(policy.isQuietCycle(2048, 2)); + } + + @Test + public void testReadyForCutoverWhenMaxCyclesReached() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 2, 1024, 1024); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.READY_FOR_CUTOVER_MAX_CYCLES, + policy.decide(5, 0, 4096, 1)); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsMaxCyclesBelowMinCycles() { + new VmwareCbtMigrationCutoverPolicy(3, 2, 1, 1024, 1024); + } +} diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 1187b3e62b40..5b34f1ac2ab6 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -2412,6 +2412,10 @@ "label.user.data": "User Data", "label.user.data.library": "User Data Library", "label.use.vddk": "Use VDDK", +"label.vmware.migration.mode": "VMware migration mode", +"label.vmware.migration.mode.ovf": "OVF", +"label.vmware.migration.mode.vddk": "VDDK", +"label.vmware.migration.mode.cbt": "CBT", "label.ssh.port": "SSH port", "label.sshkeypair": "New SSH key pair", "label.sshkeypairs": "SSH key pairs", diff --git a/ui/src/views/tools/ImportUnmanagedInstance.vue b/ui/src/views/tools/ImportUnmanagedInstance.vue index ffa0d9344335..b787615adde8 100644 --- a/ui/src/views/tools/ImportUnmanagedInstance.vue +++ b/ui/src/views/tools/ImportUnmanagedInstance.vue @@ -152,11 +152,18 @@ - + - + + {{ $t('label.vmware.migration.mode.ovf') }} + {{ $t('label.vmware.migration.mode.vddk') }} + {{ $t('label.vmware.migration.mode.cbt') }} + @@ -242,6 +272,12 @@ export default { } }, methods: { + canSync (record) { + return ['Created', 'InitialSync', 'Replicating'].includes(record.state) + }, + canCutover (record) { + return record.state === 'ReadyForCutover' + }, canCancel (record) { return !['Completed', 'Failed', 'Cancelled'].includes(record.state) }, From 1ece73f2d393f7f2d442e2a56f39e45b8ae9ebb4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 05:46:49 +0200 Subject: [PATCH 06/29] Start VMware CBT sessions from import UI --- ui/public/locales/en.json | 1 + .../views/tools/ImportUnmanagedInstance.vue | 55 ++++++++++++++++++- ui/src/views/tools/ManageInstances.vue | 5 ++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 27eeacc3ce28..3756271bd8d8 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3092,6 +3092,7 @@ "message.action.unmanage.volumes": "Please confirm that you want to unmanage the Volumes.", "message.action.vmsnapshot.delete": "Please confirm that you want to delete this Instance Snapshot.
Please notice that the Instance will be paused before the Snapshot deletion, and resumed after deletion, if it runs on KVM.", "message.activate.project": "Are you sure you want to activate this project?", +"message.api.not.available": "API is not available.", "message.add.custom.action.parameters": "Parameters to be made available while running the custom action.", "message.add.egress.rule.failed": "Adding new egress rule failed.", "message.add.egress.rule.processing": "Adding new egress rule...", diff --git a/ui/src/views/tools/ImportUnmanagedInstance.vue b/ui/src/views/tools/ImportUnmanagedInstance.vue index b787615adde8..3580efd23301 100644 --- a/ui/src/views/tools/ImportUnmanagedInstance.vue +++ b/ui/src/views/tools/ImportUnmanagedInstance.vue @@ -434,7 +434,9 @@
{{ $t('label.cancel') }} - {{ $t('label.ok') }} + + {{ form.vmwaremigrationmode === 'cbt' ? $t('label.start') : $t('label.ok') }} +
@@ -525,7 +527,7 @@ export default { required: false }, selectedVmwareVcenter: { - type: Array, + type: Object, required: false }, loadingGuestOsMappings: { @@ -1257,6 +1259,9 @@ export default { params.networkid = values.networkid } } + if (this.isVmwareCbtStart(values)) { + return this.startVmwareCbtMigration(values) + } if (!this.computeOffering || !this.computeOffering.id) { this.$notification.error({ message: this.$t('message.request.failed'), @@ -1450,6 +1455,52 @@ export default { this.$emit('loading-changed', false) }) }, + isVmwareCbtStart (values) { + return this.selectedVmwareVcenter && (values.vmwaremigrationmode || 'ovf') === 'cbt' + }, + startVmwareCbtMigration (values) { + if (!('startVmwareCbtMigration' in this.$store.getters.apis)) { + this.$notification.error({ + message: this.$t('message.request.failed'), + description: this.$t('message.api.not.available') + }) + return + } + + const params = { + zoneid: this.zoneid, + clusterid: this.cluster.id, + displayname: values.displayname, + sourcevmname: this.resource.name, + hostip: this.resource.hostname, + clustername: this.resource.clustername + } + if (this.selectedVmwareVcenter.existingvcenterid) { + params.existingvcenterid = this.selectedVmwareVcenter.existingvcenterid + } else { + params.vcenter = this.selectedVmwareVcenter.vcenter + params.datacentername = this.selectedVmwareVcenter.datacentername + params.username = this.selectedVmwareVcenter.username + params.password = this.selectedVmwareVcenter.password + } + if (this.selectedKvmHostForConversion) { + params.convertinstancehostid = this.selectedKvmHostForConversion + } + const selectedPoolForConversion = values.convertstoragepoolid || this.selectedStoragePoolForConversion + if (selectedPoolForConversion) { + params.convertinstancepoolid = selectedPoolForConversion + } + + this.updateLoading(true) + postAPI('startVmwareCbtMigration', params).then(() => { + this.$emit('vmware-cbt-migration-started') + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.closeAction() + this.updateLoading(false) + }) + }, updateLoading (value) { this.loading = value this.$emit('loading-changed', value) diff --git a/ui/src/views/tools/ManageInstances.vue b/ui/src/views/tools/ManageInstances.vue index 60c25a64f3ea..eefc86534bf4 100644 --- a/ui/src/views/tools/ManageInstances.vue +++ b/ui/src/views/tools/ManageInstances.vue @@ -563,6 +563,7 @@ @close-action="closeImportUnmanagedInstanceForm" @loading-changed="updateManageInstanceActionLoading" @track-import-jobid="trackImportJobId" + @vmware-cbt-migration-started="onVmwareCbtMigrationStarted" /> @@ -1297,6 +1298,10 @@ export default { this.$notifyError(error) }) }, + onVmwareCbtMigrationStarted () { + this.activeTabKey = 3 + this.fetchVmwareCbtMigrations() + }, fetchInstances () { this.fetchUnmanagedInstances() if (this.isUnmanaged) { From 27b23293d472ec807ab206994fc68c11c397ccd4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 11:37:46 +0200 Subject: [PATCH 07/29] Discover VMware CBT source disks --- .../cloudstack/vm/VmwareCbtDiskInfo.java | 61 +++++++ .../vm/VmwareCbtMigrationService.java | 24 +++ .../VmwareCbtMigrationServiceImpl.java | 151 ++++++++++++++++++ .../core/spring-vmware-core-context.xml | 2 + .../vm/VmwareCbtMigrationManagerImpl.java | 49 +++++- 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java create mode 100644 plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java 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..8c0ab1c3d6fb --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java @@ -0,0 +1,61 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtDiskInfo { + + private final String sourceDiskId; + private final String label; + private final String sourceDiskPath; + private final String datastoreName; + private final Long capacityBytes; + private final String changeId; + + public VmwareCbtDiskInfo(String sourceDiskId, String label, String sourceDiskPath, String datastoreName, + Long capacityBytes, String changeId) { + this.sourceDiskId = sourceDiskId; + this.label = label; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.capacityBytes = capacityBytes; + this.changeId = changeId; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + 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/VmwareCbtMigrationService.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java new file mode 100644 index 000000000000..173518ce9b21 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java @@ -0,0 +1,24 @@ +// 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 { + List listSourceDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); +} diff --git a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java new file mode 100644 index 000000000000..a427cca2d2cd --- /dev/null +++ b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/vmware/manager/VmwareCbtMigrationServiceImpl.java @@ -0,0 +1,151 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.vmware.manager; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.cloudstack.vm.VmwareCbtDiskInfo; +import org.apache.cloudstack.vm.VmwareCbtMigrationService; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.hypervisor.vmware.mo.DatacenterMO; +import com.cloud.hypervisor.vmware.mo.HostMO; +import com.cloud.hypervisor.vmware.mo.VirtualMachineMO; +import com.cloud.hypervisor.vmware.mo.VmwareHypervisorHost; +import com.cloud.hypervisor.vmware.resource.VmwareContextFactory; +import com.cloud.hypervisor.vmware.util.VmwareContext; +import com.cloud.hypervisor.vmware.util.VmwareHelper; +import com.cloud.utils.exception.CloudRuntimeException; +import com.vmware.vim25.ManagedObjectReference; +import com.vmware.vim25.VirtualDevice; +import com.vmware.vim25.VirtualDisk; + +public class VmwareCbtMigrationServiceImpl implements VmwareCbtMigrationService { + + private static final Logger LOGGER = LogManager.getLogger(VmwareCbtMigrationServiceImpl.class); + + @Override + public List listSourceDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + DatacenterMO datacenterMO = new DatacenterMO(context, datacenterName); + if (datacenterMO.getMor() == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware datacenter %s in vCenter %s", + datacenterName, vcenter)); + } + + VmwareHypervisorHost hyperHost; + VirtualMachineMO vmMO; + if (StringUtils.isNotBlank(sourceHost)) { + ManagedObjectReference hostMor = datacenterMO.findHost(sourceHost); + if (hostMor == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware host %s in vCenter %s", + sourceHost, vcenter)); + } + HostMO hostMO = new HostMO(context, hostMor); + vmMO = hostMO.findVmOnHyperHost(sourceVmName); + hyperHost = hostMO; + } else { + vmMO = datacenterMO.findVm(sourceVmName); + hyperHost = vmMO != null ? vmMO.getRunningHost() : null; + } + + if (vmMO == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware VM %s in datacenter %s", + sourceVmName, datacenterName)); + } + + UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(hyperHost, vmMO); + return toVmwareCbtDiskInfo(unmanagedInstance, collectChangeIds(vmMO)); + } catch (Exception e) { + String message = String.format("Unable to discover VMware CBT source disks for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + + private List toVmwareCbtDiskInfo(UnmanagedInstanceTO unmanagedInstance, + Map changeIds) { + List disks = new ArrayList<>(); + if (unmanagedInstance == null || CollectionUtils.isEmpty(unmanagedInstance.getDisks())) { + return disks; + } + + for (UnmanagedInstanceTO.Disk disk : unmanagedInstance.getDisks()) { + String sourceDiskId = StringUtils.defaultIfBlank(disk.getDiskId(), disk.getLabel()); + String sourceDiskPath = StringUtils.defaultIfBlank(disk.getImagePath(), disk.getFileBaseName()); + String changeId = changeIds.get(sourceDiskId); + if (StringUtils.isBlank(changeId)) { + changeId = changeIds.get(sourceDiskPath); + } + disks.add(new VmwareCbtDiskInfo(sourceDiskId, disk.getLabel(), sourceDiskPath, disk.getDatastoreName(), + disk.getCapacity(), changeId)); + } + return disks; + } + + private Map collectChangeIds(VirtualMachineMO vmMO) throws Exception { + Map changeIds = new HashMap<>(); + VirtualDisk[] disks = vmMO.getAllDiskDevice(); + if (disks == null) { + return changeIds; + } + + for (VirtualDevice device : disks) { + if (!(device instanceof VirtualDisk)) { + continue; + } + VirtualDisk disk = (VirtualDisk) device; + String changeId = getBackingStringValue(disk.getBacking(), "getChangeId"); + if (StringUtils.isBlank(changeId)) { + continue; + } + if (StringUtils.isNotBlank(disk.getDiskObjectId())) { + changeIds.put(disk.getDiskObjectId(), changeId); + } + String fileName = getBackingStringValue(disk.getBacking(), "getFileName"); + if (StringUtils.isNotBlank(fileName)) { + changeIds.put(fileName, changeId); + } + } + return changeIds; + } + + private String getBackingStringValue(Object backing, String methodName) { + if (backing == null) { + return null; + } + + try { + Method method = backing.getClass().getMethod(methodName); + Object value = method.invoke(backing); + return value != null ? value.toString() : null; + } catch (ReflectiveOperationException e) { + return null; + } + } +} diff --git a/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml b/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml index d955ede30254..28825e987dd8 100644 --- a/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml +++ b/plugins/hypervisors/vmware/src/main/resources/META-INF/cloudstack/core/spring-vmware-core-context.xml @@ -29,6 +29,8 @@ + > getCommands() { @@ -129,6 +132,7 @@ public VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigratio } VmwareSource source = resolveVmwareSource(cmd); + List sourceDisks = discoverSourceDisks(source, sourceVmName); String displayName = StringUtils.defaultIfBlank(cmd.getDisplayName(), sourceVmName); VmwareCbtMigrationVO migration = new VmwareCbtMigrationVO(zone.getId(), caller.getAccountId(), CallContext.current().getCallingUserId(), @@ -139,9 +143,11 @@ public VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigratio if (storagePool != null) { migration.setStoragePoolId(storagePool.getId()); } - migration.setCurrentStep("Waiting for initial VDDK full sync"); + migration.setState(VmwareCbtMigration.State.InitialSync); + migration.setCurrentStep(String.format("Discovered %s source disk(s); waiting for initial VDDK full sync", sourceDisks.size())); migration.setUpdated(new Date()); migration = vmwareCbtMigrationDao.persist(migration); + persistSourceDisks(migration, sourceDisks); return createVmwareCbtMigrationResponse(migration); } @@ -332,14 +338,16 @@ private VmwareSource resolveVmwareSource(StartVmwareCbtMigrationCmd cmd) { throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Cannot find any existing VMware datacenter with ID %s", cmd.getExistingVcenterId())); } - return new VmwareSource(existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName()); + return new VmwareSource(existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + existingDc.getUser(), existingDc.getPassword(), cmd.getSourceHost()); } if (StringUtils.isAnyBlank(cmd.getVcenter(), cmd.getDatacenterName(), cmd.getUsername(), cmd.getPassword())) { throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Please set all the information for a vCenter IP/Name, datacenter, username and password"); } - return new VmwareSource(cmd.getVcenter(), cmd.getDatacenterName()); + return new VmwareSource(cmd.getVcenter(), cmd.getDatacenterName(), cmd.getUsername(), cmd.getPassword(), + cmd.getSourceHost()); } private VmwareCbtMigration.State parseState(String state) { @@ -369,6 +377,33 @@ private void rejectTerminalMigration(VmwareCbtMigrationVO migration, String acti } } + private List discoverSourceDisks(VmwareSource source, String sourceVmName) { + if (vmwareCbtMigrationService == null) { + throw new ServerApiException(ApiErrorCode.UNSUPPORTED_ACTION_ERROR, + "VMware CBT disk discovery service is unavailable. Please enable the VMware hypervisor plugin."); + } + + List sourceDisks = vmwareCbtMigrationService.listSourceDisks(source.vcenter, source.datacenterName, + source.username, source.password, source.sourceHost, sourceVmName); + if (CollectionUtils.isEmpty(sourceDisks)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("No source disks were discovered for VMware VM %s", sourceVmName)); + } + return sourceDisks; + } + + private void persistSourceDisks(VmwareCbtMigrationVO migration, List sourceDisks) { + for (VmwareCbtDiskInfo sourceDisk : sourceDisks) { + VmwareCbtMigrationDiskVO disk = new VmwareCbtMigrationDiskVO(migration.getId(), + StringUtils.defaultIfBlank(sourceDisk.getSourceDiskId(), sourceDisk.getLabel()), + sourceDisk.getSourceDiskPath(), sourceDisk.getDatastoreName(), sourceDisk.getCapacityBytes()); + disk.setChangeId(sourceDisk.getChangeId()); + disk.setTargetFormat("qcow2"); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.persist(disk); + } + } + private HostVO getCbtHostForMigration(VmwareCbtMigrationVO migration) { ClusterVO destinationCluster = clusterDao.findById(migration.getDestinationClusterId()); if (destinationCluster == null) { @@ -505,10 +540,16 @@ private VmwareCbtMigrationResponse createVmwareCbtMigrationResponse(VmwareCbtMig private static class VmwareSource { private final String vcenter; private final String datacenterName; + private final String username; + private final String password; + private final String sourceHost; - private VmwareSource(String vcenter, String datacenterName) { + private VmwareSource(String vcenter, String datacenterName, String username, String password, String sourceHost) { this.vcenter = vcenter; this.datacenterName = datacenterName; + this.username = username; + this.password = password; + this.sourceHost = sourceHost; } } } From 47a4e189b738cdcdb15c8aac7154249124637c93 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 11:49:34 +0200 Subject: [PATCH 08/29] Track VMware CBT source metadata --- .../cloud/agent/api/to/VmwareCbtDiskTO.java | 11 ++++- .../vm/CutoverVmwareCbtMigrationCmd.java | 18 +++++++- .../admin/vm/SyncVmwareCbtMigrationCmd.java | 18 +++++++- .../response/VmwareCbtMigrationResponse.java | 8 ++++ .../cloudstack/vm/VmwareCbtDiskInfo.java | 10 ++++- .../cloud/vm/VmwareCbtMigrationDiskVO.java | 12 +++++- .../com/cloud/vm/VmwareCbtMigrationVO.java | 11 +++++ .../META-INF/db/schema-42200to42210.sql | 3 ++ .../VmwareCbtMigrationServiceImpl.java | 43 +++++++++++-------- .../vm/VmwareCbtMigrationManagerImpl.java | 25 ++++++++--- 10 files changed, 127 insertions(+), 32 deletions(-) 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 index e12582c22425..e6dcf1bfbca6 100644 --- a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java @@ -21,6 +21,7 @@ public class VmwareCbtDiskTO implements Serializable { private String diskId; + private Integer diskDeviceKey; private String sourceDiskPath; private String datastoreName; private String targetPath; @@ -32,9 +33,11 @@ public class VmwareCbtDiskTO implements Serializable { public VmwareCbtDiskTO() { } - public VmwareCbtDiskTO(String diskId, String sourceDiskPath, String datastoreName, String targetPath, - String targetFormat, String changeId, String snapshotMor, long capacityBytes) { + 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; @@ -48,6 +51,10 @@ public String getDiskId() { return diskId; } + public Integer getDiskDeviceKey() { + return diskDeviceKey; + } + public String getSourceDiskPath() { return sourceDiskPath; } 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 index 17d02875a7ae..127af1eda291 100644 --- 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 @@ -40,7 +40,7 @@ description = "Perform final cutover for a VMware CBT migration", responseObject = VmwareCbtMigrationResponse.class, responseView = ResponseObject.ResponseView.Full, - requestHasSensitiveInfo = false, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "4.22.1") @@ -53,10 +53,26 @@ public class CutoverVmwareCbtMigrationCmd extends BaseCmd { required = true, description = "the VMware CBT migration ID") private Long id; + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "the username for the source vCenter, required for sessions not linked to an existing vCenter") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "the password for the source vCenter, required for sessions not linked to an existing vCenter") + private String password; + public Long getId() { return id; } + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { 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 index f45cd40fc467..d52fe9ba2096 100644 --- 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 @@ -40,7 +40,7 @@ description = "Run a VMware CBT delta synchronization cycle", responseObject = VmwareCbtMigrationResponse.class, responseView = ResponseObject.ResponseView.Full, - requestHasSensitiveInfo = false, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "4.22.1") @@ -53,10 +53,26 @@ public class SyncVmwareCbtMigrationCmd extends BaseCmd { required = true, description = "the VMware CBT migration ID") private Long id; + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "the username for the source vCenter, required for sessions not linked to an existing vCenter") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "the password for the source vCenter, required for sessions not linked to an existing vCenter") + private String password; + public Long getId() { return id; } + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { 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 index 785612d2a42e..6528ae52f60f 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java @@ -85,6 +85,10 @@ public class VmwareCbtMigrationResponse extends BaseResponse { @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; @@ -197,6 +201,10 @@ public void setVcenter(String vcenter) { this.vcenter = vcenter; } + public void setExistingVcenterId(String existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + public void setDatacenterName(String datacenterName) { this.datacenterName = datacenterName; } diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java index 8c0ab1c3d6fb..99ed6d5de530 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java @@ -19,15 +19,17 @@ 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, String label, String sourceDiskPath, String datastoreName, - Long capacityBytes, 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; @@ -39,6 +41,10 @@ public String getSourceDiskId() { return sourceDiskId; } + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + public String getLabel() { return label; } diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java index 61b2f8da22d5..30b435ab09ab 100644 --- a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java @@ -39,11 +39,12 @@ public VmwareCbtMigrationDiskVO() { uuid = UUID.randomUUID().toString(); } - public VmwareCbtMigrationDiskVO(long migrationId, String sourceDiskId, String sourceDiskPath, - String datastoreName, Long capacityBytes) { + 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; @@ -64,6 +65,9 @@ public VmwareCbtMigrationDiskVO(long migrationId, String sourceDiskId, String so @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; @@ -119,6 +123,10 @@ public String getSourceDiskId() { return sourceDiskId; } + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + public String getSourceDiskPath() { return sourceDiskPath; } diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java index 46b4e0eb0753..8a1de30623ef 100644 --- a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java @@ -77,6 +77,9 @@ public VmwareCbtMigrationVO(long zoneId, long accountId, long userId, long desti @Column(name = "vm_id") private Long vmId; + @Column(name = "existing_vcenter_id") + private Long existingVcenterId; + @Column(name = "destination_cluster_id") private long destinationClusterId; @@ -171,6 +174,14 @@ public void setVmId(Long vmId) { this.vmId = vmId; } + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public void setExistingVcenterId(Long existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + public long getDestinationClusterId() { return destinationClusterId; } diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql b/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql index 85c81fcfa32e..9adfde2b545a 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration` ( `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', `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', @@ -81,6 +82,7 @@ CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration` ( 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, @@ -94,6 +96,7 @@ CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration_disk` ( `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', 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 a427cca2d2cd..70c26d92fef2 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 @@ -79,7 +79,7 @@ public List listSourceDisks(String vcenter, String datacenter } UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(hyperHost, vmMO); - return toVmwareCbtDiskInfo(unmanagedInstance, collectChangeIds(vmMO)); + return toVmwareCbtDiskInfo(unmanagedInstance, collectDiskDeviceInfo(vmMO)); } catch (Exception e) { String message = String.format("Unable to discover VMware CBT source disks for VM %s in vCenter %s: %s", sourceVmName, vcenter, e.getMessage()); @@ -89,7 +89,7 @@ public List listSourceDisks(String vcenter, String datacenter } private List toVmwareCbtDiskInfo(UnmanagedInstanceTO unmanagedInstance, - Map changeIds) { + Map diskDeviceInfo) { List disks = new ArrayList<>(); if (unmanagedInstance == null || CollectionUtils.isEmpty(unmanagedInstance.getDisks())) { return disks; @@ -98,21 +98,22 @@ private List toVmwareCbtDiskInfo(UnmanagedInstanceTO unmanage for (UnmanagedInstanceTO.Disk disk : unmanagedInstance.getDisks()) { String sourceDiskId = StringUtils.defaultIfBlank(disk.getDiskId(), disk.getLabel()); String sourceDiskPath = StringUtils.defaultIfBlank(disk.getImagePath(), disk.getFileBaseName()); - String changeId = changeIds.get(sourceDiskId); - if (StringUtils.isBlank(changeId)) { - changeId = changeIds.get(sourceDiskPath); + DiskDeviceInfo deviceInfo = diskDeviceInfo.get(sourceDiskId); + if (deviceInfo == null) { + deviceInfo = diskDeviceInfo.get(sourceDiskPath); } - disks.add(new VmwareCbtDiskInfo(sourceDiskId, disk.getLabel(), sourceDiskPath, disk.getDatastoreName(), - disk.getCapacity(), changeId)); + disks.add(new VmwareCbtDiskInfo(sourceDiskId, deviceInfo != null ? deviceInfo.deviceKey : null, + disk.getLabel(), sourceDiskPath, disk.getDatastoreName(), disk.getCapacity(), + deviceInfo != null ? deviceInfo.changeId : null)); } return disks; } - private Map collectChangeIds(VirtualMachineMO vmMO) throws Exception { - Map changeIds = new HashMap<>(); + private Map collectDiskDeviceInfo(VirtualMachineMO vmMO) throws Exception { + Map diskDeviceInfo = new HashMap<>(); VirtualDisk[] disks = vmMO.getAllDiskDevice(); if (disks == null) { - return changeIds; + return diskDeviceInfo; } for (VirtualDevice device : disks) { @@ -120,19 +121,17 @@ private Map collectChangeIds(VirtualMachineMO vmMO) throws Excep continue; } VirtualDisk disk = (VirtualDisk) device; - String changeId = getBackingStringValue(disk.getBacking(), "getChangeId"); - if (StringUtils.isBlank(changeId)) { - continue; - } + DiskDeviceInfo deviceInfo = new DiskDeviceInfo(disk.getKey(), + getBackingStringValue(disk.getBacking(), "getChangeId")); if (StringUtils.isNotBlank(disk.getDiskObjectId())) { - changeIds.put(disk.getDiskObjectId(), changeId); + diskDeviceInfo.put(disk.getDiskObjectId(), deviceInfo); } String fileName = getBackingStringValue(disk.getBacking(), "getFileName"); if (StringUtils.isNotBlank(fileName)) { - changeIds.put(fileName, changeId); + diskDeviceInfo.put(fileName, deviceInfo); } } - return changeIds; + return diskDeviceInfo; } private String getBackingStringValue(Object backing, String methodName) { @@ -148,4 +147,14 @@ private String getBackingStringValue(Object backing, String methodName) { return null; } } + + private static class DiskDeviceInfo { + private final Integer deviceKey; + private final String changeId; + + private DiskDeviceInfo(Integer deviceKey, String changeId) { + this.deviceKey = deviceKey; + this.changeId = changeId; + } + } } 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 880aec76e6be..b460582e6bc9 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -137,6 +137,7 @@ public VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigratio VmwareCbtMigrationVO migration = new VmwareCbtMigrationVO(zone.getId(), caller.getAccountId(), CallContext.current().getCallingUserId(), destinationCluster.getId(), displayName, source.vcenter, source.datacenterName, cmd.getSourceHost(), cmd.getSourceCluster(), sourceVmName); + migration.setExistingVcenterId(source.existingVcenterId); if (convertHost != null) { migration.setConvertHostId(convertHost.getId()); } @@ -338,7 +339,7 @@ private VmwareSource resolveVmwareSource(StartVmwareCbtMigrationCmd cmd) { throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Cannot find any existing VMware datacenter with ID %s", cmd.getExistingVcenterId())); } - return new VmwareSource(existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + return new VmwareSource(existingDc.getId(), existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), existingDc.getUser(), existingDc.getPassword(), cmd.getSourceHost()); } @@ -346,7 +347,7 @@ private VmwareSource resolveVmwareSource(StartVmwareCbtMigrationCmd cmd) { throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Please set all the information for a vCenter IP/Name, datacenter, username and password"); } - return new VmwareSource(cmd.getVcenter(), cmd.getDatacenterName(), cmd.getUsername(), cmd.getPassword(), + return new VmwareSource(null, cmd.getVcenter(), cmd.getDatacenterName(), cmd.getUsername(), cmd.getPassword(), cmd.getSourceHost()); } @@ -396,7 +397,8 @@ private void persistSourceDisks(VmwareCbtMigrationVO migration, List getDiskTransferObjects(VmwareCbtMigrationVO migrat List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); List diskTOs = new ArrayList<>(); for (VmwareCbtMigrationDiskVO disk : disks) { - diskTOs.add(new VmwareCbtDiskTO(disk.getSourceDiskId(), disk.getSourceDiskPath(), disk.getDatastoreName(), - disk.getTargetPath(), disk.getTargetFormat(), disk.getChangeId(), disk.getSnapshotMor(), - disk.getCapacityBytes() == null ? 0L : disk.getCapacityBytes())); + diskTOs.add(new VmwareCbtDiskTO(disk.getSourceDiskId(), disk.getSourceDiskDeviceKey(), + disk.getSourceDiskPath(), disk.getDatastoreName(), disk.getTargetPath(), disk.getTargetFormat(), + disk.getChangeId(), disk.getSnapshotMor(), disk.getCapacityBytes() == null ? 0L : disk.getCapacityBytes())); } return diskTOs; } @@ -519,6 +521,12 @@ private VmwareCbtMigrationResponse createVmwareCbtMigrationResponse(VmwareCbtMig response.setDisplayName(migration.getDisplayName()); response.setVcenter(migration.getVcenter()); + if (migration.getExistingVcenterId() != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(migration.getExistingVcenterId()); + if (existingDc != null) { + response.setExistingVcenterId(existingDc.getUuid()); + } + } response.setDatacenterName(migration.getDatacenter()); response.setSourceHost(migration.getSourceHost()); response.setSourceCluster(migration.getSourceCluster()); @@ -538,13 +546,16 @@ private VmwareCbtMigrationResponse createVmwareCbtMigrationResponse(VmwareCbtMig } private static class VmwareSource { + private final Long existingVcenterId; private final String vcenter; private final String datacenterName; private final String username; private final String password; private final String sourceHost; - private VmwareSource(String vcenter, String datacenterName, String username, String password, String sourceHost) { + private VmwareSource(Long existingVcenterId, String vcenter, String datacenterName, String username, + String password, String sourceHost) { + this.existingVcenterId = existingVcenterId; this.vcenter = vcenter; this.datacenterName = datacenterName; this.username = username; From bf313bce6cbe0a6677c9f73ec8e8b3244c5431ee Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 11:54:23 +0200 Subject: [PATCH 09/29] Add VMware CBT changed block query contract --- .../vm/VmwareCbtChangedBlockInfo.java | 36 ++++ .../vm/VmwareCbtChangedDiskInfo.java | 46 +++++ .../vm/VmwareCbtMigrationService.java | 4 + .../VmwareCbtMigrationServiceImpl.java | 195 +++++++++++++++--- 4 files changed, 254 insertions(+), 27 deletions(-) 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 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/VmwareCbtMigrationService.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java index 173518ce9b21..7d01aa095d3c 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java @@ -21,4 +21,8 @@ public interface VmwareCbtMigrationService { List listSourceDisks(String vcenter, String datacenterName, String username, String password, String sourceHost, String sourceVmName); + + List queryChangedDiskAreas(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName, + List disks, String snapshotMor); } 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 70c26d92fef2..0c5f7f13a18f 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 @@ -23,6 +23,8 @@ import java.util.Map; import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.cloudstack.vm.VmwareCbtChangedBlockInfo; +import org.apache.cloudstack.vm.VmwareCbtChangedDiskInfo; import org.apache.cloudstack.vm.VmwareCbtDiskInfo; import org.apache.cloudstack.vm.VmwareCbtMigrationService; import org.apache.commons.collections4.CollectionUtils; @@ -51,43 +53,72 @@ public List listSourceDisks(String vcenter, String datacenter String sourceHost, String sourceVmName) { try { VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); - DatacenterMO datacenterMO = new DatacenterMO(context, datacenterName); - if (datacenterMO.getMor() == null) { - throw new CloudRuntimeException(String.format("Unable to find VMware datacenter %s in vCenter %s", - datacenterName, vcenter)); - } + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(lookup.hyperHost, lookup.vmMO); + return toVmwareCbtDiskInfo(unmanagedInstance, collectDiskDeviceInfo(lookup.vmMO)); + } catch (Exception e) { + String message = String.format("Unable to discover VMware CBT source disks for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } - VmwareHypervisorHost hyperHost; - VirtualMachineMO vmMO; - if (StringUtils.isNotBlank(sourceHost)) { - ManagedObjectReference hostMor = datacenterMO.findHost(sourceHost); - if (hostMor == null) { - throw new CloudRuntimeException(String.format("Unable to find VMware host %s in vCenter %s", - sourceHost, vcenter)); - } - HostMO hostMO = new HostMO(context, hostMor); - vmMO = hostMO.findVmOnHyperHost(sourceVmName); - hyperHost = hostMO; - } else { - vmMO = datacenterMO.findVm(sourceVmName); - hyperHost = vmMO != null ? vmMO.getRunningHost() : null; + @Override + public List queryChangedDiskAreas(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName, + List disks, String snapshotMor) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ManagedObjectReference snapshot = toManagedObjectReference("VirtualMachineSnapshot", snapshotMor); + List changedDisks = new ArrayList<>(); + if (CollectionUtils.isEmpty(disks)) { + return changedDisks; } - - if (vmMO == null) { - throw new CloudRuntimeException(String.format("Unable to find VMware VM %s in datacenter %s", - sourceVmName, datacenterName)); + for (VmwareCbtDiskInfo disk : disks) { + changedDisks.add(queryChangedDiskAreas(context, lookup.vmMO, snapshot, disk)); } - - UnmanagedInstanceTO unmanagedInstance = VmwareHelper.getUnmanagedInstance(hyperHost, vmMO); - return toVmwareCbtDiskInfo(unmanagedInstance, collectDiskDeviceInfo(vmMO)); + return changedDisks; } catch (Exception e) { - String message = String.format("Unable to discover VMware CBT source disks for VM %s in vCenter %s: %s", + String message = String.format("Unable to query VMware CBT changed areas for VM %s in vCenter %s: %s", sourceVmName, vcenter, e.getMessage()); LOGGER.error(message, e); throw new CloudRuntimeException(message, e); } } + private VmwareVmLookup lookupVirtualMachine(VmwareContext context, String vcenter, String datacenterName, + String sourceHost, String sourceVmName) throws Exception { + DatacenterMO datacenterMO = new DatacenterMO(context, datacenterName); + if (datacenterMO.getMor() == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware datacenter %s in vCenter %s", + datacenterName, vcenter)); + } + + VmwareHypervisorHost hyperHost; + VirtualMachineMO vmMO; + if (StringUtils.isNotBlank(sourceHost)) { + ManagedObjectReference hostMor = datacenterMO.findHost(sourceHost); + if (hostMor == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware host %s in vCenter %s", + sourceHost, vcenter)); + } + HostMO hostMO = new HostMO(context, hostMor); + vmMO = hostMO.findVmOnHyperHost(sourceVmName); + hyperHost = hostMO; + } else { + vmMO = datacenterMO.findVm(sourceVmName); + hyperHost = vmMO != null ? vmMO.getRunningHost() : null; + } + + if (vmMO == null) { + throw new CloudRuntimeException(String.format("Unable to find VMware VM %s in datacenter %s", + sourceVmName, datacenterName)); + } + return new VmwareVmLookup(hyperHost, vmMO); + } + private List toVmwareCbtDiskInfo(UnmanagedInstanceTO unmanagedInstance, Map diskDeviceInfo) { List disks = new ArrayList<>(); @@ -134,6 +165,72 @@ private Map collectDiskDeviceInfo(VirtualMachineMO vmMO) return diskDeviceInfo; } + private VmwareCbtChangedDiskInfo queryChangedDiskAreas(VmwareContext context, VirtualMachineMO vmMO, + ManagedObjectReference snapshot, VmwareCbtDiskInfo disk) + throws ReflectiveOperationException { + if (disk.getSourceDiskDeviceKey() == null) { + throw new CloudRuntimeException(String.format("VMware disk device key is missing for source disk %s", + disk.getSourceDiskId())); + } + if (StringUtils.isBlank(disk.getChangeId())) { + throw new CloudRuntimeException(String.format("VMware CBT change ID is missing for source disk %s", + disk.getSourceDiskId())); + } + + List changedBlocks = new ArrayList<>(); + long startOffset = 0L; + long capacityBytes = disk.getCapacityBytes() == null ? 0L : disk.getCapacityBytes(); + String nextChangeId = null; + + do { + Object diskChangeInfo = invokeQueryChangedDiskAreas(context, vmMO, snapshot, disk, startOffset); + nextChangeId = StringUtils.defaultIfBlank(getObjectStringValue(diskChangeInfo, "getChangeId"), + nextChangeId); + for (Object changedArea : getObjectListValue(diskChangeInfo, "getChangedArea")) { + Long areaStart = getObjectLongValue(changedArea, "getStart"); + Long areaLength = getObjectLongValue(changedArea, "getLength"); + if (areaStart != null && areaLength != null && areaLength > 0L) { + changedBlocks.add(new VmwareCbtChangedBlockInfo(areaStart, areaLength)); + } + } + + Long responseStart = getObjectLongValue(diskChangeInfo, "getStartOffset"); + Long responseLength = getObjectLongValue(diskChangeInfo, "getLength"); + if (responseStart == null || responseLength == null || responseLength <= 0L) { + break; + } + startOffset = responseStart + responseLength; + } while (capacityBytes > 0L && startOffset < capacityBytes); + + return new VmwareCbtChangedDiskInfo(disk.getSourceDiskId(), nextChangeId, changedBlocks); + } + + private Object invokeQueryChangedDiskAreas(VmwareContext context, VirtualMachineMO vmMO, + ManagedObjectReference snapshot, VmwareCbtDiskInfo disk, + long startOffset) throws ReflectiveOperationException { + Method method = context.getService().getClass().getMethod("queryChangedDiskAreas", + ManagedObjectReference.class, ManagedObjectReference.class, int.class, long.class, String.class); + return method.invoke(context.getService(), vmMO.getMor(), snapshot, disk.getSourceDiskDeviceKey(), + startOffset, disk.getChangeId()); + } + + private ManagedObjectReference toManagedObjectReference(String defaultType, String mor) { + if (StringUtils.isBlank(mor)) { + return null; + } + + ManagedObjectReference reference = new ManagedObjectReference(); + if (mor.contains(":")) { + String[] parts = mor.split(":", 2); + reference.setType(parts[0]); + reference.setValue(parts[1]); + } else { + reference.setType(defaultType); + reference.setValue(mor); + } + return reference; + } + private String getBackingStringValue(Object backing, String methodName) { if (backing == null) { return null; @@ -148,6 +245,40 @@ 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) { + return ((Number)value).longValue(); + } + return null; + } + + private List getObjectListValue(Object object, String methodName) { + Object value = invokeGetter(object, methodName); + if (value instanceof List) { + return (List)value; + } + return new ArrayList<>(); + } + + private Object invokeGetter(Object object, String methodName) { + if (object == null) { + return null; + } + + try { + Method method = object.getClass().getMethod(methodName); + return method.invoke(object); + } catch (ReflectiveOperationException e) { + return null; + } + } + private static class DiskDeviceInfo { private final Integer deviceKey; private final String changeId; @@ -157,4 +288,14 @@ private DiskDeviceInfo(Integer deviceKey, String changeId) { this.changeId = changeId; } } + + private static class VmwareVmLookup { + private final VmwareHypervisorHost hyperHost; + private final VirtualMachineMO vmMO; + + private VmwareVmLookup(VmwareHypervisorHost hyperHost, VirtualMachineMO vmMO) { + this.hyperHost = hyperHost; + this.vmMO = vmMO; + } + } } From b8b9700ee35dbda2e22b2b800797a3dc6de94783 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 11:57:40 +0200 Subject: [PATCH 10/29] Add VMware CBT snapshot service hooks --- .../vm/VmwareCbtMigrationService.java | 7 +++ .../cloudstack/vm/VmwareCbtSnapshotInfo.java | 36 ++++++++++++ .../VmwareCbtMigrationServiceImpl.java | 55 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java index 7d01aa095d3c..84dea224480a 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java @@ -22,7 +22,14 @@ public interface VmwareCbtMigrationService { List listSourceDisks(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); + + 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/VmwareCbtSnapshotInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java new file mode 100644 index 000000000000..eec220f8dde4 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.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 VmwareCbtSnapshotInfo { + + private final String snapshotName; + private final String snapshotMor; + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor) { + this.snapshotName = snapshotName; + this.snapshotMor = snapshotMor; + } + + public String getSnapshotName() { + return snapshotName; + } + + public String getSnapshotMor() { + return snapshotMor; + } +} 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 0c5f7f13a18f..05843cbe76a9 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 @@ -27,6 +27,7 @@ import org.apache.cloudstack.vm.VmwareCbtChangedDiskInfo; import org.apache.cloudstack.vm.VmwareCbtDiskInfo; import org.apache.cloudstack.vm.VmwareCbtMigrationService; +import org.apache.cloudstack.vm.VmwareCbtSnapshotInfo; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; @@ -64,6 +65,28 @@ public List listSourceDisks(String vcenter, String datacenter } } + @Override + public VmwareCbtSnapshotInfo createSnapshot(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotName, + String snapshotDescription, boolean quiesce) { + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + VmwareVmLookup lookup = lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ManagedObjectReference snapshot = lookup.vmMO.createSnapshotGetReference(snapshotName, + snapshotDescription, false, quiesce); + if (snapshot == null) { + throw new CloudRuntimeException(String.format("Unable to create VMware snapshot %s for VM %s", + snapshotName, sourceVmName)); + } + return new VmwareCbtSnapshotInfo(snapshotName, formatManagedObjectReference(snapshot)); + } catch (Exception e) { + String message = String.format("Unable to create VMware CBT snapshot for VM %s in vCenter %s: %s", + sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + @Override public List queryChangedDiskAreas(String vcenter, String datacenterName, String username, String password, String sourceHost, String sourceVmName, @@ -88,6 +111,31 @@ public List queryChangedDiskAreas(String vcenter, Stri } } + @Override + public void removeSnapshot(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotMor) { + if (StringUtils.isBlank(snapshotMor)) { + return; + } + + try { + VmwareContext context = VmwareContextFactory.getContext(vcenter, username, password); + lookupVirtualMachine(context, vcenter, datacenterName, sourceHost, sourceVmName); + ManagedObjectReference snapshot = toManagedObjectReference("VirtualMachineSnapshot", snapshotMor); + ManagedObjectReference task = context.getService().removeSnapshotTask(snapshot, false, true); + if (!context.getVimClient().waitForTask(task)) { + throw new CloudRuntimeException(String.format("Unable to remove VMware snapshot %s for VM %s", + snapshotMor, sourceVmName)); + } + context.waitForTaskProgressDone(task); + } catch (Exception e) { + String message = String.format("Unable to remove VMware CBT snapshot %s for VM %s in vCenter %s: %s", + snapshotMor, sourceVmName, vcenter, e.getMessage()); + LOGGER.error(message, e); + throw new CloudRuntimeException(message, e); + } + } + private VmwareVmLookup lookupVirtualMachine(VmwareContext context, String vcenter, String datacenterName, String sourceHost, String sourceVmName) throws Exception { DatacenterMO datacenterMO = new DatacenterMO(context, datacenterName); @@ -231,6 +279,13 @@ private ManagedObjectReference toManagedObjectReference(String defaultType, Stri return reference; } + private String formatManagedObjectReference(ManagedObjectReference mor) { + if (mor == null) { + return null; + } + return String.format("%s:%s", mor.getType(), mor.getValue()); + } + private String getBackingStringValue(Object backing, String methodName) { if (backing == null) { return null; From 533a56f485908c40fb3f04148aede54be1855fb1 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 12:03:23 +0200 Subject: [PATCH 11/29] Wire VMware CBT delta snapshot cycle --- .../vm/VmwareCbtMigrationManagerImpl.java | 201 +++++++++++++++--- 1 file changed, 169 insertions(+), 32 deletions(-) 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 b460582e6bc9..eee4da8d8af4 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -17,7 +17,6 @@ package org.apache.cloudstack.vm; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.List; @@ -37,6 +36,8 @@ import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import com.cloud.agent.AgentManager; @@ -47,6 +48,7 @@ import com.cloud.agent.api.VmwareCbtMigrationAnswer; import com.cloud.agent.api.VmwareCbtSyncCommand; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; import com.cloud.agent.api.to.VmwareCbtDiskTO; import com.cloud.dc.ClusterVO; import com.cloud.dc.DataCenterVO; @@ -77,6 +79,7 @@ public class VmwareCbtMigrationManagerImpl implements VmwareCbtMigrationManager { private static final String OBJECT_NAME = "vmwarecbtmigration"; + private static final Logger LOGGER = LogManager.getLogger(VmwareCbtMigrationManagerImpl.class); @Inject private VmwareCbtMigrationDao vmwareCbtMigrationDao; @@ -171,6 +174,7 @@ public ListResponse listVmwareCbtMigrations(ListVmwa public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationCmd cmd) { VmwareCbtMigrationVO migration = getMigration(cmd.getId()); rejectTerminalMigration(migration, "synchronize"); + VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); HostVO cbtHost = getCbtHostForMigration(migration); int cycleNumber = migration.getCompletedCycles() + 1; VmwareCbtMigrationCycleVO cycle = new VmwareCbtMigrationCycleVO(migration.getId(), cycleNumber); @@ -185,34 +189,57 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC migration.setUpdated(new Date()); vmwareCbtMigrationDao.update(migration.getId(), migration); - VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), createRemoteInstance(migration), - getDiskTransferObjects(migration), Collections.emptyList(), cycleNumber, null, false); - syncCommand.setWait(3600); + VmwareCbtSnapshotInfo snapshot = null; + try { + snapshot = createDeltaSnapshot(source, migration, cycleNumber); + VmwareCbtChangedBlockQueryResult changedBlockQuery = queryChangedBlocks(source, migration, + snapshot.getSnapshotMor()); + + cycle.setDescription(String.format("Dispatching %s VMware CBT changed block range(s) to KVM agent", + changedBlockQuery.changedBlocks.size())); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), + createRemoteInstance(migration), getDiskTransferObjects(migration), changedBlockQuery.changedBlocks, + cycleNumber, snapshot.getSnapshotMor(), false); + syncCommand.setWait(3600); + + VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "synchronize", + migration.getUuid()); + if (!answer.getResult()) { + markCycleFailed(cycle, answer.getDetails()); + markMigrationFailed(migration, "CBT delta synchronization failed", answer.getDetails()); + return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } - VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "synchronize", migration.getUuid()); - if (!answer.getResult()) { - markCycleFailed(cycle, answer.getDetails()); - markMigrationFailed(migration, "CBT delta synchronization failed", answer.getDetails()); + updateDiskChangeIds(migration, changedBlockQuery.changedDisks); + + cycle.setState(VmwareCbtMigrationCycle.State.Completed); + cycle.setChangedBytes(answer.getChangedBytes()); + cycle.setDirtyRate(answer.getDirtyRateBytesPerSecond()); + cycle.setDuration(answer.getDurationSeconds() * 1000); + cycle.setDescription(answer.getDetails()); + cycle.setUpdated(new Date()); + vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); + + migration.setCompletedCycles(cycleNumber); + migration.setTotalChangedBytes(migration.getTotalChangedBytes() + answer.getChangedBytes()); + migration.setLastChangedBytes(answer.getChangedBytes()); + migration.setLastDirtyRate(answer.getDirtyRateBytesPerSecond()); + migration.setState(answer.getReadyForCutover() ? VmwareCbtMigration.State.ReadyForCutover : VmwareCbtMigration.State.Replicating); + migration.setCurrentStep(answer.getReadyForCutover() ? "Ready for final cutover" : "CBT delta synchronization completed"); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + return createVmwareCbtMigrationResponse(migration); + } catch (RuntimeException e) { + String error = StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()); + markCycleFailed(cycle, error); + markMigrationFailed(migration, "CBT delta synchronization failed", error); return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); + } finally { + removeDeltaSnapshotIfPossible(source, migration, snapshot); } - - cycle.setState(VmwareCbtMigrationCycle.State.Completed); - cycle.setChangedBytes(answer.getChangedBytes()); - cycle.setDirtyRate(answer.getDirtyRateBytesPerSecond()); - cycle.setDuration(answer.getDurationSeconds() * 1000); - cycle.setDescription(answer.getDetails()); - cycle.setUpdated(new Date()); - vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); - - migration.setCompletedCycles(cycleNumber); - migration.setTotalChangedBytes(migration.getTotalChangedBytes() + answer.getChangedBytes()); - migration.setLastChangedBytes(answer.getChangedBytes()); - migration.setLastDirtyRate(answer.getDirtyRateBytesPerSecond()); - migration.setState(answer.getReadyForCutover() ? VmwareCbtMigration.State.ReadyForCutover : VmwareCbtMigration.State.Replicating); - migration.setCurrentStep(answer.getReadyForCutover() ? "Ready for final cutover" : "CBT delta synchronization completed"); - migration.setUpdated(new Date()); - vmwareCbtMigrationDao.update(migration.getId(), migration); - return createVmwareCbtMigrationResponse(migration); } @Override @@ -351,6 +378,26 @@ private VmwareSource resolveVmwareSource(StartVmwareCbtMigrationCmd cmd) { cmd.getSourceHost()); } + private VmwareSource resolveVmwareSource(VmwareCbtMigrationVO migration, String username, String password) { + if (migration.getExistingVcenterId() != null) { + VmwareDatacenterVO existingDc = vmwareDatacenterDao.findById(migration.getExistingVcenterId()); + if (existingDc == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot find any existing VMware datacenter with ID %s", + migration.getExistingVcenterId())); + } + return new VmwareSource(existingDc.getId(), existingDc.getVcenterHost(), existingDc.getVmwareDatacenterName(), + existingDc.getUser(), existingDc.getPassword(), migration.getSourceHost()); + } + + if (StringUtils.isAnyBlank(migration.getVcenter(), migration.getDatacenter(), username, password)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + "Please provide source vCenter username and password for this VMware CBT migration"); + } + return new VmwareSource(null, migration.getVcenter(), migration.getDatacenter(), username, password, + migration.getSourceHost()); + } + private VmwareCbtMigration.State parseState(String state) { if (StringUtils.isBlank(state)) { return null; @@ -379,12 +426,7 @@ private void rejectTerminalMigration(VmwareCbtMigrationVO migration, String acti } private List discoverSourceDisks(VmwareSource source, String sourceVmName) { - if (vmwareCbtMigrationService == null) { - throw new ServerApiException(ApiErrorCode.UNSUPPORTED_ACTION_ERROR, - "VMware CBT disk discovery service is unavailable. Please enable the VMware hypervisor plugin."); - } - - List sourceDisks = vmwareCbtMigrationService.listSourceDisks(source.vcenter, source.datacenterName, + List sourceDisks = getVmwareCbtMigrationService().listSourceDisks(source.vcenter, source.datacenterName, source.username, source.password, source.sourceHost, sourceVmName); if (CollectionUtils.isEmpty(sourceDisks)) { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, @@ -406,6 +448,90 @@ private void persistSourceDisks(VmwareCbtMigrationVO migration, List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + List sourceDisks = new ArrayList<>(); + for (VmwareCbtMigrationDiskVO disk : disks) { + sourceDisks.add(new VmwareCbtDiskInfo(disk.getSourceDiskId(), disk.getSourceDiskDeviceKey(), null, + disk.getSourceDiskPath(), disk.getDatastoreName(), disk.getCapacityBytes(), disk.getChangeId())); + disk.setSnapshotMor(snapshotMor); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + + List changedDisks = getVmwareCbtMigrationService().queryChangedDiskAreas(source.vcenter, + source.datacenterName, source.username, source.password, source.sourceHost, + migration.getSourceVmName(), sourceDisks, snapshotMor); + List changedBlocks = new ArrayList<>(); + for (VmwareCbtChangedDiskInfo changedDisk : changedDisks) { + for (VmwareCbtChangedBlockInfo changedBlock : changedDisk.getChangedBlocks()) { + changedBlocks.add(new VmwareCbtChangedBlockRangeTO(changedDisk.getSourceDiskId(), + changedBlock.getStartOffset(), changedBlock.getLength())); + } + } + return new VmwareCbtChangedBlockQueryResult(changedBlocks, changedDisks); + } + + private void updateDiskChangeIds(VmwareCbtMigrationVO migration, List changedDisks) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + for (VmwareCbtChangedDiskInfo changedDisk : changedDisks) { + if (StringUtils.isNotBlank(changedDisk.getNextChangeId()) && + StringUtils.equals(disk.getSourceDiskId(), changedDisk.getSourceDiskId())) { + disk.setChangeId(changedDisk.getNextChangeId()); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + break; + } + } + } + } + + private void removeDeltaSnapshotIfPossible(VmwareSource source, VmwareCbtMigrationVO migration, + VmwareCbtSnapshotInfo snapshot) { + if (source == null || snapshot == null || StringUtils.isBlank(snapshot.getSnapshotMor())) { + return; + } + + try { + getVmwareCbtMigrationService().removeSnapshot(source.vcenter, source.datacenterName, source.username, + source.password, source.sourceHost, migration.getSourceVmName(), snapshot.getSnapshotMor()); + clearDiskSnapshotMor(migration, snapshot.getSnapshotMor()); + } catch (RuntimeException e) { + LOGGER.warn(String.format("Unable to remove VMware CBT snapshot %s for migration %s: %s", + snapshot.getSnapshotMor(), migration.getUuid(), e.getMessage()), e); + } + } + + private void clearDiskSnapshotMor(VmwareCbtMigrationVO migration, String snapshotMor) { + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.equals(disk.getSnapshotMor(), snapshotMor)) { + disk.setSnapshotMor(null); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + } + } + + private VmwareCbtMigrationService getVmwareCbtMigrationService() { + if (vmwareCbtMigrationService == null) { + throw new ServerApiException(ApiErrorCode.UNSUPPORTED_ACTION_ERROR, + "VMware CBT migration service is unavailable. Please enable the VMware hypervisor plugin."); + } + return vmwareCbtMigrationService; + } + private HostVO getCbtHostForMigration(VmwareCbtMigrationVO migration) { ClusterVO destinationCluster = clusterDao.findById(migration.getDestinationClusterId()); if (destinationCluster == null) { @@ -563,4 +689,15 @@ private VmwareSource(Long existingVcenterId, String vcenter, String datacenterNa this.sourceHost = sourceHost; } } + + private static class VmwareCbtChangedBlockQueryResult { + private final List changedBlocks; + private final List changedDisks; + + private VmwareCbtChangedBlockQueryResult(List changedBlocks, + List changedDisks) { + this.changedBlocks = changedBlocks; + this.changedDisks = changedDisks; + } + } } From b5afd8deebec8ad8e204b0b70bcb8795388d8260 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 12:06:12 +0200 Subject: [PATCH 12/29] Handle empty VMware CBT delta cycles --- .../LibvirtVmwareCbtSyncCommandWrapper.java | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) 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 index cbf8eb104341..9f131410eb8d 100644 --- 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 @@ -16,9 +16,12 @@ // under the License. package com.cloud.hypervisor.kvm.resource.wrapper; +import java.util.List; + import com.cloud.agent.api.Answer; import com.cloud.agent.api.VmwareCbtMigrationAnswer; import com.cloud.agent.api.VmwareCbtSyncCommand; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; @@ -36,10 +39,31 @@ public Answer execute(VmwareCbtSyncCommand cmd, LibvirtComputingResource serverR 0, 0, 0, false, null); } - String msg = String.format("VMware CBT cycle %s for migration %s reached the KVM agent, but changed-block copy is not implemented yet.", - cmd.getCycleNumber(), cmd.getMigrationUuid()); + 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); + } + + long changedBytes = getChangedBytes(changedBlocks); + String msg = String.format("VMware CBT cycle %s for migration %s received %s changed block range(s) " + + "totaling %s bytes, but changed-block copy is not implemented yet.", + cmd.getCycleNumber(), cmd.getMigrationUuid(), changedBlocks.size(), changedBytes); logger.info(msg); return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), - 0, 0, 0, false, null); + changedBytes, 0, 0, false, null); + } + + private long getChangedBytes(List changedBlocks) { + long changedBytes = 0; + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + changedBytes += changedBlock.getLength(); + } + return changedBytes; } } From 7319aa0863fbba6c46a99edb9d3966340cd1af8b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 13:08:19 +0200 Subject: [PATCH 13/29] Move VMware CBT schema to 4.23 upgrade file --- .../META-INF/db/schema-42200to42210.sql | 85 ------------------- .../META-INF/db/schema-42210to42300.sql | 85 +++++++++++++++++++ 2 files changed, 85 insertions(+), 85 deletions(-) diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql b/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql index 9adfde2b545a..baa20e9f0ad5 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42200to42210.sql @@ -48,91 +48,6 @@ UPDATE `cloud`.`vm_template` SET guest_os_id = 99 WHERE name = 'kvm-default-vm-i -- Update existing vm_template records with NULL type to "USER" UPDATE `cloud`.`vm_template` SET `type` = 'USER' WHERE `type` IS NULL; --- VMware CBT warm migration session state -CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration` ( - `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', - `uuid` varchar(40) NOT NULL COMMENT 'UUID', - `zone_id` bigint unsigned NOT NULL COMMENT 'Zone ID', - `account_id` bigint unsigned NOT NULL COMMENT 'Account ID', - `user_id` bigint unsigned NOT NULL COMMENT 'User ID', - `vm_id` bigint unsigned COMMENT 'Imported VM ID after cutover', - `existing_vcenter_id` bigint unsigned COMMENT 'Linked VMware datacenter ID when the source vCenter is registered', - `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', - `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', - `state` varchar(32) NOT NULL COMMENT 'Migration state', - `current_step` varchar(64) 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; - -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; - -- remove unused config item DELETE FROM `cloud`.`configuration` WHERE name = 'consoleproxy.cmd.port'; diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index c99f798d3d56..2e1db72bd70d 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -131,3 +131,88 @@ CREATE TABLE IF NOT EXISTS `cloud_usage`.`quota_tariff_usage` ( -- Add the 'keep_mac_address_on_public_nic' column to the 'cloud.networks' and 'cloud.vpc' tables CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.networks', 'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1'); CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc', 'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1'); + +-- 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', + `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', + `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', + `state` varchar(32) NOT NULL COMMENT 'Migration state', + `current_step` varchar(64) 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; + +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; From 9e2b1110d852105bf32137035c2055ed122d23d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 15:40:00 +0200 Subject: [PATCH 14/29] Propagate VMware CBT source credentials --- .../vm/VmwareCbtMigrationManagerImpl.java | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) 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 eee4da8d8af4..8ac371396800 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -49,6 +49,7 @@ 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.dc.ClusterVO; import com.cloud.dc.DataCenterVO; @@ -201,8 +202,8 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); VmwareCbtSyncCommand syncCommand = new VmwareCbtSyncCommand(migration.getUuid(), - createRemoteInstance(migration), getDiskTransferObjects(migration), changedBlockQuery.changedBlocks, - cycleNumber, snapshot.getSnapshotMor(), false); + createRemoteInstance(source, migration), getDiskTransferObjects(migration), + changedBlockQuery.changedBlocks, cycleNumber, snapshot.getSnapshotMor(), false); syncCommand.setWait(3600); VmwareCbtMigrationAnswer answer = sendVmwareCbtCommand(cbtHost, syncCommand, "synchronize", @@ -213,6 +214,7 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); } + applyDiskResults(migration, answer.getDiskResults()); updateDiskChangeIds(migration, changedBlockQuery.changedDisks); cycle.setState(VmwareCbtMigrationCycle.State.Completed); @@ -246,6 +248,7 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd) { VmwareCbtMigrationVO migration = getMigration(cmd.getId()); rejectTerminalMigration(migration, "cut over"); + VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); HostVO cbtHost = getCbtHostForMigration(migration); migration.setState(VmwareCbtMigration.State.CuttingOver); @@ -254,7 +257,7 @@ public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigr migration.setUpdated(new Date()); vmwareCbtMigrationDao.update(migration.getId(), migration); - VmwareCbtCutoverCommand cutoverCommand = new VmwareCbtCutoverCommand(migration.getUuid(), createRemoteInstance(migration), + VmwareCbtCutoverCommand cutoverCommand = new VmwareCbtCutoverCommand(migration.getUuid(), createRemoteInstance(source, migration), getDiskTransferObjects(migration), migration.getCompletedCycles() + 1, true); cutoverCommand.setWait(3600); @@ -264,6 +267,7 @@ public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigr return createVmwareCbtMigrationResponse(vmwareCbtMigrationDao.findById(migration.getId())); } + applyDiskResults(migration, answer.getDiskResults()); migration.setState(VmwareCbtMigration.State.Completed); migration.setCurrentStep("Completed"); migration.setUpdated(new Date()); @@ -497,6 +501,37 @@ private void updateDiskChangeIds(VmwareCbtMigrationVO migration, List diskResults) { + if (CollectionUtils.isEmpty(diskResults)) { + return; + } + + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.equals(disk.getSourceDiskId(), diskResult.getDiskId())) { + applyDiskResult(disk, diskResult); + break; + } + } + } + } + + private void applyDiskResult(VmwareCbtMigrationDiskVO disk, VmwareCbtDiskSyncResultTO diskResult) { + if (StringUtils.isNotBlank(diskResult.getTargetPath())) { + disk.setTargetPath(diskResult.getTargetPath()); + } + if (StringUtils.isNotBlank(diskResult.getChangeId())) { + disk.setChangeId(diskResult.getChangeId()); + } + if (StringUtils.isNotBlank(diskResult.getSnapshotMor())) { + disk.setSnapshotMor(diskResult.getSnapshotMor()); + } + disk.setState(diskResult.getResult() ? VmwareCbtMigrationDisk.State.Ready : VmwareCbtMigrationDisk.State.Failed); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + private void removeDeltaSnapshotIfPossible(VmwareSource source, VmwareCbtMigrationVO migration, VmwareCbtSnapshotInfo snapshot) { if (source == null || snapshot == null || StringUtils.isBlank(snapshot.getSnapshotMor())) { @@ -543,9 +578,9 @@ private HostVO getCbtHostForMigration(VmwareCbtMigrationVO migration) { return selectCbtHost(null, destinationCluster); } - private RemoteInstanceTO createRemoteInstance(VmwareCbtMigrationVO migration) { - return new RemoteInstanceTO(migration.getSourceVmName(), null, migration.getVcenter(), null, null, - migration.getDatacenter(), migration.getSourceCluster(), migration.getSourceHost()); + private RemoteInstanceTO createRemoteInstance(VmwareSource source, VmwareCbtMigrationVO migration) { + return new RemoteInstanceTO(migration.getSourceVmName(), null, source.vcenter, source.username, source.password, + source.datacenterName, migration.getSourceCluster(), migration.getSourceHost()); } private List getDiskTransferObjects(VmwareCbtMigrationVO migration) { From 43ecca65ba95d75dba9afb110755a63e83007d12 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 15:45:04 +0200 Subject: [PATCH 15/29] Validate VMware CBT delta sync plans --- .../LibvirtVmwareCbtSyncCommandWrapper.java | 27 ++- .../resource/wrapper/VmwareCbtSyncPlan.java | 222 ++++++++++++++++++ ...ibvirtVmwareCbtSyncCommandWrapperTest.java | 93 ++++++++ .../wrapper/VmwareCbtSyncPlanTest.java | 78 ++++++ 4 files changed, 407 insertions(+), 13 deletions(-) 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/LibvirtVmwareCbtSyncCommandWrapperTest.java create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java 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 index 9f131410eb8d..d0875625baaa 100644 --- 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 @@ -50,20 +50,21 @@ public Answer execute(VmwareCbtSyncCommand cmd, LibvirtComputingResource serverR 0, 0, durationSeconds, true, null); } - long changedBytes = getChangedBytes(changedBlocks); - String msg = String.format("VMware CBT cycle %s for migration %s received %s changed block range(s) " + - "totaling %s bytes, but changed-block copy is not implemented yet.", - cmd.getCycleNumber(), cmd.getMigrationUuid(), changedBlocks.size(), changedBytes); + 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); + } + + String msg = String.format("VMware CBT cycle %s for migration %s validated %s changed block range(s) " + + "across %s disk(s), totaling %s bytes, but changed-block copy is not implemented yet.", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getChangedRangeCount(), syncPlan.getDiskPlans().size(), + syncPlan.getChangedBytes()); logger.info(msg); return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), - changedBytes, 0, 0, false, null); - } - - private long getChangedBytes(List changedBlocks) { - long changedBytes = 0; - for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { - changedBytes += changedBlock.getLength(); - } - return changedBytes; + syncPlan.getChangedBytes(), 0, 0, false, null); } } 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..716a2a632f4a --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java @@ -0,0 +1,222 @@ +// 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.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 long changedBytes; + + private VmwareCbtSyncPlan(boolean valid, String validationError, List diskPlans, + int changedRangeCount, long changedBytes) { + this.valid = valid; + this.validationError = validationError; + this.diskPlans = diskPlans; + this.changedRangeCount = changedRangeCount; + this.changedBytes = changedBytes; + } + + static VmwareCbtSyncPlan create(List disks, List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return new VmwareCbtSyncPlan(true, null, Collections.emptyList(), 0, 0); + } + + Map diskPlansById = getDiskPlansById(disks); + long changedBytes = 0; + 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); + changedBytes += changedBlock.getLength(); + changedRangeCount++; + } + + return new VmwareCbtSyncPlan(true, null, getPopulatedDiskPlans(diskPlansById), changedRangeCount, 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); + } + + boolean isValid() { + return valid; + } + + String getValidationError() { + return validationError; + } + + List getDiskPlans() { + return diskPlans; + } + + int getChangedRangeCount() { + return changedRangeCount; + } + + 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<>(); + private long changedBytes; + + DiskPlanBuilder(VmwareCbtDiskTO disk) { + this.disk = disk; + } + + void addChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock) { + changedBlocks.add(changedBlock); + changedBytes += changedBlock.getLength(); + } + + DiskPlan build() { + return new DiskPlan(disk, new ArrayList<>(changedBlocks), 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/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java new file mode 100644 index 000000000000..2bf54f0ef665 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapperTest.java @@ -0,0 +1,93 @@ +// 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.Collections; +import java.util.List; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtSyncCommand; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtVmwareCbtSyncCommandWrapperTest { + + private final LibvirtVmwareCbtSyncCommandWrapper wrapper = new LibvirtVmwareCbtSyncCommandWrapper(); + + @Mock + private LibvirtComputingResource libvirtComputingResource; + + @Before + public void setUp() { + Mockito.when(libvirtComputingResource.hostSupportsVmwareCbtMigration()).thenReturn(true); + } + + @Test + public void testExecuteNoChangedBlocksReturnsReadyForCutover() { + VmwareCbtSyncCommand command = createCommand(Collections.emptyList(), Collections.emptyList()); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertTrue(((VmwareCbtMigrationAnswer) answer).getReadyForCutover()); + } + + @Test + public void testExecuteRejectsChangedBlocksWithoutTargetPath() { + VmwareCbtDiskTO disk = createDisk("disk-1", null, 8192); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("no target path")); + } + + @Test + public void testExecuteReportsValidatedChangedBlocks() { + VmwareCbtDiskTO disk = createDisk("disk-1", "/var/lib/libvirt/images/disk-1.qcow2", 8192); + VmwareCbtSyncCommand command = createCommand(List.of(disk), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + + Answer answer = wrapper.execute(command, libvirtComputingResource); + + Assert.assertFalse(answer.getResult()); + Assert.assertEquals(1024, ((VmwareCbtMigrationAnswer) answer).getChangedBytes()); + Assert.assertTrue(answer.getDetails().contains("validated 1 changed block range")); + } + + private VmwareCbtSyncCommand createCommand(List disks, List changedBlocks) { + return new VmwareCbtSyncCommand("migration-uuid", null, disks, changedBlocks, 1, "snapshot-1", false); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath, long capacityBytes) { + return new VmwareCbtDiskTO(diskId, 2000, String.format("[%s] vm/%s.vmdk", diskId, diskId), + "datastore1", targetPath, "qcow2", "*", null, capacityBytes); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.java new file mode 100644 index 000000000000..034dc95a699c --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlanTest.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.hypervisor.kvm.resource.wrapper; + +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; + +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; + +public class VmwareCbtSyncPlanTest { + + @Test + public void testCreateGroupsChangedBlocksByDisk() { + VmwareCbtDiskTO disk1 = createDisk("disk-1", "/var/lib/libvirt/images/disk-1.qcow2", 8192); + VmwareCbtDiskTO disk2 = createDisk("disk-2", "/var/lib/libvirt/images/disk-2.qcow2", 8192); + + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(disk1, disk2), List.of( + new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024), + new VmwareCbtChangedBlockRangeTO("disk-1", 4096, 512), + new VmwareCbtChangedBlockRangeTO("disk-2", 2048, 2048))); + + Assert.assertTrue(syncPlan.isValid()); + Assert.assertEquals(3, syncPlan.getChangedRangeCount()); + Assert.assertEquals(3584, syncPlan.getChangedBytes()); + Assert.assertEquals(2, syncPlan.getDiskPlans().size()); + Assert.assertEquals(1536, syncPlan.getDiskPlans().get(0).getChangedBytes()); + Assert.assertEquals(2048, syncPlan.getDiskPlans().get(1).getChangedBytes()); + } + + @Test + public void testCreateRejectsUnknownDisk() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", "/target", 8192)), + List.of(new VmwareCbtChangedBlockRangeTO("disk-2", 0, 1024))); + + Assert.assertFalse(syncPlan.isValid()); + Assert.assertTrue(syncPlan.getValidationError().contains("unknown disk disk-2")); + } + + @Test + public void testCreateRejectsMissingTargetPath() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", null, 8192)), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 0, 1024))); + + Assert.assertFalse(syncPlan.isValid()); + Assert.assertTrue(syncPlan.getValidationError().contains("no target path")); + } + + @Test + public void testCreateRejectsOutOfBoundsRange() { + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(List.of(createDisk("disk-1", "/target", 1024)), + List.of(new VmwareCbtChangedBlockRangeTO("disk-1", 512, 1024))); + + Assert.assertFalse(syncPlan.isValid()); + Assert.assertTrue(syncPlan.getValidationError().contains("exceeds disk capacity")); + } + + private VmwareCbtDiskTO createDisk(String diskId, String targetPath, long capacityBytes) { + return new VmwareCbtDiskTO(diskId, 2000, String.format("[%s] vm/%s.vmdk", diskId, diskId), + "datastore1", targetPath, "qcow2", "*", null, capacityBytes); + } +} From 60ec473649f57baf8582961e5c1e7a4bfd57a5f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 15:49:49 +0200 Subject: [PATCH 16/29] Apply VMware CBT cutover policy --- .../vm/UnmanagedVMsManagerImpl.java | 52 +------- .../vm/VmwareCbtMigrationCutoverPolicy.java | 3 + .../vm/VmwareCbtMigrationManagerImpl.java | 119 ++++++++++++++++-- .../VmwareCbtMigrationCutoverPolicyTest.java | 10 ++ 4 files changed, 124 insertions(+), 60 deletions(-) diff --git a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java index 7378b9b142ce..966f0a38daa9 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java @@ -233,51 +233,6 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { ConfigKey.Kind.CSV, null); - ConfigKey VmwareCbtMigrationMinCycles = new ConfigKey<>(Integer.class, - "vmware.cbt.migration.min.cycles", - "Advanced", - "1", - "Minimum number of CBT delta synchronization cycles to run before CloudStack can recommend final VMware to KVM cutover", - true, - ConfigKey.Scope.Global, - null); - - ConfigKey VmwareCbtMigrationMaxCycles = new ConfigKey<>(Integer.class, - "vmware.cbt.migration.max.cycles", - "Advanced", - "5", - "Maximum number of CBT delta synchronization cycles to run before CloudStack recommends final VMware to KVM cutover", - true, - ConfigKey.Scope.Global, - null); - - ConfigKey VmwareCbtMigrationQuietCycles = new ConfigKey<>(Integer.class, - "vmware.cbt.migration.quiet.cycles", - "Advanced", - "2", - "Number of consecutive quiet CBT delta synchronization cycles required before CloudStack recommends final VMware to KVM cutover", - true, - ConfigKey.Scope.Global, - null); - - ConfigKey VmwareCbtMigrationQuietBytes = new ConfigKey<>(Long.class, - "vmware.cbt.migration.quiet.bytes", - "Advanced", - "1073741824", - "Maximum changed bytes in a CBT delta synchronization cycle for the cycle to be considered quiet", - true, - ConfigKey.Scope.Global, - null); - - ConfigKey VmwareCbtMigrationQuietDirtyRate = new ConfigKey<>(Long.class, - "vmware.cbt.migration.quiet.dirty.rate", - "Advanced", - "16777216", - "Maximum changed bytes per second in a CBT delta synchronization cycle for the cycle to be considered quiet", - true, - ConfigKey.Scope.Global, - null); - @Inject private AgentManager agentManager; @Inject @@ -3235,12 +3190,7 @@ public ConfigKey[] getConfigKeys() { ThreadsOnMSToImportVMwareVMFiles, ThreadsOnKVMHostToImportVMwareVMFiles, ConvertVmwareInstanceToKvmExtraParamsAllowed, - ConvertVmwareInstanceToKvmExtraParamsAllowedList, - VmwareCbtMigrationMinCycles, - VmwareCbtMigrationMaxCycles, - VmwareCbtMigrationQuietCycles, - VmwareCbtMigrationQuietBytes, - VmwareCbtMigrationQuietDirtyRate + ConvertVmwareInstanceToKvmExtraParamsAllowedList }; } } diff --git a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java index cb548acd0557..f6fd7d5c7ff7 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicy.java @@ -55,6 +55,9 @@ public Decision decide(int completedCycles, int quietCycles, long lastChangedByt if (completedCycles < minCycles) { return Decision.CONTINUE; } + if (lastChangedBytes == 0) { + return Decision.READY_FOR_CUTOVER; + } int updatedQuietCycles = isQuietCycle(lastChangedBytes, lastCycleDurationSeconds) ? quietCycles + 1 : 0; if (updatedQuietCycles >= quietCyclesRequired) { return Decision.READY_FOR_CUTOVER; 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 8ac371396800..d2d1d42dcaf0 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -32,6 +32,8 @@ import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.commons.collections4.CollectionUtils; @@ -77,11 +79,56 @@ import com.cloud.vm.dao.VmwareCbtMigrationDao; import com.cloud.vm.dao.VmwareCbtMigrationDiskDao; -public class VmwareCbtMigrationManagerImpl implements VmwareCbtMigrationManager { +public class VmwareCbtMigrationManagerImpl implements VmwareCbtMigrationManager, Configurable { private static final String OBJECT_NAME = "vmwarecbtmigration"; private static final Logger LOGGER = LogManager.getLogger(VmwareCbtMigrationManagerImpl.class); + static final ConfigKey VmwareCbtMigrationMinCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.min.cycles", + "Advanced", + "1", + "Minimum number of CBT delta synchronization cycles to run before CloudStack can recommend final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationMaxCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.max.cycles", + "Advanced", + "5", + "Maximum number of CBT delta synchronization cycles to run before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationQuietCycles = new ConfigKey<>(Integer.class, + "vmware.cbt.migration.quiet.cycles", + "Advanced", + "2", + "Number of consecutive quiet CBT delta synchronization cycles required before CloudStack recommends final VMware to KVM cutover", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationQuietBytes = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.bytes", + "Advanced", + "1073741824", + "Maximum changed bytes in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + + static final ConfigKey VmwareCbtMigrationQuietDirtyRate = new ConfigKey<>(Long.class, + "vmware.cbt.migration.quiet.dirty.rate", + "Advanced", + "16777216", + "Maximum changed bytes per second in a CBT delta synchronization cycle for the cycle to be considered quiet", + true, + ConfigKey.Scope.Global, + null); + @Inject private VmwareCbtMigrationDao vmwareCbtMigrationDao; @Inject @@ -216,21 +263,31 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC applyDiskResults(migration, answer.getDiskResults()); updateDiskChangeIds(migration, changedBlockQuery.changedDisks); + long changedBytes = answer.getChangedBytes(); + long durationSeconds = answer.getDurationSeconds(); + long dirtyRate = getDirtyRateBytesPerSecond(changedBytes, durationSeconds, answer.getDirtyRateBytesPerSecond()); + VmwareCbtMigrationCutoverPolicy cutoverPolicy = getCutoverPolicy(); + VmwareCbtMigrationCutoverPolicy.Decision cutoverDecision = cutoverPolicy.decide(cycleNumber, + migration.getQuietCycles(), changedBytes, durationSeconds); + int quietCycles = cutoverPolicy.isQuietCycle(changedBytes, durationSeconds) ? + migration.getQuietCycles() + 1 : 0; cycle.setState(VmwareCbtMigrationCycle.State.Completed); - cycle.setChangedBytes(answer.getChangedBytes()); - cycle.setDirtyRate(answer.getDirtyRateBytesPerSecond()); - cycle.setDuration(answer.getDurationSeconds() * 1000); + cycle.setChangedBytes(changedBytes); + cycle.setDirtyRate(dirtyRate); + cycle.setDuration(durationSeconds * 1000); cycle.setDescription(answer.getDetails()); cycle.setUpdated(new Date()); vmwareCbtMigrationCycleDao.update(cycle.getId(), cycle); migration.setCompletedCycles(cycleNumber); - migration.setTotalChangedBytes(migration.getTotalChangedBytes() + answer.getChangedBytes()); - migration.setLastChangedBytes(answer.getChangedBytes()); - migration.setLastDirtyRate(answer.getDirtyRateBytesPerSecond()); - migration.setState(answer.getReadyForCutover() ? VmwareCbtMigration.State.ReadyForCutover : VmwareCbtMigration.State.Replicating); - migration.setCurrentStep(answer.getReadyForCutover() ? "Ready for final cutover" : "CBT delta synchronization completed"); + migration.setQuietCycles(quietCycles); + migration.setTotalChangedBytes(migration.getTotalChangedBytes() + changedBytes); + migration.setLastChangedBytes(changedBytes); + migration.setLastDirtyRate(dirtyRate); + migration.setState(cutoverDecision == VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE ? + VmwareCbtMigration.State.Replicating : VmwareCbtMigration.State.ReadyForCutover); + migration.setCurrentStep(getCutoverDecisionStep(cutoverDecision)); migration.setUpdated(new Date()); vmwareCbtMigrationDao.update(migration.getId(), migration); return createVmwareCbtMigrationResponse(migration); @@ -501,6 +558,34 @@ private void updateDiskChangeIds(VmwareCbtMigrationVO migration, List 0) { + return reportedDirtyRate; + } + if (durationSeconds <= 0) { + return changedBytes; + } + return changedBytes / durationSeconds; + } + + private String getCutoverDecisionStep(VmwareCbtMigrationCutoverPolicy.Decision cutoverDecision) { + switch (cutoverDecision) { + case READY_FOR_CUTOVER: + return "Ready for final cutover"; + case READY_FOR_CUTOVER_MAX_CYCLES: + return "Ready for final cutover after reaching maximum CBT delta cycles"; + case CONTINUE: + default: + return "CBT delta synchronization completed; continue replication cycles"; + } + } + private void applyDiskResults(VmwareCbtMigrationVO migration, List diskResults) { if (CollectionUtils.isEmpty(diskResults)) { return; @@ -706,6 +791,22 @@ private VmwareCbtMigrationResponse createVmwareCbtMigrationResponse(VmwareCbtMig return response; } + @Override + public String getConfigComponentName() { + return VmwareCbtMigrationManagerImpl.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] { + VmwareCbtMigrationMinCycles, + VmwareCbtMigrationMaxCycles, + VmwareCbtMigrationQuietCycles, + VmwareCbtMigrationQuietBytes, + VmwareCbtMigrationQuietDirtyRate + }; + } + private static class VmwareSource { private final Long existingVcenterId; private final String vcenter; diff --git a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java index cd7a069eec21..9baca363f3c8 100644 --- a/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java +++ b/server/src/test/java/org/apache/cloudstack/vm/VmwareCbtMigrationCutoverPolicyTest.java @@ -37,6 +37,16 @@ public void testReadyForCutoverAfterRequiredQuietCycles() { policy.decide(3, 1, 512, 10)); } + @Test + public void testReadyForCutoverAfterMinimumCyclesWhenNoBlocksChanged() { + VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(2, 5, 2, 1024, 0); + + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.CONTINUE, + policy.decide(1, 0, 0, 10)); + Assert.assertEquals(VmwareCbtMigrationCutoverPolicy.Decision.READY_FOR_CUTOVER, + policy.decide(2, 0, 0, 10)); + } + @Test public void testDirtyRateCanKeepReplicationRunning() { VmwareCbtMigrationCutoverPolicy policy = new VmwareCbtMigrationCutoverPolicy(1, 5, 1, 0, 1024); From 987424baf0ca7c4405c1d3b43e216b0cf545a9fd Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 15:53:05 +0200 Subject: [PATCH 17/29] Pass VMware CBT source credentials from UI --- ui/src/views/tools/ManageInstances.vue | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/src/views/tools/ManageInstances.vue b/ui/src/views/tools/ManageInstances.vue index eefc86534bf4..893c4a614000 100644 --- a/ui/src/views/tools/ManageInstances.vue +++ b/ui/src/views/tools/ManageInstances.vue @@ -1285,19 +1285,29 @@ export default { }) }, syncVmwareCbtMigration (migration) { - postAPI('syncVmwareCbtMigration', { id: migration.id }).then(() => { + postAPI('syncVmwareCbtMigration', this.getVmwareCbtMigrationActionParams(migration)).then(() => { this.fetchVmwareCbtMigrations() }).catch(error => { this.$notifyError(error) }) }, cutoverVmwareCbtMigration (migration) { - postAPI('cutoverVmwareCbtMigration', { id: migration.id }).then(() => { + postAPI('cutoverVmwareCbtMigration', this.getVmwareCbtMigrationActionParams(migration)).then(() => { this.fetchVmwareCbtMigrations() }).catch(error => { this.$notifyError(error) }) }, + getVmwareCbtMigrationActionParams (migration) { + const params = { id: migration.id } + if (!migration.existingvcenterid && this.selectedVmwareVcenter && + this.selectedVmwareVcenter.vcenter === migration.vcenter && + this.selectedVmwareVcenter.datacentername === migration.datacentername) { + params.username = this.selectedVmwareVcenter.username + params.password = this.selectedVmwareVcenter.password + } + return params + }, onVmwareCbtMigrationStarted () { this.activeTabKey = 3 this.fetchVmwareCbtMigrations() From a9ce2dafaf0e4671c38c7c4888dd61798c0abf16 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 15:54:31 +0200 Subject: [PATCH 18/29] Require VMware CBT initial sync targets --- .../vm/VmwareCbtMigrationManagerImpl.java | 17 +++++++++++++++++ 1 file changed, 17 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 d2d1d42dcaf0..979aa1ff2fa7 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -223,6 +223,7 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC VmwareCbtMigrationVO migration = getMigration(cmd.getId()); rejectTerminalMigration(migration, "synchronize"); VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); + validateInitialSyncTargetDisks(migration); HostVO cbtHost = getCbtHostForMigration(migration); int cycleNumber = migration.getCompletedCycles() + 1; VmwareCbtMigrationCycleVO cycle = new VmwareCbtMigrationCycleVO(migration.getId(), cycleNumber); @@ -306,6 +307,7 @@ public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigr VmwareCbtMigrationVO migration = getMigration(cmd.getId()); rejectTerminalMigration(migration, "cut over"); VmwareSource source = resolveVmwareSource(migration, cmd.getUsername(), cmd.getPassword()); + validateInitialSyncTargetDisks(migration); HostVO cbtHost = getCbtHostForMigration(migration); migration.setState(VmwareCbtMigration.State.CuttingOver); @@ -509,6 +511,21 @@ private void persistSourceDisks(VmwareCbtMigrationVO migration, List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + if (CollectionUtils.isEmpty(disks)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s has no discovered source disks", migration.getUuid())); + } + for (VmwareCbtMigrationDiskVO disk : disks) { + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("VMware CBT migration %s cannot run delta sync before initial full sync registers target disk path for source disk %s", + migration.getUuid(), disk.getSourceDiskId())); + } + } + } + private VmwareCbtSnapshotInfo createDeltaSnapshot(VmwareSource source, VmwareCbtMigrationVO migration, int cycleNumber) { String snapshotName = String.format("cloudstack-cbt-%s-%s", migration.getUuid(), cycleNumber); From 7a6b8e26655a8dd9401da260f98a9da26f0f70b6 Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Thu, 14 May 2026 16:11:34 +0200 Subject: [PATCH 19/29] Register VMware CBT initial sync targets --- .../apache/cloudstack/api/ApiConstants.java | 8 ++ .../RegisterVmwareCbtMigrationTargetCmd.java | 111 +++++++++++++++++ .../VmwareCbtMigrationDiskResponse.java | 117 ++++++++++++++++++ .../response/VmwareCbtMigrationResponse.java | 9 ++ .../vm/VmwareCbtMigrationManager.java | 3 + .../vm/VmwareCbtTargetDiskInfo.java | 55 ++++++++ .../vm/VmwareCbtMigrationManagerImpl.java | 93 ++++++++++++++ 7 files changed, 396 insertions(+) create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RegisterVmwareCbtMigrationTargetCmd.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/vm/VmwareCbtTargetDiskInfo.java 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 d8f51950a5c9..1c9966f957c2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -640,6 +640,14 @@ public class ApiConstants { 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_DISK_LIST = "targetdisklist"; + 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"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RegisterVmwareCbtMigrationTargetCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RegisterVmwareCbtMigrationTargetCmd.java new file mode 100644 index 000000000000..17a4865ad535 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/RegisterVmwareCbtMigrationTargetCmd.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.command.admin.vm; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +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.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; +import org.apache.cloudstack.vm.VmwareCbtTargetDiskInfo; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; + +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.user.Account; + +@APICommand(name = "registerVmwareCbtMigrationTarget", + description = "Register KVM target disk paths produced by the initial full sync for a VMware CBT migration", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.22.1") +public class RegisterVmwareCbtMigrationTargetCmd 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.TARGET_DISK_LIST, type = CommandType.MAP, required = true, + description = "source disk to KVM target disk mapping. Example: targetdisklist[0].sourcediskid=scsi0:0&" + + "targetdisklist[0].targetpath=/var/lib/libvirt/images/vm-disk.qcow2&targetdisklist[0].targetformat=qcow2&" + + "targetdisklist[0].changeid=") + private Map targetDiskList; + + public Long getId() { + return id; + } + + @SuppressWarnings("unchecked") + public List getTargetDisks() { + if (MapUtils.isEmpty(targetDiskList)) { + throw new InvalidParameterValueException("Target disk list cannot be empty"); + } + + List targetDisks = new ArrayList<>(); + for (Map entry : (Collection>)targetDiskList.values()) { + String sourceDiskId = StringUtils.trimToNull(entry.get(ApiConstants.SOURCE_DISK_ID)); + String targetPath = StringUtils.trimToNull(entry.get(ApiConstants.TARGET_PATH)); + String targetFormat = StringUtils.trimToNull(entry.get(ApiConstants.TARGET_FORMAT)); + String changeId = StringUtils.trimToNull(entry.get(ApiConstants.CHANGE_ID)); + String snapshotMor = StringUtils.trimToNull(entry.get(ApiConstants.SNAPSHOT_MOR)); + + if (StringUtils.isAnyBlank(sourceDiskId, targetPath)) { + throw new InvalidParameterValueException(String.format("%s and %s are required for each target disk", + ApiConstants.SOURCE_DISK_ID, ApiConstants.TARGET_PATH)); + } + targetDisks.add(new VmwareCbtTargetDiskInfo(sourceDiskId, targetPath, targetFormat, changeId, snapshotMor)); + } + return targetDisks; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.registerVmwareCbtMigrationTarget(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/VmwareCbtMigrationDiskResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java new file mode 100644 index 000000000000..3a6ff90fa496 --- /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 KVM target disk path after initial full sync") + 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/VmwareCbtMigrationResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java index 6528ae52f60f..be67463373e8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java @@ -17,6 +17,7 @@ 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; @@ -137,6 +138,10 @@ public class VmwareCbtMigrationResponse extends BaseResponse { @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.CREATED) @Param(description = "the create date of the VMware CBT migration") private Date created; @@ -253,6 +258,10 @@ public void setLastDirtyRate(Long lastDirtyRate) { this.lastDirtyRate = lastDirtyRate; } + public void setDisks(List disks) { + this.disks = disks; + } + public void setCreated(Date created) { this.created = created; } diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java index 46d98ec1a11a..d50edaa14d35 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java @@ -19,6 +19,7 @@ import org.apache.cloudstack.api.command.admin.vm.CancelVmwareCbtMigrationCmd; import org.apache.cloudstack.api.command.admin.vm.CutoverVmwareCbtMigrationCmd; import org.apache.cloudstack.api.command.admin.vm.ListVmwareCbtMigrationsCmd; +import org.apache.cloudstack.api.command.admin.vm.RegisterVmwareCbtMigrationTargetCmd; 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; @@ -33,6 +34,8 @@ public interface VmwareCbtMigrationManager extends PluggableService { VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationCmd cmd); + VmwareCbtMigrationResponse registerVmwareCbtMigrationTarget(RegisterVmwareCbtMigrationTargetCmd cmd); + VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd); VmwareCbtMigrationResponse cancelVmwareCbtMigration(CancelVmwareCbtMigrationCmd cmd); diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtTargetDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtTargetDiskInfo.java new file mode 100644 index 000000000000..ebcd9ed926b0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtTargetDiskInfo.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.vm; + +public class VmwareCbtTargetDiskInfo { + + private final String sourceDiskId; + private final String targetPath; + private final String targetFormat; + private final String changeId; + private final String snapshotMor; + + public VmwareCbtTargetDiskInfo(String sourceDiskId, String targetPath, String targetFormat, + String changeId, String snapshotMor) { + this.sourceDiskId = sourceDiskId; + this.targetPath = targetPath; + this.targetFormat = targetFormat; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public String getTargetPath() { + return targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } +} 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 979aa1ff2fa7..4c204c01018f 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManagerImpl.java @@ -18,7 +18,9 @@ import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import javax.inject.Inject; @@ -27,9 +29,11 @@ import org.apache.cloudstack.api.command.admin.vm.CancelVmwareCbtMigrationCmd; import org.apache.cloudstack.api.command.admin.vm.CutoverVmwareCbtMigrationCmd; import org.apache.cloudstack.api.command.admin.vm.ListVmwareCbtMigrationsCmd; +import org.apache.cloudstack.api.command.admin.vm.RegisterVmwareCbtMigrationTargetCmd; 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.VmwareCbtMigrationDiskResponse; import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.ConfigKey; @@ -160,6 +164,7 @@ public List> getCommands() { cmdList.add(StartVmwareCbtMigrationCmd.class); cmdList.add(ListVmwareCbtMigrationsCmd.class); cmdList.add(SyncVmwareCbtMigrationCmd.class); + cmdList.add(RegisterVmwareCbtMigrationTargetCmd.class); cmdList.add(CutoverVmwareCbtMigrationCmd.class); cmdList.add(CancelVmwareCbtMigrationCmd.class); return cmdList; @@ -302,6 +307,31 @@ public VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationC } } + @Override + public VmwareCbtMigrationResponse registerVmwareCbtMigrationTarget(RegisterVmwareCbtMigrationTargetCmd cmd) { + VmwareCbtMigrationVO migration = getMigration(cmd.getId()); + rejectTerminalMigration(migration, "register target disks for"); + if (migration.getState() == VmwareCbtMigration.State.CuttingOver) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Cannot register target disks for VMware CBT migration %s while cutover is in progress", + migration.getUuid())); + } + + int registeredDiskCount = registerTargetDisks(migration, cmd.getTargetDisks()); + int diskCount = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()).size(); + boolean allTargetDisksRegistered = registeredDiskCount == diskCount; + + migration.setState(allTargetDisksRegistered ? VmwareCbtMigration.State.Replicating : + VmwareCbtMigration.State.InitialSync); + migration.setCurrentStep(allTargetDisksRegistered ? + "Initial full sync target disks registered; ready for CBT delta synchronization" : + String.format("Registered %s of %s initial full sync target disk(s)", registeredDiskCount, diskCount)); + migration.setLastError(null); + migration.setUpdated(new Date()); + vmwareCbtMigrationDao.update(migration.getId(), migration); + return createVmwareCbtMigrationResponse(migration); + } + @Override public VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd) { VmwareCbtMigrationVO migration = getMigration(cmd.getId()); @@ -526,6 +556,46 @@ private void validateInitialSyncTargetDisks(VmwareCbtMigrationVO migration) { } } + private int registerTargetDisks(VmwareCbtMigrationVO migration, List targetDisks) { + if (CollectionUtils.isEmpty(targetDisks)) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "At least one target disk must be provided"); + } + + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + Map disksBySourceDiskId = new HashMap<>(); + for (VmwareCbtMigrationDiskVO disk : disks) { + disksBySourceDiskId.put(disk.getSourceDiskId(), disk); + } + + for (VmwareCbtTargetDiskInfo targetDisk : targetDisks) { + VmwareCbtMigrationDiskVO disk = disksBySourceDiskId.get(targetDisk.getSourceDiskId()); + if (disk == null) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, + String.format("Source disk %s is not tracked by VMware CBT migration %s", + targetDisk.getSourceDiskId(), migration.getUuid())); + } + disk.setTargetPath(targetDisk.getTargetPath()); + disk.setTargetFormat(StringUtils.defaultIfBlank(targetDisk.getTargetFormat(), "qcow2")); + if (StringUtils.isNotBlank(targetDisk.getChangeId())) { + disk.setChangeId(targetDisk.getChangeId()); + } + if (StringUtils.isNotBlank(targetDisk.getSnapshotMor())) { + disk.setSnapshotMor(targetDisk.getSnapshotMor()); + } + disk.setState(VmwareCbtMigrationDisk.State.Ready); + disk.setUpdated(new Date()); + vmwareCbtMigrationDiskDao.update(disk.getId(), disk); + } + + int registeredDiskCount = 0; + for (VmwareCbtMigrationDiskVO disk : vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId())) { + if (StringUtils.isNotBlank(disk.getTargetPath())) { + registeredDiskCount++; + } + } + return registeredDiskCount; + } + private VmwareCbtSnapshotInfo createDeltaSnapshot(VmwareSource source, VmwareCbtMigrationVO migration, int cycleNumber) { String snapshotName = String.format("cloudstack-cbt-%s-%s", migration.getUuid(), cycleNumber); @@ -802,12 +872,35 @@ private VmwareCbtMigrationResponse createVmwareCbtMigrationResponse(VmwareCbtMig response.setTotalChangedBytes(migration.getTotalChangedBytes()); response.setLastChangedBytes(migration.getLastChangedBytes()); response.setLastDirtyRate(migration.getLastDirtyRate()); + response.setDisks(createVmwareCbtMigrationDiskResponses(migration)); response.setCreated(migration.getCreated()); response.setLastUpdated(migration.getUpdated()); response.setObjectName(OBJECT_NAME); return response; } + private List createVmwareCbtMigrationDiskResponses(VmwareCbtMigrationVO migration) { + List diskResponses = new ArrayList<>(); + List disks = vmwareCbtMigrationDiskDao.listByMigrationId(migration.getId()); + for (VmwareCbtMigrationDiskVO disk : disks) { + VmwareCbtMigrationDiskResponse diskResponse = new VmwareCbtMigrationDiskResponse(); + diskResponse.setId(disk.getUuid()); + diskResponse.setSourceDiskId(disk.getSourceDiskId()); + diskResponse.setSourceDiskDeviceKey(disk.getSourceDiskDeviceKey()); + diskResponse.setSourceDiskPath(disk.getSourceDiskPath()); + diskResponse.setDatastoreName(disk.getDatastoreName()); + diskResponse.setCapacityBytes(disk.getCapacityBytes()); + diskResponse.setTargetPath(disk.getTargetPath()); + diskResponse.setTargetFormat(disk.getTargetFormat()); + diskResponse.setChangeId(disk.getChangeId()); + diskResponse.setSnapshotMor(disk.getSnapshotMor()); + diskResponse.setState(disk.getState().name()); + diskResponse.setObjectName("vmwarecbtmigrationdisk"); + diskResponses.add(diskResponse); + } + return diskResponses; + } + @Override public String getConfigComponentName() { return VmwareCbtMigrationManagerImpl.class.getSimpleName(); From 8e088c70d10972c5752b1d1bf6fb90e430a89b1a Mon Sep 17 00:00:00 2001 From: andrijapanicsb Date: Thu, 14 May 2026 16:17:13 +0200 Subject: [PATCH 20/29] Expose VMware CBT disk target registration in UI --- ui/public/locales/en.json | 8 + ui/src/views/tools/VmwareCbtMigrations.vue | 195 ++++++++++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 3756271bd8d8..480bd46e5348 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -2426,6 +2426,13 @@ "label.lastdirtyrate": "Last dirty rate", "label.totalchangedbytes": "Total changed bytes", "label.lasterror": "Last error", +"label.register.targets": "Register targets", +"label.source.disk": "Source disk", +"label.source.disk.path": "Source disk path", +"label.target.path": "Target path", +"label.target.format": "Target format", +"label.change.id": "Change ID", +"label.snapshot.mor": "Snapshot MOR", "label.ssh.port": "SSH port", "label.sshkeypair": "New SSH key pair", "label.sshkeypairs": "SSH key pairs", @@ -3607,6 +3614,7 @@ "message.error.vcenter.password": "Please enter vCenter password.", "message.error.vcenter.username": "Please enter vCenter username.", "message.error.version.for.cluster": "Please select Kubernetes version for Kubernetes Cluster.", +"message.error.vmware.cbt.target.path": "Enter a target path for every VMware CBT disk.", "message.error.vlan.range": "Please enter a valid VLAN/VNI range.", "message.error.volume.name": "Please enter volume name.", "message.error.volume": "Please enter volume.", diff --git a/ui/src/views/tools/VmwareCbtMigrations.vue b/ui/src/views/tools/VmwareCbtMigrations.vue index 9ecf62d62abd..3900286763b4 100644 --- a/ui/src/views/tools/VmwareCbtMigrations.vue +++ b/ui/src/views/tools/VmwareCbtMigrations.vue @@ -61,7 +61,8 @@ size="middle" :pagination="false" :rowKey="record => record.id" - :columns="columns"> + :columns="columns" + :rowExpandable="record => getDisks(record).length > 0"> +