From d6a77a72f00d01e82b536c2b9d43c00690ee96fd Mon Sep 17 00:00:00 2001 From: slavkap <51903378+slavkap@users.noreply.github.com> Date: Mon, 2 Aug 2021 16:48:07 +0300 Subject: [PATCH] Allow updating the storage/host tags of service offerings (#5043) --- .../apache/cloudstack/api/ApiConstants.java | 1 + .../offering/UpdateServiceOfferingCmd.java | 24 +++++ .../api/response/ServiceOfferingResponse.java | 2 +- .../main/java/com/cloud/host/dao/HostDao.java | 2 + .../java/com/cloud/host/dao/HostDaoImpl.java | 25 ++++++ .../datastore/db/PrimaryDataStoreDao.java | 1 + .../datastore/db/PrimaryDataStoreDaoImpl.java | 26 ++++++ .../ConfigurationManagerImpl.java | 88 +++++++++++++------ .../ConfigurationManagerTest.java | 12 +-- .../smoke/test_service_offerings.py | 15 ++++ ui/src/config/section/offering.js | 4 +- ui/src/views/AutogenView.vue | 6 +- 12 files changed, 165 insertions(+), 41 deletions(-) diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index b060b5a2176..6d64fe9778b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -357,6 +357,7 @@ public class ApiConstants { public static final String SWAP_OWNER = "swapowner"; public static final String SYSTEM_VM_TYPE = "systemvmtype"; public static final String TAGS = "tags"; + public static final String STORAGE_TAGS = "storagetags"; public static final String TARGET_IQN = "targetiqn"; public static final String TEMPLATE_FILTER = "templatefilter"; public static final String TEMPLATE_ID = "templateid"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java index 43a0666e934..4212a0059e2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java @@ -19,12 +19,14 @@ package org.apache.cloudstack.api.command.admin.offering; import java.util.ArrayList; import java.util.List; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.BaseCmd.CommandType; import org.apache.cloudstack.api.response.ServiceOfferingResponse; import org.apache.log4j.Logger; @@ -71,6 +73,20 @@ public class UpdateServiceOfferingCmd extends BaseCmd { since = "4.13") private String zoneIds; + @Parameter(name = ApiConstants.STORAGE_TAGS, + type = CommandType.STRING, + description = "comma-separated list of tags for the service offering, tags should match with existing storage pool tags", + authorized = {RoleType.Admin}, + since = "4.16") + private String storageTags; + + @Parameter(name = ApiConstants.HOST_TAGS, + type = CommandType.STRING, + description = "the host tag for this service offering.", + authorized = {RoleType.Admin}, + since = "4.16") + private String hostTags; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -151,6 +167,14 @@ public class UpdateServiceOfferingCmd extends BaseCmd { return validZoneIds; } + public String getStorageTags() { + return storageTags; + } + + public String getHostTags() { + return hostTags; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ServiceOfferingResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ServiceOfferingResponse.java index 59bd72ce88e..42f89b1b8a9 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/ServiceOfferingResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/ServiceOfferingResponse.java @@ -76,7 +76,7 @@ public class ServiceOfferingResponse extends BaseResponse { @Param(description = "true if the vm needs to be volatile, i.e., on every reboot of vm from API root disk is discarded and creates a new root disk") private Boolean isVolatile; - @SerializedName("tags") + @SerializedName("storagetags") @Param(description = "the tags for the service offering") private String tags; diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java index af06fcc31f7..12207da5e69 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java @@ -138,4 +138,6 @@ public interface HostDao extends GenericDao, StateDao listByClusterAndHypervisorType(long clusterId, HypervisorType hypervisorType); HostVO findByName(String name); + + List listHostsWithActiveVMs(long offeringId); } diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java index e58df1defd0..b19f717f27e 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java @@ -79,6 +79,10 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao private static final Logger state_logger = Logger.getLogger(ResourceState.class); private static final String LIST_CLUSTERID_FOR_HOST_TAG = "select distinct cluster_id from host join host_tags on host.id = host_tags.host_id and host_tags.tag = ?"; + private static final String GET_HOSTS_OF_ACTIVE_VMS = "select h.id " + + "from vm_instance vm " + + "join host h on (vm.host_id=h.id) " + + "where vm.service_offering_id= ? and vm.state not in (\"Destroyed\", \"Expunging\", \"Error\") group by h.id"; protected SearchBuilder TypePodDcStatusSearch; @@ -1197,6 +1201,27 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao } } + @Override + public List listHostsWithActiveVMs(long offeringId) { + TransactionLegacy txn = TransactionLegacy.currentTxn(); + PreparedStatement pstmt = null; + List result = new ArrayList<>(); + StringBuilder sql = new StringBuilder(GET_HOSTS_OF_ACTIVE_VMS); + try { + pstmt = txn.prepareAutoCloseStatement(sql.toString()); + pstmt.setLong(1, offeringId); + ResultSet rs = pstmt.executeQuery(); + while (rs.next()) { + result.add(toEntityBean(rs, false)); + } + return result; + } catch (SQLException e) { + throw new CloudRuntimeException("DB Exception on: " + sql, e); + } catch (Throwable e) { + throw new CloudRuntimeException("Caught: " + sql, e); + } + } + @Override public List listAllHostsByType(Host.Type type) { SearchCriteria sc = TypeSearch.create(); diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java index f56fdb85e58..80fddc9bd94 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDao.java @@ -132,4 +132,5 @@ public interface PrimaryDataStoreDao extends GenericDao { List findPoolsByStorageType(String storageType); + List listStoragePoolsWithActiveVolumesByOfferingId(long offeringid); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java index 6b07ef95ca3..2ab95bb8cfc 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/PrimaryDataStoreDaoImpl.java @@ -74,6 +74,11 @@ public class PrimaryDataStoreDaoImpl extends GenericDaoBase protected final String TagsSqlPrefix = "SELECT storage_pool.* from storage_pool LEFT JOIN storage_pool_tags ON storage_pool.id = storage_pool_tags.pool_id WHERE storage_pool.removed is null and storage_pool.status = 'Up' and storage_pool.data_center_id = ? and (storage_pool.pod_id = ? or storage_pool.pod_id is null) and storage_pool.scope = ? and ("; protected final String TagsSqlSuffix = ") GROUP BY storage_pool_tags.pool_id HAVING COUNT(storage_pool_tags.tag) >= ?"; + private static final String GET_STORAGE_POOLS_OF_VOLUMES_WITHOUT_OR_NOT_HAVING_TAGS = "select s.id " + + "from volumes vol " + + "join storage_pool s on vol.pool_id=s.id " + + "where vol.disk_offering_id= ? and vol.state not in (\"Destroy\", \"Error\", \"Expunging\") group by s.id"; + /** * Used in method findPoolsByDetailsOrTagsInternal */ @@ -589,4 +594,25 @@ public class PrimaryDataStoreDaoImpl extends GenericDaoBase sc.setParameters("poolType", storageType); return listBy(sc); } + + @Override + public List listStoragePoolsWithActiveVolumesByOfferingId(long offeringId) { + TransactionLegacy txn = TransactionLegacy.currentTxn(); + PreparedStatement pstmt = null; + List result = new ArrayList<>(); + StringBuilder sql = new StringBuilder(GET_STORAGE_POOLS_OF_VOLUMES_WITHOUT_OR_NOT_HAVING_TAGS); + try { + pstmt = txn.prepareAutoCloseStatement(sql.toString()); + pstmt.setLong(1, offeringId); + ResultSet rs = pstmt.executeQuery(); + while (rs.next()) { + result.add(toEntityBean(rs, false)); + } + return result; + } catch (SQLException e) { + throw new CloudRuntimeException("DB Exception on: " + sql, e); + } catch (Throwable e) { + throw new CloudRuntimeException("Caught: " + sql, e); + } + } } diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 319f7977315..d06f7481710 100755 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -161,7 +161,9 @@ import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.gpu.GPU; +import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostTagsDao; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.network.IpAddressManager; import com.cloud.network.Network; @@ -211,6 +213,7 @@ import com.cloud.storage.Storage.ProvisioningType; import com.cloud.storage.StorageManager; import com.cloud.storage.Volume; import com.cloud.storage.dao.DiskOfferingDao; +import com.cloud.storage.dao.StoragePoolTagsDao; import com.cloud.storage.dao.VMTemplateZoneDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.test.IPRangeConfig; @@ -392,6 +395,10 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati private VMTemplateZoneDao templateZoneDao; @Inject VsphereStoragePolicyDao vsphereStoragePolicyDao; + @Inject + HostTagsDao hostTagDao; + @Inject + StoragePoolTagsDao storagePoolTagDao; // FIXME - why don't we have interface for DataCenterLinkLocalIpAddressDao? @@ -2706,6 +2713,8 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati Long userId = CallContext.current().getCallingUserId(); final List domainIds = cmd.getDomainIds(); final List zoneIds = cmd.getZoneIds(); + String storageTags = cmd.getStorageTags(); + String hostTags = cmd.getHostTags(); if (userId == null) { userId = Long.valueOf(User.UID_SYSTEM); @@ -2787,7 +2796,7 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati throw new InvalidParameterValueException(String.format("Unable to update service offering: %s by id user: %s because it is not root-admin or domain-admin", offeringHandle.getUuid(), user.getUuid())); } - final boolean updateNeeded = name != null || displayText != null || sortKey != null; + final boolean updateNeeded = name != null || displayText != null || sortKey != null || storageTags != null || hostTags != null; final boolean detailsUpdateNeeded = !filteredDomainIds.equals(existingDomainIds) || !filteredZoneIds.equals(existingZoneIds); if (!updateNeeded && !detailsUpdateNeeded) { return _serviceOfferingDao.findById(id); @@ -2807,29 +2816,9 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati offering.setSortKey(sortKey); } - // Note: tag editing commented out for now; keeping the code intact, - // might need to re-enable in next releases - // if (tags != null) - // { - // if (tags.trim().isEmpty() && offeringHandle.getTags() == null) - // { - // //no new tags; no existing tags - // offering.setTagsArray(csvTagsToList(null)); - // } - // else if (!tags.trim().isEmpty() && offeringHandle.getTags() != null) - // { - // //new tags + existing tags - // List oldTags = csvTagsToList(offeringHandle.getTags()); - // List newTags = csvTagsToList(tags); - // oldTags.addAll(newTags); - // offering.setTagsArray(oldTags); - // } - // else if(!tags.trim().isEmpty()) - // { - // //new tags; NO existing tags - // offering.setTagsArray(csvTagsToList(tags)); - // } - // } + updateOfferingTagsIfIsNotNull(storageTags, offering); + + updateServiceOfferingHostTagsIfNotNull(hostTags, offering); if (updateNeeded && !_serviceOfferingDao.update(id, offering)) { return null; @@ -3284,7 +3273,7 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati diskOffering.setDisplayOffering(displayDiskOffering); } - updateDiskOfferingTagsIfIsNotNull(tags, diskOffering); + updateOfferingTagsIfIsNotNull(tags, diskOffering); validateMaxRateEqualsOrGreater(iopsReadRate, iopsReadRateMax, IOPS_READ_RATE); validateMaxRateEqualsOrGreater(iopsWriteRate, iopsWriteRateMax, IOPS_WRITE_RATE); @@ -3336,22 +3325,63 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati } /** - * Check the tags parameters to the diskOffering + * Check the tags parameters to the disk/service offering *
    *
  • If tags is null, do nothing and return.
  • - *
  • If tags is not null, set tag to the diskOffering.
  • - *
  • If tags is an blank string, set null on diskOffering tag.
  • + *
  • If tags is not null, will set tag to the disk/service offering if the pools with active volumes have the new tags.
  • + *
  • If tags is an blank string, set null on disk/service offering tag.
  • *
*/ - protected void updateDiskOfferingTagsIfIsNotNull(String tags, DiskOfferingVO diskOffering) { + protected void updateOfferingTagsIfIsNotNull(String tags, DiskOfferingVO diskOffering) { if (tags == null) { return; } if (StringUtils.isNotBlank(tags)) { + tags = StringUtils.cleanupTags(tags); + List pools = _storagePoolDao.listStoragePoolsWithActiveVolumesByOfferingId(diskOffering.getId()); + if (CollectionUtils.isNotEmpty(pools)) { + List listOfTags = Arrays.asList(tags.split(",")); + for (StoragePoolVO storagePoolVO : pools) { + List tagsOnPool = storagePoolTagDao.getStoragePoolTags(storagePoolVO.getId()); + if (CollectionUtils.isEmpty(tagsOnPool) || !tagsOnPool.containsAll(listOfTags)) { + throw new InvalidParameterValueException(String.format("There are active volumes using offering [%s], and the pools [%s] don't have the new tags", diskOffering.getId(), pools)); + } + } + } diskOffering.setTags(tags); } else { diskOffering.setTags(null); } } + /** + * Check the host tags parameters to the service offering + *
    + *
  • If host tags is null, do nothing and return.
  • + *
  • If host tags is not null, will set host tag to the service offering if the hosts with active VMs have the new tags.
  • + *
  • If host tags is an blank string, set null on service offering tag.
  • + *
+ */ + protected void updateServiceOfferingHostTagsIfNotNull(String hostTags, ServiceOfferingVO offering) { + if (hostTags == null) { + return; + } + if (StringUtils.isNotBlank(hostTags)) { + hostTags = StringUtils.cleanupTags(hostTags); + List hosts = _hostDao.listHostsWithActiveVMs(offering.getId()); + if (CollectionUtils.isNotEmpty(hosts)) { + List listOfHostTags = Arrays.asList(hostTags.split(",")); + for (HostVO host : hosts) { + List tagsOnHost = hostTagDao.gethostTags(host.getId()); + if (CollectionUtils.isEmpty(tagsOnHost) || !tagsOnHost.containsAll(listOfHostTags)) { + throw new InvalidParameterValueException(String.format("There are active VMs using offering [%s], and the hosts [%s] don't have the new tags", offering.getId(), hosts)); + } + } + } + offering.setHostTag(hostTags); + } else { + offering.setHostTag(null); + } + } + /** * Check if it needs to update any parameter when updateDiskoffering is called * Verify if name or displayText are not blank, tags is not null, sortkey and displayDiskOffering is not null diff --git a/server/src/test/java/com/cloud/configuration/ConfigurationManagerTest.java b/server/src/test/java/com/cloud/configuration/ConfigurationManagerTest.java index 3d18d1a99c0..f8155f17758 100644 --- a/server/src/test/java/com/cloud/configuration/ConfigurationManagerTest.java +++ b/server/src/test/java/com/cloud/configuration/ConfigurationManagerTest.java @@ -991,15 +991,15 @@ public class ConfigurationManagerTest { @Test public void updateDiskOfferingTagsIfIsNotNullTestWhenTagsIsNull(){ - Mockito.doNothing().when(configurationMgr).updateDiskOfferingTagsIfIsNotNull(null, diskOfferingVOMock); - this.configurationMgr.updateDiskOfferingTagsIfIsNotNull(null, diskOfferingVOMock); - Mockito.verify(configurationMgr, Mockito.times(1)).updateDiskOfferingTagsIfIsNotNull(null, diskOfferingVOMock); + Mockito.doNothing().when(configurationMgr).updateOfferingTagsIfIsNotNull(null, diskOfferingVOMock); + this.configurationMgr.updateOfferingTagsIfIsNotNull(null, diskOfferingVOMock); + Mockito.verify(configurationMgr, Mockito.times(1)).updateOfferingTagsIfIsNotNull(null, diskOfferingVOMock); } @Test public void updateDiskOfferingTagsIfIsNotNullTestWhenTagsIsNotNull(){ String tags = "tags"; - Mockito.doNothing().when(configurationMgr).updateDiskOfferingTagsIfIsNotNull(tags, diskOfferingVOMock); - this.configurationMgr.updateDiskOfferingTagsIfIsNotNull(tags, diskOfferingVOMock); - Mockito.verify(configurationMgr, Mockito.times(1)).updateDiskOfferingTagsIfIsNotNull(tags, diskOfferingVOMock); + Mockito.doNothing().when(configurationMgr).updateOfferingTagsIfIsNotNull(tags, diskOfferingVOMock); + this.configurationMgr.updateOfferingTagsIfIsNotNull(tags, diskOfferingVOMock); + Mockito.verify(configurationMgr, Mockito.times(1)).updateOfferingTagsIfIsNotNull(tags, diskOfferingVOMock); } } diff --git a/test/integration/smoke/test_service_offerings.py b/test/integration/smoke/test_service_offerings.py index 8a7682ea462..3a942a10b62 100644 --- a/test/integration/smoke/test_service_offerings.py +++ b/test/integration/smoke/test_service_offerings.py @@ -414,6 +414,8 @@ class TestServiceOfferings(cloudstackTestCase): # Generate new name & displaytext from random data random_displaytext = random_gen() random_name = random_gen() + random_tag = random_gen() + random_hosttag = random_gen() self.debug("Updating service offering with ID: %s" % self.service_offering_1.id) @@ -423,6 +425,8 @@ class TestServiceOfferings(cloudstackTestCase): cmd.id = self.service_offering_1.id cmd.displaytext = random_displaytext cmd.name = random_name + cmd.storagetags = random_tag + cmd.hosttags = random_hosttag self.apiclient.updateServiceOffering(cmd) list_service_response = list_service_offering( @@ -452,6 +456,17 @@ class TestServiceOfferings(cloudstackTestCase): "Check server name in updateServiceOffering" ) + self.assertEqual( + list_service_response[0].storagetags, + random_tag, + "Check storage tags in updateServiceOffering" + ) + + self.assertEqual( + list_service_response[0].hosttags, + random_hosttag, + "Check host tags in updateServiceOffering" + ) return @attr( diff --git a/ui/src/config/section/offering.js b/ui/src/config/section/offering.js index 3bd1ab98eb4..9e22d9142d3 100644 --- a/ui/src/config/section/offering.js +++ b/ui/src/config/section/offering.js @@ -31,7 +31,7 @@ export default { params: { isrecursive: 'true' }, columns: ['name', 'displaytext', 'cpunumber', 'cpuspeed', 'memory', 'domain', 'zone', 'order'], details: () => { - var fields = ['name', 'id', 'displaytext', 'offerha', 'provisioningtype', 'storagetype', 'iscustomized', 'iscustomizediops', 'limitcpuuse', 'cpunumber', 'cpuspeed', 'memory', 'hosttags', 'tags', 'domain', 'zone', 'created', 'dynamicscalingenabled'] + var fields = ['name', 'id', 'displaytext', 'offerha', 'provisioningtype', 'storagetype', 'iscustomized', 'iscustomizediops', 'limitcpuuse', 'cpunumber', 'cpuspeed', 'memory', 'hosttags', 'storagetags', 'domain', 'zone', 'created', 'dynamicscalingenabled'] if (store.getters.apis.createServiceOffering && store.getters.apis.createServiceOffering.params.filter(x => x.name === 'storagepolicy').length > 0) { fields.splice(6, 0, 'vspherestoragepolicy') @@ -61,7 +61,7 @@ export default { label: 'label.edit', docHelp: 'adminguide/service_offerings.html#modifying-or-deleting-a-service-offering', dataView: true, - args: ['name', 'displaytext'] + args: ['name', 'displaytext', 'storagetags', 'hosttags'] }, { api: 'updateServiceOffering', icon: 'lock', diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index 229cd2181e4..24a966d5077 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -931,7 +931,7 @@ export default { this.showAction = true for (const param of this.currentAction.paramFields) { - if (param.type === 'list' && ['tags', 'hosttags'].includes(param.name)) { + if (param.type === 'list' && ['tags', 'hosttags', 'storagetags'].includes(param.name)) { param.type = 'string' } if (param.type === 'uuid' || param.type === 'list' || param.name === 'account' || (this.currentAction.mapping && param.name in this.currentAction.mapping)) { @@ -1214,13 +1214,13 @@ export default { continue } if (input === undefined || input === null || - (input === '' && !['updateStoragePool', 'updateHost', 'updatePhysicalNetwork', 'updateDiskOffering', 'updateNetworkOffering'].includes(action.api))) { + (input === '' && !['updateStoragePool', 'updateHost', 'updatePhysicalNetwork', 'updateDiskOffering', 'updateNetworkOffering', 'updateServiceOffering'].includes(action.api))) { if (param.type === 'boolean') { params[key] = false } break } - if (input === '' && !['tags'].includes(key)) { + if (input === '' && !['tags', 'hosttags', 'storagetags'].includes(key)) { break } if (action.mapping && key in action.mapping && action.mapping[key].options) {