From 4ac4d9cf29abd83526093d162424da0419496297 Mon Sep 17 00:00:00 2001 From: Henrique Sato Date: Tue, 3 Dec 2024 14:41:25 -0300 Subject: [PATCH] API to validate Quota activation rule (#9605) * API to validate Quota activation rule * Apply suggestions from code review Co-authored-by: Bryan Lima <42067040+BryanMLima@users.noreply.github.com> * Use constants --------- Co-authored-by: Henrique Sato Co-authored-by: Bryan Lima <42067040+BryanMLima@users.noreply.github.com> --- .../presetvariables/PresetVariables.java | 13 + .../cloudstack/quota/constant/QuotaTypes.java | 16 ++ .../QuotaValidateActivationRuleCmd.java | 70 +++++ .../api/response/QuotaResponseBuilder.java | 3 + .../response/QuotaResponseBuilderImpl.java | 93 ++++++- .../QuotaValidateActivationRuleResponse.java | 76 ++++++ .../cloudstack/quota/QuotaServiceImpl.java | 2 + .../QuotaValidateActivationRuleCmdTest.java | 41 +++ .../QuotaResponseBuilderImplTest.java | 88 ++++++- .../jsinterpreter/JsInterpreterHelper.java | 240 ++++++++++++++++++ .../spring-server-core-managers-context.xml | 2 + .../JsInterpreterHelperTest.java | 228 +++++++++++++++++ ui/public/locales/en.json | 3 + ui/public/locales/pt_BR.json | 9 +- ui/src/style/objects/form.scss | 8 + .../views/plugins/quota/CreateQuotaTariff.vue | 52 +++- .../views/plugins/quota/EditQuotaTariff.vue | 59 ++++- 17 files changed, 993 insertions(+), 10 deletions(-) create mode 100644 plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmd.java create mode 100644 plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaValidateActivationRuleResponse.java create mode 100644 plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmdTest.java create mode 100644 server/src/main/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelper.java create mode 100644 server/src/test/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelperTest.java diff --git a/framework/quota/src/main/java/org/apache/cloudstack/quota/activationrule/presetvariables/PresetVariables.java b/framework/quota/src/main/java/org/apache/cloudstack/quota/activationrule/presetvariables/PresetVariables.java index b27bf589c16..6dab6604e91 100644 --- a/framework/quota/src/main/java/org/apache/cloudstack/quota/activationrule/presetvariables/PresetVariables.java +++ b/framework/quota/src/main/java/org/apache/cloudstack/quota/activationrule/presetvariables/PresetVariables.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.quota.activationrule.presetvariables; +import java.util.List; + public class PresetVariables { @PresetVariableDefinition(description = "Account owner of the resource.") @@ -37,6 +39,9 @@ public class PresetVariables { @PresetVariableDefinition(description = "Zone where the resource is.") private GenericPresetVariable zone; + @PresetVariableDefinition(description = "A list containing the tariffs ordered by the field 'position'.") + private List lastTariffs; + public Account getAccount() { return account; } @@ -84,4 +89,12 @@ public class PresetVariables { public void setZone(GenericPresetVariable zone) { this.zone = zone; } + + public List getLastTariffs() { + return lastTariffs; + } + + public void setLastTariffs(List lastTariffs) { + this.lastTariffs = lastTariffs; + } } diff --git a/framework/quota/src/main/java/org/apache/cloudstack/quota/constant/QuotaTypes.java b/framework/quota/src/main/java/org/apache/cloudstack/quota/constant/QuotaTypes.java index 947183577a8..0da0d6e53f7 100644 --- a/framework/quota/src/main/java/org/apache/cloudstack/quota/constant/QuotaTypes.java +++ b/framework/quota/src/main/java/org/apache/cloudstack/quota/constant/QuotaTypes.java @@ -20,9 +20,11 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.usage.UsageTypes; import org.apache.cloudstack.usage.UsageUnitTypes; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import org.apache.commons.lang3.StringUtils; public class QuotaTypes extends UsageTypes { private final Integer quotaType; @@ -106,6 +108,20 @@ public class QuotaTypes extends UsageTypes { return quotaTypeMap.get(quotaType); } + static public QuotaTypes getQuotaTypeByName(String name) { + if (StringUtils.isBlank(name)) { + throw new CloudRuntimeException("Could not retrieve Quota type by name because the value passed as parameter is null, empty, or blank."); + } + + for (QuotaTypes type : quotaTypeMap.values()) { + if (type.getQuotaName().equals(name)) { + return type; + } + } + + throw new CloudRuntimeException(String.format("Could not find Quota type with name [%s].", name)); + } + @Override public String toString() { return ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "quotaType", "quotaName"); diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmd.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmd.java new file mode 100644 index 00000000000..a9dc7ea63eb --- /dev/null +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmd.java @@ -0,0 +1,70 @@ +//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.command; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.QuotaResponseBuilder; +import org.apache.cloudstack.api.response.QuotaValidateActivationRuleResponse; +import org.apache.cloudstack.quota.constant.QuotaTypes; + +import javax.inject.Inject; + +@APICommand(name = "quotaValidateActivationRule", responseObject = QuotaValidateActivationRuleResponse.class, description = "Validates if the given activation rule is valid for the informed usage type.", since = "4.20.0", requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) +public class QuotaValidateActivationRuleCmd extends BaseCmd { + + @Inject + QuotaResponseBuilder responseBuilder; + + @Parameter(name = ApiConstants.ACTIVATION_RULE, type = CommandType.STRING, required = true, description = "Quota tariff's activation rule to validate. The activation rule is valid if it has no syntax errors and all " + + "variables are compatible with the given usage type.", length = 65535) + private String activationRule; + + @Parameter(name = ApiConstants.USAGE_TYPE, type = CommandType.INTEGER, required = true, description = "The Quota usage type used to validate the activation rule.") + private Integer quotaType; + + @Override + public void execute() { + QuotaValidateActivationRuleResponse response = responseBuilder.validateActivationRule(this); + + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + public String getActivationRule() { + return activationRule; + } + + public QuotaTypes getQuotaType() { + QuotaTypes quotaTypes = QuotaTypes.getQuotaType(quotaType); + + if (quotaTypes == null) { + throw new InvalidParameterValueException(String.format("Usage type not found for value [%s].", quotaType)); + } + + return quotaTypes; + } +} diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java index c635551aeb5..56935a1360c 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java @@ -26,6 +26,7 @@ import org.apache.cloudstack.api.command.QuotaStatementCmd; import org.apache.cloudstack.api.command.QuotaTariffCreateCmd; import org.apache.cloudstack.api.command.QuotaTariffListCmd; import org.apache.cloudstack.api.command.QuotaTariffUpdateCmd; +import org.apache.cloudstack.api.command.QuotaValidateActivationRuleCmd; import org.apache.cloudstack.quota.vo.QuotaBalanceVO; import org.apache.cloudstack.quota.vo.QuotaEmailConfigurationVO; import org.apache.cloudstack.quota.vo.QuotaTariffVO; @@ -88,4 +89,6 @@ public interface QuotaResponseBuilder { QuotaConfigureEmailResponse createQuotaConfigureEmailResponse(QuotaEmailConfigurationVO quotaEmailConfigurationVO, Double minBalance, long accountId); List listEmailConfiguration(long accountId); + + QuotaValidateActivationRuleResponse validateActivationRule(QuotaValidateActivationRuleCmd cmd); } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java index 1c486759e43..733f7792356 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java @@ -16,6 +16,7 @@ //under the License. package org.apache.cloudstack.api.response; +import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; @@ -34,12 +35,15 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.ListIterator; +import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; import javax.inject.Inject; import com.cloud.utils.DateUtil; +import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.QuotaBalanceCmd; @@ -51,8 +55,10 @@ import org.apache.cloudstack.api.command.QuotaStatementCmd; import org.apache.cloudstack.api.command.QuotaTariffCreateCmd; import org.apache.cloudstack.api.command.QuotaTariffListCmd; import org.apache.cloudstack.api.command.QuotaTariffUpdateCmd; +import org.apache.cloudstack.api.command.QuotaValidateActivationRuleCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.discovery.ApiDiscoveryService; +import org.apache.cloudstack.jsinterpreter.JsInterpreterHelper; import org.apache.cloudstack.quota.QuotaManager; import org.apache.cloudstack.quota.QuotaManagerImpl; import org.apache.cloudstack.quota.QuotaService; @@ -78,6 +84,7 @@ import org.apache.cloudstack.quota.vo.QuotaEmailConfigurationVO; import org.apache.cloudstack.quota.vo.QuotaEmailTemplatesVO; import org.apache.cloudstack.quota.vo.QuotaTariffVO; import org.apache.cloudstack.quota.vo.QuotaUsageVO; +import org.apache.cloudstack.utils.jsinterpreter.JsInterpreter; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.reflect.FieldUtils; @@ -133,11 +140,13 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { private QuotaManager _quotaManager; @Inject private QuotaEmailConfigurationDao quotaEmailConfigurationDao; + @Inject + private JsInterpreterHelper jsInterpreterHelper; + @Inject + private ApiDiscoveryService apiDiscoveryService; private final Class[] assignableClasses = {GenericPresetVariable.class, ComputingResources.class}; - @Inject - private ApiDiscoveryService apiDiscoveryService; @Override public QuotaTariffResponse createQuotaTariffResponse(QuotaTariffVO tariff, boolean returnActivationRule) { @@ -789,7 +798,7 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { */ public void filterSupportedTypes(List> variables, QuotaTypes quotaType, PresetVariableDefinition presetVariableDefinitionAnnotation, Class fieldClass, String presetVariableName) { - if (Arrays.stream(presetVariableDefinitionAnnotation.supportedTypes()).noneMatch(supportedType -> + if (quotaType != null && Arrays.stream(presetVariableDefinitionAnnotation.supportedTypes()).noneMatch(supportedType -> supportedType == quotaType.getQuotaType() || supportedType == 0)) { return; } @@ -928,4 +937,82 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { return quotaConfigureEmailResponse; } + + @Override + public QuotaValidateActivationRuleResponse validateActivationRule(QuotaValidateActivationRuleCmd cmd) { + String message; + String activationRule = cmd.getActivationRule(); + QuotaTypes quotaType = cmd.getQuotaType(); + String quotaName = quotaType.getQuotaName(); + List> usageTypeVariablesAndDescriptions = new ArrayList<>(); + + addAllPresetVariables(PresetVariables.class, quotaType, usageTypeVariablesAndDescriptions, null); + List usageTypeVariables = usageTypeVariablesAndDescriptions.stream().map(Pair::first).collect(Collectors.toList()); + + try (JsInterpreter jsInterpreter = new JsInterpreter(QuotaConfig.QuotaActivationRuleTimeout.value())) { + Map newVariables = injectUsageTypeVariables(jsInterpreter, usageTypeVariables); + String scriptToExecute = jsInterpreterHelper.replaceScriptVariables(activationRule, newVariables); + jsInterpreter.executeScript(String.format("new Function(\"%s\")", scriptToExecute.replaceAll("\n", ""))); + } catch (IOException | CloudRuntimeException e) { + logger.error("Unable to execute activation rule due to: [{}].", e.getMessage(), e); + message = "Error while executing activation rule. Check if there are no syntax errors and all variables are compatible with the given usage type."; + return createValidateActivationRuleResponse(activationRule, quotaName, false, message); + } + + Set scriptVariables = jsInterpreterHelper.getScriptVariables(activationRule); + if (isScriptVariablesValid(scriptVariables, usageTypeVariables)) { + message = "The script has no syntax errors and all variables are compatible with the given usage type."; + return createValidateActivationRuleResponse(activationRule, quotaName, true, message); + } + + message = "Found variables that are not compatible with the given usage type."; + return createValidateActivationRuleResponse(activationRule, quotaName, false, message); + } + + /** + * Checks whether script variables are compatible with the usage type. First, we remove all script variables that correspond to the script's usage type variables. + * Then, returns true if none of the remaining script variables match any usage types variables, and false otherwise. + * + * @param scriptVariables Script variables. + * @param scriptUsageTypeVariables Script usage type variables. + * @return True if the script variables are valid, false otherwise. + */ + protected boolean isScriptVariablesValid(Set scriptVariables, List scriptUsageTypeVariables) { + List> allUsageTypeVariablesAndDescriptions = new ArrayList<>(); + addAllPresetVariables(PresetVariables.class, null, allUsageTypeVariablesAndDescriptions, null); + List allUsageTypesVariables = allUsageTypeVariablesAndDescriptions.stream().map(Pair::first).collect(Collectors.toList()); + + List matchVariables = scriptVariables.stream().filter(scriptUsageTypeVariables::contains).collect(Collectors.toList()); + matchVariables.forEach(scriptVariables::remove); + + return scriptVariables.stream().noneMatch(allUsageTypesVariables::contains); + } + + /** + * Injects variables into JavaScript interpreter. It's necessary to remove all dots from the given variables, as the interpreter + * does not interpret the variables as attributes of objects. + * + * @param jsInterpreter the {@link JsInterpreter} which the variables will be injected. + * @param variables the {@link List} with variables to format and inject the formatted variables into interpreter. + * @return A {@link Map} which has the key as the given variable and the value as the given variable formatted (without dots). + */ + protected Map injectUsageTypeVariables(JsInterpreter jsInterpreter, List variables) { + Map formattedVariables = new HashMap<>(); + for (String variable : variables) { + String formattedVariable = variable.replace(".", ""); + formattedVariables.put(variable, formattedVariable); + jsInterpreter.injectVariable(formattedVariable, "false"); + } + + return formattedVariables; + } + + public QuotaValidateActivationRuleResponse createValidateActivationRuleResponse(String activationRule, String quotaType, Boolean isValid, String message) { + QuotaValidateActivationRuleResponse response = new QuotaValidateActivationRuleResponse(); + response.setActivationRule(activationRule); + response.setQuotaType(quotaType); + response.setValid(isValid); + response.setMessage(message); + return response; + } } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaValidateActivationRuleResponse.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaValidateActivationRuleResponse.java new file mode 100644 index 00000000000..0726764568f --- /dev/null +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaValidateActivationRuleResponse.java @@ -0,0 +1,76 @@ +//Licensed to the Apache Software Foundation (ASF) under one +//or more contributor license agreements. See the NOTICE file +//distributed with this work for additional information +//regarding copyright ownership. The ASF licenses this file +//to you under the Apache License, Version 2.0 (the +//"License"); you may not use this file except in compliance +//with the License. You may obtain a copy of the License at +// +//http://www.apache.org/licenses/LICENSE-2.0 +// +//Unless required by applicable law or agreed to in writing, +//software distributed under the License is distributed on an +//"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +//KIND, either express or implied. See the License for the +//specific language governing permissions and limitations +//under the License. +package org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.BaseResponse; + +public class QuotaValidateActivationRuleResponse extends BaseResponse { + + @SerializedName("activationrule") + @Param(description = "The validated activation rule.") + private String activationRule; + + @SerializedName("quotatype") + @Param(description = "The Quota usage type used to validate the activation rule.") + private String quotaType; + + @SerializedName("isvalid") + @Param(description = "Whether the activation rule is valid.") + private Boolean isValid; + + @SerializedName("message") + @Param(description = "The reason whether the activation rule is valid or not.") + private String message; + + public QuotaValidateActivationRuleResponse() { + super("validactivationrule"); + } + + public String getActivationRule() { + return activationRule; + } + + public void setActivationRule(String activationRule) { + this.activationRule = activationRule; + } + + public Boolean isValid() { + return isValid; + } + + public void setValid(Boolean valid) { + isValid = valid; + } + + public String getQuotaType() { + return quotaType; + } + + public void setQuotaType(String quotaType) { + this.quotaType = quotaType; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java index 17fa7bd8425..97d77b8aa22 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/quota/QuotaServiceImpl.java @@ -41,6 +41,7 @@ import org.apache.cloudstack.api.command.QuotaTariffDeleteCmd; import org.apache.cloudstack.api.command.QuotaTariffListCmd; import org.apache.cloudstack.api.command.QuotaTariffUpdateCmd; import org.apache.cloudstack.api.command.QuotaUpdateCmd; +import org.apache.cloudstack.api.command.QuotaValidateActivationRuleCmd; import org.apache.cloudstack.api.response.QuotaResponseBuilder; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.ConfigKey; @@ -121,6 +122,7 @@ public class QuotaServiceImpl extends ManagerBase implements QuotaService, Confi cmdList.add(QuotaConfigureEmailCmd.class); cmdList.add(QuotaListEmailConfigurationCmd.class); cmdList.add(QuotaPresetVariablesListCmd.class); + cmdList.add(QuotaValidateActivationRuleCmd.class); return cmdList; } diff --git a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmdTest.java b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmdTest.java new file mode 100644 index 00000000000..1d0c0cf7ff6 --- /dev/null +++ b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaValidateActivationRuleCmdTest.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command; + +import org.apache.cloudstack.api.response.QuotaResponseBuilder; +import org.apache.cloudstack.api.response.QuotaValidateActivationRuleResponse; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class QuotaValidateActivationRuleCmdTest { + @Mock + QuotaResponseBuilder responseBuilderMock; + + @Test + public void executeTestVerifyCalls() { + QuotaValidateActivationRuleCmd cmd = new QuotaValidateActivationRuleCmd(); + cmd.responseBuilder = responseBuilderMock; + Mockito.doReturn(new QuotaValidateActivationRuleResponse()).when(responseBuilderMock).validateActivationRule(cmd); + cmd.execute(); + + Mockito.verify(responseBuilderMock).validateActivationRule(cmd); + } +} diff --git a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java index fd359525893..a26e6c0476d 100644 --- a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java +++ b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java @@ -25,6 +25,9 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.HashSet; import java.util.function.Consumer; import com.cloud.domain.DomainVO; @@ -34,7 +37,10 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd; +import org.apache.cloudstack.api.command.QuotaValidateActivationRuleCmd; +import org.apache.cloudstack.discovery.ApiDiscoveryService; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.jsinterpreter.JsInterpreterHelper; import org.apache.cloudstack.quota.QuotaService; import org.apache.cloudstack.quota.QuotaStatement; import org.apache.cloudstack.quota.activationrule.presetvariables.PresetVariableDefinition; @@ -55,7 +61,7 @@ import org.apache.cloudstack.quota.vo.QuotaCreditsVO; import org.apache.cloudstack.quota.vo.QuotaEmailConfigurationVO; import org.apache.cloudstack.quota.vo.QuotaEmailTemplatesVO; import org.apache.cloudstack.quota.vo.QuotaTariffVO; -import org.apache.cloudstack.discovery.ApiDiscoveryService; +import org.apache.cloudstack.utils.jsinterpreter.JsInterpreter; import org.apache.commons.lang3.time.DateUtils; @@ -160,6 +166,12 @@ public class QuotaResponseBuilderImplTest extends TestCase { return new Calendar[] {calendar, calendar}; } + @Mock + QuotaValidateActivationRuleCmd quotaValidateActivationRuleCmdMock = Mockito.mock(QuotaValidateActivationRuleCmd.class); + + @Mock + JsInterpreterHelper jsInterpreterHelperMock = Mockito.mock(JsInterpreterHelper.class); + private QuotaTariffVO makeTariffTestData() { QuotaTariffVO tariffVO = new QuotaTariffVO(); tariffVO.setUsageType(QuotaTypes.IP_ADDRESS); @@ -645,4 +657,78 @@ public class QuotaResponseBuilderImplTest extends TestCase { assertFalse(quotaResponseBuilderSpy.isUserAllowedToSeeActivationRules(userMock)); } + + @Test + public void validateActivationRuleTestValidateActivationRuleReturnValidScriptResponse() { + Mockito.doReturn("if (account.name == 'test') { true } else { false }").when(quotaValidateActivationRuleCmdMock).getActivationRule(); + Mockito.doReturn(QuotaTypes.getQuotaType(30)).when(quotaValidateActivationRuleCmdMock).getQuotaType(); + Mockito.doReturn(quotaValidateActivationRuleCmdMock.getActivationRule()).when(jsInterpreterHelperMock).replaceScriptVariables(Mockito.anyString(), Mockito.any()); + + QuotaValidateActivationRuleResponse response = quotaResponseBuilderSpy.validateActivationRule(quotaValidateActivationRuleCmdMock); + + Assert.assertTrue(response.isValid()); + } + + @Test + public void validateActivationRuleTestUsageTypeIncompatibleVariableReturnInvalidScriptResponse() { + Mockito.doReturn("if (value.osName == 'test') { true } else { false }").when(quotaValidateActivationRuleCmdMock).getActivationRule(); + Mockito.doReturn(QuotaTypes.getQuotaType(30)).when(quotaValidateActivationRuleCmdMock).getQuotaType(); + Mockito.doReturn(quotaValidateActivationRuleCmdMock.getActivationRule()).when(jsInterpreterHelperMock).replaceScriptVariables(Mockito.anyString(), Mockito.any()); + Mockito.when(jsInterpreterHelperMock.getScriptVariables(quotaValidateActivationRuleCmdMock.getActivationRule())).thenReturn(Set.of("value.osName")); + + QuotaValidateActivationRuleResponse response = quotaResponseBuilderSpy.validateActivationRule(quotaValidateActivationRuleCmdMock); + + Assert.assertFalse(response.isValid()); + } + + @Test + public void validateActivationRuleTestActivationRuleWithSyntaxErrorsReturnInvalidScriptResponse() { + Mockito.doReturn("{ if (account.name == 'test') { true } else { false } }}").when(quotaValidateActivationRuleCmdMock).getActivationRule(); + Mockito.doReturn(QuotaTypes.getQuotaType(1)).when(quotaValidateActivationRuleCmdMock).getQuotaType(); + Mockito.doReturn(quotaValidateActivationRuleCmdMock.getActivationRule()).when(jsInterpreterHelperMock).replaceScriptVariables(Mockito.anyString(), Mockito.any()); + + QuotaValidateActivationRuleResponse response = quotaResponseBuilderSpy.validateActivationRule(quotaValidateActivationRuleCmdMock); + + Assert.assertFalse(response.isValid()); + } + + @Test + public void isScriptVariablesValidTestUnsupportedUsageTypeVariablesReturnFalse() { + Set scriptVariables = new HashSet<>(List.of("value.computingResources.cpuNumber", "account.name", "zone.id")); + List usageTypeVariables = List.of("value.virtualSize", "account.name", "zone.id"); + + boolean isScriptVariablesValid = quotaResponseBuilderSpy.isScriptVariablesValid(scriptVariables, usageTypeVariables); + + Assert.assertFalse(isScriptVariablesValid); + } + + @Test + public void isScriptVariablesValidTestSupportedUsageTypeVariablesReturnTrue() { + Set scriptVariables = new HashSet<>(List.of("value.computingResources.cpuNumber", "account.name", "zone.id")); + List usageTypeVariables = List.of("value.computingResources.cpuNumber", "account.name", "zone.id"); + + boolean isScriptVariablesValid = quotaResponseBuilderSpy.isScriptVariablesValid(scriptVariables, usageTypeVariables); + + Assert.assertTrue(isScriptVariablesValid); + } + + @Test + public void isScriptVariablesValidTestVariablesUnrelatedToUsageTypeReturnTrue() { + Set scriptVariables = new HashSet<>(List.of("variable1.valid", "variable2.valid.", "variable3.valid")); + List usageTypeVariables = List.of("project.name", "account.id", "domain.path"); + + boolean isScriptVariablesValid = quotaResponseBuilderSpy.isScriptVariablesValid(scriptVariables, usageTypeVariables); + + Assert.assertTrue(isScriptVariablesValid); + } + + @Test + public void injectUsageTypeVariablesTestReturnInjectedVariables() { + JsInterpreter interpreter = Mockito.mock(JsInterpreter.class); + + Map formattedVariables = quotaResponseBuilderSpy.injectUsageTypeVariables(interpreter, List.of("account.name", "zone.name")); + + Assert.assertTrue(formattedVariables.containsValue("accountname")); + Assert.assertTrue(formattedVariables.containsValue("zonename")); + } } diff --git a/server/src/main/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelper.java b/server/src/main/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelper.java new file mode 100644 index 00000000000..b4d4b5fca45 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelper.java @@ -0,0 +1,240 @@ +// 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.jsinterpreter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.openjdk.nashorn.api.scripting.ScriptUtils; +import org.openjdk.nashorn.internal.runtime.Context; +import org.openjdk.nashorn.internal.runtime.ErrorManager; +import org.openjdk.nashorn.internal.runtime.options.Options; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class JsInterpreterHelper { + private final Logger logger = LogManager.getLogger(getClass()); + + private static final String NAME = "name"; + private static final String PROPERTY = "property"; + private static final String TYPE = "type"; + private static final String CALL_EXPRESSION = "CallExpression"; + + private int callExpressions; + + private StringBuilder variable; + + private Set variables; + + /** + * Returns all variables from the given script. + * + * @param script the script to extract the variables. + * @return A {@link Set} containing all variables in the script. + */ + public Set getScriptVariables(String script) { + String parseTree = getScriptAsJsonTree(script); + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = null; + variables = new HashSet<>(); + variable = new StringBuilder(); + + try { + jsonNode = mapper.readTree(parseTree); + } catch (JsonProcessingException e) { + logger.error("Unable to create the script JSON tree due to: [{}].", e.getMessage(), e); + } + + logger.trace("Searching script variables from [{}].", script); + iterateOverJsonTree(jsonNode.fields()); + + if (StringUtils.isNotBlank(variable.toString())) { + logger.trace("Adding variable [{}] into the variables set.", variable); + removeCallFunctionsFromVariable(); + variables.add(variable.toString()); + } + + logger.trace("Found the following variables from the given script: [{}]", variables); + return variables; + } + + private String getScriptAsJsonTree(String script) { + logger.trace("Creating JSON Tree for script [{}].", script); + Options options = new Options("nashorn"); + options.set("anon.functions", true); + options.set("parse.only", true); + options.set("scripting", true); + + ErrorManager errors = new ErrorManager(); + Context context = new Context(options, errors, Thread.currentThread().getContextClassLoader()); + Context.setGlobal(context.createGlobal()); + + return ScriptUtils.parse(script, "nashorn", false); + } + + protected void iterateOverJsonTree(Iterator> iterator) { + while (iterator.hasNext()) { + iterateOverJsonTree(iterator.next()); + } + } + + protected void iterateOverJsonTree(Map.Entry fields) { + JsonNode node = null; + + if (fields.getValue().isArray()) { + iterateOverArrayNodes(fields); + } else { + node = fields.getValue(); + } + + String fieldName = searchIntoObjectNodes(node); + + if (fieldName == null) { + String key = fields.getKey(); + if (TYPE.equals(key) && CALL_EXPRESSION.equals(node.textValue())) { + callExpressions++; + } + + if (NAME.equals(key) || PROPERTY.equals(key)) { + appendFieldValueToVariable(key, node); + } + } + } + + protected void iterateOverArrayNodes(Map.Entry fields) { + for (int count = 0; fields.getValue().get(count) != null; count++) { + iterateOverJsonTree(fields.getValue().get(count).fields()); + } + } + + protected String searchIntoObjectNodes(JsonNode node) { + if (node == null) { + return null; + } + + String fieldName = null; + Iterator iterator = node.fieldNames(); + while (iterator.hasNext()) { + fieldName = iterator.next(); + if (TYPE.equals(fieldName) && CALL_EXPRESSION.equals(node.get(fieldName).textValue())) { + callExpressions++; + } + + if (NAME.equals(fieldName) || PROPERTY.equals(fieldName)) { + appendFieldValueToVariable(fieldName, node.get(fieldName)); + } + + if (node.get(fieldName).isArray()) { + JsonNode blockStatementContent = node.get(fieldName).get(0); + if (blockStatementContent != null) { + iterateOverJsonTree(blockStatementContent.fields()); + } + } else { + iterateOverJsonTree(node.get(fieldName).fields()); + } + } + + return fieldName; + } + + protected void appendFieldValueToVariable(String key, JsonNode node) { + String nodeTextValue = node.textValue(); + if (nodeTextValue == null) { + return; + } + + if (PROPERTY.equals(key)) { + logger.trace("Appending field value [{}] to variable [{}] as the field name is \"property\".", nodeTextValue, variable); + variable.append(".").append(nodeTextValue); + return; + } + + logger.trace("Building new variable [{}] as the field name is \"name\"", nodeTextValue); + if (StringUtils.isNotBlank(variable.toString())) { + logger.trace("Adding variable [{}] into the variables set.", variable); + removeCallFunctionsFromVariable(); + variables.add(variable.toString()); + variable.setLength(0); + } + variable.append(nodeTextValue); + } + + protected void removeCallFunctionsFromVariable() { + String[] disassembledVariable = variable.toString().split("\\."); + variable.setLength(0); + + int newVariableSize = disassembledVariable.length - callExpressions; + String[] newVariable = Arrays.copyOfRange(disassembledVariable, 0, newVariableSize); + + variable.append(String.join(".", newVariable)); + callExpressions = 0; + } + + /** + * Replaces all variables in script that matches the key in {@link Map} for their respective values. + * + * @param script the script which the variables will be replaced. + * @param variablesToReplace a {@link Map} which has the key as the variable to be replaced and the value as the variable to replace. + * @return A new script with the variables replaced. + */ + public String replaceScriptVariables(String script, Map variablesToReplace) { + String regex = String.format("\\b(%s)\\b", String.join("|", variablesToReplace.keySet())); + Matcher matcher = Pattern.compile(regex).matcher(script); + + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + matcher.appendReplacement(sb, variablesToReplace.get(matcher.group())); + } + matcher.appendTail(sb); + + return sb.toString(); + } + + public int getCallExpressions() { + return callExpressions; + } + + public void setCallExpressions(int callExpressions) { + this.callExpressions = callExpressions; + } + + public StringBuilder getVariable() { + return variable; + } + + public void setVariable(StringBuilder variable) { + this.variable = variable; + } + + public Set getVariables() { + return variables; + } + + public void setVariables(Set variables) { + this.variables = variables; + } +} diff --git a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index 1bf921f625e..68abe7a16f1 100644 --- a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -218,6 +218,8 @@ + + diff --git a/server/src/test/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelperTest.java b/server/src/test/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelperTest.java new file mode 100644 index 00000000000..7195424a1a2 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/jsinterpreter/JsInterpreterHelperTest.java @@ -0,0 +1,228 @@ +// 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.jsinterpreter; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RunWith(MockitoJUnitRunner.class) +public class JsInterpreterHelperTest { + + @Spy + private JsInterpreterHelper jsInterpreterHelperSpy; + + @Mock + private JsonNode jsonNodeMock; + + @Mock + private Iterator fieldNamesMock; + + @Mock + private Map.Entry fields; + + public void setupIterateOverJsonTreeTests() { + JsonNode node = Map.entry("array", jsonNodeMock).getValue(); + Mockito.doReturn(true).when(node).isArray(); + Mockito.doReturn(node).when(fields).getValue(); + } + + @Test + public void getScriptVariablesTestReturnVariables() { + String script = "if (account.name == 'test') { domain.id } else { zone.id }"; + + Set variables = jsInterpreterHelperSpy.getScriptVariables(script); + + Assert.assertEquals(variables.size(), 3); + Assert.assertTrue(variables.containsAll(List.of("account.name", "domain.id", "zone.id"))); + } + + @Test + public void getScriptVariablesTestScriptWithoutVariablesReturnEmptyList() { + String script = "if (4 < 2) { 3 } else if (3 != 3) { 3 } else { 3 > 3 } while (false) { 3 }"; + + Set variables = jsInterpreterHelperSpy.getScriptVariables(script); + + Assert.assertTrue(variables.isEmpty()); + } + + @Test + public void replaceScriptVariablesTestReturnScriptWithVariablesReplaced() { + String script = "if (account.name == 'test') { domain.id } else { zone.id }"; + Map newVariables = new HashMap<>(); + newVariables.put("account.name", "accountname"); + newVariables.put("domain.id", "domainid"); + newVariables.put("zone.id", "zoneid"); + + String newScript = jsInterpreterHelperSpy.replaceScriptVariables(script, newVariables); + + Assert.assertEquals("if (accountname == 'test') { domainid } else { zoneid }", newScript); + } + + @Test + public void searchIntoObjectNodesTestNullNodeReturnNull() { + String fieldName = jsInterpreterHelperSpy.searchIntoObjectNodes(null); + + Assert.assertEquals(null, fieldName); + } + + @Test + public void searchIntoObjectNodesTestNonEmptyFieldNamesReturnFieldName() { + Mockito.doReturn(true, false).when(fieldNamesMock).hasNext(); + Mockito.doReturn("fieldName").when(fieldNamesMock).next(); + Mockito.doReturn(jsonNodeMock).when(jsonNodeMock).get("fieldName"); + Mockito.doNothing().when(jsInterpreterHelperSpy).iterateOverJsonTree((Iterator>) Mockito.any()); + Mockito.doReturn(fieldNamesMock).when(jsonNodeMock).fieldNames(); + + String fieldName = jsInterpreterHelperSpy.searchIntoObjectNodes(jsonNodeMock); + + Assert.assertEquals("fieldName", fieldName); + } + + @Test + public void searchIntoObjectNodesTestNameFieldAppendFieldValueToVariable() { + Mockito.doReturn(true, false).when(fieldNamesMock).hasNext(); + Mockito.doReturn("name").when(fieldNamesMock).next(); + Mockito.doReturn(jsonNodeMock).when(jsonNodeMock).get("name"); + Mockito.doNothing().when(jsInterpreterHelperSpy).iterateOverJsonTree((Iterator>) Mockito.any()); + Mockito.doReturn(fieldNamesMock).when(jsonNodeMock).fieldNames(); + + jsInterpreterHelperSpy.searchIntoObjectNodes(jsonNodeMock); + + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(1)).appendFieldValueToVariable(Mockito.any(), Mockito.any()); + } + + @Test + public void searchIntoObjectNodesTestPropertyFieldAppendFieldValueToVariable() { + Mockito.doReturn(true, false).when(fieldNamesMock).hasNext(); + Mockito.doReturn("property").when(fieldNamesMock).next(); + Mockito.doReturn(jsonNodeMock).when(jsonNodeMock).get("property"); + Mockito.doNothing().when(jsInterpreterHelperSpy).iterateOverJsonTree((Iterator>) Mockito.any()); + Mockito.doReturn(fieldNamesMock).when(jsonNodeMock).fieldNames(); + + jsInterpreterHelperSpy.searchIntoObjectNodes(jsonNodeMock); + + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(1)).appendFieldValueToVariable(Mockito.any(), Mockito.any()); + } + + @Test + public void appendFieldValueToVariableTestPropertyKeyAppendFieldValueAsAttribute() { + jsInterpreterHelperSpy.setVariable(new StringBuilder("account")); + jsInterpreterHelperSpy.setVariables(new HashSet<>()); + Mockito.doReturn("name").when(jsonNodeMock).textValue(); + + jsInterpreterHelperSpy.appendFieldValueToVariable("property", jsonNodeMock); + + Assert.assertEquals("account.name", jsInterpreterHelperSpy.getVariable().toString()); + } + + @Test + public void appendFieldValueToVariableTestNameKeyAppendFieldValueAsNewVariable() { + jsInterpreterHelperSpy.setVariable(new StringBuilder("account")); + jsInterpreterHelperSpy.setVariables(new HashSet<>()); + Mockito.doReturn("zone").when(jsonNodeMock).textValue(); + + jsInterpreterHelperSpy.appendFieldValueToVariable("name", jsonNodeMock); + + Assert.assertEquals("zone", jsInterpreterHelperSpy.getVariable().toString()); + } + + @Test + public void iterateOverJsonTreeTestMethodsCall() { + setupIterateOverJsonTreeTests(); + + jsInterpreterHelperSpy.iterateOverJsonTree(fields); + + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(1)).iterateOverArrayNodes(Mockito.any()); + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(1)).searchIntoObjectNodes(Mockito.any()); + } + + @Test + public void iterateOverJsonTreeTestFieldNameNullAndNameKeyAppendFieldValueToVariable() { + setupIterateOverJsonTreeTests(); + Mockito.doReturn("name").when(fields).getKey(); + Mockito.doNothing().when(jsInterpreterHelperSpy).appendFieldValueToVariable(Mockito.any(), Mockito.any()); + + jsInterpreterHelperSpy.iterateOverJsonTree(fields); + + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(1)).appendFieldValueToVariable(Mockito.any(), Mockito.any()); + } + + @Test + public void iterateOverJsonTreeTestFieldNameNullAndNamePropertyAppendFieldValueToVariable() { + setupIterateOverJsonTreeTests(); + Mockito.doReturn("property").when(fields).getKey(); + Mockito.doNothing().when(jsInterpreterHelperSpy).appendFieldValueToVariable(Mockito.any(), Mockito.any()); + + jsInterpreterHelperSpy.iterateOverJsonTree(fields); + + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(1)).appendFieldValueToVariable(Mockito.any(), Mockito.any()); + } + + @Test + public void iterateOverArrayNodesTestThreeSizeArrayCallIterateOverJsonTreeThreeTimes() { + Map fields = new HashMap<>(); + fields.put("field", jsonNodeMock); + + JsonNode root = Mockito.mock(JsonNode.class); + JsonNode node1 = Mockito.mock(JsonNode.class); + JsonNode node2 = Mockito.mock(JsonNode.class); + JsonNode node3 = Mockito.mock(JsonNode.class); + Mockito.doReturn(fields.entrySet().iterator()).when(node1).fields(); + Mockito.doReturn(fields.entrySet().iterator()).when(node2).fields(); + Mockito.doReturn(fields.entrySet().iterator()).when(node3).fields(); + + Map childrenMap = new HashMap<>(); + childrenMap.put("node1", node1); + childrenMap.put("node2", node2); + childrenMap.put("node3", node3); + + Map.Entry rootEntry = Map.entry("rootNode", root); + + Mockito.doReturn(node1).when(rootEntry.getValue()).get(0); + Mockito.doReturn(node2).when(rootEntry.getValue()).get(1); + Mockito.doReturn(node3).when(rootEntry.getValue()).get(2); + Mockito.doNothing().when(jsInterpreterHelperSpy).iterateOverJsonTree((Iterator>) Mockito.any()); + + jsInterpreterHelperSpy.iterateOverArrayNodes(rootEntry); + + Mockito.verify(jsInterpreterHelperSpy, Mockito.times(3)).iterateOverJsonTree((Iterator>) Mockito.any()); + } + + @Test + public void removeCallFunctionsFromVariableTestTwoCallExpressionsRemoveTwoLastProperties() { + jsInterpreterHelperSpy.setCallExpressions(2); + jsInterpreterHelperSpy.setVariable(new StringBuilder("value.osName.toLowerCase().indexOf('windows')")); + + jsInterpreterHelperSpy.removeCallFunctionsFromVariable(); + + Assert.assertEquals("value.osName", jsInterpreterHelperSpy.getVariable().toString()); + } +} diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index d56ab379b3f..e03aee00599 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1803,6 +1803,7 @@ "label.quota.statement.tariff": "Quota tariff", "label.quota.summary": "Summary", "label.quota.tariff": "Tariff", +"label.quota.tariff.activationrule": "Activation rule", "label.quota.tariff.effectivedate": "Effective date", "label.quota.tariff.position": "Position", "label.quota.tariff.value": "Tariff value", @@ -1810,6 +1811,7 @@ "label.quota.type.name": "Usage Type", "label.quota.type.unit": "Usage unit", "label.quota.usage": "Quota consumption", +"label.quota.validate.activation.rule": "Validate activation rule", "label.quota.value": "Quota value", "label.quotastate": "Quota state", "label.quota_enforce": "Enforce Quota", @@ -3695,6 +3697,7 @@ "migrate.from": "Migrate from", "migrate.to": "Migrate to", "migrationPolicy": "Migration policy", +"placeholder.quota.tariff.activationrule": "Quota tariff's activation rule", "placeholder.quota.tariff.description": "Quota tariff's description", "placeholder.quota.tariff.enddate": "Quota tariff's end date", "placeholder.quota.tariff.name": "Quota tariff's name", diff --git a/ui/public/locales/pt_BR.json b/ui/public/locales/pt_BR.json index 73d36f2609d..c64163e8e30 100644 --- a/ui/public/locales/pt_BR.json +++ b/ui/public/locales/pt_BR.json @@ -128,6 +128,9 @@ "label.action.migrate.systemvm.to.ps": "Migrar VM de sistema para outro armazenamento prim\u00e1rio", "label.action.project.add.account": "Adicionar conta ao projeto", "label.action.project.add.user": "Adicionar usu\u00e1rio a um projeto", +"label.action.quota.tariff.create": "Criar tarifa", +"label.action.quota.tariff.edit": "Editar tarifa", +"label.action.quota.tariff.remove": "Remover tarifa", "label.action.reboot.instance": "Reiniciar inst\u00e2ncia", "label.action.reboot.router": "Reiniciar roteador", "label.action.reboot.systemvm": "Reiniciar VM de sistema", @@ -1283,9 +1286,7 @@ "label.quotastate": "Estado da cota", "label.summary": "Sum\u00e1rio", "label.quota.tariff": "Tarifa", -"label.action.quota.tariff.create": "Criar tarifa", -"label.action.quota.tariff.edit": "Editar tarifa", -"label.action.quota.tariff.remove": "Remover tarifa", +"label.quota.tariff.activationrule": "Regra de ativa\u00e7\u00e3o", "label.quota.tariff.effectivedate": "Data efetiva", "label.quota.tariff.position": "Posi\u00e7\u00e3o", "label.quota.tariff.value": "Valor", @@ -1293,6 +1294,7 @@ "label.quota.type.name": "Tipo de uso", "label.quota.type.unit": "Unidade do uso", "label.quota.usage": "Consumo da cota", +"label.quota.validate.activation.rule": "Validar regra de ativa\u00e7\u00e3o", "label.quota.value": "Valor", "label.rados.monitor": "Monitor RADOS", "label.rados.pool": "Pool do RADOS", @@ -2514,6 +2516,7 @@ "migrate.from": "Migrar de", "migrate.to": "Migrar para", "migrationPolicy": "Pol\u00edtica de migra\u00e7\u00e3o", +"placeholder.quota.tariff.activationrule": "Regra de ativa\u00e7\u00e3o", "placeholder.quota.tariff.description": "Descri\u00e7\u00e3o", "placeholder.quota.tariff.enddate": "Data de t\u00e9rmino", "placeholder.quota.tariff.name": "Nome", diff --git a/ui/src/style/objects/form.scss b/ui/src/style/objects/form.scss index 1789e03717b..290a3b6e78c 100644 --- a/ui/src/style/objects/form.scss +++ b/ui/src/style/objects/form.scss @@ -24,6 +24,14 @@ } } +.border-success { + border-color: #349469; +} + +.border-fail { + border-color: #dc3545; +} + .form textarea { resize: both; min-width: 20vw; diff --git a/ui/src/views/plugins/quota/CreateQuotaTariff.vue b/ui/src/views/plugins/quota/CreateQuotaTariff.vue index bf8cb743b41..942de8c69db 100644 --- a/ui/src/views/plugins/quota/CreateQuotaTariff.vue +++ b/ui/src/views/plugins/quota/CreateQuotaTariff.vue @@ -66,6 +66,20 @@ v-model:value="form.value" :placeholder="$t('placeholder.quota.tariff.value')" /> + + + + +
+ {{ $t('label.quota.validate.activation.rule') }} +