/*
* 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.geode.management.internal.web.controllers;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.management.ManagementFactory;
import java.net.URI;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import javax.management.JMX;
import javax.management.MBeanServer;
import javax.management.MalformedObjectNameException;
import javax.management.ObjectName;
import javax.management.Query;
import javax.management.QueryExp;
import org.apache.geode.internal.cache.GemFireCacheImpl;
import org.apache.geode.internal.lang.StringUtils;
import org.apache.geode.internal.logging.LogService;
import org.apache.geode.internal.logging.log4j.LogMarker;
import org.apache.geode.internal.security.IntegratedSecurityService;
import org.apache.geode.internal.security.SecurityService;
import org.apache.geode.internal.util.ArrayUtils;
import org.apache.geode.management.DistributedSystemMXBean;
import org.apache.geode.management.ManagementService;
import org.apache.geode.management.MemberMXBean;
import org.apache.geode.management.internal.MBeanJMXAdapter;
import org.apache.geode.management.internal.ManagementConstants;
import org.apache.geode.management.internal.SystemManagementService;
import org.apache.geode.management.internal.cli.shell.Gfsh;
import org.apache.geode.management.internal.cli.util.CommandStringBuilder;
import org.apache.geode.management.internal.web.controllers.support.LoginHandlerInterceptor;
import org.apache.geode.management.internal.web.util.UriUtils;
import org.apache.geode.security.NotAuthorizedException;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.propertyeditors.StringArrayPropertyEditor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
/**
* The AbstractCommandsController class is the abstract base class encapsulating common
* functionality across all Management Controller classes that expose REST API web service endpoints
* (URLs/URIs) for GemFire shell (Gfsh) commands.
*
* @see org.apache.geode.management.MemberMXBean
* @see org.apache.geode.management.internal.cli.shell.Gfsh
* @see org.springframework.stereotype.Controller
* @see org.springframework.web.bind.annotation.ExceptionHandler
* @see org.springframework.web.bind.annotation.InitBinder
* @since GemFire 8.0
*/
@SuppressWarnings("unused")
public abstract class AbstractCommandsController {
private static final Logger logger = LogService.getLogger();
protected static final String DEFAULT_ENCODING = UriUtils.DEFAULT_ENCODING;
protected static final String REST_API_VERSION = "/v1";
private MemberMXBean managingMemberMXBeanProxy;
private SecurityService securityService = IntegratedSecurityService.getSecurityService();
private Class accessControlKlass;
// Convert a predefined exception to an HTTP Status code
@ResponseStatus(value = HttpStatus.UNAUTHORIZED, reason = "Not authenticated") // 401
@ExceptionHandler(org.apache.geode.security.AuthenticationFailedException.class)
public void authenticate() {
}
// Convert a predefined exception to an HTTP Status code
@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Access Denied") // 403
@ExceptionHandler(java.lang.SecurityException.class)
public void authorize() {
}
/**
* Asserts the argument is valid, as determined by the caller passing the result of an evaluated
* expression to this assertion.
*
* @param validArg a boolean value indicating the evaluation of the expression validating the
* argument.
* @param message a String value used as the message when constructing an
* IllegalArgumentException.
* @param args Object arguments used to populate placeholder's in the message.
* @throws IllegalArgumentException if the argument is not valid.
* @see java.lang.String#format(String, Object...)
*/
protected static void assertArgument(final boolean validArg, final String message,
final Object... args) {
if (!validArg) {
throw new IllegalArgumentException(String.format(message, args));
}
}
/**
* Asserts the Object reference is not null!
*
* @param obj the reference to the Object.
* @param message the String value used as the message when constructing and throwing a
* NullPointerException.
* @param args Object arguments used to populate placeholder's in the message.
* @throws NullPointerException if the Object reference is null.
* @see java.lang.String#format(String, Object...)
*/
protected static void assertNotNull(final Object obj, final String message,
final Object... args) {
if (obj == null) {
throw new NullPointerException(String.format(message, args));
}
}
/**
* Asserts whether state, based on the evaluation of a conditional expression, passed to this
* assertion is valid.
*
* @param validState a boolean value indicating the evaluation of the expression from which the
* conditional state is based. For example, a caller might use an expression of the form
* (initableObj.isInitialized()).
* @param message a String values used as the message when constructing an IllegalStateException.
* @param args Object arguments used to populate placeholder's in the message.
* @throws IllegalStateException if the conditional state is not valid.
* @see java.lang.String#format(String, Object...)
*/
protected static void assertState(final boolean validState, final String message,
final Object... args) {
if (!validState) {
throw new IllegalStateException(String.format(message, args));
}
}
/**
* Decodes the encoded String value using the default encoding UTF-8. It is assumed the String
* value was encoded with the URLEncoder using the UTF-8 encoding. This method handles
* UnsupportedEncodingException by just returning the encodedValue.
*
* @param encodedValue the encoded String value to decode.
* @return the decoded value of the String or encodedValue if the UTF-8 encoding is unsupported.
* @see org.apache.geode.management.internal.web.util.UriUtils#decode(String)
*/
protected static String decode(final String encodedValue) {
return UriUtils.decode(encodedValue);
}
/**
* Decodes the encoded String value using the specified encoding (such as UTF-8). It is assumed
* the String value was encoded with the URLEncoder using the specified encoding. This method
* handles UnsupportedEncodingException by just returning the encodedValue.
*
* @param encodedValue a String value encoded in the encoding.
* @param encoding a String value specifying the encoding.
* @return the decoded value of the String or encodedValue if the specified encoding is
* unsupported.
* @see org.apache.geode.management.internal.web.util.UriUtils#decode(String, String)
*/
protected static String decode(final String encodedValue, final String encoding) {
return UriUtils.decode(encodedValue, encoding);
}
/**
* Gets the specified value if not null or empty, otherwise returns the default value.
*
* @param value the String value being evaluated for having value (not null and not empty).
* @param defaultValue the default String value returned if 'value' has no value.
* @return 'value' if not null or empty, otherwise returns the default value.
* @see #hasValue(String)
*/
protected static String defaultIfNoValue(final String value, final String defaultValue) {
return (hasValue(value) ? value : defaultValue);
}
/**
* Encodes the String value using the default encoding UTF-8.
*
* @param value the String value to encode.
* @return an encoded value of the String using the default encoding UTF-8 or value if the UTF-8
* encoding is unsupported.
* @see org.apache.geode.management.internal.web.util.UriUtils#encode(String)
*/
protected static String encode(final String value) {
return UriUtils.encode(value);
}
/**
* Encodes the String value using the specified encoding (such as UTF-8).
*
* @param value the String value to encode.
* @param encoding a String value indicating the encoding.
* @return an encoded value of the String using the specified encoding or value if the specified
* encoding is unsupported.
* @see org.apache.geode.management.internal.web.util.UriUtils#encode(String, String)
*/
protected static String encode(final String value, final String encoding) {
return UriUtils.encode(value, encoding);
}
/**
* Determines whether the specified Object has value, which is determined by a non-null Object
* reference.
*
* @param value the Object value being evaluated for value.
* @return a boolean value indicating whether the specified Object has value.
* @see java.lang.Object
*/
protected static boolean hasValue(final Object value) {
return (value instanceof String[] ? hasValue((String[]) value)
: (value instanceof String ? hasValue((String) value) : value != null));
}
/**
* Determines whether the specified String has value, determined by whether the String is
* non-null, not empty and not blank.
*
* @param value the String being evaluated for value.
* @return a boolean indicating whether the specified String has value or not.
* @see java.lang.String
*/
protected static boolean hasValue(final String value) {
return !StringUtils.isBlank(value);
}
/**
* Determines whether the specified String array has any value, which is determined by a non-null
* String array reference along with containing at least 1 non-null, not empty and not blank
* element.
*
* @param array an String array being evaluated for value.
* @return a boolean indicating whether the specified String array has any value.
* @see #hasValue(String)
* @see java.lang.String
*/
protected static boolean hasValue(final String[] array) {
if (array != null && array.length > 0) {
for (final String element : array) {
if (hasValue(element)) {
return true;
}
}
}
return false;
}
/**
* Writes the stack trace of the Throwable to a String.
*
* @param t a Throwable object who's stack trace will be written to a String.
* @return a String containing the stack trace of the Throwable.
* @see java.io.StringWriter
* @see java.lang.Throwable#printStackTrace(java.io.PrintWriter)
*/
protected static String printStackTrace(final Throwable t) {
final StringWriter stackTraceWriter = new StringWriter();
t.printStackTrace(new PrintWriter(stackTraceWriter));
return stackTraceWriter.toString();
}
/**
* Converts the URI relative path to an absolute path based on the Servlet context information.
*
* @param path the URI relative path to append to the Servlet context path.
* @param scheme the scheme to use for the URI
* @return a URI constructed with all component path information.
* @see java.net.URI
* @see org.springframework.web.servlet.support.ServletUriComponentsBuilder
*/
protected /* static */ URI toUri(final String path, final String scheme) {
return ServletUriComponentsBuilder.fromCurrentContextPath().path(REST_API_VERSION).path(path)
.scheme(scheme).build().toUri();
}
/**
* Handles any Exception thrown by a REST API web service endpoint, HTTP request handler method
* during the invocation and processing of a command.
*
* @param cause the Exception causing the error.
* @return a ResponseEntity with an appropriate HTTP status code (500 - Internal Server Error) and
* HTTP response body containing the stack trace of the Exception.
* @see java.lang.Exception
* @see org.springframework.http.ResponseEntity
* @see org.springframework.web.bind.annotation.ExceptionHandler
* @see org.springframework.web.bind.annotation.ResponseBody
*/
@ExceptionHandler(Exception.class)
@ResponseBody
public ResponseEntity<String> handleException(final Exception cause) {
final String stackTrace = printStackTrace(cause);
logger.fatal(stackTrace);
return new ResponseEntity<String>(stackTrace, HttpStatus.INTERNAL_SERVER_ERROR);
}
/**
* Initializes data bindings for various HTTP request handler method parameter Java class types.
*
* @param dataBinder the DataBinder implementation used for Web transactions.
* @see org.springframework.web.bind.WebDataBinder
* @see org.springframework.web.bind.annotation.InitBinder
*/
@InitBinder
public void initBinder(final WebDataBinder dataBinder) {
dataBinder.registerCustomEditor(String[].class,
new StringArrayPropertyEditor(StringArrayPropertyEditor.DEFAULT_SEPARATOR, false));
}
/**
* Logs the client's HTTP (web) request including details of the HTTP headers and request
* parameters along with the web request context and description.
*
* @param request the object encapsulating the details of the client's HTTP (web) request.
* @see org.springframework.web.context.request.WebRequest
*/
protected void logRequest(final WebRequest request) {
if (request != null) {
final Map<String, String> headers = new HashMap<java.lang.String, java.lang.String>();
for (Iterator<String> it = request.getHeaderNames(); it.hasNext();) {
final String headerName = it.next();
headers.put(headerName,
ArrayUtils.toString((Object[]) request.getHeaderValues(headerName)));
}
final Map<String, String> parameters =
new HashMap<String, String>(request.getParameterMap().size());
for (Iterator<String> it = request.getParameterNames(); it.hasNext();) {
final String parameterName = it.next();
parameters.put(parameterName,
ArrayUtils.toString((Object[]) request.getParameterValues(parameterName)));
}
logger.info("HTTP-request: description ({}), context ({}), headers ({}), parameters ({})",
request.getDescription(false), request.getContextPath(), headers, parameters);
}
}
/**
* Gets a reference to the platform MBeanServer running in this JVM process. The MBeanServer
* instance constitutes a connection to the MBeanServer.
*
* @return a reference to the platform MBeanServer for this JVM process.
* @see java.lang.management.ManagementFactory#getPlatformMBeanServer()
* @see javax.management.MBeanServer
*/
protected MBeanServer getMBeanServer() {
return ManagementFactory.getPlatformMBeanServer();
}
/**
* Gets the MemberMXBean from the JVM Platform MBeanServer for the specified member, identified by
* name or ID in the GemFire cluster.
*
* @param memberNameId a String indicating the name or ID of the GemFire member.
* @return a proxy to the GemFire member's MemberMXBean.
* @throws IllegalStateException if no MemberMXBean could be found for GemFire member with ID or
* name.
* @throws RuntimeException wrapping the MalformedObjectNameException if the ObjectName pattern is
* malformed.
* @see #getMBeanServer()
* @see #isMemberMXBeanFound(java.util.Collection)
* @see javax.management.ObjectName
* @see javax.management.QueryExp
* @see javax.management.MBeanServer#queryNames(javax.management.ObjectName,
* javax.management.QueryExp)
* @see javax.management.JMX#newMXBeanProxy(javax.management.MBeanServerConnection,
* javax.management.ObjectName, Class)
* @see org.apache.geode.management.MemberMXBean
*/
protected MemberMXBean getMemberMXBean(final String memberNameId) {
try {
final MBeanServer connection = getMBeanServer();
final String objectNamePattern =
ManagementConstants.OBJECTNAME__PREFIX.concat("type=Member,*");
// NOTE throws a MalformedObjectNameException, but this should not happen since we constructed
// the ObjectName above
final ObjectName objectName = ObjectName.getInstance(objectNamePattern);
final QueryExp query = Query.or(Query.eq(Query.attr("Name"), Query.value(memberNameId)),
Query.eq(Query.attr("Id"), Query.value(memberNameId)));
final Set<ObjectName> objectNames = connection.queryNames(objectName, query);
assertState(isMemberMXBeanFound(objectNames),
"No MemberMXBean with ObjectName (%1$s) based on Query (%2$s) was found in the Platform MBeanServer for member (%3$s)!",
objectName, query, memberNameId);
return JMX.newMXBeanProxy(connection, objectNames.iterator().next(), MemberMXBean.class);
} catch (MalformedObjectNameException e) {
throw new RuntimeException(e);
}
}
/**
* Determines whether the desired MemberMXBean, identified by name or ID, was found in the
* platform MBeanServer of this JVM process.
*
* @param objectNames a Collection of ObjectNames possibly referring to the desired MemberMXBean.
* @return a boolean value indicating whether the desired MemberMXBean was found.
* @see javax.management.ObjectName
*/
private boolean isMemberMXBeanFound(final Collection<ObjectName> objectNames) {
return !(objectNames == null || objectNames.isEmpty());
}
/**
* Lookup operation for the MemberMXBean representing the Manager in the GemFire cluster. This
* method gets an instance fo the Platform MBeanServer for this JVM process and uses it to lookup
* the MemberMXBean for the GemFire Manager based on the ObjectName declared in the
* DistributedSystemMXBean.getManagerObjectName() operation.
*
* @return a proxy instance to the MemberMXBean of the GemFire Manager.
* @see #getMBeanServer()
* @see #createMemberMXBeanForManagerUsingProxy(javax.management.MBeanServer,
* javax.management.ObjectName)
* @see org.apache.geode.management.DistributedSystemMXBean
* @see org.apache.geode.management.MemberMXBean
*/
protected synchronized MemberMXBean getManagingMemberMXBean() {
if (managingMemberMXBeanProxy == null) {
SystemManagementService service = (SystemManagementService) ManagementService
.getExistingManagementService(GemFireCacheImpl.getInstance());
MBeanServer mbs = getMBeanServer();
final DistributedSystemMXBean distributedSystemMXBean = JMX.newMXBeanProxy(mbs,
MBeanJMXAdapter.getDistributedSystemName(), DistributedSystemMXBean.class);
managingMemberMXBeanProxy = createMemberMXBeanForManagerUsingProxy(mbs,
distributedSystemMXBean.getMemberObjectName());
}
return managingMemberMXBeanProxy;
}
protected synchronized ObjectName getMemberObjectName() {
final MBeanServer platformMBeanServer = getMBeanServer();
final DistributedSystemMXBean distributedSystemMXBean = JMX.newMXBeanProxy(platformMBeanServer,
MBeanJMXAdapter.getDistributedSystemName(), DistributedSystemMXBean.class);
return distributedSystemMXBean.getMemberObjectName();
}
/**
* Creates a Proxy using the Platform MBeanServer and ObjectName in order to access attributes and
* invoke operations on the GemFire Manager's MemberMXBean.
*
* @param server a reference to this JVM's Platform MBeanServer.
* @param managingMemberObjectName the ObjectName of the GemFire Manager's MemberMXBean registered
* in the Platform MBeanServer.
* @return a Proxy for accessing attributes and invoking operations on the GemFire Manager's
* MemberMXBean.
* @see javax.management.JMX#newMXBeanProxy(javax.management.MBeanServerConnection,
* javax.management.ObjectName, Class)
*/
private MemberMXBean createMemberMXBeanForManagerUsingProxy(final MBeanServer server,
final ObjectName managingMemberObjectName) {
return JMX.newMXBeanProxy(server, managingMemberObjectName, MemberMXBean.class);
}
/**
* Gets the environment setup during this HTTP/command request for the current command process
* execution.
*
* @return a mapping of environment variables to values.
* @see LoginHandlerInterceptor#getEnvironment()
*/
protected Map<String, String> getEnvironment() {
final Map<String, String> environment = new HashMap<String, String>();
environment.putAll(LoginHandlerInterceptor.getEnvironment());
environment.put(Gfsh.ENV_APP_NAME, Gfsh.GFSH_APP_NAME);
return environment;
}
/**
* Adds the named option to the command String to be processed if the named option has value or
* the named option is present in the HTTP request.
*
* @param request the WebRequest object encapsulating the details (headers, request parameters and
* message body) of the user HTTP request.
* @param command the Gfsh command String to append options and process.
* @param optionName the name of the command option.
* @param optionValue the value for the named command option.
* @see #hasValue(Object)
* @see #hasValue(String[])
* @see org.apache.geode.management.internal.cli.util.CommandStringBuilder
* @see org.springframework.web.context.request.WebRequest
*/
protected void addCommandOption(final WebRequest request, final CommandStringBuilder command,
final String optionName, final Object optionValue) {
assertNotNull(command, "The command to append options to cannot be null!");
assertNotNull(optionName, "The name of the option to add to the command cannot be null!");
if (hasValue(optionValue)) {
final String optionValueString = (optionValue instanceof String[]
? StringUtils.concat((String[]) optionValue, StringUtils.COMMA_DELIMITER)
: String.valueOf(optionValue));
command.addOption(optionName, optionValueString);
} else if (request != null && request.getParameterMap().containsKey(optionName)) {
command.addOption(optionName);
} else {
// do nothing!
}
}
/**
* Executes the specified command as entered by the user using the GemFire Shell (Gfsh). Note,
* Gfsh performs validation of the command during parsing before sending the command to the
* Manager for processing.
*
* @param command a String value containing a valid command String as would be entered by the user
* in Gfsh.
* @return a result of the command execution as a String, typically marshalled in JSON to be
* serialized back to Gfsh.
* @see org.apache.geode.management.internal.cli.shell.Gfsh
* @see LoginHandlerInterceptor#getEnvironment()
* @see #getEnvironment()
* @see #processCommand(String, java.util.Map, byte[][])
*/
protected String processCommand(final String command) {
return processCommand(command, getEnvironment(), null);
}
protected Callable<ResponseEntity<String>> getProcessCommandCallable(final String command) {
return getProcessCommandCallable(command, getEnvironment(), null);
}
protected Callable<ResponseEntity<String>> getProcessCommandCallable(final String command,
final Map<String, String> environment, final byte[][] fileData) {
Callable callable = new Callable<ResponseEntity<String>>() {
@Override
public ResponseEntity<String> call() throws Exception {
String result = null;
try {
result = processCommand(command, environment, fileData);
} catch (NotAuthorizedException ex) {
return new ResponseEntity<String>(ex.getMessage(), HttpStatus.FORBIDDEN);
} catch (Exception ex) {
return new ResponseEntity<String>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
return new ResponseEntity<String>(result, HttpStatus.OK);
}
};
return this.securityService.associateWith(callable);
}
/**
* Executes the specified command as entered by the user using the GemFire Shell (Gfsh). Note,
* Gfsh performs validation of the command during parsing before sending the command to the
* Manager for processing.
*
* @param command a String value containing a valid command String as would be entered by the user
* in Gfsh.
* @param fileData is a two-dimensional byte array containing the pathnames and contents of file
* data streamed to the Manager, usually for the 'deploy' Gfsh command.
* @return a result of the command execution as a String, typically marshalled in JSON to be
* serialized back to Gfsh.
* @see org.apache.geode.management.internal.cli.shell.Gfsh
* @see LoginHandlerInterceptor#getEnvironment()
* @see #getEnvironment()
* @see #processCommand(String, java.util.Map, byte[][])
*/
protected String processCommand(final String command, final byte[][] fileData) {
return processCommand(command, getEnvironment(), fileData);
}
/**
* Executes the specified command as entered by the user using the GemFire Shell (Gfsh). Note,
* Gfsh performs validation of the command during parsing before sending the command to the
* Manager for processing.
*
* @param command a String value containing a valid command String as would be entered by the user
* in Gfsh.
* @param environment a Map containing any environment configuration settings to be used by the
* Manager during command execution. For example, when executing commands originating from
* Gfsh, the key/value pair (APP_NAME=gfsh) is a specified mapping in the "environment.
* Note, it is common for the REST API to act as a bridge, or an adapter between Gfsh and
* the Manager, and thus need to specify this key/value pair mapping.
* @return a result of the command execution as a String, typically marshalled in JSON to be
* serialized back to Gfsh.
* @see org.apache.geode.management.internal.cli.shell.Gfsh
* @see LoginHandlerInterceptor#getEnvironment()
* @see #processCommand(String, java.util.Map, byte[][])
*/
protected String processCommand(final String command, final Map<String, String> environment) {
return processCommand(command, environment, null);
}
/**
* Executes the specified command as entered by the user using the GemFire Shell (Gfsh). Note,
* Gfsh performs validation of the command during parsing before sending the command to the
* Manager for processing.
*
* @param command a String value containing a valid command String as would be entered by the user
* in Gfsh.
* @param environment a Map containing any environment configuration settings to be used by the
* Manager during command execution. For example, when executing commands originating from
* Gfsh, the key/value pair (APP_NAME=gfsh) is a specified mapping in the "environment.
* Note, it is common for the REST API to act as a bridge, or an adapter between Gfsh and
* the Manager, and thus need to specify this key/value pair mapping.
* @param fileData is a two-dimensional byte array containing the pathnames and contents of file
* data streamed to the Manager, usually for the 'deploy' Gfsh command.
* @return a result of the command execution as a String, typically marshalled in JSON to be
* serialized back to Gfsh.
* @see org.apache.geode.management.MemberMXBean#processCommand(String, java.util.Map, Byte[][])
*/
protected String processCommand(final String command, final Map<String, String> environment,
final byte[][] fileData) {
logger.info(LogMarker.CONFIG,
"Processing Command ({}) with Environment ({}) having File Data ({})...", command,
environment, (fileData != null));
return getManagingMemberMXBean().processCommand(command, environment,
ArrayUtils.toByteArray(fileData));
}
@ExceptionHandler(NotAuthorizedException.class)
public ResponseEntity<String> handleAppException(NotAuthorizedException ex) {
return new ResponseEntity<String>(ex.getMessage(), HttpStatus.FORBIDDEN);
}
}