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/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/response/ExceptionResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ExceptionResponse.java index 65d4ac333711..0d7750aa92bd 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..9e3820876c4c 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,20 @@ import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.managed.threadlocal.ManagedThreadLocal; -import org.apache.logging.log4j.Logger; 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 +56,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; @@ -134,6 +138,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(); 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..da2e7579dfa3 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/context/ErrorMessageResolver.java @@ -0,0 +1,241 @@ +// 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.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +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.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.exception.InvalidParameterValueException; +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 final class ErrorMessageResolver { + + private static final Logger LOG = + LogManager.getLogger(ErrorMessageResolver.class); + + private static final String ERROR_MESSAGES_FILENAME = "error-messages.json"; + private static final String ERROR_KEY_ADMIN_SUFFIX = ".admin"; + + 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() { + } + + public static String getMessage(String errorKey, Map metadata) { + return getMessageUsingStringMap(errorKey, getStringMap(metadata)); + } + + private 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); + } + + private static String getMessageUsingStringMap(String errorKey, Map metadata) { + String template = getTemplateForKey(errorKey); + if (template == null) { + return errorKey; + } + return expand(template, metadata); + } + + private 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; + } + + private static String getMetadataObjectStringValue(Object obj) { + if (obj == null) { + return null; + } + // obj is of primitive type + // String value structure should be 'NAME' (ID: id, UUID: uuid) + // NAME is obtained from obj.getName() or obj.getDisplayText() + // ID is obtained from obj.getId() if obj instanceof InternalIdentity and only for root admin + // UUID is obtained from obj.getUuid() if obj instanceof Identity + // If NAME is not available, fallback to obj.toString() then simply return UUID if available + 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("'"); + + Long id = null; + if (CallContext.current().isCallingAccountRootAdmin() && obj instanceof InternalIdentity) { + id = ((InternalIdentity) obj).getId(); + } + + if (id == null && uuid == null) { + 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(); + } + + private 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; + } + } + + public static void updateExceptionResponse(ExceptionResponse response, CloudRuntimeException cre) { + String key = cre.getMessageKey(); + Map map = cre.getMetadata(); + + if (key == null) { + if (cre.getCause() instanceof InvalidParameterValueException) { + key = ((InvalidParameterValueException) cre.getCause()).getMessageKey(); + map = ((InvalidParameterValueException) cre.getCause()).getMetadata(); + } else { + return; + } + } + response.setErrorTextKey(key); + Map stringMap = getStringMap(map); + String message = getMessageUsingStringMap(key, stringMap); + if (message != null) { + response.setErrorText(message); + } + response.setErrorMetadata(stringMap); + } + + private 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); + } + } + + private static String expand(String template, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + 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; + } +} diff --git a/client/conf/error-messages.json.in b/client/conf/error-messages.json.in new file mode 100644 index 000000000000..be840b3f87e7 --- /dev/null +++ b/client/conf/error-messages.json.in @@ -0,0 +1,9 @@ +{ + "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.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.not.specified": "Unable to deploy Instance as the required parameter 'serviceofferingid' is missing.", + "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." +} 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 abfab23f7052..b3fbc81a389d 100644 --- a/packaging/el8/cloud.spec +++ b/packaging/el8/cloud.spec @@ -290,7 +290,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 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/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index b0ba97d6de44..bdb6cfd6b8ec 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 2e30b4ecbd8c..d9ecc6b80de5 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; @@ -6265,18 +6265,23 @@ public String finalizeUserData(String userData, Long userDataId, VirtualMachineT private void verifyServiceOffering(BaseDeployVMCmd cmd, 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", + Map.of("serviceOffering", serviceOffering)); } 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", + Map.of("serviceOffering", serviceOffering)); } 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)); } } } @@ -6324,7 +6329,7 @@ public UserVm createVirtualMachine(DeployVMCmd cmd) throws InsufficientCapacityE 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(); 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; + } }