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