diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/CommonConfigurationKeysPublic.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/CommonConfigurationKeysPublic.java index f23dd5137a5..e1feda19dce 100644 --- a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/CommonConfigurationKeysPublic.java +++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/CommonConfigurationKeysPublic.java @@ -517,6 +517,21 @@ public class CommonConfigurationKeysPublic { * * core-default.xml */ + public static final String HADOOP_SECURITY_GROUP_SHELL_COMMAND_TIMEOUT_SECS = + "hadoop.security.groups.shell.command.timeout"; + /** + * @see + * + * core-default.xml + */ + public static final long + HADOOP_SECURITY_GROUP_SHELL_COMMAND_TIMEOUT_SECS_DEFAULT = + 0L; + /** + * @see + * + * core-default.xml + */ public static final String HADOOP_SECURITY_AUTHENTICATION = "hadoop.security.authentication"; /** diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/security/ShellBasedUnixGroupsMapping.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/security/ShellBasedUnixGroupsMapping.java index 9b80be97999..4146e7bc9bd 100644 --- a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/security/ShellBasedUnixGroupsMapping.java +++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/security/ShellBasedUnixGroupsMapping.java @@ -18,17 +18,25 @@ package org.apache.hadoop.security; import java.io.IOException; +import java.util.Arrays; import java.util.LinkedList; import java.util.List; import java.util.StringTokenizer; +import java.util.concurrent.TimeUnit; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; import org.apache.commons.lang.StringUtils; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.apache.hadoop.classification.InterfaceAudience; import org.apache.hadoop.classification.InterfaceStability; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.conf.Configured; +import org.apache.hadoop.fs.CommonConfigurationKeys; import org.apache.hadoop.util.Shell; import org.apache.hadoop.util.Shell.ExitCodeException; import org.apache.hadoop.util.Shell.ShellCommandExecutor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A simple shell-based implementation of {@link GroupMappingServiceProvider} @@ -37,11 +45,28 @@ import org.apache.hadoop.util.Shell.ShellCommandExecutor; */ @InterfaceAudience.LimitedPrivate({"HDFS", "MapReduce"}) @InterfaceStability.Evolving -public class ShellBasedUnixGroupsMapping +public class ShellBasedUnixGroupsMapping extends Configured implements GroupMappingServiceProvider { - - private static final Log LOG = - LogFactory.getLog(ShellBasedUnixGroupsMapping.class); + + @VisibleForTesting + protected static final Logger LOG = + LoggerFactory.getLogger(ShellBasedUnixGroupsMapping.class); + + private long timeout = 0L; + private static final List EMPTY_GROUPS = new LinkedList<>(); + + @Override + public void setConf(Configuration conf) { + super.setConf(conf); + if (conf != null) { + timeout = conf.getTimeDuration( + CommonConfigurationKeys. + HADOOP_SECURITY_GROUP_SHELL_COMMAND_TIMEOUT_SECS, + CommonConfigurationKeys. + HADOOP_SECURITY_GROUP_SHELL_COMMAND_TIMEOUT_SECS_DEFAULT, + TimeUnit.SECONDS); + } + } @SuppressWarnings("serial") private static class PartialGroupNameException extends IOException { @@ -98,7 +123,17 @@ public class ShellBasedUnixGroupsMapping */ protected ShellCommandExecutor createGroupExecutor(String userName) { return new ShellCommandExecutor( - Shell.getGroupsForUserCommand(userName), null, null, 0L); + getGroupsForUserCommand(userName), null, null, timeout); + } + + /** + * Returns just the shell command to be used to fetch a user's groups list. + * This is mainly separate to make some tests easier. + * @param userName The username that needs to be passed into the command built + * @return An appropriate shell command with arguments + */ + protected String[] getGroupsForUserCommand(String userName) { + return Shell.getGroupsForUserCommand(userName); } /** @@ -109,7 +144,17 @@ public class ShellBasedUnixGroupsMapping */ protected ShellCommandExecutor createGroupIDExecutor(String userName) { return new ShellCommandExecutor( - Shell.getGroupsIDForUserCommand(userName), null, null, 0L); + getGroupsIDForUserCommand(userName), null, null, timeout); + } + + /** + * Returns just the shell command to be used to fetch a user's group IDs list. + * This is mainly separate to make some tests easier. + * @param userName The username that needs to be passed into the command built + * @return An appropriate shell command with arguments + */ + protected String[] getGroupsIDForUserCommand(String userName) { + return Shell.getGroupsIDForUserCommand(userName); } /** @@ -133,8 +178,26 @@ public class ShellBasedUnixGroupsMapping groups = resolvePartialGroupNames(user, e.getMessage(), executor.getOutput()); } catch (PartialGroupNameException pge) { - LOG.warn("unable to return groups for user " + user, pge); - return new LinkedList<>(); + LOG.warn("unable to return groups for user {}", user, pge); + return EMPTY_GROUPS; + } + } catch (IOException ioe) { + // If its a shell executor timeout, indicate so in the message + // but treat the result as empty instead of throwing it up, + // similar to how partial resolution failures are handled above + if (executor.isTimedOut()) { + LOG.warn( + "Unable to return groups for user '{}' as shell group lookup " + + "command '{}' ran longer than the configured timeout limit of " + + "{} seconds.", + user, + Joiner.on(' ').join(executor.getExecString()), + timeout + ); + return EMPTY_GROUPS; + } else { + // If its not an executor timeout, we should let the caller handle it + throw ioe; } } @@ -196,7 +259,7 @@ public class ShellBasedUnixGroupsMapping * @param errMessage error message from the shell command * @param groupNames the incomplete list of group names * @return a list of resolved group names - * @throws PartialGroupNameException + * @throws PartialGroupNameException if the resolution fails or times out */ private List resolvePartialGroupNames(String userName, String errMessage, String groupNames) throws PartialGroupNameException { @@ -212,21 +275,29 @@ public class ShellBasedUnixGroupsMapping throw new PartialGroupNameException("The user name '" + userName + "' is not found. " + errMessage); } else { - LOG.warn("Some group names for '" + userName + "' are not resolvable. " - + errMessage); + LOG.warn("Some group names for '{}' are not resolvable. {}", + userName, errMessage); // attempt to partially resolve group names + ShellCommandExecutor partialResolver = createGroupIDExecutor(userName); try { - ShellCommandExecutor exec2 = createGroupIDExecutor(userName); - exec2.execute(); - return parsePartialGroupNames(groupNames, exec2.getOutput()); + partialResolver.execute(); + return parsePartialGroupNames( + groupNames, partialResolver.getOutput()); } catch (ExitCodeException ece) { // If exception is thrown trying to get group id list, // something is terribly wrong, so give up. - throw new PartialGroupNameException("failed to get group id list for " + - "user '" + userName + "'", ece); + throw new PartialGroupNameException( + "failed to get group id list for user '" + userName + "'", ece); } catch (IOException ioe) { - throw new PartialGroupNameException("can't execute the shell command to" - + " get the list of group id for user '" + userName + "'", ioe); + String message = + "Can't execute the shell command to " + + "get the list of group id for user '" + userName + "'"; + if (partialResolver.isTimedOut()) { + message += + " because of the command taking longer than " + + "the configured timeout: " + timeout + " seconds"; + } + throw new PartialGroupNameException(message, ioe); } } } @@ -237,7 +308,8 @@ public class ShellBasedUnixGroupsMapping * @param groupNames a string representing the user's group names * @return a linked list of group names */ - private List resolveFullGroupNames(String groupNames) { + @VisibleForTesting + protected List resolveFullGroupNames(String groupNames) { StringTokenizer tokenizer = new StringTokenizer(groupNames, Shell.TOKEN_SEPARATOR_REGEX); List groups = new LinkedList(); diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/util/Shell.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/util/Shell.java index ca59b0e30df..bfb8183be25 100644 --- a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/util/Shell.java +++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/util/Shell.java @@ -955,7 +955,15 @@ public abstract class Shell { line = errReader.readLine(); } } catch(IOException ioe) { - LOG.warn("Error reading the error stream", ioe); + // Its normal to observe a "Stream closed" I/O error on + // command timeouts destroying the underlying process + // so only log a WARN if the command didn't time out + if (!isTimedOut()) { + LOG.warn("Error reading the error stream", ioe); + } else { + LOG.debug("Error reading the error stream due to shell " + + "command timeout", ioe); + } } } }; @@ -1180,6 +1188,15 @@ public abstract class Shell { this.inheritParentEnv = inheritParentEnv; } + /** + * Returns the timeout value set for the executor's sub-commands. + * @return The timeout value in seconds + */ + @VisibleForTesting + public long getTimeoutInterval() { + return timeOutInterval; + } + /** * Execute the shell command. * @throws IOException if the command fails, or if the command is diff --git a/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml b/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml index 1941bec6fd8..d8136eeacb7 100644 --- a/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml +++ b/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml @@ -187,6 +187,19 @@ + + hadoop.security.groups.shell.command.timeout + 0s + + Used by the ShellBasedUnixGroupsMapping class, this property controls how + long to wait for the underlying shell command that is run to fetch groups. + Expressed in seconds (e.g. 10s, 1m, etc.), if the running command takes + longer than the value configured, the command is aborted and the groups + resolver would return a result of no groups found. A value of 0s (default) + would mean an infinite wait (i.e. wait until the command exits on its own). + + + hadoop.security.group.mapping.ldap.connection.timeout.ms 60000 diff --git a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestGroupsCaching.java b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestGroupsCaching.java index 58c2d1aac63..930c45e0208 100644 --- a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestGroupsCaching.java +++ b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestGroupsCaching.java @@ -50,7 +50,7 @@ import org.apache.hadoop.security.ShellBasedUnixGroupsMapping; public class TestGroupsCaching { - public static final Log LOG = LogFactory.getLog(TestGroupsCaching.class); + public static final Log TESTLOG = LogFactory.getLog(TestGroupsCaching.class); private static String[] myGroups = {"grp1", "grp2"}; private Configuration conf; @@ -76,7 +76,7 @@ public class TestGroupsCaching { @Override public List getGroups(String user) throws IOException { - LOG.info("Getting groups for " + user); + TESTLOG.info("Getting groups for " + user); delayIfNecessary(); requestCount++; @@ -115,18 +115,18 @@ public class TestGroupsCaching { @Override public void cacheGroupsRefresh() throws IOException { - LOG.info("Cache is being refreshed."); + TESTLOG.info("Cache is being refreshed."); clearBlackList(); return; } public static void clearBlackList() throws IOException { - LOG.info("Clearing the blacklist"); + TESTLOG.info("Clearing the blacklist"); blackList.clear(); } public static void clearAll() throws IOException { - LOG.info("Resetting FakeGroupMapping"); + TESTLOG.info("Resetting FakeGroupMapping"); blackList.clear(); allGroups.clear(); requestCount = 0; @@ -137,12 +137,12 @@ public class TestGroupsCaching { @Override public void cacheGroupsAdd(List groups) throws IOException { - LOG.info("Adding " + groups + " to groups."); + TESTLOG.info("Adding " + groups + " to groups."); allGroups.addAll(groups); } public static void addToBlackList(String user) throws IOException { - LOG.info("Adding " + user + " to the blacklist"); + TESTLOG.info("Adding " + user + " to the blacklist"); blackList.add(user); } @@ -226,11 +226,12 @@ public class TestGroupsCaching { // ask for a negative entry try { - LOG.error("We are not supposed to get here." + groups.getGroups("user1").toString()); + TESTLOG.error("We are not supposed to get here." + + groups.getGroups("user1").toString()); fail(); } catch (IOException ioe) { if(!ioe.getMessage().startsWith("No groups found")) { - LOG.error("Got unexpected exception: " + ioe.getMessage()); + TESTLOG.error("Got unexpected exception: " + ioe.getMessage()); fail(); } } diff --git a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestShellBasedUnixGroupsMapping.java b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestShellBasedUnixGroupsMapping.java index f28cc62d193..6d9ea0853f5 100644 --- a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestShellBasedUnixGroupsMapping.java +++ b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/security/TestShellBasedUnixGroupsMapping.java @@ -22,9 +22,15 @@ import java.util.List; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.CommonConfigurationKeys; +import org.apache.hadoop.test.GenericTestUtils; +import org.apache.hadoop.util.ReflectionUtils; +import org.apache.hadoop.util.Shell; import org.apache.hadoop.util.Shell.ExitCodeException; import org.apache.hadoop.util.Shell.ShellCommandExecutor; import org.junit.Test; + import static org.junit.Assert.*; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -32,9 +38,13 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class TestShellBasedUnixGroupsMapping { - private static final Log LOG = + private static final Log TESTLOG = LogFactory.getLog(TestShellBasedUnixGroupsMapping.class); + private final GenericTestUtils.LogCapturer shellMappingLog = + GenericTestUtils.LogCapturer.captureLogs( + ShellBasedUnixGroupsMapping.LOG); + private class TestGroupUserNotExist extends ShellBasedUnixGroupsMapping { /** @@ -55,7 +65,7 @@ public class TestShellBasedUnixGroupsMapping { when(executor.getOutput()).thenReturn(""); } catch (IOException e) { - LOG.warn(e.getMessage()); + TESTLOG.warn(e.getMessage()); } return executor; } @@ -90,7 +100,7 @@ public class TestShellBasedUnixGroupsMapping { when(executor.getOutput()).thenReturn("9999\n9999 abc def"); } catch (IOException e) { - LOG.warn(e.getMessage()); + TESTLOG.warn(e.getMessage()); } return executor; } @@ -133,7 +143,7 @@ public class TestShellBasedUnixGroupsMapping { doNothing().when(executor).execute(); when(executor.getOutput()).thenReturn("23\n23 groupname zzz"); } catch (IOException e) { - LOG.warn(e.getMessage()); + TESTLOG.warn(e.getMessage()); } return executor; } @@ -146,7 +156,7 @@ public class TestShellBasedUnixGroupsMapping { doNothing().when(executor).execute(); when(executor.getOutput()).thenReturn("111\n111 112 113"); } catch (IOException e) { - LOG.warn(e.getMessage()); + TESTLOG.warn(e.getMessage()); } return executor; } @@ -179,7 +189,7 @@ public class TestShellBasedUnixGroupsMapping { doNothing().when(executor).execute(); when(executor.getOutput()).thenReturn("abc\ndef abc hij"); } catch (IOException e) { - LOG.warn(e.getMessage()); + TESTLOG.warn(e.getMessage()); } return executor; } @@ -192,7 +202,7 @@ public class TestShellBasedUnixGroupsMapping { doNothing().when(executor).execute(); when(executor.getOutput()).thenReturn("1\n1 2 3"); } catch (IOException e) { - LOG.warn(e.getMessage()); + TESTLOG.warn(e.getMessage()); } return executor; } @@ -208,6 +218,117 @@ public class TestShellBasedUnixGroupsMapping { assertTrue(groups.contains("def")); assertTrue(groups.contains("hij")); } + + private static class TestDelayedGroupCommand + extends ShellBasedUnixGroupsMapping { + + private Long timeoutSecs = 2L; + + TestDelayedGroupCommand() { + super(); + } + + @Override + protected String[] getGroupsForUserCommand(String userName) { + // Sleeps 2 seconds when executed and writes no output + if (Shell.WINDOWS) { + return new String[]{"timeout", timeoutSecs.toString()}; + } + return new String[]{"sleep", timeoutSecs.toString()}; + } + + @Override + protected String[] getGroupsIDForUserCommand(String userName) { + return getGroupsForUserCommand(userName); + } + } + + @Test(timeout=4000) + public void testFiniteGroupResolutionTime() throws Exception { + Configuration conf = new Configuration(); + String userName = "foobarnonexistinguser"; + String commandTimeoutMessage = + "ran longer than the configured timeout limit"; + long testTimeout = 1L; + + // Test a 1 second max-runtime timeout + conf.setLong( + CommonConfigurationKeys. + HADOOP_SECURITY_GROUP_SHELL_COMMAND_TIMEOUT_SECS, + testTimeout); + + TestDelayedGroupCommand mapping = + ReflectionUtils.newInstance(TestDelayedGroupCommand.class, conf); + + ShellCommandExecutor executor = mapping.createGroupExecutor(userName); + assertEquals( + "Expected the group names executor to carry the configured timeout", + testTimeout, + executor.getTimeoutInterval()); + + executor = mapping.createGroupIDExecutor(userName); + assertEquals( + "Expected the group ID executor to carry the configured timeout", + testTimeout, + executor.getTimeoutInterval()); + + assertEquals( + "Expected no groups to be returned given a shell command timeout", + 0, + mapping.getGroups(userName).size()); + assertTrue( + "Expected the logs to carry " + + "a message about command timeout but was: " + + shellMappingLog.getOutput(), + shellMappingLog.getOutput().contains(commandTimeoutMessage)); + shellMappingLog.clearOutput(); + + // Test also the parent Groups framework for expected behaviour + conf.setClass(CommonConfigurationKeys.HADOOP_SECURITY_GROUP_MAPPING, + TestDelayedGroupCommand.class, + GroupMappingServiceProvider.class); + Groups groups = new Groups(conf); + try { + groups.getGroups(userName); + fail( + "The groups framework call should " + + "have failed with a command timeout"); + } catch (IOException e) { + assertTrue( + "Expected the logs to carry " + + "a message about command timeout but was: " + + shellMappingLog.getOutput(), + shellMappingLog.getOutput().contains(commandTimeoutMessage)); + } + shellMappingLog.clearOutput(); + + // Test the no-timeout (default) configuration + conf = new Configuration(); + long defaultTimeout = + CommonConfigurationKeys. + HADOOP_SECURITY_GROUP_SHELL_COMMAND_TIMEOUT_SECS_DEFAULT; + + mapping = + ReflectionUtils.newInstance(TestDelayedGroupCommand.class, conf); + + executor = mapping.createGroupExecutor(userName); + assertEquals( + "Expected the group names executor to carry the default timeout", + defaultTimeout, + executor.getTimeoutInterval()); + + executor = mapping.createGroupIDExecutor(userName); + assertEquals( + "Expected the group ID executor to carry the default timeout", + defaultTimeout, + executor.getTimeoutInterval()); + + mapping.getGroups(userName); + assertFalse( + "Didn't expect a timeout of command in execution but logs carry it: " + + shellMappingLog.getOutput(), + shellMappingLog.getOutput().contains(commandTimeoutMessage)); + } }