Clone: configure pushUrl if sshdAdvertisedPrimaryAddress is set

The new option `plugin.download-commands.sshdAdvertisedPrimaryAddress`
specifies the address clients should be configured to reach a primary
Gerrit instance.

This may differ from `sshd.listenAddress` if fetch is served from
another address. An example is a setup where upload-pack requests
are served by a Gerrit replica and receive-pack by a Gerrit primary.
Since ssh cannot be load balanced on layer 7 the addresses of the
primary and replica need to be different.

Configure `remote.origin.pushUrl` in the clone command installing the
commit-msg hook which is typically used if the client is used to push
new changes.

Change-Id: I69a271c55fe53a4d1c01b5f24f1a6fd8313e7ba8
diff --git a/src/main/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHook.java b/src/main/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHook.java
index 431c8b9..f7bc005 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHook.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHook.java
@@ -91,6 +91,22 @@
           .append(extraCommand)
           .append(")");
     }
+
+    if (scheme instanceof SshScheme) {
+      String sshPushAddress = ((SshScheme) scheme).getPushUrl(project);
+      if (sshPushAddress != null) {
+        command
+            .append(" && ")
+            .append("(cd ")
+            .append(QuoteUtil.quote(projectName))
+            .append(" && ")
+            .append(
+                "git remote set-url --push \"$(git config --default origin --get clone.defaultRemoteName)\"")
+            .append(" ")
+            .append(QuoteUtil.quote(sshPushAddress))
+            .append(")");
+      }
+    }
     return command != null ? command.toString() : null;
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/download/scheme/SshScheme.java b/src/main/java/com/googlesource/gerrit/plugins/download/scheme/SshScheme.java
index 4628f73..6e23ef9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/download/scheme/SshScheme.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/download/scheme/SshScheme.java
@@ -18,10 +18,13 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DownloadConfig;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,6 +38,7 @@
 
 public class SshScheme extends DownloadScheme {
   private final String sshdAddress;
+  private final String sshdPrimaryAddress;
   private final String sshdHost;
   private final int sshdPort;
   private final Provider<CurrentUser> userProvider;
@@ -45,6 +49,8 @@
   @VisibleForTesting
   public SshScheme(
       @SshAdvertisedAddresses List<String> sshAddresses,
+      @PluginName String pluginName,
+      PluginConfigFactory configFactory,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       Provider<CurrentUser> userProvider,
       DownloadConfig downloadConfig) {
@@ -81,6 +87,17 @@
     this.sshdHost = host;
     this.sshdPort = port;
 
+    PluginConfig config = configFactory.getFromGerritConfig(pluginName);
+    String sshdPrimaryAddress = config.getString("sshdAdvertisedPrimaryAddress");
+    if (sshdPrimaryAddress != null && sshdPrimaryAddress.startsWith("*:") && urlProvider != null) {
+      try {
+        sshdPrimaryAddress = new URL(urlProvider.get()).getHost() + sshdPrimaryAddress.substring(1);
+      } catch (MalformedURLException e) {
+        // ignore, then this scheme will be disabled
+      }
+    }
+    this.sshdPrimaryAddress = sshdPrimaryAddress;
+
     this.userProvider = userProvider;
     this.schemeAllowed = downloadConfig.getDownloadSchemes().contains(SSH);
     this.schemeHidden = downloadConfig.getHiddenSchemes().contains(SSH);
@@ -89,7 +106,17 @@
   @Nullable
   @Override
   public String getUrl(String project) {
-    if (!isEnabled() || !userProvider.get().isIdentifiedUser()) {
+    return buildSshUrl(sshdAddress, project);
+  }
+
+  @Nullable
+  public String getPushUrl(String project) {
+    return buildSshUrl(sshdPrimaryAddress, project);
+  }
+
+  @Nullable
+  private String buildSshUrl(String address, String project) {
+    if (!isEnabled() || address == null || !userProvider.get().isIdentifiedUser()) {
       return null;
     }
 
@@ -106,7 +133,7 @@
       throw new IllegalStateException("No UTF-8 support", e);
     }
     r.append("@");
-    r.append(ensureSlash(sshdAddress));
+    r.append(ensureSlash(address));
     r.append(project);
     return r.toString();
   }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 526f298..3386747 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -138,3 +138,29 @@
   Optional command to complete the commit-msg hook. For example:
   `git submodule update --init --recursive && git review -s`
   would initialize the submodules and setup git review.
+
+### <a id="plugin.@PLUGIN@">Section plugin.@PLUGIN@</a>
+
+```
+[plugin "@PLUGIN@"]
+	sshdAdvertisedPrimaryAddress = host:port
+```
+
+<a id="plugin.@PLUGIN@.sshdAdvertisedPrimaryAddress">plugin.@PLUGIN@.sshdAdvertisedPrimaryAddress</a>
+Specifies the address where clients can reach a Gerrit primary
+instance via ssh protocol.
+
+This may differ from sshd.listenAddress if fetch is served from
+another address. An example is a setup where upload-pack requests
+are served by a Gerrit replica and receive-pack by a Gerrit primary.
+Since ssh cannot be load balanced on layer 7 the addresses of the
+primary and replica need to be different.
+
+The following forms may be used to specify an address. In any
+form, `:'port'` may be omitted to use the default SSH port of 22.
+
+* `'hostname':'port'` (for example `review.example.com:22`)
+* `'IPv4':'port'` (for example `10.0.0.1:29418`)
+* `['IPv6']:'port'` (for example `[ff02::1]:29418`)
+
+By default unset.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/download/DownloadCommandTest.java b/src/test/java/com/googlesource/gerrit/plugins/download/DownloadCommandTest.java
index bf7a49d..c39bf25 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/download/DownloadCommandTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/download/DownloadCommandTest.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.config.DownloadConfig;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 import com.googlesource.gerrit.plugins.download.scheme.HttpScheme;
@@ -29,6 +31,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.mockito.Mockito;
 
 @Ignore
 public class DownloadCommandTest {
@@ -67,6 +70,7 @@
     public final String projectName = "my/project";
     public final String userName = "john-doe@company.com";
     public final int sshPort = 29418;
+    public final int sshdAdvertisedPrimaryAddress = 39418;
 
     public String urlEncodedUserName() throws UnsupportedEncodingException {
       return URLEncoder.encode(userName, StandardCharsets.UTF_8.name());
@@ -90,14 +94,25 @@
 
   @Before
   public void setUp() {
-    Config cfg = new Config();
+    final String pluginName = "download-commands";
+
+    PluginConfigFactory configFactory = Mockito.mock(PluginConfigFactory.class);
+    Mockito.when(configFactory.getFromGerritConfig(pluginName))
+        .thenReturn(PluginConfig.createFromGerritConfig(pluginName, new Config()));
+
     urlProvider = Providers.of(ENV.canonicalUrl());
+
+    Config cfg = new Config();
     DownloadConfig downloadConfig = new DownloadConfig(cfg);
+
     userProvider = Providers.of(fakeUser());
+
     httpScheme = new HttpScheme(cfg, urlProvider, userProvider, downloadConfig);
     sshScheme =
         new SshScheme(
             ImmutableList.of(String.format("%s:%d", ENV.fqdn, ENV.sshPort)),
+            pluginName,
+            configFactory,
             urlProvider,
             userProvider,
             downloadConfig);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHookTest.java b/src/test/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHookTest.java
index 24b521e..f9d62ba 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHookTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/download/command/CloneWithCommitMsgHookTest.java
@@ -18,15 +18,21 @@
 import static com.googlesource.gerrit.plugins.download.command.CloneWithCommitMsgHook.HOOKS_DIR;
 import static com.googlesource.gerrit.plugins.download.command.CloneWithCommitMsgHook.HOOK_COMMAND_KEY;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.config.DownloadConfig;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.googlesource.gerrit.plugins.download.DownloadCommandTest;
+import com.googlesource.gerrit.plugins.download.scheme.SshScheme;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
+import org.mockito.Mockito;
 
 public class CloneWithCommitMsgHookTest extends DownloadCommandTest {
 
   @Test
   public void testSshNoConfiguredCommands() throws Exception {
-    String command = getCloneCommand(null, null).getCommand(sshScheme, ENV.projectName);
+    String command = getCloneCommand(null, null, null).getCommand(sshScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -37,7 +43,8 @@
   @Test
   public void testSshConfiguredHookCommand() throws Exception {
     String hookCommand = "my hook command";
-    String command = getCloneCommand(hookCommand, null).getCommand(sshScheme, ENV.projectName);
+    String command =
+        getCloneCommand(hookCommand, null, null).getCommand(sshScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -46,9 +53,28 @@
   }
 
   @Test
+  public void testSshConfiguredHookCommandAndPrimaryAddress() throws Exception {
+    String hookCommand = "my hook command";
+    String primaryAddress = getSshdAdvertisedPrimaryAddress();
+    String command =
+        getCloneCommand(hookCommand, null, primaryAddress).getCommand(sshScheme, ENV.projectName);
+    assertThat(command)
+        .isEqualTo(
+            String.format(
+                "git clone \"%s\" && (cd %s && %s) && (cd %s && git remote set-url --push "
+                    + "\"$(git config --default origin --get clone.defaultRemoteName)\" \"%s\")",
+                sshScheme.getUrl(ENV.projectName),
+                baseName(ENV.projectName),
+                hookCommand,
+                baseName(ENV.projectName),
+                sshScheme.getPushUrl(ENV.projectName)));
+  }
+
+  @Test
   public void testSshConfiguredExtraCommand() throws Exception {
     String extraCommand = "my extra command";
-    String command = getCloneCommand(extraCommand, null).getCommand(sshScheme, ENV.projectName);
+    String command =
+        getCloneCommand(extraCommand, null, null).getCommand(sshScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -61,7 +87,7 @@
     String hookCommand = "my hook command";
     String extraCommand = "my extra command";
     String command =
-        getCloneCommand(hookCommand, extraCommand).getCommand(sshScheme, ENV.projectName);
+        getCloneCommand(hookCommand, extraCommand, null).getCommand(sshScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -74,8 +100,30 @@
   }
 
   @Test
+  public void testSshConfiguredHookExtraCommandAndAdvertisedPrimary() throws Exception {
+    String hookCommand = "my hook command";
+    String extraCommand = "my extra command";
+    String primaryAddress = getSshdAdvertisedPrimaryAddress();
+    String command =
+        getCloneCommand(hookCommand, extraCommand, primaryAddress)
+            .getCommand(sshScheme, ENV.projectName);
+    assertThat(command)
+        .isEqualTo(
+            String.format(
+                "git clone \"%s\" && (cd %s && %s) && (cd %s && %s) && (cd %s && git remote set-url --push "
+                    + "\"$(git config --default origin --get clone.defaultRemoteName)\" \"%s\")",
+                sshScheme.getUrl(ENV.projectName),
+                baseName(ENV.projectName),
+                hookCommand,
+                baseName(ENV.projectName),
+                extraCommand,
+                baseName(ENV.projectName),
+                sshScheme.getPushUrl(ENV.projectName)));
+  }
+
+  @Test
   public void testHttpNoConfiguredCommands() throws Exception {
-    String command = getCloneCommand(null, null).getCommand(httpScheme, ENV.projectName);
+    String command = getCloneCommand(null, null, null).getCommand(httpScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -86,7 +134,8 @@
   @Test
   public void testHttpConfiguredExtraCommand() throws Exception {
     String extraCommand = "my extra command";
-    String command = getCloneCommand(extraCommand, null).getCommand(httpScheme, ENV.projectName);
+    String command =
+        getCloneCommand(extraCommand, null, null).getCommand(httpScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -99,7 +148,7 @@
     String hookCommand = "my hook command";
     String extraCommand = "my extra command";
     String command =
-        getCloneCommand(hookCommand, extraCommand).getCommand(httpScheme, ENV.projectName);
+        getCloneCommand(hookCommand, extraCommand, null).getCommand(httpScheme, ENV.projectName);
     assertThat(command)
         .isEqualTo(
             String.format(
@@ -121,10 +170,31 @@
         baseName(ENV.projectName), HOOKS_DIR, HOOKS_DIR, ENV.fqdn, HOOKS_DIR);
   }
 
-  private CloneCommand getCloneCommand(String hookCommand, String extraCommand) {
+  private String getSshdAdvertisedPrimaryAddress() {
+    return String.format("%s:%d", ENV.fqdn, ENV.sshdAdvertisedPrimaryAddress);
+  }
+
+  private CloneCommand getCloneCommand(
+      String hookCommand, String extraCommand, String sshdAdvertisedPrimaryAddress) {
+    final String pluginName = "download-commands";
     Config cfg = new Config();
     cfg.setString("gerrit", null, HOOK_COMMAND_KEY, hookCommand);
     cfg.setString("gerrit", null, EXTRA_COMMAND_KEY, extraCommand);
+    cfg.setString(
+        "plugin", pluginName, "sshdadvertisedprimaryaddress", sshdAdvertisedPrimaryAddress);
+
+    PluginConfigFactory configFactory = Mockito.mock(PluginConfigFactory.class);
+    Mockito.when(configFactory.getFromGerritConfig(pluginName))
+        .thenReturn(PluginConfig.createFromGerritConfig(pluginName, cfg));
+
+    sshScheme =
+        new SshScheme(
+            ImmutableList.of(String.format("%s:%d", ENV.fqdn, ENV.sshPort)),
+            pluginName,
+            configFactory,
+            urlProvider,
+            userProvider,
+            new DownloadConfig(cfg));
     return new CloneWithCommitMsgHook(cfg, urlProvider);
   }
 }