This is an automated email from the ASF dual-hosted git repository.
harikrishna-patnala pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/main by this push:
new ea6cbada9b2 Multiple CD-ROM / ISO Support Per VM (#13101)
ea6cbada9b2 is described below
commit ea6cbada9b2faf7e55bbd6273af4e86ef68b363d
Author: Daman Arora <[email protected]>
AuthorDate: Wed Jun 24 07:08:26 2026 -0400
Multiple CD-ROM / ISO Support Per VM (#13101)
* pre-allocate a second empty cdrom slot at boot (hardcoded)
* drive cdrom slot count via vm.cdrom.max.count ConfigKey
* add vm_iso_map table + VO/DAO
* persist multi-ISO state via vm_iso_map
* carry target cdrom slot through AttachCommand to KVM agent
* enforce per-VM cdrom cap, clamp to hypervisor max
* make detachIso accepts an ISO id
* expose attached ISOs as isos[] in listVirtualMachines response
* extract CDROM_PRIMARY_DEVICE_SEQ constant
* unit tests for cdrom slot allocation logic
* implement multi-ISO attachment and detachment for VMs with enhanced
validation
* implement multi-ISO display in InstanceTab with computed property for
attached ISOs
* add warning alert for max CDROM selections and enhance global capacity
fetching
* enhance ISO attachment validation to handle multiple ISOs and prevent
duplicates
* refactor ISO attachment logic for detachment and validation
* add unit tests for ISO detachment resolution and validation logic
* add mock for VmIsoMapDao in UserVmJoinDaoImplTest and set lenient
behavior for listByVmId
* refactor ISO attachment logic and enhance UI for multi-CDROM management
* refactor ISO attachment methods to use VM ID and improve parameter
handling
* remove unnecessary mock for VM ISO mapping in TemplateManagerImplTest
* add 'since' attribute to ISO detach command parameter description
* scope vm.cdrom.max.count to cluster
* add support for configurable CD-ROM count per VM and improve handling in
TemplateManager
* add HostDetailsDao mock to UserVmJoinDaoImplTest
* fix: handle null poolId when loading attached ISO slots in
prepareIsoForVmProfile
* implement listByIsoId method in VmIsoMapDao and update
TemplateManagerImpl for ISO deletion checks
* improve logging messages for ISO deletion checks
* add unit tests for CD-ROM handling and enforce limits in TemplateManager
* refactor: update configuration value handling and improve notification
logic
* refactor: rename CD-ROM references to ISO and update related logic
* refactor: enhance effective CD-ROM max count logic to handle missing host
IDs and improve cluster ID retrieval
* refactor: enhance effective CD-ROM max count logic to handle
misconfigurations during VM boot
* refactor: enhance effective CD-ROM max count logic to retrieve host ID
from candidates based on hypervisor type
* refactor: enhance host ID retrieval logic for VMs based on hypervisor type
* feat: add bootable ISO flag to AttachedIsoResponse and update UI to
display it
* refactor: simplify effectiveMaxCdroms method and improve logging for
CD-ROM capacity
* test: update AttachedIsoResponseTest to include bootable flag in
constructor tests
* feat: include bootable flag in AttachedIsoResponse for user VMs
* feat: enhance CD-ROM management by defining empty slots for user VMs
---
api/src/main/java/com/cloud/host/Host.java | 1 +
.../api/command/user/iso/DetachIsoCmd.java | 7 +-
.../api/response/AttachedIsoResponse.java | 76 ++++++
.../cloudstack/api/response/UserVmResponse.java | 24 ++
.../api/response/AttachedIsoResponseTest.java | 46 ++++
.../java/com/cloud/template/TemplateManager.java | 15 ++
.../src/main/java/com/cloud/vm/VmIsoMapVO.java | 83 ++++++
.../main/java/com/cloud/vm/dao/VmIsoMapDao.java | 34 +++
.../java/com/cloud/vm/dao/VmIsoMapDaoImpl.java | 92 +++++++
.../spring-engine-schema-core-daos-context.xml | 1 +
.../resources/META-INF/db/schema-42210to42300.sql | 14 ++
.../src/test/java/com/cloud/vm/VmIsoMapVOTest.java | 41 +++
.../kvm/resource/LibvirtComputingResource.java | 16 ++
.../hypervisor/kvm/resource/LibvirtVMDef.java | 4 +
.../kvm/storage/KVMStorageProcessor.java | 15 +-
.../com/cloud/api/query/dao/UserVmJoinDaoImpl.java | 70 ++++++
.../com/cloud/template/TemplateManagerImpl.java | 278 +++++++++++++++++----
.../cloud/api/query/dao/UserVmJoinDaoImplTest.java | 46 ++++
.../cloud/template/TemplateManagerImplTest.java | 243 ++++++++++++++++++
ui/src/config/section/compute.js | 28 +--
ui/src/views/compute/AttachIso.vue | 112 ++++++---
ui/src/views/compute/DetachIso.vue | 178 +++++++++++++
ui/src/views/compute/InstanceTab.vue | 34 ++-
ui/src/views/setting/ConfigurationValue.vue | 5 +-
24 files changed, 1353 insertions(+), 110 deletions(-)
diff --git a/api/src/main/java/com/cloud/host/Host.java
b/api/src/main/java/com/cloud/host/Host.java
index 8b14cfd3a39..c110e4ca94e 100644
--- a/api/src/main/java/com/cloud/host/Host.java
+++ b/api/src/main/java/com/cloud/host/Host.java
@@ -63,6 +63,7 @@ public interface Host extends StateObject<Status>, Identity,
Partition, HAResour
String HOST_OVFTOOL_VERSION = "host.ovftool.version";
String HOST_VIRTV2V_VERSION = "host.virtv2v.version";
String HOST_SSH_PORT = "host.ssh.port";
+ String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count";
String GUEST_OS_CATEGORY_ID = "guest.os.category.id";
String GUEST_OS_RULE = "guest.os.rule";
diff --git
a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java
b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java
index cf4aa41f795..2560d837de1 100644
---
a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java
+++
b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java
@@ -27,6 +27,7 @@ import org.apache.cloudstack.api.ResponseObject.ResponseView;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.command.user.UserCmd;
import org.apache.cloudstack.api.command.user.vm.DeployVMCmd;
+import org.apache.cloudstack.api.response.TemplateResponse;
import org.apache.cloudstack.api.response.UserVmResponse;
import com.cloud.event.EventTypes;
@@ -51,6 +52,10 @@ public class DetachIsoCmd extends BaseAsyncCmd implements
UserCmd {
description = "If true, ejects the ISO before detaching on VMware.
Default: false", since = "4.15.1")
protected Boolean forced;
+ @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType =
TemplateResponse.class,
+ description = "The ID of the ISO to detach. Required when the
Instance has more than one ISO attached.", since = "4.23.0")
+ protected Long id;
+
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@@ -104,7 +109,7 @@ public class DetachIsoCmd extends BaseAsyncCmd implements
UserCmd {
@Override
public void execute() {
- boolean result = _templateService.detachIso(virtualMachineId, null,
isForced());
+ boolean result = _templateService.detachIso(virtualMachineId, id,
isForced());
if (result) {
UserVm userVm = _entityMgr.findById(UserVm.class,
virtualMachineId);
UserVmResponse response =
_responseGenerator.createUserVmResponse(getResponseView(), "virtualmachine",
userVm).get(0);
diff --git
a/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java
b/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java
new file mode 100644
index 00000000000..b259de56218
--- /dev/null
+++
b/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES 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 AttachedIsoResponse extends BaseResponse {
+
+ @SerializedName("id")
+ @Param(description = "The ID of the attached ISO")
+ private String id;
+
+ @SerializedName("name")
+ @Param(description = "The name of the attached ISO")
+ private String name;
+
+ @SerializedName("displaytext")
+ @Param(description = "The display text of the attached ISO")
+ private String displayText;
+
+ @SerializedName("deviceseq")
+ @Param(description = "The cdrom slot that holds this ISO (3=hdc, 4=hdd,
...)")
+ private Integer deviceSeq;
+
+ @SerializedName("bootable")
+ @Param(description = "Whether this is the bootable ISO for the VM")
+ private Boolean bootable;
+
+ public AttachedIsoResponse() {
+ }
+
+ public AttachedIsoResponse(String id, String name, String displayText,
Integer deviceSeq, boolean bootable) {
+ this.id = id;
+ this.name = name;
+ this.displayText = displayText;
+ this.deviceSeq = deviceSeq;
+ this.bootable = bootable;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDisplayText() {
+ return displayText;
+ }
+
+ public Integer getDeviceSeq() {
+ return deviceSeq;
+ }
+
+ public Boolean getBootable() {
+ return bootable;
+ }
+}
diff --git
a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java
b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java
index a7f6dff96f8..4d6eae2fad2 100644
--- a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java
+++ b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java
@@ -166,6 +166,14 @@ public class UserVmResponse extends
BaseResponseWithTagInformation implements Co
@Param(description = "An alternate display text of the ISO attached to the
Instance")
private String isoDisplayText;
+ @SerializedName("isos")
+ @Param(description = "All ISOs attached to the Instance, keyed by cdrom
slot. The first entry mirrors isoid/isoname for back-compat.", responseObject =
AttachedIsoResponse.class, since = "4.23.0")
+ private List<AttachedIsoResponse> isos;
+
+ @SerializedName("isomaxcount")
+ @Param(description = "Maximum number of ISOs that may be attached to this
Instance, after applying the cluster-scoped vm.iso.max.count and the
hypervisor's own cap.", since = "4.23.0")
+ private Integer isoMaxCount;
+
@SerializedName(ApiConstants.SERVICE_OFFERING_ID)
@Param(description = "The ID of the service offering of the Instance")
private String serviceOfferingId;
@@ -871,6 +879,22 @@ public class UserVmResponse extends
BaseResponseWithTagInformation implements Co
this.isoId = isoId;
}
+ public void setIsos(List<AttachedIsoResponse> isos) {
+ this.isos = isos;
+ }
+
+ public List<AttachedIsoResponse> getIsos() {
+ return isos;
+ }
+
+ public void setIsoMaxCount(Integer isoMaxCount) {
+ this.isoMaxCount = isoMaxCount;
+ }
+
+ public Integer getIsoMaxCount() {
+ return isoMaxCount;
+ }
+
public void setIsoName(String isoName) {
this.isoName = isoName;
}
diff --git
a/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java
b/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java
new file mode 100644
index 00000000000..09d4eb598ab
--- /dev/null
+++
b/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.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.api.response;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public final class AttachedIsoResponseTest {
+
+ @Test
+ public void testFullConstructorPopulatesAllFields() {
+ AttachedIsoResponse response = new AttachedIsoResponse("uuid-1",
"alpine-iso", "Alpine boot", 3, true);
+ Assert.assertEquals("uuid-1", response.getId());
+ Assert.assertEquals("alpine-iso", response.getName());
+ Assert.assertEquals("Alpine boot", response.getDisplayText());
+ Assert.assertEquals(Integer.valueOf(3), response.getDeviceSeq());
+ Assert.assertTrue(response.getBootable());
+ }
+
+ @Test
+ public void testNoArgConstructorLeavesFieldsNull() {
+ AttachedIsoResponse response = new AttachedIsoResponse();
+ Assert.assertNull(response.getId());
+ Assert.assertNull(response.getName());
+ Assert.assertNull(response.getDisplayText());
+ Assert.assertNull(response.getDeviceSeq());
+ Assert.assertNull(response.getBootable());
+ }
+}
diff --git
a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java
b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java
index f1891c774ed..24d7bf621f6 100644
---
a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java
+++
b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java
@@ -64,6 +64,21 @@ public interface TemplateManager {
true,
ConfigKey.Scope.Global);
+ ConfigKey<Integer> VmIsoMaxCount = new ConfigKey<Integer>("Advanced",
+ Integer.class,
+ "vm.iso.max.count", "1",
+ "Maximum number of ISOs that may be attached to a VM.",
+ true,
+ ConfigKey.Scope.Cluster);
+
+ // KVM/libvirt maps deviceSeq=3 to hdc (hda/hdb are taken by the root
volume on i440fx/IDE).
+ // user_vm.iso_id has always pointed at this slot; additional cdroms live
in vm_iso_map.
+ int CDROM_PRIMARY_DEVICE_SEQ = 3;
+
+ // Fallback per-VM cdrom cap when the placement host hasn't advertised
host.cdrom.max.count
+ // (older agent, never-deployed VM, etc.).
+ int DEFAULT_CDROM_MAX_PER_VM = 1;
+
static final String VMWARE_TOOLS_ISO = "vmware-tools.iso";
static final String XS_TOOLS_ISO = "xs-tools.iso";
diff --git a/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java
b/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java
new file mode 100644
index 00000000000..f4a3f116818
--- /dev/null
+++ b/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.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.vm;
+
+import java.util.Date;
+
+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 javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+
+import org.apache.cloudstack.api.InternalIdentity;
+
+@Entity
+@Table(name = "vm_iso_map")
+public class VmIsoMapVO implements InternalIdentity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "vm_id")
+ private long vmId;
+
+ @Column(name = "iso_id")
+ private long isoId;
+
+ @Column(name = "device_seq")
+ private int deviceSeq;
+
+ @Column(name = "created")
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date created;
+
+ public VmIsoMapVO() {
+ }
+
+ public VmIsoMapVO(long vmId, long isoId, int deviceSeq) {
+ this.vmId = vmId;
+ this.isoId = isoId;
+ this.deviceSeq = deviceSeq;
+ this.created = new Date();
+ }
+
+ @Override
+ public long getId() {
+ return id;
+ }
+
+ public long getVmId() {
+ return vmId;
+ }
+
+ public long getIsoId() {
+ return isoId;
+ }
+
+ public int getDeviceSeq() {
+ return deviceSeq;
+ }
+
+ public Date getCreated() {
+ return created;
+ }
+}
diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java
b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java
new file mode 100644
index 00000000000..a472a3b4dec
--- /dev/null
+++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java
@@ -0,0 +1,34 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES 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 com.cloud.vm.VmIsoMapVO;
+
+public interface VmIsoMapDao extends GenericDao<VmIsoMapVO, Long> {
+ List<VmIsoMapVO> listByVmId(long vmId);
+
+ List<VmIsoMapVO> listByIsoId(long isoId);
+
+ VmIsoMapVO findByVmIdDeviceSeq(long vmId, int deviceSeq);
+
+ VmIsoMapVO findByVmIdIsoId(long vmId, long isoId);
+
+ int removeByVmId(long vmId);
+}
diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java
b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java
new file mode 100644
index 00000000000..44749eea75f
--- /dev/null
+++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java
@@ -0,0 +1,92 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES 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 com.cloud.vm.VmIsoMapVO;
+
+@Component
+public class VmIsoMapDaoImpl extends GenericDaoBase<VmIsoMapVO, Long>
implements VmIsoMapDao {
+
+ private SearchBuilder<VmIsoMapVO> ListByVmId;
+ private SearchBuilder<VmIsoMapVO> ListByIsoId;
+ private SearchBuilder<VmIsoMapVO> ByVmIdDeviceSeq;
+ private SearchBuilder<VmIsoMapVO> ByVmIdIsoId;
+
+ protected VmIsoMapDaoImpl() {
+ ListByVmId = createSearchBuilder();
+ ListByVmId.and("vmId", ListByVmId.entity().getVmId(),
SearchCriteria.Op.EQ);
+ ListByVmId.done();
+
+ ListByIsoId = createSearchBuilder();
+ ListByIsoId.and("isoId", ListByIsoId.entity().getIsoId(),
SearchCriteria.Op.EQ);
+ ListByIsoId.done();
+
+ ByVmIdDeviceSeq = createSearchBuilder();
+ ByVmIdDeviceSeq.and("vmId", ByVmIdDeviceSeq.entity().getVmId(),
SearchCriteria.Op.EQ);
+ ByVmIdDeviceSeq.and("deviceSeq",
ByVmIdDeviceSeq.entity().getDeviceSeq(), SearchCriteria.Op.EQ);
+ ByVmIdDeviceSeq.done();
+
+ ByVmIdIsoId = createSearchBuilder();
+ ByVmIdIsoId.and("vmId", ByVmIdIsoId.entity().getVmId(),
SearchCriteria.Op.EQ);
+ ByVmIdIsoId.and("isoId", ByVmIdIsoId.entity().getIsoId(),
SearchCriteria.Op.EQ);
+ ByVmIdIsoId.done();
+ }
+
+ @Override
+ public List<VmIsoMapVO> listByVmId(long vmId) {
+ SearchCriteria<VmIsoMapVO> sc = ListByVmId.create();
+ sc.setParameters("vmId", vmId);
+ return listBy(sc);
+ }
+
+ @Override
+ public List<VmIsoMapVO> listByIsoId(long isoId) {
+ SearchCriteria<VmIsoMapVO> sc = ListByIsoId.create();
+ sc.setParameters("isoId", isoId);
+ return listBy(sc);
+ }
+
+ @Override
+ public VmIsoMapVO findByVmIdDeviceSeq(long vmId, int deviceSeq) {
+ SearchCriteria<VmIsoMapVO> sc = ByVmIdDeviceSeq.create();
+ sc.setParameters("vmId", vmId);
+ sc.setParameters("deviceSeq", deviceSeq);
+ return findOneBy(sc);
+ }
+
+ @Override
+ public VmIsoMapVO findByVmIdIsoId(long vmId, long isoId) {
+ SearchCriteria<VmIsoMapVO> sc = ByVmIdIsoId.create();
+ sc.setParameters("vmId", vmId);
+ sc.setParameters("isoId", isoId);
+ return findOneBy(sc);
+ }
+
+ @Override
+ public int removeByVmId(long vmId) {
+ SearchCriteria<VmIsoMapVO> sc = ListByVmId.create();
+ sc.setParameters("vmId", vmId);
+ return remove(sc);
+ }
+}
diff --git
a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
index 26181d3fce0..3f72ad9dfc8 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,7 @@
<bean id="instanceGroupJoinDaoImpl"
class="com.cloud.api.query.dao.InstanceGroupJoinDaoImpl" />
<bean id="managementServerJoinDaoImpl"
class="com.cloud.api.query.dao.ManagementServerJoinDaoImpl" />
<bean id="instanceGroupVMMapDaoImpl"
class="com.cloud.vm.dao.InstanceGroupVMMapDaoImpl" />
+ <bean id="vmIsoMapDaoImpl" class="com.cloud.vm.dao.VmIsoMapDaoImpl" />
<bean id="itWorkDaoImpl" class="com.cloud.vm.ItWorkDaoImpl" />
<bean id="lBHealthCheckPolicyDaoImpl"
class="com.cloud.network.dao.LBHealthCheckPolicyDaoImpl" />
<bean id="lBStickinessPolicyDaoImpl"
class="com.cloud.network.dao.LBStickinessPolicyDaoImpl" />
diff --git
a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
index bd5ecbab21c..31e7e237afb 100644
--- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
+++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
@@ -136,6 +136,20 @@ CREATE TABLE IF NOT EXISTS
`cloud_usage`.`quota_tariff_usage` (
CONSTRAINT `fk_quota_tariff_usage__tariff_id` FOREIGN KEY (`tariff_id`)
REFERENCES `cloud_usage`.`quota_tariff` (`id`),
CONSTRAINT `fk_quota_tariff_usage__quota_usage_id` FOREIGN KEY
(`quota_usage_id`) REFERENCES `cloud_usage`.`quota_usage` (`id`));
+--- Per-VM ISO attachments. user_vm.iso_id remains as the primary/bootable ISO
pointer.
+CREATE TABLE IF NOT EXISTS `cloud`.`vm_iso_map` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `vm_id` bigint(20) unsigned NOT NULL COMMENT 'foreign key to user_vm',
+ `iso_id` bigint(20) unsigned NOT NULL COMMENT 'foreign key to vm_template
(ISOs are templates of format ISO)',
+ `device_seq` int(10) unsigned NOT NULL COMMENT 'cdrom slot index used to
derive the libvirt device label (3=hdc, 4=hdd)',
+ `created` datetime NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uc_vm_iso_map__vm_iso` (`vm_id`, `iso_id`),
+ UNIQUE KEY `uc_vm_iso_map__vm_seq` (`vm_id`, `device_seq`),
+ CONSTRAINT `fk_vm_iso_map__vm_id` FOREIGN KEY (`vm_id`) REFERENCES
`cloud`.`user_vm` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_vm_iso_map__iso_id` FOREIGN KEY (`iso_id`) REFERENCES
`cloud`.`vm_template` (`id`)
+);
+
-- Add the 'keep_mac_address_on_public_nic' column to the 'cloud.networks' and
'cloud.vpc' tables
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.networks',
'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1');
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc',
'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1');
diff --git a/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java
b/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java
new file mode 100644
index 00000000000..d5b1fef7a76
--- /dev/null
+++ b/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.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;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class VmIsoMapVOTest {
+
+ @Test
+ public void testFullConstructorPopulatesAllFields() {
+ VmIsoMapVO row = new VmIsoMapVO(7L, 42L, 4);
+ Assert.assertEquals(7L, row.getVmId());
+ Assert.assertEquals(42L, row.getIsoId());
+ Assert.assertEquals(4, row.getDeviceSeq());
+ Assert.assertNotNull(row.getCreated());
+ }
+
+ @Test
+ public void testNoArgConstructorLeavesNonIdFieldsAtDefaults() {
+ VmIsoMapVO row = new VmIsoMapVO();
+ Assert.assertEquals(0L, row.getVmId());
+ Assert.assertEquals(0L, row.getIsoId());
+ Assert.assertEquals(0, row.getDeviceSeq());
+ Assert.assertNull(row.getCreated());
+ }
+}
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 4a93b1bce4a..41716881fa4 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
@@ -16,6 +16,7 @@
// under the License.
package com.cloud.hypervisor.kvm.resource;
+import static com.cloud.host.Host.HOST_CDROM_MAX_COUNT;
import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION;
import static com.cloud.host.Host.HOST_OVFTOOL_VERSION;
import static com.cloud.host.Host.HOST_VDDK_LIB_DIR;
@@ -226,6 +227,7 @@ import com.cloud.resource.ResourceStatusUpdater;
import com.cloud.resource.ServerResource;
import com.cloud.resource.ServerResourceBase;
import com.cloud.storage.JavaStorageLayer;
+import com.cloud.template.TemplateManager;
import com.cloud.storage.Storage;
import com.cloud.storage.Storage.StoragePoolType;
import com.cloud.storage.StorageLayer;
@@ -3696,6 +3698,7 @@ public class LibvirtComputingResource extends
ServerResourceBase implements Serv
if (vmSpec.getOs().toLowerCase().contains("window")) {
isWindowsTemplate = true;
}
+ final Set<Integer> definedCdromSlots = new HashSet<>();
for (final DiskTO volume : disks) {
KVMPhysicalDisk physicalDisk = null;
KVMStoragePool pool = null;
@@ -3774,6 +3777,7 @@ public class LibvirtComputingResource extends
ServerResourceBase implements Serv
if (volume.getType() == Volume.Type.ISO) {
final DiskDef.DiskType diskType = getDiskType(physicalDisk);
disk.defISODisk(volPath, devId, isUefiEnabled, diskType);
+ definedCdromSlots.add(devId);
if (guestCpuArch != null && (guestCpuArch.equals("aarch64") ||
guestCpuArch.equals("s390x"))) {
disk.setBusType(DiskDef.DiskBus.SCSI);
@@ -3871,6 +3875,17 @@ public class LibvirtComputingResource extends
ServerResourceBase implements Serv
vm.getDevices().addDevice(disk);
}
+ if (vmSpec.getType() == VirtualMachine.Type.User) {
+ for (int slot = TemplateManager.CDROM_PRIMARY_DEVICE_SEQ;
+ slot < TemplateManager.CDROM_PRIMARY_DEVICE_SEQ +
LibvirtVMDef.MAX_CDROMS_PER_VM; slot++) {
+ if (!definedCdromSlots.contains(slot)) {
+ final DiskDef emptyCdrom = new DiskDef();
+ emptyCdrom.defISODisk(null, slot, isUefiEnabled,
DiskDef.DiskType.FILE);
+ vm.getDevices().addDevice(emptyCdrom);
+ }
+ }
+ }
+
if (vmSpec.getType() != VirtualMachine.Type.User) {
final DiskDef iso = new DiskDef();
iso.defISODisk(sysvmISOPath, DiskDef.DiskType.FILE);
@@ -4381,6 +4396,7 @@ public class LibvirtComputingResource extends
ServerResourceBase implements Serv
boolean instanceConversionSupported = hostSupportsInstanceConversion();
cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION,
String.valueOf(instanceConversionSupported));
cmd.getHostDetails().put(HOST_VDDK_SUPPORT,
String.valueOf(hostSupportsVddk()));
+ cmd.getHostDetails().put(HOST_CDROM_MAX_COUNT,
String.valueOf(LibvirtVMDef.MAX_CDROMS_PER_VM));
if (StringUtils.isNotBlank(vddkLibDir)) {
cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir);
}
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java
index bf8b1af6c18..7f6725b6d15 100644
---
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java
@@ -57,6 +57,10 @@ import static
java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public class LibvirtVMDef {
protected static Logger LOGGER = LogManager.getLogger(LibvirtVMDef.class);
+ // CD-ROM slot allocation: getDevLabel() maps deviceSeq=3,4 to hdc and hdd
on the IDE bus.
+ // Bumping this requires extending getDevLabel() (e.g. to spill onto SATA
or a second IDE controller).
+ public static final int MAX_CDROMS_PER_VM = 2;
+
private String _hvsType;
private static long s_libvirtVersion;
private static long s_qemuVersion;
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
index 4a77f7e9e19..009e1decee2 100644
---
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
@@ -21,6 +21,8 @@ package com.cloud.hypervisor.kvm.storage;
import static com.cloud.utils.NumbersUtil.toHumanReadableSize;
import static com.cloud.utils.storage.S3.S3Utils.putFile;
+import com.cloud.template.TemplateManager;
+
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
@@ -1346,10 +1348,11 @@ public class KVMStorageProcessor implements
StorageProcessor {
}
}
- protected synchronized void attachOrDetachISO(final Connect conn, final
String vmName, String isoPath, final boolean isAttach, Map<String, String>
params, DataStoreTO store) throws
+ protected synchronized void attachOrDetachISO(final Connect conn, final
String vmName, String isoPath, final boolean isAttach, Map<String, String>
params, DataStoreTO store, Integer deviceSeq) throws
LibvirtException, InternalErrorException {
DiskDef iso = new DiskDef();
boolean isUefiEnabled = MapUtils.isNotEmpty(params) &&
params.containsKey("UEFI");
+ Integer devId = (deviceSeq != null) ? deviceSeq :
TemplateManager.CDROM_PRIMARY_DEVICE_SEQ;
if (isoPath != null && isAttach) {
final int index = isoPath.lastIndexOf("/");
final String path = isoPath.substring(0, index);
@@ -1365,9 +1368,9 @@ public class KVMStorageProcessor implements
StorageProcessor {
final DiskDef.DiskType isoDiskType =
LibvirtComputingResource.getDiskType(isoVol);
isoPath = isoVol.getPath();
- iso.defISODisk(isoPath, isUefiEnabled, isoDiskType);
+ iso.defISODisk(isoPath, devId, isUefiEnabled, isoDiskType);
} else {
- iso.defISODisk(null, isUefiEnabled, DiskDef.DiskType.FILE);
+ iso.defISODisk(null, devId, isUefiEnabled, DiskDef.DiskType.FILE);
}
final List<DiskDef> disks = resource.getDisks(conn, vmName);
@@ -1387,11 +1390,12 @@ public class KVMStorageProcessor implements
StorageProcessor {
final DiskTO disk = cmd.getDisk();
final TemplateObjectTO isoTO = (TemplateObjectTO)disk.getData();
final DataStoreTO store = isoTO.getDataStore();
+ final Integer deviceSeq = (disk.getDiskSeq() != null) ?
disk.getDiskSeq().intValue() : null;
try {
String dataStoreUrl = getDataStoreUrlFromStore(store);
final Connect conn =
LibvirtConnection.getConnectionByVmName(cmd.getVmName());
- attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl +
File.separator + isoTO.getPath(), true, cmd.getControllerInfo(), store);
+ attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl +
File.separator + isoTO.getPath(), true, cmd.getControllerInfo(), store,
deviceSeq);
} catch (final LibvirtException e) {
return new Answer(cmd, false, e.toString());
} catch (final InternalErrorException e) {
@@ -1408,11 +1412,12 @@ public class KVMStorageProcessor implements
StorageProcessor {
final DiskTO disk = cmd.getDisk();
final TemplateObjectTO isoTO = (TemplateObjectTO)disk.getData();
final DataStoreTO store = isoTO.getDataStore();
+ final Integer deviceSeq = (disk.getDiskSeq() != null) ?
disk.getDiskSeq().intValue() : null;
try {
String dataStoreUrl = getDataStoreUrlFromStore(store);
final Connect conn =
LibvirtConnection.getConnectionByVmName(cmd.getVmName());
- attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl +
File.separator + isoTO.getPath(), false, cmd.getParams(), store);
+ attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl +
File.separator + isoTO.getPath(), false, cmd.getParams(), store, deviceSeq);
} catch (final LibvirtException e) {
return new Answer(cmd, false, e.toString());
} catch (final InternalErrorException e) {
diff --git
a/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java
b/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java
index 4877eb844af..aeb54de1290 100644
--- a/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java
+++ b/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java
@@ -39,6 +39,7 @@ import org.apache.cloudstack.annotation.dao.AnnotationDao;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiConstants.VMDetails;
import org.apache.cloudstack.api.ResponseObject.ResponseView;
+import org.apache.cloudstack.api.response.AttachedIsoResponse;
import org.apache.cloudstack.api.response.NicExtraDhcpOptionResponse;
import org.apache.cloudstack.api.response.NicResponse;
import org.apache.cloudstack.api.response.NicSecondaryIpResponse;
@@ -62,6 +63,11 @@ import com.cloud.gpu.GPU;
import com.cloud.gpu.dao.VgpuProfileDao;
import com.cloud.host.ControlState;
import com.cloud.hypervisor.Hypervisor;
+import com.cloud.host.DetailVO;
+import com.cloud.host.Host;
+import com.cloud.host.HostVO;
+import com.cloud.host.dao.HostDao;
+import com.cloud.host.dao.HostDetailsDao;
import com.cloud.network.IpAddress;
import com.cloud.network.vpc.VpcVO;
import com.cloud.network.vpc.dao.VpcDao;
@@ -72,6 +78,7 @@ import com.cloud.storage.GuestOS;
import com.cloud.storage.Storage.TemplateType;
import com.cloud.storage.VMTemplateVO;
import com.cloud.storage.VnfTemplateDetailVO;
+import com.cloud.template.TemplateManager;
import com.cloud.storage.VnfTemplateNicVO;
import com.cloud.storage.Volume;
import com.cloud.storage.dao.VMTemplateDao;
@@ -93,10 +100,12 @@ import com.cloud.vm.UserVmManager;
import com.cloud.vm.VMInstanceDetailVO;
import com.cloud.vm.VirtualMachine;
import com.cloud.vm.VirtualMachine.State;
+import com.cloud.vm.VmIsoMapVO;
import com.cloud.vm.VmStats;
import com.cloud.vm.dao.NicExtraDhcpOptionDao;
import com.cloud.vm.dao.NicSecondaryIpVO;
import com.cloud.vm.dao.VMInstanceDetailsDao;
+import com.cloud.vm.dao.VmIsoMapDao;
@Component
public class UserVmJoinDaoImpl extends
GenericDaoBaseWithTagInformation<UserVmJoinVO, UserVmResponse> implements
UserVmJoinDao {
@@ -130,6 +139,12 @@ public class UserVmJoinDaoImpl extends
GenericDaoBaseWithTagInformation<UserVmJo
@Inject
VMTemplateDao vmTemplateDao;
@Inject
+ VmIsoMapDao vmIsoMapDao;
+ @Inject
+ HostDetailsDao hostDetailsDao;
+ @Inject
+ HostDao hostDao;
+ @Inject
ExtensionHelper extensionHelper;
private final SearchBuilder<UserVmJoinVO> VmDetailSearch;
@@ -246,6 +261,23 @@ public class UserVmJoinDaoImpl extends
GenericDaoBaseWithTagInformation<UserVmJo
userVmResponse.setIsoId(userVm.getIsoUuid());
userVmResponse.setIsoName(userVm.getIsoName());
userVmResponse.setIsoDisplayText(userVm.getIsoDisplayText());
+
+ List<AttachedIsoResponse> attachedIsos = new ArrayList<>();
+ if (userVm.getIsoUuid() != null) {
+ VMTemplateVO bootIso =
vmTemplateDao.findById(userVm.getIsoId());
+ boolean bootIsoBootable = bootIso != null &&
bootIso.isBootable();
+ attachedIsos.add(new AttachedIsoResponse(userVm.getIsoUuid(),
userVm.getIsoName(),
+ userVm.getIsoDisplayText(),
TemplateManager.CDROM_PRIMARY_DEVICE_SEQ, bootIsoBootable));
+ }
+ for (VmIsoMapVO row : vmIsoMapDao.listByVmId(userVm.getId())) {
+ VMTemplateVO tmpl = vmTemplateDao.findById(row.getIsoId());
+ if (tmpl != null) {
+ attachedIsos.add(new AttachedIsoResponse(tmpl.getUuid(),
tmpl.getName(),
+ tmpl.getDisplayText(), row.getDeviceSeq(), false));
+ }
+ }
+ userVmResponse.setIsos(attachedIsos);
+ userVmResponse.setIsoMaxCount(effectiveCdromMaxCount(userVm));
}
if (details.contains(VMDetails.all) ||
details.contains(VMDetails.servoff)) {
userVmResponse.setServiceOfferingId(userVm.getServiceOfferingUuid());
@@ -540,6 +572,44 @@ public class UserVmJoinDaoImpl extends
GenericDaoBaseWithTagInformation<UserVmJo
return ChronoUnit.DAYS.between(createdDate, expiryDate);
}
+ int effectiveCdromMaxCount(UserVmJoinVO userVm) {
+ Long hostId = userVm.getHostId() != null && userVm.getHostId() > 0
+ ? userVm.getHostId() : userVm.getLastHostId();
+ if (hostId == null && userVm.getHypervisorType() != null) {
+ List<HostVO> candidates =
hostDao.listByDataCenterIdAndHypervisorType(userVm.getDataCenterId(),
userVm.getHypervisorType());
+ if (!candidates.isEmpty()) {
+ hostId = candidates.get(0).getId();
+ }
+ }
+ Long clusterId = userVm.getClusterId();
+ if (clusterId == null && hostId != null) {
+ HostVO host = hostDao.findById(hostId);
+ if (host != null) {
+ clusterId = host.getClusterId();
+ }
+ }
+ int configuredCap = TemplateManager.VmIsoMaxCount.valueIn(clusterId);
+ int hypervisorCap = advertisedCdromCap(hostId);
+ // List endpoint clamps for display robustness; the action paths in
TemplateManagerImpl
+ // throw on misconfiguration so operators still see the loud error
when they try to attach.
+ return Math.min(configuredCap, hypervisorCap);
+ }
+
+ int advertisedCdromCap(Long hostId) {
+ if (hostId == null) {
+ return TemplateManager.DEFAULT_CDROM_MAX_PER_VM;
+ }
+ DetailVO detail = hostDetailsDao.findDetail(hostId,
Host.HOST_CDROM_MAX_COUNT);
+ if (detail == null || detail.getValue() == null) {
+ return TemplateManager.DEFAULT_CDROM_MAX_PER_VM;
+ }
+ try {
+ return Integer.parseInt(detail.getValue());
+ } catch (NumberFormatException e) {
+ return TemplateManager.DEFAULT_CDROM_MAX_PER_VM;
+ }
+ }
+
private void addVnfInfoToserVmResponse(UserVmJoinVO userVm, UserVmResponse
userVmResponse) {
List<VnfTemplateNicVO> vnfNics =
vnfTemplateNicDao.listByTemplateId(userVm.getTemplateId());
for (VnfTemplateNicVO nic : vnfNics) {
diff --git a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java
b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java
index 3aaebc69130..6cac485c4e1 100755
--- a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java
+++ b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java
@@ -21,6 +21,7 @@ import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -145,8 +146,11 @@ import com.cloud.exception.InvalidParameterValueException;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.exception.ResourceAllocationException;
import com.cloud.exception.StorageUnavailableException;
+import com.cloud.host.DetailVO;
+import com.cloud.host.Host;
import com.cloud.host.HostVO;
import com.cloud.host.dao.HostDao;
+import com.cloud.host.dao.HostDetailsDao;
import com.cloud.hypervisor.Hypervisor;
import com.cloud.hypervisor.Hypervisor.HypervisorType;
import com.cloud.hypervisor.HypervisorGuru;
@@ -222,7 +226,9 @@ import com.cloud.vm.VirtualMachine.State;
import com.cloud.vm.VirtualMachineProfile;
import com.cloud.vm.VirtualMachineProfileImpl;
import com.cloud.vm.VmDetailConstants;
+import com.cloud.vm.VmIsoMapVO;
import com.cloud.vm.dao.UserVmDao;
+import com.cloud.vm.dao.VmIsoMapDao;
import com.cloud.vm.dao.VMInstanceDao;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
@@ -252,10 +258,14 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
@Inject
private HostDao _hostDao;
@Inject
+ private HostDetailsDao _hostDetailsDao;
+ @Inject
private DataCenterDao _dcDao;
@Inject
private UserVmDao _userVmDao;
@Inject
+ private VmIsoMapDao _vmIsoMapDao;
+ @Inject
private VolumeDao _volumeDao;
@Inject
private SnapshotDao _snapshotDao;
@@ -679,45 +689,73 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
@Override
public void prepareIsoForVmProfile(VirtualMachineProfile profile,
DeployDestination dest) {
UserVmVO vm = _userVmDao.findById(profile.getId());
- if (vm.getIsoId() != null) {
- Map<Volume, StoragePool> storageForDisks =
dest.getStorageForDisks();
- Long poolId = null;
- TemplateInfo template;
- if (MapUtils.isNotEmpty(storageForDisks)) {
- for (StoragePool storagePool : storageForDisks.values()) {
- if (poolId != null && storagePool.getId() != poolId) {
- throw new CloudRuntimeException("Cannot determine
where to download ISO");
- }
- poolId = storagePool.getId();
+ Map<Integer, Long> slotToIsoId = loadAttachedIsoSlots(vm);
+ Long poolId = slotToIsoId.isEmpty() ? null : singleStoragePoolId(dest);
+
+ // Pre-allocate every cdrom slot at boot. QEMU/IDE refuses to hot-add
new cdrom drives, so
+ // runtime attachIso can only media-swap into a slot the domain
already owns.
+ int totalSlots = Math.max(effectiveMaxCdroms(vm,
dest.getHost().getId()), slotsNeededFor(slotToIsoId));
+ for (int i = 0; i < totalSlots; i++) {
+ int diskSeq = CDROM_PRIMARY_DEVICE_SEQ + i;
+ Long isoId = slotToIsoId.get(diskSeq);
+ profile.addDisk(isoId != null
+ ? buildIsoDisk(profile, vm, dest, poolId, diskSeq, isoId)
+ : buildEmptyCdromDisk(diskSeq));
+ }
+ }
+
+ private Long singleStoragePoolId(DeployDestination dest) {
+ Long poolId = null;
+ Map<Volume, StoragePool> storageForDisks = dest.getStorageForDisks();
+ if (MapUtils.isNotEmpty(storageForDisks)) {
+ for (StoragePool pool : storageForDisks.values()) {
+ if (poolId != null && pool.getId() != poolId) {
+ throw new CloudRuntimeException("Cannot determine where to
download ISO");
}
+ poolId = pool.getId();
}
- template = prepareIso(vm.getIsoId(), vm.getDataCenterId(),
dest.getHost().getId(), poolId);
+ }
+ return poolId;
+ }
- if (template == null){
- logger.error("Failed to prepare ISO on secondary or cache
storage");
- throw new CloudRuntimeException("Failed to prepare ISO on
secondary or cache storage");
- }
- if (template.isBootable()) {
- profile.setBootLoaderType(BootloaderType.CD);
- }
+ private Map<Integer, Long> loadAttachedIsoSlots(UserVmVO vm) {
+ Map<Integer, Long> slots = new HashMap<>();
+ if (vm.getIsoId() != null) {
+ slots.put(CDROM_PRIMARY_DEVICE_SEQ, vm.getIsoId());
+ }
+ for (VmIsoMapVO row : _vmIsoMapDao.listByVmId(vm.getId())) {
+ slots.put(row.getDeviceSeq(), row.getIsoId());
+ }
+ return slots;
+ }
- GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId());
- String displayName = null;
- if (guestOS != null) {
- displayName = guestOS.getDisplayName();
- }
+ private int slotsNeededFor(Map<Integer, Long> slotToIsoId) {
+ if (slotToIsoId.isEmpty()) {
+ return 0;
+ }
+ return Collections.max(slotToIsoId.keySet()) -
CDROM_PRIMARY_DEVICE_SEQ + 1;
+ }
- TemplateObjectTO iso = (TemplateObjectTO)template.getTO();
- iso.setDirectDownload(template.isDirectDownload());
- iso.setGuestOsType(displayName);
- DiskTO disk = new DiskTO(iso, 3L, null, Volume.Type.ISO);
- profile.addDisk(disk);
- } else {
- TemplateObjectTO iso = new TemplateObjectTO();
- iso.setFormat(ImageFormat.ISO);
- DiskTO disk = new DiskTO(iso, 3L, null, Volume.Type.ISO);
- profile.addDisk(disk);
+ private DiskTO buildIsoDisk(VirtualMachineProfile profile, UserVmVO vm,
DeployDestination dest, Long poolId, int diskSeq, long isoId) {
+ TemplateInfo template = prepareIso(isoId, vm.getDataCenterId(),
dest.getHost().getId(), poolId);
+ if (template == null) {
+ logger.error("Failed to prepare ISO on secondary or cache
storage");
+ throw new CloudRuntimeException("Failed to prepare ISO on
secondary or cache storage");
}
+ if (diskSeq == CDROM_PRIMARY_DEVICE_SEQ && template.isBootable()) {
+ profile.setBootLoaderType(BootloaderType.CD);
+ }
+ GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId());
+ TemplateObjectTO iso = (TemplateObjectTO) template.getTO();
+ iso.setDirectDownload(template.isDirectDownload());
+ iso.setGuestOsType(guestOS != null ? guestOS.getDisplayName() : null);
+ return new DiskTO(iso, (long) diskSeq, null, Volume.Type.ISO);
+ }
+
+ private DiskTO buildEmptyCdromDisk(int diskSeq) {
+ TemplateObjectTO empty = new TemplateObjectTO();
+ empty.setFormat(ImageFormat.ISO);
+ return new DiskTO(empty, (long) diskSeq, null, Volume.Type.ISO);
}
private void prepareTemplateInOneStoragePool(final VMTemplateVO template,
final StoragePoolVO pool) {
@@ -1206,17 +1244,20 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
@Override
public boolean templateIsDeleteable(long templateId) {
+ // ISO can only be referenced by user_vm.iso_id (primary cdrom slot)
or vm_iso_map (extra slots).
+ // Templates always live on primary storage and aren't tracked here.
List<UserVmJoinVO> userVmUsingIso =
_userVmJoinDao.listActiveByIsoId(templateId);
- // check if there is any Vm using this ISO. We only need to check the
- // case where templateId is an ISO since
- // VM can be launched from ISO in secondary storage, while template
will
- // always be copied to
- // primary storage before deploying VM.
if (!userVmUsingIso.isEmpty()) {
- logger.debug("ISO " + templateId + " is not deleteable because it
is attached to " + userVmUsingIso.size() + " Instances");
+ logger.debug("Unable to delete ISO {} because it is attached to {}
Instances", templateId, userVmUsingIso.size());
return false;
}
-
+ for (VmIsoMapVO row : _vmIsoMapDao.listByIsoId(templateId)) {
+ UserVmVO vm = _userVmDao.findById(row.getVmId());
+ if (vm != null && vm.getState() != State.Error && vm.getState() !=
State.Expunging) {
+ logger.debug("Unable to delete ISO {} because it is attached
to Instance {} at slot {}", templateId, vm.getUuid(), row.getDeviceSeq());
+ return false;
+ }
+ }
return true;
}
@@ -1237,7 +1278,14 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
_accountMgr.checkAccess(caller, null, true, virtualMachine);
- Long isoId = !isVirtualRouter ? ((UserVm) virtualMachine).getIsoId() :
isoParamId;
+ Long isoId;
+ if (isVirtualRouter) {
+ isoId = isoParamId;
+ } else {
+ Long primaryIsoId = ((UserVm) virtualMachine).getIsoId();
+ List<VmIsoMapVO> extras = _vmIsoMapDao.listByVmId(vmId);
+ isoId = resolveIsoIdForDetach(primaryIsoId, extras, isoParamId);
+ }
if (isoId == null) {
throw new InvalidParameterValueException("The specified instance
has no ISO attached to it.");
}
@@ -1321,6 +1369,9 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
if (VMWARE_TOOLS_ISO.equals(iso.getUniqueName()) &&
vm.getHypervisorType() != Hypervisor.HypervisorType.VMware) {
throw new InvalidParameterValueException("Cannot attach VMware
tools drivers to incompatible hypervisor " + vm.getHypervisorType());
}
+ if (!isVirtualRouter) {
+ enforceCdromAttachLimits(vmId, (UserVm) vm, isoId);
+ }
boolean result = attachISOToVM(vmId, userId, isoId, true, forced,
isVirtualRouter);
if (result) {
return result;
@@ -1360,7 +1411,7 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
}
}
- private boolean attachISOToVM(long vmId, long isoId, boolean attach,
boolean forced, boolean isVirtualRouter) {
+ private boolean attachISOToVM(long vmId, long isoId, int deviceSeq,
boolean attach, boolean forced, boolean isVirtualRouter) {
VirtualMachine vm = !isVirtualRouter ? _userVmDao.findById(vmId) :
_vmInstanceDao.findById(vmId);
if (vm == null || (isVirtualRouter && vm.getType() !=
VirtualMachine.Type.DomainRouter)) {
@@ -1384,7 +1435,7 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
}
DataTO isoTO = tmplt.getTO();
- DiskTO disk = new DiskTO(isoTO, null, null, Volume.Type.ISO);
+ DiskTO disk = new DiskTO(isoTO, (long) deviceSeq, null,
Volume.Type.ISO);
HypervisorGuru hvGuru = _hvGuruMgr.getGuru(vm.getHypervisorType());
VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm);
@@ -1402,20 +1453,148 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
return (a != null && a.getResult());
}
- private boolean attachISOToVM(long vmId, long userId, long isoId, boolean
attach, boolean forced, boolean isVirtualRouter) {
+ boolean attachISOToVM(long vmId, long userId, long isoId, boolean attach,
boolean forced, boolean isVirtualRouter) {
UserVmVO vm = _userVmDao.findById(vmId);
VMTemplateVO iso = _tmpltDao.findById(isoId);
- boolean success = attachISOToVM(vmId, isoId, attach, forced,
isVirtualRouter);
- if (success && attach && !isVirtualRouter) {
+ int targetSlot = attach ? chooseAttachSlot(vmId, vm) :
findAttachedSlot(vmId, vm, isoId);
+ boolean success = attachISOToVM(vmId, isoId, targetSlot, attach,
forced, isVirtualRouter);
+ if (!success || isVirtualRouter) {
+ return success;
+ }
+ if (attach) {
+ persistIsoAttachment(vmId, vm, iso, targetSlot);
+ } else {
+ persistIsoDetachment(vmId, vm, isoId, targetSlot);
+ }
+ return success;
+ }
+
+ private int chooseAttachSlot(long vmId, UserVmVO vm) {
+ if (vm.getIsoId() == null) {
+ return CDROM_PRIMARY_DEVICE_SEQ;
+ }
+ VmIsoMapVO highest = highestCdromMapEntry(vmId);
+ return highest == null ? CDROM_PRIMARY_DEVICE_SEQ + 1 :
highest.getDeviceSeq() + 1;
+ }
+
+ private int findAttachedSlot(long vmId, UserVmVO vm, long isoId) {
+ if (vm.getIsoId() != null && vm.getIsoId() == isoId) {
+ return CDROM_PRIMARY_DEVICE_SEQ;
+ }
+ VmIsoMapVO entry = _vmIsoMapDao.findByVmIdIsoId(vmId, isoId);
+ return entry != null ? entry.getDeviceSeq() : CDROM_PRIMARY_DEVICE_SEQ;
+ }
+
+ private void persistIsoAttachment(long vmId, UserVmVO vm, VMTemplateVO
iso, int slot) {
+ if (slot == CDROM_PRIMARY_DEVICE_SEQ) {
vm.setIsoId(iso.getId());
_userVmDao.update(vmId, vm);
+ } else {
+ _vmIsoMapDao.persist(new VmIsoMapVO(vmId, iso.getId(), slot));
}
- if (success && !attach && !isVirtualRouter) {
+ }
+
+ private void persistIsoDetachment(long vmId, UserVmVO vm, long isoId, int
slot) {
+ if (slot == CDROM_PRIMARY_DEVICE_SEQ) {
vm.setIsoId(null);
_userVmDao.update(vmId, vm);
+ return;
}
- return success;
+ VmIsoMapVO entry = _vmIsoMapDao.findByVmIdIsoId(vmId, isoId);
+ if (entry != null) {
+ _vmIsoMapDao.remove(entry.getId());
+ }
+ }
+
+ VmIsoMapVO highestCdromMapEntry(long vmId) {
+ VmIsoMapVO highest = null;
+ for (VmIsoMapVO row : _vmIsoMapDao.listByVmId(vmId)) {
+ if (highest == null || row.getDeviceSeq() >
highest.getDeviceSeq()) {
+ highest = row;
+ }
+ }
+ return highest;
+ }
+
+ Long resolveIsoIdForDetach(Long primaryIsoId, List<VmIsoMapVO> extras,
Long isoParamId) {
+ if (isoParamId != null) {
+ boolean attached = (primaryIsoId != null &&
primaryIsoId.equals(isoParamId))
+ || extras.stream().anyMatch(r -> r.getIsoId() ==
isoParamId);
+ if (!attached) {
+ throw new InvalidParameterValueException("The specified ISO is
not attached to this Instance.");
+ }
+ return isoParamId;
+ }
+ int totalAttached = (primaryIsoId != null ? 1 : 0) + extras.size();
+ if (totalAttached == 0) {
+ throw new InvalidParameterValueException("The specified instance
has no ISO attached to it.");
+ }
+ if (totalAttached > 1) {
+ throw new InvalidParameterValueException("Instance has more than
one ISO attached; specify the 'id' parameter to choose which to detach.");
+ }
+ return primaryIsoId != null ? primaryIsoId : extras.get(0).getIsoId();
+ }
+
+ boolean isIsoAlreadyAttached(long vmId, Long primaryIsoId, long isoId) {
+ if (primaryIsoId != null && primaryIsoId.equals(isoId)) {
+ return true;
+ }
+ return _vmIsoMapDao.findByVmIdIsoId(vmId, isoId) != null;
+ }
+
+ void enforceCdromAttachLimits(long vmId, UserVm vm, long isoId) {
+ Long primaryIsoId = vm.getIsoId();
+ if (isIsoAlreadyAttached(vmId, primaryIsoId, isoId)) {
+ throw new InvalidParameterValueException("The specified ISO is
already attached to this Instance.");
+ }
+ int effectiveMax = effectiveMaxCdroms(vm, hostIdForVm(vm));
+ int attached = (primaryIsoId != null ? 1 : 0) +
_vmIsoMapDao.listByVmId(vmId).size();
+ if (attached >= effectiveMax) {
+ throw new InvalidParameterValueException(String.format(
+ "Instance has reached the maximum of %d attached
CD-ROM(s); detach one before attaching another.", effectiveMax));
+ }
+ }
+
+ int effectiveMaxCdroms(VirtualMachine vm, Long hostId) {
+ HostVO host = hostId != null ? _hostDao.findById(hostId) : null;
+ Long clusterId = host != null ? host.getClusterId() : null;
+ int configuredCap = VmIsoMaxCount.valueIn(clusterId);
+ int hypervisorCap = advertisedCdromCap(hostId);
+ if (configuredCap > hypervisorCap) {
+ logger.warn("{} is set to {} but the placement host supports a
maximum of {} CD-ROM(s) per Instance. Clamping to {}.",
+ VmIsoMaxCount.key(), configuredCap, hypervisorCap,
hypervisorCap);
+ return hypervisorCap;
+ }
+ return configuredCap;
+ }
+
+ int advertisedCdromCap(Long hostId) {
+ if (hostId == null) {
+ return DEFAULT_CDROM_MAX_PER_VM;
+ }
+ DetailVO detail = _hostDetailsDao.findDetail(hostId,
Host.HOST_CDROM_MAX_COUNT);
+ if (detail == null || detail.getValue() == null) {
+ return DEFAULT_CDROM_MAX_PER_VM;
+ }
+ try {
+ return Integer.parseInt(detail.getValue());
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid {} value '{}' for host {}; using default {}.",
+ Host.HOST_CDROM_MAX_COUNT, detail.getValue(), hostId,
DEFAULT_CDROM_MAX_PER_VM);
+ return DEFAULT_CDROM_MAX_PER_VM;
+ }
+ }
+
+ Long hostIdForVm(VirtualMachine vm) {
+ Long hostId = vm.getHostId() != null ? vm.getHostId() :
vm.getLastHostId();
+ if (hostId == null && vm.getHypervisorType() != null) {
+ List<HostVO> candidates =
_hostDao.listByDataCenterIdAndHypervisorType(vm.getDataCenterId(),
vm.getHypervisorType());
+ if (!candidates.isEmpty()) {
+ hostId = candidates.get(0).getId();
+ }
+ }
+ return hostId;
}
@Override
@@ -2538,7 +2717,8 @@ public class TemplateManagerImpl extends ManagerBase
implements TemplateManager,
return new ConfigKey<?>[] {AllowPublicUserTemplates,
TemplatePreloaderPoolSize,
ValidateUrlIsResolvableBeforeRegisteringTemplate,
- TemplateDeleteFromPrimaryStorage};
+ TemplateDeleteFromPrimaryStorage,
+ VmIsoMaxCount};
}
public List<TemplateAdapter> getTemplateAdapters() {
diff --git
a/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java
b/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java
index e4146fd2265..f657a8bbf04 100755
--- a/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java
+++ b/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java
@@ -16,10 +16,12 @@
// under the License.
package com.cloud.api.query.dao;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.MockitoAnnotations.openMocks;
import java.util.Arrays;
+import java.util.Collections;
import java.util.EnumSet;
import com.cloud.storage.dao.VMTemplateDao;
@@ -49,9 +51,11 @@ import com.cloud.user.AccountManager;
import com.cloud.user.UserStatisticsVO;
import com.cloud.user.dao.UserDao;
import com.cloud.user.dao.UserStatisticsDao;
+import com.cloud.host.dao.HostDetailsDao;
import com.cloud.utils.db.SearchBuilder;
import com.cloud.utils.db.SearchCriteria;
import com.cloud.vm.dao.VMInstanceDetailsDao;
+import com.cloud.vm.dao.VmIsoMapDao;
@RunWith(MockitoJUnitRunner.class)
public class UserVmJoinDaoImplTest extends
GenericDaoBaseWithTagInformationBaseTest<UserVmJoinVO, UserVmResponse> {
@@ -83,6 +87,12 @@ public class UserVmJoinDaoImplTest extends
GenericDaoBaseWithTagInformationBaseT
@Mock
private VMTemplateDao vmTemplateDao;
+ @Mock
+ private VmIsoMapDao vmIsoMapDao;
+
+ @Mock
+ private HostDetailsDao hostDetailsDao;
+
@Mock
ExtensionHelper extensionHelper;
@@ -103,6 +113,7 @@ public class UserVmJoinDaoImplTest extends
GenericDaoBaseWithTagInformationBaseT
@Before
public void setup() {
closeable = openMocks(this);
+
Mockito.lenient().when(vmIsoMapDao.listByVmId(anyLong())).thenReturn(Collections.emptyList());
prepareSetup();
}
@@ -166,4 +177,39 @@ public class UserVmJoinDaoImplTest extends
GenericDaoBaseWithTagInformationBaseT
Assert.assertEquals(2, response.getVnfNics().size());
Assert.assertEquals(3, response.getVnfDetails().size());
}
+
+ @Test
+ public void advertisedCdromCapReturnsDefaultWhenHostIdNull() {
+
Assert.assertEquals(com.cloud.template.TemplateManager.DEFAULT_CDROM_MAX_PER_VM,
+ _userVmJoinDaoImpl.advertisedCdromCap(null));
+ }
+
+ @Test
+ public void advertisedCdromCapReturnsParsedValue() {
+ com.cloud.host.DetailVO detail =
Mockito.mock(com.cloud.host.DetailVO.class);
+ Mockito.when(detail.getValue()).thenReturn("2");
+ Mockito.when(hostDetailsDao.findDetail(7L,
com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail);
+ Assert.assertEquals(2, _userVmJoinDaoImpl.advertisedCdromCap(7L));
+ }
+
+ @Test
+ public void advertisedCdromCapFallsBackOnInvalidValue() {
+ com.cloud.host.DetailVO detail =
Mockito.mock(com.cloud.host.DetailVO.class);
+ Mockito.when(detail.getValue()).thenReturn("xyz");
+ Mockito.when(hostDetailsDao.findDetail(7L,
com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail);
+
Assert.assertEquals(com.cloud.template.TemplateManager.DEFAULT_CDROM_MAX_PER_VM,
+ _userVmJoinDaoImpl.advertisedCdromCap(7L));
+ }
+
+ @Test
+ public void effectiveCdromMaxCountClampsToHypervisorCap() {
+ UserVmJoinVO userVm = Mockito.mock(UserVmJoinVO.class);
+ Mockito.when(userVm.getHostId()).thenReturn(7L);
+ Mockito.when(userVm.getClusterId()).thenReturn(5L);
+ com.cloud.host.DetailVO detail =
Mockito.mock(com.cloud.host.DetailVO.class);
+ Mockito.when(detail.getValue()).thenReturn("2");
+ Mockito.when(hostDetailsDao.findDetail(7L,
com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail);
+ // Configured cap defaults to 1 (no cluster override mocked); host
advertises 2; clamps to 1.
+ Assert.assertEquals(1,
_userVmJoinDaoImpl.effectiveCdromMaxCount(userVm));
+ }
}
diff --git
a/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java
b/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java
index 6288180a9f4..47099c371dc 100755
--- a/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java
+++ b/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java
@@ -22,6 +22,7 @@ package com.cloud.template;
import com.cloud.agent.AgentManager;
import com.cloud.api.query.dao.SnapshotJoinDao;
import com.cloud.api.query.dao.UserVmJoinDao;
+import com.cloud.api.query.vo.UserVmJoinVO;
import com.cloud.dc.dao.DataCenterDao;
import com.cloud.deployasis.dao.TemplateDeployAsIsDetailsDao;
import com.cloud.domain.dao.DomainDao;
@@ -29,7 +30,11 @@ import com.cloud.event.dao.UsageEventDao;
import com.cloud.exception.InvalidParameterValueException;
import com.cloud.exception.ResourceAllocationException;
import com.cloud.host.Status;
+import com.cloud.host.DetailVO;
+import com.cloud.host.Host;
+import com.cloud.host.HostVO;
import com.cloud.host.dao.HostDao;
+import com.cloud.host.dao.HostDetailsDao;
import com.cloud.hypervisor.Hypervisor;
import com.cloud.hypervisor.HypervisorGuruManager;
import com.cloud.projects.ProjectManager;
@@ -66,9 +71,15 @@ import com.cloud.user.UserVO;
import com.cloud.user.dao.AccountDao;
import com.cloud.utils.concurrency.NamedThreadFactory;
import com.cloud.utils.exception.CloudRuntimeException;
+import com.cloud.uservm.UserVm;
+import com.cloud.vm.UserVmVO;
import com.cloud.vm.VMInstanceVO;
+import com.cloud.vm.VirtualMachine;
+import com.cloud.vm.VirtualMachine.State;
+import com.cloud.vm.VmIsoMapVO;
import com.cloud.vm.dao.UserVmDao;
import com.cloud.vm.dao.VMInstanceDao;
+import com.cloud.vm.dao.VmIsoMapDao;
import junit.framework.TestCase;
@@ -133,6 +144,7 @@ import org.springframework.core.type.filter.TypeFilter;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
@@ -220,6 +232,21 @@ public class TemplateManagerImplTest extends TestCase {
@Mock
HeuristicRuleHelper heuristicRuleHelperMock;
+ @Mock
+ UserVmDao _userVmDao;
+
+ @Mock
+ VmIsoMapDao _vmIsoMapDao;
+
+ @Mock
+ HostDao _hostDao;
+
+ @Mock
+ HostDetailsDao _hostDetailsDao;
+
+ @Mock
+ UserVmJoinDao _userVmJoinDao;
+
public class CustomThreadPoolExecutor extends ThreadPoolExecutor {
AtomicInteger ai = new AtomicInteger(0);
public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
@@ -750,6 +777,222 @@ public class TemplateManagerImplTest extends TestCase {
Mockito.verify(heuristicRuleHelperMock,
Mockito.times(1)).getImageStoreIfThereIsHeuristicRule(1L,
HeuristicType.TEMPLATE, vmTemplateVOMock);
}
+ @Test
+ public void highestCdromMapEntryReturnsNullWhenMapIsEmpty() {
+ Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(new
ArrayList<>());
+ Assert.assertNull(templateManager.highestCdromMapEntry(1L));
+ }
+
+ @Test
+ public void highestCdromMapEntryReturnsEntryWithMaxDeviceSeq() {
+ VmIsoMapVO low = new VmIsoMapVO(1L, 100L, 4);
+ VmIsoMapVO high = new VmIsoMapVO(1L, 200L, 5);
+
Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(Arrays.asList(low, high));
+ VmIsoMapVO result = templateManager.highestCdromMapEntry(1L);
+ Assert.assertNotNull(result);
+ Assert.assertEquals(5, result.getDeviceSeq());
+ }
+
+ @Test
+ public void attachISOToVMAttachWritesToIsoIdWhenPrimarySlotEmpty() {
+ UserVmVO vm = Mockito.mock(UserVmVO.class);
+ VMTemplateVO iso = Mockito.mock(VMTemplateVO.class);
+ Mockito.when(_userVmDao.findById(1L)).thenReturn(vm);
+ Mockito.when(vmTemplateDao.findById(42L)).thenReturn(iso);
+ Mockito.when(iso.getId()).thenReturn(42L);
+ Mockito.when(vm.getIsoId()).thenReturn(null);
+
+ boolean result = templateManager.attachISOToVM(1L, 1L, 42L, true,
false, false);
+
+ Assert.assertTrue(result);
+ Mockito.verify(vm).setIsoId(42L);
+ Mockito.verify(_userVmDao).update(eq(1L), eq(vm));
+ Mockito.verify(_vmIsoMapDao,
Mockito.never()).persist(any(VmIsoMapVO.class));
+ }
+
+ @Test
+ public void resolveIsoIdForDetachReturnsPrimaryWhenOnlyPrimaryIsAttached()
{
+ Long resolved = templateManager.resolveIsoIdForDetach(99L, new
ArrayList<>(), null);
+ Assert.assertEquals(Long.valueOf(99L), resolved);
+ }
+
+ @Test
+ public void resolveIsoIdForDetachReturnsMapEntryWhenOnlyMapHasOne() {
+ VmIsoMapVO row = new VmIsoMapVO(1L, 100L, 4);
+ Long resolved = templateManager.resolveIsoIdForDetach(null,
Arrays.asList(row), null);
+ Assert.assertEquals(Long.valueOf(100L), resolved);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void resolveIsoIdForDetachThrowsWhenMultipleAttachedAndNoIdGiven() {
+ VmIsoMapVO row = new VmIsoMapVO(1L, 100L, 4);
+ templateManager.resolveIsoIdForDetach(99L, Arrays.asList(row), null);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void resolveIsoIdForDetachThrowsWhenNothingAttached() {
+ templateManager.resolveIsoIdForDetach(null, new ArrayList<>(), null);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void resolveIsoIdForDetachThrowsWhenIdNotAttached() {
+ templateManager.resolveIsoIdForDetach(99L, new ArrayList<>(), 42L);
+ }
+
+ @Test
+ public void isIsoAlreadyAttachedReturnsTrueWhenPrimaryMatches() {
+ Assert.assertTrue(templateManager.isIsoAlreadyAttached(1L, 42L, 42L));
+ }
+
+ @Test
+ public void isIsoAlreadyAttachedReturnsTrueWhenInMap() {
+ Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(new
VmIsoMapVO(1L, 42L, 4));
+ Assert.assertTrue(templateManager.isIsoAlreadyAttached(1L, 99L, 42L));
+ }
+
+ @Test
+ public void isIsoAlreadyAttachedReturnsFalseWhenNotAttached() {
+ Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(null);
+ Assert.assertFalse(templateManager.isIsoAlreadyAttached(1L, null,
42L));
+ }
+
+ @Test
+ public void attachISOToVMAttachWritesToVmIsoMapWhenPrimarySlotOccupied() {
+ UserVmVO vm = Mockito.mock(UserVmVO.class);
+ VMTemplateVO iso = Mockito.mock(VMTemplateVO.class);
+ Mockito.when(_userVmDao.findById(1L)).thenReturn(vm);
+ Mockito.when(vmTemplateDao.findById(42L)).thenReturn(iso);
+ Mockito.when(iso.getId()).thenReturn(42L);
+ Mockito.when(vm.getIsoId()).thenReturn(99L);
+ Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(new
ArrayList<>());
+
+ boolean result = templateManager.attachISOToVM(1L, 1L, 42L, true,
false, false);
+
+ Assert.assertTrue(result);
+ Mockito.verify(_vmIsoMapDao).persist(Mockito.argThat(row ->
+ row.getVmId() == 1L && row.getIsoId() == 42L
+ && row.getDeviceSeq() ==
TemplateManager.CDROM_PRIMARY_DEVICE_SEQ + 1));
+ Mockito.verify(vm, Mockito.never()).setIsoId(anyLong());
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void
enforceCdromAttachLimitsThrowsWhenIsoAlreadyAttachedAtPrimary() {
+ UserVm vm = Mockito.mock(UserVm.class);
+ Mockito.when(vm.getIsoId()).thenReturn(42L);
+ templateManager.enforceCdromAttachLimits(1L, vm, 42L);
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void enforceCdromAttachLimitsThrowsWhenIsoAlreadyAttachedInMap() {
+ UserVm vm = Mockito.mock(UserVm.class);
+ Mockito.when(vm.getIsoId()).thenReturn(99L);
+ Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(new
VmIsoMapVO(1L, 42L, 4));
+ templateManager.enforceCdromAttachLimits(1L, vm, 42L);
+ }
+
+ @Test
+ public void advertisedCdromCapReturnsDefaultWhenHostIdNull() {
+ Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM,
templateManager.advertisedCdromCap(null));
+ }
+
+ @Test
+ public void advertisedCdromCapReturnsDefaultWhenDetailMissing() {
+ Mockito.when(_hostDetailsDao.findDetail(7L,
Host.HOST_CDROM_MAX_COUNT)).thenReturn(null);
+ Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM,
templateManager.advertisedCdromCap(7L));
+ }
+
+ @Test
+ public void advertisedCdromCapReturnsParsedValue() {
+ DetailVO detail = Mockito.mock(DetailVO.class);
+ Mockito.when(detail.getValue()).thenReturn("3");
+ Mockito.when(_hostDetailsDao.findDetail(7L,
Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail);
+ Assert.assertEquals(3, templateManager.advertisedCdromCap(7L));
+ }
+
+ @Test
+ public void advertisedCdromCapFallsBackOnInvalidValue() {
+ DetailVO detail = Mockito.mock(DetailVO.class);
+ Mockito.when(detail.getValue()).thenReturn("not-a-number");
+ Mockito.when(_hostDetailsDao.findDetail(7L,
Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail);
+ Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM,
templateManager.advertisedCdromCap(7L));
+ }
+
+ @Test
+ public void hostIdForVmReturnsCurrentHost() {
+ VirtualMachine vm = Mockito.mock(VirtualMachine.class);
+ Mockito.when(vm.getHostId()).thenReturn(42L);
+ Assert.assertEquals(Long.valueOf(42L),
templateManager.hostIdForVm(vm));
+ }
+
+ @Test
+ public void hostIdForVmFallsBackToLastHost() {
+ VirtualMachine vm = Mockito.mock(VirtualMachine.class);
+ Mockito.when(vm.getHostId()).thenReturn(null);
+ Mockito.when(vm.getLastHostId()).thenReturn(99L);
+ Assert.assertEquals(Long.valueOf(99L),
templateManager.hostIdForVm(vm));
+ }
+
+ @Test
+ public void hostIdForVmReturnsNullWhenNoHost() {
+ VirtualMachine vm = Mockito.mock(VirtualMachine.class);
+ Mockito.when(vm.getHostId()).thenReturn(null);
+ Mockito.when(vm.getLastHostId()).thenReturn(null);
+ Assert.assertNull(templateManager.hostIdForVm(vm));
+ }
+
+ @Test
+ public void
effectiveMaxCdromsReturnsConfiguredCapWhenWithinHypervisorCap() {
+ VirtualMachine vm = Mockito.mock(VirtualMachine.class);
+ DetailVO detail = Mockito.mock(DetailVO.class);
+ Mockito.when(detail.getValue()).thenReturn("2");
+ HostVO host = Mockito.mock(HostVO.class);
+ Mockito.when(host.getClusterId()).thenReturn(5L);
+ Mockito.when(_hostDao.findById(7L)).thenReturn(host);
+ Mockito.when(_hostDetailsDao.findDetail(7L,
Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail);
+ // Configured cap defaults to 1 (no cluster override mocked);
hypervisor cap is 2; 1 <= 2 → no throw, returns 1.
+ Assert.assertEquals(1, templateManager.effectiveMaxCdroms(vm, 7L));
+ }
+
+ @Test
+ public void templateIsDeleteableReturnsTrueWhenNoVmsUseIso() {
+ Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new
ArrayList<>());
+ Mockito.when(_vmIsoMapDao.listByIsoId(42L)).thenReturn(new
ArrayList<>());
+ Assert.assertTrue(templateManager.templateIsDeleteable(42L));
+ }
+
+ @Test
+ public void templateIsDeleteableReturnsFalseWhenPrimarySlotInUse() {
+ Mockito.when(_userVmJoinDao.listActiveByIsoId(42L))
+
.thenReturn(java.util.Collections.singletonList(Mockito.mock(UserVmJoinVO.class)));
+ Assert.assertFalse(templateManager.templateIsDeleteable(42L));
+ // Should not even need to consult vm_iso_map once primary slot in use.
+ Mockito.verify(_vmIsoMapDao, Mockito.never()).listByIsoId(anyLong());
+ }
+
+ @Test
+ public void
templateIsDeleteableReturnsFalseWhenAttachedViaVmIsoMapToActiveVm() {
+ Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new
ArrayList<>());
+ Mockito.when(_vmIsoMapDao.listByIsoId(42L))
+ .thenReturn(java.util.Collections.singletonList(new
VmIsoMapVO(1L, 42L, 4)));
+ UserVmVO vm = Mockito.mock(UserVmVO.class);
+ Mockito.when(vm.getState()).thenReturn(State.Running);
+ Mockito.when(vm.getUuid()).thenReturn("uuid-1");
+ Mockito.when(_userVmDao.findById(1L)).thenReturn(vm);
+ Assert.assertFalse(templateManager.templateIsDeleteable(42L));
+ }
+
+ @Test
+ public void templateIsDeleteableIgnoresVmIsoMapForDestroyedVm() {
+ Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new
ArrayList<>());
+ Mockito.when(_vmIsoMapDao.listByIsoId(42L))
+ .thenReturn(java.util.Collections.singletonList(new
VmIsoMapVO(1L, 42L, 4)));
+ UserVmVO vm = Mockito.mock(UserVmVO.class);
+ Mockito.when(vm.getState()).thenReturn(State.Expunging);
+ Mockito.when(_userVmDao.findById(1L)).thenReturn(vm);
+ Assert.assertTrue(templateManager.templateIsDeleteable(42L));
+ }
+
+
@Configuration
@ComponentScan(basePackageClasses = {TemplateManagerImpl.class},
includeFilters = {@ComponentScan.Filter(value =
TestConfiguration.Library.class, type = FilterType.CUSTOM)},
diff --git a/ui/src/config/section/compute.js b/ui/src/config/section/compute.js
index 6b7a5428b1f..d054d2d3db4 100644
--- a/ui/src/config/section/compute.js
+++ b/ui/src/config/section/compute.js
@@ -22,6 +22,15 @@ import { getAPI, postAPI, getBaseUrl } from '@/api'
import { getLatestKubernetesIsoParams } from '@/utils/acsrepo'
import kubernetesIcon from '@/assets/icons/kubernetes.svg?inline'
+const attachedIsoCount = (record) => (record.isos && record.isos.length) ||
(record.isoid ? 1 : 0)
+// Server pre-computes the effective cap (cluster-scoped vm.iso.max.count
clamped to the
+// hypervisor's own limit). Fall back to the hypervisor floor for older
servers.
+const isoMaxCount = (record) => record.isomaxcount != null
+ ? record.isomaxcount
+ : (record.hypervisor === 'KVM' ? 2 : 1)
+const isoActionAvailable = (record) =>
+ record.hypervisor !== 'External' && ['Running',
'Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm'
+
export default {
name: 'compute',
title: 'label.compute',
@@ -299,7 +308,7 @@ export default {
docHelp: 'adminguide/templates.html#attaching-an-iso-to-a-vm',
dataView: true,
popup: true,
- show: (record) => { return record.hypervisor !== 'External' &&
['Running', 'Stopped'].includes(record.state) && !record.isoid && record.vmtype
!== 'sharedfsvm' },
+ show: (record) => isoActionAvailable(record) &&
attachedIsoCount(record) < isoMaxCount(record),
disabled: (record) => { return record.hostcontrolstate === 'Offline'
|| record.hostcontrolstate === 'Maintenance' },
component: shallowRef(defineAsyncComponent(() =>
import('@/views/compute/AttachIso.vue')))
},
@@ -307,22 +316,11 @@ export default {
api: 'detachIso',
icon: 'link-outlined',
label: 'label.action.detach.iso',
- message: 'message.detach.iso.confirm',
dataView: true,
- args: (record, store) => {
- var args = ['virtualmachineid']
- if (record && record.hypervisor && record.hypervisor === 'VMware')
{
- args.push('forced')
- }
- return args
- },
- show: (record) => { return record.hypervisor !== 'External' &&
['Running', 'Stopped'].includes(record.state) && 'isoid' in record &&
record.isoid && record.vmtype !== 'sharedfsvm' },
+ popup: true,
+ show: (record) => isoActionAvailable(record) &&
attachedIsoCount(record) > 0,
disabled: (record) => { return record.hostcontrolstate === 'Offline'
|| record.hostcontrolstate === 'Maintenance' },
- mapping: {
- virtualmachineid: {
- value: (record, params) => { return record.id }
- }
- }
+ component: shallowRef(defineAsyncComponent(() =>
import('@/views/compute/DetachIso.vue')))
},
{
api: 'updateVMAffinityGroup',
diff --git a/ui/src/views/compute/AttachIso.vue
b/ui/src/views/compute/AttachIso.vue
index 60694cb8f57..daa555c4538 100644
--- a/ui/src/views/compute/AttachIso.vue
+++ b/ui/src/views/compute/AttachIso.vue
@@ -17,23 +17,38 @@
<template>
<div class="form-layout" v-ctrl-enter="handleSubmit">
<a-spin :spinning="loading">
+ <a-alert
+ v-if="!loading && maxSelections === 0"
+ type="warning"
+ showIcon
+ :message="$t('label.iso.name') + ': max reached'"
+ style="margin-bottom: 12px;" />
<a-form
:ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
@finish="handleSubmit">
- <a-form-item :label="$t('label.iso.name')" ref="id" name="id">
+ <a-form-item
+ :label="$t('label.iso.name') + ' (' + form.ids.length + ' / ' +
maxSelections + ')'"
+ ref="ids"
+ name="ids">
<a-select
+ mode="multiple"
:loading="loading"
- v-model:value="form.id"
+ v-model:value="form.ids"
v-focus="true"
+ :disabled="maxSelections === 0"
showSearch
optionFilterProp="label"
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase())
>= 0
}">
- <a-select-option v-for="iso in isos" :key="iso.id"
:label="iso.displaytext || iso.name">
+ <a-select-option
+ v-for="iso in isos"
+ :key="iso.id"
+ :label="iso.displaytext || iso.name"
+ :disabled="form.ids.length >= maxSelections &&
!form.ids.includes(iso.id)">
{{ iso.displaytext || iso.name }}
</a-select-option>
</a-select>
@@ -69,19 +84,44 @@ export default {
data () {
return {
loading: false,
- isos: []
+ isos: [],
+ maxSelections: 1
}
},
created () {
this.initForm()
+ this.computeMaxSelections()
this.fetchData()
},
+ watch: {
+ 'form.ids' (newVal) {
+ if (newVal && newVal.length > this.maxSelections) {
+ this.form.ids = newVal.slice(0, this.maxSelections)
+ this.$message.warning(this.$t('label.iso.name') + ': max ' +
this.maxSelections)
+ }
+ }
+ },
methods: {
+ computeMaxSelections () {
+ // Server pre-computes the effective cap (cluster-scoped
vm.iso.max.count clamped to
+ // the hypervisor's own limit) and exposes it on the VM as isomaxcount.
+ const effectiveCap = this.resource.isomaxcount != null
+ ? this.resource.isomaxcount
+ : (this.resource.hypervisor === 'KVM' ? 2 : 1)
+ const alreadyAttached = (this.resource.isos &&
this.resource.isos.length) ||
+ (this.resource.isoid ? 1 : 0)
+ this.maxSelections = Math.max(0, effectiveCap - alreadyAttached)
+ },
initForm () {
this.formRef = ref()
- this.form = reactive({})
+ this.form = reactive({ ids: [] })
this.rules = reactive({
- id: [{ required: true, message: `${this.$t('label.required')}` }]
+ ids: [{
+ required: true,
+ type: 'array',
+ min: 1,
+ message: `${this.$t('label.required')}`
+ }]
})
},
fetchData () {
@@ -93,9 +133,6 @@ export default {
})
Promise.all(promises).then(() => {
this.isos = _.uniqBy(this.isos, 'id')
- if (this.isos.length > 0) {
- this.form.id = this.isos[0].id
- }
}).catch((error) => {
console.log(error)
}).finally(() => {
@@ -127,35 +164,42 @@ export default {
if (this.loading) return
this.formRef.value.validate().then(() => {
const values = toRaw(this.form)
- const params = {
- id: values.id,
- virtualmachineid: this.resource.id
- }
-
- if (values.forced) {
- params.forced = values.forced
- }
+ const ids = values.ids || []
+ if (ids.length === 0) return
this.loading = true
const title = this.$t('label.action.attach.iso')
- postAPI('attachIso', params).then(json => {
- const jobId = json.attachisoresponse.jobid
- if (jobId) {
- this.$pollJob({
- jobId,
- title,
- description: values.id,
- successMessage: `${this.$t('label.action.attach.iso')}
${this.$t('label.success')}`,
- loadingMessage: `${title} ${this.$t('label.in.progress')}`,
- catchMessage: this.$t('error.fetching.async.job.result')
- })
+ // attachIso is single-ISO server-side; fan out one call per selection.
+ const sendOne = (isoId) => {
+ const params = {
+ id: isoId,
+ virtualmachineid: this.resource.id
}
- this.closeAction()
- }).catch(error => {
- this.$notifyError(error)
- }).finally(() => {
- this.loading = false
- })
+ if (values.forced) {
+ params.forced = values.forced
+ }
+ return new Promise((resolve, reject) => {
+ postAPI('attachIso', params).then(json => {
+ const jobId = json.attachisoresponse &&
json.attachisoresponse.jobid
+ if (jobId) {
+ this.$pollJob({
+ jobId,
+ title,
+ description: isoId,
+ successMessage: `${this.$t('label.action.attach.iso')}
${this.$t('label.success')}`,
+ loadingMessage: `${title} ${this.$t('label.in.progress')}`,
+ catchMessage: this.$t('error.fetching.async.job.result')
+ })
+ }
+ resolve()
+ }).catch(reject)
+ })
+ }
+
+ ids.reduce((p, id) => p.then(() => sendOne(id)), Promise.resolve())
+ .then(() => { this.closeAction() })
+ .catch(error => { this.$notifyError(error) })
+ .finally(() => { this.loading = false })
}).catch(error => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
diff --git a/ui/src/views/compute/DetachIso.vue
b/ui/src/views/compute/DetachIso.vue
new file mode 100644
index 00000000000..1e9f3cf1bc0
--- /dev/null
+++ b/ui/src/views/compute/DetachIso.vue
@@ -0,0 +1,178 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+<template>
+ <div class="form-layout" v-ctrl-enter="handleSubmit">
+ <a-spin :spinning="loading">
+ <a-form
+ :ref="formRef"
+ :model="form"
+ :rules="rules"
+ layout="vertical"
+ @finish="handleSubmit">
+ <a-form-item
+ :label="$t('label.iso.name') + ' (' + form.ids.length + ' / ' +
attached.length + ')'"
+ ref="ids"
+ name="ids">
+ <a-select
+ mode="multiple"
+ :loading="loading"
+ v-model:value="form.ids"
+ v-focus="true">
+ <a-select-option
+ v-for="iso in attached"
+ :key="iso.id"
+ :label="iso.displaytext || iso.name">
+ {{ (iso.displaytext || iso.name) + ' (' +
slotLabel(iso.deviceseq) + ')' }}
+ </a-select-option>
+ </a-select>
+ </a-form-item>
+ <a-form-item
+ :label="$t('label.forced')"
+ v-if="resource && resource.hypervisor === 'VMware'"
+ ref="forced"
+ name="forced">
+ <a-switch v-model:checked="form.forced" v-focus="true" />
+ </a-form-item>
+ </a-form>
+ <div :span="24" class="action-button">
+ <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+ <a-button :loading="loading" type="primary" @click="handleSubmit"
ref="submit">{{ $t('label.ok') }}</a-button>
+ </div>
+ </a-spin>
+ </div>
+</template>
+<script>
+import { ref, reactive, toRaw } from 'vue'
+import { postAPI } from '@/api'
+
+export default {
+ name: 'DetachIso',
+ props: {
+ resource: {
+ type: Object,
+ required: true
+ }
+ },
+ data () {
+ return {
+ loading: false,
+ attached: []
+ }
+ },
+ created () {
+ this.initForm()
+ this.populateAttached()
+ },
+ methods: {
+ initForm () {
+ this.formRef = ref()
+ this.form = reactive({ ids: [] })
+ this.rules = reactive({
+ ids: [{
+ required: true,
+ type: 'array',
+ min: 1,
+ message: `${this.$t('label.required')}`
+ }]
+ })
+ },
+ populateAttached () {
+ if (this.resource.isos && this.resource.isos.length > 0) {
+ this.attached = [...this.resource.isos].sort((a, b) => (a.deviceseq ||
0) - (b.deviceseq || 0))
+ } else if (this.resource.isoid) {
+ this.attached = [{
+ id: this.resource.isoid,
+ name: this.resource.isoname,
+ displaytext: this.resource.isodisplaytext,
+ deviceseq: 3
+ }]
+ }
+ if (this.attached.length === 1) {
+ this.form.ids = [this.attached[0].id]
+ }
+ },
+ slotLabel (deviceseq) {
+ // 3 -> hdc, 4 -> hdd, ... matches LibvirtVMDef.getDevLabel for the IDE
bus on KVM.
+ if (typeof deviceseq !== 'number') return ''
+ return 'hd' + String.fromCharCode('a'.charCodeAt(0) + deviceseq - 1)
+ },
+ closeAction () {
+ this.$emit('close-action')
+ },
+ handleSubmit (e) {
+ e.preventDefault()
+ if (this.loading) return
+ this.formRef.value.validate().then(() => {
+ const values = toRaw(this.form)
+ const ids = values.ids || []
+ if (ids.length === 0) return
+
+ this.loading = true
+ const title = this.$t('label.action.detach.iso')
+ // detachIso is single-ISO server-side; fan out one call per selection.
+ const sendOne = (isoId) => {
+ const params = {
+ virtualmachineid: this.resource.id
+ }
+ // Single-attached: omit id so older servers (without the id
parameter) still accept the call.
+ if (this.attached.length > 1 || ids.length > 1) {
+ params.id = isoId
+ }
+ if (values.forced) {
+ params.forced = values.forced
+ }
+ return new Promise((resolve, reject) => {
+ postAPI('detachIso', params).then(json => {
+ const jobId = json.detachisoresponse &&
json.detachisoresponse.jobid
+ if (jobId) {
+ this.$pollJob({
+ jobId,
+ title,
+ description: isoId,
+ successMessage: `${this.$t('label.action.detach.iso')}
${this.$t('label.success')}`,
+ loadingMessage: `${title} ${this.$t('label.in.progress')}`,
+ catchMessage: this.$t('error.fetching.async.job.result')
+ })
+ }
+ resolve()
+ }).catch(reject)
+ })
+ }
+
+ ids.reduce((p, id) => p.then(() => sendOne(id)), Promise.resolve())
+ .then(() => { this.closeAction() })
+ .catch(error => { this.$notifyError(error) })
+ .finally(() => { this.loading = false })
+ }).catch(error => {
+ this.formRef.value.scrollToField(error.errorFields[0].name)
+ })
+ }
+ }
+}
+</script>
+<style lang="scss" scoped>
+.form-layout {
+ width: 80vw;
+ @media (min-width: 700px) {
+ width: 600px;
+ }
+}
+
+.form {
+ margin: 10px 0;
+}
+</style>
diff --git a/ui/src/views/compute/InstanceTab.vue
b/ui/src/views/compute/InstanceTab.vue
index 9576e70c8d5..24c1a08056f 100644
--- a/ui/src/views/compute/InstanceTab.vue
+++ b/ui/src/views/compute/InstanceTab.vue
@@ -28,10 +28,15 @@
<a-tab-pane :tab="$t('label.metrics')" key="stats">
<StatsTab :resource="resource"/>
</a-tab-pane>
- <a-tab-pane :tab="$t('label.iso')" key="cdrom" v-if="vm.isoid">
- <usb-outlined />
- <router-link :to="{ path: '/iso/' + vm.isoid }">{{ vm.isoname
}}</router-link> <br/>
- <barcode-outlined /> {{ vm.isoid }}
+ <a-tab-pane :tab="$t('label.iso')" key="cdrom" v-if="attachedIsos.length
> 0">
+ <div v-for="iso in attachedIsos" :key="iso.id" style="margin-bottom:
12px;">
+ <usb-outlined />
+ <router-link :to="{ path: '/iso/' + iso.id }">{{ iso.displaytext ||
iso.name }}</router-link>
+ <a-tag style="margin-left: 8px;">{{ slotLabel(iso.deviceseq)
}}</a-tag>
+ <a-tag v-if="iso.bootable" color="blue" style="margin-left: 4px;">{{
$t('label.bootable') }}</a-tag>
+ <br/>
+ <barcode-outlined /> {{ iso.id }}
+ </div>
</a-tab-pane>
<a-tab-pane :tab="$t('label.volumes')" key="volumes" v-if="'listVolumes'
in $store.getters.apis">
<a-button
@@ -226,7 +231,28 @@ export default {
mounted () {
this.setCurrentTab()
},
+ computed: {
+ attachedIsos () {
+ if (this.vm.isos && this.vm.isos.length > 0) {
+ return [...this.vm.isos].sort((a, b) => (a.deviceseq || 0) -
(b.deviceseq || 0))
+ }
+ if (this.vm.isoid) {
+ return [{
+ id: this.vm.isoid,
+ name: this.vm.isoname,
+ displaytext: this.vm.isodisplaytext,
+ deviceseq: 3
+ }]
+ }
+ return []
+ }
+ },
methods: {
+ slotLabel (deviceseq) {
+ // 3 -> hdc, 4 -> hdd, ... matches LibvirtVMDef.getDevLabel for the IDE
bus on KVM.
+ if (typeof deviceseq !== 'number') return ''
+ return 'hd' + String.fromCharCode('a'.charCodeAt(0) + deviceseq - 1)
+ },
setCurrentTab () {
this.currentTab = this.$route.query.tab ? this.$route.query.tab :
'details'
},
diff --git a/ui/src/views/setting/ConfigurationValue.vue
b/ui/src/views/setting/ConfigurationValue.vue
index d9e36d9af53..5a6f0b025ca 100644
--- a/ui/src/views/setting/ConfigurationValue.vue
+++ b/ui/src/views/setting/ConfigurationValue.vue
@@ -295,13 +295,14 @@ export default {
params[this.scopeKey] = this.resource?.id
}
postAPI('updateConfiguration', params).then(json => {
- configRecordEntry = json.updateconfigurationresponse.configuration
+ const apiRecord = json.updateconfigurationresponse.configuration
+ configRecordEntry = { ...apiRecord, value: String(newValue) }
this.editableValue = this.getEditableValue(configRecordEntry)
this.actualValue = this.editableValue
this.$emit('change-config', { value: newValue })
this.$store.dispatch('RefreshFeatures')
this.$messageConfigSuccess(`${this.$t('message.setting.updated')}
${configrecord.name}`, configrecord)
-
this.$notifyConfigurationValueChange(json?.updateconfigurationresponse?.configuration
|| null)
+ this.$notifyConfigurationValueChange(configRecordEntry)
}).catch(error => {
this.editableValue = this.actualValue
console.error(error)