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)

Reply via email to