Merge branch 'stable-2.14'

* stable-2.14:
  Fix race condition when removing changeId/accountId locks
  Allow to configure own URL instead of relying on httpd.listenUrl
  ConfigurationTest: Use a valid URL for test URL value
  RestForwarder: Remove redundant logRetry method
  Extend debug logging
  Setup: Fix typo in method name
  Option to setup a copy of the master site during init
  Use Java 8's Optional instead of Guava's
  Upgrade wiremock to 2.8.0
  Upgrade mockito-core to 2.9.0
  Add support for forwarding cache eviction for custom caches
  Allow to discover peers with JGroups
  Update bazlets to latest revision to build with API version 2.14.3
  Move cache pattern test from CacheEvictionIT to ConfigurationTest
  CacheRestApiServletTest: Add missing verifyResponseIsOK in evictGroupsMembers
  CacheEvictionHandler: Factor cache name pattern matching to own class

Change-Id: I89f6007ea98e49a8ca32beb6cf79209b0dd6d347
diff --git a/BUILD b/BUILD
index 781ad36..4660487 100644
--- a/BUILD
+++ b/BUILD
@@ -16,6 +16,7 @@
         "Implementation-Title: high-availability plugin",
         "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/high-availability",
     ],
+    deps = ["@jgroups//jar"],
     resources = glob(["src/main/resources/**/*"]),
 )
 
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl
index ff3a6d7..9fb12ce 100644
--- a/external_plugin_deps.bzl
+++ b/external_plugin_deps.bzl
@@ -3,14 +3,14 @@
 def external_plugin_deps():
   maven_jar(
     name = "wiremock",
-    artifact = "com.github.tomakehurst:wiremock-standalone:2.5.1",
-    sha1 = "9cda1bf1674c8de3a1116bae4d7ce0046a857d30",
+    artifact = "com.github.tomakehurst:wiremock-standalone:2.8.0",
+    sha1 = "b4d91aca283a86b447d3906deac6e1509c3a94c5",
   )
 
   maven_jar(
     name = "mockito",
-    artifact = "org.mockito:mockito-core:2.7.21",
-    sha1 = "23e9f7bfb9717e849a05b84c29ee3ac723f1a653",
+    artifact = "org.mockito:mockito-core:2.9.0",
+    sha1 = "f28b9606eca8da77e10df30a7e301f589733143e",
     deps = [
       '@byte-buddy//jar',
       '@objenesis//jar',
@@ -19,12 +19,18 @@
 
   maven_jar(
     name = "byte-buddy",
-    artifact = "net.bytebuddy:byte-buddy:1.6.11",
-    sha1 = "8a8f9409e27f1d62c909c7eef2aa7b3a580b4901",
+    artifact = "net.bytebuddy:byte-buddy:1.7.0",
+    sha1 = "48481d20ed4334ee0abfe8212ecb44e0233a97b5",
   )
 
   maven_jar(
     name = "objenesis",
-    artifact = "org.objenesis:objenesis:2.5",
-    sha1 = "612ecb799912ccf77cba9b3ed8c813da086076e9",
+    artifact = "org.objenesis:objenesis:2.6",
+    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+  )
+
+  maven_jar(
+    name = "jgroups",
+    artifact = "org.jgroups:jgroups:3.6.5.Final",
+    sha1 = "fe575fe2d473566ad3f4ace4702ff4bfcf2587a6",
   )
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
index 6c56a8c..8cb57f3 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
@@ -19,6 +19,8 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfigFactory;
@@ -28,6 +30,9 @@
 import com.google.inject.Singleton;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -39,10 +44,15 @@
   // main section
   static final String MAIN_SECTION = "main";
   static final String SHARED_DIRECTORY_KEY = "sharedDirectory";
+  static final String DEFAULT_SHARED_DIRECTORY = "shared";
 
   // peerInfo section
   static final String PEER_INFO_SECTION = "peerInfo";
+  static final String STATIC_SUBSECTION = PeerInfoStrategy.STATIC.name().toLowerCase();
+  static final String JGROUPS_SUBSECTION = PeerInfoStrategy.JGROUPS.name().toLowerCase();
   static final String URL_KEY = "url";
+  static final String STRATEGY_KEY = "strategy";
+  static final String MY_URL_KEY = "myUrl";
 
   // http section
   static final String HTTP_SECTION = "http";
@@ -55,6 +65,7 @@
 
   // cache section
   static final String CACHE_SECTION = "cache";
+  static final String PATTERN_KEY = "pattern";
 
   // event section
   static final String EVENT_SECTION = "event";
@@ -72,6 +83,10 @@
   static final String WEBSESSION_SECTION = "websession";
   static final String CLEANUP_INTERVAL_KEY = "cleanupInterval";
 
+  // jgroups section used if peerInfo.strategy == jgroups
+  static final String SKIP_INTERFACE_KEY = "skipInterface";
+  static final String CLUSTER_NAME_KEY = "clusterName";
+
   static final int DEFAULT_TIMEOUT_MS = 5000;
   static final int DEFAULT_MAX_TRIES = 5;
   static final int DEFAULT_RETRY_INTERVAL = 1000;
@@ -79,6 +94,10 @@
   static final String DEFAULT_CLEANUP_INTERVAL = "24 hours";
   static final long DEFAULT_CLEANUP_INTERVAL_MS = HOURS.toMillis(24);
   static final boolean DEFAULT_SYNCHRONIZE = true;
+  static final PeerInfoStrategy DEFAULT_PEER_INFO_STRATEGY = PeerInfoStrategy.STATIC;
+  static final ImmutableList<String> DEFAULT_SKIP_INTERFACE_LIST =
+      ImmutableList.of("lo*", "utun*", "awdl*");
+  static final String DEFAULT_CLUSTER_NAME = "GerritHA";
 
   private final Main main;
   private final PeerInfo peerInfo;
@@ -87,6 +106,13 @@
   private final Event event;
   private final Index index;
   private final Websession websession;
+  private PeerInfoStatic peerInfoStatic;
+  private PeerInfoJGroups peerInfoJGroups;
+
+  public enum PeerInfoStrategy {
+    JGROUPS,
+    STATIC
+  }
 
   @Inject
   Configuration(
@@ -94,6 +120,16 @@
     Config cfg = pluginConfigFactory.getGlobalPluginConfig(pluginName);
     main = new Main(site, cfg);
     peerInfo = new PeerInfo(cfg);
+    switch (peerInfo.strategy()) {
+      case STATIC:
+        peerInfoStatic = new PeerInfoStatic(cfg);
+        break;
+      case JGROUPS:
+        peerInfoJGroups = new PeerInfoJGroups(cfg);
+        break;
+      default:
+        throw new IllegalArgumentException("Not supported strategy: " + peerInfo.strategy);
+    }
     http = new Http(cfg);
     cache = new Cache(cfg);
     event = new Event(cfg);
@@ -109,6 +145,14 @@
     return peerInfo;
   }
 
+  public PeerInfoStatic peerInfoStatic() {
+    return peerInfoStatic;
+  }
+
+  public PeerInfoJGroups peerInfoJGroups() {
+    return peerInfoJGroups;
+  }
+
   public Http http() {
     return http;
   }
@@ -149,6 +193,17 @@
     }
   }
 
+  private static String getString(
+      Config cfg, String section, String subSection, String name, String defaultValue) {
+    String value = cfg.getString(section, subSection, name);
+    return ((value == null) ? defaultValue : value);
+  }
+
+  @Nullable
+  private static String trimTrailingSlash(@Nullable String in) {
+    return in == null ? null : CharMatcher.is('/').trimTrailingFrom(in);
+  }
+
   public static class Main {
     private final Path sharedDirectory;
 
@@ -171,13 +226,24 @@
   }
 
   public static class PeerInfo {
-    private final String url;
+    private final PeerInfoStrategy strategy;
 
     private PeerInfo(Config cfg) {
+      strategy = cfg.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY);
+    }
+
+    public PeerInfoStrategy strategy() {
+      return strategy;
+    }
+  }
+
+  public class PeerInfoStatic {
+    private final String url;
+
+    private PeerInfoStatic(Config cfg) {
       url =
-          CharMatcher.is('/')
-              .trimTrailingFrom(
-                  Strings.nullToEmpty(cfg.getString(PEER_INFO_SECTION, null, URL_KEY)));
+          trimTrailingSlash(
+              Strings.nullToEmpty(cfg.getString(PEER_INFO_SECTION, STATIC_SUBSECTION, URL_KEY)));
     }
 
     public String url() {
@@ -185,6 +251,33 @@
     }
   }
 
+  public static class PeerInfoJGroups {
+    private final ImmutableList<String> skipInterface;
+    private final String clusterName;
+    private final String myUrl;
+
+    private PeerInfoJGroups(Config cfg) {
+      String[] skip = cfg.getStringList(PEER_INFO_SECTION, JGROUPS_SUBSECTION, SKIP_INTERFACE_KEY);
+      skipInterface = skip == null ? DEFAULT_SKIP_INTERFACE_LIST : ImmutableList.copyOf(skip);
+      clusterName =
+          getString(
+              cfg, PEER_INFO_SECTION, JGROUPS_SUBSECTION, CLUSTER_NAME_KEY, DEFAULT_CLUSTER_NAME);
+      myUrl = trimTrailingSlash(cfg.getString(PEER_INFO_SECTION, JGROUPS_SUBSECTION, MY_URL_KEY));
+    }
+
+    public ImmutableList<String> skipInterface() {
+      return skipInterface;
+    }
+
+    public String clusterName() {
+      return clusterName;
+    }
+
+    public String myUrl() {
+      return myUrl;
+    }
+  }
+
   public static class Http {
     private final String user;
     private final String password;
@@ -242,15 +335,21 @@
 
   public static class Cache extends Forwarding {
     private final int threadPoolSize;
+    private final List<String> patterns;
 
     private Cache(Config cfg) {
       super(cfg, CACHE_SECTION);
       threadPoolSize = getInt(cfg, CACHE_SECTION, THREAD_POOL_SIZE_KEY, DEFAULT_THREAD_POOL_SIZE);
+      patterns = Arrays.asList(cfg.getStringList(CACHE_SECTION, null, PATTERN_KEY));
     }
 
     public int threadPoolSize() {
       return threadPoolSize;
     }
+
+    public List<String> patterns() {
+      return Collections.unmodifiableList(patterns);
+    }
   }
 
   public static class Event extends Forwarding {
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
index dc9ff62..2e28105 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
@@ -50,7 +50,7 @@
     if (config.index().synchronize()) {
       install(new IndexModule());
     }
-    install(new PeerInfoModule());
+    install(new PeerInfoModule(config.peerInfo().strategy()));
   }
 
   @Provides
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java
index dfb4887..5534927 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java
@@ -14,16 +14,47 @@
 
 package com.ericsson.gerrit.plugins.highavailability;
 
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.*;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.CACHE_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.CLEANUP_INTERVAL_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.CLUSTER_NAME_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.CONNECTION_TIMEOUT_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_CLEANUP_INTERVAL;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_CLUSTER_NAME;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_MAX_TRIES;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_RETRY_INTERVAL;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_SHARED_DIRECTORY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_THREAD_POOL_SIZE;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_TIMEOUT_MS;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.HTTP_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.INDEX_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.JGROUPS_SUBSECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAIN_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAX_TRIES_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.PASSWORD_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.PEER_INFO_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.RETRY_INTERVAL_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.SHARED_DIRECTORY_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.SOCKET_TIMEOUT_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.STATIC_SUBSECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.STRATEGY_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.THREAD_POOL_SIZE_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.URL_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.USER_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.WEBSESSION_SECTION;
 
+import com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfoStrategy;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.EnumSet;
 import java.util.Objects;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -32,14 +63,24 @@
 
   private final ConsoleUI ui;
   private final String pluginName;
+  private final InitFlags flags;
   private final SitePaths site;
+  private final SetupLocalHAReplica setupLocalHAReplica;
+
   private FileBasedConfig config;
 
   @Inject
-  public Setup(ConsoleUI ui, @PluginName String pluginName, SitePaths site) {
+  public Setup(
+      ConsoleUI ui,
+      @PluginName String pluginName,
+      InitFlags flags,
+      SitePaths site,
+      SetupLocalHAReplica setupLocalHAReplica) {
     this.ui = ui;
     this.pluginName = pluginName;
+    this.flags = flags;
     this.site = site;
+    this.setupLocalHAReplica = setupLocalHAReplica;
   }
 
   @Override
@@ -52,29 +93,47 @@
       Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
       config = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
       config.load();
-      configureMainSection();
-      configurePeerInfoSection();
       configureHttp();
       configureCacheSection();
       configureIndexSection();
-      configureWebsessiosSection();
-      config.save();
+      configureWebsessionsSection();
+      if (!createHAReplicaSite(config)) {
+        configureMainSection();
+        configurePeerInfoSection();
+        config.save();
+      }
+      flags.cfg.setBoolean("database", "h2", "autoServer", true);
     }
   }
 
   private void configureMainSection() {
     ui.header("Main section");
-    String sharedDir =
-        promptAndSetString("Shared directory", MAIN_SECTION, SHARED_DIRECTORY_KEY, null);
-    if (!Strings.isNullOrEmpty(sharedDir)) {
-      Path shared = site.site_path.resolve(sharedDir);
-      FileUtil.mkdirsOrDie(shared, "cannot create " + shared);
+    String sharedDirDefault = ui.isBatch() ? DEFAULT_SHARED_DIRECTORY : null;
+    String shared =
+        promptAndSetString(
+            "Shared directory", MAIN_SECTION, SHARED_DIRECTORY_KEY, sharedDirDefault);
+    if (!Strings.isNullOrEmpty(shared)) {
+      Path resolved = site.site_path.resolve(Paths.get(shared));
+      FileUtil.mkdirsOrDie(resolved, "cannot create " + resolved);
     }
   }
 
   private void configurePeerInfoSection() {
     ui.header("PeerInfo section");
-    promptAndSetString("Peer URL", PEER_INFO_SECTION, URL_KEY, null);
+    PeerInfoStrategy strategy =
+        ui.readEnum(
+            PeerInfoStrategy.JGROUPS, EnumSet.allOf(PeerInfoStrategy.class), "Peer info strategy");
+    config.setEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, strategy);
+    if (strategy == PeerInfoStrategy.STATIC) {
+      promptAndSetString("Peer URL", PEER_INFO_SECTION, STATIC_SUBSECTION, URL_KEY, null);
+    } else {
+      promptAndSetString(
+          "JGroups cluster name",
+          PEER_INFO_SECTION,
+          JGROUPS_SUBSECTION,
+          CLUSTER_NAME_KEY,
+          DEFAULT_CLUSTER_NAME);
+    }
   }
 
   private void configureHttp() {
@@ -112,7 +171,7 @@
         str(DEFAULT_THREAD_POOL_SIZE));
   }
 
-  private void configureWebsessiosSection() {
+  private void configureWebsessionsSection() {
     ui.header("Websession section");
     promptAndSetString(
         "Cleanup interval", WEBSESSION_SECTION, CLEANUP_INTERVAL_KEY, DEFAULT_CLEANUP_INTERVAL);
@@ -120,13 +179,18 @@
 
   private String promptAndSetString(
       String title, String section, String name, String defaultValue) {
-    String oldValue = Strings.emptyToNull(config.getString(section, null, name));
+    return promptAndSetString(title, section, null, name, defaultValue);
+  }
+
+  private String promptAndSetString(
+      String title, String section, String subsection, String name, String defaultValue) {
+    String oldValue = Strings.emptyToNull(config.getString(section, subsection, name));
     String newValue = ui.readString(oldValue != null ? oldValue : defaultValue, title);
     if (!Objects.equals(oldValue, newValue)) {
       if (!Strings.isNullOrEmpty(newValue)) {
-        config.setString(section, null, name, newValue);
+        config.setString(section, subsection, name, newValue);
       } else {
-        config.unset(section, name, name);
+        config.unset(section, subsection, name);
       }
     }
     return newValue;
@@ -136,6 +200,30 @@
     return Integer.toString(n);
   }
 
+  private boolean createHAReplicaSite(FileBasedConfig pluginConfig) throws Exception {
+    ui.header("HA replica site setup");
+    ui.message(
+        "It is possible to create a copy of the master site and configure both sites to run\n"
+            + "in HA mode as peers. This is possible when the directory where the copy will be\n"
+            + "created is accessible from this machine\n"
+            + "\n"
+            + "NOTE: This step is optional. If you want to create the other site manually, or\n"
+            + "if the other site needs to be created in a directory not accessible from this\n"
+            + "machine then please skip this step.\n");
+    if (ui.yesno(true, "Create a HA replica site")) {
+      String replicaPath = ui.readString("ha/1", "Location of the HA replica");
+      Path replica = site.site_path.resolve(Paths.get(replicaPath));
+      if (Files.exists(replica)) {
+        ui.message("%s already exists, exiting", replica);
+        return true;
+      }
+      config.save();
+      setupLocalHAReplica.run(new SitePaths(replica), pluginConfig);
+      return true;
+    }
+    return false;
+  }
+
   @Override
   public void postRun() throws Exception {}
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java
new file mode 100644
index 0000000..53d33ed
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability;
+
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.CLUSTER_NAME_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_CLUSTER_NAME;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_SHARED_DIRECTORY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.JGROUPS_SUBSECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAIN_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.PEER_INFO_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.SHARED_DIRECTORY_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.STRATEGY_KEY;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+class SetupLocalHAReplica {
+  private final SitePaths master;
+  private final FileBasedConfig masterConfig;
+
+  private Path sharedDir;
+  private SitePaths replica;
+
+  @Inject
+  SetupLocalHAReplica(SitePaths master, InitFlags flags) {
+    this.master = master;
+    this.masterConfig = flags.cfg;
+    this.sharedDir = master.site_path.resolve(DEFAULT_SHARED_DIRECTORY);
+  }
+
+  void run(SitePaths replica, FileBasedConfig pluginConfig) throws Exception {
+    this.replica = replica;
+
+    FileUtil.mkdirsOrDie(replica.site_path, "cannot create " + replica.site_path);
+
+    configureMainSection(pluginConfig);
+    configurePeerInfo(pluginConfig);
+
+    for (Path dir : listDirsForCopy()) {
+      copyFiles(dir);
+    }
+
+    mkdir(replica.logs_dir);
+    mkdir(replica.tmp_dir);
+    symlink(Paths.get(masterConfig.getString("gerrit", null, "basePath")));
+    symlink(sharedDir);
+
+    FileBasedConfig replicaConfig =
+        new FileBasedConfig(replica.gerrit_config.toFile(), FS.DETECTED);
+    replicaConfig.load();
+
+    if ("h2".equals(masterConfig.getString("database", null, "type"))) {
+      masterConfig.setBoolean("database", "h2", "autoServer", true);
+      replicaConfig.setBoolean("database", "h2", "autoServer", true);
+      symlinkH2ReviewDbDir();
+    }
+  }
+
+  private List<Path> listDirsForCopy() throws IOException {
+    ImmutableList.Builder<Path> toSkipBuilder = ImmutableList.builder();
+    toSkipBuilder.add(
+        master.resolve(masterConfig.getString("gerrit", null, "basePath")),
+        master.db_dir,
+        master.logs_dir,
+        replica.site_path,
+        master.site_path.resolve(sharedDir),
+        master.tmp_dir);
+    if ("h2".equals(masterConfig.getString("database", null, "type"))) {
+      toSkipBuilder.add(
+          master.resolve(masterConfig.getString("database", null, "database")).getParent());
+    }
+    final ImmutableList<Path> toSkip = toSkipBuilder.build();
+
+    final ArrayList<Path> dirsForCopy = new ArrayList<>();
+    Files.walkFileTree(
+        master.site_path,
+        EnumSet.of(FileVisitOption.FOLLOW_LINKS),
+        Integer.MAX_VALUE,
+        new SimpleFileVisitor<Path>() {
+          @Override
+          public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
+              throws IOException {
+            if (Files.isSameFile(dir, master.site_path)) {
+              return FileVisitResult.CONTINUE;
+            }
+
+            Path p = master.site_path.relativize(dir);
+            if (shouldSkip(p)) {
+              return FileVisitResult.SKIP_SUBTREE;
+            }
+            dirsForCopy.add(p);
+            return FileVisitResult.CONTINUE;
+          }
+
+          private boolean shouldSkip(Path p) throws IOException {
+            Path resolved = master.site_path.resolve(p);
+            for (Path skip : toSkip) {
+              if (Files.exists(skip) && Files.isSameFile(resolved, skip)) {
+                return true;
+              }
+            }
+            return false;
+          }
+        });
+
+    return dirsForCopy;
+  }
+
+  private void copyFiles(Path dir) throws IOException {
+    final Path source = master.site_path.resolve(dir);
+    final Path target = replica.site_path.resolve(dir);
+    Files.createDirectories(target);
+    Files.walkFileTree(
+        source,
+        EnumSet.noneOf(FileVisitOption.class),
+        1,
+        new SimpleFileVisitor<Path>() {
+          @Override
+          public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+              throws IOException {
+            Path f = source.relativize(file);
+            if (Files.isRegularFile(file)) {
+              Files.copy(file, target.resolve(f));
+            }
+            return FileVisitResult.CONTINUE;
+          }
+        });
+  }
+
+  private static void mkdir(Path dir) throws IOException {
+    Files.createDirectories(dir);
+  }
+
+  private void symlink(Path path) throws IOException {
+    if (!path.isAbsolute()) {
+      Files.createSymbolicLink(
+          replica.site_path.resolve(path),
+          master.site_path.resolve(path).toAbsolutePath().normalize());
+    }
+  }
+
+  private void symlinkH2ReviewDbDir() throws IOException {
+    symlink(Paths.get(masterConfig.getString("database", null, "database")).getParent());
+  }
+
+  private void configureMainSection(FileBasedConfig pluginConfig) throws IOException {
+    pluginConfig.setString(
+        MAIN_SECTION,
+        null,
+        SHARED_DIRECTORY_KEY,
+        master.site_path.relativize(sharedDir).toString());
+    pluginConfig.save();
+  }
+
+  private void configurePeerInfo(FileBasedConfig pluginConfig) throws IOException {
+    pluginConfig.setString(PEER_INFO_SECTION, null, STRATEGY_KEY, "jgroups");
+    pluginConfig.setString(
+        PEER_INFO_SECTION, JGROUPS_SUBSECTION, CLUSTER_NAME_KEY, DEFAULT_CLUSTER_NAME);
+    pluginConfig.save();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java
index 7e32689..94894be 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java
@@ -21,37 +21,33 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.inject.Inject;
 import java.util.concurrent.Executor;
-import java.util.regex.Pattern;
 
 class CacheEvictionHandler<K, V> implements CacheRemovalListener<K, V> {
   private final Executor executor;
   private final Forwarder forwarder;
   private final String pluginName;
-  private final Pattern pattern;
+  private final CachePatternMatcher matcher;
 
   @Inject
   CacheEvictionHandler(
-      Forwarder forwarder, @CacheExecutor Executor executor, @PluginName String pluginName) {
+      Forwarder forwarder,
+      @CacheExecutor Executor executor,
+      @PluginName String pluginName,
+      CachePatternMatcher matcher) {
     this.forwarder = forwarder;
     this.executor = executor;
     this.pluginName = pluginName;
-    pattern =
-        Pattern.compile(
-            "^accounts.*|^groups.*|ldap_groups|ldap_usernames|^project.*|sshkeys|web_sessions");
+    this.matcher = matcher;
   }
 
   @Override
   public void onRemoval(
       String pluginName, String cacheName, RemovalNotification<K, V> notification) {
-    if (!Context.isForwardedEvent() && !notification.wasEvicted() && isSynchronized(cacheName)) {
+    if (!Context.isForwardedEvent() && !notification.wasEvicted() && matcher.matches(cacheName)) {
       executor.execute(new CacheEvictionTask(cacheName, notification.getKey()));
     }
   }
 
-  private boolean isSynchronized(String cacheName) {
-    return pattern.matcher(cacheName).matches();
-  }
-
   class CacheEvictionTask implements Runnable {
     private String cacheName;
     private Object key;
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java
new file mode 100644
index 0000000..f8d71f3
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CachePatternMatcher.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability.cache;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+@Singleton
+public class CachePatternMatcher {
+  private static final List<String> DEFAULT_PATTERNS =
+      ImmutableList.of(
+          "^accounts.*",
+          "^groups.*",
+          "ldap_groups",
+          "ldap_usernames",
+          "^project.*",
+          "sshkeys",
+          "web_sessions");
+
+  private final Pattern pattern;
+
+  @Inject
+  public CachePatternMatcher(Configuration cfg) {
+    List<String> patterns = new ArrayList<>(DEFAULT_PATTERNS);
+    patterns.addAll(cfg.cache().patterns());
+    this.pattern = Pattern.compile(Joiner.on("|").join(patterns));
+  }
+
+  public boolean matches(String cacheName) {
+    return pattern.matcher(cacheName).matches();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java
index 6b047d3..94c3d30 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/Constants.java
@@ -15,7 +15,7 @@
 package com.ericsson.gerrit.plugins.highavailability.cache;
 
 public final class Constants {
-
+  public static final String GERRIT = "gerrit";
   public static final String PROJECT_LIST = "project_list";
   public static final String ACCOUNTS = "accounts";
   public static final String GROUPS = "groups";
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/AbstractIndexRestApiServlet.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/AbstractIndexRestApiServlet.java
index fca807f..5b7cc60 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/AbstractIndexRestApiServlet.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/AbstractIndexRestApiServlet.java
@@ -79,6 +79,7 @@
     rsp.setCharacterEncoding(UTF_8.name());
     String path = req.getPathInfo();
     T id = parse(path.substring(path.lastIndexOf('/') + 1));
+    logger.debug("{} {} {}", operation.name(), type, id);
     try {
       Context.setForwardedEvent(true);
       AtomicInteger idLock = getAndIncrementIdLock(id);
@@ -116,7 +117,9 @@
 
   private void removeIdLock(T id) {
     synchronized (idLocks) {
-      idLocks.remove(id);
+      if (idLocks.get(id).get() == 0) {
+        idLocks.remove(id);
+      }
     }
   }
 
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java
index b388b0b..253ff4a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServlet.java
@@ -19,6 +19,7 @@
 
 import com.ericsson.gerrit.plugins.highavailability.cache.Constants;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -37,7 +38,6 @@
 class CacheRestApiServlet extends HttpServlet {
   private static final int CACHENAME_INDEX = 1;
   private static final long serialVersionUID = -1L;
-  private static final String GERRIT = "gerrit";
   private static final Logger logger = LoggerFactory.getLogger(CacheRestApiServlet.class);
 
   private final DynamicMap<Cache<?, ?>> cacheMap;
@@ -57,10 +57,17 @@
       String cacheName = params.get(CACHENAME_INDEX);
       String json = req.getReader().readLine();
       Object key = GsonParser.fromJson(cacheName, json);
-      Cache<?, ?> cache = cacheMap.get(GERRIT, cacheName);
-      Context.setForwardedEvent(true);
-      evictCache(cache, cacheName, key);
-      rsp.setStatus(SC_NO_CONTENT);
+      CacheParameters cacheKey = getCacheParameters(cacheName);
+      Cache<?, ?> cache = cacheMap.get(cacheKey.pluginName, cacheKey.cacheName);
+      if (cache == null) {
+        String msg = String.format("cache %s not found", cacheName);
+        logger.error("Failed to process eviction request: " + msg);
+        sendError(rsp, SC_BAD_REQUEST, msg);
+      } else {
+        Context.setForwardedEvent(true);
+        evictCache(cache, cacheKey.cacheName, key);
+        rsp.setStatus(SC_NO_CONTENT);
+      }
     } catch (IOException e) {
       logger.error("Failed to process eviction request: " + e.getMessage(), e);
       sendError(rsp, SC_BAD_REQUEST, e.getMessage());
@@ -69,6 +76,26 @@
     }
   }
 
+  @VisibleForTesting
+  public static class CacheParameters {
+    public final String pluginName;
+    public final String cacheName;
+
+    public CacheParameters(String pluginName, String cacheName) {
+      this.pluginName = pluginName;
+      this.cacheName = cacheName;
+    }
+  }
+
+  @VisibleForTesting
+  public static CacheParameters getCacheParameters(String cache) {
+    int dot = cache.indexOf(".");
+    if (dot > 0) {
+      return new CacheParameters(cache.substring(0, dot), cache.substring(dot + 1));
+    }
+    return new CacheParameters(Constants.GERRIT, cache);
+  }
+
   private static void sendError(HttpServletResponse rsp, int statusCode, String message) {
     try {
       rsp.sendError(statusCode, message);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java
index 6f43eaf..92f3d0d 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java
@@ -67,6 +67,7 @@
         return;
       }
       Event event = getEventFromRequest(req);
+      logger.debug("event {}", event.getType());
       dispatcher.postEvent(event);
       rsp.setStatus(SC_NO_CONTENT);
     } catch (OrmException e) {
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java
index 23f42ab..8a177de 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/GsonParser.java
@@ -44,7 +44,11 @@
         key = gson.fromJson(Strings.nullToEmpty(json), Object.class);
         break;
       default:
-        key = gson.fromJson(Strings.nullToEmpty(json).trim(), String.class);
+        try {
+          key = gson.fromJson(Strings.nullToEmpty(json).trim(), String.class);
+        } catch (Exception e) {
+          key = gson.fromJson(Strings.nullToEmpty(json), Object.class);
+        }
     }
     return key;
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSession.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSession.java
index 0a0e898..136e7f3 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSession.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSession.java
@@ -16,13 +16,13 @@
 
 import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.HttpResponseHandler.HttpResult;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.net.MediaType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.Optional;
 import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.entity.StringEntity;
@@ -57,7 +57,7 @@
   }
 
   private PeerInfo getPeerInfo() throws PeerInfoNotAvailableException {
-    PeerInfo info = peerInfo.get().orNull();
+    PeerInfo info = peerInfo.get().orElse(null);
     if (info == null) {
       throw new PeerInfoNotAvailableException();
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
index 18c8645..c38e849 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
@@ -123,10 +123,12 @@
     }
 
     boolean execute() {
+      log.debug(name);
       for (; ; ) {
         try {
           execCnt++;
           tryOnce();
+          log.debug("{} OK", name);
           return true;
         } catch (ForwardingException e) {
           if (!e.isRecoverable()) {
@@ -138,7 +140,7 @@
             return false;
           }
 
-          logRetry(e);
+          log.debug("Retrying to {} caused by '{}'", name, e);
           try {
             Thread.sleep(cfg.http().retryInterval());
           } catch (InterruptedException ie) {
@@ -166,11 +168,5 @@
     boolean isRecoverable(IOException e) {
       return !(e instanceof SSLException);
     }
-
-    void logRetry(Throwable cause) {
-      if (log.isDebugEnabled()) {
-        log.debug("Retrying to {} caused by '{}'", name, cause);
-      }
-    }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java
index b28a4d2..74310ae 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java
@@ -18,7 +18,7 @@
 
   private final String directUrl;
 
-  PeerInfo(String directUrl) {
+  public PeerInfo(String directUrl) {
     this.directUrl = directUrl;
   }
 
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java
index 6a58661..25e0c41 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java
@@ -14,13 +14,25 @@
 
 package com.ericsson.gerrit.plugins.highavailability.peers;
 
-import com.google.common.base.Optional;
-import com.google.inject.AbstractModule;
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.JGroupsPeerInfoProvider;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
 
-public class PeerInfoModule extends AbstractModule {
+public class PeerInfoModule extends LifecycleModule {
+
+  private final Configuration.PeerInfoStrategy strategy;
+
+  public PeerInfoModule(Configuration.PeerInfoStrategy strategy) {
+    this.strategy = strategy;
+  }
+
   @Override
   protected void configure() {
-    bind(new TypeLiteral<Optional<PeerInfo>>() {}).toProvider(PluginConfigPeerInfoProvider.class);
+    bind(new TypeLiteral<Optional<PeerInfo>>() {}).toProvider(PeerInfoProvider.class);
+    if (strategy == Configuration.PeerInfoStrategy.JGROUPS) {
+      listener().to(JGroupsPeerInfoProvider.class);
+    }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoProvider.java
new file mode 100644
index 0000000..2649f50
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoProvider.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability.peers;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfoStrategy;
+import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.JGroupsPeerInfoProvider;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+@Singleton
+public class PeerInfoProvider implements Provider<Optional<PeerInfo>> {
+
+  private final Provider<Optional<PeerInfo>> configuredProvider;
+
+  @Inject
+  PeerInfoProvider(Injector injector, Configuration cfg) {
+    PeerInfoStrategy strategy = cfg.peerInfo().strategy();
+    switch (strategy) {
+      case STATIC:
+        configuredProvider = injector.getInstance(PluginConfigPeerInfoProvider.class);
+        break;
+      case JGROUPS:
+        configuredProvider = injector.getInstance(JGroupsPeerInfoProvider.class);
+        break;
+      default:
+        throw new IllegalArgumentException("Unsupported peer info strategy: " + strategy);
+    }
+  }
+
+  @Override
+  public Optional<PeerInfo> get() {
+    return configuredProvider.get();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PluginConfigPeerInfoProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PluginConfigPeerInfoProvider.java
index a78b397..f9f182b 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PluginConfigPeerInfoProvider.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PluginConfigPeerInfoProvider.java
@@ -15,9 +15,9 @@
 package com.ericsson.gerrit.plugins.highavailability.peers;
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.google.common.base.Optional;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Optional;
 
 public class PluginConfigPeerInfoProvider implements Provider<Optional<PeerInfo>> {
 
@@ -25,7 +25,7 @@
 
   @Inject
   PluginConfigPeerInfoProvider(Configuration cfg) {
-    peerInfo = Optional.of(new PeerInfo(cfg.peerInfo().url()));
+    peerInfo = Optional.of(new PeerInfo(cfg.peerInfoStatic().url()));
   }
 
   @Override
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java
new file mode 100644
index 0000000..04c67d4
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability.peers.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Optional;
+
+@Singleton
+public class InetAddressFinder {
+
+  private final boolean preferIPv4;
+  private final Configuration.PeerInfoJGroups jgroupsConfig;
+
+  @Inject
+  InetAddressFinder(Configuration pluginConfiguration) {
+    preferIPv4 = Boolean.getBoolean("java.net.preferIPv4Stack");
+    jgroupsConfig = pluginConfiguration.peerInfoJGroups();
+  }
+
+  /**
+   * Iterate over all network interfaces and return the first appropriate address. Interfaces which
+   * are loopback interfaces, or down or which don't support multicast are not inspected. Interfaces
+   * whose name matches a {@code #skipInterface} are also ignored. By that it is possible to skip
+   * interfaces which should not be used by jgroups (e.g. 'lo0', 'utun0' on MacOS).
+   *
+   * @return an Optional<InetAddress>
+   */
+  public Optional<InetAddress> findAddress() throws SocketException {
+    return findFirstAppropriateAddress(Collections.list(NetworkInterface.getNetworkInterfaces()));
+  }
+
+  @VisibleForTesting
+  Optional<InetAddress> findFirstAppropriateAddress(List<NetworkInterface> networkInterfaces)
+      throws SocketException {
+    for (NetworkInterface ni : networkInterfaces) {
+      if (ni.isLoopback() || !ni.isUp() || !ni.supportsMulticast()) {
+        continue;
+      }
+      if (shouldSkip(ni.getName())) {
+        continue;
+      }
+      Enumeration<InetAddress> inetAddresses = ni.getInetAddresses();
+      while (inetAddresses.hasMoreElements()) {
+        InetAddress a = inetAddresses.nextElement();
+        if (preferIPv4 && a instanceof Inet4Address) {
+          return Optional.of(a);
+        }
+        if (!preferIPv4 && a instanceof Inet6Address) {
+          return Optional.of(a);
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  @VisibleForTesting
+  boolean shouldSkip(String name) {
+    for (String s : jgroupsConfig.skipInterface()) {
+      if (s.endsWith("*") && name.startsWith(s.substring(0, s.length() - 1))) {
+        return true;
+      }
+      if (name.equals(s)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java
new file mode 100644
index 0000000..39ba578
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability.peers.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.util.Optional;
+import org.jgroups.Address;
+import org.jgroups.JChannel;
+import org.jgroups.Message;
+import org.jgroups.ReceiverAdapter;
+import org.jgroups.View;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provider which uses JGroups to find the peer gerrit instances. On startup every gerrit instance
+ * joins a jgroups channel. Whenever the set of channel members changes each gerrit server publishes
+ * its url to all channel members.
+ *
+ * <p>This provider maintains a list of all members which joined the jgroups channel. This may be
+ * more than two. But will always pick the first node which sent its url as the peer to be returned
+ * by {@link #get()}. It will continue to return that node until that node leaves the jgroups
+ * channel.
+ */
+@Singleton
+public class JGroupsPeerInfoProvider extends ReceiverAdapter
+    implements Provider<Optional<PeerInfo>>, LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(JGroupsPeerInfoProvider.class);
+
+  private final Configuration.PeerInfoJGroups jgroupsConfig;
+  private final InetAddressFinder finder;
+  private final String myUrl;
+
+  private JChannel channel;
+  private Optional<PeerInfo> peerInfo = Optional.empty();
+  private Address peerAddress;
+
+  @Inject
+  JGroupsPeerInfoProvider(
+      Configuration pluginConfiguration, InetAddressFinder finder, MyUrlProvider myUrlProvider) {
+    this.jgroupsConfig = pluginConfiguration.peerInfoJGroups();
+    this.finder = finder;
+    this.myUrl = myUrlProvider.get();
+  }
+
+  @Override
+  public void receive(Message msg) {
+    synchronized (this) {
+      if (peerAddress != null) {
+        return;
+      }
+      peerAddress = msg.getSrc();
+      String url = (String) msg.getObject();
+      peerInfo = Optional.of(new PeerInfo(url));
+      log.info("receive(): Set new peerInfo: {}", url);
+    }
+  }
+
+  @Override
+  public void viewAccepted(View view) {
+    log.info("viewAccepted(view: {}) called", view);
+
+    synchronized (this) {
+      if (view.getMembers().size() > 2) {
+        log.warn(
+            "{} members joined the jgroups channel {}. Only two members are supported. Members: {}",
+            view.getMembers().size(),
+            channel.getName(),
+            view.getMembers());
+      }
+      if (peerAddress != null && !view.getMembers().contains(peerAddress)) {
+        log.info("viewAccepted(): removed peerInfo");
+        peerAddress = null;
+        peerInfo = Optional.empty();
+      }
+    }
+    if (view.size() > 1) {
+      try {
+        channel.send(new Message(null, myUrl));
+      } catch (Exception e) {
+        // channel communication caused an error. Can't do much about it.
+        log.error(
+            "Sending a message over jgroups channel {} failed", jgroupsConfig.clusterName(), e);
+      }
+    }
+  }
+
+  public void connect() {
+    try {
+      channel = new JChannel();
+      Optional<InetAddress> address = finder.findAddress();
+      if (address.isPresent()) {
+        channel.getProtocolStack().getTransport().setBindAddress(address.get());
+      }
+      channel.setReceiver(this);
+      channel.setDiscardOwnMessages(true);
+      channel.connect(jgroupsConfig.clusterName());
+      log.info("Succesfully joined jgroups channel {}", channel);
+    } catch (Exception e) {
+      log.error("joining jgroups channel {} failed", e);
+    }
+  }
+
+  @Override
+  public Optional<PeerInfo> get() {
+    return peerInfo;
+  }
+
+  @Override
+  public void start() {
+    connect();
+  }
+
+  @Override
+  public void stop() {
+    log.info("closing jgroups channel {}", jgroupsConfig.clusterName());
+    channel.close();
+    peerInfo = Optional.empty();
+    peerAddress = null;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProvider.java
new file mode 100644
index 0000000..dbb5d1d
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProvider.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability.peers.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.net.URISyntaxException;
+import java.net.UnknownHostException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.URIish;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MyUrlProvider implements Provider<String> {
+  private static final Logger log = LoggerFactory.getLogger(MyUrlProvider.class);
+
+  private static final String HTTPD_SECTION = "httpd";
+  private static final String LISTEN_URL_KEY = "listenUrl";
+  private static final String LISTEN_URL = HTTPD_SECTION + "." + LISTEN_URL_KEY;
+  private static final String PROXY_PREFIX = "proxy-";
+
+  private final String myUrl;
+
+  @Inject
+  @VisibleForTesting
+  public MyUrlProvider(@GerritServerConfig Config srvConfig, Configuration pluginConfiguration) {
+    String url = pluginConfiguration.peerInfoJGroups().myUrl();
+    if (url == null) {
+      log.info("myUrl not configured; attempting to determine from {}", LISTEN_URL);
+      try {
+        url = CharMatcher.is('/').trimTrailingFrom(getMyUrlFromListenUrl(srvConfig));
+      } catch (MyUrlProviderException e) {
+        throw new ProvisionException(e.getMessage());
+      }
+    }
+    this.myUrl = url;
+  }
+
+  @Override
+  public String get() {
+    return myUrl;
+  }
+
+  private String getMyUrlFromListenUrl(Config srvConfig) throws MyUrlProviderException {
+    String[] listenUrls = srvConfig.getStringList(HTTPD_SECTION, null, LISTEN_URL_KEY);
+    if (listenUrls.length != 1) {
+      throw new MyUrlProviderException(
+          String.format(
+              "Can only determine myUrl from %s when there is exactly 1 value configured; found %d",
+              LISTEN_URL, listenUrls.length));
+    }
+    String url = listenUrls[0];
+    if (url.startsWith(PROXY_PREFIX)) {
+      throw new MyUrlProviderException(
+          String.format(
+              "Cannot determine myUrl from %s when configured as reverse-proxy: %s",
+              LISTEN_URL, url));
+    }
+    if (url.contains("*")) {
+      throw new MyUrlProviderException(
+          String.format(
+              "Cannot determine myUrl from %s when configured with wildcard: %s", LISTEN_URL, url));
+    }
+    try {
+      URIish u = new URIish(url);
+      return u.setHost(InetAddress.getLocalHost().getHostName()).toString();
+    } catch (URISyntaxException | UnknownHostException e) {
+      throw new MyUrlProviderException(
+          String.format(
+              "Unable to determine myUrl from %s value [%s]: %s", LISTEN_URL, url, e.getMessage()));
+    }
+  }
+
+  private static class MyUrlProviderException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    MyUrlProviderException(String message) {
+      super(message);
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 8ec9432..1c7caaf 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -10,21 +10,75 @@
 [main]
 :  sharedDirectory = /directory/accessible/from/both/instances
 [peerInfo]
+:  strategy = static
+[peerInfo "static"]
 :  url = target_instance_url
 [http]
 :  user = username
 :  password = password
 
+[main]
+:  sharedDirectory = /directory/accessible/from/both/instances
+[peerInfo]
+:  strategy = jgroups
+[peerInfo "jgroups"]
+:  myUrl = local_instance_url
+:  cluster = foo
+:  skipInterface = lo*
+:  skipInterface = eth2
+[http]
+:  user = username
+:  password = password
+
 main.sharedDirectory
 :   Path to a directory accessible from both master instances.
     When given as a relative path, then it is resolved against the $SITE_PATH
     or Gerrit server. For example, if $SITE_PATH is "/gerrit/root" and
     sharedDirectory is given as "shared/dir" then the real path of the shared
-    directory is "/gerrit/root/shared/dir".
+    directory is "/gerrit/root/shared/dir". When not specified, the default
+    is "shared".
 
-peerInfo.url
+peerInfo.strategy
+:   Strategy to find other peers. Supported strategies are `static` or `jgroups`.
+    Defaults to `static`.
+* The `static` strategy allows to staticly configure the peer gerrit instance using
+the configuration parameter `peerInfo.static.url`.
+* The `jgroups` strategy allows that a gerrit instance discovers the peer
+instance by using JGroups to send multicast messages. In this case the
+configuration parameters `peerInfo.jgroups.*` are used to control the sending of
+the multicast messages. During startup each instance will advertise its address
+over a JGroups multicast message. JGroups takes care to inform each cluster when
+a member joins or leaves the cluster.
+
+peerInfo.static.url
 :   Specify the URL for the peer instance.
 
+peerInfo.jgroups.clusterName
+:   The name of the high-availability cluster. When peers discover themselves dynamically this
+    name is used to determine which instances should work together.  Only those Gerrit
+    interfaces which are configured for the same clusterName will communicate with each other.
+    Defaults to "GerritHA".
+
+peerInfo.jgroups.skipInterface
+:   A name or a wildcard of network interface(s) which should be skipped
+    for JGroups communication. Peer discovery may fail if the host has multiple
+    network interfaces and an inappropriate interface is chosen by JGroups.
+    This option can be repeated many times in the `jgroups` section.
+    Defaults to the list of: `lo*`, `utun*`, `awdl*` which are known to be
+    inappropriate for JGroups communication.
+
+peerInfo.jgroups.myUrl
+:   The URL of this instance to be broadcast to other peers. If not specified, the
+    URL is determined from the `httpd.listenUrl` in the `gerrit.config`.
+    If `httpd.listenUrl` is configured with multiple values, is configured to work
+    with a reverse proxy (i.e. uses `proxy-http` or `proxy-https` scheme), or is
+    configured to listen on all local addresses (i.e. using hostname `*`), then
+    the URL must be explicitly specified with `myUrl`.
+
+NOTE: To work properly in certain environments, JGroups needs the System property
+`java.net.preferIPv4Stack` to be set to `true`.
+See (http://jgroups.org/tutorial/index.html#_trouble_shooting).
+
 http.user
 :   Username to connect to the peer instance.
 
@@ -62,6 +116,13 @@
 :   Maximum number of threads used to send cache evictions to the target instance.
     Defaults to 1.
 
+cache.pattern
+:   Pattern to match names of custom caches for which evictions should be
+    forwarded (in addition to the core caches that are always forwarded). May be
+    specified more than once to add multiple patterns.
+    Defaults to an empty list, meaning only evictions of the core caches are
+    forwarded.
+
 event.synchronize
 :   Whether to synchronize stream events.
     Defaults to true.
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
index a0246f6..b1d9fcc 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
@@ -16,33 +16,49 @@
 
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.CACHE_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.CLEANUP_INTERVAL_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.CLUSTER_NAME_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.CONNECTION_TIMEOUT_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_CLEANUP_INTERVAL_MS;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_CLUSTER_NAME;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_MAX_TRIES;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_PEER_INFO_STRATEGY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_RETRY_INTERVAL;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_SKIP_INTERFACE_LIST;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_SYNCHRONIZE;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_THREAD_POOL_SIZE;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_TIMEOUT_MS;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.EVENT_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.HTTP_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.INDEX_SECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.JGROUPS_SUBSECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAIN_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.MAX_TRIES_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.MY_URL_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PASSWORD_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.PATTERN_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PEER_INFO_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.RETRY_INTERVAL_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.SHARED_DIRECTORY_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.SKIP_INTERFACE_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.SOCKET_TIMEOUT_KEY;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.STATIC_SUBSECTION;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.STRATEGY_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.SYNCHRONIZE_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.THREAD_POOL_SIZE_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.URL_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.USER_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.WEBSESSION_SECTION;
 import static com.google.common.truth.Truth.assertThat;
+import static java.net.InetAddress.getLocalHost;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import com.ericsson.gerrit.plugins.highavailability.cache.CachePatternMatcher;
+import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.MyUrlProvider;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.ProvisionException;
@@ -51,7 +67,9 @@
 import java.nio.file.Paths;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.ExpectedException;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
@@ -60,7 +78,7 @@
 public class ConfigurationTest {
   private static final String PASS = "fakePass";
   private static final String USER = "fakeUser";
-  private static final String URL = "fakeUrl";
+  private static final String URL = "http://fakeUrl";
   private static final String EMPTY = "";
   private static final int TIMEOUT = 5000;
   private static final int MAX_TRIES = 5;
@@ -71,18 +89,26 @@
   private static final String RELATIVE_SHARED_DIRECTORY = "relative/dir";
   private static final Path SITE_PATH = Paths.get("/site_path");
   private static final String ERROR_MESSAGE = "some error message";
+  private static final String[] CUSTOM_CACHE_PATTERNS = {"^my_cache.*", "other"};
 
   @Mock private PluginConfigFactory cfgFactoryMock;
   @Mock private Config configMock;
+  @Mock private Config serverConfigMock;
   private SitePaths site;
   private Configuration configuration;
   private String pluginName = "high-availability";
 
+  @Rule public ExpectedException exception = ExpectedException.none();
+
   @Before
   public void setUp() throws IOException {
     when(cfgFactoryMock.getGlobalPluginConfig(pluginName)).thenReturn(configMock);
     when(configMock.getString(MAIN_SECTION, null, SHARED_DIRECTORY_KEY))
         .thenReturn(SHARED_DIRECTORY);
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(DEFAULT_PEER_INFO_STRATEGY);
+    when(configMock.getStringList(CACHE_SECTION, null, PATTERN_KEY))
+        .thenReturn(CUSTOM_CACHE_PATTERNS);
     site = new SitePaths(SITE_PATH);
   }
 
@@ -91,21 +117,183 @@
   }
 
   @Test
+  public void testGetPeerInfoStrategy() {
+    initializeConfiguration();
+    assertThat(configuration.peerInfo().strategy()).isSameAs(DEFAULT_PEER_INFO_STRATEGY);
+
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+    assertThat(configuration.peerInfo().strategy())
+        .isSameAs(Configuration.PeerInfoStrategy.JGROUPS);
+  }
+
+  @Test
   public void testGetUrl() throws Exception {
     initializeConfiguration();
-    assertThat(configuration.peerInfo().url()).isEqualTo(EMPTY);
+    assertThat(configuration.peerInfoStatic().url()).isEqualTo(EMPTY);
 
-    when(configMock.getString(PEER_INFO_SECTION, null, URL_KEY)).thenReturn(URL);
+    when(configMock.getString(PEER_INFO_SECTION, STATIC_SUBSECTION, URL_KEY)).thenReturn(URL);
     initializeConfiguration();
-    assertThat(configuration.peerInfo().url()).isEqualTo(URL);
+    assertThat(configuration.peerInfoStatic().url()).isEqualTo(URL);
   }
 
   @Test
   public void testGetUrlIsDroppingTrailingSlash() throws Exception {
-    when(configMock.getString(PEER_INFO_SECTION, null, URL_KEY)).thenReturn(URL + "/");
+    when(configMock.getString(PEER_INFO_SECTION, STATIC_SUBSECTION, URL_KEY)).thenReturn(URL + "/");
     initializeConfiguration();
     assertThat(configuration).isNotNull();
-    assertThat(configuration.peerInfo().url()).isEqualTo(URL);
+    assertThat(configuration.peerInfoStatic().url()).isEqualTo(URL);
+  }
+
+  @Test
+  public void testJGroupsPeerInfoNullWhenStaticPeerInfoConfig() throws Exception {
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups()).isNull();
+  }
+
+  @Test
+  public void testGetJGroupsChannel() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().clusterName()).isEqualTo(DEFAULT_CLUSTER_NAME);
+
+    when(configMock.getString(PEER_INFO_SECTION, JGROUPS_SUBSECTION, CLUSTER_NAME_KEY))
+        .thenReturn("foo");
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().clusterName()).isEqualTo("foo");
+  }
+
+  @Test
+  public void testGetJGroupsSkipInterface() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().skipInterface())
+        .isEqualTo(DEFAULT_SKIP_INTERFACE_LIST);
+
+    when(configMock.getStringList(PEER_INFO_SECTION, JGROUPS_SUBSECTION, SKIP_INTERFACE_KEY))
+        .thenReturn(new String[] {"lo*", "eth0"});
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().skipInterface())
+        .containsAllOf("lo*", "eth0")
+        .inOrder();
+  }
+
+  @Test
+  public void testGetJGroupsMyUrl() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().myUrl()).isNull();
+
+    when(configMock.getString(PEER_INFO_SECTION, JGROUPS_SUBSECTION, MY_URL_KEY)).thenReturn(URL);
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().myUrl()).isEqualTo(URL);
+
+    when(configMock.getString(PEER_INFO_SECTION, JGROUPS_SUBSECTION, MY_URL_KEY))
+        .thenReturn(URL + "/");
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().myUrl()).isEqualTo(URL);
+  }
+
+  @Test
+  public void testGetJGroupsMyUrlFromListenUrlWhenNoListenUrlSpecified() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl")).thenReturn(new String[] {});
+
+    exception.expect(ProvisionException.class);
+    exception.expectMessage("exactly 1 value configured; found 0");
+    getMyUrlProvider();
+  }
+
+  @Test
+  public void testGetJGroupsMyUrlFromListenUrlWhenMultipleListenUrlsSpecified() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl"))
+        .thenReturn(new String[] {"a", "b"});
+
+    exception.expect(ProvisionException.class);
+    exception.expectMessage("exactly 1 value configured; found 2");
+    getMyUrlProvider();
+  }
+
+  @Test
+  public void testGetJGroupsMyUrlFromListenUrlWhenReverseProxyConfigured() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl"))
+        .thenReturn(new String[] {"proxy-https://foo"});
+
+    exception.expect(ProvisionException.class);
+    exception.expectMessage("when configured as reverse-proxy");
+    getMyUrlProvider();
+  }
+
+  @Test
+  public void testGetJGroupsMyUrlFromListenUrlWhenWildcardConfigured() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    initializeConfiguration();
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl"))
+        .thenReturn(new String[] {"https://*"});
+
+    exception.expect(ProvisionException.class);
+    exception.expectMessage("when configured with wildcard");
+    getMyUrlProvider();
+  }
+
+  @Test
+  public void testGetJGroupsMyUrlOverridesListenUrl() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    when(configMock.getString(PEER_INFO_SECTION, JGROUPS_SUBSECTION, MY_URL_KEY)).thenReturn(URL);
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().myUrl()).isEqualTo(URL);
+
+    verify(serverConfigMock, never()).getStringList("httpd", null, "listenUrl");
+    assertThat(getMyUrlProvider().get()).isEqualTo(URL);
+  }
+
+  @Test
+  public void testGetJGroupsMyUrlFromListenUrl() throws Exception {
+    when(configMock.getEnum(PEER_INFO_SECTION, null, STRATEGY_KEY, DEFAULT_PEER_INFO_STRATEGY))
+        .thenReturn(Configuration.PeerInfoStrategy.JGROUPS);
+    when(configMock.getString(PEER_INFO_SECTION, JGROUPS_SUBSECTION, MY_URL_KEY)).thenReturn(null);
+    initializeConfiguration();
+    assertThat(configuration.peerInfoJGroups().myUrl()).isNull();
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl"))
+        .thenReturn(new String[] {"https://foo:8080"});
+
+    String hostName = getLocalHost().getHostName();
+    String expected = "https://" + hostName + ":8080";
+    assertThat(getMyUrlProvider().get()).isEqualTo(expected);
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl"))
+        .thenReturn(new String[] {"https://foo"});
+
+    expected = "https://" + hostName;
+    assertThat(getMyUrlProvider().get()).isEqualTo(expected);
+
+    when(serverConfigMock.getStringList("httpd", null, "listenUrl"))
+        .thenReturn(new String[] {"https://foo/"});
+
+    assertThat(getMyUrlProvider().get()).isEqualTo(expected);
+  }
+
+  private MyUrlProvider getMyUrlProvider() {
+    return new MyUrlProvider(serverConfigMock, configuration);
   }
 
   @Test
@@ -327,4 +515,23 @@
     initializeConfiguration();
     assertThat(configuration.websession().synchronize()).isTrue();
   }
+
+  @Test
+  public void testGetCachePatterns() throws Exception {
+    initializeConfiguration();
+    CachePatternMatcher matcher = new CachePatternMatcher(configuration);
+    for (String cache :
+        ImmutableList.of(
+            "accounts_byemail",
+            "ldap_groups",
+            "project_list",
+            "my_cache_a",
+            "my_cache_b",
+            "other")) {
+      assertThat(matcher.matches(cache)).isTrue();
+    }
+    for (String cache : ImmutableList.of("ldap_groups_by_include", "foo")) {
+      assertThat(matcher.matches(cache)).isFalse();
+    }
+  }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionIT.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionIT.java
index f3d6a9d..97c496a 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionIT.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionIT.java
@@ -55,7 +55,12 @@
 
   @Test
   @UseLocalDisk
-  @GlobalPluginConfig(pluginName = "high-availability", name = "peerInfo.url", value = URL)
+  @GlobalPluginConfig(
+    pluginName = "high-availability",
+    name = "peerInfo.strategy",
+    value = "static"
+  )
+  @GlobalPluginConfig(pluginName = "high-availability", name = "peerInfo.static.url", value = URL)
   @GlobalPluginConfig(pluginName = "high-availability", name = "http.user", value = "admin")
   @GlobalPluginConfig(pluginName = "high-availability", name = "cache.threadPoolSize", value = "10")
   @GlobalPluginConfig(
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java
index c0d13c8..84ac4db 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheRestApiServletTest.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
 
+import static com.google.common.truth.Truth.assertThat;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
 import static org.mockito.Mockito.doThrow;
@@ -22,6 +23,7 @@
 import static org.mockito.Mockito.when;
 
 import com.ericsson.gerrit.plugins.highavailability.cache.Constants;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.CacheRestApiServlet.CacheParameters;
 import com.google.common.cache.Cache;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import java.io.BufferedReader;
@@ -74,7 +76,13 @@
   @Test
   public void evictGroupsMembers() throws Exception {
     configureMocksFor(Constants.GROUPS_MEMBERS);
-    servlet.doPost(request, response);
+    verifyResponseIsOK();
+  }
+
+  @Test
+  public void evictPluginCache() throws Exception {
+    configureMocksFor("my-plugin", "my-cache");
+    verifyResponseIsOK();
   }
 
   @Test
@@ -83,11 +91,6 @@
     verifyResponseIsOK();
   }
 
-  private void verifyResponseIsOK() throws Exception {
-    servlet.doPost(request, response);
-    verify(response).setStatus(SC_NO_CONTENT);
-  }
-
   @Test
   public void badRequest() throws Exception {
     when(request.getPathInfo()).thenReturn("/someCache");
@@ -106,10 +109,34 @@
     verify(response).sendError(SC_BAD_REQUEST, errorMessage);
   }
 
+  @Test
+  public void cacheParameters() throws Exception {
+    CacheParameters key = CacheRestApiServlet.getCacheParameters("accounts_by_name");
+    assertThat(key.pluginName).isEqualTo(Constants.GERRIT);
+    assertThat(key.cacheName).isEqualTo("accounts_by_name");
+
+    key = CacheRestApiServlet.getCacheParameters("my_plugin.my_cache");
+    assertThat(key.pluginName).isEqualTo("my_plugin");
+    assertThat(key.cacheName).isEqualTo("my_cache");
+  }
+
+  private void verifyResponseIsOK() throws Exception {
+    servlet.doPost(request, response);
+    verify(response).setStatus(SC_NO_CONTENT);
+  }
+
+  private void configureMocksFor(String cacheName) throws Exception {
+    configureMocksFor(Constants.GERRIT, cacheName);
+  }
+
   @SuppressWarnings("unchecked")
-  private void configureMocksFor(String cacheName) throws IOException {
-    when(cacheMap.get("gerrit", cacheName)).thenReturn(mock(Cache.class));
-    when(request.getPathInfo()).thenReturn("/" + cacheName);
+  private void configureMocksFor(String pluginName, String cacheName) throws Exception {
+    when(cacheMap.get(pluginName, cacheName)).thenReturn(mock(Cache.class));
+    if (Constants.GERRIT.equals(pluginName)) {
+      when(request.getPathInfo()).thenReturn("/" + cacheName);
+    } else {
+      when(request.getPathInfo()).thenReturn("/" + pluginName + "." + cacheName);
+    }
     when(request.getReader()).thenReturn(reader);
 
     if (Constants.PROJECTS.equals(cacheName)) {
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java
index d5e27b1..2a4f9cc 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java
@@ -34,10 +34,10 @@
 import com.github.tomakehurst.wiremock.http.Fault;
 import com.github.tomakehurst.wiremock.junit.WireMockRule;
 import com.github.tomakehurst.wiremock.stubbing.Scenario;
-import com.google.common.base.Optional;
 import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.net.SocketTimeoutException;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -186,7 +186,7 @@
   public void testNoRequestWhenPeerInfoUnknown() throws IOException {
     httpSession =
         new HttpSession(
-            new HttpClientProvider(configMock).get(), Providers.of(Optional.<PeerInfo>absent()));
+            new HttpClientProvider(configMock).get(), Providers.of(Optional.<PeerInfo>empty()));
     try {
       httpSession.post(ENDPOINT);
       fail("Expected PeerInfoNotAvailableException");
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinderTest.java
new file mode 100644
index 0000000..7b1dea4
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinderTest.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2017 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.ericsson.gerrit.plugins.highavailability.peers.jgroups;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.collect.ImmutableList;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class InetAddressFinderTest {
+
+  @Mock private Configuration configuration;
+  @Mock private Configuration.PeerInfoJGroups jgroupsConfig;
+  private InetAddressFinder finder;
+
+  @Before
+  public void setUp() {
+    when(configuration.peerInfoJGroups()).thenReturn(jgroupsConfig);
+    finder = new InetAddressFinder(configuration);
+  }
+
+  @Test
+  public void testNoSkipWhenEmptySkipList() {
+    when(jgroupsConfig.skipInterface()).thenReturn(ImmutableList.<String>of());
+    assertThat(finder.shouldSkip("foo")).isFalse();
+    assertThat(finder.shouldSkip("bar")).isFalse();
+  }
+
+  @Test
+  public void testSkipByName() {
+    when(jgroupsConfig.skipInterface()).thenReturn(ImmutableList.of("foo"));
+    assertThat(finder.shouldSkip("foo")).isTrue();
+    assertThat(finder.shouldSkip("bar")).isFalse();
+
+    when(jgroupsConfig.skipInterface()).thenReturn(ImmutableList.of("foo", "bar"));
+    assertThat(finder.shouldSkip("foo")).isTrue();
+    assertThat(finder.shouldSkip("bar")).isTrue();
+  }
+
+  @Test
+  public void testSkipByWildcard() {
+    when(jgroupsConfig.skipInterface()).thenReturn(ImmutableList.of("foo*"));
+    assertThat(finder.shouldSkip("foo")).isTrue();
+    assertThat(finder.shouldSkip("foo1")).isTrue();
+    assertThat(finder.shouldSkip("bar")).isFalse();
+  }
+}