diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c8ca10f85e9..742faf57e4bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,5 +54,7 @@ jobs: - name: Install Non-OSS uses: ./.github/actions/install-nonoss - name: Noredist Build + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: | mvn -B -P developer,systemvm -Dsimulator -Dnoredist clean install -T$(nproc) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f6645f4af4d..d46df2f395f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,8 @@ jobs: git status ipmitool -V - name: Build with Maven + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: | mvn -B -P developer,systemvm -Dsimulator clean install -DskipTests=true -T$(nproc) - name: Archive artifacts @@ -307,6 +309,8 @@ jobs: mkdir -p ~/.m2/repository tar -xzf /tmp/artifacts/m2-cloudstack.tar.gz -C ~/.m2/repository - name: Setup Simulator Prerequisites + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: | sudo python3 -m pip install --upgrade netaddr mysql-connector-python python3 -m pip install --user --upgrade tools/marvin/dist/[mM]arvin-*.tar.gz @@ -319,6 +323,8 @@ jobs: find . | grep "src/main/java" | sed 's/\/java\//\/java /g' | awk '{print "--sourcefiles", $1, "\\"}' | sort | uniq >> jacoco-report.sh echo "--xml jacoco-coverage.xml" >> jacoco-report.sh - name: Start CloudStack Management Server with Simulator + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: | export MAVEN_OPTS="-Xmx4096m -XX:MaxMetaspaceSize=800m -Djava.security.egd=file:/dev/urandom -javaagent:jacoco/lib/jacocoagent.jar=address=*,port=36320,output=tcpserver --add-opens=java.base/java.lang=ALL-UNNAMED --add-exports=java.base/sun.security.x509=ALL-UNNAMED --add-opens=java.base/jdk.internal.reflect=ALL-UNNAMED" echo -e "\nStarting simulator" @@ -329,6 +335,8 @@ jobs: echo -e "\nStarting Advanced Zone DataCenter deployment" python3 tools/marvin/marvin/deployDataCenter.py -i setup/dev/advdualzone.cfg 2>&1 || true - name: Run Integration Tests with Simulator + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: | mkdir -p integration-test-results/smoke/misc mkdir -p integration-test-results/component diff --git a/.github/workflows/docker-cloudstack-simulator.yml b/.github/workflows/docker-cloudstack-simulator.yml index 64b17fca8cca..361f2659c7f1 100644 --- a/.github/workflows/docker-cloudstack-simulator.yml +++ b/.github/workflows/docker-cloudstack-simulator.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Login to Docker Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKERHUB_USER }} diff --git a/.github/workflows/main-sonar-check.yml b/.github/workflows/main-sonar-check.yml index f48409fbc0c3..1c9f1273b976 100644 --- a/.github/workflows/main-sonar-check.yml +++ b/.github/workflows/main-sonar-check.yml @@ -49,9 +49,12 @@ jobs: - name: Install Non-OSS uses: ./.github/actions/install-nonoss - name: Run Build and Tests with Coverage + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: mvn -B -T$(nproc) -P developer,systemvm,quality -Dsimulator -Dnoredist clean install - name: Upload to SonarQube env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: mvn -B -P quality org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=apache_cloudstack -Dsonar.branch.name=${{ github.ref_name }} diff --git a/.github/workflows/rat.yml b/.github/workflows/rat.yml index 5c72d64d6ed9..f4659885fbb3 100644 --- a/.github/workflows/rat.yml +++ b/.github/workflows/rat.yml @@ -40,6 +40,8 @@ jobs: - name: Install Non-OSS uses: ./.github/actions/install-nonoss - name: RAT licence checks + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: | mvn -P developer,systemvm -Dsimulator -Dnoredist -pl . org.apache.rat:apache-rat-plugin:0.18:check - name: Rat Report diff --git a/.github/workflows/sonar-check.yml b/.github/workflows/sonar-check.yml index 7f2a1bdb2935..3fe8c53fecea 100644 --- a/.github/workflows/sonar-check.yml +++ b/.github/workflows/sonar-check.yml @@ -47,10 +47,13 @@ jobs: - name: Install Non-OSS uses: ./.github/actions/install-nonoss - name: Run Build and Tests with Coverage + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} run: mvn -B -T$(nproc) -P developer,systemvm,quality -Dsimulator -Dnoredist clean install - name: Upload to SonarQube if: github.repository == 'apache/cloudstack' && github.event.pull_request.head.repo.full_name == github.repository env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} PR_ID: ${{ github.event.pull_request.number }} diff --git a/.gitignore b/.gitignore index abaef83e4555..1c96edf51e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,9 @@ tools/marvin/marvin/cloudstackAPI/* unittest venv waf-* +waf3-* + +**/.mvn/.develocity* # this ignores _all files starting with '.'. Don't do that! #.* diff --git a/.mvn/develocity.xml b/.mvn/develocity.xml new file mode 100644 index 000000000000..bde8355a5e2f --- /dev/null +++ b/.mvn/develocity.xml @@ -0,0 +1,50 @@ + + + + + + https://develocity.apache.org + false + + + #{isFalse(env['GITHUB_ACTIONS'])} + + #{{'0.0.0.0'}} + + + + + + + + + + false + + + false + #{isTrue(env['GITHUB_ACTIONS'])} + + + diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 000000000000..8118c8f9f463 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,34 @@ + + + + + + com.gradle + develocity-maven-extension + 2.6.0 + + + com.gradle + common-custom-user-data-maven-extension + 2.4.0 + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 391681e88c31..3c573ec8da27 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -216,3 +216,10 @@ repos: types: [yaml] files: \.ya?ml$ exclude: ^.*k8s-.*\.ya?ml$|^.github/workflows/.*\.lock\.ya?ml$ + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 12e63946db2c5cfcc9030fac21c3883ba88c6ed8 # frozen: 0.38.0 + hooks: + - id: check-dependabot + name: validate dependabot config + description: ensures the dependabot config file is valid + files: ^\.github/dependabot\.ya?ml$ diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 44923495b23f..bc6068fa3310 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -55,6 +55,25 @@ example.ver.1 > example.ver.2: continue to snapshot live. 4.23.0.0 > 24.0.0: + * VMware-to-KVM import using VDDK can import powered-off VMware VMs directly + into Ceph RBD primary storage when forceconverttopool is enabled and the + selected KVM conversion host supports qemu RBD access and in-place virt-v2v + finalization. Hosts without in-place finalization support can still use the + staged VDDK flow, converting to temporary qcow2 storage before copying the + finalized disks into RBD. + + * Linstor primary storage can now be the destination for VMware-to-KVM + imports in all three modes. Staged imports (virt-v2v to a temporary NFS + location) copy the converted disks into pre-created DRBD block devices + through the Linstor storage adaptor; the import host must be a LINSTOR + satellite connected to the destination pool. Direct VDDK imports with + forceconverttopool write each powered-off VMware disk over nbdkit/VDDK + straight into the DRBD device and finalize it with virt-v2v in place. + VMware CBT warm migrations replicate the initial full copy and all + incremental changed-block cycles directly into Linstor volumes and + finalize in place at cutover; the conversion host requires VDDK, + in-place virt-v2v support and access to the Linstor pool. + * KVM/Ceph: RBD volumes can now be encrypted at rest using native librbd LUKS2 (), for both data disks and root disks. Encryption is transparent to the guest and reuses the diff --git a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java index e4775188d0ca..f27f7d89e5bc 100644 --- a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java +++ b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java @@ -848,6 +848,19 @@ public Property getWorkers() { */ public static final Property VDDK_TRANSPORTS = new Property<>("vddk.transports", null, String.class); + /** + * NBD-transport compression used between the VDDK and the VMware server for the + * agent-driven nbdkit full-disk copies (cold direct import and CBT initial full + * sync). Passed to the nbdkit vddk plugin as compression=<value>. + * Valid values: none, zlib, fastlz, + * skipz (nbdkit ≥ 1.24, vSphere ≥ 6.5). fastlz + * compresses both zero regions and real data at low CPU cost; skipz + * only elides zero blocks. Does not affect the staged virt-v2v conversion path. + * Data type: String.
+ * Default value: fastlz + */ + public static final Property VDDK_NBD_COMPRESSION = new Property<>("vddk.nbd.compression", "fastlz", String.class); + /** * vCenter TLS certificate thumbprint used by virt-v2v VDDK mode, passed as -io vddk-thumbprint=<value>. * If unset, the KVM host computes it at runtime from the vCenter endpoint. diff --git a/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java b/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java index 7daeb9649177..8aec16d4b101 100644 --- a/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java +++ b/api/src/main/java/com/cloud/agent/api/to/RemoteInstanceTO.java @@ -38,6 +38,7 @@ public class RemoteInstanceTO implements Serializable { private String datacenterName; private String clusterName; private String hostName; + private String vmwareMoref; public RemoteInstanceTO() { } @@ -65,6 +66,11 @@ public RemoteInstanceTO(String instanceName, String instancePath, String vcenter this.hostName = hostName; } + public RemoteInstanceTO(String instanceName, String instancePath, String vcenterHost, String vcenterUsername, String vcenterPassword, String datacenterName, String clusterName, String hostName, String vmwareMoref) { + this(instanceName, instancePath, vcenterHost, vcenterUsername, vcenterPassword, datacenterName, clusterName, hostName); + this.vmwareMoref = vmwareMoref; + } + public Hypervisor.HypervisorType getHypervisorType() { return this.hypervisorType; } @@ -100,4 +106,12 @@ public String getClusterName() { public String getHostName() { return hostName; } + + public String getVmwareMoref() { + return vmwareMoref; + } + + public void setVmwareMoref(String vmwareMoref) { + this.vmwareMoref = vmwareMoref; + } } diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java new file mode 100644 index 000000000000..ee8fcc59a290 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtChangedBlockRangeTO.java @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtChangedBlockRangeTO implements Serializable { + + private String diskId; + private long startOffset; + private long length; + + public VmwareCbtChangedBlockRangeTO() { + } + + public VmwareCbtChangedBlockRangeTO(String diskId, long startOffset, long length) { + this.diskId = diskId; + this.startOffset = startOffset; + this.length = length; + } + + public String getDiskId() { + return diskId; + } + + public long getStartOffset() { + return startOffset; + } + + public long getLength() { + return length; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java new file mode 100644 index 000000000000..c22ea0bb24c0 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskSyncResultTO.java @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtDiskSyncResultTO implements Serializable { + + private String diskId; + private String targetPath; + private String changeId; + private String snapshotMor; + private long changedBytes; + private long durationSeconds; + private boolean result; + private String details; + + public VmwareCbtDiskSyncResultTO() { + } + + public VmwareCbtDiskSyncResultTO(String diskId, String targetPath, String changeId, String snapshotMor, + long changedBytes, long durationSeconds, boolean result, String details) { + this.diskId = diskId; + this.targetPath = targetPath; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + this.changedBytes = changedBytes; + this.durationSeconds = durationSeconds; + this.result = result; + this.details = details; + } + + public String getDiskId() { + return diskId; + } + + public String getTargetPath() { + return targetPath; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public long getChangedBytes() { + return changedBytes; + } + + public long getDurationSeconds() { + return durationSeconds; + } + + public boolean getResult() { + return result; + } + + public String getDetails() { + return details; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java new file mode 100644 index 000000000000..e6dcf1bfbca6 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareCbtDiskTO.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareCbtDiskTO implements Serializable { + + private String diskId; + private Integer diskDeviceKey; + private String sourceDiskPath; + private String datastoreName; + private String targetPath; + private String targetFormat; + private String changeId; + private String snapshotMor; + private long capacityBytes; + + public VmwareCbtDiskTO() { + } + + public VmwareCbtDiskTO(String diskId, Integer diskDeviceKey, String sourceDiskPath, String datastoreName, + String targetPath, String targetFormat, String changeId, String snapshotMor, + long capacityBytes) { + this.diskId = diskId; + this.diskDeviceKey = diskDeviceKey; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.targetPath = targetPath; + this.targetFormat = targetFormat; + this.changeId = changeId; + this.snapshotMor = snapshotMor; + this.capacityBytes = capacityBytes; + } + + public String getDiskId() { + return diskId; + } + + public Integer getDiskDeviceKey() { + return diskDeviceKey; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public String getTargetPath() { + return targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public long getCapacityBytes() { + return capacityBytes; + } +} diff --git a/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java b/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java new file mode 100644 index 000000000000..b980c611abc9 --- /dev/null +++ b/api/src/main/java/com/cloud/agent/api/to/VmwareVddkSourceDiskTO.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +import java.io.Serializable; + +public class VmwareVddkSourceDiskTO implements Serializable { + + private String diskId; + private String sourceDiskPath; + private long capacityBytes; + private Integer position; + + public VmwareVddkSourceDiskTO() { + } + + public VmwareVddkSourceDiskTO(String diskId, String sourceDiskPath, long capacityBytes, Integer position) { + this.diskId = diskId; + this.sourceDiskPath = sourceDiskPath; + this.capacityBytes = capacityBytes; + this.position = position; + } + + public String getDiskId() { + return diskId; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public long getCapacityBytes() { + return capacityBytes; + } + + public Integer getPosition() { + return position; + } +} diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index f7d13343d469..7f45c8acd3f7 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -49,6 +49,8 @@ import org.apache.cloudstack.storage.sharedfs.SharedFS; import org.apache.cloudstack.usage.Usage; import org.apache.cloudstack.schedule.ResourceSchedule; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenterGuestIpv6Prefix; @@ -127,6 +129,9 @@ public class EventTypes { public static final String EVENT_VM_RESTORE = "VM.RESTORE"; public static final String EVENT_VM_EXPUNGE = "VM.EXPUNGE"; public static final String EVENT_VM_IMPORT = "VM.IMPORT"; + public static final String EVENT_VMWARE_CBT_MIGRATION_START = "VMWARE.CBT.MIGRATION.START"; + public static final String EVENT_VMWARE_CBT_MIGRATION_SYNC = "VMWARE.CBT.MIGRATION.SYNC"; + public static final String EVENT_VMWARE_CBT_MIGRATION_CUTOVER = "VMWARE.CBT.MIGRATION.CUTOVER"; public static final String EVENT_VM_UNMANAGE = "VM.UNMANAGE"; public static final String EVENT_VM_RECOVER = "VM.RECOVER"; @@ -899,6 +904,21 @@ public class EventTypes { public static final String EVENT_DNS_RECORD_DELETE = "DNS.RECORD.DELETE"; public static final String EVENT_DNS_NAME_COLLISION = "DNS.NAME.COLLISION"; + // Instance Boot Group + public static final String EVENT_INSTANCE_BOOT_GROUP_CREATE = "INSTANCE.BOOT.GROUP.CREATE"; + public static final String EVENT_INSTANCE_BOOT_GROUP_DELETE = "INSTANCE.BOOT.GROUP.DELETE"; + public static final String EVENT_INSTANCE_BOOT_GROUP_UPDATE = "INSTANCE.BOOT.GROUP.UPDATE"; + public static final String EVENT_INSTANCE_BOOT_GROUP_START = "INSTANCE.BOOT.GROUP.START"; + public static final String EVENT_INSTANCE_BOOT_GROUP_STOP = "INSTANCE.BOOT.GROUP.STOP"; + public static final String EVENT_INSTANCE_BOOT_GROUP_REBOOT = "INSTANCE.BOOT.GROUP.REBOOT"; + public static final String EVENT_INSTANCE_BOOT_GROUP_MEMBER_ADD = "INSTANCE.BOOT.GROUP.MEMBER.ADD"; + public static final String EVENT_INSTANCE_BOOT_GROUP_MEMBER_REMOVE = "INSTANCE.BOOT.GROUP.MEMBER.REMOVE"; + public static final String EVENT_INSTANCE_BOOT_GROUP_MEMBER_REORDER = "INSTANCE.BOOT.GROUP.MEMBER.REORDER"; + public static final String EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_CREATE = "INSTANCE.BOOT.GROUP.READINESS.RULE.CREATE"; + public static final String EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_UPDATE = "INSTANCE.BOOT.GROUP.READINESS.RULE.UPDATE"; + public static final String EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_DELETE = "INSTANCE.BOOT.GROUP.READINESS.RULE.DELETE"; + + static { // TODO: need a way to force author adding event types to declare the entity details as well, with out braking @@ -1468,6 +1488,19 @@ public class EventTypes { entityEventDetails.put(EVENT_DNS_RECORD_CREATE, DnsRecord.class); entityEventDetails.put(EVENT_DNS_RECORD_DELETE, DnsRecord.class); + // Instance Boot Group + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_CREATE, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_DELETE, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_UPDATE, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_START, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_STOP, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_REBOOT, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_MEMBER_ADD, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_MEMBER_REMOVE, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_MEMBER_REORDER, InstanceBootGroup.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_CREATE, InstanceBootGroupReadinessRule.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_UPDATE, InstanceBootGroupReadinessRule.class); + entityEventDetails.put(EVENT_INSTANCE_BOOT_GROUP_READINESS_RULE_DELETE, InstanceBootGroupReadinessRule.class); } public static boolean isNetworkEvent(String eventType) { diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index 79bec7a5871a..cca250c2a90f 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -62,8 +62,18 @@ public static String[] toStrings(Host.Type... types) { String HOST_VDDK_SUPPORT = "host.vddk.support"; String HOST_VDDK_LIB_DIR = "vddk.lib.dir"; String HOST_VDDK_VERSION = "host.vddk.version"; + String HOST_VDDK_BLOCKCOPY_SUPPORT = "host.vddk.blockcopy.support"; + String HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT = "host.vddk.blockcopy.inplace.finalization.support"; + String HOST_VDDK_BLOCKCOPY_RBD_SUPPORT = "host.vddk.blockcopy.rbd.support"; + String HOST_QEMU_IMG_VERSION = "host.qemu.img.version"; + String HOST_QEMU_NBD_VERSION = "host.qemu.nbd.version"; + String HOST_QEMU_IO_VERSION = "host.qemu.io.version"; String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; + String HOST_VIRTV2V_INPLACE_VERSION = "host.virtv2v.inplace.version"; + String HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT = "host.vddk.rbd.direct.import.support"; + String HOST_VIRTV2V_INPLACE_SUPPORT = "host.virtv2v.inplace.support"; + String HOST_QEMU_RBD_SUPPORT = "host.qemu.rbd.support"; String HOST_SSH_PORT = "host.ssh.port"; String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count"; String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; diff --git a/api/src/main/java/com/cloud/network/netris/NetrisService.java b/api/src/main/java/com/cloud/network/netris/NetrisService.java index 110e9f07105a..1e7c7d66399a 100644 --- a/api/src/main/java/com/cloud/network/netris/NetrisService.java +++ b/api/src/main/java/com/cloud/network/netris/NetrisService.java @@ -21,6 +21,8 @@ import com.cloud.network.SDNProviderNetworkRule; import com.cloud.network.vpc.StaticRoute; import com.cloud.network.vpc.Vpc; +import com.cloud.offering.NetworkOffering; +import com.cloud.utils.net.NetUtils; import java.util.List; @@ -81,18 +83,20 @@ public interface NetrisService { /** * Creates a virtual network (vNet) resource. * - * @param zoneId the ID of the zone - * @param accountId the ID of the account - * @param domainId the ID of the domain - * @param vpcName the name of the VPC - * @param vpcId the ID of the VPC - * @param networkName the name of the network - * @param networkId the ID of the network - * @param cidr the CIDR of the network - * @param globalRouting true if global routing is enabled + * @param zoneId the ID of the zone + * @param accountId the ID of the account + * @param domainId the ID of the domain + * @param vpcName the name of the VPC + * @param vpcId the ID of the VPC + * @param networkName the name of the network + * @param networkId the ID of the network + * @param cidr the CIDR of the network + * @param networkMode the network mode + * @param internetProtocol the internet protocol + * @param isL2 true if it is an L2 network * @return true if the operation is successful, false otherwise */ - boolean createVnetResource(Long zoneId, long accountId, long domainId, String vpcName, Long vpcId, String networkName, Long networkId, String cidr, Boolean globalRouting); + boolean createVnetResource(Long zoneId, long accountId, long domainId, String vpcName, Long vpcId, String networkName, Long networkId, String cidr, NetworkOffering.NetworkMode networkMode, NetUtils.InternetProtocol internetProtocol, boolean isL2); /** * Updates an existing vNet resource. @@ -105,9 +109,10 @@ public interface NetrisService { * @param networkName the new name of the network * @param networkId the ID of the network * @param prevNetworkName the previous name of the network + * @param isL2 true if it is an L2 network * @return true if the operation is successful, false otherwise */ - boolean updateVnetResource(Long zoneId, long accountId, long domainId, String vpcName, Long vpcId, String networkName, Long networkId, String prevNetworkName); + boolean updateVnetResource(Long zoneId, long accountId, long domainId, String vpcName, Long vpcId, String networkName, Long networkId, String prevNetworkName, boolean isL2); /** * Deletes an existing vNet resource. @@ -120,9 +125,10 @@ public interface NetrisService { * @param networkName the name of the network * @param networkId the ID of the network * @param cidr the CIDR of the network + * @param isL2 true if it is an L2 network * @return true if the operation is successful, false otherwise */ - boolean deleteVnetResource(long zoneId, long accountId, long domainId, String vpcName, Long vpcId, String networkName, Long networkId, String cidr); + boolean deleteVnetResource(long zoneId, long accountId, long domainId, String vpcName, Long vpcId, String networkName, Long networkId, String cidr, boolean isL2); /** * Creates a source NAT rule for a VPC or network. diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java index 2aa97b65a3d5..e12a709e6f5c 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java @@ -22,6 +22,7 @@ import java.util.Map; import org.apache.cloudstack.region.PortableIp; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.EnumUtils; import org.apache.commons.lang3.StringUtils; @@ -91,7 +92,9 @@ public enum ApiCommandResourceType { Extension(org.apache.cloudstack.extension.Extension.class), ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class), KmsKey(org.apache.cloudstack.kms.KMSKey.class), - HsmProfile(org.apache.cloudstack.kms.HSMProfile.class); + HsmProfile(org.apache.cloudstack.kms.HSMProfile.class), + InstanceBootGroup(org.apache.cloudstack.vm.bootgroup.InstanceBootGroup.class), + InstanceBootGroupReadinessRule(InstanceBootGroupReadinessRule.class); private final Class clazz; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index f74c46161180..7485029f22cb 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -93,6 +93,23 @@ public class ApiConstants { public static final String CAPACITY = "capacity"; public static final String CATEGORY = "category"; public static final String CAN_REVERT = "canrevert"; + public static final String BOOT_GROUP_ID = "bootgroupid"; + public static final String BOOT_ORDER = "order"; + public static final String MEMBER_TYPE = "membertype"; + public static final String MEMBER_ID = "memberid"; + public static final String MEMBER_NAME = "membername"; + public static final String MEMBER_STATE = "memberstate"; + public static final String CHILDREN = "children"; + public static final String RULE_TYPE = "ruletype"; + public static final String INHERITED = "inherited"; + public static final String READINESS_MODE = "readinessmode"; + public static final String READINESS_STATUS = "readinessstatus"; + public static final String READINESS_MESSAGE = "readinessmessage"; + public static final String READINESS_ATTEMPT_TIMEOUT_SECONDS = "readinessattempttimeoutseconds"; + public static final String READINESS_MAX_RETRY_ATTEMPTS = "readinessmaxretryattempts"; + public static final String READINESS_REBOOT_ON_RETRY = "readinessrebootonretry"; + public static final String READINESS_INITIAL_DELAY_SECONDS = "readinessinitialdelayseconds"; + public static final String IGNORE_INSTANCE_STATE = "ignoreinstancestate"; public static final String CA_CERTIFICATES = "cacertificates"; public static final String CERTIFICATE = "certificate"; public static final String CERTIFICATE_CHAIN = "certchain"; @@ -322,6 +339,7 @@ public class ApiConstants { public static final String INTERNAL_DNS2 = "internaldns2"; public static final String INTERNET_PROTOCOL = "internetprotocol"; public static final String INTERVAL_TYPE = "intervaltype"; + public static final String INSTANCE_GROUP_ID = "instancegroupid"; public static final String INSTANCE_LEASE_DURATION = "leaseduration"; public static final String INSTANCE_LEASE_ENABLED = "instanceleaseenabled"; public static final String INSTANCE_LEASE_EXPIRY_ACTION = "leaseexpiryaction"; @@ -653,6 +671,29 @@ public class ApiConstants { public static final String USER_SECURITY_GROUP_LIST = "usersecuritygrouplist"; public static final String USER_SECRET_KEY = "usersecretkey"; public static final String USE_VDDK = "usevddk"; + public static final String VMWARE_MIGRATION_MODE = "vmwaremigrationmode"; + public static final String VMWARE_CBT_MIGRATION_ID = "vmwarecbtmigrationid"; + public static final String SOURCE_HOST = "sourcehost"; + public static final String SOURCE_CLUSTER = "sourcecluster"; + public static final String SOURCE_VM_NAME = "sourcevmname"; + public static final String SOURCE_DISK_ID = "sourcediskid"; + public static final String SOURCE_DISK_DEVICE_KEY = "sourcediskdevicekey"; + public static final String SOURCE_DISK_PATH = "sourcediskpath"; + public static final String TARGET_PATH = "targetpath"; + public static final String TARGET_FORMAT = "targetformat"; + public static final String CHANGE_ID = "changeid"; + public static final String SNAPSHOT_MOR = "snapshotmor"; + public static final String CURRENT_STEP = "currentstep"; + public static final String LAST_ERROR = "lasterror"; + public static final String COMPLETED_CYCLES = "completedcycles"; + public static final String QUIET_CYCLES = "quietcycles"; + public static final String LAST_CHANGED_BYTES = "lastchangedbytes"; + public static final String LAST_DIRTY_RATE = "lastdirtyrate"; + public static final String TOTAL_CHANGED_BYTES = "totalchangedbytes"; + public static final String CYCLE = "cycle"; + public static final String CYCLE_NUMBER = "cyclenumber"; + public static final String CHANGED_BYTES = "changedbytes"; + public static final String DIRTY_RATE = "dirtyrate"; public static final String USE_VIRTUAL_NETWORK = "usevirtualnetwork"; public static final String USE_VIRTUAL_ROUTER_IP_RESOLVER = "userouteripresolver"; public static final String UPDATE_IN_SEQUENCE = "updateinsequence"; @@ -1342,6 +1383,7 @@ public class ApiConstants { public static final String HAS_RULES = "hasrules"; public static final String NSX_DETAIL_KEY = "forNsx"; public static final String NETRIS_DETAIL_KEY = "forNetris"; + public static final String NETRIS_NAT_PUBLIC_IP_ID = "netrisnatpublicipid"; public static final String NETRIS_TAG = "netristag"; public static final String NETRIS_VXLAN_ID = "netrisvxlanid"; public static final String NETRIS_URL = "netrisurl"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java index 9b42be137314..4b4d0dc94825 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java @@ -52,6 +52,7 @@ import static com.cloud.network.Network.Service.StaticNat; import static com.cloud.network.Network.Service.UserData; +import static com.cloud.network.Network.Service.Vpn; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNsxWithoutLb; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisNatted; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisRouted; @@ -290,6 +291,10 @@ public List getSupportedServices() { } else { services.add(Firewall.getName()); } + + if (isNetrisNatted(getProvider(), getNetworkMode())) { + services.add(Vpn.getName()); + } return services; } } @@ -395,6 +400,9 @@ else if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMo serviceProviderMap.remove(Lb.getName()); } } + if (isNetrisNatted(getProvider(), getNetworkMode())) { + serviceProviderMap.put(Vpn.getName(), List.of(routerProvider)); + } } public Map getServiceCapabilities(Network.Service service) { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..fa4fc4f7bb6f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CancelVmwareCbtMigrationCmd.java @@ -0,0 +1,73 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "cancelVmwareCbtMigration", + description = "Cancel a VMware CBT migration session", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class CancelVmwareCbtMigrationCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + public Long getId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.cancelVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java new file mode 100644 index 000000000000..cbbadc090ce5 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CheckVmwareCbtMigrationPrerequisitesCmd.java @@ -0,0 +1,204 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ClusterResponse; +import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.ServiceOfferingResponse; +import org.apache.cloudstack.api.response.StoragePoolResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightResponse; +import org.apache.cloudstack.api.response.VmwareDatacenterResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; +import org.apache.commons.collections.MapUtils; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "checkVmwareCbtMigrationPrerequisites", + description = "Check whether a VMware VM and KVM destination are ready for VMware CBT based migration", + responseObject = VmwareCbtMigrationPreflightResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class CheckVmwareCbtMigrationPrerequisitesCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + required = true, description = "the destination zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.CLUSTER_ID, type = CommandType.UUID, entityType = ClusterResponse.class, + required = true, description = "the destination KVM cluster ID") + private Long clusterId; + + @Parameter(name = ApiConstants.SOURCE_VM_NAME, type = CommandType.STRING, + required = true, description = "the source VMware VM name") + private String sourceVmName; + + @Parameter(name = ApiConstants.EXISTING_VCENTER_ID, type = CommandType.UUID, entityType = VmwareDatacenterResponse.class, + description = "UUID of a linked existing vCenter") + private Long existingVcenterId; + + @Parameter(name = ApiConstants.VCENTER, type = CommandType.STRING, + description = "the source vCenter IP address or FQDN") + private String vcenter; + + @Parameter(name = ApiConstants.DATACENTER_NAME, type = CommandType.STRING, + description = "the source VMware datacenter name") + private String datacenterName; + + @Parameter(name = ApiConstants.CLUSTER_NAME, type = CommandType.STRING, + description = "the source VMware cluster name") + private String sourceCluster; + + @Parameter(name = ApiConstants.HOST_IP, type = CommandType.STRING, + description = "the source VMware ESXi host IP address or FQDN") + private String sourceHost; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "the username for the source vCenter") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "the password for the source vCenter") + private String password; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, + description = "optional KVM host to perform CBT block replication") + private Long convertInstanceHostId; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, + description = "optional primary storage pool for converted disks") + private Long storagePoolId; + + @Parameter(name = ApiConstants.SERVICE_OFFERING_ID, type = CommandType.UUID, entityType = ServiceOfferingResponse.class, + description = "optional service offering to validate against the source VM CPU and memory requirements") + private Long serviceOfferingId; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "optional service offering custom parameters and migration details") + private Map details; + + public Long getZoneId() { + return zoneId; + } + + public Long getClusterId() { + return clusterId; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenterName() { + return datacenterName; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public Long getConvertInstanceHostId() { + return convertInstanceHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + @SuppressWarnings("unchecked") + public Map getDetails() { + Map params = new HashMap<>(); + if (MapUtils.isEmpty(details)) { + return params; + } + + for (Object value : details.values()) { + if (!(value instanceof Map)) { + continue; + } + Map detailMap = (Map)value; + for (Map.Entry entry : detailMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + params.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + } + return params; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationPreflightResponse response = vmwareCbtMigrationManager.checkVmwareCbtMigrationPrerequisites(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..61aedf31f4a2 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CutoverVmwareCbtMigrationCmd.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "cutoverVmwareCbtMigration", + description = "Perform final cutover for a VMware CBT migration", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class CutoverVmwareCbtMigrationCmd extends BaseAsyncCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "optional username override for the source vCenter. Stored external vCenter credentials are used when available") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "optional password override for the source vCenter. Stored external vCenter credentials are used when available") + private String password; + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_VMWARE_CBT_MIGRATION_CUTOVER; + } + + @Override + public String getEventDescription() { + return String.format("Cutting over VMware CBT migration: %s", id); + } + + @Override + public String getSyncObjType() { + return BaseAsyncCmd.migrationSyncObject; + } + + @Override + public Long getSyncObjId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.cutoverVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..67163814b23a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/DeleteVmwareCbtMigrationCmd.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "deleteVmwareCbtMigration", + description = "Deletes a failed, cancelled or completed VMware CBT migration record. Failed and cancelled migrations can optionally clean up replicated target disks; completed migrations are record-only deletes.", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class DeleteVmwareCbtMigrationCmd extends BaseCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.CLEANUP, type = CommandType.BOOLEAN, + description = "If true, clean up replicated target disks before removing a failed or cancelled migration record. True by default. Ignored for completed migrations.") + private Boolean cleanup; + + public Long getId() { + return id; + } + + public boolean getCleanup() { + return cleanup == null || cleanup; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + boolean result = vmwareCbtMigrationManager.deleteVmwareCbtMigration(this); + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setSuccess(result); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceCmd.java index 3284dbafe7ca..b42692ce4cf7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportUnmanagedInstanceCmd.java @@ -115,7 +115,7 @@ public class ImportUnmanagedInstanceCmd extends BaseAsyncCmd { @Parameter(name = ApiConstants.TEMPLATE_ID, type = CommandType.UUID, entityType = TemplateResponse.class, - description = "The ID of the Template for the Instance") + description = "The ID of the Template for the Instance. Required when the instance to import is an unmanaged KVM Instance.") private Long templateId; @Parameter(name = ApiConstants.SERVICE_OFFERING_ID, diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ImportVmCmd.java index db7dcc3fb44f..1d9b9504750e 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 ///////////////////// ///////////////////////////////////////////////////// @@ -92,7 +110,7 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { @Parameter(name = ApiConstants.DISK_PATH, type = CommandType.STRING, - description = "path of the disk image") + description = "path of the disk image. It is the file name on file based storage pools (NFS, Local, SharedMountPoint), and the image name on RBD storage pools") private String diskPath; @Parameter(name = ApiConstants.IMPORT_SOURCE, @@ -153,7 +171,8 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { private Long importInstanceHostId; @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, - description = "(only for importing VMs from VMware to KVM) optional - the temporary storage pool to perform the virt-v2v migration from VMware to KVM.") + description = "(only for importing VMs from VMware to KVM) optional - the temporary storage pool to perform the virt-v2v migration from VMware to KVM. " + + "When " + ApiConstants.USE_VDDK + " and " + ApiConstants.FORCE_CONVERT_TO_POOL + " are true, this can be an RBD primary storage pool for direct RBD import.") private Long convertStoragePoolId; @Parameter(name = ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES, type = CommandType.BOOLEAN, @@ -183,9 +202,18 @@ public class ImportVmCmd extends ImportUnmanagedInstanceCmd { type = CommandType.BOOLEAN, since = "4.22.1", description = "(only for importing VMs from VMware to KVM) optional - if true, uses VDDK on the KVM conversion host for converting the VM. " + - "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ".") + "This parameter is mutually exclusive with " + ApiConstants.FORCE_MS_TO_IMPORT_VM_FILES + ". " + + "With " + ApiConstants.FORCE_CONVERT_TO_POOL + "=true and an RBD conversion pool, the source VMware VM must be powered off and " + + "the conversion host must support qemu RBD access and in-place virt-v2v finalization.") private Boolean useVddk; + @Parameter(name = ApiConstants.VMWARE_MIGRATION_MODE, + type = CommandType.STRING, + since = "4.22.1", + description = "(only for importing VMs from VMware to KVM) optional - migration mode to use. Valid values are OVF, VDDK, and CBT. " + + "When omitted, CloudStack preserves the existing usevddk behavior.") + private String vmwareMigrationMode; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// @@ -267,6 +295,10 @@ public boolean getUseVddk() { return BooleanUtils.toBooleanDefaultIfNull(useVddk, true); } + public String getVmwareMigrationMode() { + return vmwareMigrationMode; + } + public String getTmpPath() { return tmpPath; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java new file mode 100644 index 000000000000..3ea5f53970cf --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/ListVmwareCbtMigrationsCmd.java @@ -0,0 +1,109 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; + +@APICommand(name = "listVmwareCbtMigrations", + description = "List VMware CBT based migration sessions", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class ListVmwareCbtMigrationsCmd extends BaseListCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "the destination zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "the account ID") + private Long accountId; + + @Parameter(name = ApiConstants.VCENTER, type = CommandType.STRING, + description = "the source VMware vCenter") + private String vcenter; + + @Parameter(name = ApiConstants.SOURCE_VM_NAME, type = CommandType.STRING, + description = "the source VMware VM name") + private String sourceVmName; + + @Parameter(name = ApiConstants.STATE, type = CommandType.STRING, + description = "the migration state") + private String state; + + public Long getId() { + return id; + } + + public Long getZoneId() { + return zoneId; + } + + public Long getAccountId() { + return accountId; + } + + public String getVcenter() { + return vcenter; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getState() { + return state; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + ListResponse response = vmwareCbtMigrationManager.listVmwareCbtMigrations(this); + response.setResponseName(getCommandName()); + response.setObjectName("vmwarecbtmigration"); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..0746e40c34ca --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/StartVmwareCbtMigrationCmd.java @@ -0,0 +1,361 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ClusterResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.GuestOSResponse; +import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.ProjectResponse; +import org.apache.cloudstack.api.response.ServiceOfferingResponse; +import org.apache.cloudstack.api.response.StoragePoolResponse; +import org.apache.cloudstack.api.response.TemplateResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.api.response.VmwareDatacenterResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; +import com.cloud.offering.DiskOffering; +import com.cloud.user.Account; +import com.cloud.utils.net.NetUtils; +import com.cloud.vm.VmDetailConstants; + +@APICommand(name = "startVmwareCbtMigration", + description = "Start tracking a VMware CBT based migration session", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class StartVmwareCbtMigrationCmd extends BaseAsyncCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + required = true, description = "the destination zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.CLUSTER_ID, type = CommandType.UUID, entityType = ClusterResponse.class, + required = true, description = "the destination KVM cluster ID") + private Long clusterId; + + @Parameter(name = ApiConstants.DISPLAY_NAME, type = CommandType.STRING, + description = "the display name of the migrated VM") + private String displayName; + + @Parameter(name = ApiConstants.HOST_NAME, type = CommandType.STRING, + description = "the host name of the migrated VM") + private String hostName; + + @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, + description = "optional account for the migrated VM. Must be used with domainid") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "import the migrated VM to the specified domain") + private Long domainId; + + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, + description = "import the migrated VM for the project") + private Long projectId; + + @Parameter(name = ApiConstants.TEMPLATE_ID, type = CommandType.UUID, entityType = TemplateResponse.class, + description = "the template ID for the migrated VM") + private Long templateId; + + @Parameter(name = ApiConstants.SERVICE_OFFERING_ID, type = CommandType.UUID, entityType = ServiceOfferingResponse.class, + required = true, description = "the service offering for the migrated VM") + private Long serviceOfferingId; + + @Parameter(name = ApiConstants.OS_ID, type = CommandType.UUID, entityType = GuestOSResponse.class, + description = "optional guest OS ID for the migrated VM") + private Long guestOsId; + + @Parameter(name = ApiConstants.SOURCE_VM_NAME, type = CommandType.STRING, + required = true, description = "the source VMware VM name") + private String sourceVmName; + + @Parameter(name = ApiConstants.EXISTING_VCENTER_ID, type = CommandType.UUID, entityType = VmwareDatacenterResponse.class, + description = "UUID of a linked existing vCenter") + private Long existingVcenterId; + + @Parameter(name = ApiConstants.VCENTER, type = CommandType.STRING, + description = "the source vCenter IP address or FQDN") + private String vcenter; + + @Parameter(name = ApiConstants.DATACENTER_NAME, type = CommandType.STRING, + description = "the source VMware datacenter name") + private String datacenterName; + + @Parameter(name = ApiConstants.CLUSTER_NAME, type = CommandType.STRING, + description = "the source VMware cluster name") + private String sourceCluster; + + @Parameter(name = ApiConstants.HOST_IP, type = CommandType.STRING, + description = "the source VMware ESXi host IP address or FQDN") + private String sourceHost; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "the username for the source vCenter") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "the password for the source vCenter") + private String password; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, + description = "optional KVM host to perform virt-v2v conversion and CBT block replication") + private Long convertInstanceHostId; + + @Parameter(name = ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, + description = "optional primary storage pool for converted disks") + private Long storagePoolId; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "optional migration details, including VDDK settings such as vddk.lib.dir, vddk.transports and vddk.thumbprint") + private Map details; + + @Parameter(name = ApiConstants.NIC_NETWORK_LIST, type = CommandType.MAP, + description = "VM NIC to network id mapping using keys NIC and network") + private Map nicNetworkList; + + @Parameter(name = ApiConstants.NIC_IP_ADDRESS_LIST, type = CommandType.MAP, + description = "VM NIC to ip address mapping using keys NIC, ip4Address") + private Map nicIpAddressList; + + @Parameter(name = ApiConstants.DATADISK_OFFERING_LIST, type = CommandType.MAP, + description = "datadisk to disk-offering mapping using keys disk and diskOffering") + private Map dataDiskToDiskOfferingList; + + @Parameter(name = ApiConstants.FORCED, type = CommandType.BOOLEAN, + description = "import despite duplicate NIC MAC addresses; CloudStack generates a new MAC address when a duplicate exists") + private Boolean forced; + + public Long getZoneId() { + return zoneId; + } + + public Long getClusterId() { + return clusterId; + } + + public String getDisplayName() { + return displayName; + } + + public String getHostName() { + return hostName; + } + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + public Long getProjectId() { + return projectId; + } + + public Long getTemplateId() { + return templateId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + public Long getGuestOsId() { + return guestOsId; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenterName() { + return datacenterName; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public Long getConvertInstanceHostId() { + return convertInstanceHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + @SuppressWarnings("unchecked") + public Map getDetails() { + Map params = new HashMap<>(); + if (MapUtils.isEmpty(details)) { + return params; + } + + for (Object value : details.values()) { + if (!(value instanceof Map)) { + continue; + } + Map detailMap = (Map)value; + for (Map.Entry entry : detailMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + params.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + } + return params; + } + + @SuppressWarnings("unchecked") + public Map getNicNetworkList() { + Map nicNetworkMap = new HashMap<>(); + if (MapUtils.isNotEmpty(nicNetworkList)) { + for (Map entry : (Collection>) nicNetworkList.values()) { + String nic = entry.get(VmDetailConstants.NIC); + String networkUuid = entry.get(VmDetailConstants.NETWORK); + if (StringUtils.isAnyEmpty(nic, networkUuid) || _entityMgr.findByUuid(Network.class, networkUuid) == null) { + throw new InvalidParameterValueException(String.format("Network ID: %s for NIC ID: %s is invalid", networkUuid, nic)); + } + nicNetworkMap.put(nic, _entityMgr.findByUuid(Network.class, networkUuid).getId()); + } + } + return nicNetworkMap; + } + + @SuppressWarnings("unchecked") + public Map getNicIpAddressList() { + Map nicIpAddressMap = new HashMap<>(); + if (MapUtils.isNotEmpty(nicIpAddressList)) { + for (Map entry : (Collection>) nicIpAddressList.values()) { + String nic = entry.get(VmDetailConstants.NIC); + String ipAddress = StringUtils.defaultIfEmpty(entry.get(VmDetailConstants.IP4_ADDRESS), null); + if (StringUtils.isEmpty(nic)) { + throw new InvalidParameterValueException(String.format("NIC ID: '%s' is invalid for IP address mapping", nic)); + } + if (StringUtils.isEmpty(ipAddress)) { + throw new InvalidParameterValueException(String.format("Empty address for NIC ID: %s is invalid", nic)); + } + if (StringUtils.isNotEmpty(ipAddress) && !ipAddress.equals("auto") && !NetUtils.isValidIp4(ipAddress)) { + throw new InvalidParameterValueException(String.format("IP address '%s' for NIC ID: %s is invalid", ipAddress, nic)); + } + nicIpAddressMap.put(nic, new Network.IpAddresses(ipAddress, null)); + } + } + return nicIpAddressMap; + } + + @SuppressWarnings("unchecked") + public Map getDataDiskToDiskOfferingList() { + Map dataDiskToDiskOfferingMap = new HashMap<>(); + if (MapUtils.isNotEmpty(dataDiskToDiskOfferingList)) { + for (Map entry : (Collection>) dataDiskToDiskOfferingList.values()) { + String disk = entry.get(VmDetailConstants.DISK); + String offeringUuid = entry.get(VmDetailConstants.DISK_OFFERING); + if (StringUtils.isAnyEmpty(disk, offeringUuid) || _entityMgr.findByUuid(DiskOffering.class, offeringUuid) == null) { + throw new InvalidParameterValueException(String.format("Disk offering ID: %s for disk ID: %s is invalid", offeringUuid, disk)); + } + dataDiskToDiskOfferingMap.put(disk, _entityMgr.findByUuid(DiskOffering.class, offeringUuid).getId()); + } + } + return dataDiskToDiskOfferingMap; + } + + public boolean isForced() { + return BooleanUtils.isTrue(forced); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_VMWARE_CBT_MIGRATION_START; + } + + @Override + public String getEventDescription() { + return String.format("Starting VMware CBT migration for source VM: %s", sourceVmName); + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.startVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); + if (accountId != null) { + return accountId; + } + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java new file mode 100644 index 000000000000..7589360564a5 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/SyncVmwareCbtMigrationCmd.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.vm; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.VmwareCbtMigrationManager; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; + +@APICommand(name = "syncVmwareCbtMigration", + description = "Run a VMware CBT delta synchronization cycle", + responseObject = VmwareCbtMigrationResponse.class, + responseView = ResponseObject.ResponseView.Full, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.24.0") +public class SyncVmwareCbtMigrationCmd extends BaseAsyncCmd { + + @Inject + public VmwareCbtMigrationManager vmwareCbtMigrationManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VmwareCbtMigrationResponse.class, + required = true, description = "the VMware CBT migration ID") + private Long id; + + @Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, + description = "optional username override for the source vCenter. Stored external vCenter credentials are used when available") + private String username; + + @Parameter(name = ApiConstants.PASSWORD, type = CommandType.STRING, + description = "optional password override for the source vCenter. Stored external vCenter credentials are used when available") + private String password; + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_VMWARE_CBT_MIGRATION_SYNC; + } + + @Override + public String getEventDescription() { + return String.format("Synchronizing VMware CBT migration: %s", id); + } + + @Override + public String getSyncObjType() { + return BaseAsyncCmd.migrationSyncObject; + } + + @Override + public Long getSyncObjId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + VmwareCbtMigrationResponse response = vmwareCbtMigrationManager.syncVmwareCbtMigration(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Account account = CallContext.current().getCallingAccount(); + return account == null ? Account.ACCOUNT_ID_SYSTEM : account.getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/ImportVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/ImportVolumeCmd.java index 50f4b9c1fbe5..db5961ac7f8b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/ImportVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/ImportVolumeCmd.java @@ -63,7 +63,7 @@ public class ImportVolumeCmd extends BaseAsyncCmd { @Parameter(name = ApiConstants.PATH, type = BaseCmd.CommandType.STRING, required = true, - description = "the path of the volume") + description = "the path of the volume. It is the file name on file based storage pools (NFS, Local, SharedMountPoint), and the image name on RBD storage pools") private String path; @Parameter(name = ApiConstants.NAME, diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java index 2b934a60da7a..a31cf1a435d2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java @@ -24,6 +24,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -60,7 +61,9 @@ import static com.cloud.network.Network.Service.NetworkACL; import static com.cloud.network.Network.Service.UserData; import static com.cloud.network.Network.Service.Gateway; +import static com.cloud.network.Network.Service.Vpn; +import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetris; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisNatted; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisRouted; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNsxWithoutLb; @@ -192,7 +195,7 @@ public List getSupportedServices() { NetworkACL.getName(), UserData.getName() )); - if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode())) { + if (isNetrisNatted(getProvider(), getNetworkMode())) { supportedServices.addAll(Arrays.asList( StaticNat.getName(), SourceNat.getName(), @@ -201,9 +204,13 @@ public List getSupportedServices() { if (NetworkOffering.NetworkMode.ROUTED.name().equalsIgnoreCase(getNetworkMode())) { supportedServices.add(Gateway.getName()); } + if (getNsxSupportsLbService() || isNetrisNatted(getProvider(), getNetworkMode())) { supportedServices.add(Lb.getName()); } + if (isNetrisNatted(getProvider(), getNetworkMode())) { + supportedServices.add(Vpn.getName()); + } } return supportedServices; } @@ -213,7 +220,7 @@ public String getProvider() { } public String getNetworkMode() { - return networkMode; + return isNetris(getProvider()) && Objects.isNull(networkMode) ? NetworkOffering.NetworkMode.NATTED.name() : networkMode; } public boolean getNsxSupportsLbService() { @@ -249,7 +256,7 @@ public Map> getServiceProviders() { private void getServiceProviderMapForExternalProvider(Map> serviceProviderMap, String provider) { List unsupportedServices = new ArrayList<>(List.of("Vpn", "BaremetalPxeService", "SecurityGroup", "Connectivity", "Firewall")); - if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode())) { + if (isNetrisNatted(getProvider(), getNetworkMode())) { unsupportedServices.add("Gateway"); } List routerSupported = List.of("Dhcp", "Dns", "UserData"); @@ -259,7 +266,7 @@ private void getServiceProviderMapForExternalProvider(Map> continue; if (routerSupported.contains(service)) serviceProviderMap.put(service, List.of(VirtualRouterProvider.Type.VPCVirtualRouter.name())); - else if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode()) || + else if (isNetrisNatted(getProvider(), getNetworkMode()) || Stream.of(NetworkACL.getName(), Gateway.getName()).anyMatch(s -> s.equalsIgnoreCase(service))) { serviceProviderMap.put(service, List.of(provider)); } @@ -269,7 +276,20 @@ else if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMo } } + @SuppressWarnings("unchecked") public Map> getServiceCapabilityList() { + // For Netris NATTED, Gateway service is excluded so the redundant-router capability + // must reference SourceNat (not Gateway). + if (serviceCapabilityList != null + && isNetrisNatted(getProvider(), getNetworkMode())) { + for (Object capEntry : serviceCapabilityList.values()) { + Map cap = (Map) capEntry; + if (Network.Capability.RedundantRouter.getName().equalsIgnoreCase(cap.get("capabilitytype")) + && Gateway.getName().equalsIgnoreCase(cap.get("service"))) { + cap.put("service", SourceNat.getName()); + } + } + } return serviceCapabilityList; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/AddMemberToInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/AddMemberToInstanceBootGroupCmd.java new file mode 100644 index 000000000000..901ca91ff83c --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/AddMemberToInstanceBootGroupCmd.java @@ -0,0 +1,107 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.InstanceGroupResponse; +import org.apache.cloudstack.api.response.UserVmResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "addMemberToInstanceBootGroup", + description = "Adds an Instance or instance group to an instance boot group. Exactly one of virtualmachineid or instancegroupid must be specified.", + since = "24.0", + responseObject = InstanceBootGroupMemberResponse.class, + entityType = {InstanceBootGroupMember.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class AddMemberToInstanceBootGroupCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, description = "The ID of the instance boot group") + private Long id; + + @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, entityType = UserVmResponse.class, description = "The ID of the Instance to add (exclusive with instancegroupid)") + private Long virtualMachineId; + + @Parameter(name = ApiConstants.INSTANCE_GROUP_ID, type = CommandType.UUID, entityType = InstanceGroupResponse.class, description = "The ID of the Instance Group to add (exclusive with virtualmachineid)") + private Long instanceGroupId; + + @Parameter(name = ApiConstants.BOOT_ORDER, type = CommandType.INTEGER, required = true, + description = "The boot order value for this member (0 or greater; non-contiguous values are allowed). " + + "Any existing member already at or past this value is shifted one slot later to make room") + private int order; + + public Long getId() { + return id; + } + + public Long getVirtualMachineId() { + return virtualMachineId; + } + + public Long getInstanceGroupId() { + return instanceGroupId; + } + + public int getOrder() { + return order; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroupMember result = instanceBootGroupService.addMemberToInstanceBootGroup(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to add member to instance boot group"); + } + InstanceBootGroupMemberResponse response = instanceBootGroupService.createInstanceBootGroupMemberResponse(result); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupCmd.java new file mode 100644 index 000000000000..29c0f8a344f1 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupCmd.java @@ -0,0 +1,142 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.ProjectResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "createInstanceBootGroup", + description = "Creates an Instance Boot Group", + since = "24.0", + responseObject = InstanceBootGroupResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CreateInstanceBootGroupCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "The name of the Instance Boot Group") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, description = "The description of the Instance Boot Group") + private String description; + + @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, description = "The account of the Instance Boot Group. Must be used with domainid") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "The domain ID of the account owning the Instance Boot Group") + private Long domainId; + + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, description = "The project of the Instance Boot Group") + private Long projectId; + + @Parameter(name = ApiConstants.READINESS_ATTEMPT_TIMEOUT_SECONDS, type = CommandType.LONG, + description = "Per-boot-group override of the global timeout (seconds) for each readiness retry attempt") + private Long readinessAttemptTimeoutSeconds; + + @Parameter(name = ApiConstants.READINESS_MAX_RETRY_ATTEMPTS, type = CommandType.LONG, + description = "Per-boot-group override of the global maximum number of readiness retry attempts") + private Long readinessMaxRetryAttempts; + + @Parameter(name = ApiConstants.READINESS_REBOOT_ON_RETRY, type = CommandType.BOOLEAN, + description = "Per-boot-group override of whether an instance is rebooted between readiness retry attempts") + private Boolean readinessRebootOnRetry; + + @Parameter(name = ApiConstants.READINESS_INITIAL_DELAY_SECONDS, type = CommandType.LONG, + description = "Per-boot-group override of the global delay (seconds) after starting or rebooting an instance before its first readiness check of that attempt") + private Long readinessInitialDelaySeconds; + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + public Long getProjectId() { + return projectId; + } + + public Long getReadinessAttemptTimeoutSeconds() { + return readinessAttemptTimeoutSeconds; + } + + public Long getReadinessMaxRetryAttempts() { + return readinessMaxRetryAttempts; + } + + public Boolean getReadinessRebootOnRetry() { + return readinessRebootOnRetry; + } + + public Long getReadinessInitialDelaySeconds() { + return readinessInitialDelaySeconds; + } + + @Override + public long getEntityOwnerId() { + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); + if (accountId == null) { + return CallContext.current().getCallingAccount().getId(); + } + return accountId; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroup result = instanceBootGroupService.createInstanceBootGroup(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to create instance boot group"); + } + InstanceBootGroupResponse response = instanceBootGroupService.createInstanceBootGroupResponse(result.getId()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupReadinessRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupReadinessRuleCmd.java new file mode 100644 index 000000000000..43c5dccc5126 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupReadinessRuleCmd.java @@ -0,0 +1,132 @@ +// 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.user.bootgroup; + +import java.util.Collection; +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.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.InstanceGroupResponse; +import org.apache.cloudstack.api.response.UserVmResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "createInstanceBootGroupReadinessRule", + description = "Creates a readiness rule for an Instance or Instance Group that is a member (directly, or via its instance group) of an Instance Boot Group", + since = "24.0", + responseObject = InstanceBootGroupReadinessRuleResponse.class, + entityType = {InstanceBootGroupReadinessRule.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CreateInstanceBootGroupReadinessRuleCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.BOOT_GROUP_ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, + description = "The ID of the Instance Boot Group this rule belongs to") + private Long bootGroupId; + + @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, entityType = UserVmResponse.class, + description = "The ID of the Instance this rule applies to (exclusive with instancegroupid)") + private Long virtualMachineId; + + @Parameter(name = ApiConstants.INSTANCE_GROUP_ID, type = CommandType.UUID, entityType = InstanceGroupResponse.class, + description = "The ID of the Instance Group this rule applies to (exclusive with virtualmachineid)") + private Long instanceGroupId; + + @Parameter(name = ApiConstants.RULE_TYPE, type = CommandType.STRING, required = true, + description = "The readiness rule type: GuestAgentLiveness, Ping, PortCheck or MemberQuorum") + private String ruleType; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "The name of the readiness rule; auto-generated if not provided") + private String name; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "Whether the rule is enabled; defaults to true") + private Boolean enabled; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "Rule-type-specific configuration, e.g. port/protocol, script, threshold_type/threshold_value") + private Map details; + + public Long getBootGroupId() { + return bootGroupId; + } + + public Long getVirtualMachineId() { + return virtualMachineId; + } + + public Long getInstanceGroupId() { + return instanceGroupId; + } + + public String getRuleType() { + return ruleType; + } + + public String getName() { + return name; + } + + public boolean isEnabled() { + return enabled == null || enabled; + } + + public Map getDetails() { + if (this.details == null || this.details.isEmpty()) { + return null; + } + Collection paramsCollection = this.details.values(); + return (Map) (paramsCollection.toArray())[0]; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroupReadinessRule; + } + + @Override + public void execute() { + InstanceBootGroupReadinessRule result = instanceBootGroupService.createInstanceBootGroupReadinessRule(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to create instance boot group readiness rule"); + } + InstanceBootGroupReadinessRuleResponse response = instanceBootGroupService.createInstanceBootGroupReadinessRuleResponse(result); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupCmd.java new file mode 100644 index 000000000000..8c6c5d7fa3e4 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupCmd.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "deleteInstanceBootGroup", + description = "Deletes an Instance Boot Group", + since = "24.0", + responseObject = SuccessResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DeleteInstanceBootGroupCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, description = "The ID of the Instance Boot Group") + private Long id; + + public Long getId() { + return id; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + boolean result = instanceBootGroupService.deleteInstanceBootGroup(this); + if (!result) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete instance boot group"); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupReadinessRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupReadinessRuleCmd.java new file mode 100644 index 000000000000..1a8a1bc79e42 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupReadinessRuleCmd.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "deleteInstanceBootGroupReadinessRule", + description = "Deletes an Instance Boot Group readiness rule", + since = "24.0", + responseObject = SuccessResponse.class, + entityType = {InstanceBootGroupReadinessRule.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DeleteInstanceBootGroupReadinessRuleCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupReadinessRuleResponse.class, required = true, + description = "The ID of the Instance Boot Group readiness rule") + private Long id; + + public Long getId() { + return id; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroupReadinessRule; + } + + @Override + public void execute() { + boolean result = instanceBootGroupService.deleteInstanceBootGroupReadinessRule(this); + if (!result) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete instance boot group readiness rule"); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupMembersCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupMembersCmd.java new file mode 100644 index 000000000000..e8af5ddf4feb --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupMembersCmd.java @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +import java.util.List; + +import javax.inject.Inject; + +import org.apache.commons.lang3.BooleanUtils; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "listInstanceBootGroupMembers", + description = "Lists members of an Instance Boot Group, sorted by boot order", + since = "24.0", + responseObject = InstanceBootGroupMemberResponse.class, + entityType = {InstanceBootGroupMember.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListInstanceBootGroupMembersCmd extends BaseListCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.BOOT_GROUP_ID, type = CommandType.UUID, required = true, entityType = InstanceBootGroupResponse.class, description = "The ID of the Instance Boot Group") + private Long bootGroupId; + + @Parameter(name = ApiConstants.MEMBER_TYPE, type = CommandType.STRING, description = "Filter by member type: VirtualMachine or InstanceGroup") + private String memberType; + + @Parameter(name = ApiConstants.DETAILS, + type = CommandType.LIST, + collectionType = CommandType.STRING, + description = "Comma separated list of additional details requested, value can be a list of [all, readiness, children]. " + + "Readiness fields are computed from cached check results (not a live re-check) and are omitted unless requested, since computing them is not free. " + + "Children returns the member Instances of InstanceGroup-type members (omitted for VirtualMachine-type members); combine with readiness to also include per-child readiness") + private List viewDetails; + + @Parameter(name = ApiConstants.IGNORE_INSTANCE_STATE, type = CommandType.BOOLEAN, + description = "If true, readiness status/message reflect the last cached rule check regardless of the member's current instance state. " + + "If false (default), an Instance that isn't Running is always reported NotReady, even if its rules were last cached Ready") + private Boolean ignoreInstanceState; + + public Long getBootGroupId() { + return bootGroupId; + } + + public String getMemberType() { + return memberType; + } + + public boolean isIgnoreInstanceState() { + return BooleanUtils.toBoolean(ignoreInstanceState); + } + + public boolean isReadinessDetailRequested() { + return viewDetails != null && (viewDetails.contains("readiness") || viewDetails.contains("all")); + } + + public boolean isChildrenDetailRequested() { + return viewDetails != null && (viewDetails.contains("children") || viewDetails.contains("all")); + } + + @Override + public void execute() { + ListResponse response = instanceBootGroupService.listInstanceBootGroupMembers(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupReadinessRulesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupReadinessRulesCmd.java new file mode 100644 index 000000000000..34db56662efe --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupReadinessRulesCmd.java @@ -0,0 +1,91 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.InstanceGroupResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.UserVmResponse; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "listInstanceBootGroupReadinessRules", + description = "Lists readiness rules for an Instance Boot Group", + since = "24.0", + responseObject = InstanceBootGroupReadinessRuleResponse.class, + entityType = {InstanceBootGroupReadinessRule.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListInstanceBootGroupReadinessRulesCmd extends BaseListCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.BOOT_GROUP_ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, + description = "The ID of the Instance Boot Group; listing is always scoped to one boot group") + private Long bootGroupId; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupReadinessRuleResponse.class, description = "List by readiness rule ID") + private Long id; + + @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, entityType = UserVmResponse.class, description = "List rules for the Instance") + private Long virtualMachineId; + + @Parameter(name = ApiConstants.INSTANCE_GROUP_ID, type = CommandType.UUID, entityType = InstanceGroupResponse.class, description = "List rules for the Instance Group") + private Long instanceGroupId; + + @Parameter(name = ApiConstants.RULE_TYPE, type = CommandType.STRING, description = "Filter by readiness rule type") + private String ruleType; + + public Long getBootGroupId() { + return bootGroupId; + } + + public Long getId() { + return id; + } + + public Long getVirtualMachineId() { + return virtualMachineId; + } + + public Long getInstanceGroupId() { + return instanceGroupId; + } + + public String getRuleType() { + return ruleType; + } + + @Override + public void execute() { + ListResponse response = instanceBootGroupService.listInstanceBootGroupReadinessRules(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupsCmd.java new file mode 100644 index 000000000000..80bc29482b85 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupsCmd.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +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.BaseListProjectAndAccountResourcesCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.InstanceGroupResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.UserVmResponse; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "listInstanceBootGroups", + description = "Lists Instance Boot Groups", + since = "24.0", + responseObject = InstanceBootGroupResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListInstanceBootGroupsCmd extends BaseListProjectAndAccountResourcesCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, description = "List Instance Boot Group by ID") + private Long id; + + @Parameter(name = ApiConstants.KEYWORD, type = CommandType.STRING, description = "List Instance Boot Groups by name keyword") + private String keyword; + + @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, entityType = UserVmResponse.class, description = "List Instance Boot Groups that contain the Instance") + private Long virtualMachineId; + + @Parameter(name = ApiConstants.INSTANCE_GROUP_ID, type = CommandType.UUID, entityType = InstanceGroupResponse.class, description = "List Instance Boot Groups that contain the Instance Group") + private Long instanceGroupId; + + public Long getId() { + return id; + } + + @Override + public String getKeyword() { + return keyword; + } + + public Long getVirtualMachineId() { + return virtualMachineId; + } + + public Long getInstanceGroupId() { + return instanceGroupId; + } + + @Override + public void execute() { + ListResponse response = instanceBootGroupService.listInstanceBootGroups(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/RebootInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/RebootInstanceBootGroupCmd.java new file mode 100644 index 000000000000..624a736bbf8e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/RebootInstanceBootGroupCmd.java @@ -0,0 +1,107 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +import com.cloud.event.EventTypes; +import com.cloud.utils.exception.CloudRuntimeException; + +@APICommand(name = "rebootInstanceBootGroup", + description = "Reboots all VMs in an Instance Boot Group: stops in reverse order then starts in forward order", + since = "24.0", + responseObject = InstanceBootGroupResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class RebootInstanceBootGroupCmd extends BaseAsyncCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, description = "The ID of the Instance Boot Group") + private Long id; + + @Parameter(name = ApiConstants.FORCED, type = CommandType.BOOLEAN, required = false, + description = "Whether to force stop every Instance in the Instance Boot Group during the stop phase of the reboot (It is force-stopped and then started)") + private Boolean forced; + + public Long getId() { + return id; + } + + public boolean isForced() { + return forced != null && forced; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_INSTANCE_BOOT_GROUP_REBOOT; + } + + @Override + public String getEventDescription() { + return "Rebooting Instance Boot Group with ID: " + getResourceUuid(ApiConstants.ID); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroup result; + try { + result = instanceBootGroupService.rebootInstanceBootGroup(this); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to reboot instance boot group"); + } + InstanceBootGroupResponse response = instanceBootGroupService.createInstanceBootGroupResponse(result.getId()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/RemoveInstanceBootGroupMemberCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/RemoveInstanceBootGroupMemberCmd.java new file mode 100644 index 000000000000..847041742511 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/RemoveInstanceBootGroupMemberCmd.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "removeInstanceBootGroupMember", + description = "Removes a member from an Instance Boot Group", + since = "24.0", + responseObject = SuccessResponse.class, + entityType = {InstanceBootGroupMember.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class RemoveInstanceBootGroupMemberCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupMemberResponse.class, required = true, description = "The ID of the Instance Boot Group member to remove") + private Long id; + + public Long getId() { + return id; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return instanceBootGroupService.getInstanceBootGroupIdForMember(getId()); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + boolean result = instanceBootGroupService.removeInstanceBootGroupMember(this); + if (!result) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to remove member from instance boot group"); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/StartInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/StartInstanceBootGroupCmd.java new file mode 100644 index 000000000000..9351e4d16d5e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/StartInstanceBootGroupCmd.java @@ -0,0 +1,99 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +import com.cloud.event.EventTypes; +import com.cloud.utils.exception.CloudRuntimeException; + +@APICommand(name = "startInstanceBootGroup", + description = "Starts all Instances in an Instance Boot Group in order (lowest boot order first). Instances within the same order tier start concurrently", + since = "24.0", + responseObject = InstanceBootGroupResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class StartInstanceBootGroupCmd extends BaseAsyncCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, description = "The ID of the Instance Boot Group") + private Long id; + + public Long getId() { + return id; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_INSTANCE_BOOT_GROUP_START; + } + + @Override + public String getEventDescription() { + return "Starting Instance Boot Group with ID: " + getResourceUuid(ApiConstants.ID); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroup result; + try { + result = instanceBootGroupService.startInstanceBootGroup(this); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to start instance boot group"); + } + InstanceBootGroupResponse response = instanceBootGroupService.createInstanceBootGroupResponse(result.getId()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/StopInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/StopInstanceBootGroupCmd.java new file mode 100644 index 000000000000..a86a81e0b038 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/StopInstanceBootGroupCmd.java @@ -0,0 +1,101 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +import com.cloud.event.EventTypes; + +@APICommand(name = "stopInstanceBootGroup", + description = "Stops all Instances in an Instance Boot Group in reverse order (highest boot order first). Continues through all tiers even if some Instances fail to stop", + since = "24.0", + responseObject = InstanceBootGroupResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class StopInstanceBootGroupCmd extends BaseAsyncCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, description = "The ID of the Instance Boot Group") + private Long id; + + @Parameter(name = ApiConstants.FORCED, type = CommandType.BOOLEAN, required = false, + description = "Force stop every Instance in the Instance Boot Group (marked as Stopped even when the stop command fails to be sent to the backend, otherwise a force poweroff is attempted)") + private Boolean forced; + + public Long getId() { + return id; + } + + public boolean isForced() { + return forced != null && forced; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_INSTANCE_BOOT_GROUP_STOP; + } + + @Override + public String getEventDescription() { + return "Stopping Instance Boot Group with ID: " + getResourceUuid(ApiConstants.ID); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroup result = instanceBootGroupService.stopInstanceBootGroup(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to stop instance boot group"); + } + InstanceBootGroupResponse response = instanceBootGroupService.createInstanceBootGroupResponse(result.getId()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupCmd.java new file mode 100644 index 000000000000..c700053dec0e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupCmd.java @@ -0,0 +1,127 @@ +// 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.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "updateInstanceBootGroup", + description = "Updates an Instance Boot Group", + since = "24.0", + responseObject = InstanceBootGroupResponse.class, + entityType = {InstanceBootGroup.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class UpdateInstanceBootGroupCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupResponse.class, required = true, description = "The ID of the Instance Boot Group") + private Long id; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "New name for the Instance Boot Group") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, description = "New description for the Instance Boot Group") + private String description; + + @Parameter(name = ApiConstants.READINESS_ATTEMPT_TIMEOUT_SECONDS, type = CommandType.LONG, + description = "Per-boot-group override of the global timeout (seconds) for each readiness retry attempt. Pass -1 to clear the override and fall back to the global setting") + private Long readinessAttemptTimeoutSeconds; + + @Parameter(name = ApiConstants.READINESS_MAX_RETRY_ATTEMPTS, type = CommandType.LONG, + description = "Per-boot-group override of the global maximum number of readiness retry attempts. Pass -1 to clear the override and fall back to the global setting") + private Long readinessMaxRetryAttempts; + + @Parameter(name = ApiConstants.READINESS_REBOOT_ON_RETRY, type = CommandType.BOOLEAN, + description = "Per-boot-group override of whether an instance is rebooted between readiness retry attempts") + private Boolean readinessRebootOnRetry; + + @Parameter(name = ApiConstants.READINESS_INITIAL_DELAY_SECONDS, type = CommandType.LONG, + description = "Per-boot-group override of the global delay (seconds) after starting or rebooting an instance before its first readiness check of that attempt. Pass -1 to clear the override and fall back to the global setting") + private Long readinessInitialDelaySeconds; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Long getReadinessAttemptTimeoutSeconds() { + return readinessAttemptTimeoutSeconds; + } + + public Long getReadinessMaxRetryAttempts() { + return readinessMaxRetryAttempts; + } + + public Boolean getReadinessRebootOnRetry() { + return readinessRebootOnRetry; + } + + public Long getReadinessInitialDelaySeconds() { + return readinessInitialDelaySeconds; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroup result = instanceBootGroupService.updateInstanceBootGroup(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update instance boot group"); + } + InstanceBootGroupResponse response = instanceBootGroupService.createInstanceBootGroupResponse(result.getId()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupMemberCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupMemberCmd.java new file mode 100644 index 000000000000..8c2316a104b7 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupMemberCmd.java @@ -0,0 +1,88 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "updateInstanceBootGroupMember", + description = "Updates the boot order of a member in an instance boot group", + since = "24.0", + responseObject = InstanceBootGroupMemberResponse.class, + entityType = {InstanceBootGroupMember.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class UpdateInstanceBootGroupMemberCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupMemberResponse.class, required = true, description = "The ID of the boot group member entry") + private Long id; + + @Parameter(name = ApiConstants.BOOT_ORDER, type = CommandType.INTEGER, required = true, description = "The new boot order value (0 or greater)") + private int order; + + public Long getId() { + return id; + } + + public int getOrder() { + return order; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return instanceBootGroupService.getInstanceBootGroupIdForMember(getId()); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroup; + } + + @Override + public void execute() { + InstanceBootGroupMember result = instanceBootGroupService.updateInstanceBootGroupMember(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update instance boot group member"); + } + InstanceBootGroupMemberResponse response = instanceBootGroupService.createInstanceBootGroupMemberResponse(result); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupReadinessRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupReadinessRuleCmd.java new file mode 100644 index 000000000000..ea399f085658 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupReadinessRuleCmd.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.bootgroup; + +import java.util.Collection; +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.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; + +@APICommand(name = "updateInstanceBootGroupReadinessRule", + description = "Updates an Instance Boot Group readiness rule. The rule type, Instance Boot Group and item are immutable after creation.", + since = "24.0", + responseObject = InstanceBootGroupReadinessRuleResponse.class, + entityType = {InstanceBootGroupReadinessRule.class}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class UpdateInstanceBootGroupReadinessRuleCmd extends BaseCmd implements UserCmd { + + @Inject + InstanceBootGroupService instanceBootGroupService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = InstanceBootGroupReadinessRuleResponse.class, required = true, + description = "The ID of the readiness rule") + private Long id; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "New name for the readiness rule") + private String name; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "Whether the rule is enabled") + private Boolean enabled; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "Rule-type-specific configuration") + private Map details; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Boolean getEnabled() { + return enabled; + } + + public Map getDetails() { + if (this.details == null || this.details.isEmpty()) { + return null; + } + Collection paramsCollection = this.details.values(); + return (Map) (paramsCollection.toArray())[0]; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public Long getApiResourceId() { + return id; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.InstanceBootGroupReadinessRule; + } + + @Override + public void execute() { + InstanceBootGroupReadinessRule result = instanceBootGroupService.updateInstanceBootGroupReadinessRule(this); + if (result == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update instance boot group readiness rule"); + } + InstanceBootGroupReadinessRuleResponse response = instanceBootGroupService.createInstanceBootGroupReadinessRuleResponse(result); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMCmd.java index 48205c50e077..618d14d90a68 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMCmd.java @@ -65,7 +65,7 @@ public class UpdateVMCmd extends BaseCustomIdCmd implements SecurityGroupAction, @Parameter(name = ApiConstants.DISPLAY_NAME, type = CommandType.STRING, description = "User generated name") private String displayName; - @Parameter(name = ApiConstants.GROUP, type = CommandType.STRING, description = "Group of the Instance") + @Parameter(name = ApiConstants.GROUP, type = CommandType.STRING, description = "Group of the Instance. To remove an Instance from its current Group, please pass an empty string as parameter") private String group; @Parameter(name = ApiConstants.HA_ENABLE, type = CommandType.BOOLEAN, description = "True if high-availability is enabled for the Instance, false otherwise") diff --git a/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java b/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java index 433a37c07cde..3885c30f0af4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java @@ -23,8 +23,12 @@ public class OfferingUtils { private OfferingUtils() { } + public static boolean isNetris(String provider) { + return "Netris".equalsIgnoreCase(provider); + } + public static boolean isNetrisNatted(String provider, String networkMode) { - return "Netris".equalsIgnoreCase(provider) && + return isNetris(provider) && NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(networkMode); } @@ -33,6 +37,6 @@ public static boolean isNsxWithoutLb(String provider, boolean nsxSupportsLbServi } public static boolean isNetrisRouted(String provider, String networkMode) { - return "Netris".equalsIgnoreCase(provider) && NetworkOffering.NetworkMode.ROUTED.name().equalsIgnoreCase(networkMode); + return isNetris(provider) && NetworkOffering.NetworkMode.ROUTED.name().equalsIgnoreCase(networkMode); } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupMemberChildResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupMemberChildResponse.java new file mode 100644 index 000000000000..d1164209d003 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupMemberChildResponse.java @@ -0,0 +1,57 @@ +// 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 com.google.gson.annotations.SerializedName; + +import org.apache.cloudstack.api.ApiConstants; + +import com.cloud.serializer.Param; + +/** + * Extends {@link UserVmResponse} rather than duplicating id/name/state fields, since this is set + * up from a plain {@code UserVmVO} (basic details only, via {@code UserVmDao}) with just a few + * boot-group-specific fields of its own — never populated from the join-based VM response builder. + */ +@SuppressWarnings("unused") +public class InstanceBootGroupMemberChildResponse extends UserVmResponse { + + @SerializedName(ApiConstants.READINESS_MODE) + @Param(description = "None, ChildDependent or RuleBased, computed from whether readiness rules are attached") + private String readinessMode; + + @SerializedName(ApiConstants.READINESS_STATUS) + @Param(description = "The last cached readiness evaluation") + private String readinessStatus; + + @SerializedName(ApiConstants.READINESS_MESSAGE) + @Param(description = "Why readinessstatus is what it is: the failing rule(s)' cached message") + private String readinessMessage; + + public void setReadinessMode(String readinessMode) { + this.readinessMode = readinessMode; + } + + public void setReadinessStatus(String readinessStatus) { + this.readinessStatus = readinessStatus; + } + + public void setReadinessMessage(String readinessMessage) { + this.readinessMessage = readinessMessage; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupMemberResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupMemberResponse.java new file mode 100644 index 000000000000..4d91714216ce --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupMemberResponse.java @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.response; + +import java.util.Date; +import java.util.List; + +import com.google.gson.annotations.SerializedName; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; + +import com.cloud.serializer.Param; + +@SuppressWarnings("unused") +@EntityReference(value = InstanceBootGroupMember.class) +public class InstanceBootGroupMemberResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "The UUID of this member entry") + private String id; + + @SerializedName(ApiConstants.BOOT_GROUP_ID) + @Param(description = "The ID of the boot group this member belongs to") + private String bootGroupId; + + @SerializedName(ApiConstants.MEMBER_TYPE) + @Param(description = "The type of the member: VirtualMachine or InstanceGroup") + private String memberType; + + @SerializedName(ApiConstants.MEMBER_ID) + @Param(description = "The ID of the VM or InstanceGroup") + private String memberId; + + @SerializedName(ApiConstants.MEMBER_NAME) + @Param(description = "The name of the VM or InstanceGroup") + private String memberName; + + @SerializedName(ApiConstants.MEMBER_STATE) + @Param(description = "The state of the Instance") + private String memberState; + + @SerializedName(ApiConstants.BOOT_ORDER) + @Param(description = "The boot order value for this member") + private int order; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "The date the member was added to the boot group") + private Date created; + + @SerializedName(ApiConstants.READINESS_MODE) + @Param(description = "None, CHILD_DEPENDENT or RULE_BASED, computed from whether readiness rules are attached") + private String readinessMode; + + @SerializedName(ApiConstants.READINESS_STATUS) + @Param(description = "The last cached readiness evaluation") + private String readinessStatus; + + @SerializedName(ApiConstants.READINESS_MESSAGE) + @Param(description = "Why readinessstatus is what it is: the failing rule(s)' cached message, and/or a count of not-ready member VMs for InstanceGroup members") + private String readinessMessage; + + @SerializedName(ApiConstants.CHILDREN) + @Param(description = "For InstanceGroup members, the VMs within the group (only present when requested via details=children)", responseObject = InstanceBootGroupMemberChildResponse.class) + private List children; + + public void setId(String id) { + this.id = id; + } + + public void setBootGroupId(String bootGroupId) { + this.bootGroupId = bootGroupId; + } + + public void setMemberType(String memberType) { + this.memberType = memberType; + } + + public void setMemberId(String memberId) { + this.memberId = memberId; + } + + public void setMemberName(String memberName) { + this.memberName = memberName; + } + + public void setMemberState(String memberState) { + this.memberState = memberState; + } + + public void setOrder(int order) { + this.order = order; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setReadinessMode(String readinessMode) { + this.readinessMode = readinessMode; + } + + public void setReadinessStatus(String readinessStatus) { + this.readinessStatus = readinessStatus; + } + + public void setReadinessMessage(String readinessMessage) { + this.readinessMessage = readinessMessage; + } + + public void setChildren(List children) { + this.children = children; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupReadinessRuleResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupReadinessRuleResponse.java new file mode 100644 index 000000000000..26228c8e8784 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupReadinessRuleResponse.java @@ -0,0 +1,148 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.response; + +import java.util.Date; +import java.util.Map; + +import com.google.gson.annotations.SerializedName; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; + +import com.cloud.serializer.Param; + +@SuppressWarnings("unused") +@EntityReference(value = InstanceBootGroupReadinessRule.class) +public class InstanceBootGroupReadinessRuleResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "The ID of the readiness rule") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "The name of the readiness rule") + private String name; + + @SerializedName(ApiConstants.BOOT_GROUP_ID) + @Param(description = "The ID of the boot group this rule belongs to") + private String bootGroupId; + + @SerializedName(ApiConstants.MEMBER_TYPE) + @Param(description = "The item type this rule applies to: VirtualMachine or InstanceGroup") + private String itemType; + + @SerializedName(ApiConstants.MEMBER_ID) + @Param(description = "The ID of the item (VM or instance group) this rule applies to") + private String itemId; + + @SerializedName(ApiConstants.MEMBER_NAME) + @Param(description = "The name of the item (VM or instance group) this rule applies to") + private String itemName; + + @SerializedName(ApiConstants.RULE_TYPE) + @Param(description = "The readiness rule type") + private String ruleType; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "Whether the rule is enabled") + private boolean enabled; + + @SerializedName(ApiConstants.INHERITED) + @Param(description = "True if this rule is not attached to the queried item directly, but inherited from its owning InstanceGroup " + + "(only possible when listing by virtualmachineid; the rule's itemtype/itemid still refer to the InstanceGroup it's actually attached to)") + private boolean inherited; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "Rule-type-specific configuration") + private Map details; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "The date the rule was created") + private Date created; + + @SerializedName(ApiConstants.READINESS_STATUS) + @Param(description = "The last cached evaluation status of this rule: READY, NOT_READY, ERROR or UNKNOWN") + private String status; + + @SerializedName("statusmessage") + @Param(description = "The message from the last evaluation of this rule") + private String statusMessage; + + @SerializedName("checkedon") + @Param(description = "When this rule was last evaluated") + private Date checkedOn; + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setBootGroupId(String bootGroupId) { + this.bootGroupId = bootGroupId; + } + + public void setItemType(String itemType) { + this.itemType = itemType; + } + + public void setItemId(String itemId) { + this.itemId = itemId; + } + + public void setItemName(String itemName) { + this.itemName = itemName; + } + + public void setRuleType(String ruleType) { + this.ruleType = ruleType; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setInherited(boolean inherited) { + this.inherited = inherited; + } + + public void setDetails(Map details) { + this.details = details; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setStatus(String status) { + this.status = status; + } + + public void setStatusMessage(String statusMessage) { + this.statusMessage = statusMessage; + } + + public void setCheckedOn(Date checkedOn) { + this.checkedOn = checkedOn; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupResponse.java new file mode 100644 index 000000000000..127eeda17744 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/InstanceBootGroupResponse.java @@ -0,0 +1,160 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.response; + +import java.util.Date; + +import com.google.gson.annotations.SerializedName; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; + +import com.cloud.serializer.Param; + +@SuppressWarnings("unused") +@EntityReference(value = InstanceBootGroup.class) +public class InstanceBootGroupResponse extends BaseResponse implements ControlledViewEntityResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "The ID of the instance boot group") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "The name of the instance boot group") + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "The description of the instance boot group") + private String description; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "The date the instance boot group was created") + private Date created; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "The account owning the instance boot group") + private String accountName; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "The account ID owning the instance boot group") + private String accountId; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "The domain ID of the instance boot group") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "The domain name of the instance boot group") + private String domainName; + + @SerializedName(ApiConstants.DOMAIN_PATH) + @Param(description = "The path of the domain the instance boot group belongs to") + private String domainPath; + + @SerializedName(ApiConstants.PROJECT_ID) + @Param(description = "The project ID of the instance boot group") + private String projectId; + + @SerializedName(ApiConstants.PROJECT) + @Param(description = "The project name of the instance boot group") + private String projectName; + + @SerializedName(ApiConstants.READINESS_ATTEMPT_TIMEOUT_SECONDS) + @Param(description = "Effective timeout in seconds for each readiness retry attempt (per-boot-group override if set, else the global default)") + private long readinessAttemptTimeoutSeconds; + + @SerializedName(ApiConstants.READINESS_MAX_RETRY_ATTEMPTS) + @Param(description = "Effective maximum number of readiness retry attempts (per-boot-group override if set, else the global default)") + private long readinessMaxRetryAttempts; + + @SerializedName(ApiConstants.READINESS_REBOOT_ON_RETRY) + @Param(description = "Effective setting for whether an instance is rebooted between readiness retry attempts (per-boot-group override if set, else the global default)") + private boolean readinessRebootOnRetry; + + @SerializedName(ApiConstants.READINESS_INITIAL_DELAY_SECONDS) + @Param(description = "Effective delay in seconds after starting or rebooting an instance before its first readiness check of that attempt (per-boot-group override if set, else the global default)") + private long readinessInitialDelaySeconds; + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + @Override + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + @Override + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + @Override + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + @Override + public void setDomainPath(String domainPath) { + this.domainPath = domainPath; + } + + @Override + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + @Override + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public void setReadinessAttemptTimeoutSeconds(long readinessAttemptTimeoutSeconds) { + this.readinessAttemptTimeoutSeconds = readinessAttemptTimeoutSeconds; + } + + public void setReadinessMaxRetryAttempts(long readinessMaxRetryAttempts) { + this.readinessMaxRetryAttempts = readinessMaxRetryAttempts; + } + + public void setReadinessRebootOnRetry(boolean readinessRebootOnRetry) { + this.readinessRebootOnRetry = readinessRebootOnRetry; + } + + public void setReadinessInitialDelaySeconds(long readinessInitialDelaySeconds) { + this.readinessInitialDelaySeconds = readinessInitialDelaySeconds; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java new file mode 100644 index 000000000000..2ceab991b179 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationCycleResponse.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import java.util.Date; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.VmwareCbtMigrationCycle; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = VmwareCbtMigrationCycle.class) +public class VmwareCbtMigrationCycleResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the VMware CBT migration cycle") + private String id; + + @SerializedName(ApiConstants.CYCLE_NUMBER) + @Param(description = "the CBT delta synchronization cycle number") + private int cycleNumber; + + @SerializedName(ApiConstants.SNAPSHOT_MOR) + @Param(description = "the VMware snapshot managed object reference used for this cycle") + private String snapshotMor; + + @SerializedName(ApiConstants.CHANGED_BYTES) + @Param(description = "the changed bytes copied in this cycle") + private Long changedBytes; + + @SerializedName(ApiConstants.DIRTY_RATE) + @Param(description = "the dirty rate in bytes per second observed in this cycle") + private Long dirtyRate; + + @SerializedName(ApiConstants.DURATION) + @Param(description = "the cycle duration in milliseconds") + private Long duration; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the cycle state") + private String state; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "the cycle status or failure description") + private String description; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the create date of the VMware CBT migration cycle") + private Date created; + + @SerializedName(ApiConstants.LAST_UPDATED) + @Param(description = "the last updated date of the VMware CBT migration cycle") + private Date lastUpdated; + + public void setId(String id) { + this.id = id; + } + + public void setCycleNumber(int cycleNumber) { + this.cycleNumber = cycleNumber; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public void setChangedBytes(Long changedBytes) { + this.changedBytes = changedBytes; + } + + public void setDirtyRate(Long dirtyRate) { + this.dirtyRate = dirtyRate; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public void setState(String state) { + this.state = state; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setLastUpdated(Date lastUpdated) { + this.lastUpdated = lastUpdated; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java new file mode 100644 index 000000000000..9a23cf44cad1 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationDiskResponse.java @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.VmwareCbtMigrationDisk; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = VmwareCbtMigrationDisk.class) +public class VmwareCbtMigrationDiskResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the VMware CBT migration disk") + private String id; + + @SerializedName(ApiConstants.SOURCE_DISK_ID) + @Param(description = "the source VMware disk identifier") + private String sourceDiskId; + + @SerializedName(ApiConstants.SOURCE_DISK_DEVICE_KEY) + @Param(description = "the source VMware disk device key") + private Integer sourceDiskDeviceKey; + + @SerializedName(ApiConstants.SOURCE_DISK_PATH) + @Param(description = "the source VMware disk path") + private String sourceDiskPath; + + @SerializedName(ApiConstants.DATASTORE_NAME) + @Param(description = "the source VMware datastore name") + private String datastoreName; + + @SerializedName(ApiConstants.CAPACITY) + @Param(description = "the source disk capacity in bytes") + private Long capacityBytes; + + @SerializedName(ApiConstants.TARGET_PATH) + @Param(description = "the planned or actual KVM target disk path") + private String targetPath; + + @SerializedName(ApiConstants.TARGET_FORMAT) + @Param(description = "the KVM target disk format") + private String targetFormat; + + @SerializedName(ApiConstants.CHANGE_ID) + @Param(description = "the VMware CBT change ID used for the next delta query") + private String changeId; + + @SerializedName(ApiConstants.SNAPSHOT_MOR) + @Param(description = "the VMware snapshot managed object reference currently associated with this disk") + private String snapshotMor; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the disk replication state") + private String state; + + public void setId(String id) { + this.id = id; + } + + public void setSourceDiskId(String sourceDiskId) { + this.sourceDiskId = sourceDiskId; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public void setSourceDiskPath(String sourceDiskPath) { + this.sourceDiskPath = sourceDiskPath; + } + + public void setDatastoreName(String datastoreName) { + this.datastoreName = datastoreName; + } + + public void setCapacityBytes(Long capacityBytes) { + this.capacityBytes = capacityBytes; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public void setChangeId(String changeId) { + this.changeId = changeId; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java new file mode 100644 index 000000000000..e637619a9a8d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightDiskResponse.java @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class VmwareCbtMigrationPreflightDiskResponse extends BaseResponse { + + @SerializedName(ApiConstants.SOURCE_DISK_ID) + @Param(description = "the source VMware disk identifier") + private String sourceDiskId; + + @SerializedName(ApiConstants.SOURCE_DISK_DEVICE_KEY) + @Param(description = "the source VMware disk device key") + private Integer sourceDiskDeviceKey; + + @SerializedName(ApiConstants.SOURCE_DISK_PATH) + @Param(description = "the source VMware disk path") + private String sourceDiskPath; + + @SerializedName(ApiConstants.DATASTORE_NAME) + @Param(description = "the source VMware datastore name") + private String datastoreName; + + @SerializedName(ApiConstants.CAPACITY) + @Param(description = "the source disk capacity in bytes") + private Long capacityBytes; + + @SerializedName("backingtype") + @Param(description = "the VMware disk backing class") + private String backingType; + + @SerializedName("diskmode") + @Param(description = "the VMware disk mode") + private String diskMode; + + @SerializedName("rdmcompatibilitymode") + @Param(description = "the RDM compatibility mode, when the disk is an RDM") + private String rdmCompatibilityMode; + + @SerializedName("independentdisk") + @Param(description = "whether the VMware disk is configured as independent") + private boolean independentDisk; + + @SerializedName("physicalrdm") + @Param(description = "whether the VMware disk is a physical-mode RDM") + private boolean physicalRdm; + + @SerializedName("changeidpresent") + @Param(description = "whether a VMware CBT change ID is currently visible for this disk") + private boolean changeIdPresent; + + public void setSourceDiskId(String sourceDiskId) { + this.sourceDiskId = sourceDiskId; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public void setSourceDiskPath(String sourceDiskPath) { + this.sourceDiskPath = sourceDiskPath; + } + + public void setDatastoreName(String datastoreName) { + this.datastoreName = datastoreName; + } + + public void setCapacityBytes(Long capacityBytes) { + this.capacityBytes = capacityBytes; + } + + public void setBackingType(String backingType) { + this.backingType = backingType; + } + + public void setDiskMode(String diskMode) { + this.diskMode = diskMode; + } + + public void setRdmCompatibilityMode(String rdmCompatibilityMode) { + this.rdmCompatibilityMode = rdmCompatibilityMode; + } + + public void setIndependentDisk(boolean independentDisk) { + this.independentDisk = independentDisk; + } + + public void setPhysicalRdm(boolean physicalRdm) { + this.physicalRdm = physicalRdm; + } + + public void setChangeIdPresent(boolean changeIdPresent) { + this.changeIdPresent = changeIdPresent; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java new file mode 100644 index 000000000000..a4cccd06fa69 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightFindingResponse.java @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class VmwareCbtMigrationPreflightFindingResponse extends BaseResponse { + + @SerializedName("severity") + @Param(description = "finding severity: PASS, WARN or FAIL") + private String severity; + + @SerializedName("code") + @Param(description = "technical preflight finding code") + private String code; + + @SerializedName("component") + @Param(description = "component checked by the preflight finding") + private String component; + + @SerializedName("resource") + @Param(description = "resource checked by the preflight finding") + private String resource; + + @SerializedName("message") + @Param(description = "human-readable preflight finding message") + private String message; + + public VmwareCbtMigrationPreflightFindingResponse(String severity, String code, String component, + String resource, String message) { + this.severity = severity; + this.code = code; + this.component = component; + this.resource = resource; + this.message = message; + setObjectName("finding"); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java new file mode 100644 index 000000000000..ec151189ee3a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationPreflightResponse.java @@ -0,0 +1,284 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class VmwareCbtMigrationPreflightResponse extends BaseResponse { + + @SerializedName("ready") + @Param(description = "whether the source VM and destination are ready for VMware CBT migration start") + private boolean ready; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the destination zone ID") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the destination zone name") + private String zoneName; + + @SerializedName(ApiConstants.CLUSTER_ID) + @Param(description = "the destination KVM cluster ID") + private String clusterId; + + @SerializedName(ApiConstants.CLUSTER_NAME) + @Param(description = "the destination KVM cluster name") + private String clusterName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_HOST_ID) + @Param(description = "the KVM host selected for conversion and CBT replication") + private String convertInstanceHostId; + + @SerializedName("convertinstancehostname") + @Param(description = "the KVM host selected for conversion and CBT replication") + private String convertInstanceHostName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID) + @Param(description = "the destination storage pool ID") + private String storagePoolId; + + @SerializedName(ApiConstants.POOL_NAME) + @Param(description = "the destination storage pool name") + private String storagePoolName; + + @SerializedName("storagepooltype") + @Param(description = "the destination storage pool type") + private String storagePoolType; + + @SerializedName("storagewritertype") + @Param(description = "the VMware CBT target storage writer type") + private String storageWriterType; + + @SerializedName("storagewritersupported") + @Param(description = "whether the target storage writer is implemented") + private boolean storageWriterSupported; + + @SerializedName("storagerequiresinplacefinalization") + @Param(description = "whether the target storage writer requires virt-v2v in-place finalization") + private boolean storageRequiresInPlaceFinalization; + + @SerializedName("convertinstancehostinplacefinalizationsupported") + @Param(description = "whether the selected KVM host reports VMware CBT in-place finalization support") + private boolean convertInstanceHostInPlaceFinalizationSupported; + + @SerializedName("noninplacefinalizationfallbackallowed") + @Param(description = "whether VMware CBT non-in-place fallback finalization is enabled by configuration") + private boolean nonInPlaceFinalizationFallbackAllowed; + + @SerializedName("noninplacefinalizationfallbacksupported") + @Param(description = "whether the selected target storage can use non-in-place fallback finalization") + private boolean nonInPlaceFinalizationFallbackSupported; + + @SerializedName(ApiConstants.VCENTER) + @Param(description = "the source VMware vCenter") + private String vcenter; + + @SerializedName(ApiConstants.DATACENTER_NAME) + @Param(description = "the source VMware datacenter") + private String datacenterName; + + @SerializedName(ApiConstants.SOURCE_HOST) + @Param(description = "the source VMware ESXi host") + private String sourceHost; + + @SerializedName(ApiConstants.SOURCE_VM_NAME) + @Param(description = "the source VMware VM name") + private String sourceVmName; + + @SerializedName("sourcevmmor") + @Param(description = "the source VMware VM managed object reference") + private String sourceVmMor; + + @SerializedName("changetrackingsupported") + @Param(description = "whether the source VM reports CBT support") + private Boolean changeTrackingSupported; + + @SerializedName("changetrackingenabled") + @Param(description = "whether CBT is currently enabled on the source VM") + private Boolean changeTrackingEnabled; + + @SerializedName("consolidationneeded") + @Param(description = "whether the source VM reports disk consolidation is needed") + private Boolean consolidationNeeded; + + @SerializedName("existingsnapshotcount") + @Param(description = "number of existing VMware snapshots on the source VM") + private Integer existingSnapshotCount; + + @SerializedName("sourcecpunumber") + @Param(description = "source VMware VM CPU count") + private Integer sourceCpuNumber; + + @SerializedName("sourcecpuspeed") + @Param(description = "source VMware VM CPU reservation in MHz used for offering validation, when available") + private Integer sourceCpuSpeed; + + @SerializedName("sourcememory") + @Param(description = "source VMware VM memory in MB") + private Integer sourceMemory; + + @SerializedName("sourceguestosid") + @Param(description = "source VMware VM guest OS identifier") + private String sourceGuestOsId; + + @SerializedName("sourceguestos") + @Param(description = "source VMware VM guest OS name") + private String sourceGuestOs; + + @SerializedName(ApiConstants.DISK) + @Param(description = "source VMware disk preflight information") + private List disks; + + @SerializedName("finding") + @Param(description = "preflight findings") + private List findings; + + public void setReady(boolean ready) { + this.ready = ready; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setClusterId(String clusterId) { + this.clusterId = clusterId; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public void setConvertInstanceHostId(String convertInstanceHostId) { + this.convertInstanceHostId = convertInstanceHostId; + } + + public void setConvertInstanceHostName(String convertInstanceHostName) { + this.convertInstanceHostName = convertInstanceHostName; + } + + public void setStoragePoolId(String storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public void setStoragePoolName(String storagePoolName) { + this.storagePoolName = storagePoolName; + } + + public void setStoragePoolType(String storagePoolType) { + this.storagePoolType = storagePoolType; + } + + public void setStorageWriterType(String storageWriterType) { + this.storageWriterType = storageWriterType; + } + + public void setStorageWriterSupported(boolean storageWriterSupported) { + this.storageWriterSupported = storageWriterSupported; + } + + public void setStorageRequiresInPlaceFinalization(boolean storageRequiresInPlaceFinalization) { + this.storageRequiresInPlaceFinalization = storageRequiresInPlaceFinalization; + } + + public void setConvertInstanceHostInPlaceFinalizationSupported(boolean convertInstanceHostInPlaceFinalizationSupported) { + this.convertInstanceHostInPlaceFinalizationSupported = convertInstanceHostInPlaceFinalizationSupported; + } + + public void setNonInPlaceFinalizationFallbackAllowed(boolean nonInPlaceFinalizationFallbackAllowed) { + this.nonInPlaceFinalizationFallbackAllowed = nonInPlaceFinalizationFallbackAllowed; + } + + public void setNonInPlaceFinalizationFallbackSupported(boolean nonInPlaceFinalizationFallbackSupported) { + this.nonInPlaceFinalizationFallbackSupported = nonInPlaceFinalizationFallbackSupported; + } + + public void setVcenter(String vcenter) { + this.vcenter = vcenter; + } + + public void setDatacenterName(String datacenterName) { + this.datacenterName = datacenterName; + } + + public void setSourceHost(String sourceHost) { + this.sourceHost = sourceHost; + } + + public void setSourceVmName(String sourceVmName) { + this.sourceVmName = sourceVmName; + } + + public void setSourceVmMor(String sourceVmMor) { + this.sourceVmMor = sourceVmMor; + } + + public void setChangeTrackingSupported(Boolean changeTrackingSupported) { + this.changeTrackingSupported = changeTrackingSupported; + } + + public void setChangeTrackingEnabled(Boolean changeTrackingEnabled) { + this.changeTrackingEnabled = changeTrackingEnabled; + } + + public void setConsolidationNeeded(Boolean consolidationNeeded) { + this.consolidationNeeded = consolidationNeeded; + } + + public void setExistingSnapshotCount(Integer existingSnapshotCount) { + this.existingSnapshotCount = existingSnapshotCount; + } + + public void setSourceCpuNumber(Integer sourceCpuNumber) { + this.sourceCpuNumber = sourceCpuNumber; + } + + public void setSourceCpuSpeed(Integer sourceCpuSpeed) { + this.sourceCpuSpeed = sourceCpuSpeed; + } + + public void setSourceMemory(Integer sourceMemory) { + this.sourceMemory = sourceMemory; + } + + public void setSourceGuestOsId(String sourceGuestOsId) { + this.sourceGuestOsId = sourceGuestOsId; + } + + public void setSourceGuestOs(String sourceGuestOs) { + this.sourceGuestOs = sourceGuestOs; + } + + public void setDisks(List disks) { + this.disks = disks; + } + + public void setFindings(List findings) { + this.findings = findings; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java new file mode 100644 index 000000000000..37ee6906f9f3 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/VmwareCbtMigrationResponse.java @@ -0,0 +1,288 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import java.util.Date; +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = VmwareCbtMigration.class) +public class VmwareCbtMigrationResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the VMware CBT migration") + private String id; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the destination zone ID") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the destination zone name") + private String zoneName; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account name") + private String accountName; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "the account ID") + private String accountId; + + @SerializedName(ApiConstants.VIRTUAL_MACHINE_ID) + @Param(description = "the ID of the imported VM after cutover") + private String virtualMachineId; + + @SerializedName(ApiConstants.CLUSTER_ID) + @Param(description = "the destination KVM cluster ID") + private String clusterId; + + @SerializedName(ApiConstants.CLUSTER_NAME) + @Param(description = "the destination KVM cluster name") + private String clusterName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_HOST_ID) + @Param(description = "the KVM host selected for conversion and CBT replication") + private String convertInstanceHostId; + + @SerializedName("convertinstancehostname") + @Param(description = "the KVM host name selected for conversion and CBT replication") + private String convertInstanceHostName; + + @SerializedName(ApiConstants.CONVERT_INSTANCE_STORAGE_POOL_ID) + @Param(description = "the storage pool selected for converted disks") + private String storagePoolId; + + @SerializedName(ApiConstants.POOL_NAME) + @Param(description = "the storage pool name selected for converted disks") + private String storagePoolName; + + @SerializedName(ApiConstants.DISPLAY_NAME) + @Param(description = "the display name of the target VM") + private String displayName; + + @SerializedName(ApiConstants.VCENTER) + @Param(description = "the source VMware vCenter") + private String vcenter; + + @SerializedName(ApiConstants.EXISTING_VCENTER_ID) + @Param(description = "the linked existing vCenter ID, when used") + private String existingVcenterId; + + @SerializedName(ApiConstants.DATACENTER_NAME) + @Param(description = "the source VMware datacenter") + private String datacenterName; + + @SerializedName(ApiConstants.SOURCE_HOST) + @Param(description = "the source VMware ESXi host") + private String sourceHost; + + @SerializedName(ApiConstants.SOURCE_CLUSTER) + @Param(description = "the source VMware cluster") + private String sourceCluster; + + @SerializedName(ApiConstants.SOURCE_VM_NAME) + @Param(description = "the source VMware VM name") + private String sourceVmName; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the migration state") + private String state; + + @SerializedName(ApiConstants.CURRENT_STEP) + @Param(description = "the current migration step") + private String currentStep; + + @SerializedName("currentstepduration") + @Param(description = "the elapsed time spent in the current migration step") + private String currentStepDuration; + + @SerializedName(ApiConstants.LAST_ERROR) + @Param(description = "the last migration error") + private String lastError; + + @SerializedName(ApiConstants.COMPLETED_CYCLES) + @Param(description = "the number of completed CBT delta cycles") + private int completedCycles; + + @SerializedName(ApiConstants.QUIET_CYCLES) + @Param(description = "the number of consecutive quiet CBT delta cycles") + private int quietCycles; + + @SerializedName(ApiConstants.TOTAL_CHANGED_BYTES) + @Param(description = "the total changed bytes synchronized across CBT delta cycles") + private long totalChangedBytes; + + @SerializedName(ApiConstants.LAST_CHANGED_BYTES) + @Param(description = "the changed bytes observed in the last CBT delta cycle") + private Long lastChangedBytes; + + @SerializedName(ApiConstants.LAST_DIRTY_RATE) + @Param(description = "the dirty rate in bytes per second observed in the last CBT delta cycle") + private Long lastDirtyRate; + + @SerializedName(ApiConstants.DISK) + @Param(description = "the source and target disks tracked by this VMware CBT migration") + private List disks; + + @SerializedName(ApiConstants.CYCLE) + @Param(description = "the CBT delta synchronization cycles tracked by this VMware CBT migration") + private List cycles; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the create date of the VMware CBT migration") + private Date created; + + @SerializedName(ApiConstants.LAST_UPDATED) + @Param(description = "the last updated date of the VMware CBT migration") + private Date lastUpdated; + + public void setId(String id) { + this.id = id; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public void setVirtualMachineId(String virtualMachineId) { + this.virtualMachineId = virtualMachineId; + } + + public void setClusterId(String clusterId) { + this.clusterId = clusterId; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public void setConvertInstanceHostId(String convertInstanceHostId) { + this.convertInstanceHostId = convertInstanceHostId; + } + + public void setConvertInstanceHostName(String convertInstanceHostName) { + this.convertInstanceHostName = convertInstanceHostName; + } + + public void setStoragePoolId(String storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public void setStoragePoolName(String storagePoolName) { + this.storagePoolName = storagePoolName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public void setVcenter(String vcenter) { + this.vcenter = vcenter; + } + + public void setExistingVcenterId(String existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + + public void setDatacenterName(String datacenterName) { + this.datacenterName = datacenterName; + } + + public void setSourceHost(String sourceHost) { + this.sourceHost = sourceHost; + } + + public void setSourceCluster(String sourceCluster) { + this.sourceCluster = sourceCluster; + } + + public void setSourceVmName(String sourceVmName) { + this.sourceVmName = sourceVmName; + } + + public void setState(String state) { + this.state = state; + } + + public void setCurrentStep(String currentStep) { + this.currentStep = currentStep; + } + + public void setCurrentStepDuration(String currentStepDuration) { + this.currentStepDuration = currentStepDuration; + } + + public void setLastError(String lastError) { + this.lastError = lastError; + } + + public void setCompletedCycles(int completedCycles) { + this.completedCycles = completedCycles; + } + + public void setQuietCycles(int quietCycles) { + this.quietCycles = quietCycles; + } + + public void setTotalChangedBytes(long totalChangedBytes) { + this.totalChangedBytes = totalChangedBytes; + } + + public void setLastChangedBytes(Long lastChangedBytes) { + this.lastChangedBytes = lastChangedBytes; + } + + public void setLastDirtyRate(Long lastDirtyRate) { + this.lastDirtyRate = lastDirtyRate; + } + + public void setDisks(List disks) { + this.disks = disks; + } + + public void setCycles(List cycles) { + this.cycles = cycles; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setLastUpdated(Date lastUpdated) { + this.lastUpdated = lastUpdated; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java index 2e821dae52de..496aaf270f52 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java @@ -62,6 +62,10 @@ public class VpcOfferingResponse extends BaseResponse { @Param(description = "Indicates if the VPC offering supports distributed router for one-hop forwarding", since = "4.4") private Boolean supportsDistributedRouter; + @SerializedName(ApiConstants.REDUNDANT_ROUTER) + @Param(description = "Indicates if the VPC offering supports redundant routers") + private Boolean redundantRouter; + @SerializedName((ApiConstants.SUPPORTS_REGION_LEVEL_VPC)) @Param(description = "Indicated if the offering can support region level VPC", since = "4.4") private Boolean supportsRegionLevelVpc; @@ -213,4 +217,8 @@ public Boolean getConserveMode() { public void setConserveMode(Boolean conserveMode) { this.conserveMode = conserveMode; } + + public void setRedundantRouter(Boolean redundantRouter) { + this.redundantRouter = redundantRouter; + } } diff --git a/api/src/main/java/org/apache/cloudstack/query/QueryService.java b/api/src/main/java/org/apache/cloudstack/query/QueryService.java index b6362e9a9c9b..4203f54aa06c 100644 --- a/api/src/main/java/org/apache/cloudstack/query/QueryService.java +++ b/api/src/main/java/org/apache/cloudstack/query/QueryService.java @@ -142,6 +142,12 @@ public interface QueryService { ConfigKey ReturnVmStatsOnVmList = new ConfigKey<>("Advanced", Boolean.class, "list.vm.default.details.stats", "true", "Determines whether VM stats should be returned when details are not explicitly specified in listVirtualMachines API request. When false, details default to [group, nics, secgrp, tmpl, servoff, diskoff, backoff, iso, volume, min, affgrp]. When true, all details are returned including 'stats'.", true, ConfigKey.Scope.Global); + ConfigKey BypassTemplateView = new ConfigKey<>("Advanced", Boolean.class, "template.list.bypass.view", + "false", + "If true, uses an optimized query path for listing templates and ISOs, which can improve performance on " + + "deployments with large numbers of templates. Automatically falls back to the standard query when " + + "filtering by tags, using the shared, featured, or community template filters, or when including " + + "removed templates.", true, ConfigKey.Scope.Global); ListResponse searchForUsers(ResponseObject.ResponseView responseView, ListUsersCmd cmd) throws PermissionDeniedException; diff --git a/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java b/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java index 13f01b4944ad..709ad691971a 100644 --- a/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java +++ b/api/src/main/java/org/apache/cloudstack/storage/volume/VolumeImportUnmanageService.java @@ -37,7 +37,8 @@ public interface VolumeImportUnmanageService extends PluggableService, Configura Arrays.asList(Hypervisor.HypervisorType.KVM, Hypervisor.HypervisorType.VMware); List SUPPORTED_STORAGE_POOL_TYPES_FOR_KVM = Arrays.asList(Storage.StoragePoolType.NetworkFilesystem, - Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.RBD, Storage.StoragePoolType.SharedMountPoint); + Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.RBD, Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.Linstor); ConfigKey AllowImportVolumeWithBackingFile = new ConfigKey<>(Boolean.class, "allow.import.volume.with.backing.file", diff --git a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java index cbb7c4de698a..63ef8892d2c6 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java +++ b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedInstanceTO.java @@ -66,6 +66,7 @@ public enum PowerState { private String bootType; private String bootMode; + private String vmwareMoref; public String getName() { return name; @@ -234,6 +235,14 @@ public void setBootMode(String bootMode) { this.bootMode = bootMode; } + public String getVmwareMoref() { + return vmwareMoref; + } + + public void setVmwareMoref(String vmwareMoref) { + this.vmwareMoref = vmwareMoref; + } + public static class Disk { private String diskId; @@ -400,6 +409,16 @@ public static class Nic { private List ipAddress; + // Guest network facts captured from VMware Tools while the source runs, used to preserve + // static configuration across the migration: IPv4 addresses in CIDR form, the default + // gateway serving this NIC's subnet, and the guest's DNS servers. Null when Tools is not + // running or the guest reports nothing for this NIC. + private List ipv4Cidrs; + + private String ipv4Gateway; + + private List dnsServers; + private String pciSlot; public String getNicId() { @@ -466,6 +485,30 @@ public void setIpAddress(List ipAddress) { this.ipAddress = ipAddress; } + public List getIpv4Cidrs() { + return ipv4Cidrs; + } + + public void setIpv4Cidrs(List ipv4Cidrs) { + this.ipv4Cidrs = ipv4Cidrs; + } + + public String getIpv4Gateway() { + return ipv4Gateway; + } + + public void setIpv4Gateway(String ipv4Gateway) { + this.ipv4Gateway = ipv4Gateway; + } + + public List getDnsServers() { + return dnsServers; + } + + public void setDnsServers(List dnsServers) { + this.dnsServers = dnsServers; + } + public String getPciSlot() { return pciSlot; } diff --git a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java index e1963313eb6f..ec7128caa7fc 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java +++ b/api/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManager.java @@ -17,10 +17,20 @@ package org.apache.cloudstack.vm; +import java.util.List; +import java.util.Map; + +import com.cloud.dc.DataCenter; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Network; +import com.cloud.org.Cluster; +import com.cloud.user.Account; import com.cloud.utils.component.PluggableService; +import com.cloud.uservm.UserVm; + import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; + import static com.cloud.hypervisor.Hypervisor.HypervisorType.KVM; import static com.cloud.hypervisor.Hypervisor.HypervisorType.VMware; @@ -75,4 +85,44 @@ public interface UnmanagedVMsManager extends VmImportService, UnmanageVMService, static boolean isSupported(Hypervisor.HypervisorType hypervisorType) { return hypervisorType == VMware || hypervisorType == KVM; } + + UserVm importConvertedVmwareCbtInstanceToKvm(String vcenter, String datacenterName, String username, String password, + String clusterName, String sourceHostName, String sourceVMName, + UnmanagedInstanceTO convertedInstance, DataCenter zone, + Cluster destinationCluster, String displayName, String hostName, + Account caller, Account owner, long userId, Long templateId, + Long serviceOfferingId, Map dataDiskOfferingMap, + Map nicNetworkMap, + Map nicIpAddressMap, + Long guestOsId, Map details, boolean forced, + Long storagePoolId); + + /** + * Maps a VMware guest identity (config.guestFullName / config.guestId, available regardless of + * the source VM's power state) to a CloudStack guest OS id, so an imported VM does not inherit + * the import dummy template's generic OS type. Returns null when no reasonable match exists; + * callers then keep their previous behaviour. + */ + Long resolveGuestOsIdForVmwareImport(String osName, String osId); + + /** + * Fills in the imported VM's hardware details from the VMware source: UEFI boot (with SECURE + * when the source has secure boot enabled) plus the q35 machine type when the source firmware + * is UEFI, and a vga console for Windows guests. Only keys the caller did not provide are + * added, so explicit user choices always win. + */ + Map applyVmwareImportHardwareDetails(Map details, String bootType, String bootMode, String osName); + + /** + * Preserves static source IPs across the migration: for every NIC that is mapped to a + * CloudStack network but has no caller-provided IP, requests the guest's own IPv4 address + * (captured from VMware Tools while the source runs) when that address fits the target + * network's CIDR, is not the network's gateway and is not already in use. Caller-provided + * entries always win; NICs whose source address does not fit fall back to normal allocation. + * + * @param nicIdToIpv4Cidrs source NIC id to its captured IPv4 addresses in CIDR form + * @return the effective NIC-to-IP map to use for the import + */ + Map autoFillStaticNicIpAddresses(Map nicNetworkMap, + Map nicIpAddressMap, Map> nicIdToIpv4Cidrs); } diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java new file mode 100644 index 000000000000..998bfc701dd0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedBlockInfo.java @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtChangedBlockInfo { + + private final long startOffset; + private final long length; + + public VmwareCbtChangedBlockInfo(long startOffset, long length) { + this.startOffset = startOffset; + this.length = length; + } + + public long getStartOffset() { + return startOffset; + } + + public long getLength() { + return length; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java new file mode 100644 index 000000000000..958cbffa3519 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtChangedDiskInfo.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import java.util.Collections; +import java.util.List; + +public class VmwareCbtChangedDiskInfo { + + private final String sourceDiskId; + private final String nextChangeId; + private final List changedBlocks; + + public VmwareCbtChangedDiskInfo(String sourceDiskId, String nextChangeId, + List changedBlocks) { + this.sourceDiskId = sourceDiskId; + this.nextChangeId = nextChangeId; + this.changedBlocks = changedBlocks == null ? Collections.emptyList() : changedBlocks; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public String getNextChangeId() { + return nextChangeId; + } + + public List getChangedBlocks() { + return changedBlocks; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java new file mode 100644 index 000000000000..99ed6d5de530 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtDiskInfo.java @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtDiskInfo { + + private final String sourceDiskId; + private final Integer sourceDiskDeviceKey; + private final String label; + private final String sourceDiskPath; + private final String datastoreName; + private final Long capacityBytes; + private final String changeId; + + public VmwareCbtDiskInfo(String sourceDiskId, Integer sourceDiskDeviceKey, String label, String sourceDiskPath, + String datastoreName, Long capacityBytes, String changeId) { + this.sourceDiskId = sourceDiskId; + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + this.label = label; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.capacityBytes = capacityBytes; + this.changeId = changeId; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + + public String getLabel() { + return label; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public Long getCapacityBytes() { + return capacityBytes; + } + + public String getChangeId() { + return changeId; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java new file mode 100644 index 000000000000..3f56a9c64451 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigration.java @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface VmwareCbtMigration extends Identity, InternalIdentity { + enum State { + Created, + InitialSync, + Replicating, + ReadyForCutover, + CuttingOver, + ReadyForImport, + Completed, + Failed, + Cancelled; + + public boolean isTerminal() { + return this == Completed || this == Failed || this == Cancelled; + } + + public static State getValue(String state) { + for (State value : values()) { + if (value.name().equalsIgnoreCase(state)) { + return value; + } + } + throw new IllegalArgumentException("Invalid VMware CBT migration state: " + state); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java new file mode 100644 index 000000000000..e1e8758358aa --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationCycle.java @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface VmwareCbtMigrationCycle extends Identity, InternalIdentity { + enum State { + Created, + QueryingChangedAreas, + CopyingChangedBlocks, + Completed, + Failed + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java new file mode 100644 index 000000000000..41d672de69c9 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationDisk.java @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface VmwareCbtMigrationDisk extends Identity, InternalIdentity { + enum State { + Created, + Prepared, + Syncing, + Ready, + Failed + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java new file mode 100644 index 000000000000..bf248782c2a0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationManager.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import org.apache.cloudstack.api.command.admin.vm.CancelVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.CheckVmwareCbtMigrationPrerequisitesCmd; +import org.apache.cloudstack.api.command.admin.vm.CutoverVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.DeleteVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.ListVmwareCbtMigrationsCmd; +import org.apache.cloudstack.api.command.admin.vm.StartVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.command.admin.vm.SyncVmwareCbtMigrationCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationPreflightResponse; +import org.apache.cloudstack.api.response.VmwareCbtMigrationResponse; + +import com.cloud.utils.component.PluggableService; + +public interface VmwareCbtMigrationManager extends PluggableService { + VmwareCbtMigrationPreflightResponse checkVmwareCbtMigrationPrerequisites(CheckVmwareCbtMigrationPrerequisitesCmd cmd); + + VmwareCbtMigrationResponse startVmwareCbtMigration(StartVmwareCbtMigrationCmd cmd); + + ListResponse listVmwareCbtMigrations(ListVmwareCbtMigrationsCmd cmd); + + VmwareCbtMigrationResponse syncVmwareCbtMigration(SyncVmwareCbtMigrationCmd cmd); + + VmwareCbtMigrationResponse cutoverVmwareCbtMigration(CutoverVmwareCbtMigrationCmd cmd); + + VmwareCbtMigrationResponse cancelVmwareCbtMigration(CancelVmwareCbtMigrationCmd cmd); + + boolean deleteVmwareCbtMigration(DeleteVmwareCbtMigrationCmd cmd); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java new file mode 100644 index 000000000000..9527ac67fd1f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtMigrationService.java @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import java.util.List; + +public interface VmwareCbtMigrationService { + VmwareCbtPreflightInfo getPreflightInfo(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); + + List listSourceDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); + + List listSnapshotDisks(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotMor); + + void ensureChangeTrackingEnabled(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName); + + VmwareCbtSnapshotInfo createSnapshot(String vcenter, String datacenterName, String username, String password, + String sourceHost, String sourceVmName, String snapshotName, + String snapshotDescription, boolean quiesce); + + List queryChangedDiskAreas(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName, + List disks, String snapshotMor); + + UnmanagedInstanceTO.PowerState getPowerState(String vcenter, String datacenterName, String username, + String password, String sourceHost, String sourceVmName); + + void removeSnapshot(String vcenter, String datacenterName, String username, String password, String sourceHost, + String sourceVmName, String snapshotMor); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java new file mode 100644 index 000000000000..a574af96a083 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightDiskInfo.java @@ -0,0 +1,59 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtPreflightDiskInfo extends VmwareCbtDiskInfo { + + private final String backingType; + private final String diskMode; + private final String rdmCompatibilityMode; + private final boolean independentDisk; + private final boolean physicalRdm; + + public VmwareCbtPreflightDiskInfo(String sourceDiskId, Integer sourceDiskDeviceKey, String label, + String sourceDiskPath, String datastoreName, Long capacityBytes, + String changeId, String backingType, String diskMode, + String rdmCompatibilityMode, boolean independentDisk, + boolean physicalRdm) { + super(sourceDiskId, sourceDiskDeviceKey, label, sourceDiskPath, datastoreName, capacityBytes, changeId); + this.backingType = backingType; + this.diskMode = diskMode; + this.rdmCompatibilityMode = rdmCompatibilityMode; + this.independentDisk = independentDisk; + this.physicalRdm = physicalRdm; + } + + public String getBackingType() { + return backingType; + } + + public String getDiskMode() { + return diskMode; + } + + public String getRdmCompatibilityMode() { + return rdmCompatibilityMode; + } + + public boolean isIndependentDisk() { + return independentDisk; + } + + public boolean isPhysicalRdm() { + return physicalRdm; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java new file mode 100644 index 000000000000..bf76922926e0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightInfo.java @@ -0,0 +1,138 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import java.util.Collections; +import java.util.List; + +public class VmwareCbtPreflightInfo { + + private final String sourceVmName; + private final String sourceVmMor; + private final Boolean changeTrackingSupported; + private final Boolean changeTrackingEnabled; + private final Boolean consolidationNeeded; + private final Integer existingSnapshotCount; + private final List disks; + private final List nics; + private final Integer cpuCores; + private final Integer cpuSpeed; + private final Integer memoryMb; + private final String operatingSystemId; + private final String operatingSystem; + private String bootType; + private String bootMode; + + public VmwareCbtPreflightInfo(String sourceVmName, String sourceVmMor, + Boolean changeTrackingSupported, Boolean changeTrackingEnabled, + Boolean consolidationNeeded, Integer existingSnapshotCount, + List disks, + Integer cpuCores, Integer cpuSpeed, Integer memoryMb) { + this(sourceVmName, sourceVmMor, changeTrackingSupported, changeTrackingEnabled, consolidationNeeded, + existingSnapshotCount, disks, null, cpuCores, cpuSpeed, memoryMb, null, null); + } + + public VmwareCbtPreflightInfo(String sourceVmName, String sourceVmMor, + Boolean changeTrackingSupported, Boolean changeTrackingEnabled, + Boolean consolidationNeeded, Integer existingSnapshotCount, + List disks, + List nics, + Integer cpuCores, Integer cpuSpeed, Integer memoryMb, + String operatingSystemId, String operatingSystem) { + this.sourceVmName = sourceVmName; + this.sourceVmMor = sourceVmMor; + this.changeTrackingSupported = changeTrackingSupported; + this.changeTrackingEnabled = changeTrackingEnabled; + this.consolidationNeeded = consolidationNeeded; + this.existingSnapshotCount = existingSnapshotCount; + this.disks = disks == null ? Collections.emptyList() : Collections.unmodifiableList(disks); + this.nics = nics == null ? Collections.emptyList() : Collections.unmodifiableList(nics); + this.cpuCores = cpuCores; + this.cpuSpeed = cpuSpeed; + this.memoryMb = memoryMb; + this.operatingSystemId = operatingSystemId; + this.operatingSystem = operatingSystem; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getSourceVmMor() { + return sourceVmMor; + } + + public Boolean getChangeTrackingSupported() { + return changeTrackingSupported; + } + + public Boolean getChangeTrackingEnabled() { + return changeTrackingEnabled; + } + + public Boolean getConsolidationNeeded() { + return consolidationNeeded; + } + + public Integer getExistingSnapshotCount() { + return existingSnapshotCount; + } + + public List getNics() { + return nics; + } + + public List getDisks() { + return disks; + } + + public Integer getCpuCores() { + return cpuCores; + } + + public Integer getCpuSpeed() { + return cpuSpeed; + } + + public Integer getMemoryMb() { + return memoryMb; + } + + public String getOperatingSystemId() { + return operatingSystemId; + } + + public String getOperatingSystem() { + return operatingSystem; + } + + public String getBootType() { + return bootType; + } + + public void setBootType(String bootType) { + this.bootType = bootType; + } + + public String getBootMode() { + return bootMode; + } + + public void setBootMode(String bootMode) { + this.bootMode = bootMode; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightNicInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightNicInfo.java new file mode 100644 index 000000000000..d22d61235d7a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtPreflightNicInfo.java @@ -0,0 +1,87 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +import java.util.List; + +/** + * A source VM NIC discovered during VMware CBT preflight, so NIC-to-network + * mappings can be validated when the migration is started instead of failing + * at import time, after replication has already run. + */ +public class VmwareCbtPreflightNicInfo { + + private final String sourceNicId; + private final String adapterType; + private final String macAddress; + private final Integer vlan; + + // Guest network facts from VMware Tools (available while the source runs): the IPv4 + // addresses in CIDR form, the default gateway of this NIC's subnet, and the guest's DNS + // servers. Null when Tools is not running or reports nothing for this NIC. Used to + // preserve a static source IP across the migration when it fits the target network. + private List ipv4Cidrs; + private String ipv4Gateway; + private List dnsServers; + + public VmwareCbtPreflightNicInfo(String sourceNicId, String adapterType, String macAddress, Integer vlan) { + this.sourceNicId = sourceNicId; + this.adapterType = adapterType; + this.macAddress = macAddress; + this.vlan = vlan; + } + + public String getSourceNicId() { + return sourceNicId; + } + + public String getAdapterType() { + return adapterType; + } + + public String getMacAddress() { + return macAddress; + } + + public Integer getVlan() { + return vlan; + } + + public List getIpv4Cidrs() { + return ipv4Cidrs; + } + + public void setIpv4Cidrs(List ipv4Cidrs) { + this.ipv4Cidrs = ipv4Cidrs; + } + + public String getIpv4Gateway() { + return ipv4Gateway; + } + + public void setIpv4Gateway(String ipv4Gateway) { + this.ipv4Gateway = ipv4Gateway; + } + + public List getDnsServers() { + return dnsServers; + } + + public void setDnsServers(List dnsServers) { + this.dnsServers = dnsServers; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java new file mode 100644 index 000000000000..c8bfe3edbe84 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/VmwareCbtSnapshotInfo.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm; + +public class VmwareCbtSnapshotInfo { + + private final String snapshotName; + private final String snapshotMor; + private final String sourceVmMor; + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor) { + this(snapshotName, snapshotMor, null); + } + + public VmwareCbtSnapshotInfo(String snapshotName, String snapshotMor, String sourceVmMor) { + this.snapshotName = snapshotName; + this.snapshotMor = snapshotMor; + this.sourceVmMor = sourceVmMor; + } + + public String getSnapshotName() { + return snapshotName; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public String getSourceVmMor() { + return sourceVmMor; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroup.java b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroup.java new file mode 100644 index 000000000000..f22a4f4b8e98 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroup.java @@ -0,0 +1,33 @@ +// 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.bootgroup; + +import java.util.Date; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface InstanceBootGroup extends ControlledEntity, Identity, InternalIdentity { + + String getName(); + + String getDescription(); + + Date getCreated(); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupMember.java b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupMember.java new file mode 100644 index 000000000000..8f070634c040 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupMember.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.vm.bootgroup; + +import java.util.Date; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface InstanceBootGroupMember extends Identity, InternalIdentity { + + long getBootGroupId(); + + MemberType getMemberType(); + + long getMemberId(); + + int getBootOrder(); + + Date getCreated(); + + enum MemberType { + VirtualMachine, InstanceGroup + } + + enum ReadinessMode { + None, RuleBased, ChildDependent + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupService.java b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupService.java new file mode 100644 index 000000000000..111bf4c50840 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupService.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.vm.bootgroup; + +import org.apache.cloudstack.api.command.user.bootgroup.AddMemberToInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.CreateInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.CreateInstanceBootGroupReadinessRuleCmd; +import org.apache.cloudstack.api.command.user.bootgroup.DeleteInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.DeleteInstanceBootGroupReadinessRuleCmd; +import org.apache.cloudstack.api.command.user.bootgroup.ListInstanceBootGroupMembersCmd; +import org.apache.cloudstack.api.command.user.bootgroup.ListInstanceBootGroupReadinessRulesCmd; +import org.apache.cloudstack.api.command.user.bootgroup.ListInstanceBootGroupsCmd; +import org.apache.cloudstack.api.command.user.bootgroup.RebootInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.RemoveInstanceBootGroupMemberCmd; +import org.apache.cloudstack.api.command.user.bootgroup.StartInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.StopInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.UpdateInstanceBootGroupCmd; +import org.apache.cloudstack.api.command.user.bootgroup.UpdateInstanceBootGroupMemberCmd; +import org.apache.cloudstack.api.command.user.bootgroup.UpdateInstanceBootGroupReadinessRuleCmd; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; + +public interface InstanceBootGroupService { + + InstanceBootGroup createInstanceBootGroup(CreateInstanceBootGroupCmd cmd); + + boolean deleteInstanceBootGroup(DeleteInstanceBootGroupCmd cmd); + + InstanceBootGroup updateInstanceBootGroup(UpdateInstanceBootGroupCmd cmd); + + ListResponse listInstanceBootGroups(ListInstanceBootGroupsCmd cmd); + + InstanceBootGroupMember addMemberToInstanceBootGroup(AddMemberToInstanceBootGroupCmd cmd); + + boolean removeInstanceBootGroupMember(RemoveInstanceBootGroupMemberCmd cmd); + + InstanceBootGroupMember updateInstanceBootGroupMember(UpdateInstanceBootGroupMemberCmd cmd); + + ListResponse listInstanceBootGroupMembers(ListInstanceBootGroupMembersCmd cmd); + + InstanceBootGroup startInstanceBootGroup(StartInstanceBootGroupCmd cmd); + + InstanceBootGroup stopInstanceBootGroup(StopInstanceBootGroupCmd cmd); + + InstanceBootGroup rebootInstanceBootGroup(RebootInstanceBootGroupCmd cmd); + + InstanceBootGroupResponse createInstanceBootGroupResponse(long id); + + InstanceBootGroupMemberResponse createInstanceBootGroupMemberResponse(InstanceBootGroupMember member); + + InstanceBootGroupReadinessRule createInstanceBootGroupReadinessRule(CreateInstanceBootGroupReadinessRuleCmd cmd); + + InstanceBootGroupReadinessRule updateInstanceBootGroupReadinessRule(UpdateInstanceBootGroupReadinessRuleCmd cmd); + + boolean deleteInstanceBootGroupReadinessRule(DeleteInstanceBootGroupReadinessRuleCmd cmd); + + ListResponse listInstanceBootGroupReadinessRules(ListInstanceBootGroupReadinessRulesCmd cmd); + + InstanceBootGroupReadinessRuleResponse createInstanceBootGroupReadinessRuleResponse(InstanceBootGroupReadinessRule rule); + + Long getInstanceBootGroupIdForMember(long memberId); + +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceBootGroupReadinessRule.java b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceBootGroupReadinessRule.java new file mode 100644 index 000000000000..e1933f9c6d12 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceBootGroupReadinessRule.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.vm.bootgroup.readiness; + +import java.util.Date; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; + +/** + * A readiness rule always belongs to exactly one boot group and references one "item" within it: + * a direct {@code VirtualMachine}-type member, a direct {@code InstanceGroup}-type member, or a VM + * sitting inside one of the boot group's {@code InstanceGroup}-type members (no member row of its + * own, referenced directly by VM id). + */ +public interface InstanceBootGroupReadinessRule extends Identity, InternalIdentity { + + long getBootGroupId(); + + InstanceBootGroupMember.MemberType getItemType(); + + long getItemId(); + + RuleType getRuleType(); + + String getName(); + + boolean isEnabled(); + + Date getCreated(); + + /** + * Which rule types are valid depends on {@link InstanceBootGroupMember.MemberType}, + * hypervisor-agnostic at the DB/API layer (no qemu/KVM naming stored). Ping/PortCheck/ + * GuestAgentLiveness apply to VirtualMachine items; MemberQuorum applies to InstanceGroup + * items. CustomScript is reserved for a future checker/evaluator implementation and is not + * currently accepted by the API for either item type. + */ + enum RuleType { + GuestAgentLiveness(true), + Ping(true), + PortCheck(true), + CustomScript(false), + MemberQuorum(false); + + private final boolean memberTargeted; + + RuleType(boolean memberTargeted) { + this.memberTargeted = memberTargeted; + } + + /** + * True for a rule type that, when attached to an InstanceGroup, is evaluated against every + * current member individually and inherited by each member's own readiness — unlike + * MemberQuorum, which only ever operates at group scope. + */ + public boolean isMemberTargeted() { + return memberTargeted; + } + } + + enum Status { + Ready, NotReady, Error, Unknown + } +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/ReadinessChecker.java b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/ReadinessChecker.java new file mode 100644 index 000000000000..cb66ad2b1d82 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/ReadinessChecker.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 org.apache.cloudstack.vm.bootgroup.readiness; + +import java.util.Map; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; + +/** + * Strategy interface for evaluating one readiness rule type. Implementations are collected by + * {@code InstanceBootGroupReadinessRuleManagerImpl} via Spring's {@code List} + * autowiring and dispatched by {@link #getRuleType()} — an internal implementation detail, not + * API-facing, so left unprefixed. + */ +public interface ReadinessChecker { + + /** + * Below this much remaining budget, a checker should not even attempt to dispatch a remote + * command — there isn't enough time left for a meaningful wait, and dispatching anyway would + * either use an unhelpfully tiny (or, worse, a zero/negative, which some transports treat as "no + * override, use the default") wait value. + */ + long MIN_REMAINING_MS_TO_DISPATCH = 2000L; + + InstanceBootGroupReadinessRule.RuleType getRuleType(); + + /** + * @param remainingMs time budget left for this VM's current attempt; bound any remote dispatch + * to it (e.g. via {@code Command.setWait}) and return {@code Status.Error} directly if + * it's already too small to be worth dispatching. + */ + Result check(InstanceBootGroupReadinessRule rule, Map details, long vmId, long remainingMs); + + class Result { + private final InstanceBootGroupReadinessRule.Status status; + private final String message; + + public Result(InstanceBootGroupReadinessRule.Status status, String message) { + this.status = status; + this.message = message; + } + + public InstanceBootGroupReadinessRule.Status getStatus() { + return status; + } + + public String getMessage() { + return message; + } + } + + default Logger getLogger() { + return null; + } + + default Result logAndReturn(InstanceBootGroupReadinessRule rule, Object vmOrId, Result result) { + Logger log = getLogger(); + if (log == null) { + return result; + } + Level level = InstanceBootGroupReadinessRule.Status.Ready.equals(result.getStatus()) + ? Level.DEBUG + : Level.WARN; + + log.log(level, "{} evaluated for {}: status={}, message={}", + rule, vmOrId, result.getStatus(), result.getMessage()); + return result; + } + + /** + * Halves the remaining budget and floors at 1 second for {@code Command.setWait}, which doubles + * whatever value it's given and treats 0 as "use the global default" rather than "no wait". + */ + default int computeWaitSeconds(long remainingMs) { + return (int) Math.max(1, remainingMs / 2000); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/AddMemberToInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/AddMemberToInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..45880112bcad --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/AddMemberToInstanceBootGroupCmdTest.java @@ -0,0 +1,95 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.junit.Test; + +public class AddMemberToInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private AddMemberToInstanceBootGroupCmd createCmd() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = new AddMemberToInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "virtualMachineId", 500L); + setField(cmd, "order", 2); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals(Long.valueOf(500L), cmd.getVirtualMachineId()); + assertEquals(2, cmd.getOrder()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = createCmd(); + + InstanceBootGroupMember member = mock(InstanceBootGroupMember.class); + InstanceBootGroupMemberResponse mockResponse = new InstanceBootGroupMemberResponse(); + + when(instanceBootGroupService.addMemberToInstanceBootGroup(cmd)).thenReturn(member); + when(instanceBootGroupService.createInstanceBootGroupMemberResponse(member)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupMemberResponse response = (InstanceBootGroupMemberResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("addmembertoinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).addMemberToInstanceBootGroup(cmd); + verify(instanceBootGroupService).createInstanceBootGroupMemberResponse(member); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.addMemberToInstanceBootGroup(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + AddMemberToInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.addMemberToInstanceBootGroup(cmd)).thenThrow(new RuntimeException("conflict")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/BaseBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/BaseBootGroupCmdTest.java new file mode 100644 index 000000000000..f3d62d207733 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/BaseBootGroupCmdTest.java @@ -0,0 +1,88 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.bootgroup; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; + +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupService; +import org.junit.After; +import org.junit.Before; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import com.cloud.user.Account; +import com.cloud.user.AccountService; + +/** + * Shared setup for all Instance Boot Group command unit tests. + */ +public abstract class BaseBootGroupCmdTest { + + protected static final long ACCOUNT_ID = 42L; + protected static final long ENTITY_ID = 100L; + + protected InstanceBootGroupService instanceBootGroupService; + protected AccountService accountService; + protected Account callingAccount; + + private MockedStatic callContextMock; + + @Before + public void setUp() { + instanceBootGroupService = mock(InstanceBootGroupService.class); + accountService = mock(AccountService.class); + + callingAccount = mock(Account.class); + when(callingAccount.getId()).thenReturn(ACCOUNT_ID); + + CallContext callContext = mock(CallContext.class); + when(callContext.getCallingAccount()).thenReturn(callingAccount); + + callContextMock = Mockito.mockStatic(CallContext.class); + callContextMock.when(CallContext::current).thenReturn(callContext); + } + + @After + public void tearDown() { + callContextMock.close(); + } + + /** + * Sets a private/inherited field value via reflection. + */ + protected void setField(Object target, String fieldName, Object value) throws Exception { + Field field = null; + Class clazz = target.getClass(); + while (clazz != null) { + try { + field = clazz.getDeclaredField(fieldName); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (field == null) { + throw new NoSuchFieldException(fieldName + " not found in hierarchy of " + target.getClass().getName()); + } + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..c6352dcc94ef --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupCmdTest.java @@ -0,0 +1,107 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.junit.Test; + +public class CreateInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private CreateInstanceBootGroupCmd createCmd() throws Exception { + CreateInstanceBootGroupCmd cmd = new CreateInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "_accountService", accountService); + setField(cmd, "name", "web-tier"); + setField(cmd, "description", "web tier boot group"); + setField(cmd, "readinessAttemptTimeoutSeconds", 120L); + setField(cmd, "readinessMaxRetryAttempts", 3L); + setField(cmd, "readinessRebootOnRetry", true); + setField(cmd, "readinessInitialDelaySeconds", 15L); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + CreateInstanceBootGroupCmd cmd = createCmd(); + assertEquals("web-tier", cmd.getName()); + assertEquals("web tier boot group", cmd.getDescription()); + assertEquals(Long.valueOf(120L), cmd.getReadinessAttemptTimeoutSeconds()); + assertEquals(Long.valueOf(3L), cmd.getReadinessMaxRetryAttempts()); + assertEquals(Boolean.TRUE, cmd.getReadinessRebootOnRetry()); + assertEquals(Long.valueOf(15L), cmd.getReadinessInitialDelaySeconds()); + } + + @Test + public void testGetEntityOwnerIdResolvedFromAccount() throws Exception { + CreateInstanceBootGroupCmd cmd = createCmd(); + setField(cmd, "accountName", "someaccount"); + setField(cmd, "domainId", 5L); + when(accountService.finalizeAccountId("someaccount", 5L, null, true)).thenReturn(200L); + + assertEquals(200L, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdFallsBackToCaller() throws Exception { + CreateInstanceBootGroupCmd cmd = createCmd(); + when(accountService.finalizeAccountId(null, null, null, true)).thenReturn(null); + + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + CreateInstanceBootGroupCmd cmd = createCmd(); + + InstanceBootGroup group = mock(InstanceBootGroup.class); + when(group.getId()).thenReturn(ENTITY_ID); + InstanceBootGroupResponse mockResponse = new InstanceBootGroupResponse(); + + when(instanceBootGroupService.createInstanceBootGroup(cmd)).thenReturn(group); + when(instanceBootGroupService.createInstanceBootGroupResponse(ENTITY_ID)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupResponse response = (InstanceBootGroupResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("createinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).createInstanceBootGroup(cmd); + verify(instanceBootGroupService).createInstanceBootGroupResponse(ENTITY_ID); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + CreateInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.createInstanceBootGroup(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + CreateInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.createInstanceBootGroup(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupReadinessRuleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupReadinessRuleCmdTest.java new file mode 100644 index 000000000000..7efb8ec4c797 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/CreateInstanceBootGroupReadinessRuleCmdTest.java @@ -0,0 +1,158 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.junit.Test; + +public class CreateInstanceBootGroupReadinessRuleCmdTest extends BaseBootGroupCmdTest { + + private CreateInstanceBootGroupReadinessRuleCmd createCmd() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = new CreateInstanceBootGroupReadinessRuleCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "bootGroupId", ENTITY_ID); + setField(cmd, "virtualMachineId", 500L); + setField(cmd, "instanceGroupId", null); + setField(cmd, "ruleType", "PortCheck"); + setField(cmd, "name", "port-check-rule"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getBootGroupId()); + assertEquals(Long.valueOf(500L), cmd.getVirtualMachineId()); + assertNull(cmd.getInstanceGroupId()); + assertEquals("PortCheck", cmd.getRuleType()); + assertEquals("port-check-rule", cmd.getName()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroupReadinessRule, cmd.getApiResourceType()); + } + + @Test + public void testIsEnabledDefaultsToTrueWhenNull() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "enabled", null); + assertTrue(cmd.isEnabled()); + } + + @Test + public void testIsEnabledFalse() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "enabled", false); + assertFalse(cmd.isEnabled()); + } + + @Test + public void testIsEnabledTrue() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "enabled", true); + assertTrue(cmd.isEnabled()); + } + + @Test + public void testGetDetailsNull() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "details", null); + assertNull(cmd.getDetails()); + } + + @Test + public void testGetDetailsEmptyMap() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "details", new HashMap<>()); + assertNull(cmd.getDetails()); + } + + @Test + public void testGetDetailsPopulatedMap() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + + Map innerMap = new HashMap<>(); + innerMap.put("port", "8080"); + innerMap.put("protocol", "tcp"); + + Map> outerMap = new HashMap<>(); + outerMap.put("0", innerMap); + + setField(cmd, "details", outerMap); + + Map details = cmd.getDetails(); + assertNotNull(details); + assertEquals("8080", details.get("port")); + assertEquals("tcp", details.get("protocol")); + } + + @Test + public void testExecuteSuccess() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + + InstanceBootGroupReadinessRule rule = mock(InstanceBootGroupReadinessRule.class); + InstanceBootGroupReadinessRuleResponse mockResponse = new InstanceBootGroupReadinessRuleResponse(); + + when(instanceBootGroupService.createInstanceBootGroupReadinessRule(cmd)).thenReturn(rule); + when(instanceBootGroupService.createInstanceBootGroupReadinessRuleResponse(rule)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupReadinessRuleResponse response = (InstanceBootGroupReadinessRuleResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("createinstancebootgroupreadinessruleresponse", response.getResponseName()); + verify(instanceBootGroupService).createInstanceBootGroupReadinessRule(cmd); + verify(instanceBootGroupService).createInstanceBootGroupReadinessRuleResponse(rule); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.createInstanceBootGroupReadinessRule(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + CreateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.createInstanceBootGroupReadinessRule(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..503efc5e94b1 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupCmdTest.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.junit.Test; + +public class DeleteInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private DeleteInstanceBootGroupCmd createCmd() throws Exception { + DeleteInstanceBootGroupCmd cmd = new DeleteInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + DeleteInstanceBootGroupCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + DeleteInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + DeleteInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + DeleteInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.deleteInstanceBootGroup(cmd)).thenReturn(true); + + cmd.execute(); + + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("deleteinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).deleteInstanceBootGroup(cmd); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + DeleteInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.deleteInstanceBootGroup(cmd)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + DeleteInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.deleteInstanceBootGroup(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupReadinessRuleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupReadinessRuleCmdTest.java new file mode 100644 index 000000000000..d31803703a3e --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/DeleteInstanceBootGroupReadinessRuleCmdTest.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.junit.Test; + +public class DeleteInstanceBootGroupReadinessRuleCmdTest extends BaseBootGroupCmdTest { + + private DeleteInstanceBootGroupReadinessRuleCmd createCmd() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = new DeleteInstanceBootGroupReadinessRuleCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroupReadinessRule, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.deleteInstanceBootGroupReadinessRule(cmd)).thenReturn(true); + + cmd.execute(); + + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("deleteinstancebootgroupreadinessruleresponse", response.getResponseName()); + verify(instanceBootGroupService).deleteInstanceBootGroupReadinessRule(cmd); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.deleteInstanceBootGroupReadinessRule(cmd)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + DeleteInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.deleteInstanceBootGroupReadinessRule(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupMembersCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupMembersCmdTest.java new file mode 100644 index 000000000000..3a9f34fef4d6 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupMembersCmdTest.java @@ -0,0 +1,143 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Test; + +public class ListInstanceBootGroupMembersCmdTest extends BaseBootGroupCmdTest { + + private ListInstanceBootGroupMembersCmd createCmd() throws Exception { + ListInstanceBootGroupMembersCmd cmd = new ListInstanceBootGroupMembersCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "bootGroupId", ENTITY_ID); + setField(cmd, "memberType", "VirtualMachine"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getBootGroupId()); + assertEquals("VirtualMachine", cmd.getMemberType()); + } + + @Test + public void testIsIgnoreInstanceStateNull() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + assertFalse(cmd.isIgnoreInstanceState()); + } + + @Test + public void testIsIgnoreInstanceStateFalse() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + setField(cmd, "ignoreInstanceState", false); + assertFalse(cmd.isIgnoreInstanceState()); + } + + @Test + public void testIsIgnoreInstanceStateTrue() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + setField(cmd, "ignoreInstanceState", true); + assertTrue(cmd.isIgnoreInstanceState()); + } + + @Test + public void testDetailFlagsNullList() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + assertFalse(cmd.isReadinessDetailRequested()); + assertFalse(cmd.isChildrenDetailRequested()); + } + + @Test + public void testDetailFlagsEmptyList() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + setField(cmd, "viewDetails", Collections.emptyList()); + assertFalse(cmd.isReadinessDetailRequested()); + assertFalse(cmd.isChildrenDetailRequested()); + } + + @Test + public void testDetailFlagsListWithoutKeyword() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + setField(cmd, "viewDetails", Collections.singletonList("other")); + assertFalse(cmd.isReadinessDetailRequested()); + assertFalse(cmd.isChildrenDetailRequested()); + } + + @Test + public void testDetailFlagsReadinessOnly() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + List details = Collections.singletonList("readiness"); + setField(cmd, "viewDetails", details); + assertTrue(cmd.isReadinessDetailRequested()); + assertFalse(cmd.isChildrenDetailRequested()); + } + + @Test + public void testDetailFlagsChildrenOnly() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + List details = Collections.singletonList("children"); + setField(cmd, "viewDetails", details); + assertFalse(cmd.isReadinessDetailRequested()); + assertTrue(cmd.isChildrenDetailRequested()); + } + + @Test + public void testDetailFlagsAll() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + List details = Collections.singletonList("all"); + setField(cmd, "viewDetails", details); + assertTrue(cmd.isReadinessDetailRequested()); + assertTrue(cmd.isChildrenDetailRequested()); + } + + @Test + public void testDetailFlagsBothExplicit() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + List details = Arrays.asList("readiness", "children"); + setField(cmd, "viewDetails", details); + assertTrue(cmd.isReadinessDetailRequested()); + assertTrue(cmd.isChildrenDetailRequested()); + } + + @Test + public void testExecute() throws Exception { + ListInstanceBootGroupMembersCmd cmd = createCmd(); + + ListResponse mockListResponse = new ListResponse<>(); + when(instanceBootGroupService.listInstanceBootGroupMembers(cmd)).thenReturn(mockListResponse); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listinstancebootgroupmembersresponse", response.getResponseName()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupReadinessRulesCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupReadinessRulesCmdTest.java new file mode 100644 index 000000000000..92b6f22110ba --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupReadinessRulesCmdTest.java @@ -0,0 +1,65 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Test; + +public class ListInstanceBootGroupReadinessRulesCmdTest extends BaseBootGroupCmdTest { + + private ListInstanceBootGroupReadinessRulesCmd createCmd() throws Exception { + ListInstanceBootGroupReadinessRulesCmd cmd = new ListInstanceBootGroupReadinessRulesCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "bootGroupId", ENTITY_ID); + setField(cmd, "id", 700L); + setField(cmd, "virtualMachineId", 500L); + setField(cmd, "instanceGroupId", 600L); + setField(cmd, "ruleType", "Http"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + ListInstanceBootGroupReadinessRulesCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getBootGroupId()); + assertEquals(Long.valueOf(700L), cmd.getId()); + assertEquals(Long.valueOf(500L), cmd.getVirtualMachineId()); + assertEquals(Long.valueOf(600L), cmd.getInstanceGroupId()); + assertEquals("Http", cmd.getRuleType()); + } + + @Test + public void testExecute() throws Exception { + ListInstanceBootGroupReadinessRulesCmd cmd = createCmd(); + + ListResponse mockListResponse = new ListResponse<>(); + when(instanceBootGroupService.listInstanceBootGroupReadinessRules(cmd)).thenReturn(mockListResponse); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = + (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listinstancebootgroupreadinessrulesresponse", response.getResponseName()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupsCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupsCmdTest.java new file mode 100644 index 000000000000..599cb05c3ba7 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/ListInstanceBootGroupsCmdTest.java @@ -0,0 +1,62 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Test; + +public class ListInstanceBootGroupsCmdTest extends BaseBootGroupCmdTest { + + private ListInstanceBootGroupsCmd createCmd() throws Exception { + ListInstanceBootGroupsCmd cmd = new ListInstanceBootGroupsCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "keyword", "web"); + setField(cmd, "virtualMachineId", 500L); + setField(cmd, "instanceGroupId", 600L); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + ListInstanceBootGroupsCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals("web", cmd.getKeyword()); + assertEquals(Long.valueOf(500L), cmd.getVirtualMachineId()); + assertEquals(Long.valueOf(600L), cmd.getInstanceGroupId()); + } + + @Test + public void testExecute() throws Exception { + ListInstanceBootGroupsCmd cmd = createCmd(); + + ListResponse mockListResponse = new ListResponse<>(); + when(instanceBootGroupService.listInstanceBootGroups(cmd)).thenReturn(mockListResponse); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listinstancebootgroupsresponse", response.getResponseName()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/RebootInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/RebootInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..a278d4991a9f --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/RebootInstanceBootGroupCmdTest.java @@ -0,0 +1,118 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.junit.Test; + +import com.cloud.event.EventTypes; +import com.cloud.utils.exception.CloudRuntimeException; + +public class RebootInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private RebootInstanceBootGroupCmd createCmd() throws Exception { + RebootInstanceBootGroupCmd cmd = new RebootInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testEventType() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_INSTANCE_BOOT_GROUP_REBOOT, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + when(CallContext.current().getApiResourceUuid(ApiConstants.ID)).thenReturn("group-uuid"); + + assertEquals("Rebooting Instance Boot Group with ID: group-uuid", cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + + InstanceBootGroup group = mock(InstanceBootGroup.class); + when(group.getId()).thenReturn(ENTITY_ID); + InstanceBootGroupResponse mockResponse = new InstanceBootGroupResponse(); + + when(instanceBootGroupService.rebootInstanceBootGroup(cmd)).thenReturn(group); + when(instanceBootGroupService.createInstanceBootGroupResponse(ENTITY_ID)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupResponse response = (InstanceBootGroupResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("rebootinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).rebootInstanceBootGroup(cmd); + verify(instanceBootGroupService).createInstanceBootGroupResponse(ENTITY_ID); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.rebootInstanceBootGroup(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.rebootInstanceBootGroup(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteWrapsCloudRuntimeException() throws Exception { + RebootInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.rebootInstanceBootGroup(cmd)).thenThrow(new CloudRuntimeException("cannot reboot")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/RemoveInstanceBootGroupMemberCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/RemoveInstanceBootGroupMemberCmdTest.java new file mode 100644 index 000000000000..08ef6c5173cf --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/RemoveInstanceBootGroupMemberCmdTest.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.junit.Test; + +public class RemoveInstanceBootGroupMemberCmdTest extends BaseBootGroupCmdTest { + + private RemoveInstanceBootGroupMemberCmd createCmd() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = new RemoveInstanceBootGroupMemberCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = createCmd(); + Long groupId = 101L; + when(instanceBootGroupService.getInstanceBootGroupIdForMember(ENTITY_ID)).thenReturn(groupId); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(groupId, cmd.getApiResourceId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = createCmd(); + when(instanceBootGroupService.removeInstanceBootGroupMember(cmd)).thenReturn(true); + + cmd.execute(); + + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("removeinstancebootgroupmemberresponse", response.getResponseName()); + verify(instanceBootGroupService).removeInstanceBootGroupMember(cmd); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = createCmd(); + when(instanceBootGroupService.removeInstanceBootGroupMember(cmd)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + RemoveInstanceBootGroupMemberCmd cmd = createCmd(); + when(instanceBootGroupService.removeInstanceBootGroupMember(cmd)).thenThrow(new RuntimeException("conflict")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/StartInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/StartInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..76689a6205d1 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/StartInstanceBootGroupCmdTest.java @@ -0,0 +1,118 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.junit.Test; + +import com.cloud.event.EventTypes; +import com.cloud.utils.exception.CloudRuntimeException; + +public class StartInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private StartInstanceBootGroupCmd createCmd() throws Exception { + StartInstanceBootGroupCmd cmd = new StartInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testEventType() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_INSTANCE_BOOT_GROUP_START, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + when(CallContext.current().getApiResourceUuid(ApiConstants.ID)).thenReturn("group-uuid"); + + assertEquals("Starting Instance Boot Group with ID: group-uuid", cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + + InstanceBootGroup group = mock(InstanceBootGroup.class); + when(group.getId()).thenReturn(ENTITY_ID); + InstanceBootGroupResponse mockResponse = new InstanceBootGroupResponse(); + + when(instanceBootGroupService.startInstanceBootGroup(cmd)).thenReturn(group); + when(instanceBootGroupService.createInstanceBootGroupResponse(ENTITY_ID)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupResponse response = (InstanceBootGroupResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("startinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).startInstanceBootGroup(cmd); + verify(instanceBootGroupService).createInstanceBootGroupResponse(ENTITY_ID); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.startInstanceBootGroup(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.startInstanceBootGroup(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteWrapsCloudRuntimeException() throws Exception { + StartInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.startInstanceBootGroup(cmd)).thenThrow(new CloudRuntimeException("cannot start")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/StopInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/StopInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..6c788f4a2b4d --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/StopInstanceBootGroupCmdTest.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.junit.Test; + +import com.cloud.event.EventTypes; + +public class StopInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private StopInstanceBootGroupCmd createCmd() throws Exception { + StopInstanceBootGroupCmd cmd = new StopInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testEventType() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_INSTANCE_BOOT_GROUP_STOP, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + when(CallContext.current().getApiResourceUuid(ApiConstants.ID)).thenReturn("group-uuid"); + + assertEquals("Stopping Instance Boot Group with ID: group-uuid", cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + + InstanceBootGroup group = mock(InstanceBootGroup.class); + when(group.getId()).thenReturn(ENTITY_ID); + InstanceBootGroupResponse mockResponse = new InstanceBootGroupResponse(); + + when(instanceBootGroupService.stopInstanceBootGroup(cmd)).thenReturn(group); + when(instanceBootGroupService.createInstanceBootGroupResponse(ENTITY_ID)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupResponse response = (InstanceBootGroupResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("stopinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).stopInstanceBootGroup(cmd); + verify(instanceBootGroupService).createInstanceBootGroupResponse(ENTITY_ID); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.stopInstanceBootGroup(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + StopInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.stopInstanceBootGroup(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupCmdTest.java new file mode 100644 index 000000000000..f64b1298b2e2 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupCmdTest.java @@ -0,0 +1,104 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupResponse; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroup; +import org.junit.Test; + +public class UpdateInstanceBootGroupCmdTest extends BaseBootGroupCmdTest { + + private UpdateInstanceBootGroupCmd createCmd() throws Exception { + UpdateInstanceBootGroupCmd cmd = new UpdateInstanceBootGroupCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "name", "web-tier-renamed"); + setField(cmd, "description", "updated description"); + setField(cmd, "readinessAttemptTimeoutSeconds", 180L); + setField(cmd, "readinessMaxRetryAttempts", 5L); + setField(cmd, "readinessRebootOnRetry", false); + setField(cmd, "readinessInitialDelaySeconds", 30L); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + UpdateInstanceBootGroupCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals("web-tier-renamed", cmd.getName()); + assertEquals("updated description", cmd.getDescription()); + assertEquals(Long.valueOf(180L), cmd.getReadinessAttemptTimeoutSeconds()); + assertEquals(Long.valueOf(5L), cmd.getReadinessMaxRetryAttempts()); + assertEquals(Boolean.FALSE, cmd.getReadinessRebootOnRetry()); + assertEquals(Long.valueOf(30L), cmd.getReadinessInitialDelaySeconds()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + UpdateInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + UpdateInstanceBootGroupCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + UpdateInstanceBootGroupCmd cmd = createCmd(); + + InstanceBootGroup group = mock(InstanceBootGroup.class); + when(group.getId()).thenReturn(ENTITY_ID); + InstanceBootGroupResponse mockResponse = new InstanceBootGroupResponse(); + + when(instanceBootGroupService.updateInstanceBootGroup(cmd)).thenReturn(group); + when(instanceBootGroupService.createInstanceBootGroupResponse(ENTITY_ID)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupResponse response = (InstanceBootGroupResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("updateinstancebootgroupresponse", response.getResponseName()); + verify(instanceBootGroupService).updateInstanceBootGroup(cmd); + verify(instanceBootGroupService).createInstanceBootGroupResponse(ENTITY_ID); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + UpdateInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.updateInstanceBootGroup(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + UpdateInstanceBootGroupCmd cmd = createCmd(); + when(instanceBootGroupService.updateInstanceBootGroup(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupMemberCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupMemberCmdTest.java new file mode 100644 index 000000000000..219ae3ce67fb --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupMemberCmdTest.java @@ -0,0 +1,95 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupMemberResponse; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.junit.Test; + +public class UpdateInstanceBootGroupMemberCmdTest extends BaseBootGroupCmdTest { + + private UpdateInstanceBootGroupMemberCmd createCmd() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = new UpdateInstanceBootGroupMemberCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "order", 3); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals(3, cmd.getOrder()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = createCmd(); + Long groupId = 101L; + when(instanceBootGroupService.getInstanceBootGroupIdForMember(ENTITY_ID)).thenReturn(groupId); + assertEquals(ApiCommandResourceType.InstanceBootGroup, cmd.getApiResourceType()); + assertEquals(groupId, cmd.getApiResourceId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = createCmd(); + + InstanceBootGroupMember member = mock(InstanceBootGroupMember.class); + InstanceBootGroupMemberResponse mockResponse = new InstanceBootGroupMemberResponse(); + + when(instanceBootGroupService.updateInstanceBootGroupMember(cmd)).thenReturn(member); + when(instanceBootGroupService.createInstanceBootGroupMemberResponse(member)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupMemberResponse response = (InstanceBootGroupMemberResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("updateinstancebootgroupmemberresponse", response.getResponseName()); + verify(instanceBootGroupService).updateInstanceBootGroupMember(cmd); + verify(instanceBootGroupService).createInstanceBootGroupMemberResponse(member); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = createCmd(); + when(instanceBootGroupService.updateInstanceBootGroupMember(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + UpdateInstanceBootGroupMemberCmd cmd = createCmd(); + when(instanceBootGroupService.updateInstanceBootGroupMember(cmd)).thenThrow(new RuntimeException("conflict")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupReadinessRuleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupReadinessRuleCmdTest.java new file mode 100644 index 000000000000..b4f0e3608287 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/bootgroup/UpdateInstanceBootGroupReadinessRuleCmdTest.java @@ -0,0 +1,139 @@ +// 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.user.bootgroup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.InstanceBootGroupReadinessRuleResponse; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.junit.Test; + +public class UpdateInstanceBootGroupReadinessRuleCmdTest extends BaseBootGroupCmdTest { + + private UpdateInstanceBootGroupReadinessRuleCmd createCmd() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = new UpdateInstanceBootGroupReadinessRuleCmd(); + setField(cmd, "instanceBootGroupService", instanceBootGroupService); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "name", "renamed-rule"); + setField(cmd, "enabled", false); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals("renamed-rule", cmd.getName()); + assertEquals(Boolean.FALSE, cmd.getEnabled()); + } + + @Test + public void testGetEnabledNullWhenNotSet() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "enabled", null); + assertNull(cmd.getEnabled()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceType() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + assertEquals(ApiCommandResourceType.InstanceBootGroupReadinessRule, cmd.getApiResourceType()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getApiResourceId()); + } + + @Test + public void testGetDetailsNull() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "details", null); + assertNull(cmd.getDetails()); + } + + @Test + public void testGetDetailsEmptyMap() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + setField(cmd, "details", new HashMap<>()); + assertNull(cmd.getDetails()); + } + + @Test + public void testGetDetailsPopulatedMap() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + + Map innerMap = new HashMap<>(); + innerMap.put("port", "9090"); + innerMap.put("protocol", "udp"); + + Map> outerMap = new HashMap<>(); + outerMap.put("0", innerMap); + + setField(cmd, "details", outerMap); + + Map details = cmd.getDetails(); + assertNotNull(details); + assertEquals("9090", details.get("port")); + assertEquals("udp", details.get("protocol")); + } + + @Test + public void testExecuteSuccess() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + + InstanceBootGroupReadinessRule rule = mock(InstanceBootGroupReadinessRule.class); + InstanceBootGroupReadinessRuleResponse mockResponse = new InstanceBootGroupReadinessRuleResponse(); + + when(instanceBootGroupService.updateInstanceBootGroupReadinessRule(cmd)).thenReturn(rule); + when(instanceBootGroupService.createInstanceBootGroupReadinessRuleResponse(rule)).thenReturn(mockResponse); + + cmd.execute(); + + InstanceBootGroupReadinessRuleResponse response = (InstanceBootGroupReadinessRuleResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("updateinstancebootgroupreadinessruleresponse", response.getResponseName()); + verify(instanceBootGroupService).updateInstanceBootGroupReadinessRule(cmd); + verify(instanceBootGroupService).createInstanceBootGroupReadinessRuleResponse(rule); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.updateInstanceBootGroupReadinessRule(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = RuntimeException.class) + public void testExecutePropagatesServiceException() throws Exception { + UpdateInstanceBootGroupReadinessRuleCmd cmd = createCmd(); + when(instanceBootGroupService.updateInstanceBootGroupReadinessRule(cmd)).thenThrow(new RuntimeException("db error")); + cmd.execute(); + } +} diff --git a/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java index c46fb697a3c1..9bd5bf168a3d 100644 --- a/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/CheckConvertInstanceCommand.java @@ -19,6 +19,8 @@ public class CheckConvertInstanceCommand extends Command { boolean checkWindowsGuestConversionSupport = false; boolean useVddk = false; + boolean checkVddkRbdDirectImportSupport = false; + boolean checkVddkInPlaceFinalizationSupport = false; String vddkLibDir; public CheckConvertInstanceCommand() { @@ -50,6 +52,22 @@ public void setUseVddk(boolean useVddk) { this.useVddk = useVddk; } + public boolean isCheckVddkRbdDirectImportSupport() { + return checkVddkRbdDirectImportSupport; + } + + public void setCheckVddkRbdDirectImportSupport(boolean checkVddkRbdDirectImportSupport) { + this.checkVddkRbdDirectImportSupport = checkVddkRbdDirectImportSupport; + } + + public boolean isCheckVddkInPlaceFinalizationSupport() { + return checkVddkInPlaceFinalizationSupport; + } + + public void setCheckVddkInPlaceFinalizationSupport(boolean checkVddkInPlaceFinalizationSupport) { + this.checkVddkInPlaceFinalizationSupport = checkVddkInPlaceFinalizationSupport; + } + public String getVddkLibDir() { return vddkLibDir; } diff --git a/core/src/main/java/com/cloud/agent/api/CheckGuestAgentLivenessAnswer.java b/core/src/main/java/com/cloud/agent/api/CheckGuestAgentLivenessAnswer.java new file mode 100644 index 000000000000..4019977de810 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/CheckGuestAgentLivenessAnswer.java @@ -0,0 +1,35 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.agent.api; + +public class CheckGuestAgentLivenessAnswer extends Answer { + + public CheckGuestAgentLivenessAnswer() { + super(); + } + + public CheckGuestAgentLivenessAnswer(CheckGuestAgentLivenessCommand command, boolean alive, String details) { + super(command, alive, details); + } + + public CheckGuestAgentLivenessAnswer(CheckGuestAgentLivenessCommand command, Exception e) { + super(command, e); + } +} diff --git a/core/src/main/java/com/cloud/agent/api/CheckGuestAgentLivenessCommand.java b/core/src/main/java/com/cloud/agent/api/CheckGuestAgentLivenessCommand.java new file mode 100644 index 000000000000..b608beb156cd --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/CheckGuestAgentLivenessCommand.java @@ -0,0 +1,42 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.agent.api; + +public class CheckGuestAgentLivenessCommand extends Command { + + private String vmName; + + public CheckGuestAgentLivenessCommand(String vmName) { + this.vmName = vmName; + } + + public String getVmName() { + return vmName; + } + + public void setVmName(String vmName) { + this.vmName = vmName; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java index 38e0dca7736c..8a8e194ee220 100644 --- a/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/ConvertInstanceCommand.java @@ -18,8 +18,11 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareVddkSourceDiskTO; import com.cloud.hypervisor.Hypervisor; +import java.util.List; + public class ConvertInstanceCommand extends Command { private RemoteInstanceTO sourceInstance; @@ -35,6 +38,8 @@ public class ConvertInstanceCommand extends Command { private String vddkLibDir; private String vddkTransports; private String vddkThumbprint; + private List vmwareVddkSourceDisks; + private boolean windowsGuest; public ConvertInstanceCommand() { } @@ -126,6 +131,22 @@ public void setVddkThumbprint(String vddkThumbprint) { this.vddkThumbprint = vddkThumbprint; } + public List getVmwareVddkSourceDisks() { + return vmwareVddkSourceDisks; + } + + public void setVmwareVddkSourceDisks(List vmwareVddkSourceDisks) { + this.vmwareVddkSourceDisks = vmwareVddkSourceDisks; + } + + public boolean isWindowsGuest() { + return windowsGuest; + } + + public void setWindowsGuest(boolean windowsGuest) { + this.windowsGuest = windowsGuest; + } + @Override public boolean executeInSequence() { return false; diff --git a/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java b/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java index eadfa6556f86..e5b970814fab 100644 --- a/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java +++ b/core/src/main/java/com/cloud/agent/api/ImportConvertedInstanceCommand.java @@ -18,6 +18,7 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.storage.Storage; import java.util.List; @@ -25,6 +26,7 @@ public class ImportConvertedInstanceCommand extends Command { private RemoteInstanceTO sourceInstance; private List destinationStoragePools; + private List destinationStoragePoolTypes; private DataStoreTO conversionTemporaryLocation; private String temporaryConvertUuid; private boolean forceConvertToPool; @@ -34,15 +36,24 @@ public ImportConvertedInstanceCommand() { public ImportConvertedInstanceCommand(RemoteInstanceTO sourceInstance, List destinationStoragePools, + List destinationStoragePoolTypes, DataStoreTO conversionTemporaryLocation, String temporaryConvertUuid, boolean forceConvertToPool) { this.sourceInstance = sourceInstance; this.destinationStoragePools = destinationStoragePools; + this.destinationStoragePoolTypes = destinationStoragePoolTypes; this.conversionTemporaryLocation = conversionTemporaryLocation; this.temporaryConvertUuid = temporaryConvertUuid; this.forceConvertToPool = forceConvertToPool; } + public ImportConvertedInstanceCommand(RemoteInstanceTO sourceInstance, + List destinationStoragePools, + DataStoreTO conversionTemporaryLocation, String temporaryConvertUuid, + boolean forceConvertToPool) { + this(sourceInstance, destinationStoragePools, null, conversionTemporaryLocation, temporaryConvertUuid, forceConvertToPool); + } + public RemoteInstanceTO getSourceInstance() { return sourceInstance; } @@ -51,6 +62,10 @@ public List getDestinationStoragePools() { return destinationStoragePools; } + public List getDestinationStoragePoolTypes() { + return destinationStoragePoolTypes; + } + public DataStoreTO getConversionTemporaryLocation() { return conversionTemporaryLocation; } diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java new file mode 100644 index 000000000000..65ae402e1140 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtCleanupCommand.java @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtCleanupCommand extends Command { + + private String migrationUuid; + private List disks; + private boolean removeTemporarySnapshots; + private boolean removeNbdExports; + private boolean removePartialTargetDisks; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + + public VmwareCbtCleanupCommand() { + } + + public VmwareCbtCleanupCommand(String migrationUuid, List disks, boolean removeTemporarySnapshots, + boolean removeNbdExports, boolean removePartialTargetDisks) { + this.migrationUuid = migrationUuid; + this.disks = disks; + this.removeTemporarySnapshots = removeTemporarySnapshots; + this.removeNbdExports = removeNbdExports; + this.removePartialTargetDisks = removePartialTargetDisks; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public List getDisks() { + return disks; + } + + public boolean getRemoveTemporarySnapshots() { + return removeTemporarySnapshots; + } + + public boolean getRemoveNbdExports() { + return removeNbdExports; + } + + public boolean getRemovePartialTargetDisks() { + return removePartialTargetDisks; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + @Override + public boolean executeInSequence() { + // Cancellation must be able to interrupt a long-running prepare/sync command on the + // same host. If cleanup is queued behind that command, the VDDK reader keeps the source + // snapshot open until the entire copy finishes and the cleanup request can time out + // before it ever reaches the agent. + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java new file mode 100644 index 000000000000..24d82c434afe --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtCutoverCommand.java @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtCutoverCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private int finalCycleNumber; + private boolean runVirtV2vFinalization; + private boolean allowNonInPlaceFinalization; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtCutoverCommand() { + } + + public VmwareCbtCutoverCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + int finalCycleNumber, boolean runVirtV2vFinalization) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.finalCycleNumber = finalCycleNumber; + this.runVirtV2vFinalization = runVirtV2vFinalization; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public int getFinalCycleNumber() { + return finalCycleNumber; + } + + public boolean getRunVirtV2vFinalization() { + return runVirtV2vFinalization; + } + + public boolean getAllowNonInPlaceFinalization() { + return allowNonInPlaceFinalization; + } + + public void setAllowNonInPlaceFinalization(boolean allowNonInPlaceFinalization) { + this.allowNonInPlaceFinalization = allowNonInPlaceFinalization; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java new file mode 100644 index 000000000000..2a0435639418 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtMigrationAnswer.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; + +public class VmwareCbtMigrationAnswer extends Answer { + + private String migrationUuid; + private int cycleNumber; + private long changedBytes; + private long dirtyRateBytesPerSecond; + private long durationSeconds; + private boolean readyForCutover; + private List diskResults; + + public VmwareCbtMigrationAnswer() { + super(); + } + + public VmwareCbtMigrationAnswer(Command command, boolean result, String details, String migrationUuid) { + super(command, result, details); + this.migrationUuid = migrationUuid; + } + + public VmwareCbtMigrationAnswer(Command command, boolean result, String details, String migrationUuid, int cycleNumber, + long changedBytes, long dirtyRateBytesPerSecond, long durationSeconds, + boolean readyForCutover, List diskResults) { + super(command, result, details); + this.migrationUuid = migrationUuid; + this.cycleNumber = cycleNumber; + this.changedBytes = changedBytes; + this.dirtyRateBytesPerSecond = dirtyRateBytesPerSecond; + this.durationSeconds = durationSeconds; + this.readyForCutover = readyForCutover; + this.diskResults = diskResults; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public long getChangedBytes() { + return changedBytes; + } + + public long getDirtyRateBytesPerSecond() { + return dirtyRateBytesPerSecond; + } + + public long getDurationSeconds() { + return durationSeconds; + } + + public boolean getReadyForCutover() { + return readyForCutover; + } + + public List getDiskResults() { + return diskResults; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java new file mode 100644 index 000000000000..0791d3b06a6f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtPrepareCommand.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtPrepareCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String baselineSnapshotMor; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtPrepareCommand() { + } + + public VmwareCbtPrepareCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + Storage.StoragePoolType destinationStoragePoolType, String destinationStoragePoolUuid, + VmwareCbtTargetStorageType targetStorageType, String baselineSnapshotMor) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.destinationStoragePoolType = destinationStoragePoolType; + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + this.targetStorageType = targetStorageType; + this.baselineSnapshotMor = baselineSnapshotMor; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public String getBaselineSnapshotMor() { + return baselineSnapshotMor; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java new file mode 100644 index 000000000000..73b532bfff71 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtRbdProbeCommand.java @@ -0,0 +1,63 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtRbdProbeCommand extends Command { + + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private String probeImageName; + private VmwareCbtTargetStorageType targetStorageType; + + public VmwareCbtRbdProbeCommand() { + } + + public VmwareCbtRbdProbeCommand(Storage.StoragePoolType destinationStoragePoolType, + String destinationStoragePoolUuid, String probeImageName) { + this.destinationStoragePoolType = destinationStoragePoolType; + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + this.probeImageName = probeImageName; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public String getProbeImageName() { + return probeImageName; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java b/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java new file mode 100644 index 000000000000..a58c0029c43f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/VmwareCbtSyncCommand.java @@ -0,0 +1,138 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api; + +import java.util.List; + +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.storage.Storage; + +public class VmwareCbtSyncCommand extends Command { + + private String migrationUuid; + private RemoteInstanceTO sourceInstance; + private List disks; + private List changedBlocks; + private int cycleNumber; + private String snapshotMor; + private boolean finalSync; + private Storage.StoragePoolType destinationStoragePoolType; + private String destinationStoragePoolUuid; + private VmwareCbtTargetStorageType targetStorageType; + private String vddkLibDir; + private String vddkTransports; + private String vddkThumbprint; + + public VmwareCbtSyncCommand() { + } + + public VmwareCbtSyncCommand(String migrationUuid, RemoteInstanceTO sourceInstance, List disks, + List changedBlocks, int cycleNumber, String snapshotMor, + boolean finalSync) { + this.migrationUuid = migrationUuid; + this.sourceInstance = sourceInstance; + this.disks = disks; + this.changedBlocks = changedBlocks; + this.cycleNumber = cycleNumber; + this.snapshotMor = snapshotMor; + this.finalSync = finalSync; + } + + public String getMigrationUuid() { + return migrationUuid; + } + + public RemoteInstanceTO getSourceInstance() { + return sourceInstance; + } + + public List getDisks() { + return disks; + } + + public List getChangedBlocks() { + return changedBlocks; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public boolean getFinalSync() { + return finalSync; + } + + public Storage.StoragePoolType getDestinationStoragePoolType() { + return destinationStoragePoolType; + } + + public void setDestinationStoragePoolType(Storage.StoragePoolType destinationStoragePoolType) { + this.destinationStoragePoolType = destinationStoragePoolType; + } + + public String getDestinationStoragePoolUuid() { + return destinationStoragePoolUuid; + } + + public void setDestinationStoragePoolUuid(String destinationStoragePoolUuid) { + this.destinationStoragePoolUuid = destinationStoragePoolUuid; + } + + public VmwareCbtTargetStorageType getTargetStorageType() { + return targetStorageType; + } + + public void setTargetStorageType(VmwareCbtTargetStorageType targetStorageType) { + this.targetStorageType = targetStorageType; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + @Override + public boolean executeInSequence() { + return true; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java new file mode 100644 index 000000000000..3707ea498744 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/to/VmwareCbtTargetStorageType.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.api.to; + +public enum VmwareCbtTargetStorageType { + QCOW2_FILE, + RBD_RAW, + /** + * Raw writes into a host-local block device provided by the destination + * primary storage (e.g. a Linstor/DRBD volume). The device is pre-created + * at source capacity through the storage adaptor before any data copy. + */ + RAW_BLOCK_DEVICE +} diff --git a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VRScripts.java b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VRScripts.java index 7bfbf786e9b4..95ac5d10c7bf 100644 --- a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VRScripts.java +++ b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VRScripts.java @@ -77,6 +77,7 @@ public class VRScripts { public static final String DIAGNOSTICS = "diagnostics.py"; public static final String RETRIEVE_DIAGNOSTICS = "get_diagnostics_files.py"; + public static final String INSTANCE_READINESS_CHECK = "instance_readiness_check.py"; public static final String VR_FILE_CLEANUP = "cleanup.sh"; public static final String VR_UPDATE_INTERFACE_CONFIG = "update_interface_config.sh"; diff --git a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VirtualRoutingResource.java b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VirtualRoutingResource.java index b9ac455130f3..6d7988c5f6cd 100644 --- a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VirtualRoutingResource.java +++ b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/VirtualRoutingResource.java @@ -50,6 +50,8 @@ import org.apache.cloudstack.diagnostics.DiagnosticsCommand; import org.apache.cloudstack.diagnostics.PrepareFilesAnswer; import org.apache.cloudstack.diagnostics.PrepareFilesCommand; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceReadinessCheckAnswer; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceReadinessCheckCommand; import org.apache.cloudstack.utils.security.KeyStoreUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.net.util.SubnetUtils; @@ -231,6 +233,8 @@ private Answer executeQueryCommand(NetworkElementCommand cmd) { return execute((GetRouterAlertsCommand)cmd); } else if (cmd instanceof DiagnosticsCommand) { return execute((DiagnosticsCommand) cmd); + } else if (cmd instanceof InstanceReadinessCheckCommand) { + return execute((InstanceReadinessCheckCommand) cmd); } else if (cmd instanceof PrepareFilesCommand) { return execute((PrepareFilesCommand) cmd); } else if (cmd instanceof DeleteFileInVrCommand) { @@ -486,6 +490,15 @@ private Answer execute(DiagnosticsCommand cmd) { return new DiagnosticsAnswer(cmd, result.isSuccess(), result.getDetails()); } + private Answer execute(InstanceReadinessCheckCommand cmd) { + _eachTimeout = Duration.standardSeconds(Math.max(cmd.getWait(), 1)); + String args = cmd.getPort() == null ? + String.format("%s %s", cmd.getCheckType(), cmd.getIpAddress()) : + String.format("%s %s %s", cmd.getCheckType(), cmd.getIpAddress(), cmd.getPort()); + final ExecutionResult result = _vrDeployer.executeInVR(cmd.getRouterAccessIp(), VRScripts.INSTANCE_READINESS_CHECK, args, _eachTimeout); + return new InstanceReadinessCheckAnswer(cmd, result.isSuccess(), result.getDetails()); + } + private Answer execute(PrepareFilesCommand cmd) { String fileList = String.join(" ", cmd.getFilesToRetrieveList()); _eachTimeout = Duration.standardSeconds(cmd.getTimeout()); diff --git a/core/src/main/java/com/cloud/agent/transport/compat/AbstractTOAdaptor.java b/core/src/main/java/com/cloud/agent/transport/compat/AbstractTOAdaptor.java new file mode 100644 index 000000000000..ed5630183ead --- /dev/null +++ b/core/src/main/java/com/cloud/agent/transport/compat/AbstractTOAdaptor.java @@ -0,0 +1,86 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.transport.compat; + +import com.cloud.utils.StringUtils; +import com.cloud.utils.exception.CloudRuntimeException; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * JSON serializer adapter for transport classes (com.cloud.agent.api.to.*) that ensures backward compatibility + * with older Agent versions due to rename of the fields + * (see https://github.com/apache/cloudstack/pull/10514) + * + * This class does not build its own Gson instance: doing so would silently drop whichever exclusion + * strategy (e.g. log redaction) and sibling compat adaptors (for nested TOs) the enclosing Gson was + * configured with. Instead, whoever registers an instance of this class into a GsonBuilder is + * responsible for also calling {@link #initGson(Gson)} with a Gson that (a) carries that same + * exclusion strategy and (b) has adapters registered for any nested TO types that also need field + * renaming, but not for this adaptor's own type (to avoid infinite recursion). See + * {@link com.cloud.serializer.GsonHelper#setDefaultGsonConfig(com.google.gson.GsonBuilder)}. + */ +public class AbstractTOAdaptor implements JsonSerializer { + private Gson gson; + private Map fieldMappings; + + protected AbstractTOAdaptor(String... fields) { + this.fieldMappings = new LinkedHashMap<>(); + for (int i = 0; i + 1 < fields.length; i += 2) { + String sourceField = fields[i]; + String destinationField = fields[i + 1]; + // skip empty fields + if (StringUtils.isBlank(sourceField) || StringUtils.isBlank(destinationField)) { + continue; + } + this.fieldMappings.put(sourceField, destinationField); + } + if (this.fieldMappings.isEmpty()) { + throw new CloudRuntimeException("Field mappings must not be empty"); + } + } + + public void initGson(Gson gson) { + this.gson = gson; + } + + @Override + public JsonElement serialize(T src, Type typeOfSrc, JsonSerializationContext context) { + if (src == null) { + return null; + } + JsonElement tree = gson.toJsonTree(src); + if (tree.isJsonObject()) { + JsonObject obj = tree.getAsJsonObject(); + for (Map.Entry field : fieldMappings.entrySet()) { + String sourceField = field.getKey(); + String destinationField = field.getValue(); + if (obj.has(sourceField)) { + obj.add(destinationField, obj.get(sourceField)); + } + } + } + return tree; + } +} diff --git a/core/src/main/java/com/cloud/agent/transport/compat/DiskTOAdaptor.java b/core/src/main/java/com/cloud/agent/transport/compat/DiskTOAdaptor.java new file mode 100644 index 000000000000..6b45529de05f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/transport/compat/DiskTOAdaptor.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.transport.compat; + +import com.cloud.agent.api.to.DiskTO; + +/** + * See {@link AbstractTOAdaptor}. + */ +public class DiskTOAdaptor extends AbstractTOAdaptor { + public DiskTOAdaptor() { + super("details", "_details"); + } +} diff --git a/core/src/main/java/com/cloud/agent/transport/compat/MigrateCommandAdaptor.java b/core/src/main/java/com/cloud/agent/transport/compat/MigrateCommandAdaptor.java new file mode 100644 index 000000000000..ddac8afe5975 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/transport/compat/MigrateCommandAdaptor.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.transport.compat; + +import com.cloud.agent.api.MigrateCommand; + +/** + * See {@link AbstractTOAdaptor}. + */ +public class MigrateCommandAdaptor extends AbstractTOAdaptor { + public MigrateCommandAdaptor() { + super("destinationIp", "destIp", "windows", "isWindows", "virtualMachine", "vmTO"); + } +} diff --git a/core/src/main/java/com/cloud/agent/transport/compat/NetworkTOAdaptor.java b/core/src/main/java/com/cloud/agent/transport/compat/NetworkTOAdaptor.java new file mode 100644 index 000000000000..01b0cdb4cdf9 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/transport/compat/NetworkTOAdaptor.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.transport.compat; + +import com.cloud.agent.api.to.NetworkTO; + +/** + * See {@link AbstractTOAdaptor}. + */ +public class NetworkTOAdaptor extends AbstractTOAdaptor { + public NetworkTOAdaptor() { + super("securityGroupEnabled", "isSecurityGroupEnabled"); + } +} diff --git a/core/src/main/java/com/cloud/agent/transport/compat/VirtualMachineTOAdaptor.java b/core/src/main/java/com/cloud/agent/transport/compat/VirtualMachineTOAdaptor.java new file mode 100644 index 000000000000..24a8c02a59c5 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/transport/compat/VirtualMachineTOAdaptor.java @@ -0,0 +1,29 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.transport.compat; + +import com.cloud.agent.api.to.VirtualMachineTO; + +/** + * See {@link AbstractTOAdaptor}. + */ +public class VirtualMachineTOAdaptor extends AbstractTOAdaptor { + + public VirtualMachineTOAdaptor() { + super("details", "params"); + } +} diff --git a/core/src/main/java/com/cloud/serializer/GsonHelper.java b/core/src/main/java/com/cloud/serializer/GsonHelper.java index 7de98c08b7e0..7fcfb28f983c 100644 --- a/core/src/main/java/com/cloud/serializer/GsonHelper.java +++ b/core/src/main/java/com/cloud/serializer/GsonHelper.java @@ -21,6 +21,14 @@ import java.util.List; +import com.cloud.agent.api.MigrateCommand; +import com.cloud.agent.api.to.DiskTO; +import com.cloud.agent.api.to.NetworkTO; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.agent.transport.compat.DiskTOAdaptor; +import com.cloud.agent.transport.compat.MigrateCommandAdaptor; +import com.cloud.agent.transport.compat.NetworkTOAdaptor; +import com.cloud.agent.transport.compat.VirtualMachineTOAdaptor; import com.cloud.hypervisor.Hypervisor; import org.apache.cloudstack.transport.HypervisorTypeAdaptor; import org.apache.logging.log4j.Logger; @@ -78,6 +86,42 @@ public static Gson setDefaultGsonConfig(GsonBuilder builder) { }.getType(), new NwGroupsCommandTypeAdaptor()); builder.registerTypeAdapter(Storage.StoragePoolType.class, new StoragePoolTypeAdaptor()); builder.registerTypeAdapter(Hypervisor.HypervisorType.class, new HypervisorTypeAdaptor()); + + // added for compatibility purposes, remove after all Agents migrate to the new version + // + // Each compat adaptor below needs a "base" Gson to run its own reflective (pre-rename) + // serialization through, so that nested TOs are renamed too and the exclusion strategy set + // on `builder` (e.g. log redaction) is honoured consistently at every nesting level. That base + // Gson is built incrementally off the same builder, snapshotted (via builder.create()) just + // before each adaptor's own type is registered on it, so it carries every sibling adaptor it + // can nest without ever routing back into itself and recursing forever. + DiskTOAdaptor diskAdaptor = new DiskTOAdaptor(); + NetworkTOAdaptor netAdaptor = new NetworkTOAdaptor(); + VirtualMachineTOAdaptor vmAdaptor = new VirtualMachineTOAdaptor(); + MigrateCommandAdaptor migrateAdaptor = new MigrateCommandAdaptor(); + + // DiskTO and NetworkTO don't nest any other compat TO, so the plain config built so far is + // already the correct base Gson for them. + Gson leafDelegateGson = builder.create(); + diskAdaptor.initGson(leafDelegateGson); + netAdaptor.initGson(leafDelegateGson); + + // VirtualMachineTO nests DiskTO[] and NicTO[] (NicTO extends NetworkTO), so its base Gson needs + // Disk/Network adapters too. registerTypeHierarchyAdapter is used for NetworkTO so that the + // NicTO[]-declared "nics" field is matched via its supertype. + builder.registerTypeAdapter(DiskTO.class, diskAdaptor); + builder.registerTypeHierarchyAdapter(NetworkTO.class, netAdaptor); + Gson vmDelegateGson = builder.create(); + vmAdaptor.initGson(vmDelegateGson); + + // MigrateCommand nests a VirtualMachineTO, so its base Gson needs the VirtualMachineTO adapter + // (which already renames the nested disks/nics above). + builder.registerTypeAdapter(VirtualMachineTO.class, vmAdaptor); + Gson migrateDelegateGson = builder.create(); + migrateAdaptor.initGson(migrateDelegateGson); + + builder.registerTypeAdapter(MigrateCommand.class, migrateAdaptor); + Gson gson = builder.create(); dsAdaptor.initGson(gson); dtAdaptor.initGson(gson); diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java index 34f8d7b8bcdd..7c2d3fc76fc1 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java @@ -33,6 +33,7 @@ public class TakeBackupCommand extends Command { private List volumePools; private List volumePaths; private Boolean quiesce; + private Integer quiesceTimeout; @LogLevel(LogLevel.Log4jLevel.Off) private String mountOptions; @@ -117,6 +118,14 @@ public void setQuiesce(Boolean quiesce) { this.quiesce = quiesce; } + public Integer getQuiesceTimeout() { + return quiesceTimeout; + } + + public void setQuiesceTimeout(Integer quiesceTimeout) { + this.quiesceTimeout = quiesceTimeout; + } + public String getMode() { return mode; } diff --git a/core/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckAnswer.java b/core/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckAnswer.java new file mode 100644 index 000000000000..733294f2b9ce --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckAnswer.java @@ -0,0 +1,57 @@ +// 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.bootgroup.readiness; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.utils.exception.CloudRuntimeException; + +public class InstanceReadinessCheckAnswer extends Answer { + + public static final String STDOUT = "stdout"; + public static final String STDERR = "stderr"; + public static final String EXITCODE = "exitcode"; + + public InstanceReadinessCheckAnswer(InstanceReadinessCheckCommand cmd, boolean result, String details) { + super(cmd, result, details); + } + + public Map getExecutionDetails() { + final Map executionDetails = new HashMap<>(); + final String details = getDetails(); + if (StringUtils.isNotEmpty(details) && details.contains("&&")) { + final String[] parts = details.split("&&"); + if (parts.length >= 3) { + executionDetails.put(STDOUT, parts[0].trim()); + executionDetails.put(STDERR, parts[1].trim()); + executionDetails.put(EXITCODE, parts[2].trim()); + } else { + throw new CloudRuntimeException("Unsupported instance boot group readiness check output format"); + } + } else { + executionDetails.put(STDOUT, ""); + executionDetails.put(STDERR, details); + executionDetails.put(EXITCODE, "-1"); + } + return executionDetails; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckCommand.java b/core/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckCommand.java new file mode 100644 index 000000000000..8ae58304ae82 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckCommand.java @@ -0,0 +1,73 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.vm.bootgroup.readiness; + +import com.cloud.agent.api.routing.NetworkElementCommand; + +/** + * Dispatched to a VR to run a readiness check (ping or TCP port connect) against a user VM's IP, + * on behalf of the instance boot group readiness feature. Deliberately separate from + * {@code org.apache.cloudstack.diagnostics.DiagnosticsCommand}, which is a general-purpose admin + * tool scoped to system VMs (SSVM/CPVM/VR) — this command runs its own dedicated VR script instead + * of sharing that one. + */ +public class InstanceReadinessCheckCommand extends NetworkElementCommand { + + public static final String CHECK_TYPE_PING = "ping"; + public static final String CHECK_TYPE_PORT_CHECK = "portcheck"; + + private final String checkType; + private final String ipAddress; + private Integer port; + private final boolean executeInSequence; + + public InstanceReadinessCheckCommand(String ipAddress, boolean executeInSequence) { + this.checkType = CHECK_TYPE_PING; + this.ipAddress = ipAddress; + this.executeInSequence = executeInSequence; + } + + public InstanceReadinessCheckCommand(String ipAddress, Integer port, boolean executeInSequence) { + this.checkType = CHECK_TYPE_PORT_CHECK; + this.ipAddress = ipAddress; + this.port = port; + this.executeInSequence = executeInSequence; + } + + public String getCheckType() { + return checkType; + } + + public String getIpAddress() { + return ipAddress; + } + + public Integer getPort() { + return port; + } + + @Override + public boolean isQuery() { + return true; + } + + @Override + public boolean executeInSequence() { + return executeInSequence; + } +} diff --git a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml index cf43b8527a97..6e53ccaf425b 100644 --- a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml +++ b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml @@ -375,4 +375,19 @@ + + + + + + + + diff --git a/core/src/test/java/com/cloud/agent/resource/virtualnetwork/InstanceReadinessCheckExecutionTest.java b/core/src/test/java/com/cloud/agent/resource/virtualnetwork/InstanceReadinessCheckExecutionTest.java new file mode 100644 index 000000000000..226bf132d04b --- /dev/null +++ b/core/src/test/java/com/cloud/agent/resource/virtualnetwork/InstanceReadinessCheckExecutionTest.java @@ -0,0 +1,122 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.agent.resource.virtualnetwork; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; + +import javax.naming.ConfigurationException; + +import org.joda.time.Duration; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.routing.NetworkElementCommand; +import com.cloud.utils.ExecutionResult; + +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceReadinessCheckAnswer; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceReadinessCheckCommand; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceReadinessCheckExecutionTest { + + private static final String ROUTER_IP = "169.254.3.4"; + + VirtualRoutingResource resource; + VirtualRouterDeployer deployer; + + @Before + public void setUp() throws ConfigurationException { + deployer = mock(VirtualRouterDeployer.class); + when(deployer.prepareCommand(any(NetworkElementCommand.class))).thenAnswer(invocation -> { + NetworkElementCommand cmd = invocation.getArgument(0); + cmd.setRouterAccessIp(ROUTER_IP); + return new ExecutionResult(true, null); + }); + when(deployer.cleanupCommand(any(NetworkElementCommand.class))).thenReturn(new ExecutionResult(true, null)); + when(deployer.executeInVR(anyString(), anyString(), anyString(), any(Duration.class))).thenReturn(new ExecutionResult(true, "out&&err&&0")); + + resource = new VirtualRoutingResource(deployer); + resource.configure("VRResource", new HashMap<>()); + } + + @Test + public void zeroWaitIsFlooredToOneSecondTimeout() { + InstanceReadinessCheckCommand cmd = new InstanceReadinessCheckCommand("10.1.1.5", false); + cmd.setWait(0); + + resource.executeRequest(cmd); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Duration.class); + org.mockito.Mockito.verify(deployer).executeInVR(eq(ROUTER_IP), anyString(), anyString(), captor.capture()); + assertEquals(1L, captor.getValue().getStandardSeconds()); + } + + @Test + public void positiveWaitIsUsedAsTimeoutSeconds() { + InstanceReadinessCheckCommand cmd = new InstanceReadinessCheckCommand("10.1.1.5", false); + cmd.setWait(5); + + resource.executeRequest(cmd); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Duration.class); + org.mockito.Mockito.verify(deployer).executeInVR(eq(ROUTER_IP), anyString(), anyString(), captor.capture()); + assertEquals(5L, captor.getValue().getStandardSeconds()); + } + + @Test + public void pingCommandOmitsPortFromArgs() { + InstanceReadinessCheckCommand cmd = new InstanceReadinessCheckCommand("10.1.1.5", false); + + resource.executeRequest(cmd); + + ArgumentCaptor argsCaptor = ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(deployer).executeInVR(eq(ROUTER_IP), anyString(), argsCaptor.capture(), any(Duration.class)); + assertEquals("ping 10.1.1.5", argsCaptor.getValue()); + } + + @Test + public void portCheckCommandIncludesPortInArgs() { + InstanceReadinessCheckCommand cmd = new InstanceReadinessCheckCommand("10.1.1.5", 8080, false); + + resource.executeRequest(cmd); + + ArgumentCaptor argsCaptor = ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(deployer).executeInVR(eq(ROUTER_IP), anyString(), argsCaptor.capture(), any(Duration.class)); + assertEquals("portcheck 10.1.1.5 8080", argsCaptor.getValue()); + } + + @Test + public void resultIsWrappedInInstanceReadinessCheckAnswer() { + InstanceReadinessCheckCommand cmd = new InstanceReadinessCheckCommand("10.1.1.5", false); + + Answer answer = resource.executeRequest(cmd); + + org.junit.Assert.assertTrue(answer instanceof InstanceReadinessCheckAnswer); + assertEquals("0", ((InstanceReadinessCheckAnswer) answer).getExecutionDetails().get(InstanceReadinessCheckAnswer.EXITCODE)); + } +} diff --git a/core/src/test/java/com/cloud/agent/transport/RequestTest.java b/core/src/test/java/com/cloud/agent/transport/RequestTest.java index 0fe42c7cede8..d68107e64917 100644 --- a/core/src/test/java/com/cloud/agent/transport/RequestTest.java +++ b/core/src/test/java/com/cloud/agent/transport/RequestTest.java @@ -19,14 +19,21 @@ package com.cloud.agent.transport; +import java.lang.reflect.Field; import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; import junit.framework.TestCase; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.config.Configurator; import org.junit.Assert; import org.mockito.Mockito; +import com.google.gson.Gson; + import org.apache.cloudstack.storage.command.DownloadCommand; import org.apache.cloudstack.storage.to.TemplateObjectTO; @@ -35,19 +42,28 @@ import com.cloud.agent.api.Command; import com.cloud.agent.api.GetHostStatsCommand; import com.cloud.agent.api.GetVolumeStatsCommand; +import com.cloud.agent.api.MigrateCommand; import com.cloud.agent.api.SecStorageFirewallCfgCommand; +import com.cloud.agent.api.StartCommand; import com.cloud.agent.api.UpdateHostPasswordCommand; import com.cloud.agent.api.storage.DownloadAnswer; import com.cloud.agent.api.storage.ListTemplateCommand; +import com.cloud.agent.api.to.DiskTO; import com.cloud.agent.api.to.NfsTO; +import com.cloud.agent.api.to.NicTO; +import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.agent.transport.Request.Version; import com.cloud.exception.UnsupportedVersionException; +import com.cloud.host.Host; import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.serializer.GsonHelper; import com.cloud.storage.DataStoreRole; import com.cloud.storage.Storage.ImageFormat; import com.cloud.storage.Storage.TemplateType; import com.cloud.storage.VMTemplateStorageResourceAssoc.Status; import com.cloud.template.VirtualMachineTemplate; +import com.cloud.template.VirtualMachineTemplate.BootloaderType; +import com.cloud.vm.VirtualMachine; /** * @@ -57,7 +73,28 @@ */ public class RequestTest extends TestCase { - protected Logger logger = LogManager.getLogger(getClass()); + private static final Logger logger = LogManager.getLogger(RequestTest.class); + + /** + * Changes GsonHelper's logging Gson level AND clears its cached TypeAdapters. + * + * Gson bakes each Command class's shouldSkipClass/shouldSkipField decision into a TypeAdapter + * the first time that class is serialized, then caches it (Gson#typeTokenCache) for the life + * of the Gson instance. Since GsonHelper.getGsonLogger() is a static singleton shared for the + * whole test JVM, just calling Configurator.setLevel() again has no effect on classes already + * cached - they'd keep reflecting whichever level was active the first time they were logged. + * Clearing the cache after every level change forces a fresh (correct) evaluation. + */ + private static void setGsonLoggerLevel(String loggerName, Level level) { + Configurator.setLevel(loggerName, level); + try { + Field field = Gson.class.getDeclaredField("typeTokenCache"); + field.setAccessible(true); + ((Map)field.get(GsonHelper.getGsonLogger())).clear(); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } public void testSerDeser() { logger.info("Testing serializing and deserializing works as expected"); @@ -73,6 +110,32 @@ public void testSerDeser() { Request sreq = new Request(2, 3, new Command[] {cmd1, cmd2, cmd3}, true, true); sreq.setSequence(892403717); + Logger gsonLogger = LogManager.getLogger(GsonHelper.class); + Level level = gsonLogger.getLevel(); + + setGsonLoggerLevel(gsonLogger.getName(), Level.DEBUG); + String log = sreq.log("Debug", true, Level.DEBUG); + assert (log.contains(UpdateHostPasswordCommand.class.getSimpleName())); + assert (log.contains(SecStorageFirewallCfgCommand.class.getSimpleName())); + assert (!log.contains(GetHostStatsCommand.class.getSimpleName())); + assert (!log.contains("username")); + assert (!log.contains("password")); + + setGsonLoggerLevel(gsonLogger.getName(), Level.TRACE); + log = sreq.log("Trace", true, Level.TRACE); + System.out.println(log); + assert (log.contains(UpdateHostPasswordCommand.class.getSimpleName())); + assert (log.contains(SecStorageFirewallCfgCommand.class.getSimpleName())); + assert (log.contains(GetHostStatsCommand.class.getSimpleName())); + assert (!log.contains("username")); + assert (!log.contains("password")); + + setGsonLoggerLevel(gsonLogger.getName(), Level.INFO); + log = sreq.log("Info", true, Level.INFO); + assert (log == null); + + setGsonLoggerLevel(GsonHelper.class.getName(), level); + byte[] bytes = sreq.getBytes(); assert Request.getSequence(bytes) == 892403717; @@ -186,6 +249,79 @@ public void testCompress() { } } + public void testLogging() { + logger.info("Testing Logging"); + GetHostStatsCommand cmd3 = new GetHostStatsCommand("hostguid", "hostname", 101); + Request sreq = new Request(2, 3, new Command[] {cmd3}, true, true); + sreq.setSequence(1); + Logger gsonLogger = LogManager.getLogger(GsonHelper.class); + Level level = gsonLogger.getLevel(); + + setGsonLoggerLevel(GsonHelper.class.getName(), Level.DEBUG); + String log = sreq.log("Debug", true, Level.DEBUG); + assert (log == null); + + log = sreq.log("Debug", false, Level.DEBUG); + assert (log != null); + + setGsonLoggerLevel(GsonHelper.class.getName(), Level.TRACE); + log = sreq.log("Trace", true, Level.TRACE); + assert (log != null); + + assert (log.contains(GetHostStatsCommand.class.getSimpleName())); + logger.debug(log); + + setGsonLoggerLevel(GsonHelper.class.getName(), level); + } + + public void testCompatFieldRenamingNestedTOs() { + logger.info("Testing that renamed fields are restored on nested TOs too, for backward compatibility with older Agents"); + + DiskTO diskTO = new DiskTO(); + diskTO.setDetails(new HashMap()); + + NicTO nicTO = new NicTO(); + nicTO.setSecurityGroupEnabled(true); + + VirtualMachineTO vmTO = new VirtualMachineTO(1, "i-2-3-VM", VirtualMachine.Type.User, 1, 512, 512L * 1024 * 1024, 512L * 1024 * 1024, + BootloaderType.HVM, "Other PV (64-bit)", true, true, "vncpassword123"); + vmTO.setDetails(new HashMap()); + vmTO.setDisks(new DiskTO[] {diskTO}); + vmTO.setNics(new NicTO[] {nicTO}); + + Host host = Mockito.mock(Host.class); + Mockito.when(host.getPrivateIpAddress()).thenReturn("10.1.1.1"); + StartCommand startCmd = new StartCommand(vmTO, host, false); + + Request startReq = new Request(1, 1, startCmd, true); + String startWireJson = GsonHelper.getGson().toJson(new Command[] {startCmd}); + assert startWireJson.contains("\"params\"") : "VirtualMachineTO.details should be serialized under its old name 'params'"; + assert startWireJson.contains("\"_details\"") : "nested DiskTO.details should be serialized under its old name '_details'"; + assert startWireJson.contains("\"isSecurityGroupEnabled\"") : "nested NicTO.securityGroupEnabled should be serialized under its old name 'isSecurityGroupEnabled'"; + assert startWireJson.contains("vncpassword123") : "wire serialization should still contain the real vncPassword value"; + + Logger gsonLogger = LogManager.getLogger(GsonHelper.class); + Level gsonLoggerLevel = gsonLogger.getLevel(); + setGsonLoggerLevel(GsonHelper.class.getName(), Level.TRACE); + String startLogJson; + try { + startLogJson = startReq.log("Trace", true, Level.TRACE); + } finally { + setGsonLoggerLevel(GsonHelper.class.getName(), gsonLoggerLevel); + } + assert startLogJson.contains("\"isSecurityGroupEnabled\"") : "renamed fields should still show up in the logging serialization"; + assert !startLogJson.contains("vncpassword123") : "logging serialization should never contain the plaintext vncPassword value"; + + MigrateCommand migrateCmd = new MigrateCommand("i-2-3-VM", "10.1.1.2", true, vmTO, false); + String migrateWireJson = GsonHelper.getGson().toJson(new Command[] {migrateCmd}); + assert migrateWireJson.contains("\"destIp\"") : "MigrateCommand.destinationIp should be serialized under its old name 'destIp'"; + assert migrateWireJson.contains("\"isWindows\"") : "MigrateCommand.windows should be serialized under its old name 'isWindows'"; + assert migrateWireJson.contains("\"vmTO\"") : "MigrateCommand.virtualMachine should be serialized under its old name 'vmTO'"; + assert migrateWireJson.contains("\"params\"") : "VirtualMachineTO nested in MigrateCommand should still be renamed"; + assert migrateWireJson.contains("\"_details\"") : "DiskTO nested inside the VirtualMachineTO nested in MigrateCommand should still be renamed"; + assert migrateWireJson.contains("\"isSecurityGroupEnabled\"") : "NicTO nested inside the VirtualMachineTO nested in MigrateCommand should still be renamed"; + } + protected void compareRequest(Request req1, Request req2) { assert req1.getSequence() == req2.getSequence(); assert req1.getAgentId() == req2.getAgentId(); diff --git a/core/src/test/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckAnswerTest.java b/core/src/test/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckAnswerTest.java new file mode 100644 index 000000000000..29aea0dcf172 --- /dev/null +++ b/core/src/test/java/org/apache/cloudstack/vm/bootgroup/readiness/InstanceReadinessCheckAnswerTest.java @@ -0,0 +1,113 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.vm.bootgroup.readiness; + +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +import com.cloud.utils.exception.CloudRuntimeException; + +public class InstanceReadinessCheckAnswerTest { + + private InstanceReadinessCheckCommand cmd() { + return new InstanceReadinessCheckCommand("10.1.1.5", false); + } + + @Test + public void wellFormedSuccessfulDetailsAreParsed() { + InstanceReadinessCheckAnswer answer = new InstanceReadinessCheckAnswer(cmd(), true, " out \n && err && 0 "); + + Map details = answer.getExecutionDetails(); + + Assert.assertEquals("out", details.get(InstanceReadinessCheckAnswer.STDOUT)); + Assert.assertEquals("err", details.get(InstanceReadinessCheckAnswer.STDERR)); + Assert.assertEquals("0", details.get(InstanceReadinessCheckAnswer.EXITCODE)); + } + + @Test + public void extraDelimitedSegmentsAreIgnoredPastTheThird() { + InstanceReadinessCheckAnswer answer = new InstanceReadinessCheckAnswer(cmd(), true, "out&&err&&1&&extra"); + + Map details = answer.getExecutionDetails(); + + Assert.assertEquals("out", details.get(InstanceReadinessCheckAnswer.STDOUT)); + Assert.assertEquals("err", details.get(InstanceReadinessCheckAnswer.STDERR)); + Assert.assertEquals("1", details.get(InstanceReadinessCheckAnswer.EXITCODE)); + } + + @Test(expected = CloudRuntimeException.class) + public void malformedSuccessfulDetailsThrow() { + new InstanceReadinessCheckAnswer(cmd(), true, "out&&err").getExecutionDetails(); + } + + @Test(expected = CloudRuntimeException.class) + public void malformedFailedDetailsThrow() { + new InstanceReadinessCheckAnswer(cmd(), false, "out&&err").getExecutionDetails(); + } + + @Test + public void failedResultIsStillParsedWhenWellFormed() { + // executeInVR (and the VR script) typically reports result=false whenever the underlying + // check (ping unreachable, port closed) exits non-zero, even though it still printed a + // well-formed "stdout&&stderr&&exitcode" triple. The non-zero exit code must not be + // discarded in favor of a generic -1/raw-blob fallback. + InstanceReadinessCheckAnswer answer = new InstanceReadinessCheckAnswer(cmd(), false, "out&&unreachable&&1"); + + Map details = answer.getExecutionDetails(); + + Assert.assertEquals("out", details.get(InstanceReadinessCheckAnswer.STDOUT)); + Assert.assertEquals("unreachable", details.get(InstanceReadinessCheckAnswer.STDERR)); + Assert.assertEquals("1", details.get(InstanceReadinessCheckAnswer.EXITCODE)); + } + + @Test + public void failedResultWithoutDelimiterFallsBackToDefaults() { + // A dispatch-level failure (e.g. an exception message) never contains the delimiter and + // must still fall back to the raw-message/-1 defaults rather than throwing. + InstanceReadinessCheckAnswer answer = new InstanceReadinessCheckAnswer(cmd(), false, "agent down"); + + Map details = answer.getExecutionDetails(); + + Assert.assertEquals("", details.get(InstanceReadinessCheckAnswer.STDOUT)); + Assert.assertEquals("agent down", details.get(InstanceReadinessCheckAnswer.STDERR)); + Assert.assertEquals("-1", details.get(InstanceReadinessCheckAnswer.EXITCODE)); + } + + @Test + public void blankDetailsWithSuccessfulResultFallsBackToDefaults() { + InstanceReadinessCheckAnswer answer = new InstanceReadinessCheckAnswer(cmd(), true, ""); + + Map details = answer.getExecutionDetails(); + + Assert.assertEquals("", details.get(InstanceReadinessCheckAnswer.STDOUT)); + Assert.assertEquals("", details.get(InstanceReadinessCheckAnswer.STDERR)); + Assert.assertEquals("-1", details.get(InstanceReadinessCheckAnswer.EXITCODE)); + } + + @Test + public void nullDetailsWithSuccessfulResultFallsBackToDefaults() { + InstanceReadinessCheckAnswer answer = new InstanceReadinessCheckAnswer(cmd(), true, null); + + Map details = answer.getExecutionDetails(); + + Assert.assertEquals("", details.get(InstanceReadinessCheckAnswer.STDOUT)); + Assert.assertNull(details.get(InstanceReadinessCheckAnswer.STDERR)); + Assert.assertEquals("-1", details.get(InstanceReadinessCheckAnswer.EXITCODE)); + } +} diff --git a/debian/changelog b/debian/changelog index 76ac865d9efe..8657d3047701 100644 --- a/debian/changelog +++ b/debian/changelog @@ -4,12 +4,6 @@ cloudstack (24.0.0-SNAPSHOT) unstable; urgency=low -- the Apache CloudStack project Wed, 02 Sep 2026 17:26:10 +0200 -cloudstack (4.24.0.0-SNAPSHOT) unstable; urgency=low - - * Update the version to 4.24.0.0-SNAPSHOT - - -- the Apache CloudStack project Tue, 01 Sep 2026 19:10:00 +0200 - cloudstack (4.23.0.0-SNAPSHOT) unstable; urgency=low * Update the version to 4.23.0.0-SNAPSHOT diff --git a/docs/vmware-cbt-migration.md b/docs/vmware-cbt-migration.md new file mode 100644 index 000000000000..cc33fa2a99df --- /dev/null +++ b/docs/vmware-cbt-migration.md @@ -0,0 +1,457 @@ + + +# VMware CBT migration to KVM + +This document describes the implemented VMware Changed Block Tracking (CBT) +migration flow for Apache CloudStack and the host-level prerequisites required +on KVM migration hosts. + +The goal of this flow is to reduce the downtime and bandwidth needed when +migrating VMware guests to KVM. Instead of repeatedly copying the full VMware +disk, CloudStack performs one initial full synchronization and then uses VMware +CBT to copy only changed disk ranges until the VM is quiet enough for final +cutover. + +No additional CloudStack software is installed on the VMware ESXi hosts or +vCenter for this flow. The extra migration tooling is installed on the selected +CloudStack KVM host that performs the VDDK read, virt-v2v conversion, and CBT +delta application work. + +## Implementation status + +The implementation contains the CloudStack management model, API +surface, state tracking, cutover policy, VMware changed-block query +orchestration, KVM agent initial sync, KVM agent delta sync, cutover +finalization, import retry handling, delete/cancel cleanup, and UI status +tracking. + +## High-level flow + +1. Start a VMware CBT migration session. + CloudStack records the destination zone, KVM cluster, optional conversion + host, optional storage pool, source vCenter/ESXi details, and discovered + VMware disks. + +2. Run the initial full sync. + The KVM agent exposes each VMware snapshot disk through `nbdkit-vddk` and + copies it to a source-equivalent target on KVM primary storage. It uses + `qemu-img convert` for the general path and can use `nbdcopy` as an optional + accelerator for supported targets. `virt-v2v` does not run in this phase. + +3. Register target disks. + The management server records the target disk path, format, initial VMware + CBT change ID, and snapshot reference for each migrated disk. + +4. Run CBT delta synchronization cycles. + For each cycle CloudStack creates a VMware snapshot, calls + `QueryChangedDiskAreas()` for the source disks, dispatches changed byte + ranges to the selected KVM host, records copied bytes and dirty rate, and + removes the VMware snapshot. + +5. Decide when to cut over. + The decision is controlled by global settings: + + * `vmware.cbt.migration.min.cycles` + * `vmware.cbt.migration.max.cycles` + * `vmware.cbt.migration.quiet.cycles` + * `vmware.cbt.migration.quiet.bytes` + * `vmware.cbt.migration.quiet.dirty.rate` + + Long-running KVM agent commands are controlled separately by: + + * `vmware.cbt.migration.agent.command.timeout` + +6. Final cutover. + After the operator powers down the VMware VM, CloudStack performs the final + delta sync, runs `virt-v2v` locally against the current KVM-side replica, + and imports/registers the VM on KVM. + +## Long-running operation timeout + +CBT initial full sync, regular delta sync, final delta sync, and cutover +finalization run as KVM agent commands. The command wait value and the +agent-side child process timeout are both controlled by: + +```text +vmware.cbt.migration.agent.command.timeout=86400 +``` + +The value is in seconds. The default is 24 hours. It is intentionally separate +from `convert.vmware.instance.to.kvm.timeout`, which applies to the older +OVF/VDDK `ConvertInstanceCommand` import path, and from +`remote.kvm.instance.disks.copy.timeout`, which applies to remote KVM unmanaged +disk copy imports. + +For very large source disks, set +`vmware.cbt.migration.agent.command.timeout` above the worst-case expected +duration. A 1 TiB initial sync can easily require multiple hours depending on +VDDK transport, ESXi storage, destination storage, and sparse extent behavior. +The setting is finite because the shared CloudStack script runner treats zero +as its default one-hour timeout, not as "no timeout". + +## KVM migration host selection + +CBT migration runs on a KVM host in the destination cluster. If the API call +does not specify a conversion host, CloudStack selects an enabled KVM routing +host in the destination cluster that reports: + +```text +host.vddk.blockcopy.support=true +``` + +The KVM agent reports this capability during host startup. It is true only when +the host satisfies the required VDDK, `virt-v2v`, `qemu-img`, `qemu-nbd`, and +`qemu-io` checks. + +Related host detail keys: + +```text +host.vddk.support +vddk.lib.dir +host.vddk.version +host.vddk.blockcopy.support +host.vddk.blockcopy.inplace.finalization.support +host.vddk.blockcopy.rbd.support +host.qemu.img.version +host.qemu.nbd.version +host.qemu.io.version +host.virtv2v.version +host.virtv2v.inplace.version +``` + +Restart `cloudstack-agent` after installing packages or changing +`agent.properties` so these details are refreshed on the management server. + +## Required host capabilities + +Each KVM host that can be selected for VMware CBT migration needs: + +* A normal CloudStack KVM agent installation. +* `virt-v2v` for final cutover conversion. +* `nbdkit` and an `nbdkit` VDDK plugin. +* VMware VDDK libraries, with a directory containing + `lib64/libvixDiskLib.so`. +* `qemu-img` for target image inspection, creation, and manipulation. +* `qemu-nbd` for block device exposure during incremental copy workflows. +* `qemu-io` for applying and verifying changed block ranges. +* Optional `nbdcopy` from libnbd to accelerate supported initial full-copy + paths. CloudStack falls back to `qemu-img` when it is unavailable. +* Network access from the KVM host to vCenter and ESXi hosts. +* Access to the destination KVM primary storage pool. +* `virtio-win` drivers when migrating Windows guests. + +Destination-specific requirements: + +* Ceph/RBD destination pools additionally require qemu built with the RBD + block driver on the conversion host, plus in-place `virt-v2v` support + (the `virt-v2v-in-place` binary or the `virt-v2v --in-place` option). +* Linstor destination pools additionally require in-place `virt-v2v` support, + and the conversion host must be a LINSTOR satellite connected to the + destination pool (the CloudStack primary storage must be attached to the + host, and the host's `hostname` must match its LINSTOR node name). Target + volumes are pre-created through the Linstor storage adaptor and written as + raw data to the local DRBD device path, so no extra qemu block driver is + needed. DRBD permits only one writer: make sure no other node holds the + target resource Primary while a migration writes to it. + +You do not have to install these packages on every KVM host in the zone unless +you want every KVM host to be eligible for VMware CBT migration. A common +deployment model is to prepare a smaller set of conversion-capable KVM hosts and +explicitly select one of them for migration work. + +The KVM agent checks these commands: + +```bash +virt-v2v --version +nbdkit vddk --dump-plugin libdir= +qemu-img --version +qemu-nbd --version +qemu-io --version +``` + +On Ubuntu and Debian hosts the conversion support check also verifies +that the `nbdkit` package is installed with `dpkg -l nbdkit`. + +## VMware VDDK installation + +Install VMware VDDK on every KVM host that may run a VMware-to-KVM conversion or +CBT replication session. A common layout is: + +```bash +mkdir -p /opt/vmware +tar -xzf VMware-vix-disklib-*.tar.gz -C /opt/vmware +``` + +The extracted directory is usually named: + +```text +/opt/vmware/vmware-vix-disklib-distrib +``` + +CloudStack auto-detects the first directory named +`vmware-vix-disklib-distrib`. If auto-detection is not desirable, configure the +path explicitly in the KVM agent properties: + +```properties +vddk.lib.dir=/opt/vmware/vmware-vix-disklib-distrib +``` + +Optional VDDK settings: + +```properties +vddk.transports=nbdssl:nbd +vddk.thumbprint= +``` + +If `vddk.thumbprint` is not set, CloudStack attempts to compute the vCenter +thumbprint on the KVM host. + +## EL8 and Oracle Linux 8 host packages + +On an EL8-compatible KVM host, the CloudStack agent package already brings in +the normal KVM runtime dependencies such as libvirt, qemu-kvm, qemu-img, and +Python libvirt bindings. The VMware CBT path additionally needs virt-v2v, +nbdkit, the nbdkit VDDK plugin, qemu-nbd support, and VDDK itself. + +Example package installation: + +```bash +dnf install -y cloudstack-agent +dnf install -y virt-v2v nbdkit qemu-img qemu-kvm-core qemu-kvm-block-curl libnbd +dnf install -y nbdkit-plugin-vddk || dnf install -y nbdkit-vddk-plugin +``` + +Package names can differ between RHEL, Oracle Linux, Rocky, AlmaLinux, and +enabled module streams. The important validation is not the exact package name, +but that these commands work: + +```bash +virt-v2v --version +qemu-img --version +qemu-nbd --version +qemu-io --version +nbdkit vddk --dump-plugin libdir=/opt/vmware/vmware-vix-disklib-distrib +``` + +For Windows guests, install a `virtio-win` package or otherwise make the +VirtIO drivers available to virt-v2v. Some EL8 environments provide this from +an additional repository rather than the base distribution repositories. + +Distribution `virtio-win` packages can lag the upstream project and omit +drivers for recent Windows releases. `virt-v2v` treats missing drivers as a +non-fatal warning and would deliver a guest that cannot boot from its VirtIO +disk, so both the cold import and the CBT cutover conversion **fail fast +instead**. When `virt-v2v` reports that no VirtIO drivers are available, the +operation is aborted and identifies the conversion host's +`/usr/share/virtio-win/virtio-win.iso` as the file to update. Install the +upstream `latest-virtio` ISO on every conversion host: + +```bash +curl -L -o /usr/share/virtio-win/virtio-win.iso \ + https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso +``` + +Windows Server 2025 additionally rejects the optional `smbus` driver's +signature at first boot, which loops the virt-v2v firstboot driver +installation forever; rebuild the ISO without `smbus.inf`/`smbus.cat` before +converting Windows Server 2025 guests (the admin guide page "Importing VMware +VMs into KVM" carries the exact commands). Older Windows releases work with +the unmodified ISO. + +### Guest CPU model for Windows 11 and Windows Server 2025 + +Those releases require CPU features that the KVM agent's default guest CPU +model (`qemu64`) does not expose. The conversion itself is unaffected — the +disks convert, the drivers install, the imported instance starts and reaches +its UEFI firmware — but the guest then drops into *Preparing Automatic Repair* +instead of the login screen, on every boot. Nothing in the migration output +indicates a problem, so it reads like a corrupt image. + +Set a modern named model on every KVM host that will run such guests, in +`/etc/cloudstack/agent/agent.properties`, and restart the agent: + +``` +guest.cpu.mode=custom +guest.cpu.model= +``` + +The model has to be one the host can actually provide, and that differs by +processor vendor and generation — `Broadwell-noTSX-IBRS` or newer on Intel, +`EPYC-IBPB` or newer on AMD. Check before setting it: + +```bash +virsh domcapabilities | grep "usable='yes'" +``` + +A model reported as `usable='no'` is refused at domain start with *"the CPU is +incompatible with host CPU: Host CPU does not provide required features"*, and +the instance never launches — including system VMs, if the host is the one +running them. In a cluster, pick a model every host reports as usable so live +migration stays possible. + +`guest.cpu.mode=host-model` and `host-passthrough` also work and need no such +check, but they tie the instance to the host's CPU and prevent live migration +between hosts with different processors. + +This applies equally to instances deployed natively on KVM — it is a host +prerequisite for the guest OS, not a property of the migration. + +If the nbdkit VDDK plugin package conflicts with the installed nbdkit module +stream, align the enabled AppStream or vendor repository so `nbdkit` and the +VDDK plugin come from compatible builds. + +## Ubuntu 24.04 host packages + +On Ubuntu 24.04, the base KVM and conversion packages are available from the +standard repositories, but the nbdkit VDDK plugin is not necessarily provided by +the default Ubuntu archive. Plan for an internal package, a vendor-provided +package, or a locally built nbdkit VDDK plugin. + +Example base package installation: + +```bash +apt-get update +apt-get install -y cloudstack-agent +apt-get install -y qemu-utils qemu-system-x86 libvirt-daemon-system libvirt-clients +apt-get install -y virt-v2v nbdkit libguestfs-tools libnbd-bin +``` + +Then install or build the nbdkit VDDK plugin and verify: + +```bash +nbdkit vddk --dump-plugin libdir=/opt/vmware/vmware-vix-disklib-distrib +``` + +`qemu-img` and `qemu-nbd` are provided by `qemu-utils` on Ubuntu 24.04. + +For Windows guests, ensure a `virtio-win` package or equivalent VirtIO driver +bundle is available. The KVM agent check looks for a package named +`virtio-win` on Debian-based hosts, so an internal package with that name is +the easiest way to satisfy the current check. The upstream-ISO, `smbus` and +guest CPU model notes above apply here as well. + +## Network and VMware requirements + +The selected KVM migration host must be able to reach: + +* vCenter HTTPS, normally TCP 443. +* ESXi hosts used for VDDK/NFC access, commonly TCP 902 for NFC paths. +* Any network paths required by the selected VDDK transport mode. +* The destination primary storage backend. + +The VMware account used for migration must be able to discover the VM, create +and remove snapshots, read virtual disk data, and query changed disk areas. + +VMware CBT must be enabled and valid for the source VM disks before incremental +cycles can be trusted. A full resync is required when VMware invalidates CBT +state, for example after CBT reset, certain disk changes, failed snapshot +consolidation, or storage operations that change disk backing identity. + +Avoid or explicitly handle VMware disk types that do not participate cleanly in +CBT, such as independent disks and unsupported raw device mappings. + +## Guest identity, firmware and network defaults derived from the source + +Both the cold import and the CBT warm migration derive sensible defaults from +the source VM instead of falling back to generic import defaults. Everything +below applies only when the caller did not specify the value explicitly - +operator-provided parameters always win. + +* **Guest OS type** is resolved from the source's configured identity + (`config.guestFullName` / `config.guestId`, available regardless of power + state). Windows releases newer than the guest OS catalog map to the nearest + older entry of the same family (for example Windows Server 2025 maps to the + Windows Server 2022 type until the catalog catches up); the mapped type + keeps Windows clock and Hyper-V enlightenment semantics correct on KVM. +* **Firmware** follows the source: a UEFI source gets the `UEFI` setting + (`SECURE` when the source has secure boot enabled) and the `q35` machine + type. Previously every import defaulted to BIOS, which leaves a UEFI + source's GPT/EFI disk unbootable. +* **Console video** for Windows guests is set to `vga`; the libvirt default + (legacy cirrus) renders a blank console on Windows Server 2025 Server Core. +* **Static IPv4 preservation**: while the source runs, per-NIC addresses, + the subnet's default gateway and the DNS servers are captured from VMware + Tools. A NIC that is mapped to a CloudStack network without an explicit + `nicipaddresslist` entry keeps its guest-configured address when that + address fits the target network's CIDR, is not the gateway and is free; + otherwise the NIC falls back to normal allocation. DHCP NICs are untouched + and pick up the virtual router's lease after migration. Capture requires + VMware Tools running in the source, which the warm flow guarantees (the + source is live when the migration starts). + +## Validation checklist + +Run this checklist on every candidate KVM migration host: + +```bash +virt-v2v --version +qemu-img --version +qemu-nbd --version +qemu-io --version +nbdkit vddk --dump-plugin libdir=/opt/vmware/vmware-vix-disklib-distrib +test -f /opt/vmware/vmware-vix-disklib-distrib/lib64/libvixDiskLib.so +systemctl restart cloudstack-agent +``` + +Then confirm the host details from the CloudStack API or UI show: + +```text +host.vddk.support=true +host.vddk.blockcopy.support=true +host.vddk.blockcopy.inplace.finalization.support= +host.vddk.blockcopy.rbd.support= +host.qemu.img.version= +host.qemu.nbd.version= +host.qemu.io.version= +host.virtv2v.version= +``` + +If `host.vddk.blockcopy.support` is false, check the agent log on the KVM host +and validate each command above manually. In-place and RBD capability can be +false on hosts intended only for QCOW2 fallback, but those hosts are not +eligible for RBD or LINSTOR cutover. + +## Operational notes + +* Prefer `nbdssl` or another encrypted VDDK transport where supported. +* Run migrations from hosts with fast access to destination primary storage. +* Keep enough temporary space for virt-v2v and libguestfs scratch data; use + `convert.instance.env.tmpdir` and `convert.instance.env.virtv2v.tmpdir` when + the default temporary filesystem is too small. +* Use the current quiet-cycle settings to avoid cutting over while the source + VM is still producing a high dirty rate. +* Increase `vmware.cbt.migration.agent.command.timeout` before migrating very + large VMs if the baseline copy or cutover finalization can exceed 24 hours. +* Always clean up VMware snapshots after failed or cancelled cycles. +* Treat CBT change IDs as per-disk state. If any disk loses valid CBT state, + restart that disk from a full sync. +* On Linstor destinations, prefer a conversion host that holds a diskful + replica of the target resources; writes from a diskless satellite traverse + the DRBD replication network. DRBD rounds volume sizes up to its extent + granularity, so target devices can be slightly larger than the source + disks; qemu writes only within the source capacity. +* Linstor storage-pool heartbeats can fence (reboot) an HA-enabled KVM host if + the LINSTOR controller becomes unreachable. Prefer conversion hosts that do + not run HA-protected guests, or ensure controller availability for the whole + duration of long migrations. +* Migrated Linstor volumes keep their migration-time names (the LINSTOR + resource is `cs-` plus the recorded volume path, e.g. `cs-cbt--` + for warm migrations). This is expected and does not affect volume lifecycle + operations, which address volumes by their recorded path. diff --git a/docs/vmware-cbt/README.md b/docs/vmware-cbt/README.md new file mode 100644 index 000000000000..b9d745750e1a --- /dev/null +++ b/docs/vmware-cbt/README.md @@ -0,0 +1,1550 @@ + + +# VMware CBT Migration Implementation Notes + +## Introduction + +This document describes the VMware Changed Block Tracking (CBT) migration design +and implementation for Apache CloudStack 24.0.0. It is written as source material +for an Apache CloudStack CWIKI architecture page and should be treated as an +implementation guide, not only as a design sketch. + +The feature provides a warm VMware-to-KVM migration flow. CloudStack creates a +KVM-side source-equivalent disk replica from a VMware VDDK snapshot, keeps that +replica current with one or more VMware CBT delta cycles, requires the operator +to power off the VMware source VM, runs one final CBT delta cycle, finalizes the +replica with `virt-v2v`, and imports the finalized disk(s) as a regular +CloudStack KVM VM. + +The essential architectural point is that VMware CBT migration is a two-phase +disk strategy: + +- Phase 1 keeps a KVM-side source-equivalent replica current using VDDK and + VMware CBT. +- Phase 2 finalizes the fully synchronized replica with `virt-v2v` only after + the source VM is powered off. + +This gives CloudStack warm migration behavior without trying to apply VMware +changed blocks to a disk that has already been transformed for KVM, and without +requiring a second full VMware read at cutover time. + +## Problem Statement + +The existing VMware import paths are useful for one-time imports, but they do +not provide a warm migration loop where the target disk is kept current while +the source VM continues to run on VMware. Large VMs therefore either require a +longer downtime window or repeated full-copy style attempts. + +VMware CBT provides the changed-block metadata needed for a replication-style +workflow, but the design must avoid a common trap: once `virt-v2v` modifies a +disk for KVM guest bootability, VMware CBT byte ranges can no longer be safely +applied to that transformed disk. The implementation must therefore keep the +replication target source-equivalent until final cutover, then run guest +conversion only after the final delta is applied. + +## Goals + +- Provide an operator-driven warm migration flow from VMware to CloudStack KVM. +- Support both existing registered vCenter records and external vCenter details + supplied during import. +- Run the initial full copy once through VDDK and `qemu-img`. +- Apply subsequent VMware CBT delta cycles to the KVM-side replica. +- Make long-running start, sync, and cutover operations asynchronous. +- Keep migration progress visible through list APIs and the UI migration table. +- Validate destination storage, host capabilities, and compute offering sizing + before expensive work starts. +- Persist external vCenter credentials only through CloudStack encrypted DAO + fields and never expose passwords in API responses. +- Reuse the existing CloudStack converted-VM import path after cutover + finalization. + +## Scope + +Current implementation scope: + +- Target hypervisor: KVM. +- Source hypervisor: VMware through VDDK and VMware CBT. +- Admin API surface for preflight, start, list, sync, cutover, cancel, and + delete. +- Async API execution for long-running start, sync, and cutover operations. +- Management-server state model for migrations, disks, and delta cycles. +- External vCenter credential persistence for long-lived UI sessions, encrypted + with CloudStack DAO encryption. +- KVM agent wrappers for initial VDDK full sync, delta block copy, cutover + finalization, and cleanup. +- Filesystem-like primary storage target support using QCOW2 files. +- Ceph/RBD primary storage target support using raw RBD images. +- Non-in-place `virt-v2v` fallback for QCOW2 file targets, gated by global + configuration. +- Early compute-offering validation against discovered VMware VM CPU and memory. +- Dynamic/custom service offering support through the same `details` keys used + by the existing OVF/VDDK import flow. +- UI integration in the Import/Export Instances area, including job polling and + table auto-refresh while CBT migrations are active. + +## Non-Goals + +Not implemented or intentionally blocked: + +- Non-in-place `virt-v2v` fallback for Ceph/RBD targets. RBD targets require + in-place finalization because fallback produces local QCOW2 files. +- Automatic source VM shutdown during cutover. +- Continuous byte-level progress from `qemu-img convert`. The UI currently + receives state transitions, disk target paths, delta cycle byte counts, dirty + rates, and cycle descriptions through the list API. +- Applying CBT deltas to a guest-converted disk. +- Replacing the existing OVF/VDDK one-time import flows. + +## Key Principles + +- CloudStack does not power off the VMware source VM automatically. The + operator must shut it down before final cutover. +- CloudStack does not store vCenter passwords in clear text. +- Initial sync and delta sync keep the target disk source-equivalent. +- `virt-v2v` runs only at finalization, after the final CBT delta cycle. +- The final `virt-v2v` work is local to the KVM-side replica and does not + perform a second full VMware read. +- The detailed status source is `listVmwareCbtMigrations`, not only the async + job result. +- Failed final import after successful finalization is retryable through the + `ReadyForImport` state. + +## High-Level Architecture + +The implementation has four main layers: + +- CloudStack management-server APIs and orchestration. +- CloudStack database state for migration sessions, source disks, and delta + cycles. +- KVM agent commands that perform VDDK reads, QCOW2 or raw RBD writes, + `virt-v2v` finalization, and cleanup. +- UI workflow in the Import/Export Instances area. + +Management-server API and orchestration: + +- `StartVmwareCbtMigrationCmd` +- `CheckVmwareCbtMigrationPrerequisitesCmd` +- `ListVmwareCbtMigrationsCmd` +- `SyncVmwareCbtMigrationCmd` +- `CutoverVmwareCbtMigrationCmd` +- `CancelVmwareCbtMigrationCmd` +- `DeleteVmwareCbtMigrationCmd` +- `VmwareCbtMigrationManagerImpl` +- `VmwareCbtStorageTarget` +- `VmwareCbtMigrationCutoverPolicy` + +Persistence: + +- `cloud.vmware_cbt_migration` +- `cloud.vmware_cbt_migration_disk` +- `cloud.vmware_cbt_migration_cycle` + +KVM agent wrappers: + +- `LibvirtVmwareCbtPrepareCommandWrapper` +- `LibvirtVmwareCbtSyncCommandWrapper` +- `LibvirtVmwareCbtCutoverCommandWrapper` +- `LibvirtVmwareCbtCleanupCommandWrapper` + +UI: + +- `ui/src/views/tools/ImportUnmanagedInstance.vue` +- `ui/src/views/tools/ManageInstances.vue` +- `ui/src/views/tools/VmwareCbtMigrations.vue` + +## Architecture Diagrams + +The diagrams below are intentionally plain text so they can be pasted directly +into Apache CWIKI source and remain editable. + +### End-To-End CBT Flow + +```text +VMware source VM + | + | VMware snapshot + CBT metadata + v +VDDK / nbdkit-vddk on KVM conversion host + | + | Initial baseline: full logical disk read + v +CloudStack primary storage + | + | Source-equivalent replica + | - qcow2 file on filesystem-like storage + | - raw RBD image on Ceph/RBD storage + v +Delta sync cycles + | + | CBT changed ranges only + v +Operator powers off source VM + | + | Cutover: final delta + virt-v2v finalization + v +CloudStack KVM VM +``` + +### Initial Sync Transfer Behavior + +VMware CBT initial sync is not guest-filesystem-used-space-oriented. CloudStack +does not inspect the guest filesystem to decide which files are in use; it reads +the disk image exposed by VDDK/NBD and writes the target replica. + +The amount of data transferred depends on what VDDK exposes for the source disk +and datastore. Some source paths expose the baseline as one allocated logical +range, while others expose sparse `hole,zero` extents. The destination can remain +thin or sparse when the source and copy path expose zero ranges correctly, but +operators must size the initial-copy window for the full logical disk capacity. + +The KVM agent uses `qemu-img convert` for the general full-copy path. When +`nbdcopy` is installed, it can accelerate supported pre-created raw targets and +RBD targets served through a local `qemu-nbd` bridge. `nbdcopy` is optional; +capability and correctness do not depend on it. + +```text +Example VMware disk capacity: 5 GiB +Guest-used data: near 0 GiB +Datastore/path behavior: datastore-dependent + +During initial sync: + VMware/VDDK exposes allocation/extents to NBD. + CloudStack reads the ranges that qemu sees through that NBD export. + qemu-img writes a qcow2 target and can preserve thin/sparse output. + +Possible outcome: full logical transfer or sparse-aware transfer +Target allocation: backend- and extent-dependent +``` + +### Linked Clone And Full Clone Baselines + +The initial baseline reads the logical disk presented by VDDK. Linked clone vs +full clone is not enough by itself to predict transfer volume. The decisive +question is whether VDDK/NBD exposes trustworthy allocation and zero extents for +the source datastore and disk chain. + +```text +Linked clone source + child delta VMDK + | + v + parent/base VMDK chain + | + v + VDDK presents one logical disk + +Full clone source + single/full VMDK + | + v + VDDK presents one logical disk + +CBT initial sync: + both are read as the full logical disk capacity. + The target qcow2 can still be sparse/thin after the write. +``` + +### Delta Sync And Cutover Eligibility + +```text +Initial sync completed + | + v +Delta cycle 1 -> changed bytes / dirty rate measured + | + v +Delta cycle 2 -> quiet-cycle counters updated + | + v +Repeat until one boundary is met: + + min cycles satisfied + AND quiet bytes threshold satisfied + AND quiet dirty-rate threshold satisfied + AND quiet cycle count satisfied + +OR + + max cycle count reached + + | + v +Migration becomes ReadyForCutover +``` + +### Cutover Finalization Paths + +```text +Final CBT delta sync + | + v +Check selected conversion host capability + | + +--> virt-v2v-in-place available + | | + | v + | run virt-v2v-in-place finalization + | + +--> virt-v2v supports --in-place + | | + | v + | run virt-v2v --in-place finalization + | + +--> non-in-place fallback explicitly allowed + | + v + run regular virt-v2v -o local fallback finalization + +Result: + finalized qcow2 moved to primary storage root + VM imported with VirtIO root disk controller +``` + +### Final Disk Placement + +```text +During sync: + /mnt//cloudstack-cbt//... + +After finalization/import: + /mnt//.qcow2 + +CloudStack DB target path: + updated to the final primary-storage-root location +``` + +## End-To-End Flow + +### 1. Preflight + +`checkVmwareCbtMigrationPrerequisites` validates the source and destination +before a long-running job is started. The command is synchronous and returns a +structured preflight response with pass, warn, and fail findings. + +The preflight checks include: + +- Destination zone and KVM cluster lookup. +- Destination primary storage lookup or implicit pool selection. +- Storage target classification. +- KVM conversion host selection. +- Host VMware CBT capability checks. +- Host in-place finalization capability checks. +- Host qemu RBD capability checks and an active temporary RBD create/write/read/delete + probe when the selected primary storage is Ceph/RBD. +- vCenter source resolution, either by `existingvcenterid` or external + vCenter fields. +- Source VM discovery. +- Source VM CBT support. +- Source VM CBT enabled state. +- Source VM consolidation-needed state. +- Existing VMware snapshot count. +- Source disk discovery. +- Windows guest conversion dependency check on the selected KVM conversion host + when the source VM is a Windows guest. +- Optional compute offering sizing validation. + +The preflight response includes, among other fields: + +- `ready` +- `storagewritertype` +- `storagewritersupported` +- `storagerequiresinplacefinalization` +- `convertinstancehostinplacefinalizationsupported` +- `noninplacefinalizationfallbackallowed` +- `noninplacefinalizationfallbacksupported` +- `sourcecpunumber` +- `sourcecpuspeed` +- `sourcememory` +- `sourceguestosid` +- `sourceguestos` +- `disk` +- `finding` + +If CBT is supported but not enabled, preflight reports a warning. The start +path can enable CBT before creating the baseline snapshot. + +The source guest OS fields are populated from the VMware inventory and are used +to decide whether Windows-specific conversion prerequisites apply. They do not +change the migration mode or disk-copy behavior by themselves. + +### Guest OS Mapping And Source OS Labels + +VMware exposes more than one guest OS signal. The VM's configured guest OS +option can differ from the runtime guest OS label reported by VMware Tools. For +example, a powered-on VM can show `Ubuntu Linux (64-bit)` in the vSphere summary +while VM Options still show `Other (64-bit)`. CloudStack discovery and preflight +therefore treat source guest OS values as inventory facts rather than as a safe +automatic target OS selection. + +The target CloudStack guest OS is the optional `osid` supplied to +`startVmwareCbtMigration` and used later by the shared KVM import path. Operators +should verify this value before starting migration, especially for generic VMware +guest IDs such as `ubuntu64Guest`, which can represent multiple Ubuntu releases. +If the desired target guest OS is not selectable, the corresponding +`guest_os_hypervisor` mapping must exist for the source hypervisor version. + +### 2. Start And Initial Full Sync + +`startVmwareCbtMigration` is async. It creates the migration record, persists +source disk rows, creates a baseline VMware snapshot, prepares target paths, and +dispatches the initial full sync to the selected KVM host. + +For Windows source VMs, the start path repeats the same conversion dependency +check reported by preflight before the baseline snapshot and initial sync are +started. This prevents a long CBT copy from producing a disk that cannot be made +bootable because the selected KVM conversion host is missing `virtio-win`. + +State transitions: + +- `Created` +- `InitialSync` +- `Replicating` when the initial VDDK full sync completes successfully +- `Failed` if any initial copy or metadata step fails + +Important implementation details: + +- For filesystem-like storage, target paths are prepared before dispatch and + are shown in API/UI while the sync is running. +- Default target path pattern with explicit primary storage: + `/mnt//cloudstack-cbt//.qcow2` +- Default target path pattern without explicit pool: + `/var/lib/libvirt/images/cloudstack-cbt//.qcow2` +- The agent writes the vCenter password to a temporary password file and passes + it to `nbdkit` as `password=+`. +- After successful copy, management refreshes and records the baseline disk + `changeId` values from the VMware snapshot. +- The baseline snapshot is removed after the initial sync attempt when possible. + +### 3. Delta Synchronization + +`syncVmwareCbtMigration` is async and serialized by migration ID through +`BaseAsyncCmd.migrationSyncObject`. + +Allowed migration states: + +- `Replicating` +- `ReadyForCutover` + +Delta cycle sequence: + +1. Create a new `vmware_cbt_migration_cycle` row. +2. Create a VMware snapshot for the cycle. +3. Query VMware CBT changed disk areas from the previous per-disk `changeId`. +4. Persist cycle state as `QueryingChangedAreas`, then `CopyingChangedBlocks`. +5. Send changed block ranges to the KVM agent. +6. Agent copies only those ranges from the VDDK NBD source into the target + replica. +7. Management records changed bytes, duration, dirty rate, and the next + per-disk `changeId`. +8. The cycle snapshot is removed when possible. +9. The cutover policy decides whether to remain in `Replicating` or move to + `ReadyForCutover`. + +The cycle response exposes: + +- `cyclenumber` +- `snapshotmor` +- `changedbytes` +- `dirtyrate` +- `duration` +- `state` +- `description` +- `created` +- `lastupdated` + +Delta sync progress is represented as state and cycle metadata rather than a +streamed progress bar. While a cycle is running, the row description can show +what the manager knows, for example that changed areas are being queried or +that a number of ranges has been dispatched. After the agent returns, the cycle +shows copied bytes, dirty rate, duration, and a textual summary. + +### 4. Cutover + +`cutoverVmwareCbtMigration` is async and serialized by migration ID. + +Allowed migration states: + +- `ReadyForCutover` +- `ReadyForImport` + +Cutover requires the source VM to be powered off. CloudStack checks the VMware +power state and rejects cutover if the source VM is still powered on: + +```text +Cannot cut over VMware CBT migration while source VM is in power state PowerOn. +Gracefully shut down the source VM, then retry cutover. +``` + +CloudStack does not attempt a graceful shutdown or power-off. The operator must +shut down the VMware source VM before final cutover. This avoids surprising the +operator and avoids needing to encode site-specific guest shutdown policy in the +migration path. + +Cutover sequence: + +1. Verify source VM is powered off. +2. Set migration state to `CuttingOver`. +3. Run a final CBT delta sync while the VM is off. +4. Run `virt-v2v` finalization on the KVM host. +5. Move finalized disk files to the primary storage root with generated UUID + names, then update disk target paths from the agent result. +6. Set state to `ReadyForImport`. +7. Call the shared external KVM VM import path. +8. On success, set state to `Completed`, store the new CloudStack VM ID, and + clear stored external vCenter credentials. +9. On import failure, keep state as `ReadyForImport` so the operator can retry + cutover to repeat only the import step against already finalized disks. + +### 5. Cancel And Delete + +`cancelVmwareCbtMigration` is synchronous. If the migration is not terminal, it +sends a best-effort cleanup command, marks the migration `Cancelled`, and clears +stored external vCenter credentials. + +`deleteVmwareCbtMigration` is synchronous. It is allowed for `Failed`, +`Cancelled`, and `Completed` migrations. With `cleanup=true` (the default), it +sends a cleanup command before deleting failed or cancelled child rows and the +parent row. `Completed` migration deletion is always record-only: CloudStack +removes the CBT bookkeeping rows but never deletes the imported VM, CloudStack +volumes, finalized target disks, or primary-storage files. + +Deletion behavior: + +- Deletes `vmware_cbt_migration_cycle` rows. +- Deletes `vmware_cbt_migration_disk` rows. +- Deletes the `vmware_cbt_migration` row. +- Clears stored source credentials before row removal. +- For `Failed` or `Cancelled` rows, cleans target files only if the paths are + under the migration-specific `/cloudstack-cbt//` directory + and cleanup is enabled. For RBD targets, cleanup removes only temporary RBD + image names containing the CloudStack-owned `cloudstack-cbt--` + marker. +- For `Completed` rows, ignores cleanup and removes only CBT migration records. +- Skips immediate cleanup if another active migration is running on the same + conversion host, to avoid disturbing active agent-side work. + +The KVM cleanup wrapper resolves migration directories from disk target paths +and removes only the migration-specific directory tree for file targets. For RBD +targets it uses the destination storage pool and deletes only marked temporary +images, never arbitrary RBD images. + +## Conversion Model: One VMware Baseline Read + +The feature deliberately separates replication from final conversion. + +Initial full sync does not run `virt-v2v`. The KVM agent opens the VMware source +snapshot through `nbdkit` with the VDDK plugin. Filesystem-like primary storage +writes QCOW2 with `qemu-img convert`: + +```text +nbdkit -r -U - vddk ... --run \ + 'qemu-img convert -f raw -O qcow2 "$uri" ""' +``` + +Ceph/RBD primary storage is a raw RBD image. When `nbdcopy` is available, the +agent pre-creates the image, exposes it through a temporary local `qemu-nbd` +bridge, and copies NBD to NBD. Otherwise it uses `qemu-img convert` directly +against the qemu RBD URL. LINSTOR similarly uses `nbdcopy` when available for +its pre-created raw DRBD device and falls back to `qemu-img convert`. + +Example RBD fallback path: + +```text +nbdkit -r -U - vddk ... --run \ + 'qemu-img convert -f raw -O raw "$uri" "rbd:/:..."' +``` + +This produces a source-equivalent replica on the selected primary storage. It +is not yet a CloudStack-imported VM disk. It is a replication target that can +safely receive raw changed blocks from later CBT cycles. + +Delta sync also does not run `virt-v2v`. Management creates a VMware snapshot, +queries changed disk areas using VMware CBT metadata, and dispatches changed +byte ranges to the KVM agent. The agent opens the snapshot through `nbdkit`, +parses the temporary nbdkit Unix socket from the NBD URI, uses `qemu-img +convert --image-opts` to materialize bounded raw chunks for only the changed +ranges, and patches the replica with `qemu-io`. File targets use `-f qcow2`; +RBD targets use `-f raw` against the qemu RBD URL for the target image. + +Final cutover is where `virt-v2v` runs. After the source VM is powered off, the +manager runs one final CBT delta cycle so the replica is current. Then the agent +runs one of the finalization paths: + +- `virt-v2v-in-place`, when available. +- `virt-v2v --in-place`, when the installed `virt-v2v` supports it. +- Regular `virt-v2v -o local`, only when the non-in-place fallback is explicitly + enabled and the target is a QCOW2 file target. + +Ceph/RBD targets require one of the in-place finalization paths. They do not +allow non-in-place fallback because fallback creates local QCOW2 output rather +than modifying the raw RBD image that CloudStack will import. + +So there is not a second full VMware read during finalization. The final +`virt-v2v` step is local work against the up-to-date KVM-side replica. It may +still require local storage reads and writes, especially in fallback mode, but +it is not another VDDK full copy from vCenter. + +The initial baseline may read up to the full logical VMware disk range, +regardless of guest filesystem usage. This does not imply thick allocation on +the destination: QCOW2, RBD, and LINSTOR can remain sparse or thin when the +source and copy path expose zero ranges correctly. Delta cycles are CBT-range +based and copy only changed ranges. + +This design avoids applying future CBT raw block deltas to a disk that has +already been modified by `virt-v2v`. Applying VMware CBT ranges to a converted +guest disk would be unsafe because the layout no longer represents the source +VMware disk byte-for-byte. + +## API Changes + +All commands are admin-only. + +### `checkVmwareCbtMigrationPrerequisites` + +Synchronous preflight command. + +Required: + +- `zoneid` +- `clusterid` +- `sourcevmname` + +Source vCenter selection: + +- Existing registered vCenter: `existingvcenterid` +- External vCenter: `vcenter`, `datacentername`, `username`, `password` + +Optional: + +- `hostip` +- `clustername` +- `convertinstancehostid` +- `convertinstancestoragepoolid` +- `serviceofferingid` +- `details` + +The command has `requestHasSensitiveInfo=true` because external vCenter +credentials can be provided. + +### `startVmwareCbtMigration` + +Async command. Starts the migration and initial full sync. + +Required: + +- `zoneid` +- `clusterid` +- `serviceofferingid` +- `sourcevmname` + +Source vCenter selection: + +- Existing registered vCenter: `existingvcenterid` +- External vCenter: `vcenter`, `datacentername`, `username`, `password` + +Optional import inputs: + +- `displayname` +- `hostname` +- `account` +- `domainid` +- `projectid` +- `templateid` +- `osid` +- `datadiskofferinglist` +- `nicnetworklist` +- `nicipaddresslist` +- `forced` + +Optional conversion inputs: + +- `convertinstancehostid` +- `convertinstancestoragepoolid` +- `details` + +If `convertinstancestoragepoolid` is omitted, CloudStack auto-selects the only +CBT-compatible primary storage pool in the destination cluster/zone. Supported +target pool types are `NetworkFilesystem`, `Filesystem`, `SharedMountPoint`, +and `RBD`. If more than one compatible pool is available, the operator must +provide the pool explicitly. + +Useful `details` keys: + +- `vddk.lib.dir` +- `vddk.transports` +- `vddk.thumbprint` +- `cpuNumber` +- `cpuSpeed` +- `memory` + +The command has `requestHasSensitiveInfo=true` and +`responseHasSensitiveInfo=false`. + +### `listVmwareCbtMigrations` + +Synchronous status command and the main progress source for UI and automation. + +Filters: + +- `id` +- `zoneid` +- `accountid` +- `vcenter` +- `sourcevmname` +- `state` + +The response includes migration-level state, current step, current step +duration, last error, cycle counters, changed byte totals, disks, and cycles. +`currentstepduration` is populated for non-terminal migrations and is intended +for the UI table marker similar to Import VM Tasks. + +### `syncVmwareCbtMigration` + +Async command. Runs one delta sync cycle. + +Required: + +- `id` + +Optional: + +- `username` +- `password` + +For an external vCenter migration, stored credentials are used when available. +If override credentials are supplied, both username and password must be +provided. Overrides are stored back to the migration so a UI operator can return +later and continue syncing. + +### `cutoverVmwareCbtMigration` + +Async command. Runs final powered-off sync, finalizes disks, and imports the VM. + +Required: + +- `id` + +Optional: + +- `username` +- `password` + +The command fails early if the source VMware VM is not powered off. + +### `cancelVmwareCbtMigration` + +Synchronous command. + +Required: + +- `id` + +Cancels a non-terminal migration, attempts cleanup, and clears stored external +source credentials. + +### `deleteVmwareCbtMigration` + +Synchronous command. + +Required: + +- `id` + +Optional: + +- `cleanup` (default `true`) + +Allowed for `Failed`, `Cancelled`, and `Completed` migrations. Completed +migration deletes are record-only and never remove the imported CloudStack VM, +volumes, finalized target disks, or primary-storage files. + +## Configuration And Cutover Policy + +The cutover recommendation is based on global settings: + +| Global setting | Default | Meaning | +| --- | ---: | --- | +| `vmware.cbt.migration.min.cycles` | `1` | Minimum number of manual/API delta cycles that must complete before quiet-cycle readiness can make the migration eligible for cutover. | +| `vmware.cbt.migration.max.cycles` | `5` | Maximum number of manual/API delta cycles before CloudStack marks the migration ready for cutover even if the quiet-cycle thresholds were not met. | +| `vmware.cbt.migration.quiet.cycles` | `2` | Number of consecutive quiet cycles required before CloudStack marks the migration ready for cutover. | +| `vmware.cbt.migration.quiet.bytes` | `1073741824` | Maximum changed bytes in one cycle for that cycle to be considered quiet. The default is 1 GiB. | +| `vmware.cbt.migration.quiet.dirty.rate` | `16777216` | Maximum changed bytes per second for that cycle to be considered quiet. The default is 16 MiB/s. | +| `vmware.cbt.migration.agent.command.timeout` | `86400` | Timeout in seconds for long-running VMware CBT data-plane commands on the KVM agent. This covers initial full sync, regular delta sync, final delta sync, and cutover finalization. The default is 24 hours. | + +The manager tracks: + +- Number of completed delta cycles. +- Number of consecutive quiet cycles. +- Last changed bytes. +- Last dirty rate. +- Total changed bytes. + +A cycle is quiet when its changed bytes and dirty rate are below the configured +thresholds. Both thresholds must pass. For example, with defaults, a cycle that +copies 1.2 GiB at 10 MiB/s is not quiet because the changed-byte threshold is +exceeded even though the dirty-rate threshold passed. + +Once enough quiet cycles have accumulated, or once the maximum manual/API +delta-cycle count is reached, the migration moves to `ReadyForCutover`. + +The operator can still manually run sync cycles while the migration is in +`Replicating` or `ReadyForCutover`. + +The UI intentionally exposes the Cutover button only when the server-side +migration state is exactly `ReadyForCutover`. Before that point the UI exposes +Sync delta, because the server has not yet recommended final cutover. If a +cutover is started, CloudStack creates one additional final CBT delta cycle +inside the cutover operation. Therefore the final displayed cycle count can be +one higher than `vmware.cbt.migration.max.cycles`: for example, five ordinary +delta cycles can make the migration ready, and cutover can then record cycle 6 +as the final synchronization cycle. + +### Timeouts And Large VMs + +The long-running CBT work is done by KVM agent commands. CloudStack sets the +agent command `wait` value from +`vmware.cbt.migration.agent.command.timeout`, and the KVM wrappers use the same +value as the timeout for child processes such as `nbdkit`, `qemu-img`, and +`virt-v2v`. + +The timeout is finite by design. The shared CloudStack `Script` helper treats a +zero timeout as its default one-hour timeout, so `0` is not a safe way to mean +"run forever". For large source disks, tune the timeout above the expected +worst-case duration. A rough estimate is: + +```text +timeout_seconds >= source_bytes / expected_bytes_per_second * safety_factor +``` + +For example, a 1 TiB initial sync at 100 MiB/s is roughly 3 hours before +overhead; at 25 MiB/s it is roughly 12 hours. The default 24-hour timeout is +intended to be safe for large migrations while still +protecting the agent from permanently stuck child processes. + +Related import settings that operators often notice are: + +| Global setting | Applies to CBT? | Default | Meaning | +| --- | --- | ---: | --- | +| `convert.vmware.instance.to.kvm.timeout` | No | `3` | OVF/VDDK import timeout, in hours, for the `ConvertInstanceCommand` virt-v2v path. CBT cutover has its own timeout because it uses CBT-specific agent commands. | +| `remote.kvm.instance.disks.copy.timeout` | No | `30` | Remote KVM unmanaged import disk-copy timeout, in minutes. It is unrelated to VMware CBT. | + +If a CBT command exceeds `vmware.cbt.migration.agent.command.timeout`, the +migration is marked failed with the agent error details in `last_error`. +Failed or cancelled migration records can be deleted with cleanup enabled to +remove CloudStack-owned `cloudstack-cbt/` working files. + +## Database Changes + +The feature targets the Apache CloudStack 24.0.0 release, so the schema changes +are delivered through the canonical 4.23.0.0 to 24.0.0 upgrade path: + +- `engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql` +- `engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42300to2400.java` + +### `cloud.vmware_cbt_migration` + +One row per migration session. + +Important columns: + +- `uuid` +- `zone_id` +- `account_id` +- `user_id` +- `vm_id` +- `existing_vcenter_id` +- `source_username` +- `source_password` +- `destination_cluster_id` +- `convert_host_id` +- `storage_pool_id` +- `display_name` +- `host_name` +- `template_id` +- `service_offering_id` +- `guest_os_id` +- `data_disk_offering_map` +- `nic_network_map` +- `nic_ip_address_map` +- `import_details` +- `forced` +- `vcenter` +- `datacenter` +- `source_host` +- `source_cluster` +- `source_vm_name` +- `vddk_lib_dir` +- `vddk_transports` +- `vddk_thumbprint` +- `state` +- `current_step` +- `last_error` +- `completed_cycles` +- `quiet_cycles` +- `total_changed_bytes` +- `last_changed_bytes` +- `last_dirty_rate` +- `created` +- `updated` +- `removed` + +`source_password` is annotated with `@Encrypt` in `VmwareCbtMigrationVO`, using +the same CloudStack encrypted DAO field mechanism used by VMware datacenter +credentials and VM VNC passwords. + +### `cloud.vmware_cbt_migration_disk` + +One row per source VMware disk. + +Important columns: + +- `migration_id` +- `source_disk_id` +- `source_disk_device_key` +- `source_disk_path` +- `datastore_name` +- `capacity_bytes` +- `target_path` +- `target_format` +- `change_id` +- `snapshot_moref` +- `state` + +Disk states: + +- `Created` +- `Prepared` +- `Syncing` +- `Ready` +- `Failed` + +### `cloud.vmware_cbt_migration_cycle` + +One row per delta cycle. + +Important columns: + +- `migration_id` +- `cycle_number` +- `snapshot_moref` +- `changed_bytes` +- `dirty_rate` +- `duration` +- `state` +- `description` + +Cycle states: + +- `Created` +- `QueryingChangedAreas` +- `CopyingChangedBlocks` +- `Completed` +- `Failed` + +## Migration State Machine + +Migration states: + +- `Created` +- `InitialSync` +- `Replicating` +- `ReadyForCutover` +- `CuttingOver` +- `ReadyForImport` +- `Completed` +- `Failed` +- `Cancelled` + +Terminal states: + +- `Completed` +- `Failed` +- `Cancelled` + +Normal successful path: + +```text +Created + -> InitialSync + -> Replicating + -> ReadyForCutover + -> CuttingOver + -> ReadyForImport + -> Completed +``` + +`ReadyForImport` is intentionally retryable. If the final `virt-v2v` +finalization succeeded but CloudStack VM import failed, the next cutover command +does not rerun the final CBT copy and finalization. It retries the VM import +against the already finalized disk paths. + +## Storage Target And Finalization + +`VmwareCbtStorageTarget` maps primary storage to a target writer: + +- `NetworkFilesystem`, `Filesystem`, and `SharedMountPoint` use `QCOW2_FILE`. +- No explicit pool also uses a filesystem QCOW2 target under the default local + CBT path. +- `RBD` maps to `RBD_RAW`. Initial sync writes a raw RBD image with `nbdcopy` + through a local NBD bridge when available, otherwise with + `qemu-img convert -O raw`. Delta sync writes changed ranges with `qemu-io -f + raw`, and cutover finalization is in-place only. +- `Linstor` maps to `RAW_BLOCK_DEVICE`. The agent pre-creates each target + volume at source capacity through the Linstor storage adaptor (qemu cannot + create DRBD devices the way it creates RBD images), then the initial sync + writes the local DRBD device with `nbdcopy` when available or + `qemu-img convert -n -O raw` otherwise. Delta sync patches changed ranges + with `qemu-io -f raw` against the device path, and cutover finalization is + in-place only, using a plain `` libvirt XML (no qemu-nbd + bridges are needed for local block devices). + Target names are `cbt--` where `mig8` is the first eight + characters of the migration UUID: LINSTOR resource names ("cs-" + name) are + capped at 48 characters, so the RBD-style naming with the full migration + UUID does not fit; the short marker still guards cleanup on both the + management server and the agent. The conversion host must be a LINSTOR + satellite connected to the pool; host selection and the preflight storage + probe (create a 4 MiB volume, qemu-io write/read on the device, delete) + enforce this. +- Other primary storage types are unsupported. + +For QCOW2 file targets, the initial replica is stored under the selected +primary storage: + +```text +/mnt//cloudstack-cbt//.qcow2 +``` + +If no storage pool is selected, the fallback path is: + +```text +/var/lib/libvirt/images/cloudstack-cbt//.qcow2 +``` + +For Ceph/RBD targets, the initial replica is a raw RBD image in the selected +pool: + +```text +/cloudstack-cbt--- +``` + +Only the image name is persisted as the migration disk target path. The agent +reconstructs the qemu RBD URL from the selected storage pool when writing +initial and delta data. During in-place `virt-v2v` finalization, the agent +starts a temporary localhost `qemu-nbd` bridge for each RBD image and gives +`virt-v2v` a libvirt XML disk that points to that local NBD endpoint. This keeps +RBD credentials and monitor options in the qemu layer and presents a local NBD +endpoint to the conversion appliance. + +The preferred cutover finalization is in-place: + +- `virt-v2v-in-place`, if present. +- `virt-v2v --in-place`, if the installed `virt-v2v` supports it. + +If neither in-place method is available, QCOW2 file targets can use regular +`virt-v2v -o local` only when this global configuration is enabled: + +```text +vmware.cbt.allow.non.inplace.finalization=true +``` + +Default: + +```text +false +``` + +When fallback is enabled: + +- The fallback is only allowed for supported QCOW2 file targets. +- The agent stages both `TMPDIR` and `virt-v2v` output under the current + migration directory on the selected primary storage. +- The agent validates free space before running fallback finalization. +- Required free space is conservatively estimated as two times the summed disk + capacity. +- After successful fallback finalization, source replica disks are deleted. + +Fallback is never allowed for RBD targets. RBD migrations require +`virt-v2v-in-place` or `virt-v2v --in-place` support on the selected conversion +host. + +Example fallback output path: + +```text +/mnt//cloudstack-cbt//virt-v2v-output-/-sda +``` + +After successful finalization, both in-place and fallback paths are normalized +to the same final layout used by the OVF/VDDK import flow: the KVM agent moves +each finalized QCOW2 file to the selected primary storage root and gives it a +generated UUID filename: + +```text +/mnt// +``` + +The agent returns the relocated target paths in the cutover answer. The +management server persists them in `vmware_cbt_migration_disk.target_path` +through the normal disk-result update path, so no manual database update or +schema change is required for this relocation. CloudStack then imports the VM +using the flat root-level file name, matching the final placement used by +regular OVF/VDDK migrations. + +For RBD targets, successful in-place finalization keeps the raw RBD image in +place and CloudStack imports it by image name from the selected RBD storage +pool. No post-finalization file move is performed, and completed migration +deletion remains record-only. + +RBD finalization creates only temporary localhost `qemu-nbd` processes and +temporary XML/script files on the conversion host. The wrapper installs a shell +trap to stop those bridge processes and remove the PID files after `virt-v2v` +finishes or fails. The persistent storage artifact remains the raw RBD image +whose name contains the `cloudstack-cbt--` marker until +CloudStack imports it or cleanup removes it for a failed/cancelled migration. + +CloudStack imports finalized CBT disks with the KVM `virtio` root disk +controller. The conversion host must have `virtio-win` available for Windows +guests so that `virt-v2v` can enable the matching boot driver before the VM is +started under KVM. This keeps the persisted `rootDiskController` detail aligned +with the controller model used by the converted guest. + +## Ceph/RBD Conversion Host Requirements + +Ceph/RBD support relies on the same KVM host plumbing that normal CloudStack RBD +primary storage uses, plus the CBT-specific VMware conversion tools. The +selected conversion host must be able to access the destination RBD pool from +both Java/libvirt storage code and the qemu command-line tools used by the CBT +wrappers. + +Required conversion-host capabilities: + +- CloudStack KVM agent with the normal RBD primary-storage dependencies. +- The selected KVM/conversion host must already be able to use the destination + CloudStack RBD primary storage pool. +- `librados` and `librbd` client libraries compatible with the Ceph cluster. +- Java RADOS/RBD bindings used by CloudStack's RBD storage adaptor. +- qemu RBD block driver support so `qemu-img` and `qemu-io` can open + `rbd:/:...` URLs. +- VDDK and `nbdkit-vddk` for VMware source reads. +- `virt-v2v-in-place` or `virt-v2v --in-place` for RBD cutover finalization. +- `virtio-win` or equivalent VirtIO driver media/packages for Windows guests, + same as the existing VMware import requirements. + +CloudStack RBD primary-storage configuration must be valid on the selected +KVM/conversion host. For CBT-to-RBD, CloudStack uses the existing KVM +storage-pool metadata to build qemu RBD URLs, including monitor addresses, +authentication mode, Ceph user, and secret. The CBT qemu path therefore does not +rely on `/etc/ceph/ceph.conf` or a local keyring as its primary credential source +when CloudStack supplies explicit RBD URL options. + +A local Ceph configuration and keyring can still be useful for operator +diagnostics and site-local tooling, for example running `ceph -s`, +`rbd ls `, or similar commands without manually passing monitor and +authentication options each time. Sites may choose to install those files for +operational convenience, but they should not be documented as the required +credential source for CBT RBD writes. + +The Ceph client packages on every KVM/conversion host should match the Ceph +cluster major release, or at least be explicitly supported by that cluster +release. Avoid relying on the distribution's stock Ceph packages if they are +older than the deployed Ceph cluster. Ancient `librbd`, `librados`, qemu RBD +block-driver, or Java binding packages can fail in ways that look like CBT +errors even though the actual problem is a client/cluster compatibility mismatch. + +Practical guidance: + +- If the Ceph cluster is deployed from upstream Ceph packages, install the + corresponding upstream Ceph client repository on all KVM/conversion hosts. +- If the Ceph cluster is deployed from a vendor repository, use that vendor's + matching client packages on the KVM/conversion hosts. +- Keep `ceph-common`, `librados`, `librbd`, qemu RBD block-driver packages, and + Java RADOS/RBD bindings aligned as a set. Do not mix a modern Ceph cluster + with old OS-default client libraries unless the Ceph vendor explicitly + supports that combination. +- Verify the KVM host can already use the selected RBD primary storage for + ordinary CloudStack VM volumes before enabling CBT migrations to the same + pool. +- Verify that qemu tools can see RBD support. Package names vary by OS, but on + many EL-like distributions this is provided by a qemu RBD block-driver package + such as `qemu-kvm-block-rbd`; on Debian/Ubuntu-like systems it is commonly + part of qemu block extras. + +CBT preflight and `startVmwareCbtMigration` actively probe RBD access when the +selected destination pool is RBD. The management server sends the selected +conversion host a small probe command using a temporary image named +`cloudstack-cbt-probe-`. The KVM agent builds the qemu RBD URL from the +existing CloudStack storage-pool metadata, runs `qemu-img create`, writes and +reads a 4 KiB pattern with `qemu-io`, and removes the temporary image through +CloudStack's existing RBD storage adaptor. This catches qemu RBD driver issues, +Ceph monitor/authentication problems, and Java RADOS/RBD cleanup failures before +the long-running VMware copy starts. + +Useful host checks: + +```bash +ceph -s +ceph osd lspools +rbd ls +qemu-img --help | grep -i rbd +qemu-io --help | grep -i rbd +``` + +Useful package/version checks: + +```bash +ceph --version +rbd --version +ldconfig -p | egrep 'librbd|librados' +java -version +``` + +Useful CloudStack database check after the host reconnects: + +```sql +SELECT h.id, h.name, hd.name, hd.value +FROM cloud.host h +JOIN cloud.host_details hd ON hd.host_id = h.id +WHERE h.name = '' + AND hd.name IN ( + 'host.vddk.blockcopy.support', + 'host.vddk.blockcopy.inplace.finalization.support', + 'host.vddk.blockcopy.rbd.support', + 'host.vddk.support', + 'host.vddk.version', + 'host.virtv2v.inplace.version', + 'vddk.lib.dir' + ) +ORDER BY hd.name; +``` + +An RBD-capable conversion host should already report VMware CBT capability as +`true` before a migration starts, and the selected destination storage pool must +be the RBD pool that the host can access. If these host details are stale after +installing packages, restart `cloudstack-agent`, ensure the management server is +running, and wait for host details to be refreshed. + +## Host Capability Reporting + +The agent checks these on startup/ready and sends them as host details. Management then persists them into `cloud.host_details`. + +| Host detail | How agent checks it | +| --- | --- | +| `host.virtv2v.version` | Runs `virt-v2v --version`. On Ubuntu/Debian it also checks `dpkg -l nbdkit`. Version is parsed from `virt-v2v --version`. | +| `vddk.lib.dir` | Reads `vddk.lib.dir` from `agent.properties`; if blank/invalid, auto-detects with `find / -type d -name 'vmware-vix-disklib-distrib' 2>/dev/null \| head -n 1`. Valid means `/lib64/libvixDiskLib.so*` exists. | +| `host.vddk.version` | Runs `nbdkit vddk --dump-plugin libdir=` and parses `vddk_library_version=`. | +| `host.vddk.support` | True only if `virt-v2v`/`nbdkit` support is present, VDDK dir is valid, and `nbdkit-vddk-plugin` can load VDDK via `nbdkit vddk --dump-plugin libdir=`. | +| `host.qemu.img.version` | Runs `qemu-img --version`, parses first non-empty line. | +| `host.qemu.nbd.version` | Runs `qemu-nbd --version`, parses first non-empty line. | +| `host.qemu.io.version` | Runs `qemu-io --version`, parses first non-empty line. | +| `host.vddk.blockcopy.support` | True only if `host.vddk.support` is true and `qemu-img --version`, `qemu-nbd --version`, and `qemu-io --version` all exit `0`. | +| `host.vddk.blockcopy.inplace.finalization.support` | True only if CBT support is true and either `virt-v2v-in-place --version` works, or `virt-v2v --help 2>&1 \| grep -q -- '--in-place'` succeeds. | +| `host.virtv2v.inplace.version` | If `virt-v2v-in-place` exists, parses `virt-v2v-in-place --version`. If only `virt-v2v --in-place` exists, reports the normal `virt-v2v` version. | +| `host.vddk.blockcopy.rbd.support` | True only if in-place finalization support is true and `qemu-img --help` advertises the `rbd` block driver. | + +Important nuance: `host.vddk.blockcopy.rbd.support` is only the coarse host capability. For an actual selected RBD pool, preflight/start also runs an active probe: create a temporary `cloudstack-cbt-probe-` RBD image, write/read 4 KiB using `qemu-io`, then delete it through CloudStack's RBD storage adapter. That catches Ceph auth, monitor, qemu RBD, librados/librbd, and Java binding problems. + +Windows source VMs also require `virtio-win` on the selected KVM conversion +host. CBT preflight and `startVmwareCbtMigration` use the same +`CheckConvertInstanceCommand` path as OVF/VDDK import with Windows guest +conversion enabled, so a missing `virtio-win` package is reported before the +initial sync starts. + +VDDK detection uses either agent configuration or auto-detection. The agent +validates the VDDK library by checking that `lib64/libvixDiskLib.so*` exists +and that `nbdkit vddk --dump-plugin libdir=` reports a library version. + +## VDDK Details + +VDDK options can be provided as API `details` at migration start: + +- `vddk.lib.dir` +- `vddk.transports` +- `vddk.thumbprint` + +If a value is not provided by the API, the agent uses its configured or detected +value. If `vddk.thumbprint` is not provided, the agent attempts to fetch the +vCenter SHA1 certificate thumbprint with `openssl`. + +The implementation does not hard-code one VDDK version. The installed VDDK +build must be compatible with the host libraries and pass the agent capability +check. + +## Credential Handling + +There are two source credential modes: + +- Existing registered vCenter: credentials are read from the registered + `vmware_data_center` record. +- External vCenter: credentials are supplied to start/preflight and stored on + the migration record so later UI sync/cutover actions can run without asking + again. + +External credential behavior: + +- `source_password` is stored through the CloudStack encrypted DAO field + mechanism via `@Encrypt`. +- Start, preflight, sync, and cutover commands that can carry credentials are + marked `requestHasSensitiveInfo=true`. +- API responses are marked `responseHasSensitiveInfo=false`. +- Agent-side VDDK commands pass the password through a temporary password file + and `nbdkit` `password=+`, not by embedding the clear-text password in + the command line. +- Management sanitizes returned error messages by replacing the resolved source + password with `******` before storing `last_error` or cycle descriptions. +- Stored external credentials are cleared when the migration completes, + when it is cancelled, and before deletion. + +The implementation avoids logging clear-text passwords. As with other +CloudStack secret handling, this also depends on callers not placing secrets in +unrelated free-form log messages. + +## Compute Offering And Preflight Validation + +CBT migration uses the same eventual import path as the existing OVF/VDDK VM +import code. The final CloudStack VM sizing still comes from the selected +service offering and any dynamic offering details. + +The CBT-specific difference is timing: CBT validates obvious sizing problems +early, before the long initial full sync begins. This is meant to avoid spending +hours copying disks only to fail final VM import because the selected offering +is too small. + +The source VM sizing captured during preflight/start includes: + +- `sourcecpunumber` +- `sourcecpuspeed` +- `sourcememory` + +Validation rule: + +- If the source value and requested value are both known and greater than zero, + the requested value must be greater than or equal to the source value. +- Unknown or zero source values are not used to reject the offering. + +Validated fields: + +- CPU number +- CPU speed +- Memory + +Fixed offerings: + +- CPU number comes from `service_offering.cpu`. +- CPU speed comes from `service_offering.speed`. +- Memory comes from `service_offering.ram_size`. + +Dynamic/custom offerings: + +- Caller details can provide `cpuNumber`, `cpuSpeed`, and `memory`. +- If caller details are missing, offering minimums such as `mincpunumber` and + `minmemory` can be used where applicable. +- This mirrors the shared import behavior in + `UnmanagedVMsManagerImpl.addServiceOfferingDetailsToParams`. + +Example custom offering details: + +```text +details[0].key=cpuNumber +details[0].value=4 +details[1].key=cpuSpeed +details[1].value=2100 +details[2].key=memory +details[2].value=4096 +``` + +This early validation is not intended to be stricter than OVF/VDDK import. It +is intended to fail earlier for the same class of sizing mismatch. + +## UI Changes + +The CBT UI is integrated into the Import/Export Instances area. + +Implemented UI behavior: + +- Start migration uses the async `startVmwareCbtMigration` job. +- Sync delta uses the async `syncVmwareCbtMigration` job. +- Cutover uses the async `cutoverVmwareCbtMigration` job. +- Each async action uses CloudStack job polling. +- The CBT migration table also calls `listVmwareCbtMigrations` as the detailed + status source. +- The table auto-refreshes while the CBT tab is active and any migration is in + an active state or a CBT action job is still running. + +Active states for UI refresh: + +- `Created` +- `InitialSync` +- `Replicating` +- `CuttingOver` + +The table exposes: + +- Migration state. +- Current step. +- Completed cycles. +- Quiet cycles. +- Last changed bytes. +- Last dirty rate. +- Total changed bytes. +- Last error. +- Per-disk target path, target format, change ID, snapshot MOR, and state. +- Per-cycle state, changed bytes, dirty rate, duration, description, created, + and updated timestamps. + +Initial sync progress is currently coarse. While `qemu-img convert` runs on the +agent, management can show the migration state, current step, target path, and +disk state, but not a continuously updated percentage. Delta syncs have better +post-cycle metrics because changed bytes and dirty rate are returned by the +cycle result. + +## Failure And Retry Semantics + +Initial full sync failure: + +- Migration moves to `Failed`. +- Created or syncing disks are marked `Failed`. +- Last error is stored on the migration. +- Delete with cleanup can remove the migration directory. + +Delta sync failure: + +- The cycle is marked `Failed`. +- The migration moves to `Failed`. +- Last error and cycle description are stored. +- VMware cycle snapshot removal is still attempted. + +Cutover finalization failure: + +- Migration moves to `Failed`. +- The source VM remains under operator control in vCenter. +- Delete with cleanup can remove replicated/finalized target directories if the + paths are under the migration directory. + +Import failure after successful finalization: + +- Migration remains `ReadyForImport`. +- Target disk paths point at the finalized disk files. +- Retrying cutover retries the import step. + +Cancel: + +- Best-effort cleanup. +- State becomes `Cancelled`. +- Stored external credentials are cleared. + +Delete: + +- `Failed`, `Cancelled`, or `Completed`. +- Optional cleanup, default `true`, applies only to `Failed` and `Cancelled`. +- `Completed` deletion is record-only and intentionally preserves the imported + VM and all destination storage artifacts. +- Child rows and parent row are removed. + +## Security And Logging Considerations + +Important security choices: + +- Credential-bearing commands are marked sensitive. +- External vCenter password storage uses encrypted DAO fields. +- Passwords are passed to VDDK through temp files instead of clear-text command + arguments. +- Password temp files are owner-readable and owner-writable where POSIX + permissions are supported. +- Returned failure messages are sanitized against the active source password + before persistence. +- CBT delta and cutover agent wrappers include the last useful command output + line in returned failure details when available. This keeps management server + and UI errors actionable for `qemu-nbd`, `qemu-io`, `nbdkit`, and `virt-v2v` + failures without logging or returning clear-text passwords. + +Operational caution: + +- `source_username` is not encrypted. +- vCenter host, datacenter, VM name, datastore names, disk paths, change IDs, + and snapshot MORs are not treated as secrets and can appear in API responses + and logs. +- Operators should still avoid manually pasting credentials into log-visible + free-form fields. + +## Testing Strategy + +Relevant unit coverage includes: + +- `VmwareCbtMigrationOfferingValidationTest` +- `VmwareCbtMigrationDeletePolicyTest` +- `VmwareCbtStorageTargetTest` +- `VmwareCbtMigrationCutoverPolicyTest` +- `LibvirtVmwareCbtSyncCommandWrapperTest` +- `VmwareCbtSyncPlanTest` +- `LibvirtVmwareCbtCutoverCommandWrapperTest` +- `LibvirtVmwareCbtCleanupCommandWrapperTest` +- `LibvirtVmwareCbtRbdProbeCommandWrapperTest` +- `LibvirtStoragePoolTest` + +The tests cover: + +- Fixed offering validation. +- Dynamic/custom offering detail handling. +- Storage target classification. +- Non-in-place finalization gating. +- Cutover policy decisions. +- Delta copy script planning. +- Chunked changed-range reads from the nbdkit Unix socket. +- RBD probe create/write/read/delete command planning. +- RBD finalization through temporary localhost `qemu-nbd` bridges. +- Fallback `virt-v2v` output path handling. +- Cleanup scoping to migration directories. +- Cleanup scoping to migration-owned RBD image names. +- Nested relative KVM volume path resolution under filesystem pools. + +## Operational Verification + +Useful host capability check: + +```bash +cmk list hosts name= details=all | egrep \ +'host.vddk.blockcopy.support|host.vddk.blockcopy.inplace.finalization.support|host.vddk.blockcopy.rbd.support|host.vddk.support|host.vddk.version|host.virtv2v.version|host.virtv2v.inplace.version|host.qemu.img.version|host.qemu.nbd.version|host.qemu.io.version|vddk.lib.dir' +``` + +Useful VDDK plugin check on the KVM host: + +```bash +nbdkit vddk --dump-plugin libdir=/opt/vmware-vix-disklib-distrib | \ + egrep 'vddk_library_version|vddk_dll|vddk_transport_modes' +``` + +Useful process check during initial sync: + +```bash +pgrep -af 'nbdkit|qemu-img|qemu-nbd|nbdcopy' +``` + +Useful target disk inspection: + +```bash +qemu-img info /mnt//cloudstack-cbt//.qcow2 +``` + +## References + +- [Apache CloudStack: Importing VMware VMs into KVM](https://docs.cloudstack.apache.org/en/latest/adminguide/virtual_machines/importing_vmware_vms_into_kvm.html) +- [Broadcom vSphere `QueryChangedDiskAreas` API](https://developer.broadcom.com/xapis/virtual-infrastructure-json-api/latest/sdk/vim25/release/VirtualMachine/moId/QueryChangedDiskAreas/post/) +- [Broadcom Virtual Disk API disk operations](https://developer.broadcom.com/xapis/virtual-disk-api/latest/vddkFunctions.6.4.html) +- [Broadcom VDDK CBT notes and limitations](https://developer.broadcom.com/xapis/virtual-disk-api/latest/vddkBkupVadp.9.5.html) +- [nbdkit VDDK plugin](https://libguestfs.org/nbdkit-vddk-plugin.1.html) diff --git a/docs/vmware-cbt/architecture.md b/docs/vmware-cbt/architecture.md new file mode 100644 index 000000000000..299a0d769afe --- /dev/null +++ b/docs/vmware-cbt/architecture.md @@ -0,0 +1,266 @@ + + +# VMware CBT migration architecture + +This document describes the implemented Apache CloudStack architecture for +warm VMware-to-KVM migration based on VMware Changed Block Tracking (CBT). +Detailed API, database, configuration, and operational information is in +[`README.md`](README.md). KVM host preparation is documented in +[`../vmware-cbt-migration.md`](../vmware-cbt-migration.md). + +## Design invariant + +The KVM-side replica remains byte-compatible with the VMware source disk until +the final CBT cycle has completed. Raw VMware CBT ranges are never applied to a +disk after guest conversion has changed its layout. + +The migration therefore has two distinct phases: + +1. Replication: copy the initial source-equivalent baseline and apply CBT delta + ranges to it. +2. Finalization: after the source VM is powered off, apply one final delta and + run `virt-v2v` locally against the current KVM-side replica. + +This avoids a second full VMware read during cutover. CloudStack does not power +off the source VM; shutdown remains an explicit operator action. + +## Components + +### Management server + +The API commands are: + +- `checkVmwareCbtMigrationPrerequisites` +- `startVmwareCbtMigration` +- `listVmwareCbtMigrations` +- `syncVmwareCbtMigration` +- `cutoverVmwareCbtMigration` +- `cancelVmwareCbtMigration` +- `deleteVmwareCbtMigration` + +`VmwareCbtMigrationManagerImpl` owns migration orchestration, host and storage +validation, state transitions, cutover policy, agent command dispatch, import, +and cleanup. `VmwareCbtMigrationServiceImpl` performs VMware inventory, +snapshot, power-state, CBT enablement, and changed-area operations. + +`VmwareCbtStorageTarget` classifies the selected primary storage and determines +the target representation and finalization constraints. + +### Persistence + +The feature persists its state in three CloudStack tables: + +- `cloud.vmware_cbt_migration`: one row per migration session. +- `cloud.vmware_cbt_migration_disk`: source and target metadata for each disk, + including the current per-disk VMware `changeId`. +- `cloud.vmware_cbt_migration_cycle`: metrics and status for each delta cycle. + +The schema and configuration entries are delivered through the standard +4.23.0.0-to-24.0.0 database upgrade path: + +- `engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql` +- `engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42300to2400.java` + +### KVM agent + +The management server sends these commands to the selected conversion host: + +- `VmwareCbtPrepareCommand`: create or resolve target disks and perform the + initial VDDK full copy. +- `VmwareCbtSyncCommand`: read and apply VMware CBT ranges. +- `VmwareCbtCutoverCommand`: finalize the current replica with `virt-v2v`. +- `VmwareCbtCleanupCommand`: terminate migration-owned processes and remove + migration-owned temporary artifacts. +- `VmwareCbtRbdProbeCommand`: actively validate RBD create/write/read/delete + access for the selected pool. + +The corresponding KVM wrappers are +`LibvirtVmwareCbtPrepareCommandWrapper`, +`LibvirtVmwareCbtSyncCommandWrapper`, +`LibvirtVmwareCbtCutoverCommandWrapper`, +`LibvirtVmwareCbtCleanupCommandWrapper`, and +`LibvirtVmwareCbtRbdProbeCommandWrapper`. + +### User interface + +The workflow is integrated into Import/Export Instances. The UI starts async +operations through the API and uses `listVmwareCbtMigrations` as the detailed +source for migration, disk, and cycle state. + +## End-to-end flow + +```text +VMware source VM + | + | preflight and source discovery + v +baseline VMware snapshot + | + | nbdkit-vddk full logical disk read + v +source-equivalent replica on KVM primary storage + | + | repeated VMware snapshots + QueryChangedDiskAreas + | changed ranges copied through VDDK and patched in place + v +ReadyForCutover + | + | operator powers off the VMware VM + | final CBT delta + | local virt-v2v finalization + v +CloudStack KVM import +``` + +### Preflight + +Preflight resolves the source, destination cluster, conversion host, and +primary storage. It validates source CBT state, source disks, snapshots, +storage support, host block-copy and in-place-finalization capabilities, RBD or +LINSTOR access where applicable, Windows conversion dependencies, and optional +compute-offering sizing. + +The selected conversion host reports capability through host details. The +principal gates are: + +- `host.vddk.blockcopy.support` +- `host.vddk.blockcopy.inplace.finalization.support` +- `host.vddk.blockcopy.rbd.support` +- `vddk.lib.dir` +- `host.vddk.version` +- qemu and `virt-v2v` version details + +### Initial synchronization + +Start creates the migration and disk records, enables CBT when required, +creates a baseline VMware snapshot, prepares destination targets, and dispatches +`VmwareCbtPrepareCommand`. + +The KVM agent exposes each snapshot disk with `nbdkit-vddk`. It uses +`qemu-img convert` for the general copy path and can use `nbdcopy` as an +optional accelerator for supported pre-created or NBD-bridged targets. The +result remains source-equivalent; `virt-v2v` is not run during initial sync. + +After a successful copy, CloudStack records the baseline per-disk `changeId` +and removes the baseline snapshot when possible. + +### Delta synchronization + +Each synchronization cycle: + +1. Creates a VMware snapshot. +2. Calls VMware `QueryChangedDiskAreas` from the previous per-disk `changeId`. +3. Persists the changed ranges and cycle state. +4. Opens the snapshot disk through `nbdkit-vddk` on the KVM host. +5. Copies only the changed ranges and writes them at their original byte + offsets with `qemu-io`. +6. Records changed bytes, duration, dirty rate, and the new `changeId`. +7. Removes the cycle snapshot when possible. + +`VmwareCbtMigrationCutoverPolicy` evaluates the configured minimum, maximum, +and quiet-cycle thresholds and moves the migration to `ReadyForCutover` when +the policy boundary is reached. + +### Cutover + +Cutover is accepted only when the source VM is powered off. CloudStack runs one +final CBT cycle, then finalizes the current KVM-side replica with one of these +paths: + +- `virt-v2v-in-place`; +- `virt-v2v --in-place`; or +- regular `virt-v2v -o local` for QCOW2 file targets only, when the explicit + non-in-place fallback configuration is enabled. + +After finalization, CloudStack imports the disks through the shared external +KVM import path. A successful import sets the migration to `Completed`. If +finalization succeeds but import fails, the migration remains `ReadyForImport` +and a retry repeats only the import stage. + +## Storage targets + +| Primary storage | Replica | Initial copy | Delta writes | Finalization | +| --- | --- | --- | --- | --- | +| NFS, filesystem, shared mount | QCOW2 file | `qemu-img` | `qemu-io -f qcow2` | In-place preferred; explicitly gated local fallback supported | +| Ceph/RBD | Raw RBD image | Direct qemu RBD path or `nbdcopy` through a local NBD bridge | `qemu-io -f raw` | In-place only | +| LINSTOR | Pre-created raw DRBD block device | `nbdcopy` when available, otherwise `qemu-img` | `qemu-io -f raw` | In-place only | + +For filesystem targets, temporary replicas are stored under a migration-owned +`cloudstack-cbt//` directory. RBD and LINSTOR resources use +CloudStack-owned migration markers. Cleanup is restricted to these owned paths +and resource names. + +## State model + +Migration states: + +```text +Created + -> InitialSync + -> Replicating + -> ReadyForCutover + -> CuttingOver + -> ReadyForImport + -> Completed +``` + +`Failed` and `Cancelled` are terminal states. `ReadyForImport` is deliberately +retryable. + +Disk states are `Created`, `Prepared`, `Syncing`, `Ready`, and `Failed`. +Cycle states are `Created`, `QueryingChangedAreas`, `CopyingChangedBlocks`, +`Completed`, and `Failed`. + +## Concurrency and cleanup + +Sync and cutover commands are serialized by migration ID. Cancellation sends a +best-effort agent cleanup command before marking a non-terminal migration as +cancelled. Failed and cancelled migrations can delete their migration-owned +temporary replicas when cleanup is requested. + +Deleting a completed migration is record-only. It removes CBT bookkeeping but +does not delete the imported VM, its CloudStack volumes, or finalized primary +storage artifacts. + +VMware snapshots are removed after initial and delta attempts when possible. +Snapshot identifiers and cleanup errors remain visible in migration state so +operators can identify VMware-side cleanup work. + +## Credential handling + +Existing registered vCenter credentials are resolved from CloudStack's VMware +datacenter record. External-source credentials are accepted by sensitive API +commands and the password is stored through CloudStack's encrypted DAO field +handling until the migration no longer needs it. + +On the KVM host, the password is written to a temporary owner-only file and +passed to `nbdkit` with `password=+`. It is not placed directly in the +process command line. Returned errors are sanitized before persistence. + +## Operational boundaries + +- The operator is responsible for shutting down the source VM before cutover. +- Initial baseline transfer size depends on the extents exposed by VMware VDDK + and the source datastore; CBT optimization applies to subsequent cycles. +- Invalidated VMware CBT state requires a new baseline migration. +- RBD and LINSTOR require in-place finalization and active backend access from + the selected conversion host. +- Windows guests require a usable `virtio-win` driver bundle on that host. +- The source VMware VM remains authoritative until cutover succeeds. diff --git a/docs/vmware-linstor-migration.md b/docs/vmware-linstor-migration.md new file mode 100644 index 000000000000..bf3f8b69b20e --- /dev/null +++ b/docs/vmware-linstor-migration.md @@ -0,0 +1,238 @@ + + +# Linstor primary storage as a destination for VMware-to-KVM migration + +This document describes the Linstor-destination portion of the VMware-to-KVM +migration work, in enough detail for LINSTOR/DRBD maintainers to review the +design choices and advise on Linstor-side behavior. + +## Summary + +This change lets Linstor (DRBD) primary storage be the **destination** for +VMware-to-KVM instance imports, across all three migration modes CloudStack +offers for Ceph/RBD: + +- **Cold, staged** — convert with virt-v2v to a temporary NFS location, then + copy the finalized disks into Linstor. +- **Cold, direct (VDDK)** — read each disk over nbdkit/VDDK straight into the + DRBD device and finalize it in place, with no NFS staging. +- **Warm (CBT)** — replicate the running VM with an initial full sync plus + incremental changed-block cycles into the DRBD device, then finalize in + place at cutover. + +Before this change Linstor was blocked everywhere in the import/conversion +path by NFS-only (and, more recently, RBD-only) assumptions. The work +generalizes those code paths rather than special-casing Linstor: a new +`RAW_BLOCK_DEVICE` target type covers "write RAW into a host-local block +device provided by the storage adaptor", which also lays the groundwork for +other block backends (PowerFlex, FiberChannel, StorPool) later. + +## Background and motivation + +CloudStack can import VMware VMs onto KVM by converting them with virt-v2v +(optionally reading disks over the VMware VDDK). The converted disks have to +land on KVM primary storage. That path historically supported only +filesystem-style pools (NFS/Filesystem/SharedMountPoint); the companion +commits in this series add Ceph/RBD as a destination and a full VMware +**CBT warm-migration** framework. Linstor — a very common CloudStack block +backend — was still unsupported as a conversion destination. + +Linstor differs from RBD in ways that shaped the design: + +- A Linstor volume is a **local block device** (`/dev/drbd/by-res//0`), + which is the universal abstraction the whole toolchain (qemu-img, qemu-io, + virt-v2v-in-place) already consumes — so finalization is actually *simpler* + than RBD (no qemu-nbd credential bridge needed; a plain + `` works). +- Unlike RBD, `qemu-img convert` **cannot create** the device — the resource + must be pre-created at the source capacity through the storage adaptor, + then written with `qemu-img convert -n` (no-create). +- LINSTOR resource names are capped at ~48 characters and cannot be renamed, + so the RBD-style naming carrying a full migration UUID does not fit; short, + deterministic names are used and imported volumes keep the name as their + recorded volume path. + +## What this change does + +### New generic block-device target type +`VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE`, alongside the existing +`QCOW2_FILE` and `RBD_RAW`. Linstor pools classify to it and require in-place +finalization (the qcow2 fallback cannot write to a device). + +### Server-side (orchestration) +- Linstor added to the staged-conversion destination pool types, the direct + VDDK-convert allow-list, and the CBT-compatible pool types, for both + explicit and implicit destination-pool selection. +- Conversion/import host selection validates that the host is a LINSTOR + satellite connected to the pool (in addition to the VDDK / in-place + virt-v2v capability checks), for auto-selection and explicit host IDs. +- `CheckConvertInstanceCommand` carries a new in-place-finalization capability + check so unsupported hosts fail fast with a clear message. +- The powered-off, non-cloned source requirement (already enforced for direct + RBD) now applies to any direct block-storage import. + +### KVM agent-side (data plane) +- **Staged**: the converted-disk move already dispatches per pool type to + `LinstorStorageAdaptor.copyPhysicalDisk` (qemu-img into the DRBD device); + converted-disk metadata for Linstor reports the pool UUID and volume name + instead of parsing an NFS mount. +- **Direct VDDK**: pre-creates each resource at source capacity via the + adaptor, then `nbdkit vddk --run 'qemu-img convert -n ... '`, and + finalizes with `virt-v2v-in-place` fed a `` domain XML. +- **Warm CBT**: initial sync pre-creates the device and `qemu-img convert -n` + into it; delta cycles patch changed extents; cutover finalizes in place + with block-device XML (no qemu-nbd bridges). Cleanup deletes only + marker-guarded volumes. +- `LinstorStorageAdaptor.listPhysicalDisks` is implemented (resource + definitions filtered by the pool's resource group), replacing the previous + `UnsupportedOperationException`, so the forced-conversion import short + circuit works for Linstor. + +### Performance: nbdcopy for full-disk copies to block devices +The cold direct-VDDK import and the CBT initial full sync copy an entire disk +from the nbdkit/VDDK source into the target. For a local raw block-device +target (Linstor/DRBD), this now uses `nbdcopy` (libnbd) when the host has it, +falling back to `qemu-img convert`. nbdcopy keeps many requests in flight +(it is what virt-v2v itself uses for this step) and is typically faster than a +single-connection `qemu-img convert`. Availability is probed locally on the +agent; it is a pure optimization with automatic fallback, so no server-side +gating is needed. Only block-device targets are affected — the RBD URI target +and qcow2 file targets keep qemu-img convert (nbdcopy is raw-only and cannot +address an rbd: URI without a bridge). On a 16 GiB disk over network NBD +transport it measured ~20% faster than qemu-img convert in the lab, and the +result was byte-identical to a full VDDK read of the source. + +### Performance: direct CBT delta copy for block devices +Previously a CBT delta copied each changed extent in two hops — qemu-img the +nbd source window into a temporary raw file, then qemu-io write that file into +the target. For a raw block-device target that doubles local I/O and needs +scratch space. This change streams each changed extent from the nbd source +window straight into the same device window in a single +`qemu-img convert -n -S 0 --image-opts --target-image-opts`. `-S 0` disables +zero/sparse skipping so blocks the source cleared to zero are actually +overwritten in the target — a full copy can skip zeros because the target +starts zeroed, but a delta into an already-populated device cannot. The +qcow2-file and RBD paths are unchanged. + +## Migration support matrix (Linstor destination) + +| Mode | Trigger | Source VM state | Data movement | Guest downtime | +| --- | --- | --- | --- | --- | +| Cold, staged | `importVm` (no `forceconverttopool`) | Running (auto-cloned on vSphere) or stopped | virt-v2v -> qcow2 on NFS temp -> qemu-img copy to DRBD device | Offline copy (running source undisturbed via clone) | +| Cold, direct VDDK | `importVm usevddk=true forceconverttopool=true` -> Linstor pool | Stopped only (non-cloned) | nbdkit/VDDK -> qemu-img convert -n into DRBD device -> virt-v2v-in-place | Whole migration (VM is off) | +| Warm, CBT | `startVmwareCbtMigration` -> sync cycles -> `cutoverVmwareCbtMigration` | Running | Initial full sync + incremental CBT deltas into DRBD device; final delta + in-place finalize at cutover | Minimal (only the final cutover delta) | + +Notes: +- Warm CBT does **not** power off the source automatically. The operator + gracefully shuts down the VMware VM, then triggers cutover; the code + rejects cutover on a running VM. This is deliberate — never auto-kill a + production VM. +- Direct VDDK requires a powered-off, non-cloned source. +- Imported Linstor volumes are recorded QCOW2 in the DB (Linstor convention; + RAW on the device). + +## How to use + +Cold, direct VDDK into Linstor (source powered off): + + import vm zoneid= clusterid= \ + importsource=vmware hypervisor=KVM \ + vcenter= datacentername= username= password=

\ + clustername= host= \ + name= displayname= serviceofferingid= \ + usevddk=true forceconverttopool=true \ + convertinstancepoolid= \ + nicnetworklist[0].nic="Network adapter 1" nicnetworklist[0].network= \ + datadiskofferinglist[0].disk= datadiskofferinglist[0].diskOffering= + +Warm CBT into Linstor: `startVmwareCbtMigration` with the Linstor pool as +`storagepoolid`, let the delta cycles run, power off the source, then +`cutoverVmwareCbtMigration`. + +(The web import wizard fills the NIC/disk mappings from the discovered VM, so +the UI is the easiest way to drive it.) + +## Host prerequisites + +For any Linstor destination the conversion host must be a LINSTOR satellite +connected to the pool (its `hostname` must match its LINSTOR node name). +Direct-VDDK and warm-CBT additionally need VDDK, an nbdkit VDDK plugin, and +in-place virt-v2v (`virt-v2v-in-place` or `virt-v2v --in-place`). No extra +qemu block-driver is needed (writes go to a local device). Prefer a diskful +satellite as the conversion host so writes are local rather than traversing +the DRBD replication network. + +## Backwards compatibility + +- No behavior change for existing NFS or RBD destinations; Linstor is added + to the same gate lists and dispatch branches. +- The CBT-delta optimization only affects `RAW_BLOCK_DEVICE` targets; qcow2 + and RBD keep their existing code paths. +- One host-capability check flag was added to `CheckConvertInstanceCommand` + (old constructor preserved, null-tolerant). + +## Testing + +- **Unit tests**: full server + KVM-plugin suites pass, including new + Linstor-specific tests across the gate lists, host selection, the + convert/import/CBT wrappers, `listPhysicalDisks`, and the block-device + delta script. +- **Data plane, real 3-node LINSTOR/DRBD lab, real VMware source**: + - Cold direct VDDK: a real VM's disks read over VDDK into DRBD devices + + in-place finalized; `qemu-img compare` against the source = identical. + - Warm CBT: baseline + delta + power-off + final delta + finalize; + integrity verified (file checksum inside the migrated volume matched the + source, and `qemu-img compare` of a delta-applied device against a full + read of the snapshot = identical). + - CBT-delta optimization: 144 real changed extents applied via the new + direct-copy script; `qemu-img compare` = images identical. +- **Full end-to-end through CloudStack**: `importVm` (usevddk + + forceconverttopool) against a real vCenter produced a Stopped CloudStack VM + with its root and data disks as Linstor volumes on the pool, each backed by + a DRBD resource UpToDate on all three nodes. + +## Known limitations / possible follow-ups + +- Full-disk copies into block devices use nbdcopy when present (see above). + RBD URI and qcow2 file targets still use qemu-img convert; extending + nbdcopy to RBD would need a qemu-nbd bridge (nbdcopy is raw-device/file + only), which is a possible follow-up. +- The conversion host is usually not ESXi-adjacent, so VDDK typically uses + network (NBD/NBDSSL) transport rather than the faster HotAdd; largely + inherent to the KVM conversion-host model. +- A diskful conversion host is recommended but not enforced; a diskless + satellite makes every write traverse the replication network. +- The staged path keeps its extra NFS hop (needed for running-VM clones and + hosts without in-place support). + +## Relationship to the RBD series + +The Linstor support is layered on the VMware-to-KVM RBD series in this same +change set (direct VDDK-to-RBD imports, the CBT warm-migration framework, +importVm RBD adoption, and the consolidated host-capability probe names). +The Linstor-specific commits, in order: + +- KVM: allow staged VMware instance imports into Linstor primary storage +- KVM: support direct VDDK VMware imports into Linstor primary storage +- VMware CBT warm migration to KVM with Linstor destination storage +- Document Linstor destination support for VMware-to-KVM imports +- Drop unsupported -O option from virt-v2v-in-place invocations +- KVM: stream VMware CBT block-device deltas directly, without a temp file +- KVM: use nbdcopy for full-disk copies into Linstor block devices when available diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java index 141596407fe8..8614197347aa 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java @@ -183,10 +183,12 @@ List allocateTemplatedVolumes(Type type, String name, DiskOffering */ DiskProfile importVolume(Type type, String name, DiskOffering offering, Long sizeInBytes, Long minIops, Long maxIops, Long zoneId, HypervisorType hypervisorType, VirtualMachine vm, VirtualMachineTemplate template, - Account owner, Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo); + Account owner, Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo, + Storage.ImageFormat format); DiskProfile updateImportedVolume(Type type, DiskOffering offering, VirtualMachine vm, VirtualMachineTemplate template, - Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo, DiskProfile diskProfile); + Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo, DiskProfile diskProfile, + Storage.ImageFormat format); /** * Unmanage VM volumes diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/PrimaryDataStoreDriver.java b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/PrimaryDataStoreDriver.java index d908ef6af508..9788b6c2dd14 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/PrimaryDataStoreDriver.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/PrimaryDataStoreDriver.java @@ -31,6 +31,13 @@ import com.cloud.utils.Pair; public interface PrimaryDataStoreDriver extends DataStoreDriver { + /** Validate backend ownership before adopting an existing volume, without modifying it. */ + default void validateVolumeForImport(StoragePool pool, String path) { + if (pool.getPoolType() == StoragePoolType.Linstor) { + throw new UnsupportedOperationException("The storage driver cannot validate LINSTOR volume imports"); + } + } + enum QualityOfServiceState { MIGRATION, NO_MIGRATION } String BASIC_CREATE = "basicCreate"; diff --git a/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java b/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java index 032fcbe76dce..962bea638d0f 100644 --- a/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java +++ b/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java @@ -443,4 +443,8 @@ void connectHostsToPool(DataStore primaryStore, List hostIds, Scope scope, String[] getStorageAccessGroups(Long zoneId, Long podId, Long clusterId, Long hostId); CapacityVO getObjectStorageUsedStats(Long zoneId); + + static ConfigKey getMountDisabledStoragePool() { + return MountDisabledStoragePool; + } } diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index d3225f2fda5e..9eab15214aeb 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java @@ -817,8 +817,21 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi String vddkSupport = detailsMap.get(Host.HOST_VDDK_SUPPORT); String vddkLibDir = detailsMap.get(Host.HOST_VDDK_LIB_DIR); String vddkVersion = detailsMap.get(Host.HOST_VDDK_VERSION); + String vmwareCbtSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_SUPPORT); + String vmwareCbtInPlaceFinalizationSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT); + String vmwareCbtRbdSupport = detailsMap.get(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT); + String qemuImgVersion = detailsMap.get(Host.HOST_QEMU_IMG_VERSION); + String qemuNbdVersion = detailsMap.get(Host.HOST_QEMU_NBD_VERSION); + String qemuIoVersion = detailsMap.get(Host.HOST_QEMU_IO_VERSION); + String virtV2vInPlaceVersion = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_VERSION); + String virtv2vInPlaceSupport = detailsMap.get(Host.HOST_VIRTV2V_INPLACE_SUPPORT); + String qemuImgRbdSupport = detailsMap.get(Host.HOST_QEMU_RBD_SUPPORT); + String vddkRbdDirectImportSupport = detailsMap.get(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT); logger.debug("Got HOST_UEFI_ENABLE [{}] for host [{}]:", uefiEnabled, host); - if (ObjectUtils.anyNotNull(uefiEnabled, diskOnlyVmSnapshotNvramSupport, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion)) { + if (ObjectUtils.anyNotNull(uefiEnabled, diskOnlyVmSnapshotNvramSupport, virtv2vVersion, ovftoolVersion, vddkSupport, vddkLibDir, vddkVersion, + vmwareCbtSupport, vmwareCbtInPlaceFinalizationSupport, vmwareCbtRbdSupport, qemuImgVersion, qemuNbdVersion, qemuIoVersion, virtV2vInPlaceVersion, + virtv2vInPlaceSupport, qemuImgRbdSupport, vddkRbdDirectImportSupport)) { + _hostDao.loadDetails(host); boolean updateNeeded = false; if (syncBooleanHostCapability(host, Host.HOST_UEFI_ENABLE, uefiEnabled)) { updateNeeded = true; @@ -854,6 +867,64 @@ protected AgentAttache notifyMonitorsOfConnection(final AgentAttache attache, fi } updateNeeded = true; } + if (StringUtils.isNotBlank(vmwareCbtSupport) && !vmwareCbtSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_SUPPORT, vmwareCbtSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtInPlaceFinalizationSupport) && + !vmwareCbtInPlaceFinalizationSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, vmwareCbtInPlaceFinalizationSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vmwareCbtRbdSupport) && + !vmwareCbtRbdSupport.equals(host.getDetails().get(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, vmwareCbtRbdSupport); + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuImgVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_IMG_VERSION)))) { + if (StringUtils.isBlank(qemuImgVersion)) { + host.getDetails().remove(Host.HOST_QEMU_IMG_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_IMG_VERSION, qemuImgVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuNbdVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_NBD_VERSION)))) { + if (StringUtils.isBlank(qemuNbdVersion)) { + host.getDetails().remove(Host.HOST_QEMU_NBD_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_NBD_VERSION, qemuNbdVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(qemuIoVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_QEMU_IO_VERSION)))) { + if (StringUtils.isBlank(qemuIoVersion)) { + host.getDetails().remove(Host.HOST_QEMU_IO_VERSION); + } else { + host.getDetails().put(Host.HOST_QEMU_IO_VERSION, qemuIoVersion); + } + updateNeeded = true; + } + if (!StringUtils.defaultString(virtV2vInPlaceVersion).equals(StringUtils.defaultString(host.getDetails().get(Host.HOST_VIRTV2V_INPLACE_VERSION)))) { + if (StringUtils.isBlank(virtV2vInPlaceVersion)) { + host.getDetails().remove(Host.HOST_VIRTV2V_INPLACE_VERSION); + } else { + host.getDetails().put(Host.HOST_VIRTV2V_INPLACE_VERSION, virtV2vInPlaceVersion); + } + updateNeeded = true; + } + if (StringUtils.isNotBlank(virtv2vInPlaceSupport) && !virtv2vInPlaceSupport.equals(host.getDetails().get(Host.HOST_VIRTV2V_INPLACE_SUPPORT))) { + host.getDetails().put(Host.HOST_VIRTV2V_INPLACE_SUPPORT, virtv2vInPlaceSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(qemuImgRbdSupport) && !qemuImgRbdSupport.equals(host.getDetails().get(Host.HOST_QEMU_RBD_SUPPORT))) { + host.getDetails().put(Host.HOST_QEMU_RBD_SUPPORT, qemuImgRbdSupport); + updateNeeded = true; + } + if (StringUtils.isNotBlank(vddkRbdDirectImportSupport) && !vddkRbdDirectImportSupport.equals(host.getDetails().get(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT))) { + host.getDetails().put(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, vddkRbdDirectImportSupport); + updateNeeded = true; + } if (updateNeeded) { _hostDao.saveDetails(host); } diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java index 8af75562b31c..0b28d070ae25 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java @@ -2163,13 +2163,26 @@ public void finalizeUpdateInSequence(Network network, boolean success) { } } + private Map batchLoadNetworksForNics(List nics) { + List networkIds = nics.stream().map(NicVO::getNetworkId).distinct().collect(Collectors.toList()); + Map result = new HashMap<>(networkIds.size()); + for (NetworkVO network : _networksDao.listByIds(networkIds)) { + result.put(network.getId(), network); + } + return result; + } + @Override public void setHypervisorHostname(VirtualMachineProfile vm, DeployDestination dest, boolean migrationSuccessful) throws ResourceUnavailableException { String hypervisorHostName = VirtualMachineManager.getHypervisorHostname(dest.getHost().getName()); if (StringUtils.isNotEmpty(hypervisorHostName)) { final List nics = _nicDao.listByVmId(vm.getId()); + final Map networksById = batchLoadNetworksForNics(nics); for (final NicVO nic : nics) { - final NetworkVO network = _networksDao.findById(nic.getNetworkId()); + final NetworkVO network = networksById.get(nic.getNetworkId()); + if (network == null) { + continue; + } final Integer networkRate = _networkModel.getNetworkRate(network.getId(), vm.getId()); final NicProfile profile = new NicProfile(nic, network, nic.getBroadcastUri(), nic.getIsolationUri(), networkRate, _networkModel.isSecurityGroupSupportedInNetwork(network), _networkModel.getNetworkTag(vm.getHypervisorType(), network)); @@ -2344,8 +2357,13 @@ public void prepareNicForMigration(final VirtualMachineProfile vm, final DeployD } final List nics = _nicDao.listByVmId(vm.getId()); final ReservationContext context = new ReservationContextImpl(UUID.randomUUID().toString(), null, null); + final Map networksById = batchLoadNetworksForNics(nics); for (final NicVO nic : nics) { - final NetworkVO network = _networksDao.findById(nic.getNetworkId()); + final NetworkVO network = networksById.get(nic.getNetworkId()); + if (network == null) { + logger.warn("Network {} not found for nic {} during migration prep, skipping", nic.getNetworkId(), nic); + continue; + } final Integer networkRate = _networkModel.getNetworkRate(network.getId(), vm.getId()); final NetworkGuru guru = AdapterBase.getAdapterByName(networkGurus, network.getGuruName()); @@ -2380,6 +2398,11 @@ public void prepareNicForMigration(final VirtualMachineProfile vm, final DeployD } } + private boolean isReservedForExternalProvider(Long vlanId) { + VlanDetailsVO vlanDetail = vlanDetailsDao.findDetail(vlanId, ApiConstants.NETRIS_DETAIL_KEY); + return vlanDetail != null && BooleanUtils.toBoolean(vlanDetail.getValue()); + } + /* Prepare All Nics for migration including the nics dynamically created and not stored in DB This is a temporary workaround work KVM migration @@ -2389,9 +2412,14 @@ public void prepareNicForMigration(final VirtualMachineProfile vm, final DeployD public void prepareAllNicsForMigration(final VirtualMachineProfile vm, final DeployDestination dest) { final List nics = _nicDao.listByVmId(vm.getId()); final ReservationContext context = new ReservationContextImpl(UUID.randomUUID().toString(), null, null); + final Map networksById = batchLoadNetworksForNics(nics); Long guestNetworkId = null; for (final NicVO nic : nics) { - final NetworkVO network = _networksDao.findById(nic.getNetworkId()); + final NetworkVO network = networksById.get(nic.getNetworkId()); + if (network == null) { + logger.warn("Network {} not found for nic {} during migration prep, skipping", nic.getNetworkId(), nic); + continue; + } if (network.getTrafficType().equals(TrafficType.Guest) && network.getGuestType().equals(GuestType.Isolated)) { guestNetworkId = network.getId(); } @@ -2428,6 +2456,10 @@ public void prepareAllNicsForMigration(final VirtualMachineProfile vm, final Dep final List publicIps = _ipAddressDao.listByAssociatedNetwork(guestNetworkId, null); for (final IPAddressVO userIp : publicIps) { final PublicIp publicIp = PublicIp.createFromAddrAndVlan(userIp, _vlanDao.findById(userIp.getVlanId())); + if (isReservedForExternalProvider(publicIp.getVlanId())) { + logger.debug("Skipping public IP {} for migration NIC profile: managed by external network provider", publicIp.getAddress()); + continue; + } final URI broadcastUri = BroadcastDomainType.Vlan.toUri(publicIp.getVlanTag()); final long ntwkId = publicIp.getNetworkId(); final Nic nic = _nicDao.findByNetworkIdInstanceIdAndBroadcastUri(ntwkId, vm.getId(), @@ -3270,7 +3302,7 @@ private boolean hasGuestBypassVlanOverlapCheck(final boolean bypassVlanOverlapCh /** * Checks for L2 network offering services. Only 2 cases allowed: * - No services - * - User Data service only, provided by ConfigDrive + * - UserData service only, provided by ConfigDrive * * @param ntwkOff network offering */ diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index 2fad96ec1da2..2b9b41b6e8ac 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -1335,6 +1335,20 @@ private ImageFormat getSupportedImageFormatForCluster(HypervisorType hyperType) } } + /** + * Returns the image format for a volume taking the underlying storage pool type into account. + * On KVM the default cluster format is QCOW2, but block-based pools such as RBD (Ceph) and + * Linstor (DRBD) store volumes as RAW images. This is relevant when importing/adopting an + * existing volume so the recorded format matches what is actually on the pool. + */ + protected ImageFormat getSupportedImageFormatForCluster(HypervisorType hyperType, Storage.StoragePoolType poolType) { + if (hyperType == HypervisorType.KVM + && (poolType == Storage.StoragePoolType.RBD || poolType == Storage.StoragePoolType.Linstor)) { + return ImageFormat.RAW; + } + return getSupportedImageFormatForCluster(hyperType); + } + private boolean isSupportedImageFormatForCluster(VolumeInfo volume, HypervisorType rootDiskHyperType) { ImageFormat volumeFormat = volume.getFormat(); if (rootDiskHyperType == HypervisorType.Hyperv) { @@ -2626,7 +2640,8 @@ public void updateVolumeDiskChain(long volumeId, String path, String chainInfo, @Override public DiskProfile importVolume(Type type, String name, DiskOffering offering, Long sizeInBytes, Long minIops, Long maxIops, Long zoneId, HypervisorType hypervisorType, VirtualMachine vm, VirtualMachineTemplate template, Account owner, - Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo) { + Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo, + ImageFormat format) { if (sizeInBytes == null) { sizeInBytes = offering.getDiskSize(); } @@ -2665,7 +2680,9 @@ public DiskProfile importVolume(Type type, String name, DiskOffering offering, L vol.setDisplayVolume(userVm.isDisplayVm()); } - vol.setFormat(getSupportedImageFormatForCluster(hypervisorType)); + // The format the hypervisor actually reported for the existing image wins; pools such as RBD + // hold raw images even though QCOW2 is the cluster default for KVM. + vol.setFormat(format != null ? format : getSupportedImageFormatForCluster(hypervisorType, poolType)); vol.setPoolId(poolId); vol.setPoolType(poolType); vol.setPath(path); @@ -2677,7 +2694,8 @@ public DiskProfile importVolume(Type type, String name, DiskOffering offering, L @Override public DiskProfile updateImportedVolume(Type type, DiskOffering offering, VirtualMachine vm, VirtualMachineTemplate template, - Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo, DiskProfile diskProfile) { + Long deviceId, Long poolId, Storage.StoragePoolType poolType, String path, String chainInfo, DiskProfile diskProfile, + ImageFormat format) { VolumeVO vol = _volsDao.findById(diskProfile.getVolumeId()); if (vm != null) { @@ -2709,7 +2727,9 @@ public DiskProfile updateImportedVolume(Type type, DiskOffering offering, Virtua vol.setDisplayVolume(userVm.isDisplayVm()); } - vol.setFormat(getSupportedImageFormatForCluster(vm.getHypervisorType())); + // The format the hypervisor actually reported for the existing image wins; pools such as RBD + // hold raw images even though QCOW2 is the cluster default for KVM. + vol.setFormat(format != null ? format : getSupportedImageFormatForCluster(vm.getHypervisorType(), poolType)); vol.setPoolId(poolId); vol.setPoolType(poolType); vol.setPath(path); diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java index d61caf16ae5d..04e80212e8cc 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java @@ -178,6 +178,31 @@ public void testCheckAndUpdateVolumeAccountResourceCountLessSize() { runCheckAndUpdateVolumeAccountResourceCountTest(20L, 10L); } + @Test + public void testGetSupportedImageFormatForClusterKvmRbdIsRaw() { + Assert.assertEquals(Storage.ImageFormat.RAW, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.RBD)); + // Linstor (DRBD) volumes are also raw block devices. + Assert.assertEquals(Storage.ImageFormat.RAW, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.Linstor)); + } + + @Test + public void testGetSupportedImageFormatForClusterKvmNonRbdIsQcow2() { + Assert.assertEquals(Storage.ImageFormat.QCOW2, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.NetworkFilesystem)); + Assert.assertEquals(Storage.ImageFormat.QCOW2, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.KVM, Storage.StoragePoolType.Filesystem)); + } + + @Test + public void testGetSupportedImageFormatForClusterNonKvmIgnoresPoolType() { + Assert.assertEquals(Storage.ImageFormat.VHD, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.XenServer, Storage.StoragePoolType.RBD)); + Assert.assertEquals(Storage.ImageFormat.OVA, + volumeOrchestrator.getSupportedImageFormatForCluster(Hypervisor.HypervisorType.VMware, Storage.StoragePoolType.RBD)); + } + @Test public void testGrantVolumeAccessToHostIfNeededDriverNoNeed() { PrimaryDataStore store = Mockito.mock(PrimaryDataStore.class); @@ -246,7 +271,7 @@ public void testImportVolume() { volumeOrchestrator.importVolume(volumeType, name, diskOffering, sizeInBytes, null, null, zoneId, hypervisorType, null, null, owner, - deviceId, poolId, Storage.StoragePoolType.NetworkFilesystem, path, chainInfo); + deviceId, poolId, Storage.StoragePoolType.NetworkFilesystem, path, chainInfo, null); VolumeVO volume = volumeVOMockedConstructionConstruction.constructed().get(0); Mockito.verify(volume, Mockito.never()).setInstanceId(Mockito.anyLong()); diff --git a/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDao.java b/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDao.java index 7cdc2aacc3ba..6cbfadf7ac50 100644 --- a/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDao.java +++ b/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDao.java @@ -17,6 +17,7 @@ package com.cloud.configuration.dao; import java.util.List; +import java.util.Set; import com.cloud.configuration.Resource; import com.cloud.configuration.Resource.ResourceOwnerType; @@ -36,4 +37,14 @@ public interface ResourceLimitDao extends GenericDao { long removeEntriesByOwner(Long ownerId, ResourceOwnerType ownerType); void removeResourceLimitsForNonMatchingTags(Long ownerId, ResourceOwnerType ownerType, List types, List tags); + + /** + * Returns the explicit {@code resource_limit} rows owned by any of + * {@code domainIds} for the supplied ({@code type}, {@code tag}), + * regardless of their {@code max} value. Domains with no matching row + * are simply absent from the result — the caller resolves inheritance + * (nearest-ancestor lookup) and the tag -> untagged fallback itself, + * mirroring {@code ResourceLimitManagerImpl#findCorrectResourceLimitForDomain}. + */ + List listByDomainIdsAndTypeAndTag(Set domainIds, Resource.ResourceType type, String tag); } diff --git a/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDaoImpl.java b/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDaoImpl.java index 96523ba9bea3..c649b038d978 100644 --- a/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/configuration/dao/ResourceLimitDaoImpl.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.apache.commons.collections.CollectionUtils; @@ -38,6 +39,8 @@ public class ResourceLimitDaoImpl extends GenericDaoBase private SearchBuilder IdTypeTagSearch; private SearchBuilder IdTypeNullTagSearch; private SearchBuilder NonMatchingTagsSearch; + private SearchBuilder DomainsLimitTagSearch; + private SearchBuilder DomainsLimitNullTagSearch; public ResourceLimitDaoImpl() { IdTypeTagSearch = createSearchBuilder(); @@ -60,6 +63,18 @@ public ResourceLimitDaoImpl() { NonMatchingTagsSearch.and("tagNotNull", NonMatchingTagsSearch.entity().getTag(), SearchCriteria.Op.NNULL); NonMatchingTagsSearch.and("tags", NonMatchingTagsSearch.entity().getTag(), SearchCriteria.Op.NIN); NonMatchingTagsSearch.done(); + + DomainsLimitTagSearch = createSearchBuilder(); + DomainsLimitTagSearch.and("type", DomainsLimitTagSearch.entity().getType(), SearchCriteria.Op.EQ); + DomainsLimitTagSearch.and("domainIds", DomainsLimitTagSearch.entity().getDomainId(), SearchCriteria.Op.IN); + DomainsLimitTagSearch.and("tag", DomainsLimitTagSearch.entity().getTag(), SearchCriteria.Op.EQ); + DomainsLimitTagSearch.done(); + + DomainsLimitNullTagSearch = createSearchBuilder(); + DomainsLimitNullTagSearch.and("type", DomainsLimitNullTagSearch.entity().getType(), SearchCriteria.Op.EQ); + DomainsLimitNullTagSearch.and("domainIds", DomainsLimitNullTagSearch.entity().getDomainId(), SearchCriteria.Op.IN); + DomainsLimitNullTagSearch.and("tag", DomainsLimitNullTagSearch.entity().getTag(), SearchCriteria.Op.NULL); + DomainsLimitNullTagSearch.done(); } @Override @@ -150,4 +165,20 @@ public void removeResourceLimitsForNonMatchingTags(Long ownerId, ResourceOwnerTy } remove(sc); } + + @Override + public List listByDomainIdsAndTypeAndTag(Set domainIds, ResourceType type, String tag) { + if (CollectionUtils.isEmpty(domainIds)) { + return new ArrayList<>(); + } + SearchCriteria sc = (tag != null) + ? DomainsLimitTagSearch.create() + : DomainsLimitNullTagSearch.create(); + sc.setParameters("type", type); + sc.setParameters("domainIds", domainIds.toArray()); + if (tag != null) { + sc.setParameters("tag", tag); + } + return listBy(sc); + } } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java index b656d896e37f..0a5c348d5bf8 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java @@ -33,6 +33,8 @@ public interface NetworkDao extends GenericDao, StateDao listByOwner(long ownerId); + List listByIds(List ids); + List listByGuestType(GuestType type); List listBy(long accountId, long offeringId, long dataCenterId); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java index fbf6a2ac2261..14d24260179a 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java @@ -564,6 +564,19 @@ public List listByOwner(final long ownerId) { return listBy(sc); } + @Override + public List listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return new ArrayList<>(); + } + SearchBuilder sb = createSearchBuilder(); + sb.and("id", sb.entity().getId(), Op.IN); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("id", ids.toArray()); + return listBy(sc); + } + @Override public void addDomainToNetwork(final long networkId, final long domainId, final Boolean subdomainAccess) { addDomainToNetworknetwork(networkId, domainId, subdomainAccess); diff --git a/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java b/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java index 57ae2c3d6491..42f1cdf0b705 100644 --- a/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java +++ b/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java @@ -91,10 +91,10 @@ import com.cloud.upgrade.dao.Upgrade42020to42030; import com.cloud.upgrade.dao.Upgrade42030to42040; import com.cloud.upgrade.dao.Upgrade42040to42100; -import com.cloud.upgrade.dao.Upgrade420to421; import com.cloud.upgrade.dao.Upgrade42100to42200; -import com.cloud.upgrade.dao.Upgrade421to430; import com.cloud.upgrade.dao.Upgrade42200to42210; +import com.cloud.upgrade.dao.Upgrade420to421; +import com.cloud.upgrade.dao.Upgrade421to430; import com.cloud.upgrade.dao.Upgrade42210to42300; import com.cloud.upgrade.dao.Upgrade42300to2400; import com.cloud.upgrade.dao.Upgrade430to440; @@ -249,6 +249,7 @@ public DatabaseUpgradeChecker() { .next("4.21.0.0", new Upgrade42100to42200()) .next("4.22.0.0", new Upgrade42200to42210()) .next("4.22.1.0", new Upgrade42210to42300()) + .next("4.22.2.0", new Upgrade42300to2400()) .next("4.23.0.0", new Upgrade42300to2400()) .build(); } diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java new file mode 100644 index 000000000000..78e492a434d9 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationCycleVO.java @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigrationCycle; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "vmware_cbt_migration_cycle") +public class VmwareCbtMigrationCycleVO implements VmwareCbtMigrationCycle { + + public VmwareCbtMigrationCycleVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationCycleVO(long migrationId, int cycleNumber) { + this(); + this.migrationId = migrationId; + this.cycleNumber = cycleNumber; + this.state = State.Created; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "migration_id") + private long migrationId; + + @Column(name = "cycle_number") + private int cycleNumber; + + @Column(name = "snapshot_moref") + private String snapshotMor; + + @Column(name = "changed_bytes") + private Long changedBytes; + + @Column(name = "dirty_rate") + private Long dirtyRate; + + @Column(name = "duration") + private Long duration; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "description") + private String description; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getMigrationId() { + return migrationId; + } + + public int getCycleNumber() { + return cycleNumber; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public Long getChangedBytes() { + return changedBytes; + } + + public void setChangedBytes(Long changedBytes) { + this.changedBytes = changedBytes; + } + + public Long getDirtyRate() { + return dirtyRate; + } + + public void setDirtyRate(Long dirtyRate) { + this.dirtyRate = dirtyRate; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java new file mode 100644 index 000000000000..62069cfbcd97 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationDiskVO.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigrationDisk; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "vmware_cbt_migration_disk") +public class VmwareCbtMigrationDiskVO implements VmwareCbtMigrationDisk { + + public VmwareCbtMigrationDiskVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationDiskVO(long migrationId, String sourceDiskId, Integer sourceDiskDeviceKey, + String sourceDiskPath, String datastoreName, Long capacityBytes) { + this(); + this.migrationId = migrationId; + this.sourceDiskId = sourceDiskId; + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + this.sourceDiskPath = sourceDiskPath; + this.datastoreName = datastoreName; + this.capacityBytes = capacityBytes; + this.state = State.Created; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "migration_id") + private long migrationId; + + @Column(name = "source_disk_id") + private String sourceDiskId; + + @Column(name = "source_disk_device_key") + private Integer sourceDiskDeviceKey; + + @Column(name = "source_disk_path") + private String sourceDiskPath; + + @Column(name = "datastore_name") + private String datastoreName; + + @Column(name = "capacity_bytes") + private Long capacityBytes; + + @Column(name = "target_path") + private String targetPath; + + @Column(name = "target_format") + private String targetFormat; + + @Column(name = "change_id") + private String changeId; + + @Column(name = "snapshot_moref") + private String snapshotMor; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getMigrationId() { + return migrationId; + } + + public String getSourceDiskId() { + return sourceDiskId; + } + + public Integer getSourceDiskDeviceKey() { + return sourceDiskDeviceKey; + } + + public void setSourceDiskDeviceKey(Integer sourceDiskDeviceKey) { + this.sourceDiskDeviceKey = sourceDiskDeviceKey; + } + + public String getSourceDiskPath() { + return sourceDiskPath; + } + + public String getDatastoreName() { + return datastoreName; + } + + public Long getCapacityBytes() { + return capacityBytes; + } + + public String getTargetPath() { + return targetPath; + } + + public void setTargetPath(String targetPath) { + this.targetPath = targetPath; + } + + public String getTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(String targetFormat) { + this.targetFormat = targetFormat; + } + + public String getChangeId() { + return changeId; + } + + public void setChangeId(String changeId) { + this.changeId = changeId; + } + + public String getSnapshotMor() { + return snapshotMor; + } + + public void setSnapshotMor(String snapshotMor) { + this.snapshotMor = snapshotMor; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java new file mode 100644 index 000000000000..24ad5b1dd552 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmwareCbtMigrationVO.java @@ -0,0 +1,465 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm; + +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +import com.cloud.utils.db.Encrypt; + +@Entity +@Table(name = "vmware_cbt_migration") +public class VmwareCbtMigrationVO implements VmwareCbtMigration { + + public VmwareCbtMigrationVO() { + uuid = UUID.randomUUID().toString(); + } + + public VmwareCbtMigrationVO(long zoneId, long accountId, long userId, long destinationClusterId, + String displayName, String vcenter, String datacenter, String sourceHost, + String sourceCluster, String sourceVmName) { + this(); + this.zoneId = zoneId; + this.accountId = accountId; + this.userId = userId; + this.destinationClusterId = destinationClusterId; + this.displayName = displayName; + this.vcenter = vcenter; + this.datacenter = datacenter; + this.sourceHost = sourceHost; + this.sourceCluster = sourceCluster; + this.sourceVmName = sourceVmName; + this.state = State.Created; + this.currentStep = "Created"; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "user_id") + private long userId; + + @Column(name = "vm_id") + private Long vmId; + + @Column(name = "existing_vcenter_id") + private Long existingVcenterId; + + @Column(name = "source_username") + private String sourceUsername; + + @Encrypt + @Column(name = "source_password") + private String sourcePassword; + + @Column(name = "destination_cluster_id") + private long destinationClusterId; + + @Column(name = "convert_host_id") + private Long convertHostId; + + @Column(name = "storage_pool_id") + private Long storagePoolId; + + @Column(name = "display_name") + private String displayName; + + @Column(name = "host_name") + private String hostName; + + @Column(name = "template_id") + private Long templateId; + + @Column(name = "service_offering_id") + private Long serviceOfferingId; + + @Column(name = "guest_os_id") + private Long guestOsId; + + @Column(name = "data_disk_offering_map") + private String dataDiskOfferingMap; + + @Column(name = "nic_network_map") + private String nicNetworkMap; + + @Column(name = "nic_ip_address_map") + private String nicIpAddressMap; + + @Column(name = "import_details") + private String importDetails; + + @Column(name = "forced") + private boolean forced; + + @Column(name = "vcenter") + private String vcenter; + + @Column(name = "datacenter") + private String datacenter; + + @Column(name = "source_host") + private String sourceHost; + + @Column(name = "source_cluster") + private String sourceCluster; + + @Column(name = "source_vm_name") + private String sourceVmName; + + @Column(name = "vddk_lib_dir") + private String vddkLibDir; + + @Column(name = "vddk_transports") + private String vddkTransports; + + @Column(name = "vddk_thumbprint") + private String vddkThumbprint; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "current_step") + private String currentStep; + + @Column(name = "last_error") + private String lastError; + + @Column(name = "completed_cycles") + private int completedCycles; + + @Column(name = "quiet_cycles") + private int quietCycles; + + @Column(name = "total_changed_bytes") + private long totalChangedBytes; + + @Column(name = "last_changed_bytes") + private Long lastChangedBytes; + + @Column(name = "last_dirty_rate") + private Long lastDirtyRate; + + @Column(name = "created") + @Temporal(value = TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "updated") + @Temporal(value = TemporalType.TIMESTAMP) + private Date updated; + + @Column(name = "removed") + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public long getZoneId() { + return zoneId; + } + + public long getAccountId() { + return accountId; + } + + public long getUserId() { + return userId; + } + + public Long getVmId() { + return vmId; + } + + public void setVmId(Long vmId) { + this.vmId = vmId; + } + + public Long getExistingVcenterId() { + return existingVcenterId; + } + + public void setExistingVcenterId(Long existingVcenterId) { + this.existingVcenterId = existingVcenterId; + } + + public String getSourceUsername() { + return sourceUsername; + } + + public void setSourceUsername(String sourceUsername) { + this.sourceUsername = sourceUsername; + } + + public String getSourcePassword() { + return sourcePassword; + } + + public void setSourcePassword(String sourcePassword) { + this.sourcePassword = sourcePassword; + } + + public long getDestinationClusterId() { + return destinationClusterId; + } + + public Long getConvertHostId() { + return convertHostId; + } + + public void setConvertHostId(Long convertHostId) { + this.convertHostId = convertHostId; + } + + public Long getStoragePoolId() { + return storagePoolId; + } + + public void setStoragePoolId(Long storagePoolId) { + this.storagePoolId = storagePoolId; + } + + public String getDisplayName() { + return displayName; + } + + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public Long getServiceOfferingId() { + return serviceOfferingId; + } + + public void setServiceOfferingId(Long serviceOfferingId) { + this.serviceOfferingId = serviceOfferingId; + } + + public Long getGuestOsId() { + return guestOsId; + } + + public void setGuestOsId(Long guestOsId) { + this.guestOsId = guestOsId; + } + + public String getDataDiskOfferingMap() { + return dataDiskOfferingMap; + } + + public void setDataDiskOfferingMap(String dataDiskOfferingMap) { + this.dataDiskOfferingMap = dataDiskOfferingMap; + } + + public String getNicNetworkMap() { + return nicNetworkMap; + } + + public void setNicNetworkMap(String nicNetworkMap) { + this.nicNetworkMap = nicNetworkMap; + } + + public String getNicIpAddressMap() { + return nicIpAddressMap; + } + + public void setNicIpAddressMap(String nicIpAddressMap) { + this.nicIpAddressMap = nicIpAddressMap; + } + + public String getImportDetails() { + return importDetails; + } + + public void setImportDetails(String importDetails) { + this.importDetails = importDetails; + } + + public boolean isForced() { + return forced; + } + + public void setForced(boolean forced) { + this.forced = forced; + } + + public String getVcenter() { + return vcenter; + } + + public String getDatacenter() { + return datacenter; + } + + public String getSourceHost() { + return sourceHost; + } + + public String getSourceCluster() { + return sourceCluster; + } + + public String getSourceVmName() { + return sourceVmName; + } + + public String getVddkLibDir() { + return vddkLibDir; + } + + public void setVddkLibDir(String vddkLibDir) { + this.vddkLibDir = vddkLibDir; + } + + public String getVddkTransports() { + return vddkTransports; + } + + public void setVddkTransports(String vddkTransports) { + this.vddkTransports = vddkTransports; + } + + public String getVddkThumbprint() { + return vddkThumbprint; + } + + public void setVddkThumbprint(String vddkThumbprint) { + this.vddkThumbprint = vddkThumbprint; + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public String getCurrentStep() { + return currentStep; + } + + public void setCurrentStep(String currentStep) { + this.currentStep = currentStep; + } + + public String getLastError() { + return lastError; + } + + public void setLastError(String lastError) { + this.lastError = lastError; + } + + public int getCompletedCycles() { + return completedCycles; + } + + public void setCompletedCycles(int completedCycles) { + this.completedCycles = completedCycles; + } + + public int getQuietCycles() { + return quietCycles; + } + + public void setQuietCycles(int quietCycles) { + this.quietCycles = quietCycles; + } + + public long getTotalChangedBytes() { + return totalChangedBytes; + } + + public void setTotalChangedBytes(long totalChangedBytes) { + this.totalChangedBytes = totalChangedBytes; + } + + public Long getLastChangedBytes() { + return lastChangedBytes; + } + + public void setLastChangedBytes(Long lastChangedBytes) { + this.lastChangedBytes = lastChangedBytes; + } + + public Long getLastDirtyRate() { + return lastDirtyRate; + } + + public void setLastDirtyRate(Long lastDirtyRate) { + this.lastDirtyRate = lastDirtyRate; + } + + public Date getCreated() { + return created; + } + + public Date getUpdated() { + return updated; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public Date getRemoved() { + return removed; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDao.java new file mode 100644 index 000000000000..e7ce4deedfcf --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDao.java @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.List; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupVO; + +public interface InstanceBootGroupDao extends GenericDao { + + List listByAccountId(long accountId); + + boolean isNameInUse(long accountId, String name); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDaoImpl.java new file mode 100644 index 000000000000..3a80e7eabeed --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDaoImpl.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 com.cloud.vm.dao; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupVO; + +@Component +public class InstanceBootGroupDaoImpl extends GenericDaoBase implements InstanceBootGroupDao { + + private final SearchBuilder accountSearch; + private final SearchBuilder accountNameSearch; + + public InstanceBootGroupDaoImpl() { + accountSearch = createSearchBuilder(); + accountSearch.and("accountId", accountSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + accountSearch.done(); + + accountNameSearch = createSearchBuilder(); + accountNameSearch.and("accountId", accountNameSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + accountNameSearch.and("name", accountNameSearch.entity().getName(), SearchCriteria.Op.EQ); + accountNameSearch.done(); + } + + @Override + public List listByAccountId(long accountId) { + SearchCriteria sc = accountSearch.create(); + sc.setParameters("accountId", accountId); + return listBy(sc); + } + + @Override + public boolean isNameInUse(long accountId, String name) { + SearchCriteria sc = accountNameSearch.create(); + sc.setParameters("accountId", accountId); + sc.setParameters("name", name); + return !listBy(sc).isEmpty(); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDetailsDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDetailsDao.java new file mode 100644 index 000000000000..6b1c241cddb0 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDetailsDao.java @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupDetailsVO; + +public interface InstanceBootGroupDetailsDao extends ResourceDetailsDao { + + String getDetail(long bootGroupId, String name); + + void setDetail(long bootGroupId, String name, String value); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDetailsDaoImpl.java new file mode 100644 index 000000000000..065b09571a96 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupDetailsDaoImpl.java @@ -0,0 +1,71 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupDetailsVO; +import org.springframework.stereotype.Component; + +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +@Component +public class InstanceBootGroupDetailsDaoImpl extends ResourceDetailsDaoBase implements InstanceBootGroupDetailsDao { + + private final SearchBuilder bootGroupSearch; + private final SearchBuilder bootGroupNameSearch; + + public InstanceBootGroupDetailsDaoImpl() { + bootGroupSearch = createSearchBuilder(); + bootGroupSearch.and("bootGroupId", bootGroupSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + bootGroupSearch.done(); + + bootGroupNameSearch = createSearchBuilder(); + bootGroupNameSearch.and("bootGroupId", bootGroupNameSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + bootGroupNameSearch.and("name", bootGroupNameSearch.entity().getName(), SearchCriteria.Op.EQ); + bootGroupNameSearch.done(); + } + + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + super.addDetail(new InstanceBootGroupDetailsVO(resourceId, key, value, display)); + } + + @Override + public String getDetail(long bootGroupId, String name) { + SearchCriteria sc = bootGroupNameSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("name", name); + InstanceBootGroupDetailsVO detail = findOneBy(sc); + return detail == null ? null : detail.getValue(); + } + + @Override + public void setDetail(long bootGroupId, String name, String value) { + SearchCriteria sc = bootGroupNameSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("name", name); + InstanceBootGroupDetailsVO existing = findOneBy(sc); + if (existing == null) { + persist(new InstanceBootGroupDetailsVO(bootGroupId, name, value)); + } else { + existing.setValue(value); + update(existing.getId(), existing); + } + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupMemberDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupMemberDao.java new file mode 100644 index 000000000000..1a7c67fce061 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupMemberDao.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.List; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMemberVO; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; + +public interface InstanceBootGroupMemberDao extends GenericDao { + + List listByBootGroupId(long bootGroupId); + + int countByBootGroupId(long bootGroupId); + + List listByBootGroupIdAndEqualOrHigherOrder(long bootGroupId, int order); + + List listByBootGroupIdAndOrderRange(long bootGroupId, int low, int high); + + Pair, Integer> searchAndCountByBootGroupId(long bootGroupId); + + Pair, Integer> searchAndCountByBootGroupIdAndType(long bootGroupId, InstanceBootGroupMember.MemberType memberType); + + InstanceBootGroupMemberVO findByMember(InstanceBootGroupMember.MemberType memberType, long memberId); + + void deleteByBootGroupId(long bootGroupId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupMemberDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupMemberDaoImpl.java new file mode 100644 index 000000000000..4529892fee49 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupMemberDaoImpl.java @@ -0,0 +1,123 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMemberVO; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; + +@Component +public class InstanceBootGroupMemberDaoImpl extends GenericDaoBase implements InstanceBootGroupMemberDao { + + private final SearchBuilder bootGroupSearch; + private final SearchBuilder bootGroupTypeSearch; + private final SearchBuilder memberSearch; + + public InstanceBootGroupMemberDaoImpl() { + bootGroupSearch = createSearchBuilder(); + bootGroupSearch.and("bootGroupId", bootGroupSearch.entity().getBootGroupId(), SearchCriteria.Op.EQ); + bootGroupSearch.done(); + + bootGroupTypeSearch = createSearchBuilder(); + bootGroupTypeSearch.and("bootGroupId", bootGroupTypeSearch.entity().getBootGroupId(), SearchCriteria.Op.EQ); + bootGroupTypeSearch.and("memberType", bootGroupTypeSearch.entity().getMemberType(), SearchCriteria.Op.EQ); + bootGroupTypeSearch.done(); + + memberSearch = createSearchBuilder(); + memberSearch.and("memberType", memberSearch.entity().getMemberType(), SearchCriteria.Op.EQ); + memberSearch.and("memberId", memberSearch.entity().getMemberId(), SearchCriteria.Op.EQ); + memberSearch.done(); + } + + @Override + public List listByBootGroupId(long bootGroupId) { + SearchCriteria sc = bootGroupSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + return listBy(sc, null); + } + + @Override + public int countByBootGroupId(long bootGroupId) { + SearchCriteria sc = bootGroupSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + return getCount(sc); + } + + @Override + public List listByBootGroupIdAndEqualOrHigherOrder(long bootGroupId, int order) { + SearchBuilder bootGroupOrderSearch = createSearchBuilder(); + bootGroupOrderSearch.and("bootGroupId", bootGroupOrderSearch.entity().getBootGroupId(), SearchCriteria.Op.EQ); + bootGroupOrderSearch.and("bootOrder", bootGroupOrderSearch.entity().getBootOrder(), SearchCriteria.Op.GTEQ); + bootGroupOrderSearch.done(); + SearchCriteria sc = bootGroupOrderSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("bootOrder", order); + return listBy(sc, null); + } + + @Override + public List listByBootGroupIdAndOrderRange(long bootGroupId, int low, int high) { + SearchBuilder bootGroupOrderSearch = createSearchBuilder(); + bootGroupOrderSearch.and("bootGroupId", bootGroupOrderSearch.entity().getBootGroupId(), SearchCriteria.Op.EQ); + bootGroupOrderSearch.and("low", bootGroupOrderSearch.entity().getBootOrder(), SearchCriteria.Op.GTEQ); + bootGroupOrderSearch.and("high", bootGroupOrderSearch.entity().getBootOrder(), SearchCriteria.Op.LTEQ); + bootGroupOrderSearch.done(); + SearchCriteria sc = bootGroupOrderSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("low", low); + sc.setParameters("high", high); + return listBy(sc, null); + } + + @Override + public Pair, Integer> searchAndCountByBootGroupId(long bootGroupId) { + SearchCriteria sc = bootGroupSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + return searchAndCount(sc, null); + } + + @Override + public Pair, Integer> searchAndCountByBootGroupIdAndType(long bootGroupId, InstanceBootGroupMember.MemberType memberType) { + SearchCriteria sc = bootGroupTypeSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("memberType", memberType); + return searchAndCount(sc, null); + } + + @Override + public InstanceBootGroupMemberVO findByMember(InstanceBootGroupMember.MemberType memberType, long memberId) { + SearchCriteria sc = memberSearch.create(); + sc.setParameters("memberType", memberType); + sc.setParameters("memberId", memberId); + return findOneBy(sc); + } + + @Override + public void deleteByBootGroupId(long bootGroupId) { + SearchCriteria sc = bootGroupSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + expunge(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDao.java new file mode 100644 index 000000000000..2f4fb1185df0 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDao.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.Date; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessCheckResultVO; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; + +import com.cloud.utils.db.GenericDao; + +public interface InstanceBootGroupReadinessCheckResultDao extends GenericDao { + + /** + * vmId 0 is the rule's own row (a VM-scoped rule's single target, or a group-scoped rule's + * all-members aggregate); any other vmId is one inherited member's individual result. + */ + InstanceBootGroupReadinessCheckResultVO findByRuleAndVm(long ruleId, long vmId); + + /** + * Inserts or updates the single cached result row for (ruleId, vmId) (no history, by design). + */ + void upsert(long ruleId, long vmId, InstanceBootGroupReadinessRule.Status status, String message, Date checkedOn); + + void deleteByRuleId(long ruleId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDaoImpl.java new file mode 100644 index 000000000000..56251a31214e --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDaoImpl.java @@ -0,0 +1,74 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.Date; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessCheckResultVO; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.springframework.stereotype.Component; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +@Component +public class InstanceBootGroupReadinessCheckResultDaoImpl extends GenericDaoBase implements InstanceBootGroupReadinessCheckResultDao { + + private final SearchBuilder ruleIdSearch; + private final SearchBuilder ruleAndVmSearch; + + public InstanceBootGroupReadinessCheckResultDaoImpl() { + ruleIdSearch = createSearchBuilder(); + ruleIdSearch.and("ruleId", ruleIdSearch.entity().getRuleId(), SearchCriteria.Op.EQ); + ruleIdSearch.done(); + + ruleAndVmSearch = createSearchBuilder(); + ruleAndVmSearch.and("ruleId", ruleAndVmSearch.entity().getRuleId(), SearchCriteria.Op.EQ); + ruleAndVmSearch.and("vmId", ruleAndVmSearch.entity().getVmId(), SearchCriteria.Op.EQ); + ruleAndVmSearch.done(); + } + + @Override + public InstanceBootGroupReadinessCheckResultVO findByRuleAndVm(long ruleId, long vmId) { + SearchCriteria sc = ruleAndVmSearch.create(); + sc.setParameters("ruleId", ruleId); + sc.setParameters("vmId", vmId); + return findOneBy(sc); + } + + @Override + public void upsert(long ruleId, long vmId, InstanceBootGroupReadinessRule.Status status, String message, Date checkedOn) { + InstanceBootGroupReadinessCheckResultVO existing = findByRuleAndVm(ruleId, vmId); + if (existing == null) { + persist(new InstanceBootGroupReadinessCheckResultVO(ruleId, vmId, status, message, checkedOn)); + } else { + existing.setStatus(status); + existing.setMessage(message); + existing.setCheckedOn(checkedOn); + update(existing.getId(), existing); + } + } + + @Override + public void deleteByRuleId(long ruleId) { + SearchCriteria sc = ruleIdSearch.create(); + sc.setParameters("ruleId", ruleId); + expunge(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDao.java new file mode 100644 index 000000000000..c5599346be96 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDao.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.List; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessRuleVO; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; + +public interface InstanceBootGroupReadinessRuleDao extends GenericDao { + + Pair, Integer> searchAndCountByBootGroupId(long bootGroupId, + Long id, + InstanceBootGroupMember.MemberType itemType, + Long itemId, + InstanceBootGroupReadinessRule.RuleType ruleType, + String keyword, + Long startIndex, + Long pageSize); + + List listEnabledByItem(long bootGroupId, InstanceBootGroupMember.MemberType itemType, long itemId); + + List listByItem(long bootGroupId, InstanceBootGroupMember.MemberType itemType, long itemId); + +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDaoImpl.java new file mode 100644 index 000000000000..125766a853b1 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDaoImpl.java @@ -0,0 +1,119 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.List; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessRuleVO; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.springframework.stereotype.Component; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +@Component +public class InstanceBootGroupReadinessRuleDaoImpl extends GenericDaoBase implements InstanceBootGroupReadinessRuleDao { + + private final SearchBuilder itemSearch; + private final SearchBuilder enabledByItemSearch; + private final SearchBuilder byItemSearch; + + public InstanceBootGroupReadinessRuleDaoImpl() { + + itemSearch = createSearchBuilder(); + itemSearch.and("itemType", itemSearch.entity().getItemType(), SearchCriteria.Op.EQ); + itemSearch.and("itemId", itemSearch.entity().getItemId(), SearchCriteria.Op.EQ); + itemSearch.done(); + + enabledByItemSearch = createSearchBuilder(); + enabledByItemSearch.and("bootGroupId", enabledByItemSearch.entity().getBootGroupId(), SearchCriteria.Op.EQ); + enabledByItemSearch.and("itemType", enabledByItemSearch.entity().getItemType(), SearchCriteria.Op.EQ); + enabledByItemSearch.and("itemId", enabledByItemSearch.entity().getItemId(), SearchCriteria.Op.EQ); + enabledByItemSearch.and("enabled", enabledByItemSearch.entity().isEnabled(), SearchCriteria.Op.EQ); + enabledByItemSearch.done(); + + byItemSearch = createSearchBuilder(); + byItemSearch.and("bootGroupId", byItemSearch.entity().getBootGroupId(), SearchCriteria.Op.EQ); + byItemSearch.and("itemType", byItemSearch.entity().getItemType(), SearchCriteria.Op.EQ); + byItemSearch.and("itemId", byItemSearch.entity().getItemId(), SearchCriteria.Op.EQ); + byItemSearch.done(); + } + + @Override + public Pair, Integer> searchAndCountByBootGroupId(long bootGroupId, + Long id, + InstanceBootGroupMember.MemberType itemType, + Long itemId, + InstanceBootGroupReadinessRule.RuleType ruleType, + String keyword, + Long startIndex, + Long pageSize) { + SearchBuilder sb = createSearchBuilder(); + sb.and("bootGroupId", sb.entity().getBootGroupId(), SearchCriteria.Op.EQ); + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("itemType", sb.entity().getItemType(), SearchCriteria.Op.EQ); + sb.and("itemId", sb.entity().getItemId(), SearchCriteria.Op.EQ); + sb.and("ruleType", sb.entity().getRuleType(), SearchCriteria.Op.EQ); + sb.and("keyword", sb.entity().getName(), SearchCriteria.Op.LIKE); + sb.done(); + + SearchCriteria sc = sb.create(); + sc.setParameters("bootGroupId", bootGroupId); + if (id != null) { + sc.setParameters("id", id); + } + if (itemType != null) { + sc.setParameters("itemType", itemType); + } + if (itemId != null) { + sc.setParameters("itemId", itemId); + } + if (ruleType != null) { + sc.setParameters("ruleType", ruleType); + } + if (keyword != null) { + sc.setParameters("keyword", "%" + keyword + "%"); + } + + Filter searchFilter = new Filter(InstanceBootGroupReadinessRuleVO.class, "id", true, startIndex, pageSize); + return searchAndCount(sc, searchFilter); + } + + @Override + public List listEnabledByItem(long bootGroupId, InstanceBootGroupMember.MemberType itemType, long itemId) { + SearchCriteria sc = enabledByItemSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("itemType", itemType); + sc.setParameters("itemId", itemId); + sc.setParameters("enabled", true); + return listBy(sc); + } + + @Override + public List listByItem(long bootGroupId, InstanceBootGroupMember.MemberType itemType, long itemId) { + SearchCriteria sc = byItemSearch.create(); + sc.setParameters("bootGroupId", bootGroupId); + sc.setParameters("itemType", itemType); + sc.setParameters("itemId", itemId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDao.java new file mode 100644 index 000000000000..eb835f1578bd --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDao.java @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.Map; + +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessRuleDetailsVO; + +public interface InstanceBootGroupReadinessRuleDetailsDao extends ResourceDetailsDao { + + /** + * Like {@link #listDetailsKeyPairs(long)} but transparently decrypts the {@code script} key + * (CustomScript rule content is stored encrypted at rest). + */ + Map getDetails(long ruleId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDaoImpl.java new file mode 100644 index 000000000000..68e01ad3feac --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDaoImpl.java @@ -0,0 +1,69 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.vm.dao; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessRuleDetailsVO; +import org.springframework.stereotype.Component; + +import com.cloud.utils.crypt.DBEncryptionUtil; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +@Component +public class InstanceBootGroupReadinessRuleDetailsDaoImpl extends ResourceDetailsDaoBase implements InstanceBootGroupReadinessRuleDetailsDao { + + private static final String ENCRYPTED_KEY = "script"; + + private final SearchBuilder ruleSearch; + + public InstanceBootGroupReadinessRuleDetailsDaoImpl() { + super(); + ruleSearch = createSearchBuilder(); + ruleSearch.and("ruleId", ruleSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + ruleSearch.done(); + } + + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + String storedValue = ENCRYPTED_KEY.equals(key) ? DBEncryptionUtil.encrypt(value) : value; + super.addDetail(new InstanceBootGroupReadinessRuleDetailsVO(resourceId, key, storedValue, display)); + } + + @Override + public Map getDetails(long ruleId) { + SearchCriteria sc = ruleSearch.create(); + sc.setParameters("ruleId", ruleId); + + List details = listBy(sc); + Map detailsMap = new HashMap<>(); + for (InstanceBootGroupReadinessRuleDetailsVO detail : details) { + String name = detail.getName(); + String value = detail.getValue(); + if (ENCRYPTED_KEY.equals(name)) { + value = DBEncryptionUtil.decrypt(value); + } + detailsMap.put(name, value); + } + return detailsMap; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java index ad599053ff0c..5838d752d6f4 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java @@ -107,6 +107,6 @@ public interface NicDao extends GenericDao { NicVO findByIpAddressAndVmType(String ip, VirtualMachine.Type vmType); - List listByNetworkIdAndType(long networkId, VirtualMachine.Type vmType); + List listNonPlaceholderNicsByNetworkIdAndType(long networkId, VirtualMachine.Type vmType); List searchRemovedByVms(List vmIds, Long batchSize); } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java index e468b1b9a3bf..9acf495d6b3b 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java @@ -463,10 +463,11 @@ public NicVO findByIpAddressAndVmType(String ip, VirtualMachine.Type vmType) { } @Override - public List listByNetworkIdAndType(long networkId, VirtualMachine.Type vmType) { + public List listNonPlaceholderNicsByNetworkIdAndType(long networkId, VirtualMachine.Type vmType) { SearchCriteria sc = AllFieldsSearch.create(); sc.setParameters("network", networkId); sc.setParameters("vmType", vmType); + sc.setParameters("strategyNEQ", Nic.ReservationStrategy.PlaceHolder.toString()); return listBy(sc); } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java index 1a5b8cedd9ea..4316a483954d 100755 --- a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java @@ -199,4 +199,6 @@ List searchRemovedByRemoveDate(final Date startDate, final Date en List listDeleteProtectedVmsByDomainIds(Set domainIds); List listIdsByHostIdForVolumeStats(long hostIds); + + List listByIds(List ids); } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java index ae1e838649ba..761b9b515114 100755 --- a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java @@ -21,6 +21,7 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -106,6 +107,7 @@ public class VMInstanceDaoImpl extends GenericDaoBase implem protected SearchBuilder LastHostAndStatesSearch; protected SearchBuilder VmsNotInClusterUsingPool; protected SearchBuilder IdsPowerStateSelectSearch; + protected SearchBuilder IdsSearch; GenericSearchBuilder CountByOfferingId; GenericSearchBuilder CountUserVmNotInDomain; SearchBuilder DeleteProtectedVmSearchByAccount; @@ -386,6 +388,10 @@ protected void init() { DeleteProtectedVmSearchByDomainIds.and(ApiConstants.DELETE_PROTECTION, DeleteProtectedVmSearchByDomainIds.entity().isDeleteProtection(), Op.EQ); DeleteProtectedVmSearchByDomainIds.and(ApiConstants.REMOVED, DeleteProtectedVmSearchByDomainIds.entity().getRemoved(), Op.NULL); DeleteProtectedVmSearchByDomainIds.done(); + + IdsSearch = createSearchBuilder(); + IdsSearch.and("ids", IdsSearch.entity().getId(), Op.IN); + IdsSearch.done(); } @Override @@ -1352,4 +1358,14 @@ public List listIdsByHostIdForVolumeStats(long hostId) { sc.setParameters("lastHost", hostId); return customSearch(sc, null); } + + @Override + public List listByIds(List ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } + SearchCriteria sc = IdsSearch.create(); + sc.setParameters("ids", ids.toArray()); + return listBy(sc); + } } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java new file mode 100644 index 000000000000..25debad70156 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDao.java @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationCycleVO; + +import java.util.List; + +public interface VmwareCbtMigrationCycleDao extends GenericDao { + List listByMigrationId(long migrationId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java new file mode 100644 index 000000000000..0d823a29287b --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationCycleDaoImpl.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationCycleVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VmwareCbtMigrationCycleDaoImpl extends GenericDaoBase implements VmwareCbtMigrationCycleDao { + + private final SearchBuilder migrationSearch; + + public VmwareCbtMigrationCycleDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("migrationId", migrationSearch.entity().getMigrationId(), SearchCriteria.Op.EQ); + migrationSearch.done(); + } + + @Override + public List listByMigrationId(long migrationId) { + SearchCriteria sc = migrationSearch.create(); + sc.setParameters("migrationId", migrationId); + Filter filter = new Filter(VmwareCbtMigrationCycleVO.class, "cycleNumber", true, null, null); + return listBy(sc, filter); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java new file mode 100644 index 000000000000..09ee0e6367c3 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDao.java @@ -0,0 +1,62 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationVO; +import org.apache.cloudstack.vm.VmwareCbtMigration; + +import java.util.Date; +import java.util.List; + +public interface VmwareCbtMigrationDao extends GenericDao { + Pair, Integer> listMigrations(Long id, Long zoneId, Long accountId, String vcenter, + String sourceVmName, VmwareCbtMigration.State state, + Long startIndex, Long pageSizeVal); + + List listByConvertHostId(Long convertHostId); + + /** + * Persists the given migration only while the stored record has not reached a terminal + * state (Completed, Failed or Cancelled), as a single conditional statement. + * + * Long-running replication and cutover jobs use this instead of a plain update so a + * migration that an operator cancelled mid-flight is not resurrected by a job that started + * before the cancellation: cancelling removes the target disks, so a later blind write of a + * non-terminal state would leave a record claiming to replicate onto disks that no longer + * exist, and which can no longer be deleted because it is not terminal. + * + * @return true if the record was still non-terminal and was updated, false if it had + * already reached a terminal state and was therefore left untouched. + */ + boolean updateIfNotTerminal(VmwareCbtMigrationVO migration); + + /** + * Atomically records a successful CloudStack VM import while the migration is still + * non-terminal. The VM reference, terminal state and credential removal must be one + * conditional update so a concurrent cancellation cannot be overwritten. + */ + boolean completeImportIfNotTerminal(long migrationId, long vmId, Date updated); + + /** + * Records the VM created by an import that raced with a terminal state without changing that + * terminal state. This keeps the imported VM discoverable for operator reconciliation and + * removes stored source credentials. + */ + boolean recordImportedVmAndClearCredentials(long migrationId, long vmId, Date updated); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java new file mode 100644 index 000000000000..3bcb0a087b39 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDaoImpl.java @@ -0,0 +1,129 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationVO; +import org.apache.cloudstack.vm.VmwareCbtMigration; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +@Component +public class VmwareCbtMigrationDaoImpl extends GenericDaoBase implements VmwareCbtMigrationDao { + + private static final VmwareCbtMigration.State[] TERMINAL_STATES = { + VmwareCbtMigration.State.Completed, + VmwareCbtMigration.State.Failed, + VmwareCbtMigration.State.Cancelled + }; + + private final SearchBuilder migrationSearch; + private final SearchBuilder convertHostSearch; + private final SearchBuilder nonTerminalSearch; + + public VmwareCbtMigrationDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("id", migrationSearch.entity().getId(), SearchCriteria.Op.EQ); + migrationSearch.and("zoneId", migrationSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + migrationSearch.and("accountId", migrationSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + migrationSearch.and("vcenter", migrationSearch.entity().getVcenter(), SearchCriteria.Op.EQ); + migrationSearch.and("sourceVmName", migrationSearch.entity().getSourceVmName(), SearchCriteria.Op.EQ); + migrationSearch.and("state", migrationSearch.entity().getState(), SearchCriteria.Op.EQ); + migrationSearch.done(); + + convertHostSearch = createSearchBuilder(); + convertHostSearch.and("convertHostId", convertHostSearch.entity().getConvertHostId(), SearchCriteria.Op.EQ); + convertHostSearch.done(); + + nonTerminalSearch = createSearchBuilder(); + nonTerminalSearch.and("id", nonTerminalSearch.entity().getId(), SearchCriteria.Op.EQ); + nonTerminalSearch.and("state", nonTerminalSearch.entity().getState(), SearchCriteria.Op.NOTIN); + nonTerminalSearch.done(); + } + + @Override + public boolean updateIfNotTerminal(VmwareCbtMigrationVO migration) { + SearchCriteria sc = nonTerminalSearch.create(); + sc.setParameters("id", migration.getId()); + sc.setParameters("state", (Object[]) TERMINAL_STATES); + return update(migration, sc) > 0; + } + + @Override + public boolean completeImportIfNotTerminal(long migrationId, long vmId, Date updated) { + VmwareCbtMigrationVO migration = createImportedVmUpdate(migrationId, vmId, updated); + migration.setState(VmwareCbtMigration.State.Completed); + migration.setCurrentStep("CloudStack VM import completed"); + migration.setLastError(null); + return updateIfNotTerminal(migration); + } + + @Override + public boolean recordImportedVmAndClearCredentials(long migrationId, long vmId, Date updated) { + return update(migrationId, createImportedVmUpdate(migrationId, vmId, updated)); + } + + private VmwareCbtMigrationVO createImportedVmUpdate(long migrationId, long vmId, Date updated) { + VmwareCbtMigrationVO migration = createForUpdate(migrationId); + migration.setVmId(vmId); + migration.setSourceUsername(null); + migration.setSourcePassword(null); + migration.setUpdated(updated); + return migration; + } + + @Override + public Pair, Integer> listMigrations(Long id, Long zoneId, Long accountId, String vcenter, + String sourceVmName, VmwareCbtMigration.State state, + Long startIndex, Long pageSizeVal) { + SearchCriteria sc = migrationSearch.create(); + if (id != null) { + sc.setParameters("id", id); + } + if (zoneId != null) { + sc.setParameters("zoneId", zoneId); + } + if (accountId != null) { + sc.setParameters("accountId", accountId); + } + if (StringUtils.isNotBlank(vcenter)) { + sc.setParameters("vcenter", vcenter); + } + if (StringUtils.isNotBlank(sourceVmName)) { + sc.setParameters("sourceVmName", sourceVmName); + } + if (state != null) { + sc.setParameters("state", state); + } + Filter filter = new Filter(VmwareCbtMigrationVO.class, "created", false, startIndex, pageSizeVal); + return searchAndCount(sc, filter); + } + + @Override + public List listByConvertHostId(Long convertHostId) { + SearchCriteria sc = convertHostSearch.create(); + sc.setParameters("convertHostId", convertHostId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java new file mode 100644 index 000000000000..60a3f13355c6 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDao.java @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmwareCbtMigrationDiskVO; + +import java.util.List; + +public interface VmwareCbtMigrationDiskDao extends GenericDao { + List listByMigrationId(long migrationId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java new file mode 100644 index 000000000000..55463f249366 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmwareCbtMigrationDiskDaoImpl.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmwareCbtMigrationDiskVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class VmwareCbtMigrationDiskDaoImpl extends GenericDaoBase implements VmwareCbtMigrationDiskDao { + + private final SearchBuilder migrationSearch; + + public VmwareCbtMigrationDiskDaoImpl() { + migrationSearch = createSearchBuilder(); + migrationSearch.and("migrationId", migrationSearch.entity().getMigrationId(), SearchCriteria.Op.EQ); + migrationSearch.done(); + } + + @Override + public List listByMigrationId(long migrationId) { + SearchCriteria sc = migrationSearch.create(); + sc.setParameters("migrationId", migrationId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java index b4cad92d8770..d162d5bde355 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java @@ -68,7 +68,7 @@ public class BackupVO implements Backup { private String type; @Column(name = "date") - @Temporal(value = TemporalType.DATE) + @Temporal(value = TemporalType.TIMESTAMP) private Date date; @Column(name = GenericDao.REMOVED_COLUMN) diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java index 57b5f6461c6a..fd85dba1efcc 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java @@ -90,6 +90,22 @@ public interface PrimaryDataStoreDao extends GenericDao { List findDisabledPoolsByScope(long dcId, Long podId, Long clusterId, ScopeType scope); + /** + * Finds disabled storage pools within the specified scope that are associated with the given storage access groups. + * This method is used to locate storage pools with 'Disabled' status that match specific storage access group + * filters. + * + * @param dcId the data center ID. + * @param podId the pod ID. + * @param clusterId the cluster ID. + * @param scope ZONE, CLUSTER OR HOST type scope + * @param storageAccessGroups array of storage access group names to match against. + * Only pools associated with these access groups will be returned. + * If null or empty, returns an empty list. + * @return a list of {@link StoragePoolVO} objects. + */ + List findDisabledPoolsByScopeAndAccessGroups(long dcId, Long podId, Long clusterId, ScopeType scope, String[] storageAccessGroups); + /** * Find pool by UUID. * @@ -167,7 +183,19 @@ Pair, Integer> searchForIdsAndCount(Long storagePoolId, String storag List listByIds(List ids); - List findStoragePoolsByEmptyStorageAccessGroups(Long dcId, Long podId, Long clusterId, ScopeType scope, HypervisorType hypervisorType); + /** + * Finds storage pools that have no storage access groups associated with them within the specified criteria. + * This method identifies storage pools without access group restrictions. + * + * @param dcId the data center ID. Can be null to include all data centers. + * @param podId the pod ID. Can be null to include all pods. + * @param clusterId the cluster ID. Can be null to include all clusters. + * @param scope ZONE, CLUSTER or HOST type scope + * @param hypervisorType the hypervisor type filter. Can be null to include all hypervisor types. + * @param status the storage pool status to filter by. + * @return a list of {@link StoragePoolVO} objects that have no storage access groups associated. + */ + List findStoragePoolsByEmptyStorageAccessGroups(Long dcId, Long podId, Long clusterId, ScopeType scope, HypervisorType hypervisorType, StoragePoolStatus status); List findPoolsByStorageTypeAndZone(Storage.StoragePoolType storageType, Long zoneId); diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java index b5d6415e3a1a..4d0e581826f4 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java @@ -54,6 +54,7 @@ import com.cloud.utils.db.SearchCriteria.Op; import com.cloud.utils.db.TransactionLegacy; import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.lang3.ArrayUtils; @DB() public class PrimaryDataStoreDaoImpl extends GenericDaoBase implements PrimaryDataStoreDao { @@ -86,6 +87,37 @@ public class PrimaryDataStoreDaoImpl extends GenericDaoBase private final String ZoneWideStorageAccessGroupsWithHypervisorTypeSqlPrefix = "SELECT storage_pool.* from storage_pool LEFT JOIN storage_pool_and_access_group_map ON storage_pool.id = storage_pool_and_access_group_map.pool_id WHERE storage_pool.removed is null and storage_pool.status = 'Up' and storage_pool.hypervisor = ? and storage_pool.data_center_id = ? and storage_pool.scope = ? and ("; private final String ZoneWideStorageAccessGroupsWithHypervisorTypeSqlSuffix = ") GROUP BY storage_pool_and_access_group_map.pool_id"; + /** + * SQL query prefix to find zone-wide disabled storage pools that are associated with specific storage access groups. + */ + private final String ZoneWideDisabledStorageAccessGroupsSqlPrefix = + "SELECT storage_pool.* FROM storage_pool " + + "LEFT JOIN storage_pool_and_access_group_map ON storage_pool.id = storage_pool_and_access_group_map.pool_id " + + "WHERE storage_pool.removed IS NULL " + + " AND storage_pool.status = 'Disabled' " + + " AND storage_pool.data_center_id = ? " + + " AND storage_pool.scope = ? " + + " AND ("; + + /** + * SQL query prefix to find disabled storage pools with specific storage access groups. The query is used at the HOST scope. + */ + private final String DisabledStorageAccessGroupsForHostConnectionSqlPrefix = + "SELECT storage_pool.* FROM storage_pool " + + "LEFT JOIN storage_pool_and_access_group_map ON storage_pool.id = storage_pool_and_access_group_map.pool_id " + + "WHERE storage_pool.removed IS NULL " + + " AND storage_pool.status = 'Disabled' " + + " AND storage_pool.data_center_id = ? " + + " AND (storage_pool.pod_id = ? OR storage_pool.pod_id IS NULL) " + + " AND storage_pool.scope = ? " + + " AND ("; + + /** + * SQL query suffix for disabled storage access groups queries. This suffix completes the query by grouping + * results by pool_id. + */ + private final String DisabledStorageAccessGroupsSqlSuffix = ") GROUP BY storage_pool_and_access_group_map.pool_id"; + // Storage tags are now separate from storage_pool_details, leaving only details on that table protected final String TagsSqlPrefix = "SELECT storage_pool.* from storage_pool LEFT JOIN storage_pool_tags ON storage_pool.id = storage_pool_tags.pool_id WHERE storage_pool.removed is null and storage_pool.status = 'Up' AND storage_pool_tags.is_tag_a_rule = 0 and storage_pool.data_center_id = ? and (storage_pool.pod_id = ? or storage_pool.pod_id is null) and storage_pool.scope = ? and ("; protected final String TagsSqlSuffix = ") GROUP BY storage_pool_tags.pool_id HAVING COUNT(storage_pool_tags.tag) >= ?"; @@ -568,6 +600,26 @@ public List findDisabledPoolsByScope(long dcId, Long podId, Long return storagePools; } + @Override + public List findDisabledPoolsByScopeAndAccessGroups(long dcId, Long podId, Long clusterId, ScopeType scope, String[] storageAccessGroups) { + if (ArrayUtils.isEmpty(storageAccessGroups)) { + return List.of(); + } + + List storagePools = null; + String sqlValues = getSqlValuesFromStorageAccessGroups(storageAccessGroups); + + if (scope == ScopeType.ZONE) { + String sql = getSqlPreparedStatement(ZoneWideDisabledStorageAccessGroupsSqlPrefix, DisabledStorageAccessGroupsSqlSuffix, sqlValues, null); + storagePools = searchStoragePoolsPreparedStatement(sql, dcId, null, null, scope, null); + } else if ((scope == ScopeType.CLUSTER || scope == ScopeType.HOST) && podId != null && clusterId != null) { + String sql = getSqlPreparedStatement(DisabledStorageAccessGroupsForHostConnectionSqlPrefix, DisabledStorageAccessGroupsSqlSuffix, sqlValues, clusterId); + storagePools = searchStoragePoolsPreparedStatement(sql, dcId, podId, clusterId, scope, null); + } + + return storagePools; + } + @Override public List findLocalStoragePoolsByTags(long dcId, long podId, Long clusterId, String[] tags, boolean validateTagRule) { return findLocalStoragePoolsByTags(dcId, podId, clusterId, tags, validateTagRule, null); @@ -691,7 +743,7 @@ public List findZoneWideStoragePoolsByAccessGroupsAndHypervisorTy } @Override - public List findStoragePoolsByEmptyStorageAccessGroups(Long dcId, Long podId, Long clusterId, ScopeType scope, HypervisorType hypervisorType) { + public List findStoragePoolsByEmptyStorageAccessGroups(Long dcId, Long podId, Long clusterId, ScopeType scope, HypervisorType hypervisorType, StoragePoolStatus status) { SearchBuilder poolSearch = createSearchBuilder(); SearchBuilder storageAccessGroupsPoolSearch = _storagePoolAccessGroupMapDao.createSearchBuilder(); // Set criteria for pools @@ -709,7 +761,6 @@ public List findStoragePoolsByEmptyStorageAccessGroups(Long dcId, SearchCriteria sc = poolSearch.create(); sc.setParameters("scope", scope.toString()); - sc.setParameters("status", Status.Up.toString()); if (dcId != null) { sc.setParameters("datacenterid", dcId); @@ -727,6 +778,10 @@ public List findStoragePoolsByEmptyStorageAccessGroups(Long dcId, sc.setParameters("hypervisortype", hypervisorType); } + if (status != null) { + sc.setParameters("status", status.toString()); + } + return listBy(sc); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupDetailsVO.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupDetailsVO.java new file mode 100644 index 000000000000..14cf82a777eb --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupDetailsVO.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 org.apache.cloudstack.vm.bootgroup; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.api.ResourceDetail; + +/** + * Per-boot-group override of the global readiness ConfigKeys. {@code name} is the exact ConfigKey + * key string, so override resolution needs no separate key-mapping. + */ +@Entity +@Table(name = "instance_boot_group_details") +public class InstanceBootGroupDetailsVO implements ResourceDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "boot_group_id") + private long resourceId; + + @Column(name = "name") + private String name; + + @Column(name = "value") + private String value; + + @Column(name = "display") + private boolean display = true; + + protected InstanceBootGroupDetailsVO() { + } + + public InstanceBootGroupDetailsVO(long bootGroupId, String name, String value) { + this.resourceId = bootGroupId; + this.name = name; + this.value = value; + } + + public InstanceBootGroupDetailsVO(long bootGroupId, String name, String value, boolean display) { + this(bootGroupId, name, value); + this.display = display; + } + + @Override + public long getId() { + return id; + } + + public long getResourceId() { + return resourceId; + } + + public String getName() { + return name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean isDisplay() { + return display; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupMemberVO.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupMemberVO.java new file mode 100644 index 000000000000..c67f63cf719b --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupMemberVO.java @@ -0,0 +1,116 @@ +// 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.bootgroup; + +import java.util.Date; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +@Entity +@Table(name = "instance_boot_group_member") +public class InstanceBootGroupMemberVO implements InstanceBootGroupMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "boot_group_id") + private long bootGroupId; + + @Column(name = "member_type") + @Enumerated(EnumType.STRING) + private MemberType memberType; + + @Column(name = "member_id") + private long memberId; + + @Column(name = "boot_order") + private int bootOrder; + + @Column(name = "created") + private Date created; + + protected InstanceBootGroupMemberVO() { + } + + public InstanceBootGroupMemberVO(long bootGroupId, MemberType memberType, long memberId, int bootOrder) { + this.bootGroupId = bootGroupId; + this.memberType = memberType; + this.memberId = memberId; + this.bootOrder = bootOrder; + this.uuid = UUID.randomUUID().toString(); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public long getBootGroupId() { + return bootGroupId; + } + + @Override + public MemberType getMemberType() { + return memberType; + } + + @Override + public long getMemberId() { + return memberId; + } + + @Override + public int getBootOrder() { + return bootOrder; + } + + public void setBootOrder(int bootOrder) { + this.bootOrder = bootOrder; + } + + @Override + public Date getCreated() { + return created; + } + + @Override + public String toString() { + return String.format("BootGroupMember %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "bootGroupId", "memberType", "memberId", "bootOrder")); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessCheckResultVO.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessCheckResultVO.java new file mode 100644 index 000000000000..4863dd7d9199 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessCheckResultVO.java @@ -0,0 +1,113 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.vm.bootgroup; + +import java.util.Date; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; + +/** + * Last cached evaluation result for a readiness rule, upserted in place — no history, by design. + * {@code vmId} is 0 for the rule's "own" row (a VM-scoped rule's single target, or a group-scoped + * rule's all-members aggregate); a group-scoped rule inherited by its members additionally gets one + * row per (ruleId, vmId) for that member's own individual result. + */ +@Entity +@Table(name = "instance_boot_group_readiness_check_result") +public class InstanceBootGroupReadinessCheckResultVO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "rule_id") + private long ruleId; + + @Column(name = "vm_id") + private long vmId; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private InstanceBootGroupReadinessRule.Status status; + + @Column(name = "message") + private String message; + + @Column(name = "checked_on") + @Temporal(TemporalType.TIMESTAMP) + private Date checkedOn; + + protected InstanceBootGroupReadinessCheckResultVO() { + } + + public InstanceBootGroupReadinessCheckResultVO(long ruleId, long vmId, InstanceBootGroupReadinessRule.Status status, String message, Date checkedOn) { + this.ruleId = ruleId; + this.vmId = vmId; + this.status = status; + this.message = message; + this.checkedOn = checkedOn; + } + + public long getId() { + return id; + } + + public long getRuleId() { + return ruleId; + } + + public long getVmId() { + return vmId; + } + + public InstanceBootGroupReadinessRule.Status getStatus() { + return status; + } + + public void setStatus(InstanceBootGroupReadinessRule.Status status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Date getCheckedOn() { + return checkedOn; + } + + public void setCheckedOn(Date checkedOn) { + this.checkedOn = checkedOn; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessRuleDetailsVO.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessRuleDetailsVO.java new file mode 100644 index 000000000000..f91bbb99b8c6 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessRuleDetailsVO.java @@ -0,0 +1,99 @@ +// 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.bootgroup; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.api.ResourceDetail; + +/** + * Generic key/value config for a readiness rule (port/protocol, script content, threshold, ...). + * The {@code script} key is encrypted at rest by {@code InstanceBootGroupReadinessRuleDetailsDaoImpl} + * for CustomScript rules, the same manual encrypt-by-known-key-name pattern used for S3/Swift + * secrets in {@code ImageStoreDetailVO} — there is no boolean "encrypted" flag column convention in + * this codebase. + */ +@Entity +@Table(name = "instance_boot_group_readiness_rule_details") +public class InstanceBootGroupReadinessRuleDetailsVO implements ResourceDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "rule_id") + private long resourceId; + + @Column(name = "name") + private String name; + + @Column(name = "value") + private String value; + + @Column(name = "display") + private boolean display = true; + + protected InstanceBootGroupReadinessRuleDetailsVO() { + } + + public InstanceBootGroupReadinessRuleDetailsVO(long ruleId, String name, String value) { + this.resourceId = ruleId; + this.name = name; + this.value = value; + } + + public InstanceBootGroupReadinessRuleDetailsVO(long ruleId, String name, String value, boolean display) { + this(ruleId, name, value); + this.display = display; + } + + @Override + public long getId() { + return id; + } + + @Override + public long getResourceId() { + return resourceId; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean isDisplay() { + return display; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessRuleVO.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessRuleVO.java new file mode 100644 index 000000000000..55596b2d580a --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupReadinessRuleVO.java @@ -0,0 +1,149 @@ +// 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.bootgroup; + +import java.util.Date; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; + +import com.cloud.utils.db.GenericDao; + +@Entity +@Table(name = "instance_boot_group_readiness_rule") +public class InstanceBootGroupReadinessRuleVO implements InstanceBootGroupReadinessRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "name") + private String name; + + @Column(name = "boot_group_id") + private long bootGroupId; + + @Column(name = "item_type") + @Enumerated(EnumType.STRING) + private InstanceBootGroupMember.MemberType itemType; + + @Column(name = "item_id") + private long itemId; + + @Column(name = "rule_type") + @Enumerated(EnumType.STRING) + private RuleType ruleType; + + @Column(name = "enabled") + private boolean enabled = true; + + @Column(name = GenericDao.CREATED_COLUMN) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + protected InstanceBootGroupReadinessRuleVO() { + } + + public InstanceBootGroupReadinessRuleVO(String name, long bootGroupId, InstanceBootGroupMember.MemberType itemType, long itemId, RuleType ruleType, boolean enabled) { + this.name = name; + this.bootGroupId = bootGroupId; + this.itemType = itemType; + this.itemId = itemId; + this.ruleType = ruleType; + this.enabled = enabled; + this.uuid = UUID.randomUUID().toString(); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public long getBootGroupId() { + return bootGroupId; + } + + @Override + public InstanceBootGroupMember.MemberType getItemType() { + return itemType; + } + + @Override + public long getItemId() { + return itemId; + } + + @Override + public RuleType getRuleType() { + return ruleType; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + @Override + public String toString() { + return String.format("ReadinessRule %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name", "itemType", "itemId", "ruleType", "enabled")); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupVO.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupVO.java new file mode 100644 index 000000000000..7ac79fda789d --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/vm/bootgroup/InstanceBootGroupVO.java @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.vm.bootgroup; + +import java.util.Date; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import com.cloud.utils.db.GenericDao; + +@Entity +@Table(name = "instance_boot_group") +public class InstanceBootGroupVO implements InstanceBootGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "name") + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "domain_id") + private long domainId; + + @Column(name = GenericDao.CREATED_COLUMN) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + protected InstanceBootGroupVO() { + } + + public InstanceBootGroupVO(String name, String description, long accountId, long domainId) { + this.name = name; + this.description = description; + this.accountId = accountId; + this.domainId = domainId; + this.uuid = UUID.randomUUID().toString(); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public long getAccountId() { + return accountId; + } + + @Override + public long getDomainId() { + return domainId; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + @Override + public Class getEntityType() { + return InstanceBootGroup.class; + } + + @Override + public String toString() { + return String.format("BootGroup %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name")); + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index 932db538f30b..291f9c0b72d8 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -108,6 +108,12 @@ + + + + + + @@ -316,6 +322,9 @@ + + + @@ -329,5 +338,6 @@ + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql index 7c11013a17d2..3365f1f855ba 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql @@ -18,3 +18,224 @@ --; -- Schema upgrade from 4.23.0.0 to 24.0.0 --; + +-- Widen the unique key on cloud_usage.usage_volume to include vm_id, so the cumulative and +-- per-VM volume usage records introduced in 4.22.1 can coexist. See #13399. +CALL `cloud_usage`.`IDEMPOTENT_DROP_INDEX`('id', 'cloud_usage.usage_volume'); +CALL `cloud_usage`.`IDEMPOTENT_ADD_UNIQUE_INDEX`('cloud_usage.usage_volume', 'id', '(volume_id ASC, created ASC, vm_id ASC)'); + +-- Reclaim VNETs belonging to Netris deleted networks +UPDATE op_dc_vnet_alloc a + JOIN networks n ON n.broadcast_uri = CONCAT('netris://', a.vnet) + SET a.account_id = NULL, + a.reservation_id = NULL, + a.taken = NULL +WHERE n.removed IS NOT NULL; + +-- VMware CBT warm migration session state +CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `zone_id` bigint unsigned NOT NULL COMMENT 'Zone ID', + `account_id` bigint unsigned NOT NULL COMMENT 'Account ID', + `user_id` bigint unsigned NOT NULL COMMENT 'User ID', + `vm_id` bigint unsigned COMMENT 'Imported VM ID after cutover', + `existing_vcenter_id` bigint unsigned COMMENT 'Linked VMware datacenter ID when the source vCenter is registered', + `source_username` varchar(255) COMMENT 'Stored source vCenter username for external VMware CBT migrations', + `source_password` varchar(1024) COMMENT 'Encrypted stored source vCenter password for external VMware CBT migrations', + `destination_cluster_id` bigint unsigned NOT NULL COMMENT 'Destination KVM cluster ID', + `convert_host_id` bigint unsigned COMMENT 'KVM host used for conversion and synchronization', + `storage_pool_id` bigint unsigned COMMENT 'Destination primary storage pool ID', + `display_name` varchar(255) COMMENT 'Target VM display name', + `host_name` varchar(255) COMMENT 'Target VM host name', + `template_id` bigint unsigned COMMENT 'Template ID for CloudStack VM import', + `service_offering_id` bigint unsigned COMMENT 'Service offering ID for CloudStack VM import', + `guest_os_id` bigint unsigned COMMENT 'Guest OS ID for CloudStack VM import', + `data_disk_offering_map` text COMMENT 'JSON data disk to disk offering ID map for CloudStack VM import', + `nic_network_map` text COMMENT 'JSON NIC to network ID map for CloudStack VM import', + `nic_ip_address_map` text COMMENT 'JSON NIC to IPv4 address map for CloudStack VM import', + `import_details` text COMMENT 'JSON details map for CloudStack VM import', + `forced` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Whether duplicate NIC MAC addresses are allowed during CloudStack VM import', + `vcenter` varchar(255) COMMENT 'Source vCenter', + `datacenter` varchar(255) COMMENT 'Source vCenter datacenter name', + `source_host` varchar(255) COMMENT 'Source VMware host name or IP', + `source_cluster` varchar(255) COMMENT 'Source VMware cluster name', + `source_vm_name` varchar(255) NOT NULL COMMENT 'Source VM name on vCenter', + `vddk_lib_dir` varchar(1024) COMMENT 'Optional VDDK library directory override', + `vddk_transports` varchar(255) COMMENT 'Optional VDDK transport list override', + `vddk_thumbprint` varchar(255) COMMENT 'Optional vCenter TLS thumbprint for VDDK connections', + `state` varchar(32) NOT NULL COMMENT 'Migration state', + `current_step` varchar(255) COMMENT 'Current migration step', + `last_error` varchar(1024) COMMENT 'Last error message', + `completed_cycles` int unsigned NOT NULL DEFAULT 0 COMMENT 'Completed CBT delta cycles', + `quiet_cycles` int unsigned NOT NULL DEFAULT 0 COMMENT 'Consecutive quiet CBT delta cycles', + `total_changed_bytes` bigint unsigned NOT NULL DEFAULT 0 COMMENT 'Total changed bytes copied across delta cycles', + `last_changed_bytes` bigint unsigned COMMENT 'Changed bytes copied in the latest delta cycle', + `last_dirty_rate` bigint unsigned COMMENT 'Changed bytes per second in the latest delta cycle', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_vmware_cbt_migration__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__user_id` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__vm_id` FOREIGN KEY (`vm_id`) REFERENCES `vm_instance`(`id`) ON DELETE SET NULL, + CONSTRAINT `fk_vmware_cbt_migration__existing_vcenter_id` FOREIGN KEY (`existing_vcenter_id`) REFERENCES `vmware_data_center`(`id`) ON DELETE SET NULL, + CONSTRAINT `fk_vmware_cbt_migration__destination_cluster_id` FOREIGN KEY (`destination_cluster_id`) REFERENCES `cluster`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vmware_cbt_migration__convert_host_id` FOREIGN KEY (`convert_host_id`) REFERENCES `host`(`id`) ON DELETE SET NULL, + CONSTRAINT `fk_vmware_cbt_migration__storage_pool_id` FOREIGN KEY (`storage_pool_id`) REFERENCES `storage_pool`(`id`) ON DELETE SET NULL, + INDEX `i_vmware_cbt_migration__zone_id` (`zone_id`), + INDEX `i_vmware_cbt_migration__state` (`state`), + INDEX `i_vmware_cbt_migration__source_vm_name` (`source_vm_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'host_name', 'varchar(255) COMMENT "Target VM host name"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'source_username', 'varchar(255) COMMENT "Stored source vCenter username for external VMware CBT migrations"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'source_password', 'varchar(1024) COMMENT "Encrypted stored source vCenter password for external VMware CBT migrations"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'template_id', 'bigint unsigned COMMENT "Template ID for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'service_offering_id', 'bigint unsigned COMMENT "Service offering ID for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'guest_os_id', 'bigint unsigned COMMENT "Guest OS ID for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'data_disk_offering_map', 'text COMMENT "JSON data disk to disk offering ID map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'nic_network_map', 'text COMMENT "JSON NIC to network ID map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'nic_ip_address_map', 'text COMMENT "JSON NIC to IPv4 address map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'import_details', 'text COMMENT "JSON details map for CloudStack VM import"'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vmware_cbt_migration', 'forced', 'tinyint(1) NOT NULL DEFAULT 0 COMMENT "Whether duplicate NIC MAC addresses are allowed during CloudStack VM import"'); + +INSERT INTO `cloud`.`configuration` (`category`, `instance`, `component`, `name`, `value`, `description`, `default_value`, `updated`, `scope`, `is_dynamic`) +VALUES ('Advanced', 'DEFAULT', 'VmwareCbtMigrationManagerImpl', 'vmware.cbt.allow.non.inplace.finalization', 'false', + 'If true, VMware CBT cutover may fall back to regular virt-v2v finalization for qcow2 file targets when true in-place finalization is unavailable. The fallback stages temporary data on the selected primary storage and requires additional free space.', + 'false', NOW(), 1, 1) +ON DUPLICATE KEY UPDATE `category` = VALUES(`category`), `component` = VALUES(`component`), + `description` = VALUES(`description`), `default_value` = VALUES(`default_value`), + `updated` = NOW(), `scope` = VALUES(`scope`), `is_dynamic` = VALUES(`is_dynamic`); + +INSERT INTO `cloud`.`configuration` (`category`, `instance`, `component`, `name`, `value`, `description`, `default_value`, `updated`, `scope`, `is_dynamic`) +VALUES ('Advanced', 'DEFAULT', 'VmwareCbtMigrationManagerImpl', 'vmware.cbt.migration.agent.command.timeout', '86400', + 'Timeout in seconds for long-running VMware CBT data-plane commands dispatched to the KVM agent, including initial full sync, delta sync, final delta sync, and cutover finalization.', + '86400', NOW(), 1, 1) +ON DUPLICATE KEY UPDATE `category` = VALUES(`category`), `component` = VALUES(`component`), + `description` = VALUES(`description`), `default_value` = VALUES(`default_value`), + `updated` = NOW(), `scope` = VALUES(`scope`), `is_dynamic` = VALUES(`is_dynamic`); + +CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration_disk` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `migration_id` bigint unsigned NOT NULL COMMENT 'VMware CBT migration ID', + `source_disk_id` varchar(255) COMMENT 'Source VMware disk key or label', + `source_disk_device_key` int COMMENT 'Source VMware virtual disk device key for QueryChangedDiskAreas', + `source_disk_path` varchar(1024) COMMENT 'Source VMware disk path', + `datastore_name` varchar(255) COMMENT 'Source VMware datastore name', + `capacity_bytes` bigint unsigned COMMENT 'Source disk capacity in bytes', + `target_path` varchar(1024) COMMENT 'Target KVM disk path', + `target_format` varchar(32) COMMENT 'Target KVM disk format', + `change_id` varchar(255) COMMENT 'Latest VMware CBT change ID', + `snapshot_moref` varchar(255) COMMENT 'Latest VMware snapshot managed object reference', + `state` varchar(32) NOT NULL COMMENT 'Disk synchronization state', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_vmware_cbt_migration_disk__migration_id` FOREIGN KEY (`migration_id`) REFERENCES `vmware_cbt_migration`(`id`) ON DELETE CASCADE, + INDEX `i_vmware_cbt_migration_disk__migration_id` (`migration_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`vmware_cbt_migration_cycle` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40) NOT NULL COMMENT 'UUID', + `migration_id` bigint unsigned NOT NULL COMMENT 'VMware CBT migration ID', + `cycle_number` int unsigned NOT NULL COMMENT 'CBT delta cycle number', + `snapshot_moref` varchar(255) COMMENT 'VMware snapshot managed object reference used for the cycle', + `changed_bytes` bigint unsigned COMMENT 'Changed bytes copied in this cycle', + `dirty_rate` bigint unsigned COMMENT 'Changed bytes per second in this cycle', + `duration` bigint unsigned COMMENT 'Cycle duration in milliseconds', + `state` varchar(32) NOT NULL COMMENT 'CBT delta cycle state', + `description` varchar(1024) COMMENT 'Cycle description or error message', + `created` datetime NOT NULL COMMENT 'date created', + `updated` datetime COMMENT 'date updated if not null', + `removed` datetime COMMENT 'date removed if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_vmware_cbt_migration_cycle__migration_id` FOREIGN KEY (`migration_id`) REFERENCES `vmware_cbt_migration`(`id`) ON DELETE CASCADE, + UNIQUE KEY `uc_vmware_cbt_migration_cycle__migration_id__cycle_number` (`migration_id`, `cycle_number`), + INDEX `i_vmware_cbt_migration_cycle__migration_id` (`migration_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- InstanceBootGroup: ordered boot sequencing for VMs and InstanceGroups +CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group` ( + `id` bigint unsigned NOT NULL UNIQUE AUTO_INCREMENT, + `uuid` varchar(40) NOT NULL, + `name` varchar(255) NOT NULL, + `description` varchar(4096) DEFAULT NULL, + `account_id` bigint unsigned NOT NULL COMMENT 'owner; foreign key to account table', + `domain_id` bigint unsigned NOT NULL, + `created` datetime NOT NULL, + `removed` datetime DEFAULT NULL COMMENT 'date the group was soft-deleted', + PRIMARY KEY (`id`), + CONSTRAINT `uc_instance_boot_group__uuid` UNIQUE (`uuid`), + CONSTRAINT `fk_instance_boot_group__account_id` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`), + CONSTRAINT `fk_instance_boot_group__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_member` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(40) NOT NULL, + `boot_group_id` bigint unsigned NOT NULL, + `member_type` varchar(32) NOT NULL COMMENT 'VirtualMachine or InstanceGroup', + `member_id` bigint unsigned NOT NULL, + `boot_order` int NOT NULL DEFAULT 0, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `uc_instance_boot_group_member__uuid` UNIQUE (`uuid`), + CONSTRAINT `uq_instance_boot_group_member__member` UNIQUE (`member_type`, `member_id`), + CONSTRAINT `fk_instance_boot_group_member__group_id` FOREIGN KEY (`boot_group_id`) REFERENCES `instance_boot_group` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- InstanceBootGroupReadinessRule: a readiness rule always belongs to exactly one boot group and +-- references either a VirtualMachine or InstanceGroup item within it +CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_readiness_rule` ( + `id` bigint unsigned NOT NULL UNIQUE AUTO_INCREMENT, + `uuid` varchar(40) NOT NULL, + `name` varchar(255) NOT NULL, + `boot_group_id` bigint unsigned NOT NULL, + `item_type` varchar(32) NOT NULL COMMENT 'VirtualMachine or InstanceGroup', + `item_id` bigint unsigned NOT NULL, + `rule_type` varchar(64) NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `created` datetime NOT NULL, + `removed` datetime DEFAULT NULL COMMENT 'date the rule was soft-deleted', + PRIMARY KEY (`id`), + CONSTRAINT `uc_instance_boot_group_readiness_rule__uuid` UNIQUE (`uuid`), + CONSTRAINT `fk_instance_boot_group_readiness_rule__group_id` FOREIGN KEY (`boot_group_id`) REFERENCES `instance_boot_group` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Generic key/value config for a readiness rule (port/protocol, script content, threshold, ...); +CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_readiness_rule_details` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `rule_id` bigint unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `value` text DEFAULT NULL, + `display` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Whether detail be displayed to the end user', + PRIMARY KEY (`id`), + CONSTRAINT `fk_instance_boot_group_readiness_rule_details__rule_id` FOREIGN KEY (`rule_id`) REFERENCES `instance_boot_group_readiness_rule` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Last cached evaluation result for a readiness rule, upserted in place (no history, by design). +CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_readiness_check_result` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `rule_id` bigint unsigned NOT NULL, + `vm_id` bigint unsigned NOT NULL DEFAULT 0, + `status` varchar(32) NOT NULL DEFAULT 'Unknown', + `message` varchar(4096) DEFAULT NULL, + `checked_on` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_instance_boot_group_readiness_check_result__rule_vm` (`rule_id`, `vm_id`), + CONSTRAINT `fk_instance_boot_group_readiness_check_result__rule_id` FOREIGN KEY (`rule_id`) REFERENCES `instance_boot_group_readiness_rule` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_details` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `boot_group_id` bigint unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `value` varchar(255) DEFAULT NULL, + `display` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Whether detail be displayed to the end user', + PRIMARY KEY (`id`), + CONSTRAINT `fk_instance_boot_group_details__group_id` FOREIGN KEY (`boot_group_id`) REFERENCES `instance_boot_group` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.instance_boot_group_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.instance_boot_group_view.sql new file mode 100644 index 000000000000..7547354f9034 --- /dev/null +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.instance_boot_group_view.sql @@ -0,0 +1,47 @@ +-- Licensed to the Apache Software Foundation (ASF) under one +-- or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information +-- regarding copyright ownership. The ASF licenses this file +-- to you under the Apache License, Version 2.0 (the +-- "License"); you may not use this file except in compliance +-- with the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, +-- software distributed under the License is distributed on an +-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +-- KIND, either express or implied. See the License for the +-- specific language governing permissions and limitations +-- under the License. + +-- VIEW `cloud`.`instance_boot_group_view`; + +DROP VIEW IF EXISTS `cloud`.`instance_boot_group_view`; +CREATE VIEW `cloud`.`instance_boot_group_view` AS +SELECT + instance_boot_group.id, + instance_boot_group.uuid, + instance_boot_group.name, + instance_boot_group.description, + instance_boot_group.created, + instance_boot_group.removed, + account.id account_id, + account.uuid account_uuid, + account.account_name account_name, + account.type account_type, + domain.id domain_id, + domain.uuid domain_uuid, + domain.name domain_name, + domain.path domain_path, + projects.id project_id, + projects.uuid project_uuid, + projects.name project_name +FROM + `cloud`.`instance_boot_group` + INNER JOIN + `cloud`.`account` ON instance_boot_group.account_id = account.id + INNER JOIN + `cloud`.`domain` ON instance_boot_group.domain_id = domain.id + LEFT JOIN + `cloud`.`projects` ON projects.project_account_id = instance_boot_group.account_id; diff --git a/engine/schema/src/test/java/com/cloud/network/dao/NetworkDaoImplTest.java b/engine/schema/src/test/java/com/cloud/network/dao/NetworkDaoImplTest.java index a78eab568af0..dd4bc175d0ee 100644 --- a/engine/schema/src/test/java/com/cloud/network/dao/NetworkDaoImplTest.java +++ b/engine/schema/src/test/java/com/cloud/network/dao/NetworkDaoImplTest.java @@ -25,10 +25,12 @@ import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import java.util.Collections; import java.util.List; @RunWith(MockitoJUnitRunner.class) @@ -61,4 +63,26 @@ public void listByPhysicalNetworkTrafficTypeTestSetParametersValidation() { Mockito.verify(searchCriteriaNetworkVoMock, Mockito.times(Networks.TrafficType.values().length)).setParameters("physicalNetworkId", expectedPhysicalNetwork); } + + @Test + public void listByIdsTestNullOrEmptyIdsReturnsEmptyList() { + NetworkDaoImpl networkDaoImplSpy = Mockito.spy(NetworkDaoImpl.class); + + Assert.assertTrue(networkDaoImplSpy.listByIds(null).isEmpty()); + Assert.assertTrue(networkDaoImplSpy.listByIds(Collections.emptyList()).isEmpty()); + Mockito.verify(networkDaoImplSpy, Mockito.never()).listBy(Mockito.any(SearchCriteria.class)); + } + + @Test + public void listByIdsTestBuildsInClause() { + NetworkDaoImpl networkDaoImplSpy = Mockito.spy(NetworkDaoImpl.class); + Mockito.doReturn(listNetworkVoMock).when(networkDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + List result = networkDaoImplSpy.listByIds(List.of(1L, 2L, 3L)); + + Assert.assertEquals(listNetworkVoMock, result); + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(networkDaoImplSpy).listBy(scCaptor.capture()); + Assert.assertEquals("networks.id IN (?,?,?)", scCaptor.getValue().getWhereClause().trim()); + } } diff --git a/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupDaoImplTest.java b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupDaoImplTest.java new file mode 100644 index 000000000000..6aa5f2813c53 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupDaoImplTest.java @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupVO; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Attribute; +import com.cloud.utils.db.SearchCriteria; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceBootGroupDaoImplTest { + + @Spy + InstanceBootGroupDaoImpl instanceBootGroupDaoImplSpy; + + private static final long ACCOUNT_ID = 10L; + private static final String NAME = "boot-group-1"; + + private Map paramMap(SearchCriteria sc) { + Map map = new HashMap<>(); + for (Pair pair : sc.getValues()) { + map.put(pair.first().getColumnName(), pair.second()); + } + return map; + } + + @SuppressWarnings("unchecked") + @Test + public void testListByAccountId() { + List expected = new ArrayList<>(); + expected.add(Mockito.mock(InstanceBootGroupVO.class)); + + Mockito.doReturn(expected).when(instanceBootGroupDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + List result = instanceBootGroupDaoImplSpy.listByAccountId(ACCOUNT_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupDaoImplSpy).listBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(ACCOUNT_ID, params.get("account_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testIsNameInUseTrueWhenResultsFound() { + List found = Collections.singletonList(Mockito.mock(InstanceBootGroupVO.class)); + Mockito.doReturn(found).when(instanceBootGroupDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + boolean result = instanceBootGroupDaoImplSpy.isNameInUse(ACCOUNT_ID, NAME); + + Assert.assertTrue(result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupDaoImplSpy).listBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(ACCOUNT_ID, params.get("account_id")); + Assert.assertEquals(NAME, params.get("name")); + } + + @SuppressWarnings("unchecked") + @Test + public void testIsNameInUseFalseWhenNoResults() { + Mockito.doReturn(new ArrayList<>()).when(instanceBootGroupDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + boolean result = instanceBootGroupDaoImplSpy.isNameInUse(ACCOUNT_ID, NAME); + + Assert.assertFalse(result); + } +} diff --git a/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupDetailsDaoImplTest.java b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupDetailsDaoImplTest.java new file mode 100644 index 000000000000..3fe22b5889fe --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupDetailsDaoImplTest.java @@ -0,0 +1,143 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupDetailsVO; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Attribute; +import com.cloud.utils.db.SearchCriteria; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceBootGroupDetailsDaoImplTest { + + @Spy + InstanceBootGroupDetailsDaoImpl instanceBootGroupDetailsDaoImplSpy; + + private static final long BOOT_GROUP_ID = 7L; + private static final String NAME = "readiness.timeout"; + private static final String VALUE = "300"; + + private Map paramMap(SearchCriteria sc) { + Map map = new HashMap<>(); + for (Pair pair : sc.getValues()) { + map.put(pair.first().getColumnName(), pair.second()); + } + return map; + } + + @SuppressWarnings("unchecked") + @Test + public void testGetDetailWhenExists() { + InstanceBootGroupDetailsVO detail = Mockito.mock(InstanceBootGroupDetailsVO.class); + Mockito.when(detail.getValue()).thenReturn(VALUE); + Mockito.doReturn(detail).when(instanceBootGroupDetailsDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + + String result = instanceBootGroupDetailsDaoImplSpy.getDetail(BOOT_GROUP_ID, NAME); + + Assert.assertEquals(VALUE, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupDetailsDaoImplSpy).findOneBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + Assert.assertEquals(NAME, params.get("name")); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetDetailWhenNotExists() { + Mockito.doReturn(null).when(instanceBootGroupDetailsDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + + String result = instanceBootGroupDetailsDaoImplSpy.getDetail(BOOT_GROUP_ID, NAME); + + Assert.assertNull(result); + } + + @SuppressWarnings("unchecked") + @Test + public void testSetDetailInsertsWhenNotExisting() { + Mockito.doReturn(null).when(instanceBootGroupDetailsDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + Mockito.doReturn(null).when(instanceBootGroupDetailsDaoImplSpy).persist(Mockito.any(InstanceBootGroupDetailsVO.class)); + + instanceBootGroupDetailsDaoImplSpy.setDetail(BOOT_GROUP_ID, NAME, VALUE); + + ArgumentCaptor voCaptor = ArgumentCaptor.forClass(InstanceBootGroupDetailsVO.class); + Mockito.verify(instanceBootGroupDetailsDaoImplSpy).persist(voCaptor.capture()); + Assert.assertEquals(BOOT_GROUP_ID, voCaptor.getValue().getResourceId()); + Assert.assertEquals(NAME, voCaptor.getValue().getName()); + Assert.assertEquals(VALUE, voCaptor.getValue().getValue()); + Mockito.verify(instanceBootGroupDetailsDaoImplSpy, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void testSetDetailUpdatesWhenExisting() { + InstanceBootGroupDetailsVO existing = new InstanceBootGroupDetailsVO(BOOT_GROUP_ID, NAME, "old-value"); + Mockito.doReturn(existing).when(instanceBootGroupDetailsDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + Mockito.doReturn(true).when(instanceBootGroupDetailsDaoImplSpy).update(Mockito.anyLong(), Mockito.any()); + + instanceBootGroupDetailsDaoImplSpy.setDetail(BOOT_GROUP_ID, NAME, VALUE); + + Assert.assertEquals(VALUE, existing.getValue()); + Mockito.verify(instanceBootGroupDetailsDaoImplSpy).update(existing.getId(), existing); + Mockito.verify(instanceBootGroupDetailsDaoImplSpy, Mockito.never()).persist(Mockito.any(InstanceBootGroupDetailsVO.class)); + } + + @SuppressWarnings("unchecked") + @Test + public void testListDetails() { + List details = new ArrayList<>(); + details.add(new InstanceBootGroupDetailsVO(BOOT_GROUP_ID, "key1", "value1")); + details.add(new InstanceBootGroupDetailsVO(BOOT_GROUP_ID, "key2", "value2")); + Mockito.doReturn(details).when(instanceBootGroupDetailsDaoImplSpy).search(Mockito.any(SearchCriteria.class), Mockito.isNull()); + + Map result = instanceBootGroupDetailsDaoImplSpy.listDetailsKeyPairs(BOOT_GROUP_ID); + + Assert.assertEquals(2, result.size()); + Assert.assertEquals("value1", result.get("key1")); + Assert.assertEquals("value2", result.get("key2")); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupDetailsDaoImplSpy).search(scCaptor.capture(), Mockito.isNull()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testListDetailsEmpty() { + Mockito.doReturn(new ArrayList<>()).when(instanceBootGroupDetailsDaoImplSpy).search(Mockito.any(SearchCriteria.class), Mockito.isNull()); + + Map result = instanceBootGroupDetailsDaoImplSpy.listDetailsKeyPairs(BOOT_GROUP_ID); + + Assert.assertNotNull(result); + Assert.assertTrue(result.isEmpty()); + } +} diff --git a/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupMemberDaoImplTest.java b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupMemberDaoImplTest.java new file mode 100644 index 000000000000..e2719a17a727 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupMemberDaoImplTest.java @@ -0,0 +1,144 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMemberVO; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Attribute; +import com.cloud.utils.db.SearchCriteria; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceBootGroupMemberDaoImplTest { + + @Spy + InstanceBootGroupMemberDaoImpl instanceBootGroupMemberDaoImplSpy; + + private static final long BOOT_GROUP_ID = 5L; + private static final long MEMBER_ID = 42L; + + private Map paramMap(SearchCriteria sc) { + Map map = new HashMap<>(); + for (Pair pair : sc.getValues()) { + map.put(pair.first().getColumnName(), pair.second()); + } + return map; + } + + @SuppressWarnings("unchecked") + @Test + public void testListByBootGroupId() { + List expected = List.of(Mockito.mock(InstanceBootGroupMemberVO.class)); + Mockito.doReturn(expected).when(instanceBootGroupMemberDaoImplSpy).listBy(Mockito.any(SearchCriteria.class), Mockito.isNull()); + + List result = instanceBootGroupMemberDaoImplSpy.listByBootGroupId(BOOT_GROUP_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupMemberDaoImplSpy).listBy(scCaptor.capture(), Mockito.isNull()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testSearchAndCountByBootGroupId() { + Pair, Integer> expected = new Pair<>(new ArrayList<>(), 0); + Mockito.doReturn(expected).when(instanceBootGroupMemberDaoImplSpy).searchAndCount(Mockito.any(SearchCriteria.class), Mockito.isNull()); + + Pair, Integer> result = instanceBootGroupMemberDaoImplSpy.searchAndCountByBootGroupId(BOOT_GROUP_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupMemberDaoImplSpy).searchAndCount(scCaptor.capture(), Mockito.isNull()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testSearchAndCountByBootGroupIdAndType() { + Pair, Integer> expected = new Pair<>(new ArrayList<>(), 0); + Mockito.doReturn(expected).when(instanceBootGroupMemberDaoImplSpy).searchAndCount(Mockito.any(SearchCriteria.class), Mockito.isNull()); + + Pair, Integer> result = instanceBootGroupMemberDaoImplSpy + .searchAndCountByBootGroupIdAndType(BOOT_GROUP_ID, InstanceBootGroupMember.MemberType.VirtualMachine); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupMemberDaoImplSpy).searchAndCount(scCaptor.capture(), Mockito.isNull()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + Assert.assertEquals(InstanceBootGroupMember.MemberType.VirtualMachine, params.get("member_type")); + } + + @SuppressWarnings("unchecked") + @Test + public void testFindByMember() { + InstanceBootGroupMemberVO expected = Mockito.mock(InstanceBootGroupMemberVO.class); + Mockito.doReturn(expected).when(instanceBootGroupMemberDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + + InstanceBootGroupMemberVO result = instanceBootGroupMemberDaoImplSpy.findByMember(InstanceBootGroupMember.MemberType.InstanceGroup, MEMBER_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupMemberDaoImplSpy).findOneBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(InstanceBootGroupMember.MemberType.InstanceGroup, params.get("member_type")); + Assert.assertEquals(MEMBER_ID, params.get("member_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testFindByMemberNotFound() { + Mockito.doReturn(null).when(instanceBootGroupMemberDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + + InstanceBootGroupMemberVO result = instanceBootGroupMemberDaoImplSpy.findByMember(InstanceBootGroupMember.MemberType.VirtualMachine, MEMBER_ID); + + Assert.assertNull(result); + } + + @SuppressWarnings("unchecked") + @Test + public void testDeleteByBootGroupId() { + Mockito.doReturn(1).when(instanceBootGroupMemberDaoImplSpy).expunge(Mockito.any(SearchCriteria.class)); + + instanceBootGroupMemberDaoImplSpy.deleteByBootGroupId(BOOT_GROUP_ID); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupMemberDaoImplSpy).expunge(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + } +} diff --git a/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDaoImplTest.java b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDaoImplTest.java new file mode 100644 index 000000000000..29b76943552e --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessCheckResultDaoImplTest.java @@ -0,0 +1,120 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessCheckResultVO; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Attribute; +import com.cloud.utils.db.SearchCriteria; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceBootGroupReadinessCheckResultDaoImplTest { + + @Spy + InstanceBootGroupReadinessCheckResultDaoImpl instanceBootGroupReadinessCheckResultDaoImplSpy; + + private static final long RULE_ID = 21L; + private static final long VM_ID = 99L; + + private Map paramMap(SearchCriteria sc) { + Map map = new HashMap<>(); + for (Pair pair : sc.getValues()) { + map.put(pair.first().getColumnName(), pair.second()); + } + return map; + } + + @SuppressWarnings("unchecked") + @Test + public void testFindByRuleAndVm() { + InstanceBootGroupReadinessCheckResultVO expected = Mockito.mock(InstanceBootGroupReadinessCheckResultVO.class); + Mockito.doReturn(expected).when(instanceBootGroupReadinessCheckResultDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + + InstanceBootGroupReadinessCheckResultVO result = instanceBootGroupReadinessCheckResultDaoImplSpy.findByRuleAndVm(RULE_ID, VM_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupReadinessCheckResultDaoImplSpy).findOneBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(RULE_ID, params.get("rule_id")); + Assert.assertEquals(VM_ID, params.get("vm_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testUpsertInsertsWhenNoExistingResult() { + Mockito.doReturn(null).when(instanceBootGroupReadinessCheckResultDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + Mockito.doReturn(null).when(instanceBootGroupReadinessCheckResultDaoImplSpy).persist(Mockito.any(InstanceBootGroupReadinessCheckResultVO.class)); + + Date checkedOn = new Date(); + instanceBootGroupReadinessCheckResultDaoImplSpy.upsert(RULE_ID, VM_ID, InstanceBootGroupReadinessRule.Status.Ready, "all good", checkedOn); + + ArgumentCaptor voCaptor = ArgumentCaptor.forClass(InstanceBootGroupReadinessCheckResultVO.class); + Mockito.verify(instanceBootGroupReadinessCheckResultDaoImplSpy).persist(voCaptor.capture()); + Assert.assertEquals(RULE_ID, voCaptor.getValue().getRuleId()); + Assert.assertEquals(VM_ID, voCaptor.getValue().getVmId()); + Assert.assertEquals(InstanceBootGroupReadinessRule.Status.Ready, voCaptor.getValue().getStatus()); + Assert.assertEquals("all good", voCaptor.getValue().getMessage()); + Assert.assertEquals(checkedOn, voCaptor.getValue().getCheckedOn()); + Mockito.verify(instanceBootGroupReadinessCheckResultDaoImplSpy, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void testUpsertUpdatesWhenExistingResult() { + InstanceBootGroupReadinessCheckResultVO existing = new InstanceBootGroupReadinessCheckResultVO( + RULE_ID, VM_ID, InstanceBootGroupReadinessRule.Status.Unknown, "old", new Date(0)); + Mockito.doReturn(existing).when(instanceBootGroupReadinessCheckResultDaoImplSpy).findOneBy(Mockito.any(SearchCriteria.class)); + Mockito.doReturn(true).when(instanceBootGroupReadinessCheckResultDaoImplSpy).update(Mockito.anyLong(), Mockito.any()); + + Date checkedOn = new Date(); + instanceBootGroupReadinessCheckResultDaoImplSpy.upsert(RULE_ID, VM_ID, InstanceBootGroupReadinessRule.Status.NotReady, "still booting", checkedOn); + + Assert.assertEquals(InstanceBootGroupReadinessRule.Status.NotReady, existing.getStatus()); + Assert.assertEquals("still booting", existing.getMessage()); + Assert.assertEquals(checkedOn, existing.getCheckedOn()); + Mockito.verify(instanceBootGroupReadinessCheckResultDaoImplSpy).update(existing.getId(), existing); + Mockito.verify(instanceBootGroupReadinessCheckResultDaoImplSpy, Mockito.never()).persist(Mockito.any(InstanceBootGroupReadinessCheckResultVO.class)); + } + + @SuppressWarnings("unchecked") + @Test + public void testDeleteByRuleId() { + Mockito.doReturn(1).when(instanceBootGroupReadinessCheckResultDaoImplSpy).expunge(Mockito.any(SearchCriteria.class)); + + instanceBootGroupReadinessCheckResultDaoImplSpy.deleteByRuleId(RULE_ID); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupReadinessCheckResultDaoImplSpy).expunge(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(RULE_ID, params.get("rule_id")); + } +} diff --git a/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDaoImplTest.java b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDaoImplTest.java new file mode 100644 index 000000000000..372ddc536ac2 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDaoImplTest.java @@ -0,0 +1,147 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupMember; +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessRuleVO; +import org.apache.cloudstack.vm.bootgroup.readiness.InstanceBootGroupReadinessRule; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Attribute; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.SearchCriteria; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceBootGroupReadinessRuleDaoImplTest { + + @Spy + InstanceBootGroupReadinessRuleDaoImpl instanceBootGroupReadinessRuleDaoImplSpy; + + private static final long BOOT_GROUP_ID = 3L; + private static final long ITEM_ID = 8L; + + private Map paramMap(SearchCriteria sc) { + Map map = new HashMap<>(); + for (Pair pair : sc.getValues()) { + map.put(pair.first().getColumnName(), pair.second()); + } + return map; + } + + @SuppressWarnings("unchecked") + @Test + public void testSearchAndCountByBootGroupIdWithAllFiltersSet() { + Pair, Integer> expected = new Pair<>(new ArrayList<>(), 0); + Mockito.doReturn(expected).when(instanceBootGroupReadinessRuleDaoImplSpy) + .searchAndCount(Mockito.any(SearchCriteria.class), Mockito.any(Filter.class)); + + Pair, Integer> result = instanceBootGroupReadinessRuleDaoImplSpy.searchAndCountByBootGroupId( + BOOT_GROUP_ID, 1L, InstanceBootGroupMember.MemberType.VirtualMachine, ITEM_ID, + InstanceBootGroupReadinessRule.RuleType.Ping, "healthcheck", 0L, 10L); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(Filter.class); + Mockito.verify(instanceBootGroupReadinessRuleDaoImplSpy).searchAndCount(scCaptor.capture(), filterCaptor.capture()); + + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + Assert.assertEquals(1L, params.get("id")); + Assert.assertEquals(InstanceBootGroupMember.MemberType.VirtualMachine, params.get("item_type")); + Assert.assertEquals(ITEM_ID, params.get("item_id")); + Assert.assertEquals(InstanceBootGroupReadinessRule.RuleType.Ping, params.get("rule_type")); + Assert.assertEquals("%healthcheck%", params.get("name")); + Assert.assertNotNull(filterCaptor.getValue()); + } + + @SuppressWarnings("unchecked") + @Test + public void testSearchAndCountByBootGroupIdWithOnlyMandatoryParams() { + Pair, Integer> expected = new Pair<>(new ArrayList<>(), 0); + Mockito.doReturn(expected).when(instanceBootGroupReadinessRuleDaoImplSpy) + .searchAndCount(Mockito.any(SearchCriteria.class), Mockito.any(Filter.class)); + + Pair, Integer> result = instanceBootGroupReadinessRuleDaoImplSpy.searchAndCountByBootGroupId( + BOOT_GROUP_ID, null, null, null, null, null, null, null); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupReadinessRuleDaoImplSpy).searchAndCount(scCaptor.capture(), Mockito.any(Filter.class)); + + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + Assert.assertFalse(params.containsKey("id")); + Assert.assertFalse(params.containsKey("item_type")); + Assert.assertFalse(params.containsKey("item_id")); + Assert.assertFalse(params.containsKey("rule_type")); + Assert.assertFalse(params.containsKey("name")); + } + + @SuppressWarnings("unchecked") + @Test + public void testListEnabledByItem() { + List expected = List.of(Mockito.mock(InstanceBootGroupReadinessRuleVO.class)); + Mockito.doReturn(expected).when(instanceBootGroupReadinessRuleDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + List result = instanceBootGroupReadinessRuleDaoImplSpy.listEnabledByItem( + BOOT_GROUP_ID, InstanceBootGroupMember.MemberType.InstanceGroup, ITEM_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupReadinessRuleDaoImplSpy).listBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + Assert.assertEquals(InstanceBootGroupMember.MemberType.InstanceGroup, params.get("item_type")); + Assert.assertEquals(ITEM_ID, params.get("item_id")); + Assert.assertEquals(Boolean.TRUE, params.get("enabled")); + } + + @SuppressWarnings("unchecked") + @Test + public void testListByItem() { + List expected = List.of(Mockito.mock(InstanceBootGroupReadinessRuleVO.class)); + Mockito.doReturn(expected).when(instanceBootGroupReadinessRuleDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + List result = instanceBootGroupReadinessRuleDaoImplSpy.listByItem( + BOOT_GROUP_ID, InstanceBootGroupMember.MemberType.VirtualMachine, ITEM_ID); + + Assert.assertEquals(expected, result); + + ArgumentCaptor scCaptor = ArgumentCaptor.forClass(SearchCriteria.class); + Mockito.verify(instanceBootGroupReadinessRuleDaoImplSpy).listBy(scCaptor.capture()); + Map params = paramMap(scCaptor.getValue()); + Assert.assertEquals(BOOT_GROUP_ID, params.get("boot_group_id")); + Assert.assertEquals(InstanceBootGroupMember.MemberType.VirtualMachine, params.get("item_type")); + Assert.assertEquals(ITEM_ID, params.get("item_id")); + Assert.assertFalse(params.containsKey("enabled")); + } +} diff --git a/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDaoImplTest.java b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDaoImplTest.java new file mode 100644 index 000000000000..dcfa00f9f362 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/dao/InstanceBootGroupReadinessRuleDetailsDaoImplTest.java @@ -0,0 +1,116 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.vm.bootgroup.InstanceBootGroupReadinessRuleDetailsVO; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.crypt.DBEncryptionUtil; +import com.cloud.utils.db.SearchCriteria; + +@RunWith(MockitoJUnitRunner.class) +public class InstanceBootGroupReadinessRuleDetailsDaoImplTest { + + @Spy + InstanceBootGroupReadinessRuleDetailsDaoImpl instanceBootGroupReadinessRuleDetailsDaoImplSpy; + + private static final long RULE_ID = 15L; + + @SuppressWarnings("unchecked") + @Test + public void testAddDetailNonScriptKeyStoredAsIs() { + Mockito.doReturn(null).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).findDetail(RULE_ID, "port"); + Mockito.doReturn(null).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).persist(Mockito.any(InstanceBootGroupReadinessRuleDetailsVO.class)); + + instanceBootGroupReadinessRuleDetailsDaoImplSpy.addDetail(RULE_ID, "port", "8080", true); + + ArgumentCaptor voCaptor = ArgumentCaptor.forClass(InstanceBootGroupReadinessRuleDetailsVO.class); + Mockito.verify(instanceBootGroupReadinessRuleDetailsDaoImplSpy).persist(voCaptor.capture()); + Assert.assertEquals(RULE_ID, voCaptor.getValue().getResourceId()); + Assert.assertEquals("port", voCaptor.getValue().getName()); + Assert.assertEquals("8080", voCaptor.getValue().getValue()); + } + + @SuppressWarnings("unchecked") + @Test + public void testAddDetailScriptKeyStoredEncrypted() { + Mockito.doReturn(null).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).findDetail(RULE_ID, "script"); + Mockito.doReturn(null).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).persist(Mockito.any(InstanceBootGroupReadinessRuleDetailsVO.class)); + + String plainScript = "#!/bin/bash\necho ok"; + instanceBootGroupReadinessRuleDetailsDaoImplSpy.addDetail(RULE_ID, "script", plainScript, true); + + ArgumentCaptor voCaptor = ArgumentCaptor.forClass(InstanceBootGroupReadinessRuleDetailsVO.class); + Mockito.verify(instanceBootGroupReadinessRuleDetailsDaoImplSpy).persist(voCaptor.capture()); + Assert.assertEquals(RULE_ID, voCaptor.getValue().getResourceId()); + Assert.assertEquals("script", voCaptor.getValue().getName()); + Assert.assertEquals(DBEncryptionUtil.encrypt(plainScript), voCaptor.getValue().getValue()); + } + + @SuppressWarnings("unchecked") + @Test + public void testAddDetailReplacesExistingDetail() { + InstanceBootGroupReadinessRuleDetailsVO existing = new InstanceBootGroupReadinessRuleDetailsVO(RULE_ID, "port", "9090"); + Mockito.doReturn(existing).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).findDetail(RULE_ID, "port"); + Mockito.doReturn(true).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).remove(existing.getId()); + Mockito.doReturn(null).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).persist(Mockito.any(InstanceBootGroupReadinessRuleDetailsVO.class)); + + instanceBootGroupReadinessRuleDetailsDaoImplSpy.addDetail(RULE_ID, "port", "8080", true); + + Mockito.verify(instanceBootGroupReadinessRuleDetailsDaoImplSpy).remove(existing.getId()); + ArgumentCaptor voCaptor = ArgumentCaptor.forClass(InstanceBootGroupReadinessRuleDetailsVO.class); + Mockito.verify(instanceBootGroupReadinessRuleDetailsDaoImplSpy).persist(voCaptor.capture()); + Assert.assertEquals("8080", voCaptor.getValue().getValue()); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetDetailsDecryptsScriptKeyAndLeavesOthersAsIs() { + String encryptedScript = DBEncryptionUtil.encrypt("plain-script-body"); + List details = new ArrayList<>(); + details.add(new InstanceBootGroupReadinessRuleDetailsVO(RULE_ID, "script", encryptedScript)); + details.add(new InstanceBootGroupReadinessRuleDetailsVO(RULE_ID, "port", "8080")); + Mockito.doReturn(details).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + Map result = instanceBootGroupReadinessRuleDetailsDaoImplSpy.getDetails(RULE_ID); + + Assert.assertEquals(2, result.size()); + Assert.assertEquals(DBEncryptionUtil.decrypt(encryptedScript), result.get("script")); + Assert.assertEquals("8080", result.get("port")); + } + + @SuppressWarnings("unchecked") + @Test + public void testGetDetailsEmpty() { + Mockito.doReturn(new ArrayList<>()).when(instanceBootGroupReadinessRuleDetailsDaoImplSpy).listBy(Mockito.any(SearchCriteria.class)); + + Map result = instanceBootGroupReadinessRuleDetailsDaoImplSpy.getDetails(RULE_ID); + + Assert.assertNotNull(result); + Assert.assertTrue(result.isEmpty()); + } +} diff --git a/framework/db/src/main/java/com/cloud/utils/db/GenericDaoBase.java b/framework/db/src/main/java/com/cloud/utils/db/GenericDaoBase.java index 3fb5728545d6..8fc10cf37ab2 100644 --- a/framework/db/src/main/java/com/cloud/utils/db/GenericDaoBase.java +++ b/framework/db/src/main/java/com/cloud/utils/db/GenericDaoBase.java @@ -34,6 +34,9 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -67,7 +70,6 @@ import org.apache.commons.lang3.exception.ExceptionUtils; import com.amazonaws.util.CollectionUtils; -import com.cloud.utils.DateUtil; import com.cloud.utils.NumbersUtil; import com.cloud.utils.Pair; import com.cloud.utils.Ternary; @@ -129,6 +131,28 @@ public abstract class GenericDaoBase extends Compone protected final static TimeZone s_gmtTimeZone = TimeZone.getTimeZone("GMT"); + /** + * Returns a fresh GMT {@link Calendar} for a single JDBC get/set timestamp call. Calendar is + * mutable and JDBC drivers may mutate the instance passed to them, so a new one is used per call + * rather than sharing a single instance across concurrent DAO operations. + */ + protected static Calendar gmtCalendar() { + return Calendar.getInstance(s_gmtTimeZone); + } + + /** + * Returns the SQL type ({@link Types}) matching the temporal flag of the given attribute, so a + * null date/time/timestamp column is bound with the correct type instead of always TIMESTAMP. + */ + protected static int temporalSqlType(Attribute attr) { + if (attr.is(Attribute.Flag.Date)) { + return Types.DATE; + } else if (attr.is(Attribute.Flag.Time)) { + return Types.TIME; + } + return Types.TIMESTAMP; + } + protected final static Map, GenericDao> s_daoMaps = new ConcurrentHashMap, GenericDao>(71); private final ConversionSupport _conversionSupport; @@ -598,20 +622,16 @@ protected void setField(Object entity, Field field, ResultSet rs, int index) thr field.set(entity, rs.getInt(index)); } } else if (type == Date.class) { - final Object data = rs.getDate(index); - if (data == null) { - field.set(entity, null); - return; - } - field.set(entity, DateUtil.parseDateString(s_gmtTimeZone, rs.getString(index))); + final Timestamp ts = rs.getTimestamp(index, gmtCalendar()); + field.set(entity, ts == null ? null : new Date(ts.getTime())); } else if (type == Calendar.class) { - final Object data = rs.getDate(index); + final Timestamp data = rs.getTimestamp(index, gmtCalendar()); if (data == null) { field.set(entity, null); return; } - final Calendar cal = Calendar.getInstance(); - cal.setTime(DateUtil.parseDateString(s_gmtTimeZone, rs.getString(index))); + final Calendar cal = Calendar.getInstance(s_gmtTimeZone); + cal.setTime(data); field.set(entity, cal); } else if (type == boolean.class) { field.setBoolean(entity, rs.getBoolean(index)); @@ -732,11 +752,11 @@ protected static M getObject(Class type, ResultSet rs, int index) throws return (M) (Long) rs.getLong(index); } } else if (type == Date.class) { - final Object data = rs.getDate(index); - if (data == null) { + final Timestamp ts = rs.getTimestamp(index, gmtCalendar()); + if (ts == null) { return null; } else { - return (M)DateUtil.parseDateString(s_gmtTimeZone, rs.getString(index)); + return (M) new Date(ts.getTime()); } } else if (type == short.class) { return (M) (Short) rs.getShort(index); @@ -779,12 +799,12 @@ protected static M getObject(Class type, ResultSet rs, int index) throws return (M) (Byte) rs.getByte(index); } } else if (type == Calendar.class) { - final Object data = rs.getDate(index); + final Timestamp data = rs.getTimestamp(index, gmtCalendar()); if (data == null) { return null; } else { - final Calendar cal = Calendar.getInstance(); - cal.setTime(DateUtil.parseDateString(s_gmtTimeZone, rs.getString(index))); + final Calendar cal = Calendar.getInstance(s_gmtTimeZone); + cal.setTime(data); return (M)cal; } } else if (type == byte[].class) { @@ -1716,7 +1736,12 @@ protected void insertElementCollection(T entity, Attribute idAttribute, ID id, M while (en.hasMoreElements()) { pstmt = txn.prepareAutoCloseStatement(ec.insertSql); if (ec.targetClass == Date.class) { - pstmt.setString(1, DateUtil.getDateDisplayString(s_gmtTimeZone, (Date)en.nextElement())); + Date d = (Date) en.nextElement(); + if (d == null) { + pstmt.setNull(1, Types.TIMESTAMP); + } else { + pstmt.setTimestamp(1, new Timestamp(d.getTime()), gmtCalendar()); + } } else { pstmt.setObject(1, en.nextElement()); } @@ -1820,28 +1845,28 @@ protected void prepareAttribute(final int j, final PreparedStatement pstmt, fina } else if (attr.field.getType() == Date.class) { final Date date = (Date)value; if (date == null || date.equals(DATE_TO_NULL)) { - pstmt.setObject(j, null); + pstmt.setNull(j, temporalSqlType(attr)); return; } if (attr.is(Attribute.Flag.Date)) { - pstmt.setString(j, DateUtil.getDateDisplayString(s_gmtTimeZone, date)); + pstmt.setDate(j, new java.sql.Date(date.getTime()), gmtCalendar()); } else if (attr.is(Attribute.Flag.TimeStamp)) { - pstmt.setString(j, DateUtil.getDateDisplayString(s_gmtTimeZone, date)); + pstmt.setTimestamp(j, new Timestamp(date.getTime()), gmtCalendar()); } else if (attr.is(Attribute.Flag.Time)) { - pstmt.setString(j, DateUtil.getDateDisplayString(s_gmtTimeZone, date)); + pstmt.setTime(j, new java.sql.Time(date.getTime()), gmtCalendar()); } } else if (attr.field.getType() == Calendar.class) { final Calendar cal = (Calendar)value; if (cal == null) { - pstmt.setObject(j, null); + pstmt.setNull(j, temporalSqlType(attr)); return; } if (attr.is(Attribute.Flag.Date)) { - pstmt.setString(j, DateUtil.getDateDisplayString(s_gmtTimeZone, cal.getTime())); + pstmt.setDate(j, new java.sql.Date(cal.getTimeInMillis()), gmtCalendar()); } else if (attr.is(Attribute.Flag.TimeStamp)) { - pstmt.setString(j, DateUtil.getDateDisplayString(s_gmtTimeZone, cal.getTime())); + pstmt.setTimestamp(j, new Timestamp(cal.getTimeInMillis()), gmtCalendar()); } else if (attr.is(Attribute.Flag.Time)) { - pstmt.setString(j, DateUtil.getDateDisplayString(s_gmtTimeZone, cal.getTime())); + pstmt.setTime(j, new Time(cal.getTimeInMillis()), gmtCalendar()); } } else if (attr.field.getType().isEnum()) { final Enumerated enumerated = attr.field.getAnnotation(Enumerated.class); @@ -1975,7 +2000,8 @@ protected void loadCollection(T entity, Attribute attr) { } } else if (ec.targetClass == Date.class) { while (rs.next()) { - lst.add(DateUtil.parseDateString(s_gmtTimeZone, rs.getString(1))); + final Timestamp ts = rs.getTimestamp(1, gmtCalendar()); + lst.add(ts == null ? null : new Date(ts.getTime())); } } else if (ec.targetClass == Boolean.class) { while (rs.next()) { diff --git a/framework/db/src/test/java/com/cloud/utils/db/GenericDaoBaseTest.java b/framework/db/src/test/java/com/cloud/utils/db/GenericDaoBaseTest.java index ebf514f532f7..41a7be4ab867 100644 --- a/framework/db/src/test/java/com/cloud/utils/db/GenericDaoBaseTest.java +++ b/framework/db/src/test/java/com/cloud/utils/db/GenericDaoBaseTest.java @@ -18,9 +18,14 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; import java.util.ArrayList; +import java.util.Calendar; import java.util.Collection; +import java.util.Date; import java.util.List; +import java.util.TimeZone; import org.junit.Assert; import org.junit.Before; @@ -331,4 +336,74 @@ public void testLockOneRandomRowReturnsFirstElement() { Assert.assertNotNull(result); Assert.assertEquals(expectedResult, result); } + + @Test + public void gmtCalendarUsesGmtTimeZone() { + Calendar calendar = GenericDaoBase.gmtCalendar(); + + Assert.assertEquals(TimeZone.getTimeZone("GMT"), calendar.getTimeZone()); + } + + @Test + public void gmtCalendarReturnsFreshInstancePerCall() { + Assert.assertNotSame(GenericDaoBase.gmtCalendar(), GenericDaoBase.gmtCalendar()); + } + + @Test + public void temporalSqlTypeDate() { + Attribute attr = new Attribute("table", "column"); + attr.flags = Attribute.Flag.Date.setTrue(attr.flags); + + Assert.assertEquals(Types.DATE, GenericDaoBase.temporalSqlType(attr)); + } + + @Test + public void temporalSqlTypeTime() { + Attribute attr = new Attribute("table", "column"); + attr.flags = Attribute.Flag.Time.setTrue(attr.flags); + + Assert.assertEquals(Types.TIME, GenericDaoBase.temporalSqlType(attr)); + } + + @Test + public void temporalSqlTypeDefaultsToTimestamp() { + Attribute attr = new Attribute("table", "column"); + attr.flags = Attribute.Flag.TimeStamp.setTrue(attr.flags); + + Assert.assertEquals(Types.TIMESTAMP, GenericDaoBase.temporalSqlType(attr)); + } + + @Test + public void getObjectDateReadsViaGmtTimestamp() throws SQLException { + Timestamp ts = new Timestamp(1_700_000_000_000L); + Mockito.when(resultSet.getTimestamp(Mockito.eq(2), Mockito.any(Calendar.class))).thenReturn(ts); + + Date result = GenericDaoBase.getObject(Date.class, resultSet, 2); + + Assert.assertEquals(ts.getTime(), result.getTime()); + } + + @Test + public void getObjectDateNullTimestampReturnsNull() throws SQLException { + Mockito.when(resultSet.getTimestamp(Mockito.eq(3), Mockito.any(Calendar.class))).thenReturn(null); + + Assert.assertNull(GenericDaoBase.getObject(Date.class, resultSet, 3)); + } + + @Test + public void getObjectCalendarReadsViaGmtTimestamp() throws SQLException { + Timestamp ts = new Timestamp(1_700_000_000_000L); + Mockito.when(resultSet.getTimestamp(Mockito.eq(4), Mockito.any(Calendar.class))).thenReturn(ts); + + Calendar result = GenericDaoBase.getObject(Calendar.class, resultSet, 4); + + Assert.assertEquals(ts.getTime(), result.getTimeInMillis()); + } + + @Test + public void getObjectCalendarNullTimestampReturnsNull() throws SQLException { + Mockito.when(resultSet.getTimestamp(Mockito.eq(5), Mockito.any(Calendar.class))).thenReturn(null); + + Assert.assertNull(GenericDaoBase.getObject(Calendar.class, resultSet, 5)); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java index e98848c01f7d..ff2595d8e215 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java @@ -2014,7 +2014,7 @@ public boolean completeAggregatedExecution(Network network, DeployDestination de } // Query all active User-VM NICs on this network - List nics = nicDao.listByNetworkIdAndType(network.getId(), VirtualMachine.Type.User); + List nics = nicDao.listNonPlaceholderNicsByNetworkIdAndType(network.getId(), VirtualMachine.Type.User); if (CollectionUtils.isEmpty(nics)) { logger.debug("completeAggregatedExecution: no user VM NICs on network={}, skipping", network); return true; diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java index 1cb1cb4e309f..1572adc50085 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java @@ -125,6 +125,10 @@ public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, Integer.class, "vm.job.lock.timeout", "1800", "Time in seconds to wait in acquiring lock to submit a vm worker job", false); private static final ConfigKey HidePassword = new ConfigKey("Advanced", Boolean.class, "log.hide.password", "true", "If set to true, the password is hidden", true, ConfigKey.Scope.Global); + public static final ConfigKey ApiJobPoolSize = new ConfigKey<>("Advanced", Integer.class, "api.job.pool.size", "50", + "Minimum size of the API job executor thread pool. Actual size is the max of this value and db.cloud.maxActive / 2.", false, ConfigKey.Scope.Global); + public static final ConfigKey WorkJobPoolSize = new ConfigKey<>("Advanced", Integer.class, "work.job.pool.size", "50", + "Minimum size of the Worker job executor thread pool. Actual size is the max of this value and db.cloud.maxActive * 2 / 3.", false, ConfigKey.Scope.Global); private static final int ACQUIRE_GLOBAL_LOCK_TIMEOUT_FOR_COOPERATION = 3; // 3 seconds @@ -198,7 +202,7 @@ public String getConfigComponentName() { @Override public ConfigKey[] getConfigKeys() { - return new ConfigKey[] {JobExpireMinutes, JobCancelThresholdMinutes, VmJobLockTimeout, HidePassword}; + return new ConfigKey[] {JobExpireMinutes, JobCancelThresholdMinutes, VmJobLockTimeout, HidePassword, ApiJobPoolSize, WorkJobPoolSize}; } @Override @@ -1100,13 +1104,18 @@ public boolean configure(String name, Map params) throws Configu final Properties dbProps = DbProperties.getDbProperties(); final int cloudMaxActive = Integer.parseInt(dbProps.getProperty("db.cloud.maxActive")); - int apiPoolSize = cloudMaxActive / 2; - int workPoolSize = (cloudMaxActive * 2) / 3; + int defaultApiPoolSize = cloudMaxActive / 2; + int defaultWorkPoolSize = (cloudMaxActive * 2) / 3; - logger.info("Start AsyncJobManager API executor thread pool in size " + apiPoolSize); + int apiPoolSize = Math.max(ApiJobPoolSize.value(), defaultApiPoolSize); + int workPoolSize = Math.max(WorkJobPoolSize.value(), defaultWorkPoolSize); + + logger.info("Start AsyncJobManager API executor thread pool in size " + apiPoolSize + + " (configured=" + ApiJobPoolSize.value() + ", db.derived=" + defaultApiPoolSize + ", db.cloud.maxActive=" + cloudMaxActive + ")"); _apiJobExecutor = Executors.newFixedThreadPool(apiPoolSize, new NamedThreadFactory(AsyncJobManager.API_JOB_POOL_THREAD_PREFIX)); - logger.info("Start AsyncJobManager Work executor thread pool in size " + workPoolSize); + logger.info("Start AsyncJobManager Work executor thread pool in size " + workPoolSize + + " (configured=" + WorkJobPoolSize.value() + ", db.derived=" + defaultWorkPoolSize + ", db.cloud.maxActive=" + cloudMaxActive + ")"); _workerJobExecutor = Executors.newFixedThreadPool(workPoolSize, new NamedThreadFactory(AsyncJobManager.WORK_JOB_POOL_THREAD_PREFIX)); } catch (final Exception e) { throw new ConfigurationException("Unable to load db.properties to configure AsyncJobManagerImpl"); diff --git a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java index 0be5dbc01cbd..08d4c2afd431 100644 --- a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java +++ b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImplTest.java @@ -17,19 +17,30 @@ package org.apache.cloudstack.framework.jobs.impl; +import java.lang.reflect.Field; +import java.util.Properties; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; + import com.cloud.network.Network; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; import com.cloud.storage.Volume; +import com.cloud.utils.db.DbProperties; import com.cloud.utils.fsm.NoTransitionException; import com.cloud.vm.VMInstanceVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.dao.VMInstanceDao; + import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; @@ -42,6 +53,12 @@ @RunWith(MockitoJUnitRunner.class) public class AsyncJobManagerImplTest { + + // db.cloud.maxActive value preloaded into DbProperties for every test in this class. + // Chosen so the db-derived defaults (4000/2 = 2000 for api, 4000*2/3 = 2666 for worker) + // are non-trivial and easy to reason about vs. the configured minimums under test. + private static final int DB_CLOUD_MAX_ACTIVE = 4000; + @Spy @InjectMocks AsyncJobManagerImpl asyncJobManager; @@ -56,6 +73,30 @@ public class AsyncJobManagerImplTest { @Mock NetworkOrchestrationService networkOrchestrationService; + @Before + public void preloadDbProperties() throws Exception { + Properties props = new Properties(); + props.setProperty("db.cloud.maxActive", String.valueOf(DB_CLOUD_MAX_ACTIVE)); + // Bypass DbProperties' internal "loaded" guard so configure() reads our + // properties instead of trying to load a real db.properties file. + setDbPropertiesStatics(props, true); + } + + @After + public void tearDown() throws Exception { + try { + shutdownIfPresent("_apiJobExecutor"); + shutdownIfPresent("_workerJobExecutor"); + } finally { + // Reset static state even if executor shutdown throws, so we do not + // pollute later test classes with our stubbed ConfigKey values or + // preloaded DbProperties. + resetConfigValue(AsyncJobManagerImpl.ApiJobPoolSize); + resetConfigValue(AsyncJobManagerImpl.WorkJobPoolSize); + setDbPropertiesStatics(new Properties(), false); + } + } + @Test public void testCleanupVolumeResource() { AsyncJobVO job = new AsyncJobVO(); @@ -93,4 +134,109 @@ public void testCleanupNetworkResource() throws NoTransitionException { Mockito.verify(networkOrchestrationService, Mockito.times(1)).stateTransitTo(networkVO, Network.Event.OperationFailed); } + + @Test + public void configureApiPoolUsesConfiguredValueWhenLargerThanDbDerived() throws Exception { + overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 3000); + overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 50); + invokeConfigureAndSwallowPostSetupNpe(); + // apiPoolSize = max(3000, 4000/2) = 3000 (configured wins) + Assert.assertEquals(3000, apiPoolCoreSize()); + } + + @Test + public void configureApiPoolUsesDbDerivedWhenLargerThanConfigured() throws Exception { + overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 50); + overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 50); + invokeConfigureAndSwallowPostSetupNpe(); + // apiPoolSize = max(50, 4000/2) = 2000 (db-derived wins) + Assert.assertEquals(DB_CLOUD_MAX_ACTIVE / 2, apiPoolCoreSize()); + } + + @Test + public void configureWorkerPoolUsesTwoThirdsFormula() throws Exception { + overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 50); + overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 50); + invokeConfigureAndSwallowPostSetupNpe(); + // workPoolSize = max(50, 4000*2/3) = 2666 (db-derived wins) + Assert.assertEquals((DB_CLOUD_MAX_ACTIVE * 2) / 3, workerPoolCoreSize()); + } + + @Test + public void configureWorkerPoolUsesConfiguredValueWhenLargerThanDbDerived() throws Exception { + overrideConfigValue(AsyncJobManagerImpl.ApiJobPoolSize, 50); + overrideConfigValue(AsyncJobManagerImpl.WorkJobPoolSize, 5000); + invokeConfigureAndSwallowPostSetupNpe(); + // workPoolSize = max(5000, 4000*2/3) = 5000 (configured wins) + Assert.assertEquals(5000, workerPoolCoreSize()); + } + + /** + * Invoke configure() and swallow the NPE it throws when wiring SearchBuilders + * on the un-mocked DAOs after the pool-sizing block. The executors are created + * inside configure()'s try/catch (before the DAO wiring), so pool sizes can + * still be inspected after the failure. + */ + private void invokeConfigureAndSwallowPostSetupNpe() { + try { + asyncJobManager.configure(null, null); + } catch (Exception ignored) { + // expected: DAO wiring after pool-sizing NPEs because we intentionally + // did not mock the DAOs (they are not part of what we are testing). + } + } + + private int apiPoolCoreSize() throws Exception { + Object executor = readField("_apiJobExecutor"); + Assert.assertNotNull("configure() did not create _apiJobExecutor - has DAO wiring been moved before pool sizing?", executor); + return ((ThreadPoolExecutor) executor).getCorePoolSize(); + } + + private int workerPoolCoreSize() throws Exception { + Object executor = readField("_workerJobExecutor"); + Assert.assertNotNull("configure() did not create _workerJobExecutor - has DAO wiring been moved before pool sizing?", executor); + return ((ThreadPoolExecutor) executor).getCorePoolSize(); + } + + private Object readField(String name) throws Exception { + Field f = AsyncJobManagerImpl.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(asyncJobManager); + } + + private void shutdownIfPresent(String executorFieldName) { + try { + Object executor = readField(executorFieldName); + if (executor instanceof ExecutorService) { + ((ExecutorService) executor).shutdownNow(); + } + } catch (Exception ignored) { + // never populated by this test - nothing to clean up + } + } + + private void overrideConfigValue(final ConfigKey configKey, final Object value) { + try { + Field f = ConfigKey.class.getDeclaredField("_value"); + f.setAccessible(true); + f.set(configKey, value); + } catch (IllegalAccessException | NoSuchFieldException e) { + Assert.fail(e.getMessage()); + } + } + + private void resetConfigValue(final ConfigKey configKey) throws Exception { + Field f = ConfigKey.class.getDeclaredField("_value"); + f.setAccessible(true); + f.set(configKey, null); + } + + private void setDbPropertiesStatics(Properties props, boolean loaded) throws Exception { + Field propsField = DbProperties.class.getDeclaredField("properties"); + Field loadedField = DbProperties.class.getDeclaredField("loaded"); + propsField.setAccessible(true); + loadedField.setAccessible(true); + propsField.set(null, props); + loadedField.set(null, loaded); + } } diff --git a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java index d08b4775c8bd..57244305ebba 100644 --- a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java +++ b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.backup; import com.cloud.agent.AgentManager; +import com.cloud.alert.AlertManager; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.OperationTimedoutException; import com.cloud.configuration.Resource; @@ -116,6 +117,15 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co ConfigKey.Scope.Zone, NASBackupIncrementalEnabled.key()); + ConfigKey NASBackupQuiesceAgentTimeout = new ConfigKey<>("Advanced", Integer.class, + "nas.backup.quiesce.agent.timeout", + "30", + "Timeout in seconds for the guest agent filesystem freeze and thaw commands of a quiesced NAS backup. " + + "Set to 0 to use the libvirt default (5 seconds unless changed on the host).", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + @Inject private BackupDao backupDao; @@ -170,6 +180,9 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co @Inject private BackupDetailsDao backupDetailsDao; + @Inject + private AlertManager alertManager; + private Long getClusterIdFromRootVolume(VirtualMachine vm) { VolumeVO rootVolume = volumeDao.getInstanceRootVolume(vm.getId()); StoragePoolVO rootDiskPool = primaryDataStoreDao.findById(rootVolume.getPoolId()); @@ -583,6 +596,7 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce command.setBackupRepoAddress(backupRepository.getAddress()); command.setMountOptions(backupRepository.getMountOptions()); command.setQuiesce(quiesceVM); + command.setQuiesceTimeout(NASBackupQuiesceAgentTimeout.valueIn(vm.getDataCenterId())); command.setMode(decision.mode); command.setBitmapNew(decision.bitmapNew); command.setBitmapParent(decision.bitmapParent); @@ -648,16 +662,30 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce logger.error("Failed to take backup for VM {}: {}", vm.getInstanceName(), answer != null ? answer.getDetails() : "No answer received"); if (answer != null && answer.getNeedsCleanup()) { logger.error("Backup cleanup failed for VM {}. Leaving the backup in Error state. Backup should be manually deleted to free up the space", vm.getInstanceName()); + sendBackupCleanupFailedAlert(vm, host, answer.getDetails()); backupVO.setStatus(Backup.Status.Error); backupDao.update(backupVO.getId(), backupVO); - } else { - backupVO.setStatus(Backup.Status.Failed); - backupDao.update(backupVO.getId(), backupVO); + // Return the row to the caller to record its schedule - e.g. Scheduled or Manual. + return new Pair<>(false, backupVO); } + backupVO.setStatus(Backup.Status.Failed); + backupDao.remove(backupVO.getId()); return new Pair<>(false, null); } } + /** + * Alerts when nasbackup.sh could not clean up after a failed backup. The script's output + * names the backup repository mount point it left on the host. + */ + private void sendBackupCleanupFailedAlert(VirtualMachine vm, Host host, String details) { + String subject = String.format("NAS backup cleanup failed for VM %s on host %s", vm.getInstanceName(), host.getName()); + String body = String.format("A failed backup of VM %s could not be cleaned up on host %s (%s). " + + "The backup repository may still be mounted on that host. Script output: %s", + vm.getInstanceName(), host.getName(), host.getPrivateIpAddress(), details); + alertManager.sendAlert(AlertManager.AlertType.ALERT_TYPE_BACKUP_STORAGE, vm.getDataCenterId(), host.getPodId(), subject, body); + } + private BackupVO createBackupObject(VirtualMachine vm, String backupPath, String type) { BackupVO backup = new BackupVO(); backup.setVmId(vm.getId()); @@ -1265,7 +1293,8 @@ public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ NASBackupRestoreMountTimeout, NASBackupFullEvery, - NASBackupIncrementalEnabled + NASBackupIncrementalEnabled, + NASBackupQuiesceAgentTimeout }; } diff --git a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java index f1d5613ab7fc..9e3d7b77c152 100644 --- a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java +++ b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java @@ -37,6 +37,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.cloud.agent.AgentManager; +import com.cloud.alert.AlertManager; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.OperationTimedoutException; import com.cloud.configuration.Resource; @@ -126,6 +127,9 @@ public class NASBackupProviderTest { @Mock private ResourceLimitService resourceLimitMgr; + @Mock + private AlertManager alertManager; + @Test public void testDeleteBackup() throws OperationTimedoutException, AgentUnavailableException { Long hostId = 1L; @@ -203,16 +207,7 @@ public void testGetBackupStorageStats() { Assert.assertEquals(Long.valueOf(3000L), result.second()); } - @Test - public void takeBackupSuccessfully() throws AgentUnavailableException, OperationTimedoutException { - Long vmId = 1L; - Long hostId = 2L; - Long backupOfferingId = 3L; - Long accountId = 4L; - Long domainId = 5L; - Long zoneId = 6L; - Long backupId = 7L; - + private VMInstanceVO mockRunningVmForBackup(Long vmId, Long hostId, Long backupOfferingId, Long accountId, Long domainId, Long zoneId) { VMInstanceVO vm = mock(VMInstanceVO.class); Mockito.when(vm.getId()).thenReturn(vmId); Mockito.when(vm.getHostId()).thenReturn(hostId); @@ -242,6 +237,16 @@ public void takeBackupSuccessfully() throws AgentUnavailableException, Operation Mockito.when(volume2.getState()).thenReturn(Volume.State.Ready); Mockito.when(volume2.getSize()).thenReturn(200L); Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume1, volume2)); + return vm; + } + + @Test + public void takeBackupSuccessfully() throws AgentUnavailableException, OperationTimedoutException { + Long backupOfferingId = 3L; + Long accountId = 4L; + Long domainId = 5L; + Long zoneId = 6L; + VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, backupOfferingId, accountId, domainId, zoneId); BackupAnswer answer = mock(BackupAnswer.class); Mockito.when(answer.getResult()).thenReturn(true); @@ -270,6 +275,60 @@ public void takeBackupSuccessfully() throws AgentUnavailableException, Operation Mockito.verify(agentManager).send(anyLong(), Mockito.any(TakeBackupCommand.class)); } + @Test + public void takeBackupFailureNeedingCleanupReturnsErrorBackup() throws AgentUnavailableException, OperationTimedoutException { + VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, 3L, 4L, 5L, 6L); + HostVO host = hostDao.findById(2L); + Mockito.when(host.getName()).thenReturn("kvm01"); + Mockito.when(host.getPodId()).thenReturn(7L); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(false); + Mockito.when(answer.getNeedsCleanup()).thenReturn(true); + Mockito.when(answer.getDetails()).thenReturn("leaving /tmp/csbackup.abcde/i-2-3-VM mounted at /tmp/csbackup.abcde"); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(TakeBackupCommand.class); + Mockito.when(agentManager.send(anyLong(), commandCaptor.capture())).thenReturn(answer); + + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Pair result = nasBackupProvider.takeBackup(vm, true, false); + + Assert.assertFalse(result.first()); + Assert.assertNotNull(result.second()); + Assert.assertEquals(Backup.Status.Error, result.second().getStatus()); + Mockito.verify(backupDao, Mockito.never()).remove(Mockito.anyLong()); + + TakeBackupCommand command = commandCaptor.getValue(); + Assert.assertTrue(command.getQuiesce()); + Assert.assertEquals(Integer.valueOf(30), command.getQuiesceTimeout()); + + ArgumentCaptor subjectCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(alertManager).sendAlert(Mockito.eq(AlertManager.AlertType.ALERT_TYPE_BACKUP_STORAGE), Mockito.eq(6L), Mockito.eq(7L), + subjectCaptor.capture(), bodyCaptor.capture()); + Assert.assertTrue(subjectCaptor.getValue().contains("kvm01")); + Assert.assertTrue(bodyCaptor.getValue().contains("/tmp/csbackup.abcde")); + } + + @Test + public void takeBackupFailureWithoutCleanupRemovesBackup() throws AgentUnavailableException, OperationTimedoutException { + VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, 3L, 4L, 5L, 6L); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(false); + Mockito.when(answer.getNeedsCleanup()).thenReturn(false); + Mockito.when(agentManager.send(anyLong(), Mockito.any(TakeBackupCommand.class))).thenReturn(answer); + + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Pair result = nasBackupProvider.takeBackup(vm, false, false); + + Assert.assertFalse(result.first()); + Assert.assertNull(result.second()); + Mockito.verify(backupDao).remove(Mockito.anyLong()); + Mockito.verifyNoInteractions(alertManager); + } + @Test public void testGetVMHypervisorHost() { Long hostId = 1L; diff --git a/plugins/hypervisors/kvm/pom.xml b/plugins/hypervisors/kvm/pom.xml index b4f59e5d0d28..1760d4334459 100644 --- a/plugins/hypervisors/kvm/pom.xml +++ b/plugins/hypervisors/kvm/pom.xml @@ -41,6 +41,12 @@ org.apache.cloudstack cloud-agent ${project.version} + + + org.apache.cxf + cxf-rt-frontend-jaxrs + + org.libvirt @@ -51,11 +57,35 @@ org.apache.cloudstack cloud-plugin-network-ovs ${project.version} + + + org.apache.cxf + cxf-rt-frontend-jaxrs + + org.apache.cloudstack cloud-engine-storage-configdrive ${project.version} + + + org.apache.cxf + cxf-rt-frontend-jaxrs + + + + + org.apache.cloudstack + cloud-engine-storage + ${project.version} + compile + + + org.apache.cxf + cxf-rt-frontend-jaxrs + + com.ceph @@ -122,6 +152,12 @@ cloud-plugin-storage-volume-scaleio ${project.version} compile + + + org.apache.cxf + cxf-rt-frontend-jaxrs + + org.json diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index 9009ec629ca3..7c3f826be171 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -20,11 +20,21 @@ import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION; import static com.cloud.host.Host.HOST_KVM_DISK_ONLY_VM_SNAPSHOT_NVRAM; import static com.cloud.host.Host.HOST_OVFTOOL_VERSION; +import static com.cloud.host.Host.HOST_QEMU_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_QEMU_IMG_VERSION; +import static com.cloud.host.Host.HOST_QEMU_IO_VERSION; +import static com.cloud.host.Host.HOST_QEMU_NBD_VERSION; import static com.cloud.host.Host.HOST_RBD_VOLUME_ENCRYPTION; import static com.cloud.host.Host.HOST_VDDK_LIB_DIR; +import static com.cloud.host.Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_SUPPORT; import static com.cloud.host.Host.HOST_VDDK_VERSION; +import static com.cloud.host.Host.HOST_VIRTV2V_INPLACE_SUPPORT; +import static com.cloud.host.Host.HOST_VIRTV2V_INPLACE_VERSION; import static com.cloud.host.Host.HOST_VIRTV2V_VERSION; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT; +import static com.cloud.host.Host.HOST_VDDK_BLOCKCOPY_SUPPORT; import static com.cloud.host.Host.HOST_VOLUME_ENCRYPTION; import static org.apache.cloudstack.utils.linux.KVMHostInfo.isHostS390x; @@ -54,6 +64,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -373,6 +384,10 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final String INSTANCE_CONVERSION_SUPPORTED_CHECK_CMD = "virt-v2v --version"; // virt-v2v --version => sample output: virt-v2v 1.42.0rhel=8,release=22.module+el8.10.0+1590+a67ab969 + public static final String INSTANCE_CONVERSION_IN_PLACE_SUPPORTED_CHECK_CMD = "virt-v2v-in-place --version"; + // EL9-family distributions install virt-v2v-in-place in libexecdir, outside $PATH + public static final String VIRT_V2V_IN_PLACE_LIBEXEC_PATH = "/usr/libexec/virt-v2v-in-place"; + public static final String INSTANCE_CONVERSION_IN_PLACE_OPTION_SUPPORTED_CHECK_CMD = "virt-v2v --help 2>&1 | grep -q -- '--in-place'"; public static final String OVF_EXPORT_SUPPORTED_CHECK_CMD = "ovftool --version"; // ovftool --version => sample output: VMware ovftool 4.6.0 (build-21452615) public static final String OVF_EXPORT_TOOl_GET_VERSION_CMD = "ovftool --version | awk '{print $3}'"; @@ -381,6 +396,12 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final String UBUNTU_WINDOWS_GUEST_CONVERSION_SUPPORTED_CHECK_CMD = "dpkg -l virtio-win"; public static final String UBUNTU_NBDKIT_PKG_CHECK_CMD = "dpkg -l nbdkit"; public static final String VDDK_AUTODETECT_PATH_CMD = "find / -type d -name 'vmware-vix-disklib-distrib' 2>/dev/null | head -n 1"; + public static final String NBDKIT_VDDK_DUMP_PLUGIN_CMD = "nbdkit vddk --dump-plugin"; + public static final String QEMU_IMG_SUPPORTED_CHECK_CMD = "qemu-img --version"; + public static final String QEMU_NBD_SUPPORTED_CHECK_CMD = "qemu-nbd --version"; + public static final String QEMU_IO_SUPPORTED_CHECK_CMD = "qemu-io --version"; + public static final String QEMU_IMG_RBD_SUPPORTED_CHECK_CMD = "qemu-img --help 2>&1 | grep -Eq '(^|[[:space:]])rbd([[:space:]]|$)'"; + public static final String NBDCOPY_SUPPORTED_CHECK_CMD = "nbdcopy --version"; public static final int LIBVIRT_CGROUP_CPU_SHARES_MIN = 2; public static final int LIBVIRT_CGROUP_CPU_SHARES_MAX = 262144; @@ -961,6 +982,7 @@ public String toString() { protected String directDownloadTemporaryDownloadPath; protected String cachePath; private String vddkTransports = null; + private String vddkNbdCompression = null; private String vddkThumbprint = null; private String vddkVersion = null; private String detectedPasswordFileOption = null; @@ -1040,6 +1062,10 @@ public String getVddkTransports() { return vddkTransports; } + public String getVddkNbdCompression() { + return vddkNbdCompression; + } + public String getVddkThumbprint() { return vddkThumbprint; } @@ -1271,13 +1297,15 @@ public boolean configure(final String name, final Map params) th LOGGER.warn("Could not detect a valid VDDK library dir; VDDK conversion will be unavailable"); } - vddkVersion = detectVddkVersion(); + vddkVersion = detectVddkVersion(vddkLibDir); if (StringUtils.isNotBlank(vddkVersion)) { - LOGGER.info("Detected nbdkit VDDK plugin version: {}", vddkVersion); + LOGGER.info("Detected usable VMware VDDK library version: {}", vddkVersion); } vddkTransports = StringUtils.trimToNull( AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VDDK_TRANSPORTS)); + vddkNbdCompression = StringUtils.trimToNull( + AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VDDK_NBD_COMPRESSION)); vddkThumbprint = StringUtils.trimToNull( AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VDDK_THUMBPRINT)); @@ -4437,15 +4465,37 @@ public StartupCommand[] initialize() { cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION, String.valueOf(instanceConversionSupported)); cmd.getHostDetails().put(HOST_VDDK_SUPPORT, String.valueOf(hostSupportsVddk())); cmd.getHostDetails().put(HOST_CDROM_MAX_COUNT, String.valueOf(LibvirtVMDef.MAX_CDROMS_PER_VM)); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_SUPPORT, String.valueOf(hostSupportsVddkBlockCopy())); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, String.valueOf(hostSupportsVddkBlockCopyInPlaceFinalization())); + cmd.getHostDetails().put(HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, String.valueOf(hostSupportsVddkBlockCopyRbd())); + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_SUPPORT, String.valueOf(hostSupportsVirtV2vInPlace())); + cmd.getHostDetails().put(HOST_QEMU_RBD_SUPPORT, String.valueOf(hostSupportsQemuRbd())); + cmd.getHostDetails().put(HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, String.valueOf(hostSupportsVddkRbdDirectImport())); if (StringUtils.isNotBlank(vddkLibDir)) { cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir); } if (StringUtils.isNotBlank(vddkVersion)) { cmd.getHostDetails().put(HOST_VDDK_VERSION, vddkVersion); } + String qemuImgVersion = getQemuImgVersion(); + if (StringUtils.isNotBlank(qemuImgVersion)) { + cmd.getHostDetails().put(HOST_QEMU_IMG_VERSION, qemuImgVersion); + } + String qemuNbdVersion = getQemuNbdVersion(); + if (StringUtils.isNotBlank(qemuNbdVersion)) { + cmd.getHostDetails().put(HOST_QEMU_NBD_VERSION, qemuNbdVersion); + } + String qemuIoVersion = getQemuIoVersion(); + if (StringUtils.isNotBlank(qemuIoVersion)) { + cmd.getHostDetails().put(HOST_QEMU_IO_VERSION, qemuIoVersion); + } if (instanceConversionSupported) { cmd.getHostDetails().put(HOST_VIRTV2V_VERSION, getHostVirtV2vVersion()); } + String virtV2vInPlaceVersion = getHostVirtV2vInPlaceVersion(); + if (StringUtils.isNotBlank(virtV2vInPlaceVersion)) { + cmd.getHostDetails().put(HOST_VIRTV2V_INPLACE_VERSION, virtV2vInPlaceVersion); + } if (hostSupportsOvfExport()) { cmd.getHostDetails().put(HOST_OVFTOOL_VERSION, getHostOvfToolVersion()); } @@ -6273,14 +6323,135 @@ public boolean hostSupportsVddk() { } public boolean hostSupportsVddk(String overriddenVddkLibDir) { - String effectiveVddkLibDir = StringUtils.trimToNull(overriddenVddkLibDir); - if (StringUtils.isBlank(effectiveVddkLibDir)) { - effectiveVddkLibDir = StringUtils.trimToNull(vddkLibDir); + String effectiveVddkLibDir = resolveVddkLibDir(overriddenVddkLibDir); + return hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && isNbdkitVddkPluginUsable(effectiveVddkLibDir); + } + + public boolean hostSupportsVddkBlockCopy() { + return hostSupportsVddkBlockCopy(null); + } + + public boolean hostSupportsVddkBlockCopy(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_NBD_SUPPORTED_CHECK_CMD) == 0 + && Script.runSimpleBashScriptForExitValue(QEMU_IO_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVddkBlockCopyInPlaceFinalization() { + return hostSupportsVddkBlockCopy() && hostSupportsVirtV2vInPlace(); + } + + public boolean hostSupportsVddkBlockCopyRbd() { + return hostSupportsVddkBlockCopyInPlaceFinalization() + && Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public String getQemuImgVersion() { + return detectFirstLineVersion("qemu-img", "--version"); + } + + public String getQemuNbdVersion() { + return detectFirstLineVersion("qemu-nbd", "--version"); + } + + public String getQemuIoVersion() { + return detectFirstLineVersion("qemu-io", "--version"); + } + + public boolean hostSupportsVirtV2vInPlace() { + return hostSupportsVirtV2vInPlaceBinary() || hostSupportsVirtV2vInPlaceOption(); + } + + /** + * nbdcopy (from libnbd) is an optional accelerator for full-disk copies from an + * nbdkit/VDDK source into a local raw block device: it uses multiple in-flight + * requests and connections, so it is typically faster than a single-connection + * qemu-img convert. It is a pure optimization - callers fall back to + * qemu-img convert when it is absent. + */ + public boolean hostSupportsNbdcopy() { + return Script.runSimpleBashScriptForExitValue(NBDCOPY_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVirtV2vInPlaceBinary() { + return getVirtV2vInPlaceBinary() != null; + } + + /** + * Resolves the virt-v2v-in-place executable. Ubuntu/Debian install it on $PATH, + * but EL9-family distributions ship it in /usr/libexec, so probing the bare name + * alone would (wrongly) disable in-place finalization there. Returns the + * invocable binary (name or absolute path), or null when neither works. + */ + public String getVirtV2vInPlaceBinary() { + if (Script.runSimpleBashScriptForExitValue(INSTANCE_CONVERSION_IN_PLACE_SUPPORTED_CHECK_CMD) == 0) { + return "virt-v2v-in-place"; } - if (StringUtils.isBlank(effectiveVddkLibDir) || !isVddkLibDirValid(effectiveVddkLibDir)) { - effectiveVddkLibDir = detectVddkLibDir(); + if (Script.runSimpleBashScriptForExitValue(VIRT_V2V_IN_PLACE_LIBEXEC_PATH + " --version") == 0) { + return VIRT_V2V_IN_PLACE_LIBEXEC_PATH; + } + return null; + } + + public boolean hostSupportsVirtV2vInPlaceOption() { + return Script.runSimpleBashScriptForExitValue(INSTANCE_CONVERSION_IN_PLACE_OPTION_SUPPORTED_CHECK_CMD) == 0; + } + + public String getHostVirtV2vInPlaceVersion() { + if (!hostSupportsVirtV2vInPlace()) { + return ""; + } + String inPlaceBinary = getVirtV2vInPlaceBinary(); + if (inPlaceBinary == null) { + return getHostVirtV2vVersion(); + } + String cmd = String.format("%s --version | awk '{print $2}'", inPlaceBinary); + String version = Script.runSimpleBashScript(cmd); + return StringUtils.isNotBlank(version) ? version.split(",")[0] : ""; + } + + protected String detectFirstLineVersion(String... command) { + try { + ProcessBuilder pb = new ProcessBuilder(command); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + process.waitFor(); + + for (String line : output.split("\\R")) { + String trimmed = StringUtils.trimToNull(line); + if (StringUtils.isNotBlank(trimmed)) { + return parseVersionToken(trimmed); + } + } + } catch (Exception e) { + LOGGER.debug("Failed to detect version for command {}: {}", String.join(" ", command), e.getMessage()); } - return hostSupportsInstanceConversion() && isVddkLibDirValid(effectiveVddkLibDir) && StringUtils.isNotBlank(detectVddkVersion()); + return null; + } + + protected String parseVersionToken(String versionLine) { + String versionMarker = " version "; + int markerIndex = versionLine.indexOf(versionMarker); + if (markerIndex < 0) { + return versionLine; + } + String value = versionLine.substring(markerIndex + versionMarker.length()); + String[] parts = value.split("\\s+", 2); + return parts.length > 0 ? parts[0] : versionLine; + } + + public boolean hostSupportsQemuRbd() { + return Script.runSimpleBashScriptForExitValue(QEMU_IMG_RBD_SUPPORTED_CHECK_CMD) == 0; + } + + public boolean hostSupportsVddkRbdDirectImport() { + return hostSupportsVddkRbdDirectImport(null); + } + + public boolean hostSupportsVddkRbdDirectImport(String overriddenVddkLibDir) { + return hostSupportsVddk(overriddenVddkLibDir) && hostSupportsQemuRbd() && hostSupportsVirtV2vInPlace(); } protected boolean isVddkLibDirValid(String path) { @@ -6295,6 +6466,17 @@ protected boolean isVddkLibDirValid(String path) { return libs != null && libs.length > 0; } + protected String resolveVddkLibDir(String overriddenVddkLibDir) { + String effectiveVddkLibDir = StringUtils.trimToNull(overriddenVddkLibDir); + if (StringUtils.isBlank(effectiveVddkLibDir)) { + effectiveVddkLibDir = StringUtils.trimToNull(vddkLibDir); + } + if (StringUtils.isBlank(effectiveVddkLibDir) || !isVddkLibDirValid(effectiveVddkLibDir)) { + effectiveVddkLibDir = detectVddkLibDir(); + } + return effectiveVddkLibDir; + } + protected String detectVddkLibDir() { String detectedPath = StringUtils.trimToNull(Script.runSimpleBashScript(VDDK_AUTODETECT_PATH_CMD)); if (StringUtils.isNotBlank(detectedPath) && isVddkLibDirValid(detectedPath)) { @@ -6304,28 +6486,73 @@ protected String detectVddkLibDir() { } protected String detectVddkVersion() { + return detectVddkVersion(null); + } + + protected String detectVddkVersion(String overriddenVddkLibDir) { + String effectiveVddkLibDir = resolveVddkLibDir(overriddenVddkLibDir); + if (!isVddkLibDirValid(effectiveVddkLibDir)) { + return null; + } + return parseVddkLibraryVersionFromDumpPluginOutput(runNbdkitVddkDumpPlugin(effectiveVddkLibDir)); + } + + protected boolean isNbdkitVddkPluginUsable(String vddkLibDir) { + if (!isVddkLibDirValid(vddkLibDir)) { + return false; + } + String dumpPluginOutput = runNbdkitVddkDumpPlugin(vddkLibDir); + if (StringUtils.isBlank(parseVddkLibraryVersionFromDumpPluginOutput(dumpPluginOutput))) { + LOGGER.warn("nbdkit-vddk-plugin could not load VMware VDDK from [{}]", vddkLibDir); + return false; + } + return true; + } + + protected String runNbdkitVddkDumpPlugin(String vddkLibDir) { try { - ProcessBuilder pb = new ProcessBuilder("nbdkit", "vddk", "--version"); + ProcessBuilder pb = new ProcessBuilder("nbdkit", "vddk", "--dump-plugin", String.format("libdir=%s", vddkLibDir)); + pb.redirectErrorStream(true); Process process = pb.start(); + boolean completed = process.waitFor(10, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } String output = new String(process.getInputStream().readAllBytes()); - process.waitFor(); - - if (StringUtils.isBlank(output)) { - return null; + if (!completed) { + LOGGER.warn("Timed out while checking nbdkit-vddk-plugin with libdir [{}]", vddkLibDir); + return output; + } + if (process.exitValue() != 0) { + LOGGER.warn("nbdkit-vddk-plugin check failed for libdir [{}]: {}", vddkLibDir, StringUtils.trimToEmpty(output)); + return output; } + return output; + } catch (Exception e) { + LOGGER.error("Failed to check nbdkit-vddk-plugin with libdir [{}]: {}", vddkLibDir, e.getMessage()); + return null; + } + } + protected String parseVddkLibraryVersionFromDumpPluginOutput(String output) { + if (StringUtils.isBlank(output)) { + return null; + } + String libraryVersionPrefix = "vddk_library_version="; + try { for (String line : output.split("\\R")) { String trimmed = StringUtils.trimToEmpty(line); - if (trimmed.startsWith("vddk ")) { - return StringUtils.trimToNull(trimmed.substring("vddk ".length())); + if (trimmed.startsWith(libraryVersionPrefix)) { + return StringUtils.trimToNull(trimmed.substring(libraryVersionPrefix.length())); } } - return null; } catch (Exception e) { - LOGGER.error("Failed to detect vddk version: {}", e.getMessage()); + LOGGER.error("Failed to parse nbdkit-vddk-plugin output: {}", e.getMessage()); return null; } + return null; } public boolean hostSupportsWindowsGuestConversion() { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java index dc34a4cb62d8..7bb525ba196f 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java @@ -128,6 +128,13 @@ protected void sanitizeDisksPath(List disks) { protected List moveTemporaryDisksToDestination(List temporaryDisks, List destinationStoragePools, KVMStoragePoolManager storagePoolMgr) { + return moveTemporaryDisksToDestination(temporaryDisks, destinationStoragePools, null, storagePoolMgr); + } + + protected List moveTemporaryDisksToDestination(List temporaryDisks, + List destinationStoragePools, + List destinationStoragePoolTypes, + KVMStoragePoolManager storagePoolMgr) { List targetDisks = new ArrayList<>(); if (temporaryDisks.size() != destinationStoragePools.size()) { String warn = String.format("Discrepancy between the converted instance disks (%s) " + @@ -136,17 +143,13 @@ protected List moveTemporaryDisksToDestination(List moveTemporaryDisksToDestination(List moveTemporaryDisksToDestination(List destinationStoragePoolTypes, int index) { + if (CollectionUtils.isEmpty(destinationStoragePoolTypes) || destinationStoragePoolTypes.size() <= index || destinationStoragePoolTypes.get(index) == null) { + return Storage.StoragePoolType.NetworkFilesystem; + } + return destinationStoragePoolTypes.get(index); + } + + private void probeRbdQemuAccess(KVMStoragePool pool, String destinationName) { + String probeName = destinationName + "-probe"; + String rbdImagePath = pool.getSourceDir() + "/" + probeName; + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(pool, rbdImagePath); + try { + Script qemuImg = new Script("qemu-img", 120000, logger); + qemuImg.add("create", "-f", "raw", qemuRbdPath, "4194304"); + qemuImg.execute(); + if (qemuImg.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-img could not create RBD probe image %s", rbdImagePath)); + } + + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", qemuRbdPath); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify RBD probe image %s", rbdImagePath)); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete RBD probe image {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + private void cleanupMovedDisksOnDestinationPool(List targetDisks) { if (CollectionUtils.isEmpty(targetDisks)) { return; @@ -220,9 +262,20 @@ protected List getUnmanagedInstanceDisks(List storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); - disk.setDatastoreHost(storagePoolHostAndPath.first()); - disk.setDatastorePath(storagePoolHostAndPath.second()); + if (storagePool.getType() == Storage.StoragePoolType.RBD) { + disk.setDatastoreHost(storagePool.getSourceHost()); + disk.setDatastorePath(storagePool.getSourceDir()); + disk.setImagePath(physicalDisk.getName()); + } else if (storagePool.getType() == Storage.StoragePoolType.Linstor) { + // Linstor pools expose no local mount path; the management server resolves the + // pool by its UUID (datastore name), and the volume by its disk name (path). + disk.setDatastoreHost(storagePool.getSourceHost()); + disk.setImagePath(physicalDisk.getName()); + } else { + Pair storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); + disk.setDatastoreHost(storagePoolHostAndPath.first()); + disk.setDatastorePath(storagePoolHostAndPath.second()); + } disk.setDatastoreName(storagePool.getUuid()); disk.setDatastoreType(storagePool.getType().name()); disk.setCapacity(physicalDisk.getVirtualSize()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java index de9341715f02..0eb728d5d37e 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapper.java @@ -31,6 +31,21 @@ public class LibvirtCheckConvertInstanceCommandWrapper extends CommandWrapper { + + private static final int MIN_AGENT_PING_TIMEOUT_SECONDS = 1; + private static final int MAX_RESULT_MESSAGE_LENGTH = 256; + + @Override + public Answer execute(CheckGuestAgentLivenessCommand command, LibvirtComputingResource serverResource) { + String vmName = command.getVmName(); + Domain domain = null; + try { + final LibvirtUtilitiesHelper libvirtUtilitiesHelper = serverResource.getLibvirtUtilitiesHelper(); + Connect connect = libvirtUtilitiesHelper.getConnection(); + domain = serverResource.getDomain(connect, vmName); + if (domain == null) { + return new CheckGuestAgentLivenessAnswer(command, false, String.format("VM %s was not found", vmName)); + } + + DomainState domainState = domain.getInfo().state; + if (domainState != DomainState.VIR_DOMAIN_RUNNING) { + return new CheckGuestAgentLivenessAnswer(command, false, String.format("VM %s is in %s state", vmName, domainState)); + } + + int timeoutSeconds = Math.max(command.getWait(), MIN_AGENT_PING_TIMEOUT_SECONDS); + String result = domain.qemuAgentCommand(QemuCommand.buildQemuCommand(QemuCommand.AGENT_PING, null), timeoutSeconds, 0); + return parseJsonResult(command, result); + } catch (LibvirtException e) { + return new CheckGuestAgentLivenessAnswer(command, false, "guest agent did not respond: " + e.getMessage()); + } finally { + if (domain != null) { + try { + domain.free(); + } catch (LibvirtException e) { + logger.trace("Ignore error ", e); + } + } + } + } + + private CheckGuestAgentLivenessAnswer parseJsonResult(CheckGuestAgentLivenessCommand command, String result) { + if (result == null || result.isBlank()) { + logger.error("Guest agent returned empty response"); + return new CheckGuestAgentLivenessAnswer(command, false, "guest agent returned empty response"); + } + try { + JsonElement parsedResult = JsonParser.parseString(result); + if (parsedResult.isJsonObject() && parsedResult.getAsJsonObject().has("return") && !parsedResult.getAsJsonObject().has("error")) { + return new CheckGuestAgentLivenessAnswer(command, true, "guest agent responded"); + } + } catch (JsonParseException e) { + return new CheckGuestAgentLivenessAnswer(command, false, "guest agent returned invalid JSON: " + abbreviateResultForMessage(result)); + } + return new CheckGuestAgentLivenessAnswer(command, false, "guest agent did not respond as expected: " + abbreviateResultForMessage(result)); + } + + private String abbreviateResultForMessage(String result) { + if (result.length() <= MAX_RESULT_MESSAGE_LENGTH) { + return result; + } + return result.substring(0, MAX_RESULT_MESSAGE_LENGTH) + "..."; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapper.java index 6788516df741..215c4ed74250 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapper.java @@ -50,11 +50,12 @@ public final class LibvirtCheckVolumeCommandWrapper extends CommandWrapper STORAGE_POOL_TYPES_SUPPORTED = Arrays.asList( Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.NetworkFilesystem, - Storage.StoragePoolType.SharedMountPoint); + Storage.StoragePoolType.SharedMountPoint, + Storage.StoragePoolType.RBD, + Storage.StoragePoolType.Linstor); @Override public Answer execute(final CheckVolumeCommand command, final LibvirtComputingResource libvirtComputingResource) { - String result = null; String srcFile = command.getSrcFile(); StorageFilerTO storageFilerTO = command.getStorageFilerTO(); KVMStoragePoolManager poolMgr = libvirtComputingResource.getStoragePoolMgr(); @@ -62,25 +63,62 @@ public Answer execute(final CheckVolumeCommand command, final LibvirtComputingRe try { if (STORAGE_POOL_TYPES_SUPPORTED.contains(storageFilerTO.getType())) { + if (Storage.StoragePoolType.Linstor.equals(storageFilerTO.getType())) { + List volumes = pool.getVolumesForImport(srcFile); + if (volumes == null || volumes.size() != 1 || !srcFile.equals(volumes.get(0).getPath())) { + return new CheckVolumeAnswer(command, false, "LINSTOR volume not found", 0, null); + } + VolumeOnStorageTO volume = volumes.get(0); + return new CheckVolumeAnswer(command, true, "", volume.getVirtualSize(), volume.getDetails()); + } final KVMPhysicalDisk vol = pool.getPhysicalDisk(srcFile); + if (Storage.StoragePoolType.RBD.equals(storageFilerTO.getType())) { + return checkRbdVolume(command, pool, vol); + } final String path = vol.getPath(); - try { - KVMPhysicalDisk.checkQcow2File(path); - } catch (final CloudRuntimeException e) { - return new CheckVolumeAnswer(command, false, "", 0, getVolumeDetails(pool, vol)); + final boolean isRbd = Storage.StoragePoolType.RBD.equals(storageFilerTO.getType()); + + Map volumeDetails = getVolumeDetails(pool, vol); + if (MapUtils.isEmpty(volumeDetails)) { + return new Answer(command, false, "Unable to read the volume on the storage pool"); } - long size = KVMPhysicalDisk.getVirtualSizeFromFile(path); - return new CheckVolumeAnswer(command, true, "", size, getVolumeDetails(pool, vol)); + if (!isRbd) { + try { + KVMPhysicalDisk.checkQcow2File(path); + } catch (final CloudRuntimeException e) { + return new CheckVolumeAnswer(command, false, "", 0, volumeDetails); + } + } + + // Images on RBD are raw and the path is an image name that qemu-img cannot open + // without the rbd: URI, so take the size libvirt already reported for the volume. + long size = isRbd ? vol.getVirtualSize() : KVMPhysicalDisk.getVirtualSizeFromFile(path); + return new CheckVolumeAnswer(command, true, "", size, volumeDetails); } else { return new Answer(command, false, "Unsupported Storage Pool"); } } catch (final Exception e) { logger.error("Error while checking the disk: {}", e.getMessage()); - return new Answer(command, false, result); + return new Answer(command, false, e.getMessage()); } } + /** + * RBD (Ceph) volumes are raw images that cannot be inspected through direct file reads + * (checkQcow2File / getVirtualSizeFromFile operate on a local path). Instead the volume is + * inspected through the RBD URI via qemu-img (see {@link #getDiskFileInfo}). The virtual size + * is taken from the disk reported by the storage pool, and the encrypted/backing-file/locked + * details are still collected so the management server can apply its import validations. + */ + private Answer checkRbdVolume(final CheckVolumeCommand command, final KVMStoragePool pool, final KVMPhysicalDisk disk) { + Map volumeDetails = getVolumeDetails(pool, disk); + if (volumeDetails == null) { + return new CheckVolumeAnswer(command, false, "", 0, null); + } + return new CheckVolumeAnswer(command, true, "", disk.getVirtualSize(), volumeDetails); + } + private Map getVolumeDetails(KVMStoragePool pool, KVMPhysicalDisk disk) { Map info = getDiskFileInfo(pool, disk, true); if (MapUtils.isEmpty(info)) { @@ -109,7 +147,21 @@ private Map getVolumeDetails(KVMStoragePool po if (StringUtils.isNotBlank(encrypted) && encrypted.equalsIgnoreCase("yes")) { volumeDetails.put(VolumeOnStorageTO.Detail.IS_ENCRYPTED, String.valueOf(Boolean.TRUE)); } - Boolean isLocked = isDiskFileLocked(pool, disk); + boolean isLocked = isDiskFileLocked(pool, disk); + if (!isLocked) { + // For clustered block storage (Linstor/DRBD) the host-local qemu-img lock is not + // authoritative: a volume can be attached to a running VM on another node. Consult + // the backend's cluster-wide in-use state so such a volume is not adopted. + String inUseNode = pool.getVolumeInUseNode(disk.getName()); + if (KVMStoragePool.IN_USE_NODE_UNKNOWN.equals(inUseNode)) { + logger.warn("Could not determine cluster-wide in-use state of volume {}; marking as locked", + disk.getName()); + isLocked = true; + } else if (inUseNode != null) { + logger.info("Volume {} is in use on node {}; marking as locked", disk.getName(), inUseNode); + isLocked = true; + } + } volumeDetails.put(VolumeOnStorageTO.Detail.IS_LOCKED, String.valueOf(isLocked)); return volumeDetails; @@ -122,6 +174,10 @@ private Map getDiskFileInfo(KVMStoragePool pool, KVMPhysicalDisk try { QemuImg qemu = new QemuImg(0); QemuImgFile qemuFile = new QemuImgFile(disk.getPath(), disk.getFormat()); + if (Storage.StoragePoolType.RBD.equals(pool.getType())) { + String rbdDestFile = KVMPhysicalDisk.RBDStringBuilder(pool, disk.getPath()); + qemuFile = new QemuImgFile(rbdDestFile, disk.getFormat()); + } return qemu.info(qemuFile, secure); } catch (QemuImgException | LibvirtException ex) { logger.error("Failed to get info of disk file: " + ex.getMessage()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java index f28b5eda4a63..0979b10d5186 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapper.java @@ -18,11 +18,13 @@ // package com.cloud.hypervisor.kvm.resource.wrapper; +import java.net.ServerSocket; import java.net.URLEncoder; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; import java.util.Locale; import java.util.Arrays; import java.util.List; @@ -33,6 +35,7 @@ import java.util.regex.Pattern; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.utils.qemu.QemuImg; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -42,14 +45,18 @@ import com.cloud.agent.api.to.DataStoreTO; import com.cloud.agent.api.to.NfsTO; import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareVddkSourceDiskTO; import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; import com.cloud.utils.FileUtil; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.script.OutputInterpreter; import com.cloud.utils.script.Script; @@ -59,6 +66,55 @@ public class LibvirtConvertInstanceCommandWrapper extends CommandWrapper supportedInstanceConvertSourceHypervisors = List.of(Hypervisor.HypervisorType.VMware); private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + // virt-v2v's exact wording when the host's virtio-win driver set has no match for the guest. + private static final String NO_VIRTIO_DRIVERS_MARKER = "there are no virtio drivers available"; + + /** + * Logs virt-v2v output like {@link OutputInterpreter.LineByLineOutputLogger} while watching for + * the "no virtio drivers available" warning. virt-v2v treats it as non-fatal and finishes the + * conversion, but a Windows guest converted without a virtio storage driver cannot boot from + * the virtio disk the import gives it - the failure only shows up later as an unbootable VM, + * so the conversion itself must refuse instead. + */ + private static class VirtioDriverOutputWatcher extends OutputInterpreter { + private final org.apache.logging.log4j.Logger watcherLogger; + private final String logPrefix; + private boolean noVirtioDriversReported; + + VirtioDriverOutputWatcher(org.apache.logging.log4j.Logger logger, String logPrefix) { + this.watcherLogger = logger; + this.logPrefix = logPrefix; + } + + @Override + public boolean drain() { + return true; + } + + @Override + public String interpret(java.io.BufferedReader reader) throws java.io.IOException { + String line; + while ((line = reader.readLine()) != null) { + watcherLogger.info("({}) {}", logPrefix, line); + if (line.toLowerCase().contains(NO_VIRTIO_DRIVERS_MARKER)) { + noVirtioDriversReported = true; + } + } + return null; + } + + boolean isNoVirtioDriversReported() { + return noVirtioDriversReported; + } + } + + private void failWindowsConversionWithoutVirtioDrivers(String originalVMName) { + throw new CloudRuntimeException(String.format( + "Conversion of Windows VM %s finished without virtio drivers: the conversion host's virtio-win driver set " + + "(/usr/share/virtio-win/virtio-win.iso) has no drivers for this Windows version, and the imported VM would not " + + "boot from its virtio disk. Update virtio-win on the conversion host to the upstream latest-virtio ISO and retry.", + originalVMName)); + } @Override public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serverResource) { @@ -70,6 +126,7 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve long timeout = (long) cmd.getWait() * 1000; String extraParams = cmd.getExtraParams(); boolean useVddk = cmd.isUseVddk(); + boolean windowsGuest = cmd.isWindowsGuest(); String originalVMName = cmd.getOriginalVMName(); if (cmd.getCheckConversionSupport() && !serverResource.hostSupportsInstanceConversion()) { @@ -89,6 +146,8 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); KVMStoragePool temporaryStoragePool = getTemporaryStoragePool(conversionTemporaryLocation, storagePoolMgr); + boolean directRbdVddkImport = isDirectRbdVddkImport(cmd); + boolean directLinstorVddkImport = isDirectLinstorVddkImport(cmd); logger.info(String.format("(%s) Attempting to convert the instance %s from %s to KVM", originalVMName, sourceInstanceName, sourceHypervisorType)); @@ -114,9 +173,27 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve String vddkTransports = resolveVddkSetting(cmd.getVddkTransports(), serverResource.getVddkTransports()); String configuredVddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); String passwordOption = serverResource.getDetectedPasswordFileOption(); - result = performInstanceConversionUsingVddk(sourceInstance, originalVMName, temporaryConvertPath, - vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, - timeout, verboseModeEnabled, extraParams, temporaryConvertUuid, passwordOption); + if (directRbdVddkImport || directLinstorVddkImport) { + if (directRbdVddkImport && !serverResource.hostSupportsVddkRbdDirectImport(cmd.getVddkLibDir())) { + String err = String.format("Direct RBD VDDK import requires VDDK, qemu-img RBD support, and virt-v2v in-place support on host %s. " + + "Use staged import with temporary conversion storage or select a newer conversion host.", serverResource.getPrivateIp()); + logger.error("({}) {}", originalVMName, err); + return new Answer(cmd, false, err); + } + if (directLinstorVddkImport && !serverResource.hostSupportsVirtV2vInPlace()) { + String err = String.format("Direct Linstor VDDK import requires virt-v2v in-place support on host %s. " + + "Use staged import with temporary conversion storage or select a newer conversion host.", serverResource.getPrivateIp()); + logger.error("({}) {}", originalVMName, err); + return new Answer(cmd, false, err); + } + result = performInstanceConversionUsingVddkDirectToPool(cmd, temporaryStoragePool, originalVMName, + vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, + timeout, verboseModeEnabled, temporaryConvertUuid, serverResource); + } else { + result = performInstanceConversionUsingVddk(sourceInstance, originalVMName, temporaryConvertPath, + vddkLibDir, serverResource.getLibguestfsBackend(), vddkTransports, configuredVddkThumbprint, + timeout, verboseModeEnabled, extraParams, temporaryConvertUuid, passwordOption, windowsGuest); + } } else { logger.info("({}) Using OVF-based conversion (export + local convert)", originalVMName); String sourceOVFDirPath; @@ -150,7 +227,7 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve } result = performInstanceConversion(originalVMName, sourceOVFDirPath, temporaryConvertPath, temporaryConvertUuid, - timeout, verboseModeEnabled, extraParams, serverResource); + timeout, verboseModeEnabled, extraParams, serverResource, windowsGuest); } if (!result) { @@ -177,6 +254,20 @@ public Answer execute(ConvertInstanceCommand cmd, LibvirtComputingResource serve } } + private boolean isDirectRbdVddkImport(ConvertInstanceCommand cmd) { + return isDirectVddkImportToPoolType(cmd, Storage.StoragePoolType.RBD); + } + + private boolean isDirectLinstorVddkImport(ConvertInstanceCommand cmd) { + return isDirectVddkImportToPoolType(cmd, Storage.StoragePoolType.Linstor); + } + + private boolean isDirectVddkImportToPoolType(ConvertInstanceCommand cmd, Storage.StoragePoolType poolType) { + DataStoreTO conversionTemporaryLocation = cmd.getConversionTemporaryLocation(); + return cmd.isUseVddk() && conversionTemporaryLocation instanceof PrimaryDataStoreTO && + ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType() == poolType; + } + protected KVMStoragePool getTemporaryStoragePool(DataStoreTO conversionTemporaryLocation, KVMStoragePoolManager storagePoolMgr) { if (conversionTemporaryLocation instanceof NfsTO) { NfsTO nfsTO = (NfsTO) conversionTemporaryLocation; @@ -251,7 +342,7 @@ protected boolean performInstanceConversion(String originalVMName, String source String temporaryConvertFolder, String temporaryConvertUuid, long timeout, boolean verboseModeEnabled, String extraParams, - LibvirtComputingResource serverResource) { + LibvirtComputingResource serverResource, boolean windowsGuest) { Script script = new Script("virt-v2v", timeout, logger); script.add("--root", "first"); script.add("-i", "ova"); @@ -266,17 +357,28 @@ protected boolean performInstanceConversion(String originalVMName, String source if (StringUtils.isNotBlank(extraParams)) { addExtraParamsToScript(extraParams, script); } + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; + if (firstbootScript != null) { + script.add("--firstboot", firstbootScript.toString()); + } - String logPrefix = String.format("(%s) virt-v2v ovf source: %s progress", originalVMName, sourceOVFDirPath); - OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); - Map convertInstanceEnv = serverResource.getConvertInstanceEnv(); - if (MapUtils.isEmpty(convertInstanceEnv)) { - script.execute(outputLogger); - } else { - script.execute(outputLogger, convertInstanceEnv); + try { + String logPrefix = String.format("(%s) virt-v2v ovf source: %s progress", originalVMName, sourceOVFDirPath); + VirtioDriverOutputWatcher outputLogger = new VirtioDriverOutputWatcher(logger, logPrefix); + Map convertInstanceEnv = serverResource.getConvertInstanceEnv(); + if (MapUtils.isEmpty(convertInstanceEnv)) { + script.execute(outputLogger); + } else { + script.execute(outputLogger, convertInstanceEnv); + } + if (windowsGuest && outputLogger.isNoVirtioDriversReported()) { + failWindowsConversionWithoutVirtioDrivers(originalVMName); + } + int exitValue = script.getExitValue(); + return exitValue == 0; + } finally { + deleteFirstbootScriptQuietly(firstbootScript, originalVMName); } - int exitValue = script.getExitValue(); - return exitValue == 0; } protected void addExtraParamsToScript(String extraParams, Script script) { @@ -308,7 +410,7 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst String libguestfsBackend, String vddkTransports, String configuredVddkThumbprint, long timeout, boolean verboseModeEnabled, String extraParams, - String temporaryConvertUuid, String passwordOption) { + String temporaryConvertUuid, String passwordOption, boolean windowsGuest) { String vcenterPassword = vmwareInstance.getVcenterPassword(); if (StringUtils.isBlank(vcenterPassword)) { @@ -328,6 +430,7 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst return false; } + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; try { String vpxUrl = buildVpxUrl(vmwareInstance); @@ -372,6 +475,10 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst cmd.append(extraParams).append(" "); } + if (firstbootScript != null) { + cmd.append("--firstboot ").append(shellQuote(firstbootScript.toString())).append(" "); + } + Script script = new Script("/bin/bash", timeout, logger); script.add("-c"); script.add(cmd.toString()); @@ -396,9 +503,620 @@ protected boolean performInstanceConversionUsingVddk(RemoteInstanceTO vmwareInst } catch (Exception e) { logger.warn("({}) Failed to delete password file {}: {}", originalVMName, passwordFilePath, e.getMessage()); } + deleteFirstbootScriptQuietly(firstbootScript, originalVMName); + } + } + + protected boolean performInstanceConversionUsingVddkDirectToPool(ConvertInstanceCommand cmd, + KVMStoragePool targetPool, + String originalVMName, + String vddkLibDir, + String libguestfsBackend, + String vddkTransports, + String configuredVddkThumbprint, + long timeout, + boolean verboseModeEnabled, + String temporaryConvertUuid, + LibvirtComputingResource serverResource) { + RemoteInstanceTO vmwareInstance = cmd.getSourceInstance(); + List sourceDisks = cmd.getVmwareVddkSourceDisks(); + final boolean linstorTarget = targetPool.getType() == Storage.StoragePoolType.Linstor; + final boolean windowsGuest = cmd.isWindowsGuest(); + if (sourceDisks == null || sourceDisks.isEmpty()) { + logger.error("({}) Direct {} VDDK import requires VMware source disk metadata", originalVMName, targetPool.getType()); + return false; + } + + if (linstorTarget) { + probeLinstorQemuAccess(targetPool, temporaryConvertUuid); + } else { + probeRbdQemuAccess(targetPool, temporaryConvertUuid); + } + + String vcenterPassword = vmwareInstance.getVcenterPassword(); + if (StringUtils.isBlank(vcenterPassword)) { + logger.error("({}) Could not determine vCenter password for {}", originalVMName, vmwareInstance.getVcenterHost()); + return false; + } + + String vddkThumbprint = StringUtils.trimToNull(configuredVddkThumbprint); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(vmwareInstance.getVcenterHost(), timeout, originalVMName); + } + if (StringUtils.isBlank(vddkThumbprint)) { + logger.error("({}) Could not determine vCenter thumbprint for {}", originalVMName, vmwareInstance.getVcenterHost()); + return false; + } + + String passwordFilePath = String.format("/tmp/v2v.rbd.pass.cloud.%s.%s", + StringUtils.defaultIfBlank(vmwareInstance.getVcenterHost(), "unknown"), + UUID.randomUUID()); + List createdImages = new ArrayList<>(); + List blockDevicePaths = new ArrayList<>(); + try { + Files.writeString(Path.of(passwordFilePath), vcenterPassword); + Files.setPosixFilePermissions(Path.of(passwordFilePath), Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + + String vddkNbdCompression = serverResource.getVddkNbdCompression(); + for (int i = 0; i < sourceDisks.size(); i++) { + VmwareVddkSourceDiskTO sourceDisk = sourceDisks.get(i); + if (linstorTarget) { + String diskName = buildLinstorDiskName(temporaryConvertUuid, i); + createdImages.add(diskName); + String devicePath = copyVddkSourceDiskToLinstor(vmwareInstance, sourceDisk, targetPool, diskName, passwordFilePath, + vddkLibDir, vddkTransports, vddkNbdCompression, vddkThumbprint, timeout, originalVMName, serverResource.hostSupportsNbdcopy()); + blockDevicePaths.add(devicePath); + } else { + String imageName = buildRbdImageName(temporaryConvertUuid, i); + createdImages.add(imageName); + copyVddkSourceDiskToRbd(vmwareInstance, sourceDisk, targetPool, imageName, passwordFilePath, + vddkLibDir, vddkTransports, vddkNbdCompression, vddkThumbprint, timeout, originalVMName, + serverResource.hostSupportsNbdcopy()); + } + } + + boolean finalized; + if (linstorTarget) { + Path inputXml = Files.createTempFile("cloudstack-vddk-direct-" + temporaryConvertUuid, ".xml"); + Files.writeString(inputXml, buildDirectBlockDeviceLibvirtXml(temporaryConvertUuid, blockDevicePaths)); + finalized = runInPlaceFinalization(inputXml, libguestfsBackend, timeout, verboseModeEnabled, originalVMName, serverResource, windowsGuest); + Files.deleteIfExists(inputXml); + } else { + finalized = runRbdInPlaceFinalizationOverNbdBridges(targetPool, createdImages, temporaryConvertUuid, + libguestfsBackend, timeout, verboseModeEnabled, originalVMName, serverResource, windowsGuest); + } + if (!finalized) { + cleanupDirectImportDisks(targetPool, createdImages, originalVMName); + } + return finalized; + } catch (Exception e) { + logger.error("({}) Direct {} VDDK import failed: {}", originalVMName, targetPool.getType(), e.getMessage(), e); + cleanupDirectImportDisks(targetPool, createdImages, originalVMName); + return false; + } finally { + try { + Files.deleteIfExists(Path.of(passwordFilePath)); + } catch (Exception e) { + logger.warn("({}) Failed to delete password file {}: {}", originalVMName, passwordFilePath, e.getMessage()); + } + } + } + + private void copyVddkSourceDiskToRbd(RemoteInstanceTO vmwareInstance, VmwareVddkSourceDiskTO sourceDisk, + KVMStoragePool targetPool, String imageName, String passwordFilePath, + String vddkLibDir, String vddkTransports, String vddkNbdCompression, + String vddkThumbprint, long timeout, String originalVMName, boolean useNbdcopy) { + if (StringUtils.isBlank(sourceDisk.getSourceDiskPath())) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a VMDK path", sourceDisk.getDiskId())); + } + String rbdImagePath = targetPool.getSourceDir() + "/" + imageName; + String qemuRbdTarget = KVMPhysicalDisk.RBDStringBuilder(targetPool, rbdImagePath); + + StringBuilder nbdkit = new StringBuilder(); + nbdkit.append("nbdkit -r -U - vddk "); + nbdkit.append("file=").append(shellQuote(sourceDisk.getSourceDiskPath())).append(" "); + nbdkit.append("server=").append(shellQuote(vmwareInstance.getVcenterHost())).append(" "); + nbdkit.append("user=").append(shellQuote(vmwareInstance.getVcenterUsername())).append(" "); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + if (StringUtils.isNotBlank(vmwareInstance.getVmwareMoref())) { + nbdkit.append("vm=").append(shellQuote("moref=" + vmwareInstance.getVmwareMoref())).append(" "); + } else { + nbdkit.append("vm=").append(shellQuote(vmwareInstance.getInstanceName())).append(" "); + } + nbdkit.append("libdir=").append(shellQuote(vddkLibDir)).append(" "); + nbdkit.append("thumbprint=").append(shellQuote(vddkThumbprint)).append(" "); + if (StringUtils.isNotBlank(vddkTransports)) { + nbdkit.append("transports=").append(shellQuote(vddkTransports)).append(" "); + } + if (StringUtils.isNotBlank(vddkNbdCompression)) { + nbdkit.append("compression=").append(shellQuote(vddkNbdCompression)).append(" "); + } + + boolean bridgeCopy = useNbdcopy && sourceDisk.getCapacityBytes() > 0; + Path scriptFile = null; + try { + Script script; + if (bridgeCopy) { + // Multi-connection nbdcopy is typically faster than a single-stream qemu-img + // convert, but nbdcopy needs both ends as NBD. qemu-img cannot create the + // image here (nbdcopy does not), so pre-create it, then serve it over a + // localhost qemu-nbd bridge and copy NBD-to-NBD. The fresh raw image reads + // as zeros, so --destination-is-zero keeps it sparse. + int port = allocateLocalhostPort(); + Path pidFile = Files.createTempFile("cloudstack-vddk-rbd-nbd-" + imageName + "-", ".pid"); + Files.deleteIfExists(pidFile); + nbdkit.append("--run ").append(shellQuote( + "nbdcopy --destination-is-zero \"$uri\" " + String.format("nbd://localhost:%d", port))); + + StringBuilder scriptBody = new StringBuilder(); + scriptBody.append("#!/bin/bash\n"); + scriptBody.append("set -euo pipefail\n"); + scriptBody.append("cleanup() {\n"); + scriptBody.append(" set +e\n"); + scriptBody.append(" if [[ -s ").append(shellQuote(pidFile.toString())).append(" ]]; then\n"); + scriptBody.append(" pid=$(cat ").append(shellQuote(pidFile.toString())).append(")\n"); + scriptBody.append(" kill \"$pid\" >/dev/null 2>&1 || true\n"); + scriptBody.append(" for attempt in {1..20}; do kill -0 \"$pid\" >/dev/null 2>&1 || break; sleep 0.1; done\n"); + scriptBody.append(" fi\n"); + scriptBody.append(" rm -f ").append(shellQuote(pidFile.toString())).append("\n"); + scriptBody.append("}\n"); + scriptBody.append("trap cleanup EXIT\n"); + scriptBody.append("qemu-img create -f raw ").append(shellQuote(qemuRbdTarget)).append(" ") + .append(sourceDisk.getCapacityBytes()).append("\n"); + scriptBody.append("qemu-nbd --fork --persistent --shared=8 --format=raw --bind=127.0.0.1 --port=") + .append(port).append(" --pid-file=").append(shellQuote(pidFile.toString())) + .append(" ").append(shellQuote(qemuRbdTarget)).append("\n"); + scriptBody.append(nbdkit).append("\n"); + + scriptFile = Files.createTempFile("cloudstack-vddk-rbd-copy-" + imageName + "-", ".sh"); + Files.writeString(scriptFile, scriptBody.toString()); + Files.setPosixFilePermissions(scriptFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + + script = new Script("/bin/bash", timeout, logger); + script.add(scriptFile.toString()); + } else { + // qemu-img convert creates and writes the RBD image in one step (fallback when + // nbdcopy is unavailable or the source capacity is unknown). + nbdkit.append("--run ").append(shellQuote("qemu-img convert -f raw -O raw \"$uri\" " + shellQuote(qemuRbdTarget))); + script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(nbdkit.toString()); + } + + String logPrefix = String.format("(%s) vddk%s to rbd disk %s", originalVMName, + bridgeCopy ? "(nbdcopy)" : "", sourceDisk.getDiskId()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + logger.info("({}) Copying VMware disk {} to RBD image {}{}", originalVMName, sourceDisk.getSourceDiskPath(), + rbdImagePath, bridgeCopy ? " via nbdcopy over qemu-nbd bridge" : ""); + script.execute(outputLogger); + if (script.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("Failed to copy VMware disk %s to RBD image %s", sourceDisk.getSourceDiskPath(), rbdImagePath)); + } + } catch (java.io.IOException e) { + throw new CloudRuntimeException(String.format("Failed to prepare RBD copy for VMware disk %s: %s", + sourceDisk.getDiskId(), e.getMessage()), e); + } finally { + deleteQuietly(scriptFile); + } + } + + private String copyVddkSourceDiskToLinstor(RemoteInstanceTO vmwareInstance, VmwareVddkSourceDiskTO sourceDisk, + KVMStoragePool targetPool, String diskName, String passwordFilePath, + String vddkLibDir, String vddkTransports, String vddkNbdCompression, + String vddkThumbprint, long timeout, String originalVMName, boolean useNbdcopy) { + if (StringUtils.isBlank(sourceDisk.getSourceDiskPath())) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a VMDK path", sourceDisk.getDiskId())); + } + if (sourceDisk.getCapacityBytes() <= 0) { + throw new CloudRuntimeException(String.format("VMware source disk %s does not have a valid capacity, " + + "which is required to pre-create the Linstor target volume", sourceDisk.getDiskId())); + } + KVMPhysicalDisk targetDisk = targetPool.createPhysicalDisk(diskName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, sourceDisk.getCapacityBytes(), null); + String devicePath = targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new CloudRuntimeException(String.format("Could not resolve a local device path for Linstor volume %s", diskName)); + } + StringBuilder command = new StringBuilder(); + command.append("nbdkit -r -U - vddk "); + command.append("file=").append(shellQuote(sourceDisk.getSourceDiskPath())).append(" "); + command.append("server=").append(shellQuote(vmwareInstance.getVcenterHost())).append(" "); + command.append("user=").append(shellQuote(vmwareInstance.getVcenterUsername())).append(" "); + command.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + if (StringUtils.isNotBlank(vmwareInstance.getVmwareMoref())) { + command.append("vm=").append(shellQuote("moref=" + vmwareInstance.getVmwareMoref())).append(" "); + } else { + command.append("vm=").append(shellQuote(vmwareInstance.getInstanceName())).append(" "); + } + command.append("libdir=").append(shellQuote(vddkLibDir)).append(" "); + command.append("thumbprint=").append(shellQuote(vddkThumbprint)).append(" "); + if (StringUtils.isNotBlank(vddkTransports)) { + command.append("transports=").append(shellQuote(vddkTransports)).append(" "); + } + if (StringUtils.isNotBlank(vddkNbdCompression)) { + command.append("compression=").append(shellQuote(vddkNbdCompression)).append(" "); + } + // Full-disk copy into the local raw device: prefer nbdcopy (libnbd) when present - + // it pipelines many in-flight requests and is typically faster than a single-connection + // qemu-img convert - and fall back to qemu-img convert otherwise. Skip writing zero + // blocks (nbdcopy --destination-is-zero / qemu-img --target-is-zero) only when the + // backend guarantees a freshly created volume reads back as zeros (thin providers); + // on backends that do not (e.g. LVM-thick) the zeros must be written or stale data from + // a previously deleted volume would leak into the unwritten regions. + boolean targetIsZero = targetPool.isVolumeZeroInitialized(diskName); + String runCommand = useNbdcopy + ? "nbdcopy " + (targetIsZero ? "--destination-is-zero " : "") + "\"$uri\" " + shellQuote(devicePath) + : "qemu-img convert -n " + (targetIsZero ? "--target-is-zero " : "") + "-f raw -O raw \"$uri\" " + shellQuote(devicePath); + command.append("--run ").append(shellQuote(runCommand)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command.toString()); + String logPrefix = String.format("(%s) %s to linstor disk %s", originalVMName, + useNbdcopy ? "vddk(nbdcopy)" : "vddk", sourceDisk.getDiskId()); + OutputInterpreter.LineByLineOutputLogger outputLogger = new OutputInterpreter.LineByLineOutputLogger(logger, logPrefix); + logger.info("({}) Copying VMware disk {} to Linstor device {}", originalVMName, sourceDisk.getSourceDiskPath(), devicePath); + script.execute(outputLogger); + if (script.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("Failed to copy VMware disk %s to Linstor device %s", sourceDisk.getSourceDiskPath(), devicePath)); + } + return devicePath; + } + + private boolean runInPlaceFinalization(Path inputXml, String libguestfsBackend, long timeout, + boolean verboseModeEnabled, String originalVMName, + LibvirtComputingResource serverResource, boolean windowsGuest) { + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; + try { + String inPlaceCommand = buildInPlaceCommand(inputXml, libguestfsBackend, verboseModeEnabled, serverResource, firstbootScript); + if (inPlaceCommand == null) { + logger.error("({}) No virt-v2v in-place finalization method is available", originalVMName); + return false; + } + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(inPlaceCommand); + VirtioDriverOutputWatcher outputLogger = new VirtioDriverOutputWatcher(logger, + String.format("(%s) virt-v2v in-place", originalVMName)); + script.execute(outputLogger); + if (windowsGuest && outputLogger.isNoVirtioDriversReported()) { + failWindowsConversionWithoutVirtioDrivers(originalVMName); + } + return script.getExitValue() == 0; + } finally { + deleteFirstbootScriptQuietly(firstbootScript, originalVMName); + } + } + + /** + * Builds the in-place finalization command, resolving the virt-v2v-in-place + * binary through the server resource (EL9 ships it in /usr/libexec, outside + * $PATH) and falling back to "virt-v2v --in-place". Returns null when the host + * has no in-place method. + */ + private String buildInPlaceCommand(Path inputXml, String libguestfsBackend, boolean verboseModeEnabled, + LibvirtComputingResource serverResource, Path firstbootScript) { + StringBuilder command = new StringBuilder(); + command.append("export LIBGUESTFS_BACKEND=").append(shellQuote(libguestfsBackend)).append(" && "); + String inPlaceBinary = serverResource.getVirtV2vInPlaceBinary(); + if (inPlaceBinary != null) { + // No -O (write updated output XML): nothing consumes it and the option only + // exists from virt-v2v 2.5 on, so passing it breaks otherwise capable hosts + // such as Ubuntu 24.04 with virt-v2v-in-place 2.4. + command.append(inPlaceBinary).append(" --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" "); + } else if (serverResource.hostSupportsVirtV2vInPlaceOption()) { + command.append("virt-v2v --root first -i libvirtxml ") + .append(shellQuote(inputXml.toString())).append(" --in-place "); + } else { + return null; + } + if (verboseModeEnabled) { + command.append("-v "); + } + if (firstbootScript != null) { + command.append("--firstboot ").append(shellQuote(firstbootScript.toString())).append(" "); + } + return command.toString(); + } + + /** + * Finalizes direct-RBD import disks with in-place virt-v2v over temporary + * localhost qemu-nbd bridges (one per image), mirroring the CBT cutover + * approach. Native {@code } sources cannot + * be used here: their cephx {@code } element references a libvirt secret, + * and virt-v2v's {@code -i libvirtxml} input runs without a libvirt connection, + * so the secret cannot be resolved and no drives are attached (observed as + * "you must call guestfs_add_drive before guestfs_launch" on EL9). + */ + protected boolean runRbdInPlaceFinalizationOverNbdBridges(KVMStoragePool targetPool, List imageNames, + String temporaryConvertUuid, String libguestfsBackend, + long timeout, boolean verboseModeEnabled, + String originalVMName, LibvirtComputingResource serverResource, + boolean windowsGuest) { + List ports = new ArrayList<>(); + List pidFiles = new ArrayList<>(); + Path inputXml = null; + Path scriptPath = null; + Path firstbootScript = windowsGuest ? writeWindowsOnlineDisksFirstbootScript(originalVMName) : null; + try { + for (int i = 0; i < imageNames.size(); i++) { + ports.add(allocateLocalhostPort()); + Path pidFile = Files.createTempFile("cloudstack-vddk-rbd-nbd-" + temporaryConvertUuid + "-", ".pid"); + Files.deleteIfExists(pidFile); + pidFiles.add(pidFile); + } + + inputXml = Files.createTempFile("cloudstack-vddk-rbd-" + temporaryConvertUuid, ".xml"); + Files.writeString(inputXml, buildDirectRbdNbdLibvirtXml(temporaryConvertUuid, ports)); + + String inPlaceCommand = buildInPlaceCommand(inputXml, libguestfsBackend, verboseModeEnabled, serverResource, firstbootScript); + if (inPlaceCommand == null) { + logger.error("({}) No virt-v2v in-place finalization method is available", originalVMName); + return false; + } + + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("cleanup() {\n"); + script.append(" set +e\n"); + script.append(" for pid_file in"); + for (Path pidFile : pidFiles) { + script.append(" ").append(shellQuote(pidFile.toString())); + } + script.append("; do\n"); + script.append(" if [[ -s \"$pid_file\" ]]; then\n"); + script.append(" pid=$(cat \"$pid_file\")\n"); + script.append(" kill \"$pid\" >/dev/null 2>&1 || true\n"); + script.append(" for attempt in {1..20}; do\n"); + script.append(" kill -0 \"$pid\" >/dev/null 2>&1 || break\n"); + script.append(" sleep 0.1\n"); + script.append(" done\n"); + script.append(" fi\n"); + script.append(" rm -f \"$pid_file\"\n"); + script.append(" done\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + for (int i = 0; i < imageNames.size(); i++) { + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, + targetPool.getSourceDir() + "/" + imageNames.get(i)); + script.append("qemu-nbd --fork --persistent --shared=1 --format=raw --bind=127.0.0.1 --port=") + .append(ports.get(i)) + .append(" --pid-file=").append(shellQuote(pidFiles.get(i).toString())) + .append(" ").append(shellQuote(qemuRbdPath)).append("\n"); + } + script.append(inPlaceCommand).append("\n"); + + scriptPath = Files.createTempFile("cloudstack-vddk-rbd-finalize-" + temporaryConvertUuid + "-", ".sh"); + Files.writeString(scriptPath, script.toString()); + Files.setPosixFilePermissions(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + + Script runner = new Script("/bin/bash", timeout, logger); + runner.add(scriptPath.toString()); + VirtioDriverOutputWatcher outputLogger = new VirtioDriverOutputWatcher(logger, + String.format("(%s) virt-v2v rbd in-place", originalVMName)); + runner.execute(outputLogger); + if (windowsGuest && outputLogger.isNoVirtioDriversReported()) { + failWindowsConversionWithoutVirtioDrivers(originalVMName); + } + return runner.getExitValue() == 0; + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + logger.error("({}) RBD in-place finalization over NBD bridges failed: {}", originalVMName, e.getMessage(), e); + return false; + } finally { + deleteQuietly(inputXml); + deleteQuietly(scriptPath); + deleteQuietly(firstbootScript); + for (Path pidFile : pidFiles) { + deleteQuietly(pidFile); + } + } + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (Exception e) { + logger.debug("Unable to delete temporary file {}: {}", path, e.getMessage()); + } + } + + private int allocateLocalhostPort() throws java.io.IOException { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(false); + return socket.getLocalPort(); + } + } + + protected String buildDirectRbdNbdLibvirtXml(String temporaryConvertUuid, List ports) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(xmlEscape("cloudstack-vddk-rbd-" + temporaryConvertUuid)).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" hvm\n"); + xml.append(" \n"); + for (int i = 0; i < ports.size(); i++) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private String buildDirectBlockDeviceLibvirtXml(String temporaryConvertUuid, List devicePaths) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(xmlEscape("cloudstack-vddk-direct-" + temporaryConvertUuid)).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" hvm\n"); + xml.append(" \n"); + for (int i = 0; i < devicePaths.size(); i++) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private String buildRbdImageName(String temporaryConvertUuid, int position) { + return String.format("%s-disk-%03d", temporaryConvertUuid, position); + } + + /** + * Linstor disk names use a shorter position suffix than the RBD naming scheme: + * LINSTOR resource names are limited to 48 characters and the "cs-" prefix plus + * a 36-character UUID leaves little room ("-d%02d" keeps the name at 43). + */ + private String buildLinstorDiskName(String temporaryConvertUuid, int position) { + return String.format("%s-d%02d", temporaryConvertUuid, position); + } + + private String diskTargetName(int index) { + StringBuilder target = new StringBuilder("sd"); + int value = index; + do { + target.insert(2, (char) ('a' + (value % 26))); + value = value / 26 - 1; + } while (value >= 0); + return target.toString(); + } + + private void probeRbdQemuAccess(KVMStoragePool pool, String temporaryConvertUuid) { + String probeName = temporaryConvertUuid + "-probe-" + UUID.randomUUID(); + String rbdImagePath = pool.getSourceDir() + "/" + probeName; + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(pool, rbdImagePath); + try { + Script qemuImg = new Script("qemu-img", 120000, logger); + qemuImg.add("create", "-f", "raw", qemuRbdPath, "4194304"); + qemuImg.execute(); + if (qemuImg.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-img could not create RBD probe image %s", rbdImagePath)); + } + + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", qemuRbdPath); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify RBD probe image %s", rbdImagePath)); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete RBD probe image {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } } } + /** + * First-boot batch script injected into converted Windows guests. virt-v2v runs it once on the + * guest's first boot. It brings migrated data disks online and read-write and sets the SAN policy to + * OnlineAll, working around the common post-v2v symptom where secondary disks stay Offline/Read-only + * under the default Windows SAN policy. The Storage cmdlets used exist on Windows Server 2012+. + */ + protected static final String WINDOWS_ONLINE_DISKS_FIRSTBOOT = + "@echo off\r\n" + + "rem CloudStack post-migration: online offline disks, clear read-only, set SAN policy OnlineAll\r\n" + + "set CSLOG=%SystemDrive%\\cloudstack-firstboot.log\r\n" + + "echo [%DATE% %TIME%] CloudStack firstboot: bringing migrated disks online >> \"%CSLOG%\"\r\n" + + "powershell -NonInteractive -ExecutionPolicy Bypass -Command \"try { Set-StorageSetting -NewDiskPolicy OnlineAll -ErrorAction SilentlyContinue } catch {}; Get-Disk | Where-Object { $_.IsOffline } | ForEach-Object { Set-Disk -Number $_.Number -IsOffline $false }; Get-Disk | Where-Object { $_.IsReadOnly } | ForEach-Object { Set-Disk -Number $_.Number -IsReadOnly $false }\" >> \"%CSLOG%\" 2>&1\r\n" + + "exit /b 0\r\n"; + + /** + * Writes the Windows online-disks first-boot batch script to a temporary file so it can be passed to + * virt-v2v / virt-v2v-in-place via {@code --firstboot}. Returns the path, or {@code null} if it could + * not be written (conversion then proceeds without the first-boot fix rather than failing). + */ + protected Path writeWindowsOnlineDisksFirstbootScript(String originalVMName) { + try { + Path firstbootScript = Files.createTempFile("cloudstack-firstboot-online-disks-", ".bat"); + Files.writeString(firstbootScript, WINDOWS_ONLINE_DISKS_FIRSTBOOT); + logger.info("({}) Windows guest detected; injecting first-boot script to online migrated disks: {}", + originalVMName, firstbootScript); + return firstbootScript; + } catch (Exception e) { + logger.warn("({}) Failed to write Windows online-disks first-boot script; conversion will proceed without it: {}", + originalVMName, e.getMessage()); + return null; + } + } + + private void deleteFirstbootScriptQuietly(Path firstbootScript, String originalVMName) { + if (firstbootScript == null) { + return; + } + try { + Files.deleteIfExists(firstbootScript); + } catch (Exception e) { + logger.warn("({}) Failed to delete first-boot script {}: {}", originalVMName, firstbootScript, e.getMessage()); + } + } + + private void probeLinstorQemuAccess(KVMStoragePool pool, String temporaryConvertUuid) { + String probeName = temporaryConvertUuid.substring(0, 8) + "-probe"; + try { + KVMPhysicalDisk probeDisk = pool.createPhysicalDisk(probeName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, 4194304L, null); + Script qemuIo = new Script("qemu-io", 120000, logger); + qemuIo.add("-f", "raw", "-c", "write -P 0x5a 0 4k", "-c", "read -P 0x5a 0 4k", probeDisk.getPath()); + qemuIo.execute(); + if (qemuIo.getExitValue() != 0) { + throw new CloudRuntimeException(String.format("qemu-io could not verify Linstor probe volume %s on pool %s", probeName, pool.getUuid())); + } + } finally { + try { + pool.deletePhysicalDisk(probeName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("Failed to delete Linstor probe volume {} from pool {}: {}", probeName, pool.getUuid(), e.getMessage()); + } + } + } + + private void cleanupDirectImportDisks(KVMStoragePool pool, List imageNames, String originalVMName) { + for (String imageName : imageNames) { + try { + logger.info("({}) Cleaning up disk {} after failed direct import", originalVMName, imageName); + pool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (Exception e) { + logger.warn("({}) Failed to delete disk {} from pool {}: {}", originalVMName, imageName, pool.getUuid(), e.getMessage()); + } + } + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String xmlEscape(String value) { + return StringUtils.defaultString(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + protected String getVcenterThumbprint(String vcenterHost, long timeout, String originalVMName) { if (StringUtils.isBlank(vcenterHost)) { return null; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateVMSnapshotCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateVMSnapshotCommandWrapper.java index de95f785d44b..e3b5c0363f1e 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateVMSnapshotCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateVMSnapshotCommandWrapper.java @@ -19,6 +19,7 @@ package com.cloud.hypervisor.kvm.resource.wrapper; +import org.apache.commons.lang.StringEscapeUtils; import org.libvirt.Connect; import org.libvirt.Domain; import org.libvirt.DomainInfo.DomainState; @@ -57,7 +58,7 @@ public Answer execute(final CreateVMSnapshotCommand cmd, final LibvirtComputingR "Create Instance Snapshot Failed due to Instance is not running: " + vmName + " with domainState = " + domainState); } - String vmSnapshotXML = "" + " " + vmSnapshotName + "" + String vmSnapshotXML = "" + " " + StringEscapeUtils.escapeXml(vmSnapshotName) + "" + " " + ""; dm.snapshotCreateXML(vmSnapshotXML); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVncPortCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVncPortCommandWrapper.java index c16c9bcb9a8b..aa628950f925 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVncPortCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVncPortCommandWrapper.java @@ -41,6 +41,7 @@ public Answer execute(final GetVncPortCommand command, final LibvirtComputingRes final Integer vncPort = libvirtComputingResource.getVncPort(conn, command.getName()); return new GetVncPortAnswer(command, libvirtComputingResource.getPrivateIp(), 5900 + vncPort); } catch (final LibvirtException e) { + logger.error("Failed to get vnc port for the vm: {} due to {}", command.getName(), e.getMessage()); return new GetVncPortAnswer(command, e.toString()); } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java index 1109d240a035..46cb1b9bee33 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java @@ -52,7 +52,7 @@ public final class LibvirtGetVolumesOnStorageCommandWrapper extends CommandWrapper { static final List STORAGE_POOL_TYPES_SUPPORTED_BY_QEMU_IMG = Arrays.asList(StoragePoolType.NetworkFilesystem, - StoragePoolType.Filesystem, StoragePoolType.RBD, StoragePoolType.SharedMountPoint); + StoragePoolType.Filesystem, StoragePoolType.RBD, StoragePoolType.SharedMountPoint, StoragePoolType.Linstor); @Override public Answer execute(final GetVolumesOnStorageCommand command, final LibvirtComputingResource libvirtComputingResource) { @@ -64,11 +64,22 @@ public Answer execute(final GetVolumesOnStorageCommand command, final LibvirtCom final KVMStoragePoolManager storagePoolMgr = libvirtComputingResource.getStoragePoolMgr(); final KVMStoragePool storagePool = storagePoolMgr.getStoragePool(pool.getType(), pool.getUuid(), true, true); + if (StoragePoolType.Linstor.equals(pool.getType())) { + try { + List volumes = storagePool.getVolumesForImport( + StringUtils.isBlank(volumePath) ? null : volumePath); + if (StringUtils.isNotBlank(keyword)) { + volumes = volumes.stream().filter(volume -> volume.getName().contains(keyword)).collect(Collectors.toList()); + } + return new GetVolumesOnStorageAnswer(command, volumes); + } catch (RuntimeException e) { + return new GetVolumesOnStorageAnswer(command, false, e.getMessage()); + } + } if (StringUtils.isNotBlank(volumePath)) { return addVolumeByVolumePath(command, storagePool, volumePath); - } else { - return addAllVolumes(command, storagePool, keyword); } + return addAllVolumes(command, storagePool, keyword); } private GetVolumesOnStorageAnswer addVolumeByVolumePath(final GetVolumesOnStorageCommand command, final KVMStoragePool storagePool, String volumePath) { @@ -128,7 +139,20 @@ private void addDetailsToVolumeOnStorageTO(VolumeOnStorageTO volumeOnStorageTO, if (StringUtils.isNotBlank(encrypted) && encrypted.equalsIgnoreCase("yes")) { volumeOnStorageTO.addDetail(VolumeOnStorageTO.Detail.IS_ENCRYPTED, String.valueOf(Boolean.TRUE)); } - Boolean isLocked = isDiskFileLocked(storagePool, disk); + boolean isLocked = isDiskFileLocked(storagePool, disk); + if (!isLocked) { + // Clustered block storage (Linstor/DRBD): the host-local qemu-img lock cannot see a + // volume in use by a running VM on another node — consult the cluster-wide state. + String inUseNode = storagePool.getVolumeInUseNode(disk.getName()); + if (KVMStoragePool.IN_USE_NODE_UNKNOWN.equals(inUseNode)) { + logger.warn("Could not determine cluster-wide in-use state of volume {}; marking as locked", + disk.getName()); + isLocked = true; + } else if (inUseNode != null) { + logger.info("Volume {} is in use on node {}; marking as locked", disk.getName(), inUseNode); + isLocked = true; + } + } volumeOnStorageTO.addDetail(VolumeOnStorageTO.Detail.IS_LOCKED, String.valueOf(isLocked)); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java index 28e24a9e0f2d..fb432cd71e66 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java @@ -20,7 +20,9 @@ import java.util.List; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.commons.collections4.CollectionUtils; import com.cloud.agent.api.Answer; import com.cloud.agent.api.ImportConvertedInstanceAnswer; @@ -35,7 +37,7 @@ import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.ResourceWrapper; -import org.apache.commons.collections4.CollectionUtils; +import com.cloud.storage.Storage; @ResourceWrapper(handles = ImportConvertedInstanceCommand.class) public class LibvirtImportConvertedInstanceCommandWrapper extends LibvirtBaseConvertCommandWrapper { @@ -46,6 +48,7 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour Hypervisor.HypervisorType sourceHypervisorType = sourceInstance.getHypervisorType(); String sourceInstanceName = sourceInstance.getInstanceName(); List destinationStoragePools = cmd.getDestinationStoragePools(); + List destinationStoragePoolTypes = cmd.getDestinationStoragePoolTypes(); DataStoreTO conversionTemporaryLocation = cmd.getConversionTemporaryLocation(); final String temporaryConvertUuid = cmd.getTemporaryConvertUuid(); final boolean forceConvertToPool = cmd.isForceConvertToPool(); @@ -55,6 +58,12 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour final String temporaryConvertPath = temporaryStoragePool.getLocalPath(); try { + if (isForcedBlockStorageConversion(conversionTemporaryLocation, forceConvertToPool)) { + List disks = getTemporaryDisksWithPrefixFromTemporaryPool(temporaryStoragePool, temporaryConvertPath, temporaryConvertUuid); + UnmanagedInstanceTO convertedInstanceTO = getConvertedUnmanagedInstance(temporaryConvertUuid, disks, null); + return new ImportConvertedInstanceAnswer(cmd, convertedInstanceTO); + } + String convertedBasePath = String.format("%s/%s", temporaryConvertPath, temporaryConvertUuid); LibvirtDomainXMLParser xmlParser = parseMigratedVMXmlDomain(convertedBasePath); @@ -68,7 +77,7 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour disks = temporaryDisks; } else { disks = moveTemporaryDisksToDestination(temporaryDisks, - destinationStoragePools, storagePoolMgr); + destinationStoragePools, destinationStoragePoolTypes, storagePoolMgr); cleanupDisksAndDomainFromTemporaryLocation(temporaryDisks, temporaryStoragePool, temporaryConvertUuid, true); } @@ -93,4 +102,12 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour } } } + + private boolean isForcedBlockStorageConversion(DataStoreTO conversionTemporaryLocation, boolean forceConvertToPool) { + if (!forceConvertToPool || !(conversionTemporaryLocation instanceof PrimaryDataStoreTO)) { + return false; + } + Storage.StoragePoolType poolType = ((PrimaryDataStoreTO) conversionTemporaryLocation).getPoolType(); + return poolType == Storage.StoragePoolType.RBD || poolType == Storage.StoragePoolType.Linstor; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtManageSnapshotCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtManageSnapshotCommandWrapper.java index ec900e9981e0..53813a0ed1bc 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtManageSnapshotCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtManageSnapshotCommandWrapper.java @@ -22,6 +22,7 @@ import java.io.File; import java.text.MessageFormat; +import org.apache.commons.lang.StringEscapeUtils; import org.libvirt.Connect; import org.libvirt.Domain; import org.libvirt.DomainInfo.DomainState; @@ -79,7 +80,7 @@ public Answer execute(final ManageSnapshotCommand command, final LibvirtComputin + " {1}" + " " + " "); final String vmUuid = vm.getUUIDString(); - final Object[] args = new Object[] {snapshotName, vmUuid}; + final Object[] args = new Object[] {StringEscapeUtils.escapeXml(snapshotName), vmUuid}; final String snapshot = snapshotXML.format(args); logger.debug(snapshot); if (command.getCommandSwitch().equalsIgnoreCase(ManageSnapshotCommand.CREATE_SNAPSHOT)) { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java index b0950376a93d..acf95edeb954 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPlugNicCommandWrapper.java @@ -30,15 +30,29 @@ import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.utils.security.ParserUtils; import org.libvirt.Connect; import org.libvirt.Domain; import org.libvirt.LibvirtException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; +import java.io.StringReader; +import java.util.HashSet; import java.util.List; +import java.util.Set; @ResourceWrapper(handles = PlugNicCommand.class) public final class LibvirtPlugNicCommandWrapper extends CommandWrapper { + /** Highest PCI slot number on a bus (0x00 is the host bridge). */ + private static final int MAX_PCI_SLOT = 0x1f; @Override public Answer execute(final PlugNicCommand command, final LibvirtComputingResource libvirtComputingResource) { @@ -65,6 +79,15 @@ public Answer execute(final PlugNicCommand command, final LibvirtComputingResour if (command.getDetails() != null) { libvirtComputingResource.setInterfaceDefQueueSettings(command.getDetails(), null, interfaceDef); } + + // Pin the PCI slot to the lowest free one above the existing NICs so the guest sees a + // deterministic, monotonic NIC order across hot-plugs (see findNextAvailablePciSlot). + Integer nextSlot = findNextAvailablePciSlot(vm, pluggedNics); + if (nextSlot != null) { + interfaceDef.setSlot(nextSlot); + logger.debug("Assigning PCI slot 0x" + String.format("%02x", nextSlot) + " to hot-plugged NIC"); + } + vm.attachDevice(interfaceDef.toString()); // apply default network rules on new nic @@ -96,4 +119,86 @@ public Answer execute(final PlugNicCommand command, final LibvirtComputingResour } } } + + /** + * Picks the PCI slot for the NIC being hot-plugged: the lowest free slot above the highest slot + * already used by a NIC. The choice is deterministic and monotonic with respect to the NICs that + * are already present (a new NIC never lands below an existing one), which is what keeps the + * guest's predictable interface names stable across hot-plugs. It does not guarantee contiguity: + * slots between the last NIC and the new one may already be taken by other devices (disks, + * controllers, balloon), in which case the next free slot above them is used. + * + * @return the slot to assign, or {@code null} to let libvirt auto-assign (domain XML unavailable + * or unparseable, or no free slot left). + */ + protected Integer findNextAvailablePciSlot(final Domain vm, final List pluggedNics) { + try { + final String domXml = vm.getXMLDesc(0); + // getXMLDesc can return null on certain libvirt error paths; fall back to libvirt's own choice. + if (domXml == null) { + logger.debug("Domain XML unavailable, letting libvirt auto-assign PCI slot"); + return null; + } + final Set usedSlots = getUsedPciSlots(domXml); + if (usedSlots == null) { + return null; + } + final Integer slot = getFirstFreeSlotAbove(getHighestNicSlot(pluggedNics), usedSlots); + if (slot == null) { + logger.warn("No free PCI slots available, letting libvirt auto-assign"); + } + return slot; + } catch (final LibvirtException e) { + logger.warn("Failed to get domain XML for PCI slot calculation, letting libvirt auto-assign", e); + return null; + } + } + + /** + * Collects the slot numbers of every {@code

} element in the domain XML, + * whichever device or bus they belong to. Returns {@code null} if the XML cannot be parsed. + */ + protected Set getUsedPciSlots(final String domXml) { + final Set usedSlots = new HashSet<>(); + try { + final DocumentBuilder builder = ParserUtils.getSaferDocumentBuilderFactory().newDocumentBuilder(); + final Document doc = builder.parse(new InputSource(new StringReader(domXml))); + final NodeList addresses = doc.getElementsByTagName("address"); + for (int i = 0; i < addresses.getLength(); i++) { + final Element address = (Element) addresses.item(i); + if (!"pci".equals(address.getAttribute("type")) || address.getAttribute("slot").isEmpty()) { + continue; + } + usedSlots.add(Integer.decode(address.getAttribute("slot"))); + } + } catch (final ParserConfigurationException | SAXException | IOException | NumberFormatException e) { + logger.warn("Failed to parse domain XML for PCI slot calculation, letting libvirt auto-assign", e); + return null; + } + return usedSlots; + } + + /** Highest PCI slot used by an existing NIC, or 0 when no NIC carries a slot. */ + protected static int getHighestNicSlot(final List pluggedNics) { + int highest = 0; + for (final InterfaceDef pluggedNic : pluggedNics) { + if (pluggedNic.getSlot() != null && pluggedNic.getSlot() > highest) { + highest = pluggedNic.getSlot(); + } + } + return highest; + } + + /** + * Lowest slot strictly above {@code from} (and no higher than {@link #MAX_PCI_SLOT}) that is not in + * {@code usedSlots}; {@code null} when the range is exhausted. Slot 0 is reserved for the host bridge. + */ + protected static Integer getFirstFreeSlotAbove(final int from, final Set usedSlots) { + for (int slot = from + 1; slot <= MAX_PCI_SLOT; slot++) { + if (!usedSlots.contains(slot)) { + return slot; + } + } + return null; + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java index 1ff6d7851f20..72d9c3c84c46 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtReadyCommandWrapper.java @@ -52,9 +52,19 @@ public Answer execute(final ReadyCommand command, final LibvirtComputingResource if (libvirtComputingResource.hostSupportsInstanceConversion()) { hostDetails.put(Host.HOST_VIRTV2V_VERSION, libvirtComputingResource.getHostVirtV2vVersion()); } + hostDetails.put(Host.HOST_VIRTV2V_INPLACE_VERSION, libvirtComputingResource.getHostVirtV2vInPlaceVersion()); hostDetails.put(Host.HOST_VDDK_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddk())); hostDetails.put(Host.HOST_VDDK_LIB_DIR, StringUtils.defaultString(libvirtComputingResource.getVddkLibDir())); hostDetails.put(Host.HOST_VDDK_VERSION, StringUtils.defaultString(libvirtComputingResource.getVddkVersion())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopy())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_INPLACE_FINALIZATION_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopyInPlaceFinalization())); + hostDetails.put(Host.HOST_VDDK_BLOCKCOPY_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkBlockCopyRbd())); + hostDetails.put(Host.HOST_QEMU_IMG_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuImgVersion())); + hostDetails.put(Host.HOST_QEMU_NBD_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuNbdVersion())); + hostDetails.put(Host.HOST_QEMU_IO_VERSION, StringUtils.defaultString(libvirtComputingResource.getQemuIoVersion())); + hostDetails.put(Host.HOST_VIRTV2V_INPLACE_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVirtV2vInPlace())); + hostDetails.put(Host.HOST_QEMU_RBD_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsQemuRbd())); + hostDetails.put(Host.HOST_VDDK_RBD_DIRECT_IMPORT_SUPPORT, Boolean.toString(libvirtComputingResource.hostSupportsVddkRbdDirectImport())); if (libvirtComputingResource.hostSupportsOvfExport()) { hostDetails.put(Host.HOST_OVFTOOL_VERSION, libvirtComputingResource.getHostOvfToolVersion()); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java index e76c9a2a3871..b7e609587650 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java @@ -143,6 +143,7 @@ private Pair runBackupScript(LibvirtComputingResource libvirtCo TakeBackupCommand command, String vmName, String backupRepoType, String backupRepoAddress, String mountOptions, String backupPath, List diskPaths, String mode, String bitmapNew, String bitmapParent, List parentPaths, int timeout) { + boolean quiesce = command.getQuiesce() != null && command.getQuiesce(); List argv = new ArrayList<>(Arrays.asList( libvirtComputingResource.getNasBackupPath(), "-o", "backup", @@ -151,9 +152,14 @@ private Pair runBackupScript(LibvirtComputingResource libvirtCo "-s", backupRepoAddress, "-m", Objects.nonNull(mountOptions) ? mountOptions : "", "-p", backupPath, - "-q", command.getQuiesce() != null && command.getQuiesce() ? "true" : "false", + "-q", quiesce ? "true" : "false", "-d", CollectionUtils.isEmpty(diskPaths) ? "" : String.join(",", diskPaths) )); + Integer quiesceTimeout = command.getQuiesceTimeout(); + if (quiesce && quiesceTimeout != null && quiesceTimeout > 0) { + argv.add("--quiesce-timeout"); + argv.add(String.valueOf(quiesceTimeout)); + } if (StringUtils.isNotBlank(mode)) { argv.add("-M"); argv.add(mode); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtUtilitiesHelper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtUtilitiesHelper.java index 70e7f074c879..0a37c971a571 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtUtilitiesHelper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtUtilitiesHelper.java @@ -26,6 +26,7 @@ import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.commons.lang.StringEscapeUtils; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; import org.libvirt.Connect; @@ -112,9 +113,9 @@ public Script buildScript(final String scriptPath) { } public String generateVMSnapshotXML(VMSnapshotTO snapshot, VMSnapshotTO parent, String domainXmlDesc) { - String parentName = (parent == null)? "": (" " + parent.getSnapshotName() + "\n"); + String parentName = (parent == null)? "": (" " + StringEscapeUtils.escapeXml(parent.getSnapshotName()) + "\n"); String vmSnapshotXML = "\n" - + " " + snapshot.getSnapshotName() + "\n" + + " " + StringEscapeUtils.escapeXml(snapshot.getSnapshotName()) + "\n" + " running\n" + parentName + " " + (int) Math.rint(snapshot.getCreateTime()/1000) + "\n" diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java new file mode 100644 index 000000000000..3db111fa56a2 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCleanupCommandWrapper.java @@ -0,0 +1,339 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCleanupCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtCleanupCommand.class) +public class LibvirtVmwareCbtCleanupCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(VmwareCbtCleanupCommand cmd, LibvirtComputingResource serverResource) { + if (!cmd.getRemovePartialTargetDisks()) { + String msg = String.format("VMware CBT cleanup for migration %s skipped target disk cleanup by command policy.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + try { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + int removedImages = deleteRbdTargetImages(cmd, serverResource.getStoragePoolMgr()); + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated RBD target image(s).", + cmd.getMigrationUuid(), removedImages); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + int removedVolumes = deleteBlockDeviceTargetVolumes(cmd, serverResource.getStoragePoolMgr()); + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated block device target volume(s).", + cmd.getMigrationUuid(), removedVolumes); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } + + killInFlightCopyProcesses(String.format("/cloudstack-cbt/%s/", cmd.getMigrationUuid()), + cmd.getMigrationUuid()); + Set migrationDirectories = getMigrationDirectories(cmd); + int removedDirectories = 0; + for (Path migrationDirectory : migrationDirectories) { + if (deleteMigrationDirectory(migrationDirectory)) { + removedDirectories++; + } + } + String msg = String.format("VMware CBT cleanup for migration %s removed %s replicated target directorie(s).", + cmd.getMigrationUuid(), removedDirectories); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid()); + } catch (Exception e) { + String msg = String.format("Unable to clean up VMware CBT migration %s on host %s: %s", + cmd.getMigrationUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + } + + private int deleteRbdTargetImages(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = getTargetStoragePool(cmd, storagePoolMgr); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD cleanup", + cmd.getMigrationUuid())); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return 0; + } + + killInFlightCopyProcesses(String.format("cloudstack-cbt-%s-", cmd.getMigrationUuid()), cmd.getMigrationUuid()); + + int removedImages = 0; + List failedImages = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + String imageName = getRbdCleanupImageName(cmd.getMigrationUuid(), disk.getTargetPath()); + if (StringUtils.isBlank(imageName)) { + continue; + } + if (deletePhysicalDiskWithRetries(targetPool, imageName, cmd.getMigrationUuid())) { + removedImages++; + } else { + failedImages.add(imageName); + } + } + if (CollectionUtils.isNotEmpty(failedImages)) { + throw new IllegalStateException(String.format("Unable to delete VMware CBT RBD target image(s) %s from storage pool %s", + StringUtils.join(failedImages, ", "), targetPool.getUuid())); + } + return removedImages; + } + + private int deleteBlockDeviceTargetVolumes(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = getTargetStoragePool(cmd, storagePoolMgr); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device cleanup", + cmd.getMigrationUuid())); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return 0; + } + + killInFlightCopyProcesses(String.format("cbt-%s-", StringUtils.defaultString(cmd.getMigrationUuid()).replace("-", "").substring(0, 8)), + cmd.getMigrationUuid()); + + int removedVolumes = 0; + List failedVolumes = new ArrayList<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + String volumeName = getBlockDeviceCleanupVolumeName(cmd.getMigrationUuid(), disk.getTargetPath()); + if (StringUtils.isBlank(volumeName)) { + continue; + } + if (deletePhysicalDiskWithRetries(targetPool, volumeName, cmd.getMigrationUuid())) { + removedVolumes++; + } else { + failedVolumes.add(volumeName); + } + } + if (CollectionUtils.isNotEmpty(failedVolumes)) { + throw new IllegalStateException(String.format("Unable to delete VMware CBT block device target volume(s) %s from storage pool %s", + StringUtils.join(failedVolumes, ", "), targetPool.getUuid())); + } + return removedVolumes; + } + + /** + * Block device target names carry a short migration marker ("cbt-" plus the first + * eight non-dash characters of the migration UUID) instead of the full RBD-style + * marker, because LINSTOR resource names are limited to 48 characters. + */ + private String getBlockDeviceCleanupVolumeName(String migrationUuid, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + String marker = String.format("cbt-%s-", StringUtils.defaultString(migrationUuid).replace("-", "").substring(0, 8)); + if (!StringUtils.startsWith(volumeName, marker)) { + logger.warn("Skipping VMware CBT block device cleanup target {} because it does not start with marker {}", targetPath, marker); + return null; + } + return volumeName; + } + + /** + * A cancelled migration's initial/delta sync may still be copying into the + * target volumes when this cleanup runs (the data-plane copy is not aborted by + * the cancel itself). Any surviving copy keeps the target device/image busy - + * volume deletion then fails and the copy keeps burning VDDK/network/storage on + * a dead migration. Kill processes whose command line references this + * migration's marker-guarded target names before deleting them: this matches + * the nbdcopy / qemu-img convert / qemu-io processes (their arguments carry the + * marker-named target); the nbdkit --run parent exits with its child. + */ + protected void killInFlightCopyProcesses(String marker, String migrationUuid) { + if (StringUtils.isBlank(marker)) { + return; + } + // Make the first character a bracket expression. The regex still matches the literal + // target marker, but it does not match the bash command line running pkill itself. + String processMatchPattern = String.format("[%s]%s", marker.charAt(0), marker.substring(1)); + int exitValue = Script.runSimpleBashScriptForExitValue(String.format("pkill -f -- %s", + quoteShellArgument(processMatchPattern))); + if (exitValue == 0) { + logger.info("Killed in-flight copy process(es) matching marker {} for VMware CBT migration {}", marker, migrationUuid); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Retries volume deletion briefly: right after in-flight copies are killed the + * device may still be held open (DRBD demotion, qemu teardown) for a moment. + */ + protected boolean deletePhysicalDiskWithRetries(KVMStoragePool targetPool, String volumeName, String migrationUuid) { + final int attempts = 3; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + if (targetPool.deletePhysicalDisk(volumeName, Storage.ImageFormat.RAW)) { + return true; + } + logger.warn("Attempt {}/{} to delete VMware CBT target {} for migration {} returned false", + attempt, attempts, volumeName, migrationUuid); + } catch (RuntimeException e) { + logger.warn("Attempt {}/{} to delete VMware CBT target {} for migration {} failed: {}", + attempt, attempts, volumeName, migrationUuid, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + if (isTargetAlreadyAbsent(targetPool, volumeName, migrationUuid)) { + return true; + } + if (attempt < attempts) { + try { + Thread.sleep(2000L * attempt); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return isTargetAlreadyAbsent(targetPool, volumeName, migrationUuid); + } + + /** + * Target cleanup has to be idempotent. A cancelled migration has already had its targets + * removed by the cancel itself, and deleting the migration afterwards runs cleanup again + * (the delete API defaults to cleanup=true). Without this check that second pass fails on + * targets that are legitimately gone, and the migration record can never be removed. + *

+ * Absence is only reported when it can be positively confirmed by listing the pool; if the + * pool cannot be inspected the target is assumed to still exist, so a genuine failure is + * still surfaced rather than being masked. + */ + protected boolean isTargetAlreadyAbsent(KVMStoragePool targetPool, String volumeName, String migrationUuid) { + try { + List disks = targetPool.listPhysicalDisks(); + if (disks == null) { + return false; + } + for (KVMPhysicalDisk disk : disks) { + if (disk != null && StringUtils.equals(disk.getName(), volumeName)) { + return false; + } + } + logger.info("VMware CBT target {} for migration {} is no longer present on storage pool {}; treating cleanup as complete", + volumeName, migrationUuid, targetPool.getUuid()); + return true; + } catch (RuntimeException e) { + logger.warn("Unable to confirm whether VMware CBT target {} for migration {} still exists on storage pool {}: {}", + volumeName, migrationUuid, targetPool.getUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + return false; + } + } + + private String quoteShellArgument(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtCleanupCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + return storagePoolMgr.getStoragePool(poolType, poolUuid); + } + return null; + } + + private String getRbdCleanupImageName(String migrationUuid, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + String marker = String.format("cloudstack-cbt-%s-", migrationUuid); + if (!StringUtils.contains(imageName, marker)) { + logger.warn("Skipping VMware CBT RBD cleanup target {} because it does not contain marker {}", targetPath, marker); + return null; + } + return imageName; + } + + private Set getMigrationDirectories(VmwareCbtCleanupCommand cmd) { + Set migrationDirectories = new HashSet<>(); + if (CollectionUtils.isEmpty(cmd.getDisks())) { + return migrationDirectories; + } + + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + Path migrationDirectory = getMigrationDirectory(cmd.getMigrationUuid(), disk.getTargetPath()); + if (migrationDirectory != null) { + migrationDirectories.add(migrationDirectory); + } + } + return migrationDirectories; + } + + private Path getMigrationDirectory(String migrationUuid, String targetPath) { + if (StringUtils.isAnyBlank(migrationUuid, targetPath)) { + return null; + } + + Path normalizedTargetPath = Path.of(targetPath).normalize(); + String normalized = normalizedTargetPath.toString().replace('\\', '/'); + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalized.indexOf(marker); + if (markerIndex < 0) { + logger.warn("Skipping VMware CBT cleanup target {} because it is not under {}", targetPath, marker); + return null; + } + String rootPath = normalized.substring(0, markerIndex + marker.length() - 1); + return Path.of(rootPath).normalize(); + } + + private boolean deleteMigrationDirectory(Path migrationDirectory) throws Exception { + if (!Files.isDirectory(migrationDirectory)) { + return false; + } + try (Stream stream = Files.walk(migrationDirectory)) { + for (Path path : stream.sorted(Comparator.reverseOrder()).collect(Collectors.toList())) { + Files.deleteIfExists(path); + } + } + return true; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java new file mode 100644 index 000000000000..b59aa1e0b1e6 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtCutoverCommandWrapper.java @@ -0,0 +1,794 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.io.IOException; +import java.net.ServerSocket; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtCutoverCommand; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtCutoverCommand.class) +public class LibvirtVmwareCbtCutoverCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(VmwareCbtCutoverCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot cut over VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + if (!cmd.getRunVirtV2vFinalization()) { + String msg = String.format("VMware CBT cutover finalization for migration %s was skipped by command policy.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, 0, true, getDiskResults(cmd.getDisks(), 0, msg)); + } + + long startTime = System.currentTimeMillis(); + Path sourceXmlPath = null; + Path fallbackOutputDir = null; + Path fallbackStagingDir = null; + Path rbdNbdBridgeScriptPath = null; + boolean keepFallbackOutputDir = false; + boolean cutoverFinalizationCompleted = false; + try { + validateCutoverCommand(cmd); + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } + + VirtV2vFinalizationMode finalizationMode = getVirtV2vFinalizationMode(serverResource); + boolean inPlaceFinalization = finalizationMode.isInPlace(); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW && !inPlaceFinalization) { + throw new IllegalArgumentException("RBD target finalization requires virt-v2v in-place support on the selected KVM host; non-in-place fallback finalization cannot write directly back to RBD targets."); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE && !inPlaceFinalization) { + throw new IllegalArgumentException("Block device target finalization requires virt-v2v in-place support on the selected KVM host; non-in-place fallback finalization cannot write directly back to block device targets."); + } + List rbdNbdBridges = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW ? + createRbdNbdBridges(cmd, targetPool) : List.of(); + sourceXmlPath = writeSourceXml(cmd, rbdNbdBridges, targetPool); + + String command; + String logSuffix; + if (inPlaceFinalization) { + command = buildVirtV2vInPlaceCommand(sourceXmlPath, serverResource, finalizationMode); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + rbdNbdBridgeScriptPath = writeRbdNbdBridgeScript(cmd, rbdNbdBridges, command); + command = String.format("bash %s", shellQuote(rbdNbdBridgeScriptPath.toString())); + } + logSuffix = String.format("%s finalization", finalizationMode.getDisplayName()); + } else { + if (!cmd.getAllowNonInPlaceFinalization()) { + throw new IllegalArgumentException("Selected KVM host cannot finalize VMware CBT migration in-place. Enable virt-v2v in-place support or explicitly allow non-in-place fallback finalization."); + } + validateFallbackCapacity(cmd); + fallbackOutputDir = getFallbackOutputDir(cmd); + fallbackStagingDir = getFallbackStagingDir(cmd); + command = buildVirtV2vFallbackCommand(sourceXmlPath, fallbackOutputDir, fallbackStagingDir, serverResource); + logSuffix = String.format("%s finalization", finalizationMode.getDisplayName()); + } + + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT %s", cmd.getMigrationUuid(), logSuffix)); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String msg = String.format("%s failed for VMware CBT migration %s with exit code %s.", + finalizationMode.getDisplayName(), cmd.getMigrationUuid(), commandResult.getExitValue()); + msg = commandResult.appendLastCommandOutput(msg); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, durationSeconds, false, null); + } + + List diskResults = inPlaceFinalization ? + getDiskResults(cmd.getDisks(), durationSeconds, + String.format("Final %s conversion completed", finalizationMode.getDisplayName())) : + getFallbackDiskResults(cmd, fallbackOutputDir, durationSeconds); + diskResults = relocateFinalizedDiskResultsToStorageRoot(cmd.getMigrationUuid(), diskResults); + if (!inPlaceFinalization) { + deleteFallbackSourceDisks(cmd); + keepFallbackOutputDir = containsDiskResultUnderDirectory(diskResults, fallbackOutputDir); + } + cutoverFinalizationCompleted = true; + String msg = String.format("Final %s conversion completed for VMware CBT migration %s.", + finalizationMode.getDisplayName(), cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, durationSeconds, true, diskResults); + } catch (IllegalArgumentException e) { + String msg = String.format("Cannot cut over VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L), false, null); + } catch (Exception e) { + String msg = String.format("Cannot cut over VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getFinalCycleNumber(), + 0, 0, Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L), false, null); + } finally { + deleteTempFile(sourceXmlPath); + deleteTempFile(rbdNbdBridgeScriptPath); + deleteTempTree(fallbackStagingDir); + if (!keepFallbackOutputDir) { + deleteTempTree(fallbackOutputDir); + } + if (cutoverFinalizationCompleted) { + deleteEmptyCbtMigrationDirectories(cmd); + } + } + } + + protected VirtV2vFinalizationMode getVirtV2vFinalizationMode(LibvirtComputingResource serverResource) { + if (serverResource.hostSupportsVirtV2vInPlaceBinary()) { + return VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_BINARY; + } + if (serverResource.hostSupportsVirtV2vInPlaceOption()) { + return VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION; + } + return VirtV2vFinalizationMode.VIRT_V2V_FALLBACK; + } + + private void validateCutoverCommand(VmwareCbtCutoverCommand cmd) { + if (StringUtils.isBlank(cmd.getMigrationUuid())) { + throw new IllegalArgumentException("migration UUID is missing"); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + throw new IllegalArgumentException("no target disks were provided for final conversion"); + } + + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + validateDisk(cmd, disk); + } + } + + private void validateDisk(VmwareCbtCutoverCommand cmd, VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("target disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("target disk ID is missing"); + } + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new IllegalArgumentException(String.format("target path is missing for disk %s", disk.getDiskId())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + String targetFormat = StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw").toLowerCase(Locale.ROOT); + if (!StringUtils.equals(targetFormat, "raw")) { + throw new IllegalArgumentException(String.format("target disk %s uses unsupported finalization format %s; only raw targets are supported for %s finalization", + disk.getDiskId(), targetFormat, cmd.getTargetStorageType())); + } + return; + } + if (!Files.isRegularFile(Path.of(disk.getTargetPath()))) { + throw new IllegalArgumentException(String.format("target disk %s does not exist at %s", + disk.getDiskId(), disk.getTargetPath())); + } + String targetFormat = StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2").toLowerCase(Locale.ROOT); + if (!StringUtils.equals(targetFormat, "qcow2")) { + throw new IllegalArgumentException(String.format("target disk %s uses unsupported finalization format %s; only qcow2 file targets are currently supported", + disk.getDiskId(), targetFormat)); + } + } + + private Path writeSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, KVMStoragePool targetPool) throws Exception { + Path sourceXmlPath = Files.createTempFile(String.format("vmware-cbt-%s-v2v-in-place-source-", + sanitizeFileName(cmd.getMigrationUuid())), ".xml"); + Files.writeString(sourceXmlPath, buildSourceXml(cmd, rbdNbdBridges, targetPool)); + return sourceXmlPath; + } + + private String buildSourceXml(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, KVMStoragePool targetPool) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append(" ").append(escapeXml(StringUtils.defaultIfBlank(cmd.getMigrationUuid(), "vmware-cbt-migration"))).append("\n"); + xml.append(" 1048576\n"); + xml.append(" 1\n"); + xml.append(" \n"); + xml.append(" hvm\n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + for (int index = 0; index < cmd.getDisks().size(); index++) { + VmwareCbtDiskTO disk = cmd.getDisks().get(index); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + appendRbdNbdDiskXml(xml, disk, rbdNbdBridges.get(index), index); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + appendBlockDeviceDiskXml(xml, disk, targetPool, index); + } else { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + } + } + xml.append(" \n"); + xml.append("\n"); + return xml.toString(); + } + + private void appendRbdNbdDiskXml(StringBuilder xml, VmwareCbtDiskTO disk, RbdNbdBridge rbdNbdBridge, int index) { + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + logger.info("Prepared temporary localhost NBD bridge on port {} for VMware CBT RBD target disk {}", + rbdNbdBridge.port, disk.getDiskId()); + } + + private void appendBlockDeviceDiskXml(StringBuilder xml, VmwareCbtDiskTO disk, KVMStoragePool targetPool, int index) { + String devicePath = getBlockDevicePath(targetPool, disk); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + xml.append(" \n"); + logger.info("Prepared block device {} for VMware CBT target disk {} finalization", devicePath, disk.getDiskId()); + } + + private String getBlockDevicePath(KVMStoragePool targetPool, VmwareCbtDiskTO disk) { + String normalizedTargetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? + StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk targetDisk = targetPool.getPhysicalDisk(volumeName); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s of disk %s", + volumeName, disk.getDiskId())); + } + return devicePath; + } + + private void validateBlockDeviceTargetPool(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target finalization", + cmd.getMigrationUuid())); + } + } + + private List createRbdNbdBridges(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) throws Exception { + List bridges = new ArrayList<>(); + // Every probe socket is held open until all ports have been chosen. Probing and closing + // one port at a time lets the kernel hand the same ephemeral port to the next probe, + // which would point two disks' bridges at a single port and silently corrupt one of them. + List portProbes = new ArrayList<>(); + try { + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + ServerSocket probe = new ServerSocket(0); + portProbes.add(probe); + int port = probe.getLocalPort(); + Path pidFile = Files.createTempFile(String.format("vmware-cbt-%s-qemu-nbd-", + sanitizeFileName(cmd.getMigrationUuid())), ".pid"); + Files.deleteIfExists(pidFile); + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, disk.getTargetPath())); + bridges.add(new RbdNbdBridge(port, pidFile, qemuRbdPath)); + } + } finally { + // Released only now, right before the ports are written into the bridge script: + // qemu-nbd cannot bind a port this process still holds. + for (ServerSocket probe : portProbes) { + try { + probe.close(); + } catch (IOException e) { + logger.debug("Failed to release probe socket for port {}: {}", probe.getLocalPort(), e.getMessage()); + } + } + } + return bridges; + } + + private Path writeRbdNbdBridgeScript(VmwareCbtCutoverCommand cmd, List rbdNbdBridges, + String virtV2vCommand) throws Exception { + Path scriptPath = Files.createTempFile(String.format("vmware-cbt-%s-rbd-finalize-", + sanitizeFileName(cmd.getMigrationUuid())), ".sh"); + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("cleanup() {\n"); + script.append(" set +e\n"); + script.append(" for pid_file in"); + for (RbdNbdBridge bridge : rbdNbdBridges) { + script.append(" ").append(shellQuote(bridge.pidFile.toString())); + } + script.append("; do\n"); + script.append(" if [[ -s \"$pid_file\" ]]; then\n"); + script.append(" pid=$(cat \"$pid_file\")\n"); + script.append(" kill \"$pid\" >/dev/null 2>&1 || true\n"); + script.append(" for attempt in {1..20}; do\n"); + script.append(" kill -0 \"$pid\" >/dev/null 2>&1 || break\n"); + script.append(" sleep 0.1\n"); + script.append(" done\n"); + script.append(" fi\n"); + script.append(" rm -f \"$pid_file\"\n"); + script.append(" done\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + for (RbdNbdBridge bridge : rbdNbdBridges) { + script.append("qemu-nbd --fork --persistent --shared=1 --format=raw --bind=127.0.0.1 --port=") + .append(bridge.port) + .append(" --pid-file=").append(shellQuote(bridge.pidFile.toString())) + .append(" ").append(shellQuote(bridge.qemuRbdPath)).append("\n"); + } + script.append(virtV2vCommand).append("\n"); + Files.writeString(scriptPath, script.toString()); + setPosixFilePermissionsIfSupported(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + return scriptPath; + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX file permissions are not supported for {}", path); + } + } + + private static class RbdNbdBridge { + private final int port; + private final Path pidFile; + private final String qemuRbdPath; + + private RbdNbdBridge(int port, Path pidFile, String qemuRbdPath) { + this.port = port; + this.pidFile = pidFile; + this.qemuRbdPath = qemuRbdPath; + } + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtCutoverCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtCutoverCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target finalization", + cmd.getMigrationUuid())); + } + } + + private String getRbdImagePath(KVMStoragePool targetPool, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private String getDiskDeviceName(int index) { + StringBuilder suffix = new StringBuilder(); + int value = index; + do { + suffix.insert(0, (char)('a' + (value % 26))); + value = (value / 26) - 1; + } while (value >= 0); + return "sd" + suffix; + } + + private String buildVirtV2vInPlaceCommand(Path sourceXmlPath, LibvirtComputingResource serverResource, + VirtV2vFinalizationMode finalizationMode) { + StringBuilder command = new StringBuilder(); + appendLibguestfsBackend(command, serverResource); + if (finalizationMode == VirtV2vFinalizationMode.VIRT_V2V_IN_PLACE_OPTION) { + command.append("virt-v2v --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("--in-place -v"); + } else { + // Resolve the binary through the server resource: EL9-family distributions + // install virt-v2v-in-place in /usr/libexec, outside $PATH. + String inPlaceBinary = StringUtils.defaultIfBlank(serverResource.getVirtV2vInPlaceBinary(), "virt-v2v-in-place"); + // No -O (write updated output XML): nothing consumes it and the option only + // exists from virt-v2v 2.5 on, so passing it breaks otherwise capable hosts + // such as Ubuntu 24.04 with virt-v2v-in-place 2.4. + command.append(inPlaceBinary).append(" --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-v"); + } + return command.toString(); + } + + private String buildVirtV2vFallbackCommand(Path sourceXmlPath, Path outputDir, Path stagingDir, + LibvirtComputingResource serverResource) { + StringBuilder command = new StringBuilder(); + appendLibguestfsBackend(command, serverResource); + command.append("export TMPDIR=").append(shellQuote(stagingDir.toString())).append(" && "); + command.append("virt-v2v --root first -i libvirtxml "); + command.append(shellQuote(sourceXmlPath.toString())).append(" "); + command.append("-o local -os ").append(shellQuote(outputDir.toString())).append(" "); + command.append("-of qcow2 -v"); + return command.toString(); + } + + private void appendLibguestfsBackend(StringBuilder command, LibvirtComputingResource serverResource) { + String libguestfsBackend = StringUtils.trimToNull(serverResource.getLibguestfsBackend()); + if (StringUtils.isNotBlank(libguestfsBackend)) { + command.append("export LIBGUESTFS_BACKEND=").append(shellQuote(libguestfsBackend)).append(" && "); + } + } + + private void validateFallbackCapacity(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + long requiredBytes = saturatingMultiply(sumDiskCapacityBytes(cmd), 2L); + long availableBytes = Files.getFileStore(targetBasePath).getUsableSpace(); + if (availableBytes < requiredBytes) { + throw new IllegalArgumentException(String.format("Non-in-place fallback finalization requires additional free space on target primary storage. Required: %s bytes, available: %s bytes.", + requiredBytes, availableBytes)); + } + } + + private long sumDiskCapacityBytes(VmwareCbtCutoverCommand cmd) throws Exception { + long total = 0L; + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + long capacity = disk.getCapacityBytes(); + if (capacity <= 0) { + capacity = Files.size(Path.of(disk.getTargetPath())); + } + total = saturatingAdd(total, capacity); + } + return total; + } + + private long saturatingAdd(long left, long right) { + if (Long.MAX_VALUE - left < right) { + return Long.MAX_VALUE; + } + return left + right; + } + + private long saturatingMultiply(long value, long multiplier) { + if (value > 0 && multiplier > Long.MAX_VALUE / value) { + return Long.MAX_VALUE; + } + return value * multiplier; + } + + private Path getFallbackOutputDir(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + Path outputDir = Files.createTempDirectory(targetBasePath, "virt-v2v-output-").normalize(); + if (!outputDir.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved fallback output path %s is outside %s", + outputDir, targetBasePath)); + } + return outputDir; + } + + private Path getFallbackStagingDir(VmwareCbtCutoverCommand cmd) throws Exception { + Path targetBasePath = getTargetBasePath(cmd); + Path stagingDir = Files.createTempDirectory(targetBasePath, "virt-v2v-tmp-").normalize(); + if (!stagingDir.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved fallback staging path %s is outside %s", + stagingDir, targetBasePath)); + } + return stagingDir; + } + + private Path getTargetBasePath(VmwareCbtCutoverCommand cmd) { + Path firstDiskPath = Path.of(cmd.getDisks().get(0).getTargetPath()).normalize(); + Path targetBasePath = firstDiskPath.getParent(); + if (targetBasePath == null) { + throw new IllegalArgumentException("unable to determine VMware CBT target disk directory"); + } + return targetBasePath; + } + + private List getFallbackDiskResults(VmwareCbtCutoverCommand cmd, Path fallbackOutputDir, + long durationSeconds) throws Exception { + List outputFiles; + try (Stream stream = Files.list(fallbackOutputDir)) { + outputFiles = stream + .filter(Files::isRegularFile) + .filter(path -> !StringUtils.endsWithIgnoreCase(path.getFileName().toString(), ".xml")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .collect(Collectors.toList()); + } + if (outputFiles.size() < cmd.getDisks().size()) { + throw new IllegalArgumentException(String.format("virt-v2v fallback produced %s disk file(s), expected at least %s", + outputFiles.size(), cmd.getDisks().size())); + } + List results = new ArrayList<>(); + for (int index = 0; index < cmd.getDisks().size(); index++) { + VmwareCbtDiskTO disk = cmd.getDisks().get(index); + results.add(new VmwareCbtDiskSyncResultTO(disk.getDiskId(), outputFiles.get(index).toString(), + disk.getChangeId(), disk.getSnapshotMor(), 0, durationSeconds, true, + "Final virt-v2v fallback conversion completed")); + } + return results; + } + + protected List relocateFinalizedDiskResultsToStorageRoot(String migrationUuid, + List diskResults) throws Exception { + if (CollectionUtils.isEmpty(diskResults)) { + return diskResults; + } + + List relocatedResults = new ArrayList<>(); + Set usedDestinations = new HashSet<>(); + List completedMoves = new ArrayList<>(); + try { + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + if (diskResult == null || !diskResult.getResult() || StringUtils.isBlank(diskResult.getTargetPath())) { + relocatedResults.add(diskResult); + continue; + } + + Path sourcePath = Path.of(diskResult.getTargetPath()).normalize(); + Path storageRoot = getStorageRootForCbtPath(migrationUuid, sourcePath); + if (storageRoot == null) { + relocatedResults.add(diskResult); + continue; + } + + Path destinationPath = getAvailableRootDiskPath(storageRoot, usedDestinations); + logger.info("Relocating finalized VMware CBT disk {} to primary storage root {}", sourcePath, destinationPath); + Files.move(sourcePath, destinationPath); + completedMoves.add(new FinalizedDiskMove(sourcePath, destinationPath)); + relocatedResults.add(new VmwareCbtDiskSyncResultTO(diskResult.getDiskId(), destinationPath.toString(), + diskResult.getChangeId(), diskResult.getSnapshotMor(), diskResult.getChangedBytes(), + diskResult.getDurationSeconds(), diskResult.getResult(), diskResult.getDetails())); + } + } catch (Exception e) { + rollbackFinalizedDiskMoves(completedMoves); + throw e; + } + return relocatedResults; + } + + private Path getStorageRootForCbtPath(String migrationUuid, Path targetPath) { + if (StringUtils.isBlank(migrationUuid) || targetPath == null) { + return null; + } + String normalizedPath = targetPath.normalize().toString().replace('\\', '/'); + String marker = String.format("/cloudstack-cbt/%s/", migrationUuid); + int markerIndex = normalizedPath.indexOf(marker); + if (markerIndex < 0) { + return null; + } + String storageRoot = StringUtils.trimToNull(normalizedPath.substring(0, markerIndex)); + return storageRoot == null ? null : Path.of(storageRoot).normalize(); + } + + private Path getAvailableRootDiskPath(Path storageRoot, Set usedDestinations) { + while (true) { + Path candidate = storageRoot.resolve(UUID.randomUUID().toString()).normalize(); + if (!Files.exists(candidate) && usedDestinations.add(candidate)) { + return candidate; + } + } + } + + private boolean containsDiskResultUnderDirectory(List diskResults, Path directory) { + if (CollectionUtils.isEmpty(diskResults) || directory == null) { + return false; + } + Path normalizedDirectory = directory.normalize(); + for (VmwareCbtDiskSyncResultTO diskResult : diskResults) { + if (diskResult != null && StringUtils.isNotBlank(diskResult.getTargetPath()) && + Path.of(diskResult.getTargetPath()).normalize().startsWith(normalizedDirectory)) { + return true; + } + } + return false; + } + + private void rollbackFinalizedDiskMoves(List completedMoves) { + if (CollectionUtils.isEmpty(completedMoves)) { + return; + } + for (int index = completedMoves.size() - 1; index >= 0; index--) { + FinalizedDiskMove move = completedMoves.get(index); + try { + if (Files.exists(move.destinationPath) && !Files.exists(move.sourcePath)) { + Files.move(move.destinationPath, move.sourcePath); + } + } catch (Exception rollbackException) { + logger.warn("Failed to roll back finalized VMware CBT disk relocation from {} to {}", + move.destinationPath, move.sourcePath, rollbackException); + } + } + } + + private static class FinalizedDiskMove { + private final Path sourcePath; + private final Path destinationPath; + + private FinalizedDiskMove(Path sourcePath, Path destinationPath) { + this.sourcePath = sourcePath; + this.destinationPath = destinationPath; + } + } + + private void deleteFallbackSourceDisks(VmwareCbtCutoverCommand cmd) { + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + deleteTempFile(Path.of(disk.getTargetPath())); + } + } + + private void deleteEmptyCbtMigrationDirectories(VmwareCbtCutoverCommand cmd) { + Set migrationDirectories = new HashSet<>(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + Path sourcePath = Path.of(disk.getTargetPath()).normalize(); + Path storageRoot = getStorageRootForCbtPath(cmd.getMigrationUuid(), sourcePath); + if (storageRoot == null) { + continue; + } + Path migrationDirectory = storageRoot.resolve("cloudstack-cbt").resolve(cmd.getMigrationUuid()).normalize(); + if (sourcePath.startsWith(migrationDirectory)) { + migrationDirectories.add(migrationDirectory); + } + } + + for (Path migrationDirectory : migrationDirectories) { + if (!Files.isDirectory(migrationDirectory) || Files.isSymbolicLink(migrationDirectory)) { + continue; + } + try { + Files.deleteIfExists(migrationDirectory); + } catch (DirectoryNotEmptyException e) { + logger.warn("Preserving non-empty VMware CBT migration directory {} after successful cutover", + migrationDirectory); + } catch (Exception e) { + logger.warn("Unable to remove VMware CBT migration directory {} after successful cutover: {}", + migrationDirectory, e.getMessage()); + } + } + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + private long getTimeout(VmwareCbtCutoverCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private List getDiskResults(List disks, long durationSeconds, + String details) { + List results = new ArrayList<>(); + if (CollectionUtils.isEmpty(disks)) { + return results; + } + for (VmwareCbtDiskTO disk : disks) { + results.add(new VmwareCbtDiskSyncResultTO(disk.getDiskId(), disk.getTargetPath(), + disk.getChangeId(), disk.getSnapshotMor(), 0, durationSeconds, true, details)); + } + return results; + } + + private void deleteTempFile(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (Exception e) { + logger.debug("Unable to delete temporary VMware CBT cutover file {}: {}", path, e.getMessage()); + } + } + + private void deleteTempTree(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream stream = Files.walk(path)) { + List paths = stream.sorted(Comparator.reverseOrder()).collect(Collectors.toList()); + for (Path entry : paths) { + Files.deleteIfExists(entry); + } + } catch (Exception e) { + logger.debug("Unable to delete temporary VMware CBT cutover directory {}: {}", path, e.getMessage()); + } + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String escapeXml(String value) { + return StringUtils.defaultString(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "migration").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "migration"); + } + + protected enum VirtV2vFinalizationMode { + VIRT_V2V_IN_PLACE_BINARY("virt-v2v-in-place", true), + VIRT_V2V_IN_PLACE_OPTION("virt-v2v --in-place", true), + VIRT_V2V_FALLBACK("virt-v2v fallback", false); + + private final String displayName; + private final boolean inPlace; + + VirtV2vFinalizationMode(String displayName, boolean inPlace) { + this.displayName = displayName; + this.inPlace = inPlace; + } + + public String getDisplayName() { + return displayName; + } + + public boolean isInPlace() { + return inPlace; + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java new file mode 100644 index 000000000000..1386c18ff443 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtPrepareCommandWrapper.java @@ -0,0 +1,506 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtPrepareCommand; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.utils.qemu.QemuImg; + +@ResourceWrapper(handles = VmwareCbtPrepareCommand.class) +public class LibvirtVmwareCbtPrepareCommandWrapper extends CommandWrapper { + + private static final String DEFAULT_CBT_DISK_BASE_PATH = "/var/lib/libvirt/images/cloudstack-cbt"; + private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + + @Override + public Answer execute(VmwareCbtPrepareCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot prepare VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + try { + validatePrepareCommand(cmd); + String vddkLibDir = resolveVddkSetting(cmd.getVddkLibDir(), serverResource.getVddkLibDir()); + if (StringUtils.isBlank(vddkLibDir)) { + String msg = String.format("Cannot prepare VMware CBT migration %s because no VDDK library directory is configured or detected.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String vddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(sourceInstance.getVcenterHost(), getTimeout(cmd), sourceInstance.getInstanceName()); + } + if (StringUtils.isBlank(vddkThumbprint)) { + String msg = String.format("Cannot prepare VMware CBT migration %s because the vCenter SSL thumbprint could not be determined.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + Path targetBasePath = null; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } else { + targetBasePath = getTargetBasePath(cmd, targetPool); + Files.createDirectories(targetBasePath); + } + + String passwordFilePath = writePasswordFile(cmd); + try { + List diskResults = new ArrayList<>(); + long startTime = System.currentTimeMillis(); + for (VmwareCbtDiskTO disk : cmd.getDisks()) { + diskResults.add(copyDiskFromVmwareSnapshot(cmd, disk, targetPool, targetBasePath, passwordFilePath, + vddkLibDir, vddkThumbprint, serverResource)); + } + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + String msg = String.format("Initial VDDK full sync for VMware CBT migration %s completed for %s disk(s).", + cmd.getMigrationUuid(), diskResults.size()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), 0, + 0, 0, durationSeconds, false, diskResults); + } finally { + Files.deleteIfExists(Path.of(passwordFilePath)); + } + } catch (Exception e) { + String msg = String.format("Cannot prepare VMware CBT migration %s: %s", + cmd.getMigrationUuid(), StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid()); + } + } + + private void validatePrepareCommand(VmwareCbtPrepareCommand cmd) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + if (sourceInstance == null) { + throw new IllegalArgumentException("source VMware instance information is missing"); + } + if (StringUtils.isAnyBlank(sourceInstance.getVcenterHost(), sourceInstance.getVcenterUsername(), + sourceInstance.getVcenterPassword(), sourceInstance.getInstanceName())) { + throw new IllegalArgumentException("source vCenter host, username, password and VM name are required"); + } + if (StringUtils.isBlank(sourceInstance.getVmwareMoref())) { + throw new IllegalArgumentException("source VMware VM managed object reference is missing"); + } + if (StringUtils.isBlank(cmd.getBaselineSnapshotMor())) { + throw new IllegalArgumentException("baseline VMware snapshot managed object reference is missing"); + } + if (CollectionUtils.isEmpty(cmd.getDisks())) { + throw new IllegalArgumentException("no source disks were provided for initial full sync"); + } + } + + private long getTimeout(VmwareCbtPrepareCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtPrepareCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target writing", + cmd.getMigrationUuid())); + } + } + + private void validateBlockDeviceTargetPool(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target writing", + cmd.getMigrationUuid())); + } + } + + private Path getTargetBasePath(VmwareCbtPrepareCommand cmd, KVMStoragePool targetPool) { + if (targetPool != null) { + return Path.of(targetPool.getLocalPath(), "cloudstack-cbt", cmd.getMigrationUuid()).normalize(); + } + return Path.of(DEFAULT_CBT_DISK_BASE_PATH, cmd.getMigrationUuid()).normalize(); + } + + private String writePasswordFile(VmwareCbtPrepareCommand cmd) throws Exception { + Path passwordFile = Files.createTempFile(String.format("vmware-cbt-%s-", sanitizeFileName(cmd.getMigrationUuid())), + ".pass"); + Files.writeString(passwordFile, cmd.getSourceInstance().getVcenterPassword()); + setPosixFilePermissionsIfSupported(passwordFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + return passwordFile.toString(); + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX permissions are not supported for temporary VMware CBT password file {}", path); + } + } + + private VmwareCbtDiskSyncResultTO copyDiskFromVmwareSnapshot(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + KVMStoragePool targetPool, Path targetBasePath, + String passwordFilePath, + String vddkLibDir, String vddkThumbprint, + LibvirtComputingResource serverResource) throws Exception { + validateDisk(disk); + long startTime = System.currentTimeMillis(); + String targetFormat = getTargetFormat(cmd, disk); + String targetPath; + String qemuTargetPath; + boolean preCreatedTarget = false; + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + targetPath = getRbdTargetImageName(cmd, disk); + deleteRbdTargetIfExists(targetPool, targetPath); + qemuTargetPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, targetPath)); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + targetPath = getBlockDeviceTargetName(disk); + qemuTargetPath = createBlockDeviceTarget(targetPool, targetPath, disk); + preCreatedTarget = true; + } else { + Path fileTargetPath = getTargetPath(disk, targetBasePath, targetFormat); + if (!fileTargetPath.startsWith(targetBasePath)) { + throw new IllegalArgumentException(String.format("resolved target path %s is outside %s", fileTargetPath, targetBasePath)); + } + Files.deleteIfExists(fileTargetPath); + targetPath = fileTargetPath.toString(); + qemuTargetPath = targetPath; + } + + // Prefer multi-connection nbdcopy over a single-stream qemu-img convert when available: + // for a pre-created local raw block-device target (Linstor) nbdcopy writes it directly; + // for an RBD target it copies over a localhost qemu-nbd bridge (see the builder). qcow2 + // file targets keep qemu-img convert. + boolean nbdcopyAvailable = serverResource.hostSupportsNbdcopy(); + // Only skip zero-writes into a pre-created block-device target when the backend + // guarantees a fresh volume reads back as zeros (thin providers); on backends that do + // not (e.g. LVM-thick) the zeros must be written or stale data leaks into the disk. + boolean targetIsZero = preCreatedTarget && targetPool.isVolumeZeroInitialized(targetPath); + String command = buildNbdkitFullCopyCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()), + serverResource.getVddkNbdCompression(), qemuTargetPath, targetFormat, preCreatedTarget, nbdcopyAvailable, targetIsZero); + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT initial full sync disk %s", cmd.getMigrationUuid(), disk.getDiskId())); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String details = String.format("qemu-img conversion from VMware VDDK snapshot failed for source disk %s with exit code %s", + disk.getDiskId(), commandResult.getExitValue()); + throw new IllegalStateException(commandResult.appendLastCommandOutput(details)); + } + + return new VmwareCbtDiskSyncResultTO(disk.getDiskId(), targetPath, disk.getChangeId(), + cmd.getBaselineSnapshotMor(), disk.getCapacityBytes(), durationSeconds, true, + "Initial VDDK full sync completed"); + } + + private String getTargetFormat(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + private String getBlockDeviceTargetName(VmwareCbtDiskTO disk) { + String targetPath = StringUtils.trimToNull(disk.getTargetPath()); + if (targetPath == null) { + throw new IllegalArgumentException(String.format("no block device target name was assigned for source disk %s", disk.getDiskId())); + } + String normalizedTargetPath = targetPath.replace('\\', '/'); + return StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + } + + private String createBlockDeviceTarget(KVMStoragePool targetPool, String targetName, VmwareCbtDiskTO disk) { + if (disk.getCapacityBytes() <= 0) { + throw new IllegalArgumentException(String.format("source disk %s does not have a valid capacity, " + + "which is required to pre-create the block device target volume", disk.getDiskId())); + } + deleteRbdTargetIfExists(targetPool, targetName); + KVMPhysicalDisk targetDisk = targetPool.createPhysicalDisk(targetName, QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, disk.getCapacityBytes(), null); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s", targetName)); + } + return devicePath; + } + + private String getRbdTargetImageName(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk) { + String targetPath = StringUtils.trimToNull(disk.getTargetPath()); + if (targetPath != null) { + String normalizedTargetPath = targetPath.replace('\\', '/'); + return StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + } + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getDiskId()), "."); + return String.format("cloudstack-cbt-%s-%s-%s", sanitizeFileName(cmd.getMigrationUuid()), + sanitizeFileName(disk.getDiskId()), sanitizeFileName(sourceName)); + } + + private String getRbdImagePath(KVMStoragePool targetPool, String imageName) { + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private void deleteRbdTargetIfExists(KVMStoragePool targetPool, String imageName) { + try { + targetPool.deletePhysicalDisk(imageName, Storage.ImageFormat.RAW); + } catch (RuntimeException e) { + logger.debug("No existing RBD image {} was removed before VMware CBT initial sync, or removal was not required: {}", + imageName, e.getMessage()); + } + } + + private Path getTargetPath(VmwareCbtDiskTO disk, Path targetBasePath, String targetFormat) { + String targetPathValue = StringUtils.trimToNull(disk.getTargetPath()); + Path targetPath = targetPathValue == null ? + targetBasePath.resolve(getTargetDiskFileName(disk, targetFormat)) : + Path.of(targetPathValue); + if (!targetPath.isAbsolute()) { + targetPath = targetBasePath.resolve(targetPath); + } + return targetPath.normalize(); + } + + private void validateDisk(VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("source disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("source disk ID is missing"); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new IllegalArgumentException(String.format("source disk path is missing for disk %s", disk.getDiskId())); + } + } + + private String buildNbdkitFullCopyCommand(VmwareCbtPrepareCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String vddkNbdCompression, String targetPath, + String targetFormat, boolean preCreatedTarget, boolean nbdcopyAvailable, + boolean targetIsZero) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String sourceVmMoref = getMorefValue(sourceInstance.getVmwareMoref()); + String snapshotMoref = getMorefValue(cmd.getBaselineSnapshotMor()); + StringBuilder nbdkit = new StringBuilder("nbdkit -r -U - vddk "); + appendPluginParameter(nbdkit, "file", disk.getSourceDiskPath()); + appendPluginParameter(nbdkit, "server", sourceInstance.getVcenterHost()); + appendPluginParameter(nbdkit, "user", sourceInstance.getVcenterUsername()); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + nbdkit.append("vm=moref=").append(shellQuote(sourceVmMoref)).append(" "); + appendPluginParameter(nbdkit, "snapshot", snapshotMoref); + appendPluginParameter(nbdkit, "libdir", vddkLibDir); + appendPluginParameter(nbdkit, "thumbprint", vddkThumbprint); + if (StringUtils.isNotBlank(vddkTransports)) { + appendPluginParameter(nbdkit, "transports", vddkTransports); + } + if (StringUtils.isNotBlank(vddkNbdCompression)) { + appendPluginParameter(nbdkit, "compression", vddkNbdCompression); + } + + boolean rbdTarget = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW; + + // RBD target with nbdcopy available: copy over a localhost qemu-nbd bridge (nbdcopy + // needs both ends as NBD, and qemu-img create - which nbdcopy cannot do - pre-creates + // the raw image). Multi-connection nbdcopy is typically faster than the single-stream + // qemu-img convert. The fresh raw image reads as zeros, so --destination-is-zero keeps + // it sparse. + if (rbdTarget && nbdcopyAvailable && disk.getCapacityBytes() > 0) { + return buildRbdNbdcopyBridgeCommand(nbdkit, targetPath, disk.getCapacityBytes()); + } + + // For a pre-created block-device target, skip writing zero blocks + // (--destination-is-zero / --target-is-zero) only when the backend guarantees the + // fresh volume reads back as zeros (targetIsZero, i.e. thin providers). On backends + // that do not (e.g. LVM-thick) the zeros must be written or stale data from a + // previously deleted volume would leak into the unwritten regions. + boolean useNbdcopy = preCreatedTarget && nbdcopyAvailable; + String nbdcopyZeroFlag = (preCreatedTarget && targetIsZero) ? "--destination-is-zero " : ""; + String qemuImgFlags = preCreatedTarget ? ("-n " + (targetIsZero ? "--target-is-zero " : "")) : ""; + String runCommand = useNbdcopy + ? String.format("nbdcopy %s\"$uri\" %s", nbdcopyZeroFlag, shellQuote(targetPath)) + : String.format("qemu-img convert %s-f raw -O %s \"$uri\" %s", + qemuImgFlags, shellQuote(targetFormat), shellQuote(targetPath)); + return nbdkit.append("--run ").append(shellQuote(runCommand)).toString(); + } + + /** + * Wraps the nbdkit VDDK source with a localhost qemu-nbd bridge over the RBD target so + * nbdcopy can copy NBD-to-NBD. The returned string is a self-contained bash script (run + * via bash -c) that pre-creates the raw RBD image, starts qemu-nbd, runs the nbdcopy, and + * tears the bridge down on exit. Package-private for unit testing. + */ + String buildRbdNbdcopyBridgeCommand(StringBuilder nbdkitSource, String qemuRbdTarget, long capacityBytes) { + int port = allocateLocalhostPort(); + nbdkitSource.append("--run ").append(shellQuote( + String.format("nbdcopy --destination-is-zero \"$uri\" nbd://localhost:%d", port))); + StringBuilder script = new StringBuilder(); + script.append("set -euo pipefail; "); + script.append("__pidf=$(mktemp); "); + script.append("cleanup() { set +e; if [[ -s \"$__pidf\" ]]; then __p=$(cat \"$__pidf\"); "); + script.append("kill \"$__p\" >/dev/null 2>&1 || true; "); + script.append("for __a in {1..20}; do kill -0 \"$__p\" >/dev/null 2>&1 || break; sleep 0.1; done; fi; "); + script.append("rm -f \"$__pidf\"; }; trap cleanup EXIT; "); + script.append("qemu-img create -f raw ").append(shellQuote(qemuRbdTarget)).append(" ").append(capacityBytes).append("; "); + script.append("qemu-nbd --fork --persistent --shared=8 --format=raw --bind=127.0.0.1 --port=").append(port) + .append(" --pid-file=\"$__pidf\" ").append(shellQuote(qemuRbdTarget)).append("; "); + script.append(nbdkitSource); + return script.toString(); + } + + private int allocateLocalhostPort() { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + socket.setReuseAddress(false); + return socket.getLocalPort(); + } catch (java.io.IOException e) { + throw new CloudRuntimeException("Unable to allocate a localhost port for the RBD qemu-nbd bridge", e); + } + } + + private void appendPluginParameter(StringBuilder command, String key, String value) { + command.append(key).append("=").append(shellQuote(value)).append(" "); + } + + private String getTargetDiskFileName(VmwareCbtDiskTO disk, String targetFormat) { + String sourceName = StringUtils.substringAfterLast(StringUtils.defaultIfBlank(disk.getSourceDiskPath(), disk.getDiskId()), "/"); + sourceName = StringUtils.substringBeforeLast(StringUtils.defaultIfBlank(sourceName, disk.getDiskId()), "."); + return String.format("%s-%s.%s", sanitizeFileName(disk.getDiskId()), sanitizeFileName(sourceName), + sanitizeFileName(targetFormat)); + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private String getMorefValue(String moref) { + String value = StringUtils.trimToNull(moref); + if (value == null) { + return null; + } + return value.contains(":") ? StringUtils.substringAfter(value, ":") : value; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String resolveVddkSetting(String commandValue, String agentValue) { + return StringUtils.defaultIfBlank(StringUtils.trimToNull(commandValue), StringUtils.trimToNull(agentValue)); + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + private String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + if (StringUtils.isBlank(vcenterHost)) { + return null; + } + String endpoint = String.format("%s:443", vcenterHost); + String command = String.format("openssl s_client -connect %s /dev/null | " + + "openssl x509 -fingerprint -sha1 -noout", shellQuote(endpoint)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser); + + String output = parser.getLines(); + if (script.getExitValue() != 0) { + logger.error("({}) Failed to fetch vCenter thumbprint for {}", sourceVmName, vcenterHost); + return null; + } + + return extractSha1Fingerprint(output); + } + + private String extractSha1Fingerprint(String output) { + String parsedOutput = StringUtils.trimToEmpty(output); + if (StringUtils.isBlank(parsedOutput)) { + return null; + } + + for (String line : parsedOutput.split("\\R")) { + String trimmedLine = StringUtils.trimToEmpty(line); + if (StringUtils.isBlank(trimmedLine)) { + continue; + } + + Matcher matcher = SHA1_FINGERPRINT_PATTERN.matcher(trimmedLine); + if (matcher.find()) { + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + if (trimmedLine.matches("(?i)[0-9a-f]{2}(:[0-9a-f]{2})+")) { + return trimmedLine.toUpperCase(Locale.ROOT); + } + } + return null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java new file mode 100644 index 000000000000..23f76b1619fb --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtRbdProbeCommandWrapper.java @@ -0,0 +1,218 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtRbdProbeCommand; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtRbdProbeCommand.class) +public class LibvirtVmwareCbtRbdProbeCommandWrapper extends CommandWrapper { + + static final String PROBE_IMAGE_PREFIX = "cloudstack-cbt-probe-"; + static final String BLOCK_DEVICE_PROBE_PREFIX = "cbt-probe-"; + private static final Pattern PROBE_IMAGE_NAME_PATTERN = Pattern.compile("^" + PROBE_IMAGE_PREFIX + + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private static final Pattern BLOCK_DEVICE_PROBE_NAME_PATTERN = Pattern.compile("^" + BLOCK_DEVICE_PROBE_PREFIX + + "[0-9a-fA-F]{8}$"); + private static final long PROBE_SIZE_BYTES = 4L * 1024L * 1024L; + + @Override + public Answer execute(VmwareCbtRbdProbeCommand cmd, LibvirtComputingResource serverResource) { + String probeImageName = StringUtils.trimToNull(cmd.getProbeImageName()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return executeBlockDeviceProbe(cmd, probeImageName, serverResource); + } + try { + validateCommand(cmd, probeImageName); + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + String qemuRbdPath = KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, probeImageName)); + + Exception probeFailure = null; + try { + executeProbeCommand(buildCreateCommand(qemuRbdPath), getTimeout(cmd), "create", targetPool, probeImageName); + executeProbeCommand(buildWriteReadCommand(qemuRbdPath), getTimeout(cmd), "write/read", targetPool, probeImageName); + } catch (Exception e) { + probeFailure = e; + throw e; + } finally { + try { + cleanupProbeImage(targetPool, probeImageName); + } catch (RuntimeException e) { + if (probeFailure == null) { + throw e; + } + logger.warn("Unable to clean up RBD probe image {} after a failed probe: {}", probeImageName, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + } + + String message = String.format("Selected KVM host can create, write, read and delete temporary RBD image %s in storage pool %s.", + probeImageName, cmd.getDestinationStoragePoolUuid()); + logger.info(message); + return new Answer(cmd, true, message); + } catch (Exception e) { + String message = String.format("Cannot verify VMware CBT access to selected RBD primary storage pool %s on host %s: %s", + cmd.getDestinationStoragePoolUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.warn(message, e); + return new Answer(cmd, false, message); + } + } + + private Answer executeBlockDeviceProbe(VmwareCbtRbdProbeCommand cmd, String probeVolumeName, + LibvirtComputingResource serverResource) { + try { + validateBlockDeviceCommand(cmd, probeVolumeName); + KVMStoragePool targetPool = getBlockDeviceTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + + Exception probeFailure = null; + try { + KVMPhysicalDisk probeDisk = targetPool.createPhysicalDisk(probeVolumeName, + org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat.RAW, + Storage.ProvisioningType.THIN, PROBE_SIZE_BYTES, null); + String devicePath = probeDisk == null ? null : probeDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for probe volume %s", probeVolumeName)); + } + executeProbeCommand(buildWriteReadCommand(devicePath), getTimeout(cmd), "write/read", targetPool, probeVolumeName); + } catch (Exception e) { + probeFailure = e; + throw e; + } finally { + try { + cleanupProbeImage(targetPool, probeVolumeName); + } catch (RuntimeException e) { + if (probeFailure == null) { + throw e; + } + logger.warn("Unable to clean up block device probe volume {} after a failed probe: {}", probeVolumeName, + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + } + } + + String message = String.format("Selected KVM host can create, write, read and delete temporary block device volume %s in storage pool %s.", + probeVolumeName, cmd.getDestinationStoragePoolUuid()); + logger.info(message); + return new Answer(cmd, true, message); + } catch (Exception e) { + String message = String.format("Cannot verify VMware CBT access to selected block device primary storage pool %s on host %s: %s", + cmd.getDestinationStoragePoolUuid(), serverResource.getPrivateIp(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.warn(message, e); + return new Answer(cmd, false, message); + } + } + + private void validateBlockDeviceCommand(VmwareCbtRbdProbeCommand cmd, String probeVolumeName) { + if (cmd.getDestinationStoragePoolType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException("VMware CBT block device probe requires a Linstor destination storage pool"); + } + if (StringUtils.isBlank(cmd.getDestinationStoragePoolUuid())) { + throw new IllegalArgumentException("destination storage pool UUID is required"); + } + if (StringUtils.isBlank(probeVolumeName) || !BLOCK_DEVICE_PROBE_NAME_PATTERN.matcher(probeVolumeName).matches()) { + throw new IllegalArgumentException(String.format("probe volume name must match %s<8 hex characters>", BLOCK_DEVICE_PROBE_PREFIX)); + } + } + + private KVMStoragePool getBlockDeviceTargetStoragePool(VmwareCbtRbdProbeCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = storagePoolMgr.getStoragePool(cmd.getDestinationStoragePoolType(), cmd.getDestinationStoragePoolUuid()); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("selected Linstor storage pool %s is not available on this KVM host", + cmd.getDestinationStoragePoolUuid())); + } + return targetPool; + } + + private void validateCommand(VmwareCbtRbdProbeCommand cmd, String probeImageName) { + if (cmd.getDestinationStoragePoolType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException("VMware CBT RBD probe requires an RBD destination storage pool"); + } + if (StringUtils.isBlank(cmd.getDestinationStoragePoolUuid())) { + throw new IllegalArgumentException("destination RBD storage pool UUID is required"); + } + if (StringUtils.isBlank(probeImageName) || !PROBE_IMAGE_NAME_PATTERN.matcher(probeImageName).matches()) { + throw new IllegalArgumentException(String.format("probe image name must match %s", PROBE_IMAGE_PREFIX)); + } + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtRbdProbeCommand cmd, KVMStoragePoolManager storagePoolMgr) { + KVMStoragePool targetPool = storagePoolMgr.getStoragePool(cmd.getDestinationStoragePoolType(), cmd.getDestinationStoragePoolUuid()); + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("selected RBD storage pool %s is not available on this KVM host", + cmd.getDestinationStoragePoolUuid())); + } + return targetPool; + } + + private String getRbdImagePath(KVMStoragePool targetPool, String imageName) { + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private String buildCreateCommand(String qemuRbdPath) { + return String.format("qemu-img create -f raw %s %s", shellQuote(qemuRbdPath), PROBE_SIZE_BYTES); + } + + private String buildWriteReadCommand(String qemuRbdPath) { + return String.format("qemu-io -f raw -c %s -c %s %s", + shellQuote("write -P 0x5a 0 4k"), shellQuote("read -P 0x5a 0 4k"), shellQuote(qemuRbdPath)); + } + + protected void executeProbeCommand(String command, long timeout, String operation, KVMStoragePool targetPool, + String probeImageName) { + int exitValue = runBashCommand(command, timeout); + if (exitValue != 0) { + throw new IllegalStateException(String.format("qemu RBD probe %s failed with exit code %s. Verify CloudStack RBD primary-storage configuration, Ceph monitor connectivity, qemu RBD block-driver support, librados/librbd client libraries, Java RADOS/RBD bindings and Ceph authentication for pool %s.", + operation, exitValue, targetPool.getUuid())); + } + } + + protected int runBashCommand(String command, long timeout) { + int boundedTimeout = (int)Math.min(Integer.MAX_VALUE, Math.max(0L, timeout)); + return Script.runSimpleBashScriptForExitValue(command, boundedTimeout, true); + } + + protected void cleanupProbeImage(KVMStoragePool targetPool, String probeImageName) { + try { + targetPool.deletePhysicalDisk(probeImageName, Storage.ImageFormat.RAW); + } catch (RuntimeException e) { + throw new IllegalStateException(String.format("RBD probe cleanup failed for temporary image %s. Verify Java RADOS/RBD bindings and Ceph authentication for pool %s. Manual cleanup may be required.", + probeImageName, targetPool.getUuid()), e); + } + } + + private long getTimeout(VmwareCbtRbdProbeCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java new file mode 100644 index 000000000000..ed9a6a992c40 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtVmwareCbtSyncCommandWrapper.java @@ -0,0 +1,489 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.VmwareCbtMigrationAnswer; +import com.cloud.agent.api.VmwareCbtSyncCommand; +import com.cloud.agent.api.to.RemoteInstanceTO; +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskSyncResultTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; +import com.cloud.agent.api.to.VmwareCbtTargetStorageType; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.script.OutputInterpreter; +import com.cloud.utils.script.Script; + +@ResourceWrapper(handles = VmwareCbtSyncCommand.class) +public class LibvirtVmwareCbtSyncCommandWrapper extends CommandWrapper { + + private static final long DEFAULT_MAX_COPY_CHUNK_BYTES = 256L * 1024L * 1024L; + private static final Pattern SHA1_FINGERPRINT_PATTERN = Pattern.compile("(?i)(?:SHA1\\s+)?Fingerprint\\s*=\\s*([0-9A-F:]+)"); + + @Override + public Answer execute(VmwareCbtSyncCommand cmd, LibvirtComputingResource serverResource) { + if (!serverResource.hostSupportsVddkBlockCopy(cmd.getVddkLibDir())) { + String msg = String.format("Cannot synchronize VMware CBT migration %s on host %s. VDDK, qemu-img, qemu-nbd and qemu-io are required.", + cmd.getMigrationUuid(), serverResource.getPrivateIp()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, 0, false, null); + } + + long startTime = System.currentTimeMillis(); + List changedBlocks = cmd.getChangedBlocks(); + if (changedBlocks == null || changedBlocks.isEmpty()) { + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + String msg = String.format("VMware CBT cycle %s for migration %s completed with no changed blocks.", + cmd.getCycleNumber(), cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, durationSeconds, true, null); + } + + VmwareCbtSyncPlan syncPlan = VmwareCbtSyncPlan.create(cmd.getDisks(), changedBlocks); + if (!syncPlan.isValid()) { + String msg = String.format("Cannot synchronize VMware CBT cycle %s for migration %s: %s", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getValidationError()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + 0, 0, 0, false, null); + } + + try { + validateSyncCommand(cmd); + String vddkLibDir = resolveVddkSetting(cmd.getVddkLibDir(), serverResource.getVddkLibDir()); + if (StringUtils.isBlank(vddkLibDir)) { + String msg = String.format("Cannot synchronize VMware CBT migration %s because no VDDK library directory is configured or detected.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String vddkThumbprint = resolveVddkSetting(cmd.getVddkThumbprint(), serverResource.getVddkThumbprint()); + if (StringUtils.isBlank(vddkThumbprint)) { + vddkThumbprint = getVcenterThumbprint(sourceInstance.getVcenterHost(), getTimeout(cmd), + sourceInstance.getInstanceName()); + } + if (StringUtils.isBlank(vddkThumbprint)) { + String msg = String.format("Cannot synchronize VMware CBT migration %s because the vCenter SSL thumbprint could not be determined.", + cmd.getMigrationUuid()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + + KVMStoragePool targetPool = getTargetStoragePool(cmd, serverResource.getStoragePoolMgr()); + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + validateRbdTargetPool(cmd, targetPool); + } else if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + validateBlockDeviceTargetPool(cmd, targetPool); + } + + String passwordFilePath = writePasswordFile(cmd); + try { + List diskResults = new ArrayList<>(); + for (VmwareCbtSyncPlan.DiskPlan diskPlan : syncPlan.getDiskPlans()) { + diskResults.add(syncDiskChangedBlocks(cmd, diskPlan, targetPool, passwordFilePath, vddkLibDir, vddkThumbprint, + StringUtils.defaultIfBlank(cmd.getVddkTransports(), serverResource.getVddkTransports()))); + } + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + long dirtyRate = getDirtyRateBytesPerSecond(syncPlan.getChangedBytes(), durationSeconds); + String msg = String.format("VMware CBT cycle %s for migration %s copied %s changed block range(s), " + + "coalesced into %s copy range(s) across %s disk(s), totaling %s bytes.", + cmd.getCycleNumber(), cmd.getMigrationUuid(), syncPlan.getChangedRangeCount(), + syncPlan.getCopyRangeCount(), syncPlan.getDiskPlans().size(), syncPlan.getChangedBytes()); + logger.info(msg); + return new VmwareCbtMigrationAnswer(cmd, true, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), dirtyRate, durationSeconds, false, diskResults); + } finally { + Files.deleteIfExists(Path.of(passwordFilePath)); + } + } catch (Exception e) { + String msg = String.format("Cannot synchronize VMware CBT cycle %s for migration %s: %s", + cmd.getCycleNumber(), cmd.getMigrationUuid(), + StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName())); + logger.error(msg, e); + return new VmwareCbtMigrationAnswer(cmd, false, msg, cmd.getMigrationUuid(), cmd.getCycleNumber(), + syncPlan.getChangedBytes(), 0, 0, false, null); + } + } + + private void validateSyncCommand(VmwareCbtSyncCommand cmd) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + if (sourceInstance == null) { + throw new IllegalArgumentException("source VMware instance information is missing"); + } + if (StringUtils.isAnyBlank(sourceInstance.getVcenterHost(), sourceInstance.getVcenterUsername(), + sourceInstance.getVcenterPassword(), sourceInstance.getInstanceName())) { + throw new IllegalArgumentException("source vCenter host, username, password and VM name are required"); + } + if (StringUtils.isBlank(sourceInstance.getVmwareMoref())) { + throw new IllegalArgumentException("source VMware VM managed object reference is missing"); + } + if (StringUtils.isBlank(cmd.getSnapshotMor())) { + throw new IllegalArgumentException("VMware snapshot managed object reference is missing"); + } + } + + private VmwareCbtDiskSyncResultTO syncDiskChangedBlocks(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool, String passwordFilePath, String vddkLibDir, + String vddkThumbprint, String vddkTransports) throws Exception { + VmwareCbtDiskTO disk = diskPlan.getDisk(); + validateDisk(cmd, disk); + long startTime = System.currentTimeMillis(); + String scriptPath = writeDiskSyncScript(cmd, diskPlan, targetPool); + try { + String command = buildNbdkitDeltaSyncCommand(cmd, disk, passwordFilePath, vddkLibDir, vddkThumbprint, + vddkTransports, scriptPath); + VmwareCbtCommandResult commandResult = executeLoggedBash(command, getTimeout(cmd), + String.format("(%s) VMware CBT delta sync disk %s", cmd.getMigrationUuid(), disk.getDiskId())); + long durationSeconds = Math.max(1L, (System.currentTimeMillis() - startTime) / 1000L); + if (commandResult.getExitValue() != 0) { + String details = String.format("changed-block copy from VMware VDDK snapshot failed for source disk %s with exit code %s", + disk.getDiskId(), commandResult.getExitValue()); + throw new IllegalStateException(commandResult.appendLastCommandOutput(details)); + } + return new VmwareCbtDiskSyncResultTO(disk.getDiskId(), disk.getTargetPath(), disk.getChangeId(), + cmd.getSnapshotMor(), diskPlan.getChangedBytes(), durationSeconds, true, + String.format("Copied %s changed bytes in %s range(s)", diskPlan.getChangedBytes(), + diskPlan.getChangedBlocks().size())); + } finally { + Files.deleteIfExists(Path.of(scriptPath)); + } + } + + private void validateDisk(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (disk == null) { + throw new IllegalArgumentException("source disk cannot be null"); + } + if (StringUtils.isBlank(disk.getDiskId())) { + throw new IllegalArgumentException("source disk ID is missing"); + } + if (StringUtils.isBlank(disk.getSourceDiskPath())) { + throw new IllegalArgumentException(String.format("source disk path is missing for disk %s", disk.getDiskId())); + } + if (StringUtils.isBlank(disk.getTargetPath())) { + throw new IllegalArgumentException(String.format("target disk path is missing for disk %s", disk.getDiskId())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "raw"), "raw")) { + throw new IllegalArgumentException(String.format("only raw target disks are supported for VMware CBT delta sync to %s; disk %s uses %s", + cmd.getTargetStorageType(), disk.getDiskId(), disk.getTargetFormat())); + } + return; + } + if (!Files.isRegularFile(Path.of(disk.getTargetPath()))) { + throw new IllegalArgumentException(String.format("target disk path %s for disk %s does not exist or is not a regular file", + disk.getTargetPath(), disk.getDiskId())); + } + if (!StringUtils.equalsIgnoreCase(StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"), "qcow2")) { + throw new IllegalArgumentException(String.format("only qcow2 target disks are supported for VMware CBT delta sync; disk %s uses %s", + disk.getDiskId(), disk.getTargetFormat())); + } + } + + private String writePasswordFile(VmwareCbtSyncCommand cmd) throws Exception { + Path passwordFile = Files.createTempFile(String.format("vmware-cbt-%s-", sanitizeFileName(cmd.getMigrationUuid())), + ".pass"); + Files.writeString(passwordFile, cmd.getSourceInstance().getVcenterPassword()); + setPosixFilePermissionsIfSupported(passwordFile, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + return passwordFile.toString(); + } + + protected String writeDiskSyncScript(VmwareCbtSyncCommand cmd, VmwareCbtSyncPlan.DiskPlan diskPlan, + KVMStoragePool targetPool) throws Exception { + VmwareCbtDiskTO disk = diskPlan.getDisk(); + boolean directBlockTarget = cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE; + Path scriptPath = Files.createTempFile(String.format("vmware-cbt-%s-%s-", sanitizeFileName(cmd.getMigrationUuid()), + sanitizeFileName(disk.getDiskId())), ".sh"); + StringBuilder script = new StringBuilder(); + script.append("#!/bin/bash\n"); + script.append("set -euo pipefail\n"); + script.append("target_path=").append(shellQuote(getQemuTargetPath(cmd, disk, targetPool))).append("\n"); + script.append("target_format=").append(shellQuote(getTargetFormat(cmd, disk))).append("\n"); + script.append("get_nbd_socket_path() {\n"); + script.append(" local socket_path=\"${uri#*socket=}\"\n"); + script.append(" socket_path=\"${socket_path%%&*}\"\n"); + script.append(" if [[ \"$socket_path\" == \"$uri\" || -z \"$socket_path\" ]]; then\n"); + script.append(" echo \"Cannot parse nbdkit unix socket from uri: $uri\" >&2\n"); + script.append(" exit 1\n"); + script.append(" fi\n"); + script.append(" echo \"$socket_path\"\n"); + script.append("}\n"); + script.append("nbd_socket_path=$(get_nbd_socket_path)\n"); + if (directBlockTarget) { + // Local raw block device (e.g. Linstor/DRBD): copy each changed extent straight from the + // nbdkit source window into the same target window in a single qemu-img convert, avoiding + // the temporary-file round-trip the qemu-io path needs. -S 0 disables zero/sparse skipping + // so blocks the source cleared to zero are actually overwritten in the target (delta + // correctness); -n keeps the pre-created device. + script.append("copy_range() {\n"); + script.append(" local range_start=\"$1\"\n"); + script.append(" local range_length=\"$2\"\n"); + script.append(" local source_opts=\"driver=raw,offset=$range_start,size=$range_length,file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path\"\n"); + script.append(" local target_opts=\"driver=raw,offset=$range_start,size=$range_length,file.driver=host_device,file.filename=$target_path\"\n"); + script.append(" qemu-img convert -n -S 0 --image-opts --target-image-opts \"$source_opts\" \"$target_opts\"\n"); + script.append("}\n"); + } else { + script.append("max_chunk_bytes=").append(DEFAULT_MAX_COPY_CHUNK_BYTES).append("\n"); + script.append("tmp_dir=$(mktemp -d /var/tmp/cloudstack-cbt-").append(sanitizeFileName(cmd.getMigrationUuid())) + .append("-").append(sanitizeFileName(disk.getDiskId())).append("-XXXXXX)\n"); + script.append("cleanup() {\n"); + script.append(" rm -rf \"$tmp_dir\"\n"); + script.append("}\n"); + script.append("trap cleanup EXIT\n"); + script.append("copy_range() {\n"); + script.append(" local range_start=\"$1\"\n"); + script.append(" local range_length=\"$2\"\n"); + script.append(" local current_start=\"$range_start\"\n"); + script.append(" local remaining=\"$range_length\"\n"); + script.append(" while (( remaining > 0 )); do\n"); + script.append(" local chunk_length=\"$remaining\"\n"); + script.append(" if (( chunk_length > max_chunk_bytes )); then\n"); + script.append(" chunk_length=\"$max_chunk_bytes\"\n"); + script.append(" fi\n"); + script.append(" local chunk_file=\"$tmp_dir/range-${current_start}-${chunk_length}.raw\"\n"); + script.append(" local source_opts=\"driver=raw,offset=$current_start,size=$chunk_length,file.driver=nbd,file.server.type=unix,file.server.path=$nbd_socket_path\"\n"); + script.append(" qemu-img convert --image-opts -O raw \"$source_opts\" \"$chunk_file\" >/dev/null\n"); + script.append(" qemu-io -f \"$target_format\" -c \"write -s $chunk_file $current_start $chunk_length\" \"$target_path\"\n"); + script.append(" rm -f \"$chunk_file\"\n"); + script.append(" remaining=$((remaining - chunk_length))\n"); + script.append(" current_start=$((current_start + chunk_length))\n"); + script.append(" done\n"); + script.append("}\n"); + } + for (VmwareCbtChangedBlockRangeTO changedBlock : diskPlan.getChangedBlocks()) { + script.append("copy_range ").append(changedBlock.getStartOffset()).append(" ") + .append(changedBlock.getLength()).append("\n"); + } + Files.writeString(scriptPath, script.toString()); + setPosixFilePermissionsIfSupported(scriptPath, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + return scriptPath.toString(); + } + + private KVMStoragePool getTargetStoragePool(VmwareCbtSyncCommand cmd, KVMStoragePoolManager storagePoolMgr) { + Storage.StoragePoolType poolType = cmd.getDestinationStoragePoolType(); + String poolUuid = StringUtils.trimToNull(cmd.getDestinationStoragePoolUuid()); + if (poolType != null && StringUtils.isNotBlank(poolUuid)) { + KVMStoragePool pool = storagePoolMgr.getStoragePool(poolType, poolUuid); + if (pool == null) { + throw new IllegalArgumentException(String.format("destination storage pool %s/%s is not available on this host", + poolType, poolUuid)); + } + return pool; + } + return null; + } + + private void validateRbdTargetPool(VmwareCbtSyncCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.RBD) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires an RBD destination storage pool for RBD target writing", + cmd.getMigrationUuid())); + } + } + + private void validateBlockDeviceTargetPool(VmwareCbtSyncCommand cmd, KVMStoragePool targetPool) { + if (targetPool == null || targetPool.getType() != Storage.StoragePoolType.Linstor) { + throw new IllegalArgumentException(String.format("VMware CBT migration %s requires a Linstor destination storage pool for block device target writing", + cmd.getMigrationUuid())); + } + } + + private String getQemuTargetPath(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, KVMStoragePool targetPool) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW) { + return KVMPhysicalDisk.RBDStringBuilder(targetPool, getRbdImagePath(targetPool, disk.getTargetPath())); + } + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return getBlockDevicePath(targetPool, disk); + } + return disk.getTargetPath(); + } + + private String getBlockDevicePath(KVMStoragePool targetPool, VmwareCbtDiskTO disk) { + String normalizedTargetPath = StringUtils.defaultString(disk.getTargetPath()).replace('\\', '/'); + String volumeName = StringUtils.contains(normalizedTargetPath, "/") ? + StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + KVMPhysicalDisk targetDisk = targetPool.getPhysicalDisk(volumeName); + String devicePath = targetDisk == null ? null : targetDisk.getPath(); + if (StringUtils.isBlank(devicePath)) { + throw new IllegalStateException(String.format("could not resolve a local device path for block device target volume %s of disk %s", + volumeName, disk.getDiskId())); + } + return devicePath; + } + + private String getTargetFormat(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk) { + if (cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RBD_RAW || + cmd.getTargetStorageType() == VmwareCbtTargetStorageType.RAW_BLOCK_DEVICE) { + return "raw"; + } + return StringUtils.defaultIfBlank(disk.getTargetFormat(), "qcow2"); + } + + private String getRbdImagePath(KVMStoragePool targetPool, String targetPath) { + String normalizedTargetPath = StringUtils.defaultString(targetPath).replace('\\', '/'); + String imageName = StringUtils.contains(normalizedTargetPath, "/") ? StringUtils.substringAfterLast(normalizedTargetPath, "/") : normalizedTargetPath; + return String.format("%s/%s", StringUtils.removeEnd(targetPool.getSourceDir(), "/"), imageName); + } + + private void setPosixFilePermissionsIfSupported(Path path, Set permissions) throws Exception { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException e) { + logger.debug("POSIX file permissions are not supported for {}", path); + } + } + + private String buildNbdkitDeltaSyncCommand(VmwareCbtSyncCommand cmd, VmwareCbtDiskTO disk, + String passwordFilePath, String vddkLibDir, String vddkThumbprint, + String vddkTransports, String scriptPath) { + RemoteInstanceTO sourceInstance = cmd.getSourceInstance(); + String sourceVmMoref = getMorefValue(sourceInstance.getVmwareMoref()); + String snapshotMoref = getMorefValue(cmd.getSnapshotMor()); + StringBuilder nbdkit = new StringBuilder("nbdkit -r -U - vddk "); + appendPluginParameter(nbdkit, "file", disk.getSourceDiskPath()); + appendPluginParameter(nbdkit, "server", sourceInstance.getVcenterHost()); + appendPluginParameter(nbdkit, "user", sourceInstance.getVcenterUsername()); + nbdkit.append("password=+").append(shellQuote(passwordFilePath)).append(" "); + nbdkit.append("vm=moref=").append(shellQuote(sourceVmMoref)).append(" "); + appendPluginParameter(nbdkit, "snapshot", snapshotMoref); + appendPluginParameter(nbdkit, "libdir", vddkLibDir); + appendPluginParameter(nbdkit, "thumbprint", vddkThumbprint); + if (StringUtils.isNotBlank(vddkTransports)) { + appendPluginParameter(nbdkit, "transports", vddkTransports); + } + String runCommand = String.format("export uri; bash %s", shellQuote(scriptPath)); + return nbdkit.append("--run ").append(shellQuote(runCommand)).toString(); + } + + private void appendPluginParameter(StringBuilder command, String key, String value) { + command.append(key).append("=").append(shellQuote(value)).append(" "); + } + + private long getDirtyRateBytesPerSecond(long changedBytes, long durationSeconds) { + if (durationSeconds <= 0) { + return changedBytes; + } + return changedBytes / durationSeconds; + } + + private long getTimeout(VmwareCbtSyncCommand cmd) { + return Math.max(1L, cmd.getWait()) * 1000L; + } + + private String getMorefValue(String moref) { + String value = StringUtils.trimToNull(moref); + if (value == null) { + return null; + } + return value.contains(":") ? StringUtils.substringAfter(value, ":") : value; + } + + private String sanitizeFileName(String value) { + String sanitized = StringUtils.defaultIfBlank(value, "disk").replaceAll("[^A-Za-z0-9._-]", "-"); + return StringUtils.defaultIfBlank(sanitized, "disk"); + } + + private String shellQuote(String value) { + return "'" + StringUtils.defaultString(value).replace("'", "'\"'\"'") + "'"; + } + + private String resolveVddkSetting(String commandValue, String agentValue) { + return StringUtils.defaultIfBlank(StringUtils.trimToNull(commandValue), StringUtils.trimToNull(agentValue)); + } + + protected VmwareCbtCommandResult executeLoggedBash(String command, long timeout, String logPrefix) { + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + VmwareCbtCommandOutputLogger outputLogger = new VmwareCbtCommandOutputLogger(logger, logPrefix); + script.execute(outputLogger); + return new VmwareCbtCommandResult(script.getExitValue(), outputLogger.getLastRelevantOutputLine()); + } + + protected String getVcenterThumbprint(String vcenterHost, long timeout, String sourceVmName) { + if (StringUtils.isBlank(vcenterHost)) { + return null; + } + String endpoint = String.format("%s:443", vcenterHost); + String command = String.format("openssl s_client -connect %s /dev/null | " + + "openssl x509 -fingerprint -sha1 -noout", shellQuote(endpoint)); + + Script script = new Script("/bin/bash", timeout, logger); + script.add("-c"); + script.add(command); + + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + script.execute(parser); + + String output = parser.getLines(); + if (script.getExitValue() != 0) { + logger.error("({}) Failed to fetch vCenter thumbprint for {}", sourceVmName, vcenterHost); + return null; + } + + return extractSha1Fingerprint(output); + } + + private String extractSha1Fingerprint(String output) { + String parsedOutput = StringUtils.trimToEmpty(output); + if (StringUtils.isBlank(parsedOutput)) { + return null; + } + + for (String line : parsedOutput.split("\\R")) { + String trimmedLine = StringUtils.trimToEmpty(line); + if (StringUtils.isBlank(trimmedLine)) { + continue; + } + + Matcher matcher = SHA1_FINGERPRINT_PATTERN.matcher(trimmedLine); + if (matcher.find()) { + return matcher.group(1).toUpperCase(Locale.ROOT); + } + + if (trimmedLine.matches("(?i)[0-9a-f]{2}(:[0-9a-f]{2})+")) { + return trimmedLine.toUpperCase(Locale.ROOT); + } + } + return null; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java new file mode 100644 index 000000000000..2825e9c52a19 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandOutputLogger.java @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; + +import com.cloud.utils.script.OutputInterpreter; + +class VmwareCbtCommandOutputLogger extends OutputInterpreter { + + private static final Pattern PASSWORD_ARGUMENT_PATTERN = Pattern.compile("(?i)(password=)([^\\s]+)"); + + private final Logger logger; + private final String logPrefix; + private String lastOutputLine; + private String lastImportantOutputLine; + + VmwareCbtCommandOutputLogger(Logger logger, String logPrefix) { + this.logger = logger; + this.logPrefix = logPrefix; + } + + @Override + public boolean drain() { + return true; + } + + @Override + public String interpret(BufferedReader reader) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + String safeLine = sanitize(line); + logger.info(StringUtils.isNotBlank(logPrefix) ? String.format("%s %s", logPrefix, safeLine) : safeLine); + captureOutputLine(safeLine); + } + return null; + } + + String getLastRelevantOutputLine() { + return StringUtils.defaultIfBlank(lastImportantOutputLine, lastOutputLine); + } + + private void captureOutputLine(String line) { + String trimmedLine = StringUtils.trimToNull(line); + if (trimmedLine == null) { + return; + } + lastOutputLine = trimmedLine; + if (isImportantOutputLine(trimmedLine)) { + lastImportantOutputLine = trimmedLine; + } + } + + private boolean isImportantOutputLine(String line) { + String lowerCaseLine = StringUtils.lowerCase(line); + return StringUtils.containsAny(lowerCaseLine, + "error", "failed", "unable", "cannot", "permission denied", "no such file", + "not found", "file is empty", "traceback", "exception"); + } + + private String sanitize(String line) { + return PASSWORD_ARGUMENT_PATTERN.matcher(StringUtils.defaultString(line)).replaceAll("$1******"); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java new file mode 100644 index 000000000000..184c6f3111f9 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtCommandResult.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import org.apache.commons.lang3.StringUtils; + +class VmwareCbtCommandResult { + + private final int exitValue; + private final String lastCommandOutput; + + VmwareCbtCommandResult(int exitValue, String lastCommandOutput) { + this.exitValue = exitValue; + this.lastCommandOutput = lastCommandOutput; + } + + int getExitValue() { + return exitValue; + } + + String appendLastCommandOutput(String details) { + if (StringUtils.isBlank(lastCommandOutput) || StringUtils.contains(details, lastCommandOutput)) { + return details; + } + return String.format("%s Last command output: %s", details, lastCommandOutput); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java new file mode 100644 index 000000000000..458b5e6bba60 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/VmwareCbtSyncPlan.java @@ -0,0 +1,273 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.to.VmwareCbtChangedBlockRangeTO; +import com.cloud.agent.api.to.VmwareCbtDiskTO; + +class VmwareCbtSyncPlan { + + private final boolean valid; + private final String validationError; + private final List diskPlans; + private final int changedRangeCount; + private final int copyRangeCount; + private final long changedBytes; + + private VmwareCbtSyncPlan(boolean valid, String validationError, List diskPlans, + int changedRangeCount, int copyRangeCount, long changedBytes) { + this.valid = valid; + this.validationError = validationError; + this.diskPlans = diskPlans; + this.changedRangeCount = changedRangeCount; + this.copyRangeCount = copyRangeCount; + this.changedBytes = changedBytes; + } + + static VmwareCbtSyncPlan create(List disks, List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return new VmwareCbtSyncPlan(true, null, Collections.emptyList(), 0, 0, 0); + } + + Map diskPlansById = getDiskPlansById(disks); + int changedRangeCount = 0; + + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + ValidationResult validationResult = validateChangedBlock(changedBlock, diskPlansById); + if (!validationResult.valid) { + return invalid(validationResult.error); + } + + DiskPlanBuilder diskPlan = diskPlansById.get(changedBlock.getDiskId()); + long rangeEnd = changedBlock.getStartOffset() + changedBlock.getLength(); + if (rangeEnd < changedBlock.getStartOffset()) { + return invalid(String.format("Changed block range for disk %s overflows the virtual disk address space.", + changedBlock.getDiskId())); + } + if (diskPlan.disk.getCapacityBytes() > 0 && rangeEnd > diskPlan.disk.getCapacityBytes()) { + return invalid(String.format("Changed block range for disk %s exceeds disk capacity.", changedBlock.getDiskId())); + } + + diskPlan.addChangedBlock(changedBlock); + changedRangeCount++; + } + + List diskPlans = getPopulatedDiskPlans(diskPlansById); + long changedBytes = 0; + int copyRangeCount = 0; + for (DiskPlan diskPlan : diskPlans) { + changedBytes += diskPlan.getChangedBytes(); + copyRangeCount += diskPlan.getChangedBlocks().size(); + } + + return new VmwareCbtSyncPlan(true, null, diskPlans, changedRangeCount, copyRangeCount, changedBytes); + } + + private static Map getDiskPlansById(List disks) { + Map diskPlansById = new LinkedHashMap<>(); + if (CollectionUtils.isEmpty(disks)) { + return diskPlansById; + } + + for (VmwareCbtDiskTO disk : disks) { + if (disk != null && StringUtils.isNotBlank(disk.getDiskId())) { + diskPlansById.put(disk.getDiskId(), new DiskPlanBuilder(disk)); + } + } + return diskPlansById; + } + + private static ValidationResult validateChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock, + Map diskPlansById) { + if (changedBlock == null) { + return ValidationResult.invalid("Changed block range cannot be null."); + } + if (StringUtils.isBlank(changedBlock.getDiskId())) { + return ValidationResult.invalid("Changed block range is missing a disk id."); + } + + DiskPlanBuilder diskPlan = diskPlansById.get(changedBlock.getDiskId()); + if (diskPlan == null) { + return ValidationResult.invalid(String.format("Changed block range references unknown disk %s.", changedBlock.getDiskId())); + } + if (StringUtils.isBlank(diskPlan.disk.getTargetPath())) { + return ValidationResult.invalid(String.format("Changed block range references disk %s, but no target path is known. " + + "The initial full sync must create and persist the KVM target disk before CBT delta sync can run.", + changedBlock.getDiskId())); + } + if (changedBlock.getStartOffset() < 0) { + return ValidationResult.invalid(String.format("Changed block range for disk %s has a negative start offset.", + changedBlock.getDiskId())); + } + if (changedBlock.getLength() <= 0) { + return ValidationResult.invalid(String.format("Changed block range for disk %s has a non-positive length.", + changedBlock.getDiskId())); + } + + return ValidationResult.valid(); + } + + private static List getPopulatedDiskPlans(Map diskPlansById) { + List diskPlans = new ArrayList<>(); + for (DiskPlanBuilder diskPlan : diskPlansById.values()) { + if (CollectionUtils.isNotEmpty(diskPlan.changedBlocks)) { + diskPlans.add(diskPlan.build()); + } + } + return diskPlans; + } + + private static VmwareCbtSyncPlan invalid(String validationError) { + return new VmwareCbtSyncPlan(false, validationError, Collections.emptyList(), 0, 0, 0); + } + + boolean isValid() { + return valid; + } + + String getValidationError() { + return validationError; + } + + List getDiskPlans() { + return diskPlans; + } + + int getChangedRangeCount() { + return changedRangeCount; + } + + int getCopyRangeCount() { + return copyRangeCount; + } + + long getChangedBytes() { + return changedBytes; + } + + static class DiskPlan { + + private final VmwareCbtDiskTO disk; + private final List changedBlocks; + private final long changedBytes; + + DiskPlan(VmwareCbtDiskTO disk, List changedBlocks, long changedBytes) { + this.disk = disk; + this.changedBlocks = Collections.unmodifiableList(changedBlocks); + this.changedBytes = changedBytes; + } + + VmwareCbtDiskTO getDisk() { + return disk; + } + + List getChangedBlocks() { + return changedBlocks; + } + + long getChangedBytes() { + return changedBytes; + } + } + + private static class DiskPlanBuilder { + + private final VmwareCbtDiskTO disk; + private final List changedBlocks = new ArrayList<>(); + + DiskPlanBuilder(VmwareCbtDiskTO disk) { + this.disk = disk; + } + + void addChangedBlock(VmwareCbtChangedBlockRangeTO changedBlock) { + changedBlocks.add(changedBlock); + } + + DiskPlan build() { + List copyBlocks = coalesceChangedBlocks(changedBlocks); + return new DiskPlan(disk, copyBlocks, sumChangedBytes(copyBlocks)); + } + + private List coalesceChangedBlocks(List changedBlocks) { + if (CollectionUtils.isEmpty(changedBlocks)) { + return Collections.emptyList(); + } + + List sortedBlocks = new ArrayList<>(changedBlocks); + sortedBlocks.sort(Comparator.comparingLong(VmwareCbtChangedBlockRangeTO::getStartOffset) + .thenComparingLong(VmwareCbtChangedBlockRangeTO::getLength)); + + List copyBlocks = new ArrayList<>(); + String diskId = disk.getDiskId(); + long currentStart = sortedBlocks.get(0).getStartOffset(); + long currentEnd = currentStart + sortedBlocks.get(0).getLength(); + + for (int i = 1; i < sortedBlocks.size(); i++) { + VmwareCbtChangedBlockRangeTO changedBlock = sortedBlocks.get(i); + long blockStart = changedBlock.getStartOffset(); + long blockEnd = blockStart + changedBlock.getLength(); + if (blockStart <= currentEnd) { + currentEnd = Math.max(currentEnd, blockEnd); + continue; + } + + copyBlocks.add(new VmwareCbtChangedBlockRangeTO(diskId, currentStart, currentEnd - currentStart)); + currentStart = blockStart; + currentEnd = blockEnd; + } + copyBlocks.add(new VmwareCbtChangedBlockRangeTO(diskId, currentStart, currentEnd - currentStart)); + return copyBlocks; + } + + private long sumChangedBytes(List changedBlocks) { + long changedBytes = 0; + for (VmwareCbtChangedBlockRangeTO changedBlock : changedBlocks) { + changedBytes += changedBlock.getLength(); + } + return changedBytes; + } + } + + private static class ValidationResult { + + private final boolean valid; + private final String error; + + private ValidationResult(boolean valid, String error) { + this.valid = valid; + this.error = error; + } + + static ValidationResult valid() { + return new ValidationResult(true, null); + } + + static ValidationResult invalid(String error) { + return new ValidationResult(false, error); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java index a8207cec3fa6..18aed95b0587 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java @@ -24,6 +24,7 @@ import com.cloud.agent.properties.AgentPropertiesFileHandler; import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat; +import org.apache.cloudstack.storage.volume.VolumeOnStorageTO; import org.joda.time.Duration; import com.cloud.agent.api.to.HostTO; @@ -56,6 +57,11 @@ default KVMPhysicalDisk createPhysicalDisk(String volumeUuid, PhysicalDiskFormat List listPhysicalDisks(); + /** Read-only backend import metadata; null path requests all volumes in this pool. */ + default List getVolumesForImport(String path) { + throw new UnsupportedOperationException("Backend import metadata is not supported by this storage pool"); + } + String getUuid(); long getCapacity(); @@ -74,6 +80,43 @@ default Long getUsedIops() { boolean refresh(); + /** + * Whether a freshly created volume on this pool is guaranteed to read back as + * all zeros. Callers that skip writing zero blocks into a pre-created target + * (e.g. qemu-img convert --target-is-zero / nbdcopy --destination-is-zero) must + * only do so when this returns true; block backends that do not zero-initialize + * new volumes (e.g. LVM-thick) return false so those callers write the zeros and + * do not leak stale data from previously deleted volumes into unwritten regions. + * Defaults to false so any backend that has not opted in is treated as unsafe. + */ + default boolean isVolumeZeroInitialized(String volumeName) { + return false; + } + + /** + * Returned by {@link #getVolumeInUseNode(String)} when the backend could not tell us + * whether the volume is in use. Callers must fail closed and treat it as "in use": + * adopting a clustered volume that is actually attached elsewhere would give it two writers. + */ + String IN_USE_NODE_UNKNOWN = ""; + + /** + * For clustered block storage whose host-local file lock is not authoritative + * (e.g. Linstor/DRBD), returns the node name on which the given volume is currently + * in use — attached to a running VM anywhere in the cluster — or null if not in use. + * Adoption/import callers treat a non-null result as "locked". + * + * Implementations that cannot establish the cluster-wide state (controller unreachable, + * empty or malformed response) must return {@link #IN_USE_NODE_UNKNOWN} rather than null, + * so the ambiguity is not silently read as "free". + * + * Defaults to null so the host-local qemu-img lock remains the sole in-use signal for + * other pool types. + */ + default String getVolumeInUseNode(String volumeName) { + return null; + } + boolean isExternalSnapshot(); String getLocalPath(); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java index 8a1a7b5bbe2e..2c642efb9cfb 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java @@ -57,6 +57,7 @@ import com.ceph.rados.IoCTX; import com.ceph.rados.Rados; +import com.ceph.rados.exceptions.ErrorCode; import com.ceph.rados.exceptions.RadosException; import com.ceph.rbd.Rbd; import com.ceph.rbd.RbdException; @@ -646,6 +647,19 @@ uuid, toHumanReadableSize(storage.getInfo().capacity), public KVMPhysicalDisk getPhysicalDisk(String volumeUuid, KVMStoragePool pool) { LibvirtStoragePool libvirtPool = (LibvirtStoragePool)pool; + /* + * An RBD volume is looked up through librbd rather than through libvirt. + * A volume that was created by another host is not in the libvirt pool + * cache, so looking it up through libvirt misses and forces a refresh of + * the whole pool. Refreshing an RBD pool stats every image in it, so that + * cost grows with the number of volumes in the pool and is paid on every + * VM start. Every other RBD operation in this class already uses librbd + * directly. + */ + if (pool.getType() == StoragePoolType.RBD) { + return getRbdPhysicalDisk(volumeUuid, libvirtPool); + } + try { StorageVol vol = getVolume(libvirtPool.getPool(), volumeUuid); if (vol == null) { @@ -687,6 +701,74 @@ public KVMPhysicalDisk getPhysicalDisk(String volumeUuid, KVMStoragePool pool) { } } + /** + * Looks an RBD volume up directly through librbd. + * + * The size reported by librbd is used for both the size and the virtual size of + * the disk, matching what this class already does after converting an image into + * an RBD volume. + */ + private KVMPhysicalDisk getRbdPhysicalDisk(String volumeUuid, LibvirtStoragePool pool) { + Rados r = new Rados(pool.getAuthUserName()); + try { + r.confSet("mon_host", pool.getSourceHost() + ":" + pool.getSourcePort()); + /* + * The secret is null when the pool has no cephx user, and librados + * aborts the process rather than returning an error if it is handed a + * null value here. + */ + if (pool.getAuthUserName() != null) { + r.confSet("key", pool.getAuthSecret()); + } else { + r.confSet("auth_client_required", "none"); + } + r.confSet("client_mount_timeout", "30"); + r.connect(); + + IoCTX io = r.ioCtxCreate(pool.getSourceDir()); + try { + Rbd rbd = new Rbd(io); + // The image is only stat'ed, so it is opened read only and cannot take the exclusive lock. + RbdImage image = rbd.openReadOnly(volumeUuid); + try { + RbdImageInfo rbdInfo = image.stat(); + KVMPhysicalDisk disk = new KVMPhysicalDisk(pool.getSourceDir() + "/" + volumeUuid, volumeUuid, pool); + disk.setFormat(PhysicalDiskFormat.RAW); + disk.setSize(rbdInfo.size); + disk.setVirtualSize(rbdInfo.size); + return disk; + } finally { + closeRbdImage(rbd, image, volumeUuid); + } + } finally { + r.ioCtxDestroy(io); + } + } catch (RadosException e) { + logger.error("A Ceph RADOS operation failed (" + e.getReturnValue() + "). The error was: " + e.getMessage() + + " - " + ErrorCode.getErrorMessage(e.getReturnValue())); + throw new CloudRuntimeException(e.toString(), e); + } catch (RbdException e) { + logger.error("A Ceph RBD operation failed (" + e.getReturnValue() + "). The error was: " + e.getMessage() + + " - " + ErrorCode.getErrorMessage(e.getReturnValue())); + throw new CloudRuntimeException(e.toString(), e); + } finally { + r.shutDown(); + } + } + + /** + * Closes an RBD image without throwing, so that a failure to close cannot discard a + * successful result or hide the exception that is already on its way out. + */ + private void closeRbdImage(Rbd rbd, RbdImage image, String volumeUuid) { + try { + rbd.close(image); + } catch (RbdException e) { + logger.warn("Failed to close RBD image " + volumeUuid + " (" + e.getReturnValue() + "): " + + e.getMessage() + " - " + ErrorCode.getErrorMessage(e.getReturnValue())); + } + } + /** * adjust refcount */ diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java index a8c32baa6ef3..7a70f2497cda 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStoragePool.java @@ -17,6 +17,7 @@ package com.cloud.hypervisor.kvm.storage; import java.io.File; +import java.io.IOException; import java.util.List; import java.util.Map; @@ -173,9 +174,9 @@ public KVMPhysicalDisk getPhysicalDisk(String volumeUid) { logger.debug("find volume bypass libvirt volumeUid " + volumeUid); //For network file system or file system, try to use java file to find the volume, instead of through libvirt. BUG:CLOUDSTACK-4459 String localPoolPath = this.getLocalPath(); - File f = new File(localPoolPath + File.separator + volumeUuid); + File f = resolvePhysicalDiskFile(localPoolPath, volumeUid, volumeUuid); if (!f.exists()) { - logger.debug("volume: " + volumeUuid + " not exist on storage pool"); + logger.debug("volume: " + volumeUid + " not exist on storage pool"); throw new CloudRuntimeException("Can't find volume:" + volumeUuid); } disk = new KVMPhysicalDisk(f.getPath(), volumeUuid, this); @@ -186,6 +187,32 @@ public KVMPhysicalDisk getPhysicalDisk(String volumeUid) { return disk; } + File resolvePhysicalDiskFile(String localPoolPath, String volumeUid, String volumeUuid) { + File poolPath = new File(localPoolPath); + if (volumeUid.contains("/") && !new File(volumeUid).isAbsolute()) { + File nestedVolume = new File(poolPath, volumeUid); + if (isFileInStoragePool(poolPath, nestedVolume)) { + return nestedVolume; + } + logger.warn(String.format("Relative volume path [%s] resolves outside storage pool [%s]. Falling back to volume name lookup.", + volumeUid, localPoolPath)); + } + return new File(poolPath, volumeUuid); + } + + private boolean isFileInStoragePool(File poolPath, File volumePath) { + try { + String canonicalPoolPath = poolPath.getCanonicalPath(); + String canonicalVolumePath = volumePath.getCanonicalPath(); + return canonicalVolumePath.equals(canonicalPoolPath) || + canonicalVolumePath.startsWith(canonicalPoolPath + File.separator); + } catch (IOException e) { + logger.warn(String.format("Unable to resolve storage pool path [%s] or volume path [%s].", + poolPath, volumePath), e); + return false; + } + } + @Override public boolean connectPhysicalDisk(String name, Map details) { return true; diff --git a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuCommand.java b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuCommand.java index 56d44ff51ba8..9e0a6b364fb6 100644 --- a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuCommand.java +++ b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuCommand.java @@ -28,6 +28,7 @@ public class QemuCommand { public static final String AGENT_FREEZE = "guest-fsfreeze-freeze"; public static final String AGENT_THAW = "guest-fsfreeze-thaw"; public static final String AGENT_FREEZE_STATUS = "guest-fsfreeze-status"; + public static final String AGENT_PING = "guest-ping"; public static final String QEMU_CMD = "execute"; diff --git a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java index 49f531ed7c11..655d0e19b173 100644 --- a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java +++ b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java @@ -134,28 +134,44 @@ public enum BitmapOperation { */ public QemuImg(final long timeout, final boolean skipZeroIfSupported, final boolean noCache) throws LibvirtException { if (skipZeroIfSupported) { - final Script s = new Script(_qemuImgPath, timeout); - s.add("--help"); - - final OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); - final String result = s.execute(parser); - - // Older Qemu returns output in result due to --help reporting error status - if (result != null) { - if (result.contains(TARGET_ZERO_FLAG)) { - this.skipZero = true; - } - } else { - if (parser.getLines().contains(TARGET_ZERO_FLAG)) { - this.skipZero = true; - } - } + this.skipZero = isTargetZeroFlagSupported(timeout); } this.timeout = timeout; this.noCache = noCache; this.version = LibvirtConnection.getConnection().getVersion(); } + /** + * Detects support for the convert --target-is-zero flag. The flag used to be + * discovered via "qemu-img --help", but newer qemu (observed with 10.x on EL9) + * no longer lists it in the help text while still supporting it - which + * silently disabled zero-skipping and fully allocated thin block-device + * targets. Probe the option directly instead: a recognized option fails with + * "--target-is-zero requires use of -n flag", an unknown one with + * "unrecognized option". + */ + private boolean isTargetZeroFlagSupported(final long timeout) { + // Fast path for older qemu that documents the flag in --help. + final Script help = new Script(_qemuImgPath, timeout); + help.add("--help"); + final OutputInterpreter.AllLinesParser helpParser = new OutputInterpreter.AllLinesParser(); + final String helpResult = help.execute(helpParser); + final String helpOutput = (helpResult != null ? helpResult : "") + StringUtils.defaultString(helpParser.getLines()); + if (helpOutput.contains(TARGET_ZERO_FLAG)) { + return true; + } + + final Script probe = new Script(_qemuImgPath, timeout); + probe.add("convert"); + probe.add(TARGET_ZERO_FLAG); + final OutputInterpreter.AllLinesParser probeParser = new OutputInterpreter.AllLinesParser(); + final String probeResult = probe.execute(probeParser); + final String probeOutput = (probeResult != null ? probeResult : "") + StringUtils.defaultString(probeParser.getLines()); + return probeOutput.contains(TARGET_ZERO_FLAG) + && !probeOutput.contains("unrecognized option") + && !probeOutput.contains("invalid option"); + } + /** * Creates a QemuImg object. * diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java index 008d9444fe5f..619aab27010e 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java @@ -3645,6 +3645,15 @@ public void testPlugNicCommandNoMatchMack() { when(vifDriver.plug(nic, "Other PV", "", null)).thenReturn(interfaceDef); when(interfaceDef.toString()).thenReturn("Interface"); + // Stub vm.getXMLDesc(0) so findNextAvailablePciSlot can scan the domain XML + // for in-use PCI slots. Returning a minimal with a single NIC at + // slot 0x03 exercises the production parser without forcing the production + // code into its null-fallback path. + when(vm.getXMLDesc(0)).thenReturn( + "" + + "

" + + ""); + final String interfaceDefStr = interfaceDef.toString(); doNothing().when(vm).attachDevice(interfaceDefStr); @@ -7244,6 +7253,38 @@ public void parseCpuFeaturesTestReturnListOfCpuFeaturesAndIgnoreMultipleWhitespa Assert.assertEquals("hle", cpuFeatures.get(3)); } + @Test + public void parseVddkLibraryVersionFromDumpPluginOutputReturnsLibraryVersion() { + String output = "vddk_default_libdir=/usr/lib64/vmware-vix-disklib\n" + + "vddk_library_version=8\n" + + "vddk_transport_modes=file:san:hotadd:nbdssl:nbd\n"; + + Assert.assertEquals("8", libvirtComputingResourceSpy.parseVddkLibraryVersionFromDumpPluginOutput(output)); + } + + @Test + public void parseVddkLibraryVersionFromDumpPluginOutputIgnoresPluginVersionWithoutLoadedLibrary() { + String output = "nbdkit 1.24.0\n" + + "vddk 1.24.0\n" + + "vddk_default_libdir=/usr/lib64/vmware-vix-disklib\n"; + + Assert.assertNull(libvirtComputingResourceSpy.parseVddkLibraryVersionFromDumpPluginOutput(output)); + } + + @Test + public void isNbdkitVddkPluginUsableRequiresLoadedVddkLibrary() throws IOException { + Path vddkDir = Files.createTempDirectory("vddk-test"); + Files.createDirectories(vddkDir.resolve("lib64")); + Files.createFile(vddkDir.resolve("lib64/libvixDiskLib.so.8")); + Mockito.doReturn("vddk_library_version=8\n").when(libvirtComputingResourceSpy).runNbdkitVddkDumpPlugin(vddkDir.toString()); + + Assert.assertTrue(libvirtComputingResourceSpy.isNbdkitVddkPluginUsable(vddkDir.toString())); + + Mockito.doReturn("nbdkit: error: libssl.so.3: cannot open shared object file\n").when(libvirtComputingResourceSpy).runNbdkitVddkDumpPlugin(vddkDir.toString()); + + Assert.assertFalse(libvirtComputingResourceSpy.isNbdkitVddkPluginUsable(vddkDir.toString())); + } + @Test public void defineDiskForDefaultPoolTypeSkipsForceDiskController() { Map details = new HashMap<>(); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java index 74090723331a..f918d85b2a06 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckConvertInstanceCommandWrapperTest.java @@ -78,6 +78,57 @@ public void testCheckInstanceCommand_vddkSuccess() { assertTrue(answer.getResult()); } + @Test + public void testCheckInstanceCommand_vddkDirectRbdSuccess() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkRbdDirectImportSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertTrue(answer.getResult()); + } + + @Test + public void testCheckInstanceCommand_vddkDirectRbdFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkRbdDirectImportSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(StringUtils.isNotBlank(answer.getDetails())); + } + + @Test + public void testCheckInstanceCommand_vddkInPlaceFinalizationSuccess() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkInPlaceFinalizationSupport()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVirtV2vInPlace()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertTrue(answer.getResult()); + } + + @Test + public void testCheckInstanceCommand_vddkInPlaceFinalizationFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.isCheckVddkInPlaceFinalizationSupport()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVirtV2vInPlace()).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(StringUtils.isNotBlank(answer.getDetails())); + } + @Test public void testCheckInstanceCommand_vddkFailure() { Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); @@ -89,4 +140,18 @@ public void testCheckInstanceCommand_vddkFailure() { assertFalse(answer.getResult()); assertTrue(StringUtils.isNotBlank(answer.getDetails())); } + + @Test + public void testCheckInstanceCommand_windowsGuestConversionFailure() { + Mockito.when(checkConvertInstanceCommandMock.isUseVddk()).thenReturn(true); + Mockito.when(checkConvertInstanceCommandMock.getVddkLibDir()).thenReturn("/opt/vmware-vddk/vmware-vix-disklib-distrib"); + Mockito.when(checkConvertInstanceCommandMock.getCheckWindowsGuestConversionSupport()).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddk("/opt/vmware-vddk/vmware-vix-disklib-distrib")).thenReturn(true); + Mockito.when(libvirtComputingResourceMock.hostSupportsWindowsGuestConversion()).thenReturn(false); + + Answer answer = checkConvertInstanceCommandWrapper.execute(checkConvertInstanceCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("virtio-win")); + } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckGuestAgentLivenessCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckGuestAgentLivenessCommandWrapperTest.java new file mode 100644 index 000000000000..9d603e0753c2 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckGuestAgentLivenessCommandWrapperTest.java @@ -0,0 +1,236 @@ +// 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.libvirt.Connect; +import org.libvirt.Domain; +import org.libvirt.DomainInfo; +import org.libvirt.DomainInfo.DomainState; +import org.libvirt.LibvirtException; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.api.CheckGuestAgentLivenessAnswer; +import com.cloud.agent.api.CheckGuestAgentLivenessCommand; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtCheckGuestAgentLivenessCommandWrapperTest { + + private static final String VM_NAME = "i-2-3-VM"; + + @Mock + private LibvirtComputingResource libvirtComputingResource; + @Mock + private LibvirtUtilitiesHelper libvirtUtilitiesHelper; + @Mock + private Connect conn; + @Mock + private Domain domain; + + private LibvirtCheckGuestAgentLivenessCommandWrapper wrapper; + private CheckGuestAgentLivenessCommand command; + + @Before + public void setUp() throws LibvirtException { + wrapper = new LibvirtCheckGuestAgentLivenessCommandWrapper(); + command = new CheckGuestAgentLivenessCommand(VM_NAME); + + when(libvirtComputingResource.getLibvirtUtilitiesHelper()).thenReturn(libvirtUtilitiesHelper); + when(libvirtUtilitiesHelper.getConnection()).thenReturn(conn); + } + + private void mockRunningDomain() throws LibvirtException { + when(libvirtComputingResource.getDomain(conn, VM_NAME)).thenReturn(domain); + DomainInfo domainInfo = new DomainInfo(); + domainInfo.state = DomainState.VIR_DOMAIN_RUNNING; + when(domain.getInfo()).thenReturn(domainInfo); + } + + @Test + public void domainNotFoundIsNotAlive() throws LibvirtException { + when(libvirtComputingResource.getDomain(conn, VM_NAME)).thenReturn(null); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("was not found")); + } + + @Test + public void domainNotRunningIsNotAlive() throws LibvirtException { + when(libvirtComputingResource.getDomain(conn, VM_NAME)).thenReturn(domain); + DomainInfo domainInfo = new DomainInfo(); + domainInfo.state = DomainState.VIR_DOMAIN_PAUSED; + when(domain.getInfo()).thenReturn(domainInfo); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("VIR_DOMAIN_PAUSED")); + verify(domain, org.mockito.Mockito.never()).qemuAgentCommand(anyString(), anyInt(), anyInt()); + } + + @Test + public void libvirtExceptionOnConnectionIsNotAlive() throws LibvirtException { + LibvirtException libvirtException = mock(LibvirtException.class); + when(libvirtException.getMessage()).thenReturn("connection refused"); + when(libvirtUtilitiesHelper.getConnection()).thenThrow(libvirtException); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("connection refused")); + } + + @Test + public void positiveJsonReturnIsAlive() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"return\": {}}"); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertTrue(answer.getResult()); + } + + @Test + public void errorFieldInJsonReturnIsNotAlive() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"return\": {}, \"error\": {\"desc\": \"timeout\"}}"); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + } + + @Test + public void nonJsonObjectReturnIsNotAlive() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("[]"); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + } + + @Test + public void missingReturnFieldIsNotAlive() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"foo\": \"bar\"}"); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + } + + @Test + public void invalidJsonIsNotAliveAndMessageIsAbbreviated() throws LibvirtException { + mockRunningDomain(); + String garbage = "{\"return\": \"" + "x".repeat(300); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn(garbage); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("invalid JSON")); + assertTrue(answer.getDetails().endsWith("...")); + assertTrue(answer.getDetails().length() < garbage.length()); + } + + @Test + public void emptyResponseIsNotAlive() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn(" "); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + assertTrue(answer.getDetails().contains("empty response")); + } + + @Test + public void nullResponseIsNotAlive() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn(null); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertFalse(answer.getResult()); + } + + @Test + public void zeroWaitIsFlooredToMinimumTimeoutSeconds() throws LibvirtException { + mockRunningDomain(); + command.setWait(0); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"return\": {}}"); + + wrapper.execute(command, libvirtComputingResource); + + ArgumentCaptor timeoutCaptor = ArgumentCaptor.forClass(Integer.class); + verify(domain).qemuAgentCommand(anyString(), timeoutCaptor.capture(), anyInt()); + assertEquals(Integer.valueOf(1), timeoutCaptor.getValue()); + } + + @Test + public void positiveWaitIsUsedAsTimeoutSeconds() throws LibvirtException { + mockRunningDomain(); + command.setWait(10); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"return\": {}}"); + + wrapper.execute(command, libvirtComputingResource); + + ArgumentCaptor timeoutCaptor = ArgumentCaptor.forClass(Integer.class); + verify(domain).qemuAgentCommand(anyString(), timeoutCaptor.capture(), anyInt()); + assertEquals(Integer.valueOf(10), timeoutCaptor.getValue()); + } + + @Test + public void domainIsFreedAfterExecution() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"return\": {}}"); + + wrapper.execute(command, libvirtComputingResource); + + verify(domain).free(); + } + + @Test + public void domainFreeExceptionIsSwallowed() throws LibvirtException { + mockRunningDomain(); + when(domain.qemuAgentCommand(anyString(), anyInt(), anyInt())).thenReturn("{\"return\": {}}"); + org.mockito.Mockito.doThrow(mock(LibvirtException.class)).when(domain).free(); + + CheckGuestAgentLivenessAnswer answer = (CheckGuestAgentLivenessAnswer) wrapper.execute(command, libvirtComputingResource); + + assertTrue(answer.getResult()); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java new file mode 100644 index 000000000000..5c113bdb4915 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckVolumeCommandWrapperTest.java @@ -0,0 +1,238 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CheckVolumeAnswer; +import com.cloud.agent.api.CheckVolumeCommand; +import com.cloud.agent.api.to.StorageFilerTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; +import org.apache.cloudstack.storage.volume.VolumeOnStorageTO; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.HashMap; +import java.util.Map; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirtCheckVolumeCommandWrapperTest { + + @Mock + LibvirtComputingResource libvirtComputingResource; + @Mock + KVMStoragePoolManager storagePoolMgr; + @Mock + KVMStoragePool storagePool; + @Mock + StorageFilerTO storageFilerTO; + @Mock + KVMPhysicalDisk disk; + + @Spy + LibvirtCheckVolumeCommandWrapper wrapper = new LibvirtCheckVolumeCommandWrapper(); + + MockedConstruction qemuImg; + + private final String poolUuid = "pool-uuid"; + private final String srcFile = "rbd-volume-uuid"; + private final long virtualSize = 21474836480L; // 20 GiB + + private Map qemuInfo; + + @Before + public void setUp() { + qemuInfo = new HashMap<>(); + qemuInfo.put(QemuImg.FILE_FORMAT, "raw"); + Mockito.when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolMgr); + } + + @After + public void tearDown() { + if (qemuImg != null) { + qemuImg.close(); + } + } + + private void mockRbdPool() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + Mockito.when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.RBD, poolUuid)).thenReturn(storagePool); + Mockito.when(storagePool.getType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storagePool.getPhysicalDisk(srcFile)).thenReturn(disk); + Mockito.when(disk.getPath()).thenReturn(srcFile); + Mockito.when(disk.getFormat()).thenReturn(QemuImg.PhysicalDiskFormat.RAW); + // values consumed by KVMPhysicalDisk.RBDStringBuilder when building the RBD URI + Mockito.when(storagePool.getSourceHost()).thenReturn("10.0.0.10"); + Mockito.when(storagePool.getSourcePort()).thenReturn(6789); + Mockito.when(storagePool.getAuthUserName()).thenReturn(null); + Mockito.when(storagePool.getDetails()).thenReturn(null); + } + + private void mockLinstorPool() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.Linstor); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + Mockito.when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.Linstor, poolUuid)).thenReturn(storagePool); + + } + + private CheckVolumeCommand buildCommand() { + CheckVolumeCommand command = new CheckVolumeCommand(); + command.setSrcFile(srcFile); + command.setStorageFilerTO(storageFilerTO); + return command; + } + + @Test + public void testRbdVolumeReturnsSuccessWithVirtualSizeAndDetails() { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue(answer.getResult()); + CheckVolumeAnswer checkVolumeAnswer = (CheckVolumeAnswer) answer; + Assert.assertEquals(virtualSize, checkVolumeAnswer.getSize()); + Map details = checkVolumeAnswer.getVolumeDetails(); + Assert.assertNotNull(details); + Assert.assertEquals("raw", details.get(VolumeOnStorageTO.Detail.FILE_FORMAT)); + Assert.assertEquals("false", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeInspectedThroughRbdUri() throws Exception { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(qemuInfo)); + + wrapper.execute(buildCommand(), libvirtComputingResource); + + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(QemuImgFile.class); + Mockito.verify(qemuImg.constructed().get(0), Mockito.atLeastOnce()) + .info(fileCaptor.capture(), Mockito.anyBoolean()); + Assert.assertTrue("qemu-img should be pointed at the RBD URI", + fileCaptor.getValue().getFileName().startsWith("rbd:" + srcFile)); + } + + @Test + public void testLinstorVolumeUsesControllerMetadataWithoutConnecting() throws Exception { + mockLinstorPool(); + VolumeOnStorageTO volume = new VolumeOnStorageTO(null, srcFile, srcFile, "/dev/drbd/by-res/cs-x/0", "RAW", virtualSize, virtualSize); + volume.addDetail(VolumeOnStorageTO.Detail.IS_LOCKED, "false"); + Mockito.when(storagePool.getVolumesForImport(srcFile)).thenReturn(java.util.Collections.singletonList(volume)); + qemuImg = Mockito.mockConstruction(QemuImg.class); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer.getResult()); + Assert.assertEquals(virtualSize, ((CheckVolumeAnswer) answer).getSize()); + Assert.assertTrue(qemuImg.constructed().isEmpty()); + Mockito.verify(storagePoolMgr, Mockito.never()).connectPhysicalDisk(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.verify(storagePoolMgr, Mockito.never()).disconnectPhysicalDisk(Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.verify(storagePool, Mockito.never()).getPhysicalDisk(Mockito.any()); + } + + @Test + public void testLinstorVolumeInUseOnAnotherNodeIsReportedLocked() { + mockLinstorPool(); + VolumeOnStorageTO volume = new VolumeOnStorageTO(null, srcFile, srcFile, "", "RAW", virtualSize, virtualSize); + volume.addDetail(VolumeOnStorageTO.Detail.IS_LOCKED, "true"); + Mockito.when(storagePool.getVolumesForImport(srcFile)).thenReturn(java.util.Collections.singletonList(volume)); + + CheckVolumeAnswer answer = (CheckVolumeAnswer) wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertEquals("true", answer.getVolumeDetails().get(VolumeOnStorageTO.Detail.IS_LOCKED)); + Mockito.verify(storagePoolMgr, Mockito.never()).disconnectPhysicalDisk(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + public void testLinstorInvalidGroupReturnsActionableFailure() { + mockLinstorPool(); + Mockito.when(storagePool.getVolumesForImport(srcFile)).thenThrow( + new com.cloud.utils.exception.CloudRuntimeException("Resource group mismatch: rg-ssd vs rg-hdd")); + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("rg-ssd")); + Mockito.verify(storagePoolMgr, Mockito.never()).connectPhysicalDisk(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + public void testRbdVolumeReportsLockedWhenInfoProbeFails() { + mockRbdPool(); + Mockito.when(disk.getVirtualSize()).thenReturn(virtualSize); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> { + // secure=true uses force-share and succeeds; secure=false is the lock probe + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.eq(true))).thenReturn(qemuInfo); + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.eq(false))).thenReturn(null); + }); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertTrue(answer.getResult()); + Map details = ((CheckVolumeAnswer) answer).getVolumeDetails(); + Assert.assertEquals("true", details.get(VolumeOnStorageTO.Detail.IS_LOCKED)); + } + + @Test + public void testRbdVolumeWithoutInfoReturnsInvalid() { + mockRbdPool(); + qemuImg = Mockito.mockConstruction(QemuImg.class, (mock, context) -> + Mockito.when(mock.info(Mockito.any(QemuImgFile.class), Mockito.anyBoolean())).thenReturn(null)); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertTrue(answer instanceof CheckVolumeAnswer); + Assert.assertFalse(answer.getResult()); + CheckVolumeAnswer checkVolumeAnswer = (CheckVolumeAnswer) answer; + Assert.assertEquals(0, checkVolumeAnswer.getSize()); + Assert.assertNull(checkVolumeAnswer.getVolumeDetails()); + } + + @Test + public void testUnsupportedStoragePoolReturnsPlainAnswer() { + Mockito.when(storageFilerTO.getType()).thenReturn(Storage.StoragePoolType.PowerFlex); + Mockito.when(storageFilerTO.getUuid()).thenReturn(poolUuid); + + Answer answer = wrapper.execute(buildCommand(), libvirtComputingResource); + + Assert.assertFalse(answer instanceof CheckVolumeAnswer); + Assert.assertFalse(answer.getResult()); + Assert.assertEquals("Unsupported Storage Pool", answer.getDetails()); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java index c30fa2f49482..810d3028ea97 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConvertInstanceCommandWrapperTest.java @@ -19,6 +19,7 @@ package com.cloud.hypervisor.kvm.resource.wrapper; import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.UUID; @@ -44,6 +45,7 @@ import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.storage.Storage; import com.cloud.utils.script.Script; @RunWith(MockitoJUnitRunner.class) @@ -151,6 +153,26 @@ public void testExecuteConvertUnsupportedHypervisors() { Assert.assertFalse(answer.getResult()); } + @Test + public void testExecuteDirectRbdVddkFailsWhenHostLacksDirectRbdSupport() { + RemoteInstanceTO remoteInstanceTO = getRemoteInstanceTO(Hypervisor.HypervisorType.VMware); + ConvertInstanceCommand cmd = getConvertInstanceCommand(remoteInstanceTO, Hypervisor.HypervisorType.KVM, false); + Mockito.when(cmd.isUseVddk()).thenReturn(true); + Mockito.when(cmd.getVddkLibDir()).thenReturn("/opt/vddk"); + Mockito.when(cmd.getConversionTemporaryLocation()).thenReturn(primaryDataStore); + Mockito.when(primaryDataStore.getPoolType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(primaryDataStore.getUuid()).thenReturn("rbd-pool-uuid"); + Mockito.when(storagePoolManager.getStoragePool(Storage.StoragePoolType.RBD, "rbd-pool-uuid")).thenReturn(destinationPool); + Mockito.when(destinationPool.getLocalPath()).thenReturn("/rbd"); + Mockito.when(libvirtComputingResourceMock.getVddkLibDir()).thenReturn("/opt/vddk"); + Mockito.when(libvirtComputingResourceMock.hostSupportsVddkRbdDirectImport("/opt/vddk")).thenReturn(false); + + Answer answer = convertInstanceCommandWrapper.execute(cmd, libvirtComputingResourceMock); + + Assert.assertFalse(answer.getResult()); + Assert.assertTrue(answer.getDetails().contains("Direct RBD VDDK import requires")); + } + @Test public void testExecuteConvertFailure() { RemoteInstanceTO remoteInstanceTO = getRemoteInstanceTO(Hypervisor.HypervisorType.VMware); @@ -169,7 +191,7 @@ public void testExecuteConvertFailure() { Answer answer = convertInstanceCommandWrapper.execute(cmd, libvirtComputingResourceMock); Assert.assertFalse(answer.getResult()); Mockito.verify(convertInstanceCommandWrapper).performInstanceConversion(Mockito.anyString(), Mockito.anyString(), - Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), Mockito.anyBoolean(), Mockito.nullable(String.class), Mockito.any(LibvirtComputingResource.class)); + Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), Mockito.anyBoolean(), Mockito.nullable(String.class), Mockito.any(LibvirtComputingResource.class), Mockito.anyBoolean()); } } @@ -214,7 +236,7 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredLibguestfsBacken .thenReturn(true); boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( - remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "libvirt", null, null, 1000L, false, null, "tmp-uuid", "-ip"); + remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "libvirt", null, null, 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertTrue(result); Script scriptMock = ignored.constructed().get(0); @@ -249,7 +271,7 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredTransportsOrder( .thenReturn(true); boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( - remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", "nbd:nbdssl", null, 1000L, false, null, "tmp-uuid", "-ip"); + remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", "nbd:nbdssl", null, 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertTrue(result); Script scriptMock = ignored.constructed().get(0); @@ -276,7 +298,7 @@ public void testPerformInstanceConversionUsingVddkFailsWhenThumbprintUnavailable .thenReturn(true); boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( - remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", null, null, 1000L, false, null, "tmp-uuid", "-ip"); + remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", null, null, 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertFalse(result); } @@ -304,7 +326,7 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredThumbprintFromAg boolean result = convertInstanceCommandWrapper.performInstanceConversionUsingVddk( remoteInstanceTO, vmName, "/tmp/convert", "/opt/vddk", "direct", null, - "AA:BB:CC:DD:EE", 1000L, false, null, "tmp-uuid", "-ip"); + "AA:BB:CC:DD:EE", 1000L, false, null, "tmp-uuid", "-ip", false); Assert.assertTrue(result); Script scriptMock = ignored.constructed().get(0); @@ -313,4 +335,95 @@ public void testPerformInstanceConversionUsingVddkUsesConfiguredThumbprintFromAg .getVcenterThumbprint(Mockito.anyString(), Mockito.anyLong(), Mockito.anyString()); } } + + @Test + public void testBuildDirectRbdNbdLibvirtXmlUsesLocalhostNbdSourcesWithoutSecrets() { + // virt-v2v's "-i libvirtxml" input runs without a libvirt connection: a native + // rbd network disk with a auth reference attaches zero drives there, + // so the finalization XML must use localhost qemu-nbd bridge endpoints. + String xml = convertInstanceCommandWrapper.buildDirectRbdNbdLibvirtXml("tmp-uuid", List.of(10809, 10810)); + + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertTrue(xml, xml.contains("")); + Assert.assertFalse(xml, xml.contains("protocol='rbd'")); + Assert.assertFalse(xml, xml.contains(" filesMock = Mockito.mockStatic(Files.class); + MockedConstruction + + diff --git a/ui/src/views/compute/EditVM.vue b/ui/src/views/compute/EditVM.vue index 9f08a3368a2b..e1dd8b6727db 100644 --- a/ui/src/views/compute/EditVM.vue +++ b/ui/src/views/compute/EditVM.vue @@ -80,7 +80,8 @@ @@ -338,6 +339,10 @@ export default { fetchInstanceGroups () { this.groups.loading = true this.groups.opts = [] + this.groups.opts.push({ + id: '', + value: '' + }) const params = { domainid: this.$store.getters.userInfo.domainid, listall: true @@ -411,8 +416,8 @@ export default { if (values.haenable !== undefined) { params.haenable = values.haenable } - if (values.group && values.group.length > 0) { - params.group = values.group + if ((values.group || '') !== (this.resource.group || '')) { + params.group = values.group || '' } if (values.userdata && values.userdata.length > 0) { params.userdata = this.$toBase64AndURIEncoded(values.userdata) diff --git a/ui/src/views/compute/InstanceBootGroupMembersTab.vue b/ui/src/views/compute/InstanceBootGroupMembersTab.vue new file mode 100644 index 000000000000..d4253c6033a6 --- /dev/null +++ b/ui/src/views/compute/InstanceBootGroupMembersTab.vue @@ -0,0 +1,352 @@ +// 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. + + + + diff --git a/ui/src/views/compute/InstanceBootGroupReadinessRulesModal.vue b/ui/src/views/compute/InstanceBootGroupReadinessRulesModal.vue new file mode 100644 index 000000000000..de40fc9908dc --- /dev/null +++ b/ui/src/views/compute/InstanceBootGroupReadinessRulesModal.vue @@ -0,0 +1,413 @@ +// 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. + + + + + + diff --git a/ui/src/views/compute/UpdateInstanceBootGroupMemberOrder.vue b/ui/src/views/compute/UpdateInstanceBootGroupMemberOrder.vue new file mode 100644 index 000000000000..ff9d7d09d6b7 --- /dev/null +++ b/ui/src/views/compute/UpdateInstanceBootGroupMemberOrder.vue @@ -0,0 +1,116 @@ +// 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. + + + + + + diff --git a/ui/src/views/infra/HostInfo.vue b/ui/src/views/infra/HostInfo.vue index cb16a5a8bbee..111de8f131d5 100644 --- a/ui/src/views/infra/HostInfo.vue +++ b/ui/src/views/infra/HostInfo.vue @@ -48,7 +48,7 @@ - +
{{ $t('label.instance.conversion.support') }}
@@ -64,7 +64,23 @@
- + +
+ {{ $t('label.host.virtv2v.inplace.support') }} +
+ {{ host.details['host.virtv2v.inplace.support'] }} +
+
+
+ +
+ {{ $t('label.host.virtv2v.inplace.version') }} +
+ {{ host.details['host.virtv2v.inplace.version'] }} +
+
+
+
{{ $t('label.host.vddk.support') }}
@@ -80,6 +96,70 @@
+ +
+ {{ $t('label.host.vmware.cbt.data.copy.support') }} +
+ {{ host.details['host.vddk.blockcopy.support'] }} +
+
+
+ +
+ {{ $t('label.host.vddk.blockcopy.inplace.finalization.support') }} +
+ {{ host.details['host.vddk.blockcopy.inplace.finalization.support'] }} +
+
+
+ +
+ {{ $t('label.host.vddk.blockcopy.rbd.support') }} +
+ {{ host.details['host.vddk.blockcopy.rbd.support'] }} +
+
+
+ +
+ {{ $t('label.host.vddk.rbd.direct.import.support') }} +
+ {{ host.details['host.vddk.rbd.direct.import.support'] }} +
+
+
+ +
+ {{ $t('label.host.qemu.img.version') }} +
+ {{ host.details['host.qemu.img.version'] }} +
+
+
+ +
+ {{ $t('label.host.qemu.io.version') }} +
+ {{ host.details['host.qemu.io.version'] }} +
+
+
+ +
+ {{ $t('label.host.qemu.nbd.version') }} +
+ {{ host.details['host.qemu.nbd.version'] }} +
+
+
+ +
+ {{ $t('label.host.qemu.rbd.support') }} +
+ {{ host.details['host.qemu.rbd.support'] }} +
+
+
{{ $t('label.host.ovftool.version') }} @@ -242,6 +322,10 @@ export default { } }, methods: { + hasHostDetail (key) { + return this.host.details && Object.prototype.hasOwnProperty.call(this.host.details, key) && + this.host.details[key] !== null && this.host.details[key] !== '' + }, fetchData () { this.fetchLoading = true getAPI('listHosts', { id: this.resource.id }).then(json => { diff --git a/ui/src/views/infra/zone/AdvancedGuestTrafficForm.vue b/ui/src/views/infra/zone/AdvancedGuestTrafficForm.vue index caf690efcc93..b5aa96fc1705 100644 --- a/ui/src/views/infra/zone/AdvancedGuestTrafficForm.vue +++ b/ui/src/views/infra/zone/AdvancedGuestTrafficForm.vue @@ -114,6 +114,10 @@ export default { min: 4096, max: 16777214 }, + Netris: { + min: 4096, + max: 16777214 + }, GRE: { min: 0, max: 4294967295 diff --git a/ui/src/views/network/VpcTab.vue b/ui/src/views/network/VpcTab.vue index 1a73342d5766..4e4d59c31e56 100644 --- a/ui/src/views/network/VpcTab.vue +++ b/ui/src/views/network/VpcTab.vue @@ -252,7 +252,7 @@ - + - + { - const vpnGateways = response.listvpngatewaysresponse.vpngateway || [] - for (const vpnGateway of vpnGateways) { - if (vpnGateway.publicip === this.resource.ipaddress) { - this.vpnGateway = vpnGateway - } + getAPI('listVPCs', { id: this.resource.vpcid, listAll: true }).then(vpcResponse => { + const vpc = vpcResponse.listvpcsresponse?.vpc?.[0] + if (vpc?.service?.some(s => s.name === 'Vpn')) { + this.vpnGatewayEnabled = true + getAPI('listVpnGateways', { + vpcid: this.resource.vpcid, + listAll: true + }).then(response => { + const vpnGateways = response.listvpngatewaysresponse.vpngateway || [] + for (const vpnGateway of vpnGateways) { + if (vpnGateway.publicip === this.resource.ipaddress) { + this.vpnGateway = vpnGateway + } + } + }).catch(error => { + console.log(error) + this.$notifyError(error) + }) } }).catch(error => { console.log(error) diff --git a/ui/src/views/offering/AddNetworkOffering.vue b/ui/src/views/offering/AddNetworkOffering.vue index 4399bce91c42..95d170412b89 100644 --- a/ui/src/views/offering/AddNetworkOffering.vue +++ b/ui/src/views/offering/AddNetworkOffering.vue @@ -312,7 +312,7 @@
- + - + @@ -375,8 +375,8 @@ name="vmautoscalingcapability" ref="vmautoscalingcapability" :label="$t('label.supportsvmautoscaling')" - v-if="lbServiceChecked && ['Netscaler', 'VirtualRouter', 'VpcVirtualRouter'].includes(lbServiceProvider)"> - + v-if="lbServiceChecked && ['Netscaler', 'VirtualRouter', 'VpcVirtualRouter', 'Netris'].includes(lbServiceProvider)"> + { + return { + ...service, + provider: (service.provider || []).map(provider => ({ ...provider })) + } + }) }).finally(() => { this.supportedServiceLoading = false this.updateSupportedServices() @@ -923,7 +929,12 @@ export default { }, updateSupportedServices () { this.supportedServiceLoading = true - var supportedServices = this.supportedServices + var supportedServices = (this.supportedSvcs.length > 0 ? this.supportedSvcs : this.supportedServices).map(service => { + return { + ...service, + provider: (service.provider || []).map(provider => ({ ...provider })) + } + }) var self = this if (this.provider !== 'NSX' && this.provider !== 'Netris') { if (this.networkmode === 'ROUTED' && this.guestType === 'isolated') { @@ -958,12 +969,10 @@ export default { } }) setTimeout(() => { - self.supportedSvcs = self.supportedServices self.supportedServices = supportedServices self.supportedServiceLoading = false }, 50) } else { - supportedServices = this.supportedSvcs supportedServices = supportedServices.filter(svc => { if (this.provider === 'NSX') { return Object.keys(this.nsxSupportedServicesMap).includes(svc.name) @@ -987,88 +996,93 @@ export default { } return svc }) - self.supportedSvcs = self.supportedServices + + const externalSelectedProviders = {} + supportedServices.forEach(svc => { + const providerName = svc.provider?.[0]?.name + if (providerName) { + externalSelectedProviders[svc.name] = providerName + } + }) + this.selectedServiceProviderMap = externalSelectedProviders + this.sourceNatServiceChecked = 'SourceNat' in externalSelectedProviders + this.lbServiceChecked = 'Lb' in externalSelectedProviders + this.lbServiceProvider = this.lbServiceChecked ? externalSelectedProviders.Lb : '' + if (this.lbServiceProvider === 'Netris') { + this.form.vmautoscalingcapability = true + } + this.staticNatServiceChecked = 'StaticNat' in externalSelectedProviders + this.staticNatServiceProvider = this.staticNatServiceChecked ? externalSelectedProviders.StaticNat : '' + this.connectivityServiceChecked = 'Connectivity' in externalSelectedProviders + this.firewallServiceChecked = 'Firewall' in externalSelectedProviders + this.firewallServiceProvider = this.firewallServiceChecked ? externalSelectedProviders.Firewall : '' + const selectedProviders = Object.values(externalSelectedProviders) + this.isVirtualRouterForAtLeastOneService = selectedProviders.includes('VirtualRouter') + this.isVpcVirtualRouterForAtLeastOneService = selectedProviders.includes('VpcVirtualRouter') + if ((this.isVirtualRouterForAtLeastOneService || this.isVpcVirtualRouterForAtLeastOneService) && + this.serviceOfferings.length === 0) { + this.fetchServiceOfferingData() + } + self.supportedServices = supportedServices self.supportedServiceLoading = false } }, + refreshExternalSupportedServicesMap () { + const selectedProvider = this.form.provider || this.provider + if (selectedProvider !== 'NSX' && selectedProvider !== 'Netris') { + return + } + + const selectedNetworkMode = this.form.networkmode || this.networkmode + const effectiveNetworkMode = selectedProvider === 'Netris' && !selectedNetworkMode ? 'NATTED' : selectedNetworkMode + const externalProvider = selectedProvider === 'NSX' ? this.NSX : this.Netris + const isNsxProvider = selectedProvider === 'NSX' + const commonServices = { + Dhcp: this.forVpc ? this.VPCVR : this.VR, + Dns: this.forVpc ? this.VPCVR : this.VR, + UserData: this.forVpc ? this.VPCVR : this.VR, + ...(this.forVpc && { NetworkACL: externalProvider }), + ...(!this.forVpc && { Firewall: externalProvider }) + } + + const nattedServices = { + SourceNat: externalProvider, + StaticNat: externalProvider, + PortForwarding: externalProvider, + ...(!isNsxProvider && { Vpn: this.forVpc ? this.VPCVR : this.VR }), + ...((!isNsxProvider || this.form.nsxsupportlb) && { Lb: externalProvider }) + } + + const serviceMap = { + ...commonServices, + ...(effectiveNetworkMode === 'NATTED' && nattedServices) + } + + if (isNsxProvider) { + this.nsxSupportedServicesMap = serviceMap + } else { + this.netrisSupportedServicesMap = serviceMap + } + }, handleForVpcChange (forVpc) { this.forVpc = forVpc - if (this.provider === 'NSX') { - this.nsxSupportedServicesMap = { - Dhcp: this.forVpc ? this.VPCVR : this.VR, - Dns: this.forVpc ? this.VPCVR : this.VR, - UserData: this.forVpc ? this.VPCVR : this.VR, - ...(this.networkmode === 'NATTED' && { - SourceNat: this.NSX, - StaticNat: this.NSX, - PortForwarding: this.NSX, - Lb: this.NSX - }), - ...(forVpc && { NetworkACL: this.NSX }), - ...(!forVpc && { Firewall: this.NSX }) - } - } else if (this.provider === 'Netris') { - this.netrisSupportedServicesMap = { - Dhcp: this.forVpc ? this.VPCVR : this.VR, - Dns: this.forVpc ? this.VPCVR : this.VR, - UserData: this.forVpc ? this.VPCVR : this.VR, - ...(this.networkmode === 'NATTED' && { - SourceNat: this.Netris, - StaticNat: this.Netris, - PortForwarding: this.Netris, - Lb: this.Netris - }), - ...(forVpc && { NetworkACL: this.Netris }), - ...(!forVpc && { Firewall: this.Netris }) - } - } + this.refreshExternalSupportedServicesMap() this.updateSupportedServices() }, handleProviderChange (provider) { this.provider = provider - if (this.provider === 'NSX') { - this.nsxSupportedServicesMap = { - Dhcp: this.forVpc ? this.VPCVR : this.VR, - Dns: this.forVpc ? this.VPCVR : this.VR, - UserData: this.forVpc ? this.VPCVR : this.VR, - ...(this.networkmode === 'NATTED' && { - SourceNat: this.NSX, - StaticNat: this.NSX, - PortForwarding: this.NSX, - Lb: this.NSX - }), - ...(this.forVpc && { NetworkACL: this.NSX }), - ...(!this.forVpc && { Firewall: this.NSX }) - } - } else if (this.provider === 'Netris') { - this.netrisSupportedServicesMap = { - Dhcp: this.forVpc ? this.VPCVR : this.VR, - Dns: this.forVpc ? this.VPCVR : this.VR, - UserData: this.forVpc ? this.VPCVR : this.VR, - ...(this.networkmode === 'NATTED' && { - SourceNat: this.Netris, - StaticNat: this.Netris, - PortForwarding: this.Netris, - Lb: this.Netris - }), - ...(this.forVpc && { NetworkACL: this.Netris }), - ...(!this.forVpc && { Firewall: this.Netris }) - } - } + this.refreshExternalSupportedServicesMap() this.fetchSupportedServiceData() }, handleForNetworkModeChange (networkMode) { this.networkmode = networkMode + this.refreshExternalSupportedServicesMap() this.fetchSupportedServiceData() }, handleNsxLbService (supportLb) { - if (!supportLb && 'Lb' in this.nsxSupportedServicesMap) { - delete this.nsxSupportedServicesMap.Lb - } - if (supportLb && !('Lb' in this.nsxSupportedServicesMap)) { - this.nsxSupportedServicesMap.Lb = this.NSX - } + this.form.nsxsupportlb = supportLb + this.refreshExternalSupportedServicesMap() this.fetchSupportedServiceData() }, handleLbTypeChange (lbType) { @@ -1083,6 +1097,10 @@ export default { this.fetchRegisteredServicePackageData() if (provider != null & provider !== undefined) { this.lbServiceProvider = provider + this.lbServiceChecked = true + if (provider === 'Netris') { + this.form.vmautoscalingcapability = true + } } } else { this.lbServiceProvider = '' @@ -1090,7 +1108,7 @@ export default { this.lbServiceChecked = checked } else if (service === 'StaticNat') { this.staticNatServiceChecked = checked - if (checked && provider != null & provider !== undefined) { + if (checked && provider != null && provider !== undefined) { this.staticNatServiceProvider = provider } else { this.staticNatServiceProvider = '' @@ -1099,13 +1117,13 @@ export default { this.connectivityServiceChecked = checked } else if (service === 'Firewall') { this.firewallServiceChecked = checked - if (checked && provider != null & provider !== undefined) { - this.staticNatServiceProvider = provider + if (checked && provider != null && provider !== undefined) { + this.firewallServiceProvider = provider } else { - this.staticNatServiceProvider = '' + this.firewallServiceProvider = '' } } - if (checked && provider != null & provider !== undefined) { + if (checked && provider != null && provider !== undefined) { this.selectedServiceProviderMap[service] = provider } else { delete this.selectedServiceProviderMap[service] diff --git a/ui/src/views/offering/AddVpcOffering.vue b/ui/src/views/offering/AddVpcOffering.vue index 780da6638f8e..ca86a2054e3c 100644 --- a/ui/src/views/offering/AddVpcOffering.vue +++ b/ui/src/views/offering/AddVpcOffering.vue @@ -167,7 +167,7 @@ - + @@ -306,6 +306,7 @@ export default { isVpcVirtualRouterForAtLeastOneService: false, connectivityServiceChecked: false, sourceNatServiceChecked: false, + gatewayServiceChecked: false, selectedServiceProviderMap: {}, ipv6NetworkOfferingEnabled: false, routedNetworkEnabled: false, @@ -453,6 +454,8 @@ export default { }, fetchSupportedServiceData () { var services = [] + this.sourceNatServiceChecked = false + this.gatewayServiceChecked = false if (this.provider === 'NSX') { services.push({ name: 'Dhcp', @@ -539,6 +542,11 @@ export default { enabled: true, provider: [{ name: 'VpcVirtualRouter' }] }) + services.push({ + name: 'Vpn', + enabled: true, + provider: [{ name: 'VpcVirtualRouter' }] + }) } else { this.supportedServices = [] this.supportedServiceLoading = true @@ -602,6 +610,13 @@ export default { }) } } + + if (this.Netris.name === this.form.provider && services.filter(svc => svc.name === 'SourceNat' && svc.provider[0].name === this.Netris.name).length > 0) { + this.sourceNatServiceChecked = true + } + if (this.Netris.name === this.form.provider && services.filter(svc => svc.name === 'Gateway').length > 0) { + this.gatewayServiceChecked = true + } for (var i in services) { services[i].description = services[i].name } @@ -617,6 +632,10 @@ export default { this.form.nsxsupportlb = true this.handleNsxLbService(true) } + if (this.provider === 'Netris') { + this.form.networkmode = 'NATTED' + this.networkmode = 'NATTED' + } this.fetchSupportedServiceData() }, handleNsxLbService (supportLb) { diff --git a/ui/src/views/tools/ImportUnmanagedInstance.vue b/ui/src/views/tools/ImportUnmanagedInstance.vue index ffa0d9344335..14c06288f308 100644 --- a/ui/src/views/tools/ImportUnmanagedInstance.vue +++ b/ui/src/views/tools/ImportUnmanagedInstance.vue @@ -35,7 +35,7 @@ @finish="handleSubmit" layout="vertical"> - + - + + {{ $t('label.vmware.migration.mode.ovf') }} + {{ $t('label.vmware.migration.mode.vddk') }} + {{ $t('label.vmware.migration.mode.cbt') }} + + + diff --git a/ui/tests/unit/utils/vmware.spec.js b/ui/tests/unit/utils/vmware.spec.js new file mode 100644 index 000000000000..a576e9f2658c --- /dev/null +++ b/ui/tests/unit/utils/vmware.spec.js @@ -0,0 +1,51 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { formatKvmConversionHostName } from '@/utils/vmware' + +const host = { + name: 'qa1-vmwarelab-576a1b2a-mix-host2', + podname: 'Pod', + clustername: 'KVM-Cluster', + instanceconversionsupported: true, + details: { + 'host.virtv2v.version': '2.7.1rhel=9', + 'host.vddk.support': 'true', + 'host.vddk.version': '8', + 'host.vddk.blockcopy.support': 'true', + 'host.qemu.img.version': '7.0.0', + 'host.qemu.nbd.version': 'qemu-nbd 7.0.0 (qemu-kvm-7.0.0-13.el9)' + } +} + +describe('utils/vmware', () => { + describe('formatKvmConversionHostName()', () => { + it('shows a concise CBT conversion host label', () => { + expect(formatKvmConversionHostName(host, 'cbt', 'Yes', 'No')).toBe( + 'qa1-vmwarelab-576a1b2a-mix-host2 [KVM-Cluster] — virt-v2v=2.7.1rhel=9 / VDDK=8 / CBT=Yes') + }) + + it('does not expose low-level QEMU or Pod details in the selector', () => { + const label = formatKvmConversionHostName(host, 'cbt', 'Yes', 'No') + + expect(label).not.toContain('qemu-img') + expect(label).not.toContain('qemu-nbd') + expect(label).not.toContain('Pod') + expect(label).not.toContain('Supported') + }) + }) +}) diff --git a/utils/src/main/java/com/cloud/utils/DateUtil.java b/utils/src/main/java/com/cloud/utils/DateUtil.java index 985a309bba34..631e8a0e833e 100644 --- a/utils/src/main/java/com/cloud/utils/DateUtil.java +++ b/utils/src/main/java/com/cloud/utils/DateUtil.java @@ -19,17 +19,16 @@ package com.cloud.utils; -import java.text.DateFormat; import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.time.LocalDateTime; -import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.YearMonth; import java.util.Calendar; import java.util.Date; import java.util.TimeZone; +import java.util.concurrent.ConcurrentHashMap; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.OffsetDateTime; @@ -49,7 +48,11 @@ public class DateUtil { public static final TimeZone GMT_TIMEZONE = TimeZone.getTimeZone("GMT"); public static final String YYYYMMDD_FORMAT = "yyyyMMddHHmmss"; public static final String ZONED_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"; - private static final DateFormat ZONED_DATETIME_SIMPLE_FORMATTER = new SimpleDateFormat(ZONED_DATETIME_FORMAT); + + private static final DateTimeFormatter OUTPUT_FORMATTER = + DateTimeFormatter.ofPattern(ZONED_DATETIME_FORMAT).withZone(ZoneId.systemDefault()); + + private static final ConcurrentHashMap s_formatterCache = new ConcurrentHashMap<>(); private static final DateTimeFormatter[] parseFormats = new DateTimeFormatter[]{ DateTimeFormatter.ISO_OFFSET_DATE_TIME, @@ -68,6 +71,11 @@ public static Date currentGMTTime() { return new Date(); } + private static DateTimeFormatter getFormatter(String pattern, ZoneId zone) { + String key = pattern + "|" + zone.getId(); + return s_formatterCache.computeIfAbsent(key, k -> DateTimeFormatter.ofPattern(pattern).withZone(zone)); + } + public static Date parseTZDateString(String str) throws ParseException { for (DateTimeFormatter formatter : parseFormats) { try { @@ -85,13 +93,12 @@ public static Date parseDateString(TimeZone tz, String dateString) { } public static Date parseDateString(TimeZone tz, String dateString, String formatString) { - DateFormat df = new SimpleDateFormat(formatString); - df.setTimeZone(tz); - + ZoneId zoneId = tz.toZoneId(); + DateTimeFormatter formatter = getFormatter(formatString, zoneId); try { - return df.parse(dateString); - } catch (ParseException e) { - throw new CloudRuntimeException("why why ", e); + return Date.from(LocalDateTime.parse(dateString, formatter).atZone(zoneId).toInstant()); + } catch (DateTimeParseException e) { + throw new CloudRuntimeException("Failed to parse date string: " + dateString, e); } } @@ -108,21 +115,14 @@ public static String getDateDisplayString(TimeZone tz, Date time, String formatS return null; } - DateFormat df = new SimpleDateFormat(formatString); - df.setTimeZone(tz); - - return df.format(time); + return getFormatter(formatString, tz.toZoneId()).format(time.toInstant()); } public static String getOutputString(Date date) { if (date == null) { return ""; } - String formattedString; - synchronized (ZONED_DATETIME_SIMPLE_FORMATTER) { - formattedString = ZONED_DATETIME_SIMPLE_FORMATTER.format(date); - } - return formattedString; + return OUTPUT_FORMATTER.format(date.toInstant()); } public static Date now() { @@ -155,7 +155,7 @@ public static IntervalType getIntervalType(short type) { /** * Return next run time - * @param intervalType hourly/daily/weekly/monthly + * @param type hourly/daily/weekly/monthly * @param schedule MM[:HH][:DD] format. DD is day of week for weekly and day of month for monthly * @param timezone The timezone in which the schedule string is specified * @param startDate if specified, returns next run time after the specified startDate @@ -177,7 +177,8 @@ public static Date getNextRunTime(IntervalType type, String schedule, String tim int minutes = 0; int hour = 0; int day = 0; - Date execDate = null; + Date execDate; + Date now = new Date(); switch (type) { case HOURLY: @@ -199,7 +200,7 @@ public static Date getNextRunTime(IntervalType type, String schedule, String tim // During testing we use a test clock which runs much faster than the real clock // So startDate and execDate will always be ahead in the future // and we will never increase the time here - if (execDate.before(new Date()) || !execDate.after(startDate)) { + if (execDate.before(now) || !execDate.after(startDate)) { scheduleTime.add(Calendar.HOUR_OF_DAY, 1); } break; @@ -225,7 +226,7 @@ public static Date getNextRunTime(IntervalType type, String schedule, String tim // During testing we use a test clock which runs much faster than the real clock // So startDate and execDate will always be ahead in the future // and we will never increase the time here - if (execDate.before(new Date()) || !execDate.after(startDate)) { + if (execDate.before(now) || !execDate.after(startDate)) { scheduleTime.add(Calendar.DAY_OF_YEAR, 1); } break; @@ -252,10 +253,9 @@ public static Date getNextRunTime(IntervalType type, String schedule, String tim // During testing we use a test clock which runs much faster than the real clock // So startDate and execDate will always be ahead in the future // and we will never increase the time here - if (execDate.before(new Date()) || !execDate.after(startDate)) { + if (execDate.before(now) || !execDate.after(startDate)) { scheduleTime.add(Calendar.DAY_OF_WEEK, 7); } - ; break; case MONTHLY: if (scheduleParts.length < 3) { @@ -283,7 +283,7 @@ public static Date getNextRunTime(IntervalType type, String schedule, String tim // During testing we use a test clock which runs much faster than the real clock // So startDate and execDate will always be ahead in the future // and we will never increase the time here - if (execDate.before(new Date()) || !execDate.after(startDate)) { + if (execDate.before(now) || !execDate.after(startDate)) { scheduleTime.add(Calendar.MONTH, 1); } break; @@ -302,14 +302,7 @@ public static Date getNextRunTime(IntervalType type, String schedule, String tim } public static long getTimeDifference(Date date1, Date date2){ - - Calendar dateCalendar1 = Calendar.getInstance(); - dateCalendar1.setTime(date1); - Calendar dateCalendar2 = Calendar.getInstance(); - dateCalendar2.setTime(date2); - - return (dateCalendar1.getTimeInMillis() - dateCalendar2.getTimeInMillis() )/1000; - + return (date1.getTime() - date2.getTime()) / 1000; } public static CronExpression parseSchedule(String schedule) { diff --git a/utils/src/main/java/com/cloud/utils/ReflectUtil.java b/utils/src/main/java/com/cloud/utils/ReflectUtil.java index 5b40d48c20e2..bf11cea22cce 100644 --- a/utils/src/main/java/com/cloud/utils/ReflectUtil.java +++ b/utils/src/main/java/com/cloud/utils/ReflectUtil.java @@ -42,6 +42,7 @@ import org.reflections.scanners.TypeAnnotationsScanner; import org.reflections.util.ClasspathHelper; import org.reflections.util.ConfigurationBuilder; +import org.reflections.util.FilterBuilder; import com.cloud.utils.exception.CloudRuntimeException; import com.google.common.collect.ImmutableSet; @@ -70,9 +71,12 @@ public static Set> getClassesWithAnnotation(Class Reflections reflections; Set> classes = new HashSet>(); ConfigurationBuilder builder=new ConfigurationBuilder(); + FilterBuilder filterBuilder = new FilterBuilder(); for (String packageName : packageNames) { builder.addUrls(ClasspathHelper.forPackage(packageName)); + filterBuilder.includePackage(packageName); } + builder.filterInputsBy(filterBuilder); builder.setScanners(new SubTypesScanner(),new TypeAnnotationsScanner()); reflections = new Reflections(builder); classes.addAll(reflections.getTypesAnnotatedWith(annotation)); diff --git a/utils/src/test/java/com/cloud/utils/DateUtilTest.java b/utils/src/test/java/com/cloud/utils/DateUtilTest.java index 98b4d11c9d76..77079986cbee 100644 --- a/utils/src/test/java/com/cloud/utils/DateUtilTest.java +++ b/utils/src/test/java/com/cloud/utils/DateUtilTest.java @@ -18,30 +18,36 @@ // package com.cloud.utils; +import com.cloud.utils.DateUtil.IntervalType; +import com.cloud.utils.exception.CloudRuntimeException; +import org.junit.Test; + import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Calendar; import java.util.Date; import java.util.TimeZone; - -import java.time.format.DateTimeFormatter; -import java.time.OffsetDateTime; - -import com.cloud.utils.DateUtil.IntervalType; - -import org.junit.Test; - import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; public class DateUtilTest { + private static final String TEST_DATE_GMT = "2023-06-15 10:30:00"; + private static final String TEST_DATE_EST = "2023-06-15 05:30:00"; + private static final String TEST_DATE_ISO = "2023-06-15T10:30:00Z"; + private static final String TEST_DATE_YYMMDD = "20230615103000"; + private static final TimeZone GMT = DateUtil.GMT_TIMEZONE; + private static final TimeZone EST = TimeZone.getTimeZone("EST"); + // command line test tool public static void main(String[] args) { TimeZone localTimezone = Calendar.getInstance().getTimeZone(); - TimeZone gmtTimezone = TimeZone.getTimeZone("GMT"); - TimeZone estTimezone = TimeZone.getTimeZone("EST"); + TimeZone gmtTimezone = GMT; + TimeZone estTimezone = EST; Date time = new Date(); System.out.println("local time :" + DateUtil.getDateDisplayString(localTimezone, time)); @@ -49,7 +55,9 @@ public static void main(String[] args) { System.out.println("EST time :" + DateUtil.getDateDisplayString(estTimezone, time)); //Test next run time. Expects interval and schedule as arguments if (args.length == 2) { - System.out.println("Next run time: " + DateUtil.getNextRunTime(IntervalType.getIntervalType(args[0]), args[1], "GMT", time).toString()); + System.out.println("Next run time: " + DateUtil.getNextRunTime(IntervalType.getIntervalType(args[0]), + args[1], "GMT", time) + .toString()); } } @@ -130,4 +138,87 @@ public void zonedTimeFormatIsoNoColonZMs() throws ParseException { assertEquals(str, time.toString(), dtParsed.toString()); } + + @Test + public void parseDateStringDefaultFormat() { + TimeZone gmt = GMT; + Date date = DateUtil.parseDateString(gmt, TEST_DATE_GMT); + assertEquals(TEST_DATE_GMT, DateUtil.getDateDisplayString(gmt, date)); + } + + @Test + public void parseDateStringInterpretedInRequestedTimezone() { + TimeZone est = EST; + Date date = DateUtil.parseDateString(est, TEST_DATE_EST); + assertEquals(TEST_DATE_GMT, DateUtil.getDateDisplayString(GMT, date)); + } + + @Test + public void parseDateStringCustomFormat() { + TimeZone gmt = GMT; + Date date = DateUtil.parseDateString(gmt, TEST_DATE_YYMMDD, DateUtil.YYYYMMDD_FORMAT); + assertEquals(TEST_DATE_GMT, DateUtil.getDateDisplayString(gmt, date)); + } + + @Test(expected = CloudRuntimeException.class) + public void parseDateStringInvalidInputThrows() { + DateUtil.parseDateString(GMT, "not-a-date"); + } + + @Test(expected = CloudRuntimeException.class) + public void parseDateStringFormatMismatchThrows() { + DateUtil.parseDateString(GMT, TEST_DATE_GMT, DateUtil.YYYYMMDD_FORMAT); + } + + @Test + public void displayDateInTimezoneGmt() { + Date date = Date.from(Instant.parse(TEST_DATE_ISO)); + assertEquals("2023-06-15T10:30:00+0000", DateUtil.displayDateInTimezone(GMT, date)); + } + + @Test + public void displayDateInTimezoneEst() { + Date date = Date.from(Instant.parse(TEST_DATE_ISO)); + assertEquals("2023-06-15T05:30:00-0500", DateUtil.displayDateInTimezone(EST, date)); + } + + @Test + public void getDateDisplayStringGmt() { + Date date = Date.from(Instant.parse(TEST_DATE_ISO)); + assertEquals(TEST_DATE_GMT, DateUtil.getDateDisplayString(GMT, date)); + } + + @Test + public void getDateDisplayStringTimezoneShift() { + Date date = Date.from(Instant.parse(TEST_DATE_ISO)); + assertEquals(TEST_DATE_EST, DateUtil.getDateDisplayString(EST, date)); + } + + @Test + public void getDateDisplayStringCustomFormat() { + Date date = Date.from(Instant.parse(TEST_DATE_ISO)); + assertEquals(TEST_DATE_YYMMDD, DateUtil.getDateDisplayString(GMT, date, DateUtil.YYYYMMDD_FORMAT)); + } + + @Test + public void getDateDisplayStringNullDate() { + assertEquals(null, DateUtil.getDateDisplayString(GMT, null)); + } + + @Test + public void displayDateInTimezoneNullDate() { + assertEquals(null, DateUtil.displayDateInTimezone(GMT, null)); + } + + @Test + public void getOutputStringNull() { + assertEquals("", DateUtil.getOutputString(null)); + } + + @Test + public void getOutputStringNonNull() { + Date date = Date.from(Instant.parse(TEST_DATE_ISO)); + String result = DateUtil.getOutputString(date); + assertTrue(result.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}[+-]\\d{4}")); + } } diff --git a/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java b/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java index 89f6d7abd7d0..2a42d49898fd 100644 --- a/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java +++ b/vmware-base/src/main/java/com/cloud/hypervisor/vmware/util/VmwareHelper.java @@ -55,6 +55,9 @@ import com.vmware.vim25.DistributedVirtualSwitchPortCriteria; import com.vmware.vim25.GuestInfo; import com.vmware.vim25.GuestNicInfo; +import com.vmware.vim25.GuestStackInfo; +import com.vmware.vim25.NetIpConfigInfoIpAddress; +import com.vmware.vim25.NetIpRouteConfigInfoIpRoute; import com.vmware.vim25.HostPortGroupSpec; import com.vmware.vim25.NasDatastoreInfo; import com.vmware.vim25.VMwareDVSPortSetting; @@ -806,6 +809,7 @@ public static UnmanagedInstanceTO getUnmanagedInstance(VmwareHypervisorHost hype instance.setName(vmMo.getVmName()); instance.setInternalCSName(vmMo.getInternalCSName()); instance.setPath((vmMo.getPath())); + instance.setVmwareMoref(vmMo.getMor().getValue()); instance.setCpuCoresPerSocket(vmMo.getCoresPerSocket()); instance.setOperatingSystemId(vmMo.getVmGuestInfo().getGuestId()); VirtualMachineConfigSummary configSummary = vmMo.getConfigSummary(); @@ -962,6 +966,9 @@ private static List getUnmanageInstanceNics(VmwareHyper List instanceNics = new ArrayList<>(); HashMap> guestNicMacIPAddressMap = new HashMap<>(); + HashMap> guestNicMacIpv4CidrMap = new HashMap<>(); + List guestDnsServers = new ArrayList<>(); + List guestDefaultGateways = new ArrayList<>(); try { GuestInfo guestInfo = vmMo.getGuestInfo(); if (guestInfo.getToolsStatus() == VirtualMachineToolsStatus.TOOLS_OK) { @@ -975,6 +982,42 @@ private static List getUnmanageInstanceNics(VmwareHyper } guestNicMacIPAddressMap.put(nicInfo.getMacAddress(), ipAddresses); } + // The plain ipAddress list carries no prefix; ipConfig has address plus + // prefix length, which static-IP preservation needs for the subnet. + if (nicInfo.getIpConfig() != null && CollectionUtils.isNotEmpty(nicInfo.getIpConfig().getIpAddress())) { + List cidrs = new ArrayList<>(); + for (NetIpConfigInfoIpAddress configuredAddress : nicInfo.getIpConfig().getIpAddress()) { + if (configuredAddress.getIpAddress() != null && NetUtils.isValidIp4(configuredAddress.getIpAddress())) { + cidrs.add(configuredAddress.getIpAddress() + "/" + configuredAddress.getPrefixLength()); + } + } + if (!cidrs.isEmpty()) { + guestNicMacIpv4CidrMap.put(nicInfo.getMacAddress(), cidrs); + } + } + } + // Default gateways and DNS servers are reported per IP stack rather than per + // NIC; each NIC gets the gateway inside one of its subnets further below. + if (CollectionUtils.isNotEmpty(guestInfo.getIpStack())) { + for (GuestStackInfo stackInfo : guestInfo.getIpStack()) { + if (stackInfo.getIpRouteConfig() != null && CollectionUtils.isNotEmpty(stackInfo.getIpRouteConfig().getIpRoute())) { + for (NetIpRouteConfigInfoIpRoute route : stackInfo.getIpRouteConfig().getIpRoute()) { + if ("0.0.0.0".equals(route.getNetwork()) && route.getPrefixLength() == 0 + && route.getGateway() != null && route.getGateway().getIpAddress() != null + && NetUtils.isValidIp4(route.getGateway().getIpAddress()) + && !guestDefaultGateways.contains(route.getGateway().getIpAddress())) { + guestDefaultGateways.add(route.getGateway().getIpAddress()); + } + } + } + if (stackInfo.getDnsConfig() != null && CollectionUtils.isNotEmpty(stackInfo.getDnsConfig().getIpAddress())) { + for (String dnsServer : stackInfo.getDnsConfig().getIpAddress()) { + if (NetUtils.isValidIp4(dnsServer) && !guestDnsServers.contains(dnsServer)) { + guestDnsServers.add(dnsServer); + } + } + } + } } } else { LOGGER.info(String.format("Unable to retrieve guest nics for instance: %s from VMware tools as tools status: %s", vmMo.getName(), guestInfo.getToolsStatus().toString())); @@ -1008,6 +1051,22 @@ private static List getUnmanageInstanceNics(VmwareHyper if (guestNicMacIPAddressMap.containsKey(instanceNic.getMacAddress())) { instanceNic.setIpAddress(guestNicMacIPAddressMap.get(instanceNic.getMacAddress())); } + if (guestNicMacIpv4CidrMap.containsKey(instanceNic.getMacAddress())) { + List nicCidrs = guestNicMacIpv4CidrMap.get(instanceNic.getMacAddress()); + instanceNic.setIpv4Cidrs(nicCidrs); + // Attach the default gateway that lives inside one of this NIC's subnets; + // gateways of other NICs' subnets do not apply to this NIC. + for (String gateway : guestDefaultGateways) { + boolean matchesNicSubnet = nicCidrs.stream().anyMatch(cidr -> NetUtils.isIpWithInCidrRange(gateway, cidr)); + if (matchesNicSubnet) { + instanceNic.setIpv4Gateway(gateway); + break; + } + } + if (!guestDnsServers.isEmpty()) { + instanceNic.setDnsServers(new ArrayList<>(guestDnsServers)); + } + } if (ethCardDevice.getSlotInfo() != null) { instanceNic.setPciSlot(ethCardDevice.getSlotInfo().toString()); }