test: Always enable SSL for ES containers

Most of the test code assumed the test container would always be
unsecured (reachable via 'http://', no username/password, etc), but it's
possible (and the default in newer Elasticsearch versions) to configure
the container to run in a secure mode. Use the new support in
testcontainers to always enable SSL for ES so that Elasticsearch
versions will be tested similarly.

This change ensures the tests use any passwords and self-signed
certificates generated by the test container, as well as correctly using
'https://' URLs when needed.

This change uses the tests in testcontainers [1] as an example for how
to do this and ensures the docker-in-docker setup used by gerrit-ci is
supported.

[1] https://github.com/testcontainers/testcontainers-java/blob/main/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java

Change-Id: Ifca5948995ec881d8e941702fef611a3455e0cb7
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index 85764f1..9caa14d 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -155,8 +155,13 @@
       credentialsProvider.setCredentials(
           AuthScope.ANY, new UsernamePasswordCredentials(username, password));
       builder.setHttpClientConfigCallback(
-          (HttpAsyncClientBuilder httpClientBuilder) ->
-              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
+          (HttpAsyncClientBuilder httpClientBuilder) -> {
+            httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
+            configureHttpClientBuilder(httpClientBuilder);
+            return httpClientBuilder;
+          });
     }
   }
+
+  protected void configureHttpClientBuilder(HttpAsyncClientBuilder httpClientBuilder) {}
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
index 76f425c..b392f40 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -15,11 +15,15 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.common.flogger.FluentLogger;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Path;
 import org.apache.http.HttpHost;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testcontainers.containers.ContainerLaunchException;
 import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.images.builder.Transferable;
 import org.testcontainers.utility.DockerImageName;
 
 /* Helper class for running ES integration tests in docker container */
@@ -30,6 +34,66 @@
   public static ElasticContainer createAndStart(ElasticVersion version) {
     ElasticContainer container = new ElasticContainer(version);
     try {
+      String hostname = System.getenv("DOCKER_HOST");
+      if (hostname != null) {
+        try {
+          hostname = new URI(hostname).getHost();
+          logger.atInfo().log("Using hostname from DOCKER_HOST: %s", hostname);
+        } catch (URISyntaxException e) {
+          logger.atWarning().log(
+              "Failed to parse DOCKER_HOST environment variable value (%s). Continuing as if unset.",
+              hostname);
+        }
+      }
+      if (hostname == null) {
+        hostname = container.getHost();
+        logger.atInfo().log("Using hostname from container.getHost(): %s", hostname);
+      }
+      Path certs = Path.of("/usr/share/elasticsearch/config/certs");
+      String customizedCertPath = certs.resolve("http_ca_customized.crt").toString();
+      String sslKeyPath = certs.resolve("elasticsearch.key").toString();
+      String sslCrtPath = certs.resolve("elasticsearch.crt").toString();
+      container =
+          (ElasticContainer)
+              container
+                  .withPassword(ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD)
+                  .withEnv("xpack.security.enabled", "true")
+                  .withEnv("xpack.security.http.ssl.enabled", "true")
+                  .withEnv("xpack.security.http.ssl.key", sslKeyPath)
+                  .withEnv("xpack.security.http.ssl.certificate", sslCrtPath)
+                  .withEnv("xpack.security.http.ssl.certificate_authorities", customizedCertPath)
+                  // Create our own cert so that the gerrit-ci docker hostname
+                  // matches the certificate subject. Otherwise we get an error like:
+                  // Host name '10.0.1.1' does not match the certificate subject provided by the
+                  // peer (CN=4932da9bab1d)
+                  .withCopyToContainer(
+                      Transferable.of(
+                          "#!/bin/bash\n"
+                              + "mkdir -p "
+                              + certs.toString()
+                              + ";"
+                              + "openssl req -x509 -newkey rsa:4096 -keyout "
+                              + sslKeyPath
+                              + " -out "
+                              + sslCrtPath
+                              + " -days 365 -nodes -subj \"/CN="
+                              + hostname
+                              + "\";"
+                              + "openssl x509 -outform der -in "
+                              + sslCrtPath
+                              + " -out "
+                              + customizedCertPath
+                              + "; chown -R elasticsearch "
+                              + certs.toString(),
+                          555),
+                      "/usr/share/elasticsearch/generate-certs.sh")
+                  // because we need to generate the certificates before Elasticsearch starts, the
+                  // entry command has to be adjusted accordingly
+                  .withCommand(
+                      "sh",
+                      "-c",
+                      "/usr/share/elasticsearch/generate-certs.sh && /usr/local/bin/docker-entrypoint.sh")
+                  .withCertPath(customizedCertPath);
       container.start();
     } catch (ContainerLaunchException e) {
       logger.atSevere().log(
@@ -58,6 +122,7 @@
   }
 
   public HttpHost getHttpHost() {
-    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
+    String protocol = caCertAsBytes().isPresent() ? "https://" : "http://";
+    return HttpHost.create(protocol + getHttpHostAddress());
   }
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticContainerRestClientProvider.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainerRestClientProvider.java
new file mode 100644
index 0000000..5b8ad40
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainerRestClientProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed 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 com.google.gerrit.elasticsearch;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+
+@Singleton
+class ElasticContainerRestClientProvider extends ElasticRestClientProvider {
+  private final ElasticContainer container;
+
+  @Inject
+  ElasticContainerRestClientProvider(ElasticConfiguration cfg, ElasticContainer container) {
+    super(cfg);
+    this.container = container;
+  }
+
+  @Override
+  protected void configureHttpClientBuilder(HttpAsyncClientBuilder httpClientBuilder) {
+    if (container.caCertAsBytes().isPresent()) {
+      httpClientBuilder.setSSLContext(container.createSslContextFromCa());
+    }
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index 3c637e7..a280ba5 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -16,12 +16,14 @@
 
 import static com.google.common.truth.Truth.assertWithMessage;
 import static java.util.concurrent.TimeUnit.MINUTES;
+import static org.testcontainers.elasticsearch.ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD;
 
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
+import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -30,20 +32,31 @@
 import java.util.UUID;
 import org.apache.http.HttpResponse;
 import org.apache.http.HttpStatus;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.apache.http.impl.nio.client.HttpAsyncClients;
 import org.apache.http.util.EntityUtils;
 import org.eclipse.jgit.lib.Config;
 
 public final class ElasticTestUtils {
+  private static final String ELASTIC_USERNAME = "elastic";
+  private static final String ELASTIC_PASSWORD = ELASTICSEARCH_DEFAULT_PASSWORD;
+
   public static void configure(Config config, ElasticContainer container, String prefix) {
-    String hostname = container.getHttpHost().getHostName();
-    int port = container.getHttpHost().getPort();
     config.setString("index", null, "type", "elasticsearch");
-    config.setString("elasticsearch", null, "server", "http://" + hostname + ":" + port);
+    config.setString("elasticsearch", null, "server", container.getHttpHost().toURI());
     config.setString("elasticsearch", null, "prefix", prefix);
     config.setInt("index", null, "maxLimit", 10000);
+    if (container.caCertAsBytes().isPresent()) {
+      config.setString("elasticsearch", null, "username", ELASTIC_USERNAME);
+      config.setString("elasticsearch", null, "password", ELASTIC_PASSWORD);
+    }
   }
 
   public static void createAllIndexes(Injector injector) {
@@ -80,6 +93,20 @@
         "com.google.gerrit.elasticsearch.ElasticIndexModule");
   }
 
+  public static class ElasticContainerTestModule extends AbstractModule {
+    private final ElasticContainer container;
+
+    ElasticContainerTestModule(ElasticContainer container) {
+      this.container = container;
+    }
+
+    @Override
+    protected void configure() {
+      bind(ElasticRestClientProvider.class).to(ElasticContainerRestClientProvider.class);
+      bind(ElasticContainer.class).toInstance(container);
+    }
+  }
+
   public static Injector createInjector(
       Config config, GerritTestName testName, ElasticContainer container) {
     Config elasticsearchConfig = new Config(config);
@@ -87,7 +114,21 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
+    return Guice.createInjector(
+        new ElasticContainerTestModule(container), new InMemoryModule(elasticsearchConfig));
+  }
+
+  public static CloseableHttpAsyncClient createHttpAsyncClient(ElasticContainer container) {
+    HttpAsyncClientBuilder builder = HttpAsyncClients.custom();
+    if (container.caCertAsBytes().isPresent()) {
+      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+      credentialsProvider.setCredentials(
+          AuthScope.ANY, new UsernamePasswordCredentials(ELASTIC_USERNAME, ELASTIC_PASSWORD));
+      builder
+          .setSSLContext(container.createSslContextFromCa())
+          .setDefaultCredentialsProvider(credentialsProvider);
+    }
+    return builder.build();
   }
 
   public static void closeIndex(
@@ -98,10 +139,8 @@
             .execute(
                 new HttpPost(
                     String.format(
-                        "http://%s:%d/%s*/_close",
-                        container.getHttpHost().getHostName(),
-                        container.getHttpHost().getPort(),
-                        testName.getSanitizedMethodName())),
+                        "%s/%s*/_close",
+                        container.getHttpHost().toURI(), testName.getSanitizedMethodName())),
                 HttpClientContext.create(),
                 null)
             .get(5, MINUTES);
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 7f6a585..8eec618 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
@@ -47,7 +46,7 @@
   @BeforeClass
   public static void startIndexService() {
     container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
+    client = ElasticTestUtils.createHttpAsyncClient(container);
     client.start();
   }
 
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index 9160b9e..c6cbd99 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Injector;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -53,7 +52,7 @@
   @BeforeClass
   public static void startIndexService() {
     container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
+    client = ElasticTestUtils.createHttpAsyncClient(container);
     client.start();
   }
 
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 271ee19..20a88e9 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
@@ -48,7 +47,7 @@
   @BeforeClass
   public static void startIndexService() {
     container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
+    client = ElasticTestUtils.createHttpAsyncClient(container);
     client.start();
   }
 
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 38eef22..6d7ce80 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
@@ -48,7 +47,7 @@
   @BeforeClass
   public static void startIndexService() {
     container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
+    client = ElasticTestUtils.createHttpAsyncClient(container);
     client.start();
   }