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
This commit is contained in:
Daman Arora 2026-06-24 07:08:26 -04:00 committed by GitHub
parent 21e4475d96
commit ea6cbada9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1360 additions and 117 deletions

View File

@ -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";

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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" />

View File

@ -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');

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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) {

View File

@ -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) {

View File

@ -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,47 +689,75 @@ 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();
}
}
template = prepareIso(vm.getIsoId(), vm.getDataCenterId(), dest.getHost().getId(), poolId);
Map<Integer, Long> slotToIsoId = loadAttachedIsoSlots(vm);
Long poolId = slotToIsoId.isEmpty() ? null : singleStoragePoolId(dest);
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);
}
GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId());
String displayName = null;
if (guestOS != null) {
displayName = guestOS.getDisplayName();
}
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);
// 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();
}
}
return poolId;
}
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;
}
private int slotsNeededFor(Map<Integer, Long> slotToIsoId) {
if (slotToIsoId.isEmpty()) {
return 0;
}
return Collections.max(slotToIsoId.keySet()) - CDROM_PRIMARY_DEVICE_SEQ + 1;
}
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) {
logger.info("Schedule to preload Template {} into primary storage {}", template, pool);
if (pool.getPoolType() == Storage.StoragePoolType.DatastoreCluster) {
@ -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,22 +1453,150 @@ 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) {
vm.setIsoId(iso.getId());
_userVmDao.update(vmId, vm);
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 (success && !attach && !isVirtualRouter) {
vm.setIsoId(null);
_userVmDao.update(vmId, vm);
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));
}
}
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;
}
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
@ActionEvent(eventType = EventTypes.EVENT_TEMPLATE_DELETE, eventDescription = "Deleting Template", async = true)
public boolean deleteTemplate(DeleteTemplateCmd cmd) {
@ -2538,7 +2717,8 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager,
return new ConfigKey<?>[] {AllowPublicUserTemplates,
TemplatePreloaderPoolSize,
ValidateUrlIsResolvableBeforeRegisteringTemplate,
TemplateDeleteFromPrimaryStorage};
TemplateDeleteFromPrimaryStorage,
VmIsoMaxCount};
}
public List<TemplateAdapter> getTemplateAdapters() {

View File

@ -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));
}
}

View File

@ -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)},

View File

@ -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',

View File

@ -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)
})

View File

@ -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>

View File

@ -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'
},

View File

@ -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)