diff --git a/api/src/main/java/com/cloud/configuration/Resource.java b/api/src/main/java/com/cloud/configuration/Resource.java index 97be7f9d64c5..460ff5d527b5 100644 --- a/api/src/main/java/com/cloud/configuration/Resource.java +++ b/api/src/main/java/com/cloud/configuration/Resource.java @@ -22,32 +22,34 @@ public interface Resource { String UNLIMITED = "Unlimited"; enum ResourceType { // All storage type resources are allocated_storage and not the physical storage. - user_vm("user_vm", 0), - public_ip("public_ip", 1), - volume("volume", 2), - snapshot("snapshot", 3), - template("template", 4), - project("project", 5), - network("network", 6), - vpc("vpc", 7), - cpu("cpu", 8), - memory("memory", 9), - primary_storage("primary_storage", 10), - secondary_storage("secondary_storage", 11), - backup("backup", 12), - backup_storage("backup_storage", 13), - bucket("bucket", 14), - object_storage("object_storage", 15), - gpu("gpu", 16); + user_vm("user_vm", "Instance", 0), + public_ip("public_ip", "Public IP", 1), + volume("volume", "Volume", 2), + snapshot("snapshot", "Snapshot", 3), + template("template", "Template", 4), + project("project", "Project", 5), + network("network", "Network", 6), + vpc("vpc", "VPC", 7), + cpu("cpu", "CPU", 8), + memory("memory", "Memory", 9), + primary_storage("primary_storage", "Primary Storage", 10), + secondary_storage("secondary_storage", "Secondary Storage", 11), + backup("backup", "Backup", 12), + backup_storage("backup_storage", "Backup Storage", 13), + bucket("bucket", "Bucket", 14), + object_storage("object_storage", "Object Storage", 15), + gpu("gpu", "GPU", 16); private String name; + private String displayName; private int ordinal; public static final long bytesToKiB = 1024; public static final long bytesToMiB = bytesToKiB * 1024; public static final long bytesToGiB = bytesToMiB * 1024; - ResourceType(String name, int ordinal) { + ResourceType(String name, String displayName, int ordinal) { this.name = name; + this.displayName = displayName; this.ordinal = ordinal; } @@ -55,6 +57,10 @@ public String getName() { return name; } + public String getDisplayName() { + return displayName; + } + public int getOrdinal() { return ordinal; } diff --git a/api/src/main/java/com/cloud/exception/InvalidParameterValueException.java b/api/src/main/java/com/cloud/exception/InvalidParameterValueException.java index ce2bc31a7f11..7ad37b83a25a 100644 --- a/api/src/main/java/com/cloud/exception/InvalidParameterValueException.java +++ b/api/src/main/java/com/cloud/exception/InvalidParameterValueException.java @@ -16,6 +16,10 @@ // under the License. package com.cloud.exception; +import java.util.Map; + +import org.apache.cloudstack.context.ErrorMessageResolver; + import com.cloud.utils.exception.CloudRuntimeException; public class InvalidParameterValueException extends CloudRuntimeException { @@ -26,4 +30,8 @@ public InvalidParameterValueException(String message) { super(message); } + public InvalidParameterValueException(String key, Map metadata) { + super(ErrorMessageResolver.getMessage(key, metadata), key, metadata); + } + } diff --git a/api/src/main/java/com/cloud/user/AccountService.java b/api/src/main/java/com/cloud/user/AccountService.java index 09fe5ffc0590..c5e5bd751c03 100644 --- a/api/src/main/java/com/cloud/user/AccountService.java +++ b/api/src/main/java/com/cloud/user/AccountService.java @@ -19,7 +19,6 @@ import java.util.List; import java.util.Map; -import com.cloud.utils.Pair; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.acl.SecurityChecker.AccessType; @@ -27,6 +26,7 @@ import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; import org.apache.cloudstack.api.command.admin.user.RegisterUserKeyCmd; import org.apache.cloudstack.api.command.admin.user.UpdateUserCmd; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import com.cloud.dc.DataCenter; import com.cloud.domain.Domain; @@ -35,7 +35,7 @@ import com.cloud.offering.DiskOffering; import com.cloud.offering.NetworkOffering; import com.cloud.offering.ServiceOffering; -import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; +import com.cloud.utils.Pair; public interface AccountService { @@ -85,6 +85,8 @@ User createUser(String userName, String password, String firstName, String lastN boolean isRootAdmin(Long accountId); + boolean isRootAdmin(Account account); + boolean isDomainAdmin(Long accountId); boolean isResourceDomainAdmin(Long accountId); diff --git a/api/src/main/java/org/apache/cloudstack/api/ServerApiException.java b/api/src/main/java/org/apache/cloudstack/api/ServerApiException.java index a8bb2ed71c93..3de14a74024d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ServerApiException.java +++ b/api/src/main/java/org/apache/cloudstack/api/ServerApiException.java @@ -17,6 +17,9 @@ package org.apache.cloudstack.api; import java.util.ArrayList; +import java.util.Map; + +import org.apache.cloudstack.context.ErrorMessageResolver; import com.cloud.exception.CloudException; import com.cloud.utils.exception.CSExceptionErrorCode; @@ -34,6 +37,14 @@ public ServerApiException() { setCSErrorCode(CSExceptionErrorCode.getCSErrCode(ServerApiException.class.getName())); } + public ServerApiException(ApiErrorCode errorCode, String messageKey, Map errorMetadata) { + _errorCode = errorCode; + this.messageKey = messageKey; + this.metadata = errorMetadata; + _description = ErrorMessageResolver.getMessage(messageKey, errorMetadata); + setCSErrorCode(CSExceptionErrorCode.getCSErrCode(ServerApiException.class.getName())); + } + public ServerApiException(ApiErrorCode errorCode, String description) { _errorCode = errorCode; _description = description; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java index ecbde47692f2..b520c307166e 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java @@ -648,7 +648,8 @@ private Long getNetworkIdFomIpMap(HashMap ips) { try { networkId = Long.parseLong(networkid); } catch (NumberFormatException e) { - throw new InvalidParameterValueException("Unable to translate and find entity with networkId: " + networkid); + throw new InvalidParameterValueException("vm.deploy.network.not.found.ip.map", + Map.of("networkId", networkid)); } } return networkId; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java index 393a2bb47275..361f2cac25a6 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java @@ -16,6 +16,7 @@ // under the License. package org.apache.cloudstack.api.command.user.vm; +import java.util.Collections; import java.util.Objects; import java.util.stream.Stream; @@ -157,7 +158,18 @@ public void create() throws ResourceAllocationException { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, ex.getMessage()); } catch (ResourceAllocationException ex) { logger.warn("Exception: ", ex); - throw new ServerApiException(ApiErrorCode.RESOURCE_ALLOCATION_ERROR, ex.getMessage()); + handleCreateResourceAllocationException(ex); } } + + protected void handleCreateResourceAllocationException(ResourceAllocationException ex) { + if (ex.getMessage() != null && ex.getMessage().startsWith("Maximum amount")) { + throw new ServerApiException(ApiErrorCode.RESOURCE_ALLOCATION_ERROR, + "vm.deploy.resourcelimit.exceeded.account", Collections.emptyMap()); + } else if (ex.getMessage() != null && ex.getMessage().startsWith("Maximum domain resource limits")) { + throw new ServerApiException(ApiErrorCode.RESOURCE_ALLOCATION_ERROR, + "vm.deploy.resourcelimit.exceeded.domain", Collections.emptyMap()); + } + throw new ServerApiException(ApiErrorCode.RESOURCE_ALLOCATION_ERROR, ex.getMessage()); + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ExceptionResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ExceptionResponse.java index d475dee0620f..19210e6f8642 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/ExceptionResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/ExceptionResponse.java @@ -18,13 +18,13 @@ import java.util.ArrayList; import java.util.List; - -import com.google.gson.annotations.SerializedName; +import java.util.Map; import org.apache.cloudstack.api.BaseResponse; import com.cloud.serializer.Param; import com.cloud.utils.exception.ExceptionProxyObject; +import com.google.gson.annotations.SerializedName; public class ExceptionResponse extends BaseResponse { @@ -44,6 +44,14 @@ public class ExceptionResponse extends BaseResponse { @Param(description = "The text associated with this error") private String errorText = "Command failed due to Internal Server Error"; + @SerializedName("errortextkey") + @Param(description = "the key for the text associated with this error") + private String errorTextKey; + + @SerializedName("errormetadata") + @Param(description = "the metadata associated with this error") + private Map errorMetadata; + public ExceptionResponse() { idList = new ArrayList(); } @@ -64,6 +72,22 @@ public void setErrorText(String errorText) { this.errorText = errorText; } + public String getErrorTextKey() { + return errorTextKey; + } + + public void setErrorTextKey(String errorTextKey) { + this.errorTextKey = errorTextKey; + } + + public Map getErrorMetadata() { + return errorMetadata; + } + + public void setErrorMetadata(Map errorMetadata) { + this.errorMetadata = errorMetadata; + } + public void addProxyObject(ExceptionProxyObject id) { idList.add(id); return; diff --git a/api/src/main/java/org/apache/cloudstack/context/CallContext.java b/api/src/main/java/org/apache/cloudstack/context/CallContext.java index 69376e4f6d7d..235198a382aa 100644 --- a/api/src/main/java/org/apache/cloudstack/context/CallContext.java +++ b/api/src/main/java/org/apache/cloudstack/context/CallContext.java @@ -23,17 +23,21 @@ import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.managed.threadlocal.ManagedThreadLocal; -import org.apache.logging.log4j.Logger; +import org.apache.commons.collections.MapUtils; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.ThreadContext; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import com.cloud.exception.CloudAuthenticationException; import com.cloud.projects.Project; import com.cloud.user.Account; +import com.cloud.user.AccountService; import com.cloud.user.User; import com.cloud.utils.UuidUtils; +import com.cloud.utils.component.ComponentContext; import com.cloud.utils.db.EntityManager; import com.cloud.utils.exception.CloudRuntimeException; -import org.apache.logging.log4j.ThreadContext; /** * CallContext records information about the environment the call is made. This @@ -53,6 +57,7 @@ protected Stack initialValue() { private String contextId; private Account account; private long accountId; + private Boolean isAccountRootAdmin = null; private long startEventId = 0; private String eventDescription; private String eventDetails; @@ -63,6 +68,7 @@ protected Stack initialValue() { private User user; private long userId; private final Map context = new HashMap(); + private final Map errorContext = new HashMap(); private Project project; private String apiName; @@ -134,6 +140,21 @@ public Account getCallingAccount() { return account; } + public boolean isCallingAccountRootAdmin() { + if (isAccountRootAdmin == null) { + AccountService accountService; + try { + accountService = ComponentContext.getDelegateComponentOfType(AccountService.class); + } catch (NoSuchBeanDefinitionException e) { + LOGGER.warn("Falling back to account type check for isRootAdmin for account ID: {} as no AccountService bean found: {}", accountId, e.getMessage()); + Account caller = getCallingAccount(); + return caller != null && caller.getType() == Account.Type.ADMIN; + } + isAccountRootAdmin = accountService.isRootAdmin(getCallingAccount()); + } + return Boolean.TRUE.equals(isAccountRootAdmin); + } + public static CallContext current() { CallContext context = s_currentContext.get(); @@ -405,6 +426,21 @@ public void putContextParameters(Map details){ } } + public Map getErrorContextParameters() { + return errorContext; + } + + public void putErrorContextParameter(String key, Object value) { + errorContext.put(key, value); + } + + public void putErrorContextParameters(Map details) { + if (MapUtils.isEmpty(details)) { + return; + } + errorContext.putAll(details); + } + public static void setActionEventInfo(String eventType, String description) { CallContext context = CallContext.current(); if (context != null) { diff --git a/api/src/main/java/org/apache/cloudstack/context/ErrorMessageResolver.java b/api/src/main/java/org/apache/cloudstack/context/ErrorMessageResolver.java new file mode 100644 index 000000000000..dfc8dfab83ec --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/context/ErrorMessageResolver.java @@ -0,0 +1,311 @@ +// 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.context; + +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.api.response.ExceptionResponse; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.utils.PropertiesUtil; +import com.cloud.utils.exception.CloudRuntimeException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class ErrorMessageResolver { + private static final Logger LOG = + LogManager.getLogger(ErrorMessageResolver.class); + + protected static final String ERROR_MESSAGES_FILENAME = "error-messages.json"; + protected static final String ERROR_KEY_ADMIN_SUFFIX = ".admin"; + protected static final boolean INCLUDE_METADATA_ID_IN_MESSAGE = false; + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{\\s*([A-Za-z0-9_]+)\\s*\\}\\}"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // volatile for safe publication + private static volatile Map templates = + Collections.emptyMap(); + + private static volatile long lastModified = -1; + + private ErrorMessageResolver() { + } + + protected static List getVariableNamesInErrorKey(String template) { + if (template == null || template.isEmpty()) { + return Collections.emptyList(); + } + List variables = new ArrayList<>(); + Matcher matcher = VARIABLE_PATTERN.matcher(template); + while (matcher.find()) { + String name = matcher.group(1); + if (name != null && !name.isEmpty()) { + variables.add(name); + } + } + return variables; + } + + protected static Map getCombinedMetadataFromErrorTemplate(String template, Map metadata) { + List variableNames = getVariableNamesInErrorKey(template); + if (variableNames.isEmpty()) { + return metadata; + } + Map contextMetadata = CallContext.current().getErrorContextParameters(); + if (MapUtils.isEmpty(contextMetadata)) { + return metadata; + } + Map combinedMetadata = new LinkedHashMap<>(); + for (String varName : variableNames) { + if (contextMetadata.containsKey(varName)) { + combinedMetadata.put(varName, contextMetadata.get(varName)); + } + } + if (MapUtils.isNotEmpty(metadata)) { + combinedMetadata.putAll(metadata); + } + return combinedMetadata; + } + + protected static String getTemplateForKey(String errorKey) { + if (errorKey == null) { + return null; + } + reloadIfRequired(); + if (!errorKey.endsWith(ERROR_KEY_ADMIN_SUFFIX) && CallContext.current().isCallingAccountRootAdmin()) { + String template = templates.get(errorKey + ERROR_KEY_ADMIN_SUFFIX); + if (template != null) { + return template; + } + } + return templates.get(errorKey); + } + + protected static Map getStringMap(Map metadata) { + Map stringMap = new LinkedHashMap<>(); + if (MapUtils.isNotEmpty(metadata)) { + for (Map.Entry entry : metadata.entrySet()) { + Object value = entry.getValue(); + stringMap.put(entry.getKey(), getMetadataObjectStringValue(value)); + } + } + return stringMap; + } + + /** + * Converts a metadata object to a human-readable string for error messages. + * + *

Behavior: + *

    + *
  • If {@code obj} is {@code null}, returns {@code null}.
  • + *
  • Attempts to obtain a display name by invoking one of the getters + * {@code getDisplayText()}, {@code getDisplayName()}, or {@code getName()} via reflection. + * If a name is found, returns it quoted as {@code 'NAME'}.
  • + *
  • When the current calling account is a root admin, the returned value will include + * an identifier suffix in the form {@code (ID: id, UUID: uuid)} when available. + * The ID is included only if {@code INCLUDE_METADATA_ID_IN_MESSAGE} is {@code true} + * and {@code obj} implements {@link InternalIdentity}. The UUID is included when + * {@code obj} implements {@link org.apache.cloudstack.api.Identity}.
  • + *
  • If no display name is available, returns the UUID (if {@code obj} implements + * {@code Identity}); otherwise returns {@code obj.toString()}.
  • + *
+ * + *

Reflection is used to call getters; invocation failures are silently ignored and treated as + * absence of the corresponding value. + * + * @param obj metadata object + * @return formatted metadata string suitable for inclusion in error messages, or {@code null} + * if {@code obj} is {@code null} + */ + protected static String getMetadataObjectStringValue(Object obj) { + if (obj == null) { + return null; + } + String uuid = null; + if (obj instanceof Identity) { + uuid = ((Identity) obj).getUuid(); + } + String name = null; + for (String getter : new String[]{"getDisplayText", "getDisplayName", "getName"}) { + name = invokeStringGetter(obj, getter); + if (name != null) { + break; + } + } + if (StringUtils.isEmpty(name)) { + if (StringUtils.isNotEmpty(uuid)) { + return uuid; + } + return obj.toString(); + } + + StringBuilder sb = new StringBuilder(); + sb.append("'").append(name).append("'"); + + if (!CallContext.current().isCallingAccountRootAdmin()) { + return sb.toString(); + } + + Long id = null; + if (INCLUDE_METADATA_ID_IN_MESSAGE && obj instanceof InternalIdentity) { + id = ((InternalIdentity) obj).getId(); + } + + if (ObjectUtils.allNull(id, uuid)) { + return sb.toString(); + } + sb.append(" ("); + if (id != null) { + sb.append("ID: ").append(id); + if (uuid != null) { + sb.append(", "); + } + } + if (uuid != null) { + sb.append("UUID: ").append(uuid); + } + sb.append(")"); + + return sb.toString(); + } + + protected static String invokeStringGetter(Object obj, String methodName) { + try { + Class cls = obj.getClass(); + var m = cls.getMethod(methodName); + Object val = m.invoke(obj); + return val == null ? null : val.toString(); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + return null; + } + } + + protected static synchronized void reloadIfRequired() { + try { + // log current directory for debugging purposes + LOG.debug("Current working directory: {}", + Paths.get(".").toAbsolutePath().normalize()); + File errorMessagesFile = PropertiesUtil.findConfigFile(ERROR_MESSAGES_FILENAME); + if (errorMessagesFile == null || !errorMessagesFile.exists()) { + if (!templates.isEmpty()) { + LOG.warn("Error messages file disappeared: {}", + errorMessagesFile != null ? errorMessagesFile.getAbsolutePath() : ERROR_MESSAGES_FILENAME); + templates = Collections.emptyMap(); + } + return; + } + + long modified = + Files.getLastModifiedTime(errorMessagesFile.toPath()).toMillis(); + + if (modified == lastModified) { + return; + } + + try (InputStream is = + Files.newInputStream(errorMessagesFile.toPath())) { + + templates = MAPPER.readValue( + is, + new TypeReference<>() { + } + ); + lastModified = modified; + + LOG.info("Reloaded {} error message templates from {}", + templates.size(), errorMessagesFile.toPath()); + } + + } catch (Exception e) { + LOG.warn("Failed to reload error messages from {}", + ERROR_MESSAGES_FILENAME, e); + } + } + + protected static String expand(String template, Map metadata) { + if (MapUtils.isEmpty(metadata)) { + return template; + } + String result = template; + for (Map.Entry entry : metadata.entrySet()) { + String placeholder = "{{" + entry.getKey() + "}}"; + Object value = entry.getValue(); + if (value != null) { + result = result.replace(placeholder, value.toString()); + } + } + return result; + } + + public static String getMessage(String errorKey, Map metadata) { + String template = getTemplateForKey(errorKey); + if (template == null) { + return errorKey; + } + Map combinedMetadata = getCombinedMetadataFromErrorTemplate(template, metadata); + return expand(template, getStringMap(combinedMetadata)); + } + + public static void updateExceptionResponse(ExceptionResponse response, CloudRuntimeException cre) { + String key = cre.getMessageKey(); + Map map = cre.getMetadata(); + + if (key == null) { + Throwable cause = cre.getCause(); + if (!(cause instanceof CloudRuntimeException)) { + return; + } + CloudRuntimeException causeEx = (CloudRuntimeException) cause; + key = causeEx.getMessageKey(); + if (key == null) { + return; + } + map = causeEx.getMetadata(); + } + response.setErrorTextKey(key); + String template = getTemplateForKey(key); + if (template == null) { + response.setErrorText(key); + response.setErrorMetadata(getStringMap(map)); + return; + } + Map combinedMetadata = getCombinedMetadataFromErrorTemplate(template, map); + Map stringMap = getStringMap(combinedMetadata); + response.setErrorText(expand(template, stringMap)); + response.setErrorMetadata(stringMap); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/context/ErrorMessageResolverTest.java b/api/src/test/java/org/apache/cloudstack/context/ErrorMessageResolverTest.java new file mode 100644 index 000000000000..95f9c88d2a18 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/context/ErrorMessageResolverTest.java @@ -0,0 +1,460 @@ +// 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.context; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.response.ExceptionResponse; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.dc.DataCenter; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.PropertiesUtil; +import com.cloud.utils.exception.CloudRuntimeException; + +@RunWith(MockitoJUnitRunner.class) +public class ErrorMessageResolverTest { + + @Mock + CallContext callContextMock; + + MockedStatic callContextMocked; + MockedStatic propertiesUtilMocked; + + Path tmpFile; + + @Before + public void setup() { + callContextMocked = Mockito.mockStatic(CallContext.class); + callContextMocked.when(CallContext::current).thenReturn(callContextMock); + propertiesUtilMocked = Mockito.mockStatic(PropertiesUtil.class); + propertiesUtilMocked.when(() -> PropertiesUtil.findConfigFile(anyString())).thenReturn(null); + } + + @After + public void tearDown() throws Exception { + callContextMocked.close(); + propertiesUtilMocked.close(); + if (tmpFile != null) { + try { + Files.deleteIfExists(tmpFile); + } catch (Exception ignored) { + } + } + } + + @Test + public void getVariableNamesInErrorKey_shouldReturnEmptyListForTemplateWithoutVariables() { + String template = "This is a template without variables."; + List result = ErrorMessageResolver.getVariableNamesInErrorKey(template); + Assert.assertTrue("Result should be an empty list", result.isEmpty()); + } + + @Test + public void getVariableNamesInErrorKey_shouldExtractSingleVariable() { + String template = "This is a template with one variable: {{variable}}."; + List result = ErrorMessageResolver.getVariableNamesInErrorKey(template); + Assert.assertEquals("Result should contain one variable", 1, result.size()); + Assert.assertEquals("Variable name should be 'variable'", "variable", result.get(0)); + } + + @Test + public void getVariableNamesInErrorKey_shouldExtractMultipleVariables() { + String template = "This template has {{var1}} and {{var2}}."; + List result = ErrorMessageResolver.getVariableNamesInErrorKey(template); + Assert.assertEquals("Result should contain two variables", 2, result.size()); + Assert.assertEquals("First variable name should be 'var1'", "var1", result.get(0)); + Assert.assertEquals("Second variable name should be 'var2'", "var2", result.get(1)); + } + + @Test + public void getVariableNamesInErrorKey_shouldIgnoreMalformedVariables() { + String template = "This template has {{var1} and {{var2}}."; + List result = ErrorMessageResolver.getVariableNamesInErrorKey(template); + Assert.assertEquals("Result should contain one valid variable", 1, result.size()); + Assert.assertEquals("Variable name should be 'var2'", "var2", result.get(0)); + } + + @Test + public void getVariableNamesInErrorKey_shouldHandleEmptyTemplate() { + String template = ""; + List result = ErrorMessageResolver.getVariableNamesInErrorKey(template); + Assert.assertTrue("Result should be an empty list", result.isEmpty()); + } + + @Test + public void getVariableNamesInErrorKey_shouldHandleTemplateWithOnlyVariables() { + String template = "{{var1}}{{var2}}{{var3}}"; + List result = ErrorMessageResolver.getVariableNamesInErrorKey(template); + Assert.assertEquals("Result should contain three variables", 3, result.size()); + Assert.assertEquals("First variable name should be 'var1'", "var1", result.get(0)); + Assert.assertEquals("Second variable name should be 'var2'", "var2", result.get(1)); + Assert.assertEquals("Third variable name should be 'var3'", "var3", result.get(2)); + } + + @Test + public void getCombinedMetadataFromErrorTemplate_shouldReturnMetadataWhenNoVariablesInTemplate() { + String template = "This is a template without variables."; + Map metadata = Map.of("key1", "value1"); + Map result = ErrorMessageResolver.getCombinedMetadataFromErrorTemplate(template, metadata); + Assert.assertEquals("Result should match the input metadata", metadata, result); + } + + @Test + public void getCombinedMetadataFromErrorTemplate_shouldReturnEmptyMapWhenContextMetadataIsEmpty() { + String template = "This template has {{var1}}."; + Map metadata = Map.of(); + when(callContextMock.getErrorContextParameters()).thenReturn(Map.of()); + Map result = ErrorMessageResolver.getCombinedMetadataFromErrorTemplate(template, metadata); + Assert.assertTrue("Result should be an empty map", result.isEmpty()); + } + + @Test + public void getCombinedMetadataFromErrorTemplate_shouldCombineContextAndProvidedMetadata() { + String template = "This template has {{var1}} and {{var2}}."; + Map metadata = Map.of("key1", "value1"); + when(callContextMock.getErrorContextParameters()).thenReturn(Map.of("var1", "valueVar1", "var2", "valueVar2")); + Map result = ErrorMessageResolver.getCombinedMetadataFromErrorTemplate(template, metadata); + Assert.assertEquals("Result should contain combined metadata", 3, result.size()); + Assert.assertEquals("Result should contain context metadata for var1", "valueVar1", result.get("var1")); + Assert.assertEquals("Result should contain context metadata for var2", "valueVar2", result.get("var2")); + Assert.assertEquals("Result should contain provided metadata", "value1", result.get("key1")); + } + + @Test + public void getCombinedMetadataFromErrorTemplate_shouldIgnoreVariablesNotInContextMetadata() { + String template = "This template has {{var1}} and {{var2}}."; + Map metadata = Map.of("key1", "value1"); + when(callContextMock.getErrorContextParameters()).thenReturn(Map.of("var1", "valueVar1")); + Map result = ErrorMessageResolver.getCombinedMetadataFromErrorTemplate(template, metadata); + Assert.assertEquals("Result should contain combined metadata", 2, result.size()); + Assert.assertEquals("Result should contain context metadata for var1", "valueVar1", result.get("var1")); + Assert.assertEquals("Result should contain provided metadata", "value1", result.get("key1")); + } + + @Test + public void getCombinedMetadataFromErrorTemplate_shouldReturnProvidedMetadataWhenTemplateIsEmpty() { + String template = ""; + Map metadata = Map.of("key1", "value1"); + Map result = ErrorMessageResolver.getCombinedMetadataFromErrorTemplate(template, metadata); + Assert.assertEquals("Result should match the input metadata", metadata, result); + } + + @Test + public void getStringMap_shouldReturnEmptyMapWhenMetadataIsEmpty() { + Map metadata = Map.of(); + Map result = ErrorMessageResolver.getStringMap(metadata); + Assert.assertTrue("Result should be an empty map", result.isEmpty()); + } + + @Test + public void getStringMap_shouldConvertAllMetadataValuesToStrings() { + Map metadata = Map.of("key1", 123, "key2", true, "key3", "value"); + Map result = ErrorMessageResolver.getStringMap(metadata); + Assert.assertEquals("Result should contain all keys", 3, result.size()); + Assert.assertEquals("Value for key1 should be '123'", "123", result.get("key1")); + Assert.assertEquals("Value for key2 should be 'true'", "true", result.get("key2")); + Assert.assertEquals("Value for key3 should be 'value'", "value", result.get("key3")); + } + + @Test + public void getStringMap_shouldHandleNullValuesInMetadata() { + Map metadata = new LinkedHashMap<>(); + metadata.put("key1", null); + metadata.put("key2", "value"); + Map result = ErrorMessageResolver.getStringMap(metadata); + Assert.assertEquals("Result should contain all keys", 2, result.size()); + Assert.assertNull("Value for key1 should be null", result.get("key1")); + Assert.assertEquals("Value for key2 should be 'value'", "value", result.get("key2")); + } + + @Test + public void getStringMap_shouldReturnEmptyMapWhenMetadataIsNull() { + Map result = ErrorMessageResolver.getStringMap(null); + Assert.assertTrue("Result should be an empty map", result.isEmpty()); + } + + @Test + public void getMetadataObjectStringValue_shouldReturnNullWhenObjectIsNull() { + Assert.assertNull(ErrorMessageResolver.getMetadataObjectStringValue(null)); + } + + @Test + public void getMetadataObjectStringValue_shouldReturnUuidWhenNameIsUnavailable() { + Identity identityMock = Mockito.mock(Identity.class); + when(identityMock.getUuid()).thenReturn("uuid-1234"); + Assert.assertEquals("uuid-1234", ErrorMessageResolver.getMetadataObjectStringValue(identityMock)); + } + + @Test + public void getMetadataObjectStringValue_shouldReturnNameWhenAvailable() { + DataCenter identityMock = Mockito.mock(DataCenter.class); + when(identityMock.getUuid()).thenReturn("uuid-1234"); + when(identityMock.getName()).thenReturn("TestName"); + Assert.assertEquals("'TestName'", ErrorMessageResolver.getMetadataObjectStringValue(identityMock)); + } + + @Test + public void getMetadataObjectStringValue_shouldIncludeIdAndUuidForRootAdmin() { + DataCenter internalIdentityMock = Mockito.mock(DataCenter.class); + when(internalIdentityMock.getUuid()).thenReturn("uuid-5678"); + if (ErrorMessageResolver.INCLUDE_METADATA_ID_IN_MESSAGE) { + when(internalIdentityMock.getId()).thenReturn(42L); + } + when(internalIdentityMock.getName()).thenReturn("AdminName"); + when(CallContext.current().isCallingAccountRootAdmin()).thenReturn(true); + String expected = String.format("'AdminName' (%sUUID: uuid-5678)", ErrorMessageResolver.INCLUDE_METADATA_ID_IN_MESSAGE ? "ID: 42, " : ""); + Assert.assertEquals(expected, ErrorMessageResolver.getMetadataObjectStringValue(internalIdentityMock)); + } + + @Test + public void getMetadataObjectStringValue_shouldFallbackToToStringWhenNameAndUuidAreUnavailable() { + Object obj = new Object(); + Assert.assertEquals(obj.toString(), ErrorMessageResolver.getMetadataObjectStringValue(obj)); + } + + @Test + public void getMetadataObjectStringValue_shouldReturnNameOnlyForNonRootAdmin() { + DataCenter internalIdentityMock = Mockito.mock(DataCenter.class); + when(internalIdentityMock.getName()).thenReturn("UserName"); + when(CallContext.current().isCallingAccountRootAdmin()).thenReturn(false); + Assert.assertEquals("'UserName'", ErrorMessageResolver.getMetadataObjectStringValue(internalIdentityMock)); + } + + @Test + public void expand_shouldReturnTemplateWhenMetadataIsEmpty() { + String template = "This is a template with no placeholders."; + Map metadata = Map.of(); + String result = ErrorMessageResolver.expand(template, metadata); + Assert.assertEquals("This is a template with no placeholders.", result); + } + + @Test + public void expand_shouldReplaceSinglePlaceholderWithMetadataValue() { + String template = "Hello, {{name}}!"; + Map metadata = Map.of("name", "World"); + String result = ErrorMessageResolver.expand(template, metadata); + Assert.assertEquals("Hello, World!", result); + } + + @Test + public void expand_shouldReplaceMultiplePlaceholdersWithMetadataValues() { + String template = "Hello, {{name}}! Today is {{day}}."; + Map metadata = Map.of("name", "Alice", "day", "Monday"); + String result = ErrorMessageResolver.expand(template, metadata); + Assert.assertEquals("Hello, Alice! Today is Monday.", result); + } + + @Test + public void expand_shouldIgnorePlaceholdersWithoutMatchingMetadata() { + String template = "Hello, {{name}}! Your age is {{age}}."; + Map metadata = Map.of("name", "Bob"); + String result = ErrorMessageResolver.expand(template, metadata); + Assert.assertEquals("Hello, Bob! Your age is {{age}}.", result); + } + + @Test + public void expand_shouldHandleNullMetadataValues() { + String template = "Hello, {{name}}!"; + Map metadata = new LinkedHashMap<>(); + metadata.put("name", null); + String result = ErrorMessageResolver.expand(template, metadata); + Assert.assertEquals("Hello, {{name}}!", result); + } + + @Test + public void expand_shouldReturnTemplateWhenMetadataIsNull() { + String template = "This is a template with no placeholders."; + String result = ErrorMessageResolver.expand(template, null); + Assert.assertEquals("This is a template with no placeholders.", result); + } + + @Test + public void expand_shouldHandleTemplateWithNoPlaceholders() { + String template = "This template has no placeholders."; + Map metadata = Map.of("key", "value"); + String result = ErrorMessageResolver.expand(template, metadata); + Assert.assertEquals("This template has no placeholders.", result); + } + + @Test + public void getMessage_shouldReturnErrorKeyWhenTemplateIsNull() { + String errorKey = "error.key"; + String result = ErrorMessageResolver.getMessage(errorKey, Map.of()); + Assert.assertEquals(errorKey, result); + } + + private void createTempFileWithTemplate(String errorKey, String template) { + try { + tmpFile = Files.createTempFile("error-template-", ".txt"); + Files.writeString(tmpFile, String.format("{ \"%s\": \"%s\" }", errorKey, template)); + propertiesUtilMocked.when(() -> PropertiesUtil.findConfigFile(anyString())) + .thenReturn(tmpFile.toFile()); + } catch (IOException e) { + Assert.fail("Failed to create temporary file for testing: " + e.getMessage()); + } + } + + @Test + public void getMessage_shouldReturnExpandedMessageWhenTemplateExists() { + String errorKey = "error.key"; + String template = "Error occurred: {{field}}"; + Map metadata = Map.of("field", "value"); + createTempFileWithTemplate(errorKey, template); + when(callContextMock.getErrorContextParameters()).thenReturn(Map.of()); + String result = ErrorMessageResolver.getMessage(errorKey, metadata); + Assert.assertEquals("Error occurred: value", result); + } + + @Test + public void getMessage_shouldHandleEmptyMetadata() { + String errorKey = "error.key"; + String template = "Error occurred."; + createTempFileWithTemplate(errorKey, template); + String result = ErrorMessageResolver.getMessage(errorKey, Map.of()); + Assert.assertEquals("Error occurred.", result); + } + + @Test + public void getMessage_shouldHandleNullMetadata() { + String errorKey = "error.key"; + String template = "Error occurred."; + createTempFileWithTemplate(errorKey, template); + String result = ErrorMessageResolver.getMessage(errorKey, null); + Assert.assertEquals("Error occurred.", result); + } + + @Test + public void getMessage_shouldCombineContextAndProvidedMetadata() { + String errorKey = "error.key"; + String template = "Error in {{field1}} and {{field2}}."; + Map metadata = Map.of("field1", "value1"); + createTempFileWithTemplate(errorKey, template); + when(callContextMock.getErrorContextParameters()).thenReturn(Map.of("field2", "value2")); + String result = ErrorMessageResolver.getMessage(errorKey, metadata); + Assert.assertEquals("Error in value1 and value2.", result); + } + + @Test + public void updateExceptionResponse_shouldSetErrorTextAndMetadataWhenKeyAndTemplateExist() { + ExceptionResponse response = new ExceptionResponse(); + CloudRuntimeException cre = Mockito.mock(CloudRuntimeException.class); + String key = "error.key"; + String template = "Error occurred: {{field}}"; + Map metadata = Map.of("field", "value"); + + when(cre.getMessageKey()).thenReturn(key); + when(cre.getMetadata()).thenReturn(metadata); + createTempFileWithTemplate(key, template); + + ErrorMessageResolver.updateExceptionResponse(response, cre); + + Assert.assertEquals(key, response.getErrorTextKey()); + Assert.assertEquals("Error occurred: value", response.getErrorText()); + Assert.assertEquals(Map.of("field", "value"), response.getErrorMetadata()); + } + + @Test + public void updateExceptionResponse_shouldSetErrorTextKeyOnlyWhenTemplateDoesNotExist() { + ExceptionResponse response = new ExceptionResponse(); + CloudRuntimeException cre = Mockito.mock(CloudRuntimeException.class); + String key = "error.key"; + Map metadata = Map.of("field", "value"); + + when(cre.getMessageKey()).thenReturn(key); + when(cre.getMetadata()).thenReturn(metadata); + + ErrorMessageResolver.updateExceptionResponse(response, cre); + + Assert.assertEquals(key, response.getErrorTextKey()); + Assert.assertEquals(key, response.getErrorText()); + Assert.assertEquals(Map.of("field", "value"), response.getErrorMetadata()); + } + + @Test + public void updateExceptionResponse_shouldHandleNullKeyAndCauseIsNotInvalidParameterValueException() { + ExceptionResponse response = new ExceptionResponse(); + String originalErrorText = response.getErrorText(); + CloudRuntimeException cre = Mockito.mock(CloudRuntimeException.class); + + when(cre.getMessageKey()).thenReturn(null); + when(cre.getCause()).thenReturn(new RuntimeException()); + + ErrorMessageResolver.updateExceptionResponse(response, cre); + + Assert.assertNull(response.getErrorTextKey()); + Assert.assertNull(response.getErrorMetadata()); + Assert.assertEquals(originalErrorText, response.getErrorText()); + } + + @Test + public void updateExceptionResponse_shouldUseCauseMetadataWhenKeyIsNullAndCauseIsInvalidParameterValueException() { + ExceptionResponse response = new ExceptionResponse(); + InvalidParameterValueException cause = Mockito.mock(InvalidParameterValueException.class); + CloudRuntimeException cre = Mockito.mock(CloudRuntimeException.class); + String key = "error.key"; + String template = "Error occurred: {{field}}"; + Map metadata = Map.of("field", "value"); + + when(cre.getMessageKey()).thenReturn(null); + when(cre.getCause()).thenReturn(cause); + when(cause.getMessageKey()).thenReturn(key); + when(cause.getMetadata()).thenReturn(metadata); + createTempFileWithTemplate(key, template); + + ErrorMessageResolver.updateExceptionResponse(response, cre); + + Assert.assertEquals(key, response.getErrorTextKey()); + Assert.assertEquals("Error occurred: value", response.getErrorText()); + Assert.assertEquals(Map.of("field", "value"), response.getErrorMetadata()); + } + + @Test + public void updateExceptionResponse_shouldHandleNullMetadata() { + ExceptionResponse response = new ExceptionResponse(); + CloudRuntimeException cre = Mockito.mock(CloudRuntimeException.class); + String key = "error.key"; + String template = "Error occurred."; + + when(cre.getMessageKey()).thenReturn(key); + when(cre.getMetadata()).thenReturn(null); + createTempFileWithTemplate(key, template); + + ErrorMessageResolver.updateExceptionResponse(response, cre); + + Assert.assertEquals(key, response.getErrorTextKey()); + Assert.assertEquals("Error occurred.", response.getErrorText()); + Assert.assertTrue(response.getErrorMetadata().isEmpty()); + } +} diff --git a/client/conf/error-messages.json.in b/client/conf/error-messages.json.in new file mode 100644 index 000000000000..aea5cc7ad4f9 --- /dev/null +++ b/client/conf/error-messages.json.in @@ -0,0 +1,25 @@ +{ + "vm.deploy.diskoffering.compute.only": "Unable to deploy Instance as the specified disk offering {{diskOffering}} is mapped to a service offering. Specify a disk offering that is not mapped to any service offering.", + "vm.deploy.diskoffering.local.storage.zone.unsupported": "Unable to deploy Instance because the zone is not configured to use local storage, but the specified disk offering {{diskOffering}} requires it.", + "vm.deploy.diskoffering.not.found": "Unable to deploy Instance as the specified disk offering is not found.", + "vm.deploy.diskoffering.with.diskoffering.details": "Unable to deploy Instance as both a disk offering ID and data disk offering details were provided. Specify only one.", + "vm.deploy.hypervisor.volume.snapshot.not.supported": "Unable to deploy Instance because deployment from an existing volume or snapshot is supported only on the KVM hypervisor.", + "vm.deploy.network.not.found.ip.map": "The network selected {{networkId}} in IP to network map could not be found. It may have been removed or is no longer accessible.", + "vm.deploy.resourcelimit.exceeded.account": "Unable to deploy Instance because allocating {{resourceRequested}} more {{resourceTypeDisplay}} would exceed the {{resourceOwnerType}} limits. Current: {{resourceAmount}}, Reserved: {{resourceReserved}}, Limit: {{resourceLimit}}. Release unused resources, then retry.", + "vm.deploy.resourcelimit.exceeded.account.admin": "Unable to deploy Instance because allocating {{resourceRequested}} more {{resourceTypeDisplay}} would exceed the {{resourceOwnerType}} limits for {{resourceOwner}}. Current: {{resourceAmount}}, Reserved: {{resourceReserved}}, Limit: {{resourceLimit}}. Release unused resources or increase the limit, then retry.", + "vm.deploy.resourcelimit.exceeded.domain": "Unable to deploy Instance because allocating {{resourceRequested}} more {{resourceTypeDisplay}} would exceed the domain limits. Current: {{resourceAmount}}, Reserved: {{resourceReserved}}, Limit: {{resourceLimit}}. Release unused resources, then retry.", + "vm.deploy.resourcelimit.exceeded.domain.admin": "Unable to deploy Instance because allocating {{resourceRequested}} more {{resourceTypeDisplay}} would exceed the limits for domain {{resourceOwnerDomain}}. Current: {{resourceAmount}}, Reserved: {{resourceReserved}}, Limit: {{resourceLimit}}. Release unused resources or increase the limit, then retry.", + "vm.deploy.serviceoffering.fixed.parameters.not.allowed": "Unable to deploy the instance because the selected service offering {{serviceOffering}} does not allow specifying {{cpuNumberKey}}, {{cpuSpeedKey}}, or {{memory}}.", + "vm.deploy.serviceoffering.fixed.parameters.not.allowed.admin": "Unable to deploy the instance because the selected service offering {{serviceOffering}} is not a dynamic offering and it does not allow specifying {{cpuNumberKey}}, {{cpuSpeedKey}}, or {{memory}}.", + "vm.deploy.serviceoffering.inactive": "Unable to deploy Instance as the given service offering {{serviceOffering}} is inactive. Specify an active service offering.", + "vm.deploy.serviceoffering.local.storage.zone.unsupported": "Unable to deploy Instance because the zone is not configured to use local storage, but the disk offering mapped to service offering {{serviceOffering}} requires it.", + "vm.deploy.serviceoffering.not.specified": "Unable to deploy Instance as the required parameter 'serviceofferingid' is missing.", + "vm.deploy.serviceoffering.not.found": "Unable to deploy Instance as the specified service offering is not found.", + "vm.deploy.serviceoffering.override.not.allowed": "Unable to deploy Instance as the selected service offering {{serviceOffering}} does not allow changing the disk offering.", + "vm.deploy.serviceoffering.override.not.allowed.admin": "Unable to deploy Instance as the selected service offering {{serviceOffering}} uses disk offering strictness and does not allow changing the disk offering.", + "vm.deploy.snapshot.not.found": "Unable to deploy Instance as the specified Snapshot is not found.", + "vm.deploy.template.associated.not.usable": "Unable to deploy Instance as the associated Template cannot be used.", + "vm.deploy.template.not.found": "Unable to deploy Instance as the specified Template is not found.", + "vm.deploy.volume.not.found": "Unable to deploy Instance as the specified Volume is not found.", + "vm.deploy.zone.not.found": "Unable to deploy Instance as the specified zone is not found." +} diff --git a/debian/cloudstack-management.install b/debian/cloudstack-management.install index befc7049c30e..a1f09003c734 100644 --- a/debian/cloudstack-management.install +++ b/debian/cloudstack-management.install @@ -22,6 +22,7 @@ /etc/cloudstack/management/java.security.ciphers /etc/cloudstack/management/log4j-cloud.xml /etc/cloudstack/management/config.json +/etc/cloudstack/management/error-messages.json /etc/cloudstack/extensions/Proxmox/proxmox.sh /etc/cloudstack/extensions/HyperV/hyperv.py /etc/cloudstack/extensions/MaaS/maas.py diff --git a/engine/schema/src/main/java/com/cloud/service/ServiceOfferingVO.java b/engine/schema/src/main/java/com/cloud/service/ServiceOfferingVO.java index cfe8049f5b2c..261efbb1d301 100644 --- a/engine/schema/src/main/java/com/cloud/service/ServiceOfferingVO.java +++ b/engine/schema/src/main/java/com/cloud/service/ServiceOfferingVO.java @@ -438,7 +438,7 @@ public String getUuid() { @Override public String toString() { - return String.format("Service offering %s.", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "name", "uuid")); + return String.format("Service offering %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "name", "uuid")); } public boolean isDynamicScalingEnabled() { diff --git a/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java b/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java index e4fcbad6b02f..fde9602fbb6c 100644 --- a/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java +++ b/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java @@ -376,7 +376,7 @@ public void setDetails(Map details) { @Override public String toString() { - return String.format("UserAccount %s.", ReflectionToStringBuilderUtils.reflectOnlySelectedFields + return String.format("UserAccount %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields (this, "id", "uuid", "username", "accountName")); } } diff --git a/engine/schema/src/main/java/com/cloud/user/UserVO.java b/engine/schema/src/main/java/com/cloud/user/UserVO.java index 6e355e102e6c..59b6f722d51a 100644 --- a/engine/schema/src/main/java/com/cloud/user/UserVO.java +++ b/engine/schema/src/main/java/com/cloud/user/UserVO.java @@ -31,11 +31,11 @@ import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import org.apache.commons.lang3.StringUtils; import com.cloud.user.Account.State; import com.cloud.utils.db.Encrypt; import com.cloud.utils.db.GenericDao; -import org.apache.commons.lang3.StringUtils; /** * A bean representing a user @@ -296,7 +296,7 @@ public void setRegistered(boolean registered) { @Override public String toString() { - return String.format("User %s.", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "username")); + return String.format("User %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "username")); } @Override diff --git a/packaging/el8/cloud.spec b/packaging/el8/cloud.spec index c5079a8aa948..2460e960010a 100644 --- a/packaging/el8/cloud.spec +++ b/packaging/el8/cloud.spec @@ -289,7 +289,7 @@ cp client/target/lib/*jar ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/lib/ rm -rf ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/webapps/client/WEB-INF/classes/scripts rm -rf ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/webapps/client/WEB-INF/classes/vms -for name in db.properties server.properties log4j-cloud.xml environment.properties java.security.ciphers +for name in db.properties server.properties log4j-cloud.xml environment.properties java.security.ciphers error-messages.json do cp client/target/conf/$name ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/management/$name done @@ -613,6 +613,7 @@ pip3 install --upgrade /usr/share/cloudstack-marvin/Marvin-*.tar.gz %config(noreplace) %{_sysconfdir}/%{name}/management/log4j2.xml %config(noreplace) %{_sysconfdir}/%{name}/management/environment.properties %config(noreplace) %{_sysconfdir}/%{name}/management/java.security.ciphers +%config(noreplace) %{_sysconfdir}/%{name}/management/error-messages.json %config(noreplace) %attr(0644,root,root) %{_sysconfdir}/logrotate.d/%{name}-management %attr(0644,root,root) %{_unitdir}/%{name}-management.service %attr(0755,cloud,cloud) %{_localstatedir}/run/%{name}-management.pid diff --git a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java index 2ff68b4836f4..15c419f1c7ae 100644 --- a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java +++ b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java @@ -17,30 +17,29 @@ package org.apache.cloudstack.network.contrail.management; +import java.net.InetAddress; import java.util.List; import java.util.Map; -import java.net.InetAddress; import javax.inject.Inject; import javax.naming.ConfigurationException; -import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; -import org.apache.cloudstack.api.command.admin.account.CreateAccountCmd; -import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; -import org.apache.cloudstack.api.command.admin.user.MoveUserCmd; -import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; -import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; -import org.apache.cloudstack.framework.config.ConfigKey; - import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.acl.SecurityChecker.AccessType; +import org.apache.cloudstack.api.command.admin.account.CreateAccountCmd; import org.apache.cloudstack.api.command.admin.account.UpdateAccountCmd; import org.apache.cloudstack.api.command.admin.user.DeleteUserCmd; +import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd; +import org.apache.cloudstack.api.command.admin.user.MoveUserCmd; import org.apache.cloudstack.api.command.admin.user.RegisterUserKeyCmd; import org.apache.cloudstack.api.command.admin.user.UpdateUserCmd; +import org.apache.cloudstack.api.response.UserTwoFactorAuthenticationSetupResponse; +import org.apache.cloudstack.auth.UserTwoFactorAuthenticator; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import com.cloud.api.auth.SetupUserTwoFactorAuthenticationCmd; import com.cloud.api.query.vo.ControlledViewEntity; import com.cloud.configuration.ResourceLimit; import com.cloud.configuration.dao.ResourceCountDao; @@ -230,6 +229,12 @@ public boolean isRootAdmin(Long accountId) { return false; } + @Override + public boolean isRootAdmin(Account account) { + // TODO Auto-generated method stub + return false; + } + @Override public boolean isDomainAdmin(Long accountId) { // TODO Auto-generated method stub diff --git a/pom.xml b/pom.xml index 97d0d2645da7..dc52e5f9d6d9 100644 --- a/pom.xml +++ b/pom.xml @@ -1027,6 +1027,7 @@ CHANGES.md ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md + client/conf/error-messages.json.in build/build.number debian/cloudstack-agent.dirs debian/cloudstack-usage.dirs diff --git a/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java b/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java index e70a6b4da639..d45017ef0246 100644 --- a/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java +++ b/server/src/main/java/com/cloud/api/ApiAsyncJobDispatcher.java @@ -28,6 +28,7 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.ExceptionResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.context.ErrorMessageResolver; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobDispatcher; import org.apache.cloudstack.framework.jobs.AsyncJobManager; @@ -39,6 +40,7 @@ import com.cloud.utils.component.AdapterBase; import com.cloud.utils.component.ComponentContext; import com.cloud.utils.db.EntityManager; +import com.cloud.utils.exception.CloudRuntimeException; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; @@ -131,6 +133,9 @@ public void runJob(final AsyncJob job) { ExceptionResponse response = new ExceptionResponse(); response.setErrorCode(errorCode); response.setErrorText(errorMsg); + if (e instanceof CloudRuntimeException) { + ErrorMessageResolver.updateExceptionResponse(response, (CloudRuntimeException) e); + } response.setResponseName((cmdObj == null) ? "unknowncommandresponse" : cmdObj.getCommandName()); _asyncJobMgr.completeAsyncJob(job.getId(), JobInfo.Status.FAILED, errorCode, ApiSerializerHelper.toSerializedString(response)); diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index 5a3c8c2c7179..cc090a261e3a 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -16,6 +16,9 @@ // under the License. package com.cloud.api; +import static com.cloud.user.AccountManagerImpl.apiKeyAccess; +import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled; + import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InterruptedIOException; @@ -31,6 +34,7 @@ import java.security.Security; import java.text.ParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.EnumSet; @@ -39,7 +43,6 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; -import java.util.Arrays; import java.util.Map; import java.util.Set; import java.util.TimeZone; @@ -58,15 +61,6 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import com.cloud.cluster.ManagementServerHostVO; -import com.cloud.cluster.dao.ManagementServerHostDao; -import com.cloud.user.Account; -import com.cloud.user.AccountManager; -import com.cloud.user.AccountManagerImpl; -import com.cloud.user.DomainManager; -import com.cloud.user.User; -import com.cloud.user.UserAccount; -import com.cloud.user.UserVO; import org.apache.cloudstack.acl.APIChecker; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -106,6 +100,7 @@ import org.apache.cloudstack.api.response.LoginCmdResponse; import org.apache.cloudstack.config.ApiServiceConfiguration; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.context.ErrorMessageResolver; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.framework.events.EventDistributor; @@ -155,6 +150,8 @@ import com.cloud.api.dispatch.DispatchChainFactory; import com.cloud.api.dispatch.DispatchTask; import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.cluster.dao.ManagementServerHostDao; import com.cloud.domain.Domain; import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; @@ -173,11 +170,18 @@ import com.cloud.exception.UnavailableCommandException; import com.cloud.projects.dao.ProjectDao; import com.cloud.storage.VolumeApiService; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.AccountManagerImpl; +import com.cloud.user.DomainManager; +import com.cloud.user.User; +import com.cloud.user.UserAccount; +import com.cloud.user.UserVO; import com.cloud.utils.ConstantTimeComparator; import com.cloud.utils.DateUtil; import com.cloud.utils.HttpUtils; -import com.cloud.utils.HttpUtils.ApiSessionKeySameSite; import com.cloud.utils.HttpUtils.ApiSessionKeyCheckOption; +import com.cloud.utils.HttpUtils.ApiSessionKeySameSite; import com.cloud.utils.Pair; import com.cloud.utils.ReflectUtil; import com.cloud.utils.StringUtils; @@ -193,9 +197,6 @@ import com.cloud.utils.net.NetUtils; import com.google.gson.reflect.TypeToken; -import static com.cloud.user.AccountManagerImpl.apiKeyAccess; -import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled; - @Component public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiServerService, Configurable { private static final Logger ACCESSLOGGER = LogManager.getLogger("apiserver." + ApiServer.class.getName()); @@ -1650,6 +1651,7 @@ public String getSerializedApiError(final ServerApiException ex, final Map idList = ex.getIdProxyList(); if (idList != null) { for (ExceptionProxyObject exceptionProxyObject : idList) { diff --git a/server/src/main/java/com/cloud/resourcelimit/ResourceLimitManagerImpl.java b/server/src/main/java/com/cloud/resourcelimit/ResourceLimitManagerImpl.java index 9a6c8a85f18e..fa98267fc726 100644 --- a/server/src/main/java/com/cloud/resourcelimit/ResourceLimitManagerImpl.java +++ b/server/src/main/java/com/cloud/resourcelimit/ResourceLimitManagerImpl.java @@ -559,6 +559,16 @@ protected void checkDomainResourceLimit(final Account account, final Project pro if (domainResourceLimit != Resource.RESOURCE_UNLIMITED && requestedDomainResourceCount > domainResourceLimit) { String message = "Maximum" + messageSuffix; + Map details = new HashMap<>(); + details.put("resourceTypeDisplay", StringUtils.isBlank(tag) ? type.getDisplayName() : type.getDisplayName() + " (tag: " + tag + ")"); + details.put("resourceOwner", ObjectUtils.firstNonNull(project, account)); + details.put("resourceOwnerDomain", domain); + details.put("resourceOwnerType", project == null ? "Account" : "Project"); + details.put("resourceLimit", convDomainResourceLimit); + details.put("resourceAmount", convCurrentDomainResourceCount); + details.put("resourceReserved", convCurrentResourceReservation); + details.put("resourceRequested", convNumResources); + CallContext.current().putErrorContextParameters(details); ResourceAllocationException e = new ResourceAllocationException(message, type); logger.error(message, e); throw e; @@ -598,6 +608,15 @@ protected void checkAccountResourceLimit(final Account account, final Project pr if (accountResourceLimit != Resource.RESOURCE_UNLIMITED && requestedResourceCount > accountResourceLimit) { String message = "Maximum" + messageSuffix; + Map details = new HashMap<>(); + details.put("resourceTypeDisplay", StringUtils.isBlank(tag) ? type.getDisplayName() : type.getDisplayName() + " (tag: " + tag + ")"); + details.put("resourceOwner", ObjectUtils.firstNonNull(project, account)); + details.put("resourceOwnerType", project == null ? "Account" : "Project"); + details.put("resourceLimit", convertedAccountResourceLimit); + details.put("resourceAmount", convertedCurrentResourceCount); + details.put("resourceReserved", convertedCurrentResourceReservation); + details.put("resourceRequested", convertedNumResources); + CallContext.current().putErrorContextParameters(details); ResourceAllocationException e = new ResourceAllocationException(message, type); logger.error(message, e); throw e; diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index dd60fbfb9cca..867a4ffb5408 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -602,21 +602,26 @@ public boolean isAdmin(Long accountId) { @Override public boolean isRootAdmin(Long accountId) { if (accountId != null) { - AccountVO acct = _accountDao.findById(accountId); - if (acct == null) { - return false; //account is deleted or does not exist - } - for (SecurityChecker checker : _securityCheckers) { - try { - if (checker.checkAccess(acct, null, null, "SystemCapability")) { - if (logger.isTraceEnabled()) { - logger.trace("Root Access granted to " + acct + " by " + checker.getName()); - } - return true; + return isRootAdmin(_accountDao.findById(accountId)); + } + return false; + } + + @Override + public boolean isRootAdmin(Account account) { + if (account == null) { + return false; //account is deleted or does not exist + } + for (SecurityChecker checker : _securityCheckers) { + try { + if (checker.checkAccess(account, null, null, "SystemCapability")) { + if (logger.isTraceEnabled()) { + logger.trace("Root Access granted to " + account + " by " + checker.getName()); } - } catch (PermissionDeniedException ex) { - return false; + return true; } + } catch (PermissionDeniedException ex) { + return false; } } return false; diff --git a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java index 1ae609c7961b..e889d199164e 100644 --- a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java +++ b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java @@ -60,8 +60,6 @@ import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.ParserConfigurationException; -import com.cloud.storage.SnapshotPolicyVO; -import com.cloud.storage.dao.SnapshotPolicyDao; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.acl.ControlledEntity.ACLType; import org.apache.cloudstack.acl.SecurityChecker.AccessType; @@ -325,6 +323,7 @@ import com.cloud.storage.GuestOSVO; import com.cloud.storage.ScopeType; import com.cloud.storage.Snapshot; +import com.cloud.storage.SnapshotPolicyVO; import com.cloud.storage.SnapshotVO; import com.cloud.storage.Storage; import com.cloud.storage.Storage.ImageFormat; @@ -343,6 +342,7 @@ import com.cloud.storage.dao.GuestOSCategoryDao; import com.cloud.storage.dao.GuestOSDao; import com.cloud.storage.dao.SnapshotDao; +import com.cloud.storage.dao.SnapshotPolicyDao; import com.cloud.storage.dao.VMTemplateDao; import com.cloud.storage.dao.VMTemplateZoneDao; import com.cloud.storage.dao.VolumeDao; @@ -6264,19 +6264,25 @@ public String finalizeUserData(String userData, Long userDataId, VirtualMachineT } private void verifyServiceOffering(BaseDeployVMCmd cmd, ServiceOffering serviceOffering) { + CallContext.current().putErrorContextParameter("serviceOffering", serviceOffering); if (ServiceOffering.State.Inactive.equals(serviceOffering.getState())) { - throw new InvalidParameterValueException(String.format("Service offering is inactive: [%s].", serviceOffering.getUuid())); + throw new InvalidParameterValueException("vm.deploy.serviceoffering.inactive", + Collections.emptyMap()); } Long overrideDiskOfferingId = cmd.getOverrideDiskOfferingId(); if (serviceOffering.getDiskOfferingStrictness() && overrideDiskOfferingId != null) { - throw new InvalidParameterValueException(String.format("Cannot override disk offering id %d since provided service offering is strictly mapped to its disk offering", overrideDiskOfferingId)); + throw new InvalidParameterValueException("vm.deploy.serviceoffering.override.not.allowed", + Collections.emptyMap()); } if (!serviceOffering.isDynamic()) { for(String detail: cmd.getDetails().keySet()) { if(detail.equalsIgnoreCase(VmDetailConstants.CPU_NUMBER) || detail.equalsIgnoreCase(VmDetailConstants.CPU_SPEED) || detail.equalsIgnoreCase(VmDetailConstants.MEMORY)) { - throw new InvalidParameterValueException("cpuNumber or cpuSpeed or memory should not be specified for static service offering"); + throw new InvalidParameterValueException("vm.deploy.serviceoffering.fixed.parameters.not.allowed", + Map.of("cpuNumberKey", VmDetailConstants.CPU_NUMBER, + "cpuSpeedKey", VmDetailConstants.CPU_NUMBER, + "memoryKey", VmDetailConstants.CPU_NUMBER)); } } } @@ -6319,18 +6325,18 @@ public UserVm createVirtualMachine(DeployVMCmd cmd) throws InsufficientCapacityE DataCenter zone = _entityMgr.findById(DataCenter.class, zoneId); if (zone == null) { - throw new InvalidParameterValueException("Unable to find zone by id=" + zoneId); + throw new InvalidParameterValueException("vm.deploy.zone.not.found", Collections.emptyMap()); } Long serviceOfferingId = cmd.getServiceOfferingId(); if (serviceOfferingId == null) { - throw new InvalidParameterValueException("Unable to execute API command deployvirtualmachine due to missing parameter serviceofferingid"); + throw new InvalidParameterValueException("vm.deploy.serviceoffering.not.specified", Collections.emptyMap()); } Long overrideDiskOfferingId = cmd.getOverrideDiskOfferingId(); ServiceOffering serviceOffering = _entityMgr.findById(ServiceOffering.class, serviceOfferingId); if (serviceOffering == null) { - throw new InvalidParameterValueException("Unable to find service offering: " + serviceOffering.getId()); + throw new InvalidParameterValueException("vm.deploy.serviceoffering.not.found", Collections.emptyMap()); } verifyServiceOffering(cmd, serviceOffering); @@ -6344,14 +6350,14 @@ public UserVm createVirtualMachine(DeployVMCmd cmd) throws InsufficientCapacityE if (cmd.getVolumeId() != null) { volume = getVolume(cmd.getVolumeId(), templateId, false); if (volume == null) { - throw new InvalidParameterValueException("Could not find volume with id=" + cmd.getVolumeId()); + throw new InvalidParameterValueException("vm.deploy.volume.not.found", Collections.emptyMap()); } _accountMgr.checkAccess(caller, null, true, volume); templateId = volume.getTemplateId(); } else if (cmd.getSnapshotId() != null) { snapshot = _snapshotDao.findById(cmd.getSnapshotId()); if (snapshot == null) { - throw new InvalidParameterValueException("Could not find snapshot with id=" + cmd.getSnapshotId()); + throw new InvalidParameterValueException("vm.deploy.snapshot.not.found", Collections.emptyMap()); } _accountMgr.checkAccess(caller, null, true, snapshot); VolumeInfo volumeOfSnapshot = getVolume(snapshot.getVolumeId(), templateId, true); @@ -6361,16 +6367,18 @@ public UserVm createVirtualMachine(DeployVMCmd cmd) throws InsufficientCapacityE VirtualMachineTemplate template = null; if (volume != null || snapshot != null) { template = _entityMgr.findByIdIncludingRemoved(VirtualMachineTemplate.class, templateId); + if (template == null) { + throw new InvalidParameterValueException("vm.deploy.template.associated.not.usable", Collections.emptyMap()); + } } else { template = _entityMgr.findById(VirtualMachineTemplate.class, templateId); + if (template == null) { + throw new InvalidParameterValueException("vm.deploy.template.not.found", Collections.emptyMap()); + } } if (cmd.isVolumeOrSnapshotProvided() && (!(HypervisorType.KVM.equals(template.getHypervisorType()) || HypervisorType.KVM.equals(cmd.getHypervisor())))) { - throw new InvalidParameterValueException("Deploying a virtual machine with existing volume/snapshot is supported only from KVM hypervisors"); - } - // Make sure a valid template ID was specified - if (template == null) { - throw new InvalidParameterValueException("Unable to use template " + templateId); + throw new InvalidParameterValueException("vm.deploy.hypervisor.volume.snapshot.not.supported", Collections.emptyMap()); } verifyTemplate(cmd, template, serviceOfferingId); @@ -6379,25 +6387,29 @@ public UserVm createVirtualMachine(DeployVMCmd cmd) throws InsufficientCapacityE if (diskOfferingId != null) { diskOffering = _entityMgr.findById(DiskOffering.class, diskOfferingId); if (diskOffering == null) { - throw new InvalidParameterValueException("Unable to find disk offering " + diskOfferingId); + throw new InvalidParameterValueException("vm.deploy.diskoffering.not.found", Collections.emptyMap()); } if (diskOffering.isComputeOnly()) { - throw new InvalidParameterValueException(String.format("The disk offering %s provided is directly mapped to a service offering, please provide an individual disk offering", diskOffering)); + throw new InvalidParameterValueException("vm.deploy.diskoffering.compute.only", + Map.of("diskOffering", diskOffering)); } } List dataDiskInfoList = cmd.getDataDiskInfoList(); if (dataDiskInfoList != null && diskOfferingId != null) { - new InvalidParameterValueException("Cannot specify both disk offering id and data disk offering details"); + throw new InvalidParameterValueException("vm.deploy.diskoffering.with.diskoffering.details", + Collections.emptyMap()); } if (!zone.isLocalStorageEnabled()) { DiskOffering diskOfferingMappedInServiceOffering = _entityMgr.findById(DiskOffering.class, serviceOffering.getDiskOfferingId()); if (diskOfferingMappedInServiceOffering.isUseLocalStorage()) { - throw new InvalidParameterValueException("Zone is not configured to use local storage but disk offering " + diskOfferingMappedInServiceOffering.getName() + " mapped in service offering uses it"); + throw new InvalidParameterValueException("vm.deploy.serviceoffering.local.storage.zone.unsupported", + Map.of("serviceOffering", serviceOffering)); } if (diskOffering != null && diskOffering.isUseLocalStorage()) { - throw new InvalidParameterValueException("Zone is not configured to use local storage but disk offering " + diskOffering.getName() + " uses it"); + throw new InvalidParameterValueException("vm.deploy.diskoffering.local.storage.zone.unsupported", + Map.of("diskOffering", diskOffering)); } } diff --git a/server/src/test/java/com/cloud/storage/StorageManagerImplTest.java b/server/src/test/java/com/cloud/storage/StorageManagerImplTest.java index 576c32c08ba2..aea800626016 100644 --- a/server/src/test/java/com/cloud/storage/StorageManagerImplTest.java +++ b/server/src/test/java/com/cloud/storage/StorageManagerImplTest.java @@ -16,6 +16,12 @@ // under the License. package com.cloud.storage; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; + import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; @@ -24,12 +30,6 @@ import java.util.Map; import java.util.Optional; -import com.cloud.dc.HostPodVO; -import com.cloud.dc.dao.HostPodDao; -import com.cloud.host.HostVO; -import com.cloud.host.dao.HostDao; -import com.cloud.resource.ResourceManager; -import com.cloud.storage.dao.StoragePoolAndAccessGroupMapDao; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.command.admin.storage.ChangeStoragePoolScopeCmd; import org.apache.cloudstack.api.command.admin.storage.ConfigureStorageAccessCmd; @@ -72,9 +72,11 @@ import com.cloud.dc.ClusterVO; import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenterVO; +import com.cloud.dc.HostPodVO; import com.cloud.dc.VsphereStoragePolicyVO; import com.cloud.dc.dao.ClusterDao; import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.HostPodDao; import com.cloud.dc.dao.VsphereStoragePolicyDao; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.ConnectionException; @@ -83,8 +85,12 @@ import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.StorageUnavailableException; import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.hypervisor.HypervisorGuruManager; +import com.cloud.resource.ResourceManager; +import com.cloud.storage.dao.StoragePoolAndAccessGroupMapDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.user.AccountManagerImpl; import com.cloud.utils.Pair; @@ -93,12 +99,6 @@ import com.cloud.vm.VMInstanceVO; import com.cloud.vm.dao.VMInstanceDao; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.doReturn; - @RunWith(MockitoJUnitRunner.class) public class StorageManagerImplTest { @@ -615,7 +615,7 @@ private void prepareTestChangeStoragePoolScope(ScopeType currentScope, StoragePo final DataCenterVO zone = new DataCenterVO(1L, null, null, null, null, null, null, null, null, null, DataCenter.NetworkType.Advanced, null, null); StoragePoolVO primaryStorage = mockStoragePoolVOForChangeStoragePoolScope(currentScope, status); - Mockito.when(accountMgr.isRootAdmin(Mockito.any())).thenReturn(true); + Mockito.when(accountMgr.isRootAdmin(Mockito.anyLong())).thenReturn(true); Mockito.when(dataCenterDao.findById(1L)).thenReturn(zone); Mockito.when(storagePoolDao.findById(1L)).thenReturn(primaryStorage); } diff --git a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java index 6dadbfe96eb8..e0d2e2e05d72 100644 --- a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java +++ b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java @@ -16,6 +16,34 @@ // under the License. package com.cloud.storage.snapshot; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.apache.cloudstack.api.command.user.snapshot.ListSnapshotPoliciesCmd; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotDataFactory; +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotResult; +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotService; +import org.apache.cloudstack.framework.async.AsyncCallFuture; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.stubbing.Answer; + import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenterVO; import com.cloud.dc.dao.DataCenterDao; @@ -41,38 +69,8 @@ import com.cloud.user.User; import com.cloud.user.dao.AccountDao; import com.cloud.utils.Pair; - import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; -import org.apache.cloudstack.api.command.user.snapshot.ListSnapshotPoliciesCmd; -import org.apache.cloudstack.context.CallContext; -import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult; -import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; -import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; -import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotDataFactory; -import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; -import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotResult; -import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotService; -import org.apache.cloudstack.framework.async.AsyncCallFuture; -import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; -import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; - -import org.junit.Assert; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; -import org.mockito.stubbing.Answer; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutionException; @RunWith(MockitoJUnitRunner.class) public class SnapshotManagerImplTest { @@ -235,7 +233,7 @@ public void testValidatePolicyZonesDisabledZone() { DataCenterVO zone1 = Mockito.mock(DataCenterVO.class); Mockito.when(zone1.getAllocationState()).thenReturn(Grouping.AllocationState.Disabled); Mockito.when(dataCenterDao.findById(2L)).thenReturn(zone1); - Mockito.when(accountManager.isRootAdmin(Mockito.any())).thenReturn(false); + Mockito.when(accountManager.isRootAdmin(Mockito.anyLong())).thenReturn(false); snapshotManager.validatePolicyZones(List.of(2L), null, volumeVO, Mockito.mock(Account.class)); } diff --git a/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java b/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java index 88493d10038e..2d54afbf0eaf 100644 --- a/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java @@ -18,11 +18,12 @@ package org.apache.cloudstack.storage.sharedfs; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -618,7 +619,7 @@ public void testSearchForSharedFS() { when(sharedFSJoinDao.searchByIds(List.of(s_sharedFSId).toArray(new Long[0]))).thenReturn(List.of(sharedFSJoinVO)); when(owner.getId()).thenReturn(s_ownerId); - when(accountMgr.isRootAdmin(any())).thenReturn(true); + when(accountMgr.isRootAdmin(anyLong())).thenReturn(true); when(sharedFSJoinDao.createSharedFSResponses(any(), any())).thenReturn(null); ListSharedFSCmd cmd = getMockListSharedFSCmd(); diff --git a/ui/src/main.js b/ui/src/main.js index 7441f8010865..69b5dbe4dd44 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -41,7 +41,8 @@ import { cpuArchitectureUtilPlugin, imagesUtilPlugin, extensionsUtilPlugin, - backupUtilPlugin + backupUtilPlugin, + localeErrorUtilPlugin } from './utils/plugins' import { VueAxios } from './utils/request' import directives from './utils/directives' @@ -65,6 +66,7 @@ vueApp.use(cpuArchitectureUtilPlugin) vueApp.use(imagesUtilPlugin) vueApp.use(extensionsUtilPlugin) vueApp.use(backupUtilPlugin) +vueApp.use(localeErrorUtilPlugin) vueApp.use(extensions) vueApp.use(directives) diff --git a/ui/src/utils/plugins.js b/ui/src/utils/plugins.js index 648bc3ae0811..0d074847e2b4 100644 --- a/ui/src/utils/plugins.js +++ b/ui/src/utils/plugins.js @@ -230,10 +230,11 @@ export const notifierPlugin = { if (error.response.headers && 'x-description' in error.response.headers) { desc = error.response.headers['x-description'] } - if (desc === '' && error.response.data) { + if (error.response.data) { const responseKey = _.findKey(error.response.data, 'errortext') - if (responseKey) { - desc = error.response.data[responseKey].errortext + if (responseKey && (desc === '' || error.response.data[responseKey].errortextkey)) { + const errObj = error.response.data[responseKey] + desc = this.$toLocaleError(errObj.errortext, errObj.errortextkey, errObj.errormetadata) } } } @@ -608,3 +609,23 @@ export const backupUtilPlugin = { } } } + +export const localeErrorUtilPlugin = { + install (app) { + app.config.globalProperties.$toLocaleError = function (msg, key, params) { + if (!key) { + return msg + } + let localeMsg = i18n.global.t(key) + if (!localeMsg || localeMsg === key) { + return msg + } + if (params && params.constructor === Object) { + for (const paramKey in params) { + localeMsg = localeMsg.replace(`{{${paramKey}}}`, params[paramKey]) + } + } + return localeMsg + } + } +} diff --git a/utils/src/main/java/com/cloud/utils/exception/CloudRuntimeException.java b/utils/src/main/java/com/cloud/utils/exception/CloudRuntimeException.java index dd5abc84f5fe..5350848944e3 100644 --- a/utils/src/main/java/com/cloud/utils/exception/CloudRuntimeException.java +++ b/utils/src/main/java/com/cloud/utils/exception/CloudRuntimeException.java @@ -24,6 +24,7 @@ import java.io.ObjectOutputStream; import java.util.ArrayList; import java.util.List; +import java.util.Map; import com.cloud.utils.Pair; import com.cloud.utils.SerialVersionUID; @@ -42,6 +43,9 @@ public class CloudRuntimeException extends RuntimeException implements ErrorCont protected int csErrorCode; + protected String messageKey = null; + protected Map metadata = null; + public CloudRuntimeException(String message) { super(message); setCSErrorCode(CSExceptionErrorCode.getCSErrCode(this.getClass().getName())); @@ -52,6 +56,13 @@ public CloudRuntimeException(String message, Throwable th) { setCSErrorCode(CSExceptionErrorCode.getCSErrCode(this.getClass().getName())); } + public CloudRuntimeException(String message, String messageKey, Map metadata) { + super(message); + this.messageKey = messageKey; + this.metadata = metadata; + setCSErrorCode(CSExceptionErrorCode.getCSErrCode(this.getClass().getName())); + } + protected CloudRuntimeException() { super(); @@ -138,4 +149,12 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE uuidList.add(new Pair, String>(Class.forName(clzName), val)); } } + + public String getMessageKey() { + return messageKey; + } + + public Map getMetadata() { + return metadata; + } }