New feature: Implicit host tags (#8929)

* Merge two HostTagVO and HostTagDaoImpl

* Implicit host tags

* PR8929: add since

* Update variable names

* Update 8929: add unit test in LibvirtComputingResourceTest

* Update 8929: add explicithosttags in response

* Update 8929 UI: Update explicit host tags

* Update 8929: remove host tags and change labels on UI

* Update 8929: update host_view to use explicit_host_tags.is_tag_a_rule

* Update: ui polish for host tags

* Update 8929: fix UI error if no host tags
This commit is contained in:
Wei Zhou 2024-05-30 13:51:13 +02:00 committed by GitHub
parent f1c3d2c4be
commit 5433e775e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 677 additions and 251 deletions

View File

@ -430,3 +430,6 @@ iscsi.session.cleanup.enabled=false
# If set to "true", the agent will register for libvirt domain events, allowing for immediate updates on crashed or
# unexpectedly stopped. Experimental, requires agent restart.
# libvirt.events.enabled=false
# Implicit host tags managed by agent.properties
# host.tags=

View File

@ -803,6 +803,13 @@ public class AgentProperties{
*/
public static final Property<String> KEYSTORE_PASSPHRASE = new Property<>(KeyStoreUtils.KS_PASSPHRASE_PROPERTY, null, String.class);
/**
* Implicit host tags
* Data type: String.<br>
* Default value: <code>null</code>
*/
public static final Property<String> HOST_TAGS = new Property<>("host.tags", null, String.class);
public static class Property <T>{
private String name;
private T defaultValue;

View File

@ -265,6 +265,7 @@ public class ApiConstants {
public static final String IS_EDGE = "isedge";
public static final String IS_EXTRACTABLE = "isextractable";
public static final String IS_FEATURED = "isfeatured";
public static final String IS_IMPLICIT = "isimplicit";
public static final String IS_PORTABLE = "isportable";
public static final String IS_PUBLIC = "ispublic";
public static final String IS_PERSISTENT = "ispersistent";

View File

@ -208,6 +208,14 @@ public class HostForMigrationResponse extends BaseResponse {
@Param(description = "comma-separated list of tags for the host")
private String hostTags;
@SerializedName("explicithosttags")
@Param(description = "comma-separated list of explicit host tags for the host", since = "4.20.0")
private String explicitHostTags;
@SerializedName("implicithosttags")
@Param(description = "comma-separated list of implicit host tags for the host", since = "4.20.0")
private String implicitHostTags;
@SerializedName("hasenoughcapacity")
@Param(description = "true if this host has enough CPU and RAM capacity to migrate a VM to it, false otherwise")
private Boolean hasEnoughCapacity;
@ -414,6 +422,14 @@ public class HostForMigrationResponse extends BaseResponse {
this.hostTags = hostTags;
}
public void setExplicitHostTags(String explicitHostTags) {
this.explicitHostTags = explicitHostTags;
}
public void setImplicitHostTags(String implicitHostTags) {
this.implicitHostTags = implicitHostTags;
}
public void setHasEnoughCapacity(Boolean hasEnoughCapacity) {
this.hasEnoughCapacity = hasEnoughCapacity;
}

View File

@ -221,6 +221,14 @@ public class HostResponse extends BaseResponseWithAnnotations {
@Param(description = "comma-separated list of tags for the host")
private String hostTags;
@SerializedName("explicithosttags")
@Param(description = "comma-separated list of explicit host tags for the host", since = "4.20.0")
private String explicitHostTags;
@SerializedName("implicithosttags")
@Param(description = "comma-separated list of implicit host tags for the host", since = "4.20.0")
private String implicitHostTags;
@SerializedName(ApiConstants.IS_TAG_A_RULE)
@Param(description = ApiConstants.PARAMETER_DESCRIPTION_IS_TAG_A_RULE)
private Boolean isTagARule;
@ -458,6 +466,22 @@ public class HostResponse extends BaseResponseWithAnnotations {
this.hostTags = hostTags;
}
public String getExplicitHostTags() {
return explicitHostTags;
}
public void setExplicitHostTags(String explicitHostTags) {
this.explicitHostTags = explicitHostTags;
}
public String getImplicitHostTags() {
return implicitHostTags;
}
public void setImplicitHostTags(String implicitHostTags) {
this.implicitHostTags = implicitHostTags;
}
public void setHasEnoughCapacity(Boolean hasEnoughCapacity) {
this.hasEnoughCapacity = hasEnoughCapacity;
}

View File

@ -19,6 +19,7 @@ package org.apache.cloudstack.api.response;
import com.google.gson.annotations.SerializedName;
import com.cloud.serializer.Param;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseResponse;
public class HostTagResponse extends BaseResponse {
@ -34,6 +35,10 @@ public class HostTagResponse extends BaseResponse {
@Param(description = "the name of the host tag")
private String name;
@SerializedName(ApiConstants.IS_IMPLICIT)
@Param(description = "true if the host tag is implicit", since = "4.20.0")
private boolean isImplicit;
public String getId() {
return id;
}
@ -57,4 +62,12 @@ public class HostTagResponse extends BaseResponse {
public void setName(String name) {
this.name = name;
}
public boolean isImplicit() {
return isImplicit;
}
public void setImplicit(boolean implicit) {
isImplicit = implicit;
}
}

View File

@ -174,6 +174,10 @@ public class StartupRoutingCommand extends StartupCommand {
this.hostTags.add(hostTag);
}
public void setHostTags(List<String> hostTags) {
this.hostTags = hostTags;
}
public HashMap<String, HashMap<String, VgpuTypesInfo>> getGpuGroupDetails() {
return groupDetails;
}

View File

@ -40,6 +40,9 @@ public class HostTagVO implements InternalIdentity {
@Column(name = "tag")
private String tag;
@Column(name = "is_implicit")
private boolean isImplicit = false;
@Column(name = "is_tag_a_rule")
private boolean isTagARule;
@ -74,6 +77,13 @@ public class HostTagVO implements InternalIdentity {
return isTagARule;
}
public void setIsImplicit(boolean isImplicit) {
this.isImplicit = isImplicit;
}
public boolean getIsImplicit() {
return isImplicit;
}
@Override
public long getId() {

View File

@ -20,6 +20,7 @@ import java.util.List;
import com.cloud.host.HostTagVO;
import com.cloud.utils.db.GenericDao;
import org.apache.cloudstack.api.response.HostTagResponse;
import org.apache.cloudstack.framework.config.ConfigKey;
public interface HostTagsDao extends GenericDao<HostTagVO, Long> {
@ -35,6 +36,13 @@ public interface HostTagsDao extends GenericDao<HostTagVO, Long> {
void deleteTags(long hostId);
boolean updateImplicitTags(long hostId, List<String> hostTags);
List<HostTagVO> getExplicitHostTags(long hostId);
List<HostTagVO> findHostRuleTags();
HostTagResponse newHostTagResponse(HostTagVO hostTag);
List<HostTagVO> searchByIds(Long... hostTagIds);
}

View File

@ -16,10 +16,14 @@
// under the License.
package com.cloud.host.dao;
import java.util.ArrayList;
import java.util.List;
import org.apache.cloudstack.api.response.HostTagResponse;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.framework.config.Configurable;
import org.apache.cloudstack.framework.config.dao.ConfigurationDao;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import com.cloud.host.HostTagVO;
@ -30,14 +34,23 @@ import com.cloud.utils.db.SearchCriteria;
import com.cloud.utils.db.TransactionLegacy;
import com.cloud.utils.db.SearchCriteria.Func;
import javax.inject.Inject;
@Component
public class HostTagsDaoImpl extends GenericDaoBase<HostTagVO, Long> implements HostTagsDao, Configurable {
protected final SearchBuilder<HostTagVO> HostSearch;
protected final GenericSearchBuilder<HostTagVO, String> DistinctImplictTagsSearch;
private final SearchBuilder<HostTagVO> stSearch;
private final SearchBuilder<HostTagVO> tagIdsearch;
private final SearchBuilder<HostTagVO> ImplicitTagsSearch;
@Inject
private ConfigurationDao _configDao;
public HostTagsDaoImpl() {
HostSearch = createSearchBuilder();
HostSearch.and("hostId", HostSearch.entity().getHostId(), SearchCriteria.Op.EQ);
HostSearch.and("isImplicit", HostSearch.entity().getIsImplicit(), SearchCriteria.Op.EQ);
HostSearch.and("isTagARule", HostSearch.entity().getIsTagARule(), SearchCriteria.Op.EQ);
HostSearch.done();
@ -46,6 +59,19 @@ public class HostTagsDaoImpl extends GenericDaoBase<HostTagVO, Long> implements
DistinctImplictTagsSearch.and("hostIds", DistinctImplictTagsSearch.entity().getHostId(), SearchCriteria.Op.IN);
DistinctImplictTagsSearch.and("implicitTags", DistinctImplictTagsSearch.entity().getTag(), SearchCriteria.Op.IN);
DistinctImplictTagsSearch.done();
stSearch = createSearchBuilder();
stSearch.and("idIN", stSearch.entity().getId(), SearchCriteria.Op.IN);
stSearch.done();
tagIdsearch = createSearchBuilder();
tagIdsearch.and("id", tagIdsearch.entity().getId(), SearchCriteria.Op.EQ);
tagIdsearch.done();
ImplicitTagsSearch = createSearchBuilder();
ImplicitTagsSearch.and("hostId", ImplicitTagsSearch.entity().getHostId(), SearchCriteria.Op.EQ);
ImplicitTagsSearch.and("isImplicit", ImplicitTagsSearch.entity().getIsImplicit(), SearchCriteria.Op.EQ);
ImplicitTagsSearch.done();
}
@Override
@ -74,6 +100,36 @@ public class HostTagsDaoImpl extends GenericDaoBase<HostTagVO, Long> implements
txn.commit();
}
@Override
public boolean updateImplicitTags(long hostId, List<String> hostTags) {
TransactionLegacy txn = TransactionLegacy.currentTxn();
txn.start();
SearchCriteria<HostTagVO> sc = ImplicitTagsSearch.create();
sc.setParameters("hostId", hostId);
sc.setParameters("isImplicit", true);
boolean expunged = expunge(sc) > 0;
boolean persisted = false;
for (String tag : hostTags) {
if (StringUtils.isNotBlank(tag)) {
HostTagVO vo = new HostTagVO(hostId, tag.trim());
vo.setIsImplicit(true);
persist(vo);
persisted = true;
}
}
txn.commit();
return expunged || persisted;
}
@Override
public List<HostTagVO> getExplicitHostTags(long hostId) {
SearchCriteria<HostTagVO> sc = ImplicitTagsSearch.create();
sc.setParameters("hostId", hostId);
sc.setParameters("isImplicit", false);
return search(sc, null);
}
@Override
public List<HostTagVO> findHostRuleTags() {
SearchCriteria<HostTagVO> sc = HostSearch.create();
@ -89,6 +145,7 @@ public class HostTagsDaoImpl extends GenericDaoBase<HostTagVO, Long> implements
txn.start();
SearchCriteria<HostTagVO> sc = HostSearch.create();
sc.setParameters("hostId", hostId);
sc.setParameters("isImplicit", false);
expunge(sc);
for (String tag : hostTags) {
@ -110,4 +167,72 @@ public class HostTagsDaoImpl extends GenericDaoBase<HostTagVO, Long> implements
public String getConfigComponentName() {
return HostTagsDaoImpl.class.getSimpleName();
}
@Override
public HostTagResponse newHostTagResponse(HostTagVO tag) {
HostTagResponse tagResponse = new HostTagResponse();
tagResponse.setName(tag.getTag());
tagResponse.setHostId(tag.getHostId());
tagResponse.setImplicit(tag.getIsImplicit());
tagResponse.setObjectName("hosttag");
return tagResponse;
}
@Override
public List<HostTagVO> searchByIds(Long... tagIds) {
String batchCfg = _configDao.getValue("detail.batch.query.size");
final int detailsBatchSize = batchCfg != null ? Integer.parseInt(batchCfg) : 2000;
// query details by batches
List<HostTagVO> tagList = new ArrayList<>();
int curr_index = 0;
if (tagIds.length > detailsBatchSize) {
while ((curr_index + detailsBatchSize) <= tagIds.length) {
Long[] ids = new Long[detailsBatchSize];
for (int k = 0, j = curr_index; j < curr_index + detailsBatchSize; j++, k++) {
ids[k] = tagIds[j];
}
SearchCriteria<HostTagVO> sc = stSearch.create();
sc.setParameters("idIN", (Object[])ids);
List<HostTagVO> vms = searchIncludingRemoved(sc, null, null, false);
if (vms != null) {
tagList.addAll(vms);
}
curr_index += detailsBatchSize;
}
}
if (curr_index < tagIds.length) {
int batch_size = (tagIds.length - curr_index);
// set the ids value
Long[] ids = new Long[batch_size];
for (int k = 0, j = curr_index; j < curr_index + batch_size; j++, k++) {
ids[k] = tagIds[j];
}
SearchCriteria<HostTagVO> sc = stSearch.create();
sc.setParameters("idIN", (Object[])ids);
List<HostTagVO> tags = searchIncludingRemoved(sc, null, null, false);
if (tags != null) {
tagList.addAll(tags);
}
}
return tagList;
}
}

View File

@ -187,7 +187,6 @@
<bean id="storageNetworkIpAddressDaoImpl" class="com.cloud.dc.dao.StorageNetworkIpAddressDaoImpl" />
<bean id="storageNetworkIpRangeDaoImpl" class="com.cloud.dc.dao.StorageNetworkIpRangeDaoImpl" />
<bean id="storagePoolJoinDaoImpl" class="com.cloud.api.query.dao.StoragePoolJoinDaoImpl" />
<bean id="hostTagDaoImpl" class="com.cloud.api.query.dao.HostTagDaoImpl" />
<bean id="storagePoolWorkDaoImpl" class="com.cloud.storage.dao.StoragePoolWorkDaoImpl" />
<bean id="uploadDaoImpl" class="com.cloud.storage.dao.UploadDaoImpl" />
<bean id="usageDaoImpl" class="com.cloud.usage.dao.UsageDaoImpl" />

View File

@ -79,3 +79,6 @@ CREATE TABLE IF NOT EXISTS `cloud_usage`.`quota_email_configuration`(
PRIMARY KEY (`account_id`, `email_template_id`),
CONSTRAINT `FK_quota_email_configuration_account_id` FOREIGN KEY (`account_id`) REFERENCES `cloud_usage`.`quota_account`(`account_id`),
CONSTRAINT `FK_quota_email_configuration_email_template_id` FOREIGN KEY (`email_template_id`) REFERENCES `cloud_usage`.`quota_email_templates`(`id`));
-- Add `is_implicit` column to `host_tags` table
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.host_tags', 'is_implicit', 'int(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT "If host tag is implicit or explicit" ');

View File

@ -53,7 +53,9 @@ SELECT
host_pod_ref.uuid pod_uuid,
host_pod_ref.name pod_name,
GROUP_CONCAT(DISTINCT(host_tags.tag)) AS tag,
`host_tags`.`is_tag_a_rule` AS `is_tag_a_rule`,
GROUP_CONCAT(DISTINCT(explicit_host_tags.tag)) AS explicit_tag,
GROUP_CONCAT(DISTINCT(implicit_host_tags.tag)) AS implicit_tag,
`explicit_host_tags`.`is_tag_a_rule` AS `is_tag_a_rule`,
guest_os_category.id guest_os_category_id,
guest_os_category.uuid guest_os_category_uuid,
guest_os_category.name guest_os_category_name,
@ -89,6 +91,10 @@ FROM
LEFT JOIN
`cloud`.`host_tags` ON host_tags.host_id = host.id
LEFT JOIN
`cloud`.`host_tags` AS explicit_host_tags ON explicit_host_tags.host_id = host.id AND explicit_host_tags.is_implicit = 0
LEFT JOIN
`cloud`.`host_tags` AS implicit_host_tags ON implicit_host_tags.host_id = host.id AND implicit_host_tags.is_implicit = 1
LEFT JOIN
`cloud`.`op_host_capacity` mem_caps ON host.id = mem_caps.host_id
AND mem_caps.capacity_type = 0
LEFT JOIN

View File

@ -3646,6 +3646,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv
cmd.setGatewayIpAddress(localGateway);
cmd.setIqn(getIqn());
cmd.getHostDetails().put(HOST_VOLUME_ENCRYPTION, String.valueOf(hostSupportsVolumeEncryption()));
cmd.setHostTags(getHostTags());
HealthCheckResult healthCheckResult = getHostHealthCheckResult();
if (healthCheckResult != HealthCheckResult.IGNORE) {
cmd.setHostHealthCheckResult(healthCheckResult == HealthCheckResult.SUCCESS);
@ -3674,6 +3675,19 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv
return startupCommandsArray;
}
protected List<String> getHostTags() {
List<String> hostTagsList = new ArrayList<>();
String hostTags = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.HOST_TAGS);
if (StringUtils.isNotBlank(hostTags)) {
for (String hostTag : hostTags.split(",")) {
if (!hostTagsList.contains(hostTag.trim())) {
hostTagsList.add(hostTag.trim());
}
}
}
return hostTagsList;
}
/**
* Calculates and sets the host CPU max capacity according to the cgroup version of the host.
* <ul>

View File

@ -6295,4 +6295,40 @@ public class LibvirtComputingResourceTest {
Assert.assertEquals(expectedShares, libvirtComputingResourceSpy.getHostCpuMaxCapacity());
}
}
@Test
public void testGetHostTags() throws ConfigurationException {
try (MockedStatic<AgentPropertiesFileHandler> ignored = Mockito.mockStatic(AgentPropertiesFileHandler.class)) {
Mockito.when(AgentPropertiesFileHandler.getPropertyValue(Mockito.eq(AgentProperties.HOST_TAGS)))
.thenReturn("aa,bb,cc,dd");
List<String> hostTagsList = libvirtComputingResourceSpy.getHostTags();
Assert.assertEquals(4, hostTagsList.size());
Assert.assertEquals("aa,bb,cc,dd", StringUtils.join(hostTagsList, ","));
}
}
@Test
public void testGetHostTagsWithSpace() throws ConfigurationException {
try (MockedStatic<AgentPropertiesFileHandler> ignored = Mockito.mockStatic(AgentPropertiesFileHandler.class)) {
Mockito.when(AgentPropertiesFileHandler.getPropertyValue(Mockito.eq(AgentProperties.HOST_TAGS)))
.thenReturn(" aa, bb , cc , dd ");
List<String> hostTagsList = libvirtComputingResourceSpy.getHostTags();
Assert.assertEquals(4, hostTagsList.size());
Assert.assertEquals("aa,bb,cc,dd", StringUtils.join(hostTagsList, ","));
}
}
@Test
public void testGetHostTagsWithEmptyPropertyValue() throws ConfigurationException {
try (MockedStatic<AgentPropertiesFileHandler> ignored = Mockito.mockStatic(AgentPropertiesFileHandler.class)) {
Mockito.when(AgentPropertiesFileHandler.getPropertyValue(Mockito.eq(AgentProperties.HOST_TAGS)))
.thenReturn(" ");
List<String> hostTagsList = libvirtComputingResourceSpy.getHostTags();
Assert.assertEquals(0, hostTagsList.size());
Assert.assertEquals("", StringUtils.join(hostTagsList, ","));
}
}
}

View File

@ -103,7 +103,6 @@ import com.cloud.api.query.dao.DiskOfferingJoinDao;
import com.cloud.api.query.dao.DomainJoinDao;
import com.cloud.api.query.dao.DomainRouterJoinDao;
import com.cloud.api.query.dao.HostJoinDao;
import com.cloud.api.query.dao.HostTagDao;
import com.cloud.api.query.dao.ImageStoreJoinDao;
import com.cloud.api.query.dao.InstanceGroupJoinDao;
import com.cloud.api.query.dao.NetworkOfferingJoinDao;
@ -129,7 +128,6 @@ import com.cloud.api.query.vo.DomainJoinVO;
import com.cloud.api.query.vo.DomainRouterJoinVO;
import com.cloud.api.query.vo.EventJoinVO;
import com.cloud.api.query.vo.HostJoinVO;
import com.cloud.api.query.vo.HostTagVO;
import com.cloud.api.query.vo.ImageStoreJoinVO;
import com.cloud.api.query.vo.InstanceGroupJoinVO;
import com.cloud.api.query.vo.NetworkOfferingJoinVO;
@ -183,9 +181,11 @@ import com.cloud.gpu.dao.VGPUTypesDao;
import com.cloud.ha.HighAvailabilityManager;
import com.cloud.host.Host;
import com.cloud.host.HostStats;
import com.cloud.host.HostTagVO;
import com.cloud.host.HostVO;
import com.cloud.host.dao.HostDao;
import com.cloud.host.dao.HostDetailsDao;
import com.cloud.host.dao.HostTagsDao;
import com.cloud.hypervisor.Hypervisor.HypervisorType;
import com.cloud.network.IpAddress;
import com.cloud.network.Network;
@ -452,7 +452,7 @@ public class ApiDBUtils {
static VolumeJoinDao s_volJoinDao;
static StoragePoolJoinDao s_poolJoinDao;
static StoragePoolTagsDao s_tagDao;
static HostTagDao s_hostTagDao;
static HostTagsDao s_hostTagDao;
static ImageStoreJoinDao s_imageStoreJoinDao;
static AccountJoinDao s_accountJoinDao;
static AsyncJobJoinDao s_jobJoinDao;
@ -675,7 +675,7 @@ public class ApiDBUtils {
@Inject
private StoragePoolTagsDao tagDao;
@Inject
private HostTagDao hosttagDao;
private HostTagsDao hosttagDao;
@Inject
private ImageStoreJoinDao imageStoreJoinDao;
@Inject

View File

@ -172,7 +172,6 @@ import com.cloud.api.query.dao.DiskOfferingJoinDao;
import com.cloud.api.query.dao.DomainJoinDao;
import com.cloud.api.query.dao.DomainRouterJoinDao;
import com.cloud.api.query.dao.HostJoinDao;
import com.cloud.api.query.dao.HostTagDao;
import com.cloud.api.query.dao.ImageStoreJoinDao;
import com.cloud.api.query.dao.InstanceGroupJoinDao;
import com.cloud.api.query.dao.ManagementServerJoinDao;
@ -197,7 +196,6 @@ import com.cloud.api.query.vo.DomainJoinVO;
import com.cloud.api.query.vo.DomainRouterJoinVO;
import com.cloud.api.query.vo.EventJoinVO;
import com.cloud.api.query.vo.HostJoinVO;
import com.cloud.api.query.vo.HostTagVO;
import com.cloud.api.query.vo.ImageStoreJoinVO;
import com.cloud.api.query.vo.InstanceGroupJoinVO;
import com.cloud.api.query.vo.ManagementServerJoinVO;
@ -229,8 +227,10 @@ import com.cloud.exception.InvalidParameterValueException;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.ha.HighAvailabilityManager;
import com.cloud.host.Host;
import com.cloud.host.HostTagVO;
import com.cloud.host.HostVO;
import com.cloud.host.dao.HostDao;
import com.cloud.host.dao.HostTagsDao;
import com.cloud.hypervisor.Hypervisor;
import com.cloud.hypervisor.Hypervisor.HypervisorType;
import com.cloud.network.PublicIpQuarantine;
@ -426,7 +426,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
private StoragePoolTagsDao _storageTagDao;
@Inject
private HostTagDao _hostTagDao;
private HostTagsDao _hostTagDao;
@Inject
private ImageStoreJoinDao _imageStoreJoinDao;
@ -2268,10 +2268,10 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
if (haHosts != null && haTag != null && !haTag.isEmpty()) {
SearchBuilder<HostTagVO> hostTagSearchBuilder = _hostTagDao.createSearchBuilder();
if ((Boolean)haHosts) {
hostTagSearchBuilder.and("tag", hostTagSearchBuilder.entity().getName(), SearchCriteria.Op.EQ);
hostTagSearchBuilder.and("tag", hostTagSearchBuilder.entity().getTag(), SearchCriteria.Op.EQ);
} else {
hostTagSearchBuilder.and().op("tag", hostTagSearchBuilder.entity().getName(), Op.NEQ);
hostTagSearchBuilder.or("tagNull", hostTagSearchBuilder.entity().getName(), Op.NULL);
hostTagSearchBuilder.and().op("tag", hostTagSearchBuilder.entity().getTag(), Op.NEQ);
hostTagSearchBuilder.or("tagNull", hostTagSearchBuilder.entity().getTag(), Op.NULL);
hostTagSearchBuilder.cp();
}
hostSearchBuilder.join("hostTagSearch", hostTagSearchBuilder, hostSearchBuilder.entity().getId(), hostTagSearchBuilder.entity().getHostId(), JoinBuilder.JoinType.LEFT);

View File

@ -74,7 +74,6 @@ import com.cloud.api.query.vo.DomainJoinVO;
import com.cloud.api.query.vo.DomainRouterJoinVO;
import com.cloud.api.query.vo.EventJoinVO;
import com.cloud.api.query.vo.HostJoinVO;
import com.cloud.api.query.vo.HostTagVO;
import com.cloud.api.query.vo.ImageStoreJoinVO;
import com.cloud.api.query.vo.InstanceGroupJoinVO;
import com.cloud.api.query.vo.ProjectAccountJoinVO;
@ -91,6 +90,7 @@ import com.cloud.api.query.vo.UserVmJoinVO;
import com.cloud.api.query.vo.VolumeJoinVO;
import com.cloud.configuration.Resource;
import com.cloud.domain.Domain;
import com.cloud.host.HostTagVO;
import com.cloud.storage.Storage.ImageFormat;
import com.cloud.storage.StoragePoolTagVO;
import com.cloud.storage.VolumeStats;

View File

@ -205,6 +205,8 @@ public class HostJoinDaoImpl extends GenericDaoBase<HostJoinVO, Long> implements
hostResponse.setHostTags(hostTags);
hostResponse.setIsTagARule(host.getIsTagARule());
hostResponse.setHaHost(containsHostHATag(hostTags));
hostResponse.setExplicitHostTags(host.getExplicitTag());
hostResponse.setImplicitHostTags(host.getImplicitTag());
hostResponse.setHypervisorVersion(host.getHypervisorVersion());
@ -349,6 +351,7 @@ public class HostJoinDaoImpl extends GenericDaoBase<HostJoinVO, Long> implements
String hostTags = host.getTag();
hostResponse.setHostTags(hostTags);
hostResponse.setHaHost(containsHostHATag(hostTags));
hostResponse.setImplicitHostTags(host.getImplicitTag());
hostResponse.setHypervisorVersion(host.getHypervisorVersion());

View File

@ -1,30 +0,0 @@
// 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.api.query.dao;
import java.util.List;
import org.apache.cloudstack.api.response.HostTagResponse;
import com.cloud.api.query.vo.HostTagVO;
import com.cloud.utils.db.GenericDao;
public interface HostTagDao extends GenericDao<HostTagVO, Long> {
HostTagResponse newHostTagResponse(HostTagVO hostTag);
List<HostTagVO> searchByIds(Long... hostTagIds);
}

View File

@ -1,122 +0,0 @@
// 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.api.query.dao;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import org.apache.cloudstack.api.response.HostTagResponse;
import org.apache.cloudstack.framework.config.dao.ConfigurationDao;
import org.springframework.stereotype.Component;
import com.cloud.api.query.vo.HostTagVO;
import com.cloud.utils.db.GenericDaoBase;
import com.cloud.utils.db.SearchBuilder;
import com.cloud.utils.db.SearchCriteria;
@Component
public class HostTagDaoImpl extends GenericDaoBase<HostTagVO, Long> implements HostTagDao {
@Inject
private ConfigurationDao _configDao;
private final SearchBuilder<HostTagVO> stSearch;
private final SearchBuilder<HostTagVO> stIdSearch;
protected HostTagDaoImpl() {
stSearch = createSearchBuilder();
stSearch.and("idIN", stSearch.entity().getId(), SearchCriteria.Op.IN);
stSearch.done();
stIdSearch = createSearchBuilder();
stIdSearch.and("id", stIdSearch.entity().getId(), SearchCriteria.Op.EQ);
stIdSearch.done();
_count = "select count(distinct id) from host_tags WHERE ";
}
@Override
public HostTagResponse newHostTagResponse(HostTagVO tag) {
HostTagResponse tagResponse = new HostTagResponse();
tagResponse.setName(tag.getName());
tagResponse.setHostId(tag.getHostId());
tagResponse.setObjectName("hosttag");
return tagResponse;
}
@Override
public List<HostTagVO> searchByIds(Long... stIds) {
String batchCfg = _configDao.getValue("detail.batch.query.size");
final int detailsBatchSize = batchCfg != null ? Integer.parseInt(batchCfg) : 2000;
// query details by batches
List<HostTagVO> uvList = new ArrayList<HostTagVO>();
int curr_index = 0;
if (stIds.length > detailsBatchSize) {
while ((curr_index + detailsBatchSize) <= stIds.length) {
Long[] ids = new Long[detailsBatchSize];
for (int k = 0, j = curr_index; j < curr_index + detailsBatchSize; j++, k++) {
ids[k] = stIds[j];
}
SearchCriteria<HostTagVO> sc = stSearch.create();
sc.setParameters("idIN", (Object[])ids);
List<HostTagVO> vms = searchIncludingRemoved(sc, null, null, false);
if (vms != null) {
uvList.addAll(vms);
}
curr_index += detailsBatchSize;
}
}
if (curr_index < stIds.length) {
int batch_size = (stIds.length - curr_index);
// set the ids value
Long[] ids = new Long[batch_size];
for (int k = 0, j = curr_index; j < curr_index + batch_size; j++, k++) {
ids[k] = stIds[j];
}
SearchCriteria<HostTagVO> sc = stSearch.create();
sc.setParameters("idIN", (Object[])ids);
List<HostTagVO> vms = searchIncludingRemoved(sc, null, null, false);
if (vms != null) {
uvList.addAll(vms);
}
}
return uvList;
}
}

View File

@ -174,6 +174,12 @@ public class HostJoinVO extends BaseViewVO implements InternalIdentity, Identity
@Column(name = "tag")
private String tag;
@Column(name = "explicit_tag")
private String explicitTag;
@Column(name = "implicit_tag")
private String implicitTag;
@Column(name = "is_tag_a_rule")
private Boolean isTagARule;
@ -393,6 +399,14 @@ public class HostJoinVO extends BaseViewVO implements InternalIdentity, Identity
return tag;
}
public String getExplicitTag() {
return explicitTag;
}
public String getImplicitTag() {
return implicitTag;
}
public Boolean getIsTagARule() {
return isTagARule;
}

View File

@ -1,61 +0,0 @@
// 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.api.query.vo;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import org.apache.cloudstack.api.InternalIdentity;
/**
* Storage Tags DB view.
*
*/
@Entity
@Table(name = "host_tags")
public class HostTagVO extends BaseViewVO implements InternalIdentity {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "id")
private long id;
@Column(name = "tag")
private String name;
@Column(name = "host_id")
long hostId;
@Override
public long getId() {
return id;
}
public String getName() {
return name;
}
public long getHostId() {
return hostId;
}
public void setHostId(long hostId) {
this.hostId = hostId;
}
}

View File

@ -38,9 +38,9 @@ import javax.inject.Inject;
import javax.naming.ConfigurationException;
import com.cloud.alert.AlertManager;
import com.cloud.host.HostTagVO;
import com.cloud.exception.StorageConflictException;
import com.cloud.exception.StorageUnavailableException;
import com.cloud.host.HostTagVO;
import com.cloud.storage.Volume;
import com.cloud.storage.VolumeVO;
import com.cloud.storage.dao.VolumeDao;
@ -2334,22 +2334,6 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager,
}
}
if (startup instanceof StartupRoutingCommand) {
final StartupRoutingCommand ssCmd = (StartupRoutingCommand)startup;
final List<String> implicitHostTags = ssCmd.getHostTags();
if (!implicitHostTags.isEmpty()) {
if (hostTags == null) {
hostTags = _hostTagsDao.getHostTags(host.getId()).parallelStream().map(HostTagVO::getTag).collect(Collectors.toList());
}
if (hostTags != null) {
implicitHostTags.removeAll(hostTags);
hostTags.addAll(implicitHostTags);
} else {
hostTags = implicitHostTags;
}
}
}
host.setDataCenterId(dc.getId());
host.setPodId(podId);
host.setClusterId(clusterId);
@ -2392,6 +2376,7 @@ public class ResourceManagerImpl extends ManagerBase implements ResourceManager,
if (startup instanceof StartupRoutingCommand) {
final StartupRoutingCommand ssCmd = (StartupRoutingCommand)startup;
_hostTagsDao.updateImplicitTags(host.getId(), ssCmd.getHostTags());
updateSupportsClonedVolumes(host, ssCmd.getSupportsClonedVolumes());
}

View File

@ -0,0 +1,160 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
""" Tests for importVolume and unmanageVolume APIs
"""
# Import Local Modules
from marvin.cloudstackAPI import updateHost
from marvin.cloudstackTestCase import cloudstackTestCase, unittest
from marvin.lib.base import Host
from marvin.lib.utils import is_server_ssh_ready, wait_until
# Import System modules
from nose.plugins.attrib import attr
import logging
class TestHostTags(cloudstackTestCase):
@classmethod
def setUpClass(cls):
testClient = super(TestHostTags, cls).getClsTestClient()
cls.apiclient = testClient.getApiClient()
cls.hypervisor = testClient.getHypervisorInfo()
if cls.testClient.getHypervisorInfo().lower() != "kvm":
raise unittest.SkipTest("This is only available for KVM")
cls.hostConfig = cls.config.__dict__["zones"][0].__dict__["pods"][0].__dict__["clusters"][0].__dict__["hosts"][0].__dict__
hosts = Host.list(
cls.apiclient,
type = "Routing",
hypervisor = cls.hypervisor
)
if isinstance(hosts, list) and len(hosts) > 0:
cls.host = hosts[0]
else:
raise unittest.SkipTest("No available host for this test")
cls.logger = logging.getLogger("TestHostTags")
cls.stream_handler = logging.StreamHandler()
cls.logger.setLevel(logging.DEBUG)
cls.logger.addHandler(cls.stream_handler)
@classmethod
def tearDownClass(cls):
cls.update_host_tags_via_api(cls.host.hosttags)
cls.update_implicit_host_tags_via_agent_properties(cls.host.implicithosttags)
@classmethod
def update_host_tags_via_api(cls, hosttags):
cmd = updateHost.updateHostCmd()
cmd.id = cls.host.id
cmd.hosttags = hosttags
cls.apiclient.updateHost(cmd)
@classmethod
def update_implicit_host_tags_via_agent_properties(cls, implicithosttags):
ssh_client = is_server_ssh_ready(
cls.host.ipaddress,
22,
cls.hostConfig["username"],
cls.hostConfig["password"],
)
if implicithosttags:
command = "sed -i '/host.tags=/d' /etc/cloudstack/agent/agent.properties \
&& echo 'host.tags=%s' >> /etc/cloudstack/agent/agent.properties \
&& systemctl restart cloudstack-agent" % implicithosttags
else:
command = "sed -i '/host.tags=/d' /etc/cloudstack/agent/agent.properties \
&& systemctl restart cloudstack-agent"
ssh_client.execute(command)
def wait_until_host_is_up_and_verify_hosttags(self, explicithosttags, implicithosttags, interval=3, retries=20):
def check_host_state():
hosts = Host.list(
self.apiclient,
id=self.host.id
)
if isinstance(hosts, list) and len(hosts) > 0:
host = hosts[0]
if host.state == "Up":
self.logger.debug("Host %s is in Up state" % host.name)
self.logger.debug("Host explicithosttags is %s, implicit hosttags is %s" % (host.explicithosttags, host.implicithosttags))
if explicithosttags:
self.assertEquals(explicithosttags, host.explicithosttags)
else:
self.assertIsNone(host.explicithosttags)
if implicithosttags:
self.assertEquals(implicithosttags, host.implicithosttags)
else:
self.assertIsNone(host.implicithosttags)
return True, None
else:
self.logger.debug("Waiting for host %s to be Up state, current state is %s" % (host.name, host.state))
return False, None
done, _ = wait_until(interval, retries, check_host_state)
if not done:
raise Exception("Failed to wait for host %s to be Up" % self.host.name)
return True
@attr(tags=['advanced', 'basic', 'sg'], required_hardware=False)
def test_01_host_tags(self):
"""Test implicit/explicit host tags
"""
# update explicit host tags to "s1,s2"
explicithosttags="s1,s2"
implicithosttags=self.host.implicithosttags
self.update_host_tags_via_api(explicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update implicit host tags to "d1,d2"
implicithosttags="d1,d2"
self.update_implicit_host_tags_via_agent_properties(implicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update explicit host tags to "s3,s4"
explicithosttags="s3,s4"
self.update_host_tags_via_api(explicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update implicit host tags to "d3,d4"
implicithosttags="d3,d4"
self.update_implicit_host_tags_via_agent_properties(implicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update hosttags to ""
explicithosttags=""
self.update_host_tags_via_api(explicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update implicit host tags to ""
implicithosttags=""
self.update_implicit_host_tags_via_agent_properties(implicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update explicit host tags to "s1,s2"
explicithosttags="s1,s2"
self.update_host_tags_via_api(explicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)
# update implicit host tags to "d1,d2"
implicithosttags="d1,d2"
self.update_implicit_host_tags_via_agent_properties(implicithosttags)
self.wait_until_host_is_up_and_verify_hosttags(explicithosttags, implicithosttags)

View File

@ -198,6 +198,7 @@
"label.action.unmanage.virtualmachine": "Unmanage Instance",
"label.action.unmanage.volume": "Unmanage Volume",
"label.action.unmanage.volumes": "Unmanage Volumes",
"label.action.update.host": "Update host",
"label.action.update.offering.access": "Update offering access",
"label.action.update.resource.count": "Update resource count",
"label.action.value": "Action/Value",
@ -1006,6 +1007,12 @@
"label.hostnamelabel": "Host name",
"label.hosts": "Hosts",
"label.hosttags": "Host tags",
"label.hosttags.explicit": "API-defined Host tags",
"label.hosttags.explicit.abbr": "api-defined",
"label.hosttags.explicit.description": "The host tags defined by CloudStack APIs",
"label.hosttags.implicit": "Agent-defined Host tags",
"label.hosttags.implicit.abbr": "agent-defined",
"label.hosttags.implicit.description": "The host tags defined by CloudStack Agent",
"label.hourly": "Hourly",
"label.hypervisor": "Hypervisor",
"label.hypervisor.capabilities": "Hypervisor capabilities",

View File

@ -74,12 +74,8 @@ export default {
icon: 'edit-outlined',
label: 'label.edit',
dataView: true,
args: ['name', 'hosttags', 'istagarule', 'oscategoryid'],
mapping: {
oscategoryid: {
api: 'listOsCategories'
}
}
popup: true,
component: shallowRef(defineAsyncComponent(() => import('@/views/infra/HostUpdate')))
},
{
api: 'provisionCertificate',

View File

@ -51,8 +51,14 @@
<a-list-item v-if="host.hosttags">
<div>
<strong>{{ $t('label.hosttags') }}</strong>
<div>
{{ host.hosttags }}
<div v-for="hosttag in host.allhosttags" :key="hosttag.tag">
{{ hosttag.tag }}
<span v-if="hosttag.isexplicit">
<a-tag color="blue">{{ $t('label.hosttags.explicit.abbr') }}</a-tag>
</span>
<span v-if="hosttag.isimplicit">
<a-tag color="orange">{{ $t('label.hosttags.implicit.abbr') }}</a-tag>
</span>
</div>
</div>
</a-list-item>
@ -158,6 +164,22 @@ export default {
this.fetchLoading = true
api('listHosts', { id: this.resource.id }).then(json => {
this.host = json.listhostsresponse.host[0]
const hosttags = this.host.hosttags?.split(',') || []
const explicithosttags = this.host.explicithosttags?.split(',') || []
const implicithosttags = this.host.implicithosttags?.split(',') || []
const allHostTags = []
for (const hosttag of hosttags) {
var isexplicit = false
var isimplicit = false
if (explicithosttags.includes(hosttag)) {
isexplicit = true
}
if (implicithosttags.includes(hosttag)) {
isimplicit = true
}
allHostTags.push({ tag: hosttag, isexplicit: isexplicit, isimplicit: isimplicit })
}
this.host.allhosttags = allHostTags
}).catch(error => {
this.$notifyError(error)
}).finally(() => {

View File

@ -0,0 +1,183 @@
// 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>
<a-spin :spinning="loading">
<a-form
class="form-layout"
layout="vertical"
:ref="formRef"
:model="form"
:rules="rules"
v-ctrl-enter="handleSubmit"
@finish="handleSubmit">
<a-form-item name="name" ref="name">
<template #label>
<tooltip-label :title="$t('label.name')" :tooltip="apiParams.name.description"/>
</template>
<a-input
v-model:value="form.name"
v-focus="true" />
</a-form-item>
<a-form-item name="hosttags" ref="hosttags">
<template #label>
<tooltip-label :title="$t('label.hosttags')" :tooltip="$t('label.hosttags.explicit.description')"/>
</template>
<a-input v-model:value="form.hosttags" />
</a-form-item>
<a-form-item name="istagarule" ref="istagarule">
<template #label>
<tooltip-label :title="$t('label.istagarule')" :tooltip="apiParams.istagarule.description"/>
</template>
<a-switch v-model:checked="form.istagarule" />
</a-form-item>
<a-form-item name="oscategoryid" ref="oscategoryid">
<template #label>
<tooltip-label :title="$t('label.oscategoryid')" :tooltip="apiParams.oscategoryid.description"/>
</template>
<a-select
showSearch
optionFilterProp="label"
:filterOption="(input, option) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
}"
:loading="osCategories.loading"
v-model:value="form.oscategoryid">
<a-select-option v-for="(osCategory) in osCategories.opts" :key="osCategory.id" :label="osCategory.name">
{{ osCategory.name }}
</a-select-option>
</a-select>
</a-form-item>
<div :span="24" class="action-button">
<a-button :loading="loading" @click="onCloseAction">{{ $t('label.cancel') }}</a-button>
<a-button :loading="loading" ref="submit" type="primary" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
</div>
</a-form>
</a-spin>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { api } from '@/api'
import TooltipLabel from '@/components/widgets/TooltipLabel'
export default {
name: 'EditVM',
components: {
TooltipLabel
},
props: {
action: {
type: Object,
required: true
},
resource: {
type: Object,
required: true
}
},
data () {
return {
loading: false,
osCategories: {
loading: false,
opts: []
}
}
},
beforeCreate () {
this.apiParams = this.$getApiParams('updateHost')
},
created () {
this.initForm()
this.fetchOsCategories()
},
methods: {
initForm () {
this.formRef = ref()
this.form = reactive({
name: this.resource.name,
hosttags: this.resource.explicithosttags,
istagarule: this.resource.istagarule,
oscategoryid: this.resource.oscategoryid
})
this.rules = reactive({})
},
fetchOsCategories () {
this.osCategories.loading = true
this.osCategories.opts = []
api('listOsCategories').then(json => {
this.osCategories.opts = json.listoscategoriesresponse.oscategory || []
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
this.osCategories.loading = false
})
},
handleSubmit () {
this.formRef.value.validate().then(() => {
const values = toRaw(this.form)
const params = {}
params.id = this.resource.id
params.name = values.name
params.hosttags = values.hosttags
params.oscategoryid = values.oscategoryid
if (values.istagarule !== undefined) {
params.istagarule = values.istagarule
}
this.loading = true
api('updateHost', params).then(json => {
this.$message.success({
content: `${this.$t('label.action.update.host')} - ${values.name}`,
duration: 2
})
this.$emit('refresh-data')
this.onCloseAction()
}).catch(error => {
this.$notifyError(error)
}).finally(() => { this.loading = false })
}).catch(error => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
},
onCloseAction () {
this.$emit('close-action')
}
}
}
</script>
<style scoped lang="less">
.form-layout {
width: 80vw;
@media (min-width: 600px) {
width: 450px;
}
.action-button {
text-align: right;
margin-top: 20px;
button {
margin-right: 5px;
}
}
}
</style>