]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14583 add elasticsearch-keystore CLI utility class
authorJacek <jacek.poreda@sonarsource.com>
Thu, 15 Apr 2021 14:23:43 +0000 (16:23 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 23 Apr 2021 20:03:29 +0000 (20:03 +0000)
server/sonar-main/src/main/java/org/sonar/application/ProcessLauncherImpl.java
server/sonar-main/src/main/java/org/sonar/application/command/JavaCommand.java
server/sonar-main/src/main/java/org/sonar/application/es/EsInstallation.java
server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java [new file with mode: 0644]
server/sonar-main/src/test/java/org/sonar/application/ProcessLauncherImplTest.java
server/sonar-main/src/test/java/org/sonar/application/es/EsKeyStoreCliTest.java [new file with mode: 0644]

index 3db72c9a35724952f9409ee608875d43e43beb07..657d3a693e8d68dfbcd38f796420bb032ed3800d 100644 (file)
@@ -24,10 +24,15 @@ import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Properties;
 import java.util.function.Supplier;
 import org.slf4j.Logger;
@@ -38,6 +43,7 @@ import org.sonar.application.command.JavaCommand;
 import org.sonar.application.command.JvmOptions;
 import org.sonar.application.es.EsConnectorImpl;
 import org.sonar.application.es.EsInstallation;
+import org.sonar.application.es.EsKeyStoreCli;
 import org.sonar.application.process.EsManagedProcess;
 import org.sonar.application.process.ManagedProcess;
 import org.sonar.application.process.ProcessCommandsManagedProcess;
@@ -50,6 +56,9 @@ import static com.google.common.base.Preconditions.checkArgument;
 import static java.lang.String.format;
 import static java.util.Collections.singleton;
 import static java.util.Objects.requireNonNull;
+import static org.sonar.application.es.EsKeyStoreCli.BOOTSTRAP_PASSWORD_PROPERTY_KEY;
+import static org.sonar.application.es.EsKeyStoreCli.KEYSTORE_PASSWORD_PROPERTY_KEY;
+import static org.sonar.application.es.EsKeyStoreCli.TRUSTSTORE_PASSWORD_PROPERTY_KEY;
 import static org.sonar.process.ProcessEntryPoint.PROPERTY_GRACEFUL_STOP_TIMEOUT_MS;
 import static org.sonar.process.ProcessEntryPoint.PROPERTY_PROCESS_INDEX;
 import static org.sonar.process.ProcessEntryPoint.PROPERTY_PROCESS_KEY;
@@ -97,7 +106,8 @@ public class ProcessLauncherImpl implements ProcessLauncher {
     try {
       if (processId == ProcessId.ELASTICSEARCH) {
         checkArgument(esInstallation != null, "Incorrect configuration EsInstallation is null");
-        EsConnectorImpl esConnector = new EsConnectorImpl(singleton(HostAndPort.fromParts(esInstallation.getHost(), esInstallation.getHttpPort())));
+        EsConnectorImpl esConnector = new EsConnectorImpl(singleton(HostAndPort.fromParts(esInstallation.getHost(),
+          esInstallation.getHttpPort())));
         return new EsManagedProcess(process, processId, esConnector);
       } else {
         ProcessCommands commands = allProcessesCommands.createAfterClean(processId.getIpcIndex());
@@ -140,16 +150,66 @@ public class ProcessLauncherImpl implements ProcessLauncher {
       });
   }
 
-  private static void writeConfFiles(EsInstallation esInstallation) {
+  private void writeConfFiles(EsInstallation esInstallation) {
     File confDir = esInstallation.getConfDirectory();
-    if (!confDir.exists() && !confDir.mkdirs()) {
+
+    pruneElasticsearchConfDirectory(confDir);
+    createElasticsearchConfDirectory(confDir);
+    setupElasticsearchAuthentication(esInstallation);
+
+    esInstallation.getEsYmlSettings().writeToYmlSettingsFile(esInstallation.getElasticsearchYml());
+    esInstallation.getEsJvmOptions().writeToJvmOptionFile(esInstallation.getJvmOptions());
+    storeElasticsearchLog4j2Properties(esInstallation);
+  }
+
+  private static void pruneElasticsearchConfDirectory(File confDir) {
+    try {
+      Files.deleteIfExists(confDir.toPath());
+    } catch (IOException e) {
+      throw new IllegalStateException("Could not delete Elasticsearch temporary conf directory", e);
+    }
+  }
+
+  private static void createElasticsearchConfDirectory(File confDir) {
+    if (!confDir.mkdirs()) {
       String error = format("Failed to create temporary configuration directory [%s]", confDir.getAbsolutePath());
       LOG.error(error);
       throw new IllegalStateException(error);
     }
+  }
 
-    esInstallation.getEsYmlSettings().writeToYmlSettingsFile(esInstallation.getElasticsearchYml());
-    esInstallation.getEsJvmOptions().writeToJvmOptionFile(esInstallation.getJvmOptions());
+  private void setupElasticsearchAuthentication(EsInstallation esInstallation) {
+    if (esInstallation.isSecurityEnabled()) {
+      EsKeyStoreCli keyStoreCli = EsKeyStoreCli.getInstance(esInstallation)
+        .store(BOOTSTRAP_PASSWORD_PROPERTY_KEY, esInstallation.getBootstrapPassword());
+
+      String esConfPath = esInstallation.getConfDirectory().getAbsolutePath();
+
+      Path trustStoreLocation = esInstallation.getTrustStoreLocation();
+      Path keyStoreLocation = esInstallation.getKeyStoreLocation();
+      if (trustStoreLocation.equals(keyStoreLocation)) {
+        copyFile(trustStoreLocation, Paths.get(esConfPath, trustStoreLocation.toFile().getName()));
+      } else {
+        copyFile(trustStoreLocation, Paths.get(esConfPath, trustStoreLocation.toFile().getName()));
+        copyFile(keyStoreLocation, Paths.get(esConfPath, keyStoreLocation.toFile().getName()));
+      }
+
+      esInstallation.getTrustStorePassword().ifPresent(s -> keyStoreCli.store(TRUSTSTORE_PASSWORD_PROPERTY_KEY, s));
+      esInstallation.getKeyStorePassword().ifPresent(s -> keyStoreCli.store(KEYSTORE_PASSWORD_PROPERTY_KEY, s));
+
+      keyStoreCli.executeWith(this::launchJava);
+    }
+  }
+
+  private static void copyFile(Path from, Path to) {
+    try {
+      Files.copy(from, to, StandardCopyOption.REPLACE_EXISTING);
+    } catch (IOException e) {
+      throw new IllegalStateException("Could not copy file: " + from.toString(), e);
+    }
+  }
+
+  private static void storeElasticsearchLog4j2Properties(EsInstallation esInstallation) {
     try (FileOutputStream fileOutputStream = new FileOutputStream(esInstallation.getLog4j2PropertiesLocation())) {
       esInstallation.getLog4j2Properties().store(fileOutputStream, "log4j2 properties file for ES bundled in SonarQube");
     } catch (IOException e) {
@@ -193,6 +253,8 @@ public class ProcessLauncherImpl implements ProcessLauncher {
     commands.addAll(javaCommand.getJvmOptions().getAll());
     commands.addAll(buildClasspath(javaCommand));
     commands.add(javaCommand.getClassName());
+    commands.addAll(javaCommand.getParameters());
+
     if (javaCommand.getReadsArgumentsFromFile()) {
       commands.add(buildPropertiesFile(javaCommand).getAbsolutePath());
     } else {
index b72d7bfc53ed9a0b92f5f7bd515191d14c559922..5f0eb48b16da1d5ff5830ac5e9d04c3154f14415 100644 (file)
@@ -22,6 +22,7 @@ package org.sonar.application.command;
 import java.io.File;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Properties;
@@ -34,6 +35,7 @@ import static java.util.Objects.requireNonNull;
 public class JavaCommand<T extends JvmOptions> extends AbstractCommand<JavaCommand<T>> {
   // program arguments
   private final Map<String, String> arguments = new LinkedHashMap<>();
+  private final List<String> parameters = new LinkedList<>();
   // entry point
   private String className;
   private JvmOptions<T> jvmOptions;
@@ -59,6 +61,19 @@ public class JavaCommand<T extends JvmOptions> extends AbstractCommand<JavaComma
     return this;
   }
 
+  public List<String> getParameters() {
+    return parameters;
+  }
+
+  public JavaCommand<T> addParameter(@Nullable String parameter) {
+    if (parameter == null) {
+      parameters.remove(parameter);
+    } else {
+      parameters.add(parameter);
+    }
+    return this;
+  }
+
   public String getClassName() {
     return className;
   }
index 377765500a385563a744d2214be2cb7528511da6..8afcf3e39234dced53124d8ec03c0300d092de50 100644 (file)
 package org.sonar.application.es;
 
 import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.Properties;
 import java.util.stream.Stream;
+import javax.annotation.Nullable;
+import org.apache.commons.lang.StringUtils;
 import org.sonar.application.command.EsJvmOptions;
 import org.sonar.core.util.stream.MoreCollectors;
 import org.sonar.process.Props;
 
+import static org.sonar.process.ProcessProperties.Property.CLUSTER_ENABLED;
+import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_KEYSTORE;
+import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_KEYSTORE_PASSWORD;
+import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_TRUSTSTORE;
+import static org.sonar.process.ProcessProperties.Property.CLUSTER_ES_TRUSTSTORE_PASSWORD;
+import static org.sonar.process.ProcessProperties.Property.CLUSTER_SEARCH_PASSWORD;
 import static org.sonar.process.ProcessProperties.Property.PATH_DATA;
 import static org.sonar.process.ProcessProperties.Property.PATH_HOME;
 import static org.sonar.process.ProcessProperties.Property.PATH_LOGS;
@@ -53,6 +64,16 @@ public class EsInstallation {
   private String host;
   private int httpPort;
 
+  // ES authentication settings
+  private final boolean securityEnabled;
+  private final String bootstrapPassword;
+  private final Path keyStoreLocation;
+  private final Path trustStoreLocation;
+  @Nullable
+  private final String keyStorePassword;
+  @Nullable
+  private final String trustStorePassword;
+
   public EsInstallation(Props props) {
     File sqHomeDir = props.nonNullValueAsFile(PATH_HOME.getKey());
 
@@ -61,6 +82,17 @@ public class EsInstallation {
     this.dataDirectory = buildDataDir(props);
     this.confDirectory = buildConfDir(props);
     this.logDirectory = buildLogPath(props);
+
+    this.bootstrapPassword = props.value(CLUSTER_SEARCH_PASSWORD.getKey());
+    this.securityEnabled = props.valueAsBoolean(CLUSTER_ENABLED.getKey()) && StringUtils.isNotBlank(bootstrapPassword);
+    this.keyStoreLocation = getPath(props.value(CLUSTER_ES_KEYSTORE.getKey()));
+    this.keyStorePassword = props.value(CLUSTER_ES_KEYSTORE_PASSWORD.getKey());
+    this.trustStoreLocation = getPath(props.value(CLUSTER_ES_TRUSTSTORE.getKey()));
+    this.trustStorePassword = props.value(CLUSTER_ES_TRUSTSTORE_PASSWORD.getKey());
+  }
+
+  private static Path getPath(@Nullable String path) {
+    return Optional.ofNullable(path).map(Paths::get).orElse(null);
   }
 
   private static List<File> buildOutdatedSearchDirs(Props props) {
@@ -168,4 +200,28 @@ public class EsInstallation {
     this.httpPort = httpPort;
     return this;
   }
+
+  public boolean isSecurityEnabled() {
+    return securityEnabled;
+  }
+
+  public String getBootstrapPassword() {
+    return bootstrapPassword;
+  }
+
+  public Path getKeyStoreLocation() {
+    return keyStoreLocation;
+  }
+
+  public Path getTrustStoreLocation() {
+    return trustStoreLocation;
+  }
+
+  public Optional<String> getKeyStorePassword() {
+    return Optional.ofNullable(keyStorePassword);
+  }
+
+  public Optional<String> getTrustStorePassword() {
+    return Optional.ofNullable(trustStorePassword);
+  }
 }
diff --git a/server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java b/server/sonar-main/src/main/java/org/sonar/application/es/EsKeyStoreCli.java
new file mode 100644 (file)
index 0000000..76816f6
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.application.es;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import org.sonar.application.command.JavaCommand;
+import org.sonar.application.command.JvmOptions;
+import org.sonar.process.ProcessId;
+
+import static java.util.Objects.requireNonNull;
+
+public class EsKeyStoreCli {
+  public static final String BOOTSTRAP_PASSWORD_PROPERTY_KEY = "bootstrap.password";
+  public static final String KEYSTORE_PASSWORD_PROPERTY_KEY = "xpack.security.transport.ssl.keystore.secure_password";
+  public static final String TRUSTSTORE_PASSWORD_PROPERTY_KEY = "xpack.security.transport.ssl.truststore.secure_password";
+
+  private static final String MAIN_CLASS_NAME = "org.elasticsearch.common.settings.KeyStoreCli";
+
+  private final Map<String, String> properties = new LinkedHashMap<>();
+  private final JavaCommand<EsKeyStoreJvmOptions> command;
+
+  private EsKeyStoreCli(EsInstallation esInstallation) {
+    String esHomeAbsolutePath = esInstallation.getHomeDirectory().getAbsolutePath();
+    command = new JavaCommand<EsKeyStoreJvmOptions>(ProcessId.ELASTICSEARCH, esInstallation.getConfDirectory())
+      .setClassName(MAIN_CLASS_NAME)
+      .setJvmOptions(new EsKeyStoreJvmOptions(esInstallation))
+      .addClasspath(Paths.get(esHomeAbsolutePath, "lib").toAbsolutePath() + "/*")
+      .addClasspath(Paths.get(esHomeAbsolutePath, "lib", "tools", "keystore-cli")
+        .toAbsolutePath() + "/*")
+      .addParameter("add")
+      .addParameter("-x")
+      .addParameter("-f");
+  }
+
+  public static EsKeyStoreCli getInstance(EsInstallation esInstallation) {
+    return new EsKeyStoreCli(esInstallation);
+  }
+
+  public EsKeyStoreCli store(String key, String value) {
+    requireNonNull(key, "Property key cannot be null");
+    requireNonNull(value, "Property value cannot be null");
+    properties.computeIfAbsent(key, s -> {
+      command.addParameter(key);
+      return value;
+    });
+    return this;
+  }
+
+  public Process executeWith(Function<JavaCommand<?>, Process> commandLauncher) {
+    Process process = commandLauncher.apply(command);
+    writeValues(process);
+    waitFor(process);
+    checkExitValue(process.exitValue());
+    return process;
+  }
+
+  private void writeValues(Process process) {
+    try (OutputStream stdin = process.getOutputStream();
+      BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(stdin, StandardCharsets.UTF_8))) {
+      for (Entry<String, String> entry : properties.entrySet()) {
+        writer.write(entry.getValue());
+        writer.write("\n");
+      }
+      writer.flush();
+
+    } catch (IOException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private static void waitFor(Process process) {
+    try {
+      process.waitFor(1, TimeUnit.MINUTES);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException("EsKeyStoreCli has been interrupted", e);
+    }
+  }
+
+  private static void checkExitValue(int code) {
+    if (code != 0) {
+      throw new IllegalStateException("Elasticsearch KeyStore tool exited with code: " + code);
+    }
+  }
+
+  public static class EsKeyStoreJvmOptions extends JvmOptions<EsKeyStoreJvmOptions> {
+
+    public EsKeyStoreJvmOptions(EsInstallation esInstallation) {
+      super(mandatoryOptions(esInstallation));
+    }
+
+    private static Map<String, String> mandatoryOptions(EsInstallation esInstallation) {
+      Map<String, String> res = new LinkedHashMap<>(7);
+      res.put("-Xshare:auto", "");
+      res.put("-Xms4m", "");
+      res.put("-Xmx64m", "");
+      res.put("-Des.path.home=", esInstallation.getHomeDirectory().getAbsolutePath());
+      res.put("-Des.path.conf=", esInstallation.getConfDirectory().getAbsolutePath());
+      res.put("-Des.distribution=", "default");
+      res.put("-Des.distribution.type=", "tar");
+      return res;
+    }
+  }
+}
index f97b1a562f16db4177e72708680ca9534af3b676..131626c796b6e9fe3227b98546c2a1334d3f5bbe 100644 (file)
@@ -22,6 +22,7 @@ package org.sonar.application;
 import java.io.File;
 import java.io.FileReader;
 import java.io.IOException;
+import java.nio.file.Paths;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -29,7 +30,6 @@ import java.util.Properties;
 import java.util.Random;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 import org.junit.rules.TemporaryFolder;
 import org.sonar.application.command.EsJvmOptions;
 import org.sonar.application.command.EsScriptCommand;
@@ -43,6 +43,7 @@ import org.sonar.process.Props;
 import org.sonar.process.sharedmemoryfile.AllProcessesCommands;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.data.MapEntry.entry;
 import static org.mockito.Mockito.RETURNS_MOCKS;
 import static org.mockito.Mockito.mock;
@@ -51,11 +52,9 @@ import static org.mockito.Mockito.when;
 public class ProcessLauncherImplTest {
 
   @Rule
-  public TemporaryFolder temp = new TemporaryFolder();
-  @Rule
-  public ExpectedException expectedException = ExpectedException.none();
+  public final TemporaryFolder temp = new TemporaryFolder();
 
-  private AllProcessesCommands commands = mock(AllProcessesCommands.class, RETURNS_MOCKS);
+  private final AllProcessesCommands commands = mock(AllProcessesCommands.class, RETURNS_MOCKS);
 
   @Test
   public void launch_forks_a_new_process() throws Exception {
@@ -91,6 +90,99 @@ public class ProcessLauncherImplTest {
       .containsAllEntriesOf(command.getEnvVariables());
   }
 
+  @Test
+  public void enabling_es_security_should_execute_keystore_cli_if_cert_password_provided() throws Exception {
+    File tempDir = temp.newFolder();
+    File certificateFile = temp.newFile("certificate.pk12");
+    TestProcessBuilder processBuilder = new TestProcessBuilder();
+    ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> processBuilder);
+
+    EsInstallation esInstallation = createEsInstallation(new Props(new Properties())
+      .set("sonar.cluster.enabled", "true")
+      .set("sonar.cluster.search.password", "bootstrap-password")
+      .set("sonar.cluster.es.ssl.keystore", certificateFile.getAbsolutePath())
+      .set("sonar.cluster.es.ssl.keystorePassword", "keystore-password")
+      .set("sonar.cluster.es.ssl.truststore", certificateFile.getAbsolutePath())
+      .set("sonar.cluster.es.ssl.truststorePassword", "truststore-password"));
+
+    JavaCommand<JvmOptions> command = new JavaCommand<>(ProcessId.ELASTICSEARCH, temp.newFolder());
+    command.addClasspath("lib/*.class");
+    command.addClasspath("lib/*.jar");
+    command.setArgument("foo", "bar");
+    command.setClassName("org.sonarqube.Main");
+    command.setEnvVariable("VAR1", "valueOfVar1");
+    command.setJvmOptions(new JvmOptions<>()
+      .add("-Dfoo=bar")
+      .add("-Dfoo2=bar2"));
+    command.setEsInstallation(esInstallation);
+
+    ManagedProcess monitor = underTest.launch(command);
+    assertThat(monitor).isNotNull();
+    assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "certificate.pk12")).exists();
+  }
+
+  @Test
+  public void enabling_es_security_should_execute_keystore_cli_if_no_cert_password_provided() throws Exception {
+    File tempDir = temp.newFolder();
+    File certificateFile = temp.newFile("certificate.pk12");
+    TestProcessBuilder processBuilder = new TestProcessBuilder();
+    ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> processBuilder);
+
+    EsInstallation esInstallation = createEsInstallation(new Props(new Properties())
+      .set("sonar.cluster.enabled", "true")
+      .set("sonar.cluster.search.password", "bootstrap-password")
+      .set("sonar.cluster.es.ssl.keystore", certificateFile.getAbsolutePath())
+      .set("sonar.cluster.es.ssl.truststore", certificateFile.getAbsolutePath()));
+
+    JavaCommand<JvmOptions> command = new JavaCommand<>(ProcessId.ELASTICSEARCH, temp.newFolder());
+    command.addClasspath("lib/*.class");
+    command.addClasspath("lib/*.jar");
+    command.setArgument("foo", "bar");
+    command.setClassName("org.sonarqube.Main");
+    command.setEnvVariable("VAR1", "valueOfVar1");
+    command.setJvmOptions(new JvmOptions<>()
+      .add("-Dfoo=bar")
+      .add("-Dfoo2=bar2"));
+    command.setEsInstallation(esInstallation);
+
+    ManagedProcess monitor = underTest.launch(command);
+    assertThat(monitor).isNotNull();
+    assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "certificate.pk12")).exists();
+  }
+
+  @Test
+  public void enabling_es_security_should_execute_keystore_cli_if_truststore_and_keystore_provided() throws Exception {
+    File tempDir = temp.newFolder();
+    File truststoreFile = temp.newFile("truststore.pk12");
+    File keystoreFile = temp.newFile("keystore.pk12");
+    TestProcessBuilder processBuilder = new TestProcessBuilder();
+    ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> processBuilder);
+
+    EsInstallation esInstallation = createEsInstallation(new Props(new Properties())
+      .set("sonar.cluster.enabled", "true")
+      .set("sonar.cluster.search.password", "bootstrap-password")
+      .set("sonar.cluster.es.ssl.keystore", keystoreFile.getAbsolutePath())
+      .set("sonar.cluster.es.ssl.keystorePassword", "keystore-password")
+      .set("sonar.cluster.es.ssl.truststore", truststoreFile.getAbsolutePath())
+      .set("sonar.cluster.es.ssl.truststorePassword", "truststore-password"));
+
+    JavaCommand<JvmOptions> command = new JavaCommand<>(ProcessId.ELASTICSEARCH, temp.newFolder());
+    command.addClasspath("lib/*.class");
+    command.addClasspath("lib/*.jar");
+    command.setArgument("foo", "bar");
+    command.setClassName("org.sonarqube.Main");
+    command.setEnvVariable("VAR1", "valueOfVar1");
+    command.setJvmOptions(new JvmOptions<>()
+      .add("-Dfoo=bar")
+      .add("-Dfoo2=bar2"));
+    command.setEsInstallation(esInstallation);
+
+    ManagedProcess monitor = underTest.launch(command);
+    assertThat(monitor).isNotNull();
+    assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "truststore.pk12")).exists();
+    assertThat(Paths.get(esInstallation.getConfDirectory().getAbsolutePath(), "keystore.pk12")).exists();
+  }
+
   @Test
   public void properties_are_passed_to_command_via_a_temporary_properties_file() throws Exception {
     File tempDir = temp.newFolder();
@@ -127,7 +219,7 @@ public class ProcessLauncherImplTest {
     File tempDir = temp.newFolder();
     TestProcessBuilder processBuilder = new TestProcessBuilder();
     ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> processBuilder);
-    JavaCommand<JvmOptions> command = new JavaCommand<>(ProcessId.WEB_SERVER, temp.newFolder());
+    JavaCommand<JvmOptions<?>> command = new JavaCommand<>(ProcessId.WEB_SERVER, temp.newFolder());
     command.setReadsArgumentsFromFile(false);
     command.setArgument("foo", "bar");
     command.setArgument("baz", "woo");
@@ -146,16 +238,16 @@ public class ProcessLauncherImplTest {
     File homeDir = temp.newFolder();
     File dataDir = temp.newFolder();
     File logDir = temp.newFolder();
-    ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> new TestProcessBuilder());
+    ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, TestProcessBuilder::new);
     EsScriptCommand command = createEsScriptCommand(tempDir, homeDir, dataDir, logDir);
 
     File outdatedEsDir = new File(dataDir, "es");
     assertThat(outdatedEsDir.mkdir()).isTrue();
-    assertThat(outdatedEsDir.exists()).isTrue();
+    assertThat(outdatedEsDir).exists();
 
     underTest.launch(command);
 
-    assertThat(outdatedEsDir.exists()).isFalse();
+    assertThat(outdatedEsDir).doesNotExist();
   }
 
   @Test
@@ -168,11 +260,11 @@ public class ProcessLauncherImplTest {
     EsScriptCommand command = createEsScriptCommand(tempDir, homeDir, dataDir, logDir);
 
     File outdatedEsDir = new File(dataDir, "es");
-    assertThat(outdatedEsDir.exists()).isFalse();
+    assertThat(outdatedEsDir).doesNotExist();
 
     underTest.launch(command);
 
-    assertThat(outdatedEsDir.exists()).isFalse();
+    assertThat(outdatedEsDir).doesNotExist();
   }
 
   @Test
@@ -182,10 +274,10 @@ public class ProcessLauncherImplTest {
     when(processBuilder.start()).thenThrow(new IOException("error"));
     ProcessLauncher underTest = new ProcessLauncherImpl(tempDir, commands, () -> processBuilder);
 
-    expectedException.expect(IllegalStateException.class);
-    expectedException.expectMessage("Fail to launch process [es]");
-
-    underTest.launch(new JavaCommand(ProcessId.ELASTICSEARCH, temp.newFolder()));
+    JavaCommand<?> javaCommand = new JavaCommand<>(ProcessId.ELASTICSEARCH, temp.newFolder());
+    assertThatThrownBy(() -> underTest.launch(javaCommand))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Fail to launch process [es]");
   }
 
   private EsScriptCommand createEsScriptCommand(File tempDir, File homeDir, File dataDir, File logDir) throws IOException {
@@ -204,6 +296,20 @@ public class ProcessLauncherImplTest {
     return command;
   }
 
+  private EsInstallation createEsInstallation(Props props) throws IOException {
+    File tempFolder = this.temp.newFolder("temp");
+    return new EsInstallation(props
+      .set("sonar.path.home", this.temp.newFolder("home").getAbsolutePath())
+      .set("sonar.path.data", this.temp.newFolder("data").getAbsolutePath())
+      .set("sonar.path.temp", tempFolder.getAbsolutePath())
+      .set("sonar.path.logs", this.temp.newFolder("logs").getAbsolutePath()))
+        .setHttpPort(9001)
+        .setHost("localhost")
+        .setEsYmlSettings(new EsYmlSettings(new HashMap<>()))
+        .setEsJvmOptions(new EsJvmOptions(new Props(new Properties()), tempFolder))
+        .setLog4j2Properties(new Properties());
+  }
+
   private EsInstallation createEsInstallation() throws IOException {
     File tempFolder = this.temp.newFolder("temp");
     return new EsInstallation(new Props(new Properties())
@@ -256,7 +362,7 @@ public class ProcessLauncherImplTest {
     @Override
     public Process start() {
       this.started = true;
-      return mock(Process.class);
+      return mock(Process.class, RETURNS_MOCKS);
     }
   }
 }
diff --git a/server/sonar-main/src/test/java/org/sonar/application/es/EsKeyStoreCliTest.java b/server/sonar-main/src/test/java/org/sonar/application/es/EsKeyStoreCliTest.java
new file mode 100644 (file)
index 0000000..8ea404f
--- /dev/null
@@ -0,0 +1,205 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.application.es;
+
+import java.io.File;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.TimeUnit;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.ArgumentMatcher;
+import org.sonar.application.command.JavaCommand;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class EsKeyStoreCliTest {
+
+  @Rule
+  public TemporaryFolder temp = new TemporaryFolder();
+
+  EsInstallation esInstallation = mock(EsInstallation.class);
+
+  @Test
+  public void execute_command_should_preserve_order_of_properties() throws Exception {
+    File homeDir = temp.newFolder();
+    File confDir = temp.newFolder();
+    when(esInstallation.getHomeDirectory()).thenReturn(homeDir);
+    when(esInstallation.getConfDirectory()).thenReturn(confDir);
+
+    EsKeyStoreCli underTest = EsKeyStoreCli.getInstance(esInstallation);
+    underTest
+      .store("test.property1", "value1")
+      .store("test.property2", "value2")
+      .store("test.property3", "value3");
+
+    MockProcess process = (MockProcess) underTest.executeWith(EsKeyStoreCliTest::mockLaunch);
+
+    JavaCommand<?> executedCommand = process.getExecutedCommand();
+    assertThat(executedCommand.getClassName()).isEqualTo("org.elasticsearch.common.settings.KeyStoreCli");
+    assertThat(executedCommand.getClasspath())
+      .containsExactly(homeDir.getAbsolutePath() + "/lib/*", homeDir.getAbsolutePath() + "/lib/tools/keystore-cli/*");
+    assertThat(executedCommand.getParameters()).containsExactly("add", "-x", "-f", "test.property1", "test.property2", "test.property3");
+    assertThat(executedCommand.getJvmOptions().getAll()).containsExactly(
+      "-Xshare:auto",
+      "-Xms4m",
+      "-Xmx64m",
+      "-Des.path.home=" + homeDir.getAbsolutePath(),
+      "-Des.path.conf=" + confDir.getAbsolutePath(),
+      "-Des.distribution=default",
+      "-Des.distribution.type=tar");
+
+    verify(process.getOutputStream()).write(argThat(new ArrayContainsMatcher("value1\nvalue2\nvalue3\n")), eq(0), eq(21));
+    verify(process.getOutputStream()).flush();
+    verify(process.getMock()).waitFor(1L, TimeUnit.MINUTES);
+  }
+
+  @Test
+  public void ISE_if_process_exited_abnormally() throws Exception {
+    File homeDir = temp.newFolder();
+    File confDir = temp.newFolder();
+    when(esInstallation.getHomeDirectory()).thenReturn(homeDir);
+    when(esInstallation.getConfDirectory()).thenReturn(confDir);
+
+    EsKeyStoreCli underTest = EsKeyStoreCli.getInstance(esInstallation);
+    underTest.store("test.property1", "value1");
+
+    assertThatThrownBy(() -> underTest.executeWith(EsKeyStoreCliTest::mockFailureLaunch))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage("Elasticsearch KeyStore tool exited with code: 1");
+  }
+
+  @Test
+  public void fail_if_tries_to_store_null_key() throws Exception {
+    File homeDir = temp.newFolder();
+    File confDir = temp.newFolder();
+    when(esInstallation.getHomeDirectory()).thenReturn(homeDir);
+    when(esInstallation.getConfDirectory()).thenReturn(confDir);
+
+    EsKeyStoreCli underTest = EsKeyStoreCli.getInstance(esInstallation);
+    assertThatThrownBy(() -> underTest.store(null, "value1"))
+      .isInstanceOf(NullPointerException.class)
+      .hasMessage("Property key cannot be null");
+  }
+
+  @Test
+  public void fail_if_tries_to_store_null_value() throws Exception {
+    File homeDir = temp.newFolder();
+    File confDir = temp.newFolder();
+    when(esInstallation.getHomeDirectory()).thenReturn(homeDir);
+    when(esInstallation.getConfDirectory()).thenReturn(confDir);
+
+    EsKeyStoreCli underTest = EsKeyStoreCli.getInstance(esInstallation);
+    assertThatThrownBy(() -> underTest.store("key", null))
+      .isInstanceOf(NullPointerException.class)
+      .hasMessage("Property value cannot be null");
+  }
+
+  private static MockProcess mockLaunch(JavaCommand<?> javaCommand) {
+    return new MockProcess(javaCommand);
+  }
+
+  private static MockProcess mockFailureLaunch(JavaCommand<?> javaCommand) {
+    return new MockProcess(javaCommand, 1);
+  }
+
+  public static class ArrayContainsMatcher implements ArgumentMatcher<byte[]> {
+    private final String left;
+
+    public ArrayContainsMatcher(String left) {
+      this.left = left;
+    }
+
+    @Override
+    public boolean matches(byte[] right) {
+      return new String(right).startsWith(left);
+    }
+  }
+
+  private static class MockProcess extends Process {
+    JavaCommand<?> executedCommand;
+    Process process;
+    OutputStream outputStream = mock(OutputStream.class);
+
+    public MockProcess(JavaCommand<?> executedCommand) {
+      this(executedCommand, 0);
+    }
+
+    public MockProcess(JavaCommand<?> executedCommand, int exitCode) {
+      this.executedCommand = executedCommand;
+      process = mock(Process.class);
+      when(process.getOutputStream()).thenReturn(outputStream);
+      when(process.exitValue()).thenReturn(exitCode);
+    }
+
+    public Process getMock() {
+      return process;
+    }
+
+    public JavaCommand<?> getExecutedCommand() {
+      return executedCommand;
+    }
+
+    @Override
+    public OutputStream getOutputStream() {
+      return outputStream;
+    }
+
+    @Override
+    public InputStream getInputStream() {
+      return null;
+    }
+
+    @Override
+    public InputStream getErrorStream() {
+      return null;
+    }
+
+    @Override
+    public int waitFor() throws InterruptedException {
+      process.waitFor();
+      return 0;
+    }
+
+    @Override
+    public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException {
+      process.waitFor(timeout, unit);
+      return true;
+    }
+
+    @Override
+    public int exitValue() {
+      return process.exitValue();
+    }
+
+    @Override
+    public void destroy() {
+
+    }
+  }
+
+}