001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.http.log;
019
020import static org.junit.Assert.assertEquals;
021import static org.junit.Assert.assertFalse;
022import static org.junit.Assert.assertNotEquals;
023import static org.junit.Assert.assertSame;
024import static org.junit.Assert.assertTrue;
025import static org.junit.Assert.fail;
026
027import java.io.File;
028import java.io.IOException;
029import java.net.BindException;
030import java.net.SocketException;
031import java.net.URI;
032import java.security.PrivilegedExceptionAction;
033import java.util.Properties;
034import java.util.regex.Matcher;
035import java.util.regex.Pattern;
036import javax.net.ssl.SSLException;
037import javax.servlet.http.HttpServletResponse;
038import org.apache.commons.io.FileUtils;
039import org.apache.hadoop.HadoopIllegalArgumentException;
040import org.apache.hadoop.conf.Configuration;
041import org.apache.hadoop.fs.CommonConfigurationKeys;
042import org.apache.hadoop.fs.CommonConfigurationKeysPublic;
043import org.apache.hadoop.fs.FileUtil;
044import org.apache.hadoop.hbase.HBaseClassTestRule;
045import org.apache.hadoop.hbase.HBaseCommonTestingUtil;
046import org.apache.hadoop.hbase.http.HttpConfig;
047import org.apache.hadoop.hbase.http.HttpServer;
048import org.apache.hadoop.hbase.http.log.LogLevel.CLI;
049import org.apache.hadoop.hbase.http.ssl.KeyStoreTestUtil;
050import org.apache.hadoop.hbase.logging.Log4jUtils;
051import org.apache.hadoop.hbase.testclassification.MiscTests;
052import org.apache.hadoop.hbase.testclassification.SmallTests;
053import org.apache.hadoop.hdfs.DFSConfigKeys;
054import org.apache.hadoop.minikdc.MiniKdc;
055import org.apache.hadoop.net.NetUtils;
056import org.apache.hadoop.security.UserGroupInformation;
057import org.apache.hadoop.security.authorize.AccessControlList;
058import org.apache.hadoop.security.ssl.SSLFactory;
059import org.apache.hadoop.test.GenericTestUtils;
060import org.apache.hadoop.util.StringUtils;
061import org.junit.AfterClass;
062import org.junit.BeforeClass;
063import org.junit.ClassRule;
064import org.junit.Test;
065import org.junit.experimental.categories.Category;
066
067/**
068 * Test LogLevel.
069 */
070@Category({ MiscTests.class, SmallTests.class })
071public class TestLogLevel {
072  @ClassRule
073  public static final HBaseClassTestRule CLASS_RULE =
074    HBaseClassTestRule.forClass(TestLogLevel.class);
075
076  private static String keystoresDir;
077  private static String sslConfDir;
078  private static Configuration serverConf;
079  private static Configuration clientConf;
080  private static Configuration sslConf;
081  private static final String logName = TestLogLevel.class.getName();
082  private static final String protectedPrefix = "protected";
083  private static final String protectedLogName = protectedPrefix + "." + logName;
084  private static final org.apache.logging.log4j.Logger log =
085    org.apache.logging.log4j.LogManager.getLogger(logName);
086  private final static String PRINCIPAL = "loglevel.principal";
087  private final static String KEYTAB = "loglevel.keytab";
088
089  private static MiniKdc kdc;
090
091  private static final String LOCALHOST = "localhost";
092  private static final String clientPrincipal = "client/" + LOCALHOST;
093  private static String HTTP_PRINCIPAL = "HTTP/" + LOCALHOST;
094  private static HBaseCommonTestingUtil HTU;
095  private static File keyTabFile;
096  private static final Pattern EFFECTIVE_LEVEL = Pattern.compile("Effective level:\\s*(\\S+)");
097
098  @FunctionalInterface
099  private interface ThrowingRunnable {
100    void run() throws Exception;
101  }
102
103  @FunctionalInterface
104  private interface ThrowingConsumer {
105    void accept(String url) throws Exception;
106  }
107
108  private enum Protocol {
109    C_HTTP_S_HTTP(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTP),
110    C_HTTP_S_HTTPS(LogLevel.PROTOCOL_HTTP, LogLevel.PROTOCOL_HTTPS),
111    C_HTTPS_S_HTTP(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTP),
112    C_HTTPS_S_HTTPS(LogLevel.PROTOCOL_HTTPS, LogLevel.PROTOCOL_HTTPS);
113
114    final String client;
115    final String server;
116
117    Protocol(String client, String server) {
118      this.client = client;
119      this.server = server;
120    }
121  }
122
123  @BeforeClass
124  public static void setUp() throws Exception {
125    serverConf = new Configuration();
126    serverConf.setStrings(LogLevel.READONLY_LOGGERS_CONF_KEY, protectedPrefix);
127    HTU = new HBaseCommonTestingUtil(serverConf);
128
129    File keystoreDir = new File(HTU.getDataTestDir("keystore").toString());
130    keystoreDir.mkdirs();
131    keyTabFile = new File(HTU.getDataTestDir("keytab").toString(), "keytabfile");
132    keyTabFile.getParentFile().mkdirs();
133    clientConf = new Configuration();
134
135    setupSSL(keystoreDir);
136
137    kdc = setupMiniKdc();
138    // Create two principles: a client and an HTTP principal
139    kdc.createPrincipal(keyTabFile, clientPrincipal, HTTP_PRINCIPAL);
140  }
141
142  /**
143   * Sets up {@link MiniKdc} for testing security. Copied from HBaseTestingUtility#setupMiniKdc().
144   */
145  static private MiniKdc setupMiniKdc() throws Exception {
146    Properties conf = MiniKdc.createConf();
147    conf.put(MiniKdc.DEBUG, true);
148    MiniKdc kdc = null;
149    File dir = null;
150    // There is time lag between selecting a port and trying to bind with it. It's possible that
151    // another service captures the port in between which'll result in BindException.
152    boolean bindException;
153    int numTries = 0;
154    do {
155      try {
156        bindException = false;
157        dir = new File(HTU.getDataTestDir("kdc").toUri().getPath());
158        kdc = new MiniKdc(conf, dir);
159        kdc.start();
160      } catch (BindException e) {
161        FileUtils.deleteDirectory(dir); // clean directory
162        numTries++;
163        if (numTries == 3) {
164          log.error("Failed setting up MiniKDC. Tried " + numTries + " times.");
165          throw e;
166        }
167        log.error("BindException encountered when setting up MiniKdc. Trying again.");
168        bindException = true;
169      }
170    } while (bindException);
171    return kdc;
172  }
173
174  static private void setupSSL(File base) throws Exception {
175    clientConf.set(DFSConfigKeys.DFS_HTTP_POLICY_KEY, HttpConfig.Policy.HTTPS_ONLY.name());
176    clientConf.set(DFSConfigKeys.DFS_NAMENODE_HTTPS_ADDRESS_KEY, "localhost:0");
177    clientConf.set(DFSConfigKeys.DFS_DATANODE_HTTPS_ADDRESS_KEY, "localhost:0");
178
179    keystoresDir = base.getAbsolutePath();
180    sslConfDir = KeyStoreTestUtil.getClasspathDir(TestLogLevel.class);
181    KeyStoreTestUtil.setupSSLConfig(keystoresDir, sslConfDir, serverConf, false);
182
183    sslConf = getSslConfig(serverConf);
184  }
185
186  /**
187   * Get the SSL configuration. This method is copied from KeyStoreTestUtil#getSslConfig() in
188   * Hadoop.
189   * @return {@link Configuration} instance with ssl configs loaded.
190   * @param conf to pull client/server SSL settings filename from
191   */
192  private static Configuration getSslConfig(Configuration conf) {
193    Configuration sslConf = new Configuration(false);
194    String sslServerConfFile = conf.get(SSLFactory.SSL_SERVER_CONF_KEY);
195    String sslClientConfFile = conf.get(SSLFactory.SSL_CLIENT_CONF_KEY);
196    sslConf.addResource(sslServerConfFile);
197    sslConf.addResource(sslClientConfFile);
198    sslConf.set(SSLFactory.SSL_SERVER_CONF_KEY, sslServerConfFile);
199    sslConf.set(SSLFactory.SSL_CLIENT_CONF_KEY, sslClientConfFile);
200    return sslConf;
201  }
202
203  @AfterClass
204  public static void tearDown() {
205    if (kdc != null) {
206      kdc.stop();
207    }
208
209    FileUtil.fullyDelete(new File(HTU.getDataTestDir().toString()));
210  }
211
212  /**
213   * Test client command line options. Does not validate server behavior.
214   * @throws Exception if commands return unexpected results.
215   */
216  @Test
217  public void testCommandOptions() throws Exception {
218    final String className = this.getClass().getName();
219
220    assertFalse(validateCommand(new String[] { "-foo" }));
221    // fail due to insufficient number of arguments
222    assertFalse(validateCommand(new String[] {}));
223    assertFalse(validateCommand(new String[] { "-getlevel" }));
224    assertFalse(validateCommand(new String[] { "-setlevel" }));
225    assertFalse(validateCommand(new String[] { "-getlevel", "foo.bar:8080" }));
226
227    // valid command arguments
228    assertTrue(validateCommand(new String[] { "-getlevel", "foo.bar:8080", className }));
229    assertTrue(validateCommand(new String[] { "-setlevel", "foo.bar:8080", className, "DEBUG" }));
230    assertTrue(validateCommand(new String[] { "-getlevel", "foo.bar:8080", className }));
231    assertTrue(validateCommand(new String[] { "-setlevel", "foo.bar:8080", className, "DEBUG" }));
232
233    // fail due to the extra argument
234    assertFalse(validateCommand(new String[] { "-getlevel", "foo.bar:8080", className, "blah" }));
235    assertFalse(
236      validateCommand(new String[] { "-setlevel", "foo.bar:8080", className, "DEBUG", "blah" }));
237    assertFalse(validateCommand(new String[] { "-getlevel", "foo.bar:8080", className, "-setlevel",
238      "foo.bar:8080", className }));
239  }
240
241  /**
242   * Check to see if a command can be accepted.
243   * @param args a String array of arguments
244   * @return true if the command can be accepted, false if not.
245   */
246  private boolean validateCommand(String[] args) {
247    CLI cli = new CLI(clientConf);
248    try {
249      cli.parseArguments(args);
250    } catch (HadoopIllegalArgumentException e) {
251      return false;
252    } catch (Exception e) {
253      // this is used to verify the command arguments only.
254      // no HadoopIllegalArgumentException = the arguments are good.
255      return true;
256    }
257    return true;
258  }
259
260  /**
261   * Creates and starts a Jetty server binding at an ephemeral port to run LogLevel servlet.
262   * @param protocol "http" or "https"
263   * @param isSpnego true if SPNEGO is enabled
264   * @return a created HttpServer object
265   * @throws Exception if unable to create or start a Jetty server
266   */
267  private HttpServer createServer(String protocol, boolean isSpnego) throws Exception {
268    // Changed to "" as ".." moves it a steps back in path because the path is relative to the
269    // current working directory. throws "java.lang.IllegalArgumentException: Base Resource is not
270    // valid: hbase-http/target/test-classes/static" as it is not able to find the static folder.
271    HttpServer.Builder builder = new HttpServer.Builder().setName("")
272      .addEndpoint(new URI(protocol + "://localhost:0")).setFindPort(true).setConf(serverConf);
273    if (isSpnego) {
274      // Set up server Kerberos credentials.
275      // Since the server may fall back to simple authentication,
276      // use ACL to make sure the connection is Kerberos/SPNEGO authenticated.
277      builder.setSecurityEnabled(true).setUsernameConfKey(PRINCIPAL).setKeytabConfKey(KEYTAB)
278        .setACL(new AccessControlList("client"));
279    }
280
281    // if using HTTPS, configure keystore/truststore properties.
282    if (protocol.equals(LogLevel.PROTOCOL_HTTPS)) {
283      builder = builder.keyPassword(sslConf.get("ssl.server.keystore.keypassword"))
284        .keyStore(sslConf.get("ssl.server.keystore.location"),
285          sslConf.get("ssl.server.keystore.password"),
286          sslConf.get("ssl.server.keystore.type", "jks"))
287        .trustStore(sslConf.get("ssl.server.truststore.location"),
288          sslConf.get("ssl.server.truststore.password"),
289          sslConf.get("ssl.server.truststore.type", "jks"));
290    }
291
292    HttpServer server = builder.build();
293    server.start();
294    return server;
295  }
296
297  private void testGetLogLevel(Protocol protocol, boolean isSpnego, String loggerName,
298    String expectedLevel) throws Exception {
299    withLogLevelServer(protocol, isSpnego, (authority) -> {
300      final String level = getLevel(protocol.client, authority, loggerName);
301      assertEquals("Log level not equal to expected: ", expectedLevel, level);
302    });
303  }
304
305  private void testSetLogLevel(Protocol protocol, boolean isSpnego, String loggerName,
306    String newLevel) throws Exception {
307    String oldLevel = Log4jUtils.getEffectiveLevel(loggerName);
308    assertNotEquals("New level is same as old level: ", newLevel, oldLevel);
309
310    try {
311      withLogLevelServer(protocol, isSpnego, (authority) -> {
312        setLevel(protocol.client, authority, loggerName, newLevel);
313      });
314    } finally {
315      // restore log level
316      Log4jUtils.setLogLevel(loggerName, oldLevel);
317    }
318  }
319
320  /**
321   * Starts a LogLevel server and executes a client action against it.
322   * @param protocol protocol configuration for client and server
323   * @param isSpnego whether SPNEGO authentication is enabled
324   * @param consumer client action executed with the server authority (host:port)
325   * @throws Exception if server setup or client execution fails
326   */
327  private void withLogLevelServer(Protocol protocol, final boolean isSpnego,
328    ThrowingConsumer consumer) throws Exception {
329    if (!LogLevel.isValidProtocol(protocol.server)) {
330      throw new Exception("Invalid server protocol " + protocol.server);
331    }
332    if (!LogLevel.isValidProtocol(protocol.client)) {
333      throw new Exception("Invalid client protocol " + protocol.client);
334    }
335
336    // configs needed for SPNEGO at server side
337    if (isSpnego) {
338      serverConf.set(PRINCIPAL, HTTP_PRINCIPAL);
339      serverConf.set(KEYTAB, keyTabFile.getAbsolutePath());
340      serverConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION, "kerberos");
341      serverConf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, true);
342      UserGroupInformation.setConfiguration(serverConf);
343    } else {
344      serverConf.set(CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION, "simple");
345      serverConf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, false);
346      UserGroupInformation.setConfiguration(serverConf);
347    }
348
349    final HttpServer server = createServer(protocol.server, isSpnego);
350    final String authority = NetUtils.getHostPortString(server.getConnectorAddress(0));
351    String keytabFilePath = keyTabFile.getAbsolutePath();
352
353    UserGroupInformation clientUGI =
354      UserGroupInformation.loginUserFromKeytabAndReturnUGI(clientPrincipal, keytabFilePath);
355    try {
356      clientUGI.doAs((PrivilegedExceptionAction<Void>) () -> {
357        consumer.accept(authority);
358        return null;
359      });
360    } finally {
361      clientUGI.logoutUserFromKeytab();
362      server.stop();
363    }
364  }
365
366  /**
367   * Run LogLevel command line to start a client to get log level of this test class.
368   * @param protocol  specify either http or https
369   * @param authority daemon's web UI address
370   * @throws Exception if unable to connect
371   */
372  private String getLevel(String protocol, String authority, String logName) throws Exception {
373    String[] getLevelArgs = { "-getlevel", authority, logName, "-protocol", protocol };
374    CLI cli = new CLI(protocol.equalsIgnoreCase("https") ? sslConf : clientConf);
375    cli.parseArguments(getLevelArgs);
376    final String response = cli.fetchGetLevelResponse();
377    return extractEffectiveLevel(response);
378  }
379
380  /**
381   * Run LogLevel command line to start a client to set log level of this test class to debug.
382   * @param protocol  specify either http or https
383   * @param authority daemon's web UI address
384   * @throws Exception if unable to run or log level does not change as expected
385   */
386  private void setLevel(String protocol, String authority, String logName, String newLevel)
387    throws Exception {
388    String[] setLevelArgs = { "-setlevel", authority, logName, newLevel, "-protocol", protocol };
389    CLI cli = new CLI(protocol.equalsIgnoreCase("https") ? sslConf : clientConf);
390    cli.parseArguments(setLevelArgs);
391    final String response = cli.fetchSetLevelResponse();
392    final String responseLevel = extractEffectiveLevel(response);
393    final String currentLevel = Log4jUtils.getEffectiveLevel(logName);
394    assertEquals("new level not equal to expected: ", newLevel, currentLevel);
395    assertSame("new level not equal to response level: ", newLevel, responseLevel);
396  }
397
398  /**
399   * Extract effective log level from server response.
400   * @param response server body response string
401   * @return the effective log level
402   */
403  private String extractEffectiveLevel(String response) {
404    Matcher m = EFFECTIVE_LEVEL.matcher(response);
405    if (m.find()) {
406      return org.apache.logging.log4j.Level.toLevel(m.group(1)).name();
407    }
408
409    fail("Cannot find effective log level from response: " + response);
410    return null;
411  }
412
413  @Test
414  public void testSettingProtectedLogLevel() throws Exception {
415    try {
416      testSetLogLevel(Protocol.C_HTTP_S_HTTP, true, protectedLogName, "DEBUG");
417      fail("Expected IO exception due to protected logger");
418    } catch (IOException e) {
419      assertTrue(e.getMessage().contains("" + HttpServletResponse.SC_PRECONDITION_FAILED));
420      assertTrue(e.getMessage().contains(
421        "Modification of logger " + protectedLogName + " is disallowed in configuration."));
422    }
423  }
424
425  @Test
426  public void testGetDebugLogLevel() throws Exception {
427    Log4jUtils.setLogLevel(logName, "DEBUG");
428    testGetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "DEBUG");
429  }
430
431  @Test
432  public void testGetInfoLogLevel() throws Exception {
433    Log4jUtils.setLogLevel(logName, "INFO");
434    testGetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "INFO");
435  }
436
437  /**
438   * Test setting log level to "Info".
439   * @throws Exception if client can't set log level to INFO.
440   */
441  @Test
442  public void testSetInfoLogLevel() throws Exception {
443    Log4jUtils.setLogLevel(logName, "DEBUG");
444    testSetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "INFO");
445  }
446
447  /**
448   * Test setting log level to "Error".
449   * @throws Exception if client can't set log level to ERROR.
450   */
451  @Test
452  public void testSetErrorLogLevel() throws Exception {
453    Log4jUtils.setLogLevel(logName, "DEBUG");
454    testSetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "ERROR");
455  }
456
457  /**
458   * Server runs HTTP, no SPNEGO.
459   * @throws Exception if http client can't access http server, or http client can access https
460   *                   server.
461   */
462  @Test
463  public void testLogLevelByHttp() throws Exception {
464    Log4jUtils.setLogLevel(logName, "DEBUG");
465    testGetLogLevel(Protocol.C_HTTP_S_HTTP, false, logName, "DEBUG");
466    try {
467      testGetLogLevel(Protocol.C_HTTPS_S_HTTP, false, logName, "DEBUG");
468      fail("An HTTPS Client should not have succeeded in connecting to a HTTP server");
469    } catch (SSLException e) {
470      exceptionShouldContains("Unrecognized SSL message", e);
471    }
472  }
473
474  /**
475   * Server runs HTTP + SPNEGO.
476   * @throws Exception if http client can't access http server, or http client can access https
477   *                   server.
478   */
479  @Test
480  public void testLogLevelByHttpWithSpnego() throws Exception {
481    Log4jUtils.setLogLevel(logName, "DEBUG");
482    testGetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "DEBUG");
483    try {
484      testGetLogLevel(Protocol.C_HTTPS_S_HTTP, true, logName, "DEBUG");
485      fail("An HTTPS Client should not have succeeded in connecting to a HTTP server");
486    } catch (SSLException e) {
487      exceptionShouldContains("Unrecognized SSL message", e);
488    }
489  }
490
491  /**
492   * Server runs HTTPS, no SPNEGO.
493   * @throws Exception if https client can't access https server, or https client can access http
494   *                   server.
495   */
496  @Test
497  public void testLogLevelByHttps() throws Exception {
498    Log4jUtils.setLogLevel(logName, "DEBUG");
499    testGetLogLevel(Protocol.C_HTTPS_S_HTTPS, false, logName, "DEBUG");
500    try {
501      testGetLogLevel(Protocol.C_HTTP_S_HTTPS, false, logName, "DEBUG");
502      fail("An HTTP Client should not have succeeded in connecting to a HTTPS server");
503    } catch (SocketException e) {
504      exceptionShouldContains("Unexpected end of file from server", e);
505    }
506  }
507
508  /**
509   * Server runs HTTPS + SPNEGO.
510   * @throws Exception if https client can't access https server, or https client can access http
511   *                   server.
512   */
513  @Test
514  public void testLogLevelByHttpsWithSpnego() throws Exception {
515    Log4jUtils.setLogLevel(logName, "DEBUG");
516    testGetLogLevel(Protocol.C_HTTPS_S_HTTPS, true, logName, "DEBUG");
517    try {
518      testGetLogLevel(Protocol.C_HTTP_S_HTTPS, true, logName, "DEBUG");
519      fail("An HTTP Client should not have succeeded in connecting to a HTTPS server");
520    } catch (SocketException e) {
521      exceptionShouldContains("Unexpected end of file from server", e);
522    }
523  }
524
525  /**
526   * Test getting log level in readonly mode.
527   * @throws Exception if a client can't get log level.
528   */
529  @Test
530  public void testGetLogLevelAllowedInReadonlyMode() throws Exception {
531    withMasterUIReadonly(() -> {
532      Log4jUtils.setLogLevel(logName, "DEBUG");
533      testGetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "DEBUG");
534    });
535  }
536
537  /**
538   * Test setting log level in readonly mode.
539   * @throws Exception if a client can set log level.
540   */
541  @Test
542  public void testSetLogLevelDisallowedInReadonlyMode() throws Exception {
543    withMasterUIReadonly(() -> {
544      Log4jUtils.setLogLevel(logName, "DEBUG");
545      try {
546        testSetLogLevel(Protocol.C_HTTP_S_HTTP, true, logName, "INFO");
547        fail("Setting log level should be disallowed in readonly mode.");
548      } catch (IOException e) {
549        exceptionShouldContains("Modification of HBase via the UI is disallowed in configuration.",
550          e);
551      }
552    });
553  }
554
555  private void withMasterUIReadonly(ThrowingRunnable runnable) throws Exception {
556    boolean prev = serverConf.getBoolean(LogLevel.MASTER_UI_READONLY_CONF_KEY, false);
557    serverConf.setBoolean(LogLevel.MASTER_UI_READONLY_CONF_KEY, true);
558    try {
559      runnable.run();
560    } finally {
561      serverConf.setBoolean(LogLevel.MASTER_UI_READONLY_CONF_KEY, prev);
562    }
563  }
564
565  /**
566   * Assert that a throwable or one of its causes should contain the substr in its message. Ideally
567   * we should use {@link GenericTestUtils#assertExceptionContains(String, Throwable)} util method
568   * which asserts t.toString() contains the substr. As the original throwable may have been wrapped
569   * in Hadoop3 because of HADOOP-12897, it's required to check all the wrapped causes. After stop
570   * supporting Hadoop2, this method can be removed and assertion in tests can use t.getCause()
571   * directly, similar to HADOOP-15280.
572   */
573  private static void exceptionShouldContains(String substr, Throwable throwable) {
574    Throwable t = throwable;
575    while (t != null) {
576      String msg = t.toString();
577      if (msg != null && msg.toLowerCase().contains(substr.toLowerCase())) {
578        return;
579      }
580      t = t.getCause();
581    }
582    throw new AssertionError("Expected to find '" + substr + "' but got unexpected exception:"
583      + StringUtils.stringifyException(throwable), throwable);
584  }
585}