Introduce endpoint to query replication-lag

Introduce an HTTP endpoint that can be called by admin users in order to
understand the replication lag status by project.

Bug: Issue 15309
Change-Id: Iec196d7aed4f585353b8a6549cb89406e95715ac
diff --git a/BUILD b/BUILD
index 39e1bc3..d7bd3d8 100644
--- a/BUILD
+++ b/BUILD
@@ -12,6 +12,7 @@
     manifest_entries = [
         "Gerrit-PluginName: multi-site",
         "Gerrit-Module: com.googlesource.gerrit.plugins.multisite.PluginModule",
+        "Gerrit-HttpModule: com.googlesource.gerrit.plugins.multisite.http.HttpModule",
         "Implementation-Title: multi-site plugin",
         "Implementation-URL: https://review.gerrithub.io/admin/repos/GerritForge/plugins_multi-site",
     ],
diff --git a/README.md b/README.md
index 34f7709..79094d3 100644
--- a/README.md
+++ b/README.md
@@ -86,3 +86,8 @@
 You also need to setup the Git-level replication between nodes, for more details
 please refer to the
 [replication plugin documentation](https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md).
+
+# HTTP endpoints
+
+For information about available HTTP endpoints please refer to
+the [documentation](src/main/resources/Documentation/http-endpoints.md).
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/consumer/ReplicationStatus.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/consumer/ReplicationStatus.java
index cac9913..45e66a6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/multisite/consumer/ReplicationStatus.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/consumer/ReplicationStatus.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.multisite.consumer;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.inject.Inject;
@@ -23,8 +24,10 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.stream.Collectors;
 
 @Singleton
 public class ReplicationStatus {
@@ -50,7 +53,19 @@
     return Collections.max(lags);
   }
 
-  void updateReplicationLag(Project.NameKey projectName) {
+  public Map<String, Long> getReplicationLags(Integer limit) {
+    return replicationStatusPerProject.entrySet().stream()
+        .sorted((c1, c2) -> c2.getValue().compareTo(c1.getValue()))
+        .limit(limit)
+        .collect(
+            Collectors.toMap(
+                Map.Entry::getKey,
+                Map.Entry::getValue,
+                (oldValue, newValue) -> oldValue,
+                LinkedHashMap::new));
+  }
+
+  public void updateReplicationLag(Project.NameKey projectName) {
     Optional<Long> remoteVersion =
         projectVersionRefUpdate.getProjectRemoteVersion(projectName.get());
     Optional<Long> localVersion = projectVersionRefUpdate.getProjectLocalVersion(projectName.get());
@@ -62,7 +77,7 @@
         logger.atFine().log(
             "Updated replication lag for project '%s' of %d sec(s) [local-ref=%d global-ref=%d]",
             projectName, lag, localVersion.get(), remoteVersion.get());
-        replicationStatusPerProject.put(projectName.get(), lag);
+        doUpdateLag(projectName, lag);
         localVersionPerProject.put(projectName.get(), localVersion.get());
         verLogger.log(projectName, localVersion.get(), lag);
       }
@@ -72,4 +87,9 @@
           projectName, localVersion.isPresent() ? "remote" : "local");
     }
   }
+
+  @VisibleForTesting
+  public void doUpdateLag(Project.NameKey projectName, Long lag) {
+    replicationStatusPerProject.put(projectName.get(), lag);
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/http/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/http/HttpModule.java
new file mode 100644
index 0000000..2ae2d9b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/http/HttpModule.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.multisite.http;
+
+import com.google.inject.servlet.ServletModule;
+
+public class HttpModule extends ServletModule {
+
+  public static final String LAG_ENDPOINT_SEGMENT = "replication-lag";
+
+  @Override
+  protected void configureServlets() {
+    serve(String.format("/%s", LAG_ENDPOINT_SEGMENT)).with(ReplicationStatusServlet.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/http/ReplicationStatusServlet.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/http/ReplicationStatusServlet.java
new file mode 100644
index 0000000..e2b3e18
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/http/ReplicationStatusServlet.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.multisite.http;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.multisite.consumer.ReplicationStatus;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Optional;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class ReplicationStatusServlet extends HttpServlet {
+  protected static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String LIMIT_RESULT_PARAMETER = "limit";
+  private static final long serialVersionUID = 1L;
+  private static final Integer DEFAULT_LIMIT_RESULT_PARAMETER = 10;
+
+  private final Gson gson;
+  private final ReplicationStatus replicationStatus;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  ReplicationStatusServlet(
+      Gson gson, ReplicationStatus replicationStatus, PermissionBackend permissionBackend) {
+    this.gson = gson;
+    this.replicationStatus = replicationStatus;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  protected void doGet(
+      HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)
+      throws ServletException, IOException {
+    if (!permissionBackend.currentUser().testOrFalse(ADMINISTRATE_SERVER)) {
+      setResponse(
+          httpServletResponse,
+          HttpServletResponse.SC_FORBIDDEN,
+          String.format("%s permissions required. Operation not permitted", ADMINISTRATE_SERVER));
+      return;
+    }
+
+    int limitResult =
+        Optional.ofNullable(httpServletRequest.getParameter(LIMIT_RESULT_PARAMETER))
+            .map(Integer::parseInt)
+            .orElse(DEFAULT_LIMIT_RESULT_PARAMETER);
+
+    setResponse(
+        httpServletResponse,
+        HttpServletResponse.SC_OK,
+        gson.toJson(replicationStatus.getReplicationLags(limitResult)));
+  }
+
+  static void setResponse(HttpServletResponse httpResponse, int statusCode, String value)
+      throws IOException {
+    httpResponse.setContentType("application/json");
+    httpResponse.setStatus(statusCode);
+    PrintWriter writer = httpResponse.getWriter();
+    writer.print(new String(RestApiServlet.JSON_MAGIC));
+    writer.print(value);
+  }
+}
diff --git a/src/main/resources/Documentation/http-endpoints.md b/src/main/resources/Documentation/http-endpoints.md
new file mode 100644
index 0000000..ff56fdf
--- /dev/null
+++ b/src/main/resources/Documentation/http-endpoints.md
@@ -0,0 +1,42 @@
+HTTP endpoints
+=========================
+
+The @PLUGIN@ plugin also provides HTTP endpoints, as described here below:
+
+## replication-lag
+
+Admin users can query for the replication lag in order to understand what
+projects' replication is running behind and by how much (in milliseconds).
+
+The results are returned in a map ordered in descending order by the replication
+lag, so that the most behind projects are returned first.
+
+Whilst some lag information is also available as a metric (see
+the [documentation](./about.md#metrics)), this endpoint provides more
+information since it shows _which_ project is associated to _which specific lag_.
+
+You can query the endpoint (at the receiving end of the replication) as follows:
+
+```bash
+curl -v -XGET -u <admin> '<gerrit>/a/plugins/multi-site/replication-lag?[limit=LIMIT]'
+```
+
+Output example:
+
+```
+)]}'
+{
+  "All-Users": 62,
+  "bar": 13,
+  "foo": 0,
+  "some/other/project": 1451,
+  "baz": 6432
+}
+```
+
+Optionally the REST endpoint can receive the following additional arguments:
+
+* limit=LIMIT
+
+maximum number of projects to return
+*default:10*
\ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/multisite/http/ReplicationStatusServletIT.java b/src/test/java/com/googlesource/gerrit/plugins/multisite/http/ReplicationStatusServletIT.java
new file mode 100644
index 0000000..3b5f9a6
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/multisite/http/ReplicationStatusServletIT.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.multisite.http;
+
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.multisite.http.HttpModule.LAG_ENDPOINT_SEGMENT;
+
+import com.gerritforge.gerrit.globalrefdb.validation.Log4jSharedRefLogger;
+import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbConfiguration;
+import com.gerritforge.gerrit.globalrefdb.validation.SharedRefLogger;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.inject.AbstractModule;
+import com.googlesource.gerrit.plugins.multisite.Log4jProjectVersionLogger;
+import com.googlesource.gerrit.plugins.multisite.ProjectVersionLogger;
+import com.googlesource.gerrit.plugins.multisite.cache.CacheModule;
+import com.googlesource.gerrit.plugins.multisite.consumer.ReplicationStatus;
+import com.googlesource.gerrit.plugins.multisite.forwarder.ForwarderModule;
+import com.googlesource.gerrit.plugins.multisite.forwarder.router.RouterModule;
+import com.googlesource.gerrit.plugins.multisite.index.IndexModule;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "multi-site",
+    sysModule =
+        "com.googlesource.gerrit.plugins.multisite.http.ReplicationStatusServletIT$TestModule",
+    httpModule = "com.googlesource.gerrit.plugins.multisite.http.HttpModule")
+public class ReplicationStatusServletIT extends LightweightPluginDaemonTest {
+  private static final String APPLICATION_JSON = "application/json";
+  private static final String LAG_ENDPOINT =
+      String.format("/plugins/multi-site/%s", LAG_ENDPOINT_SEGMENT);
+  private ReplicationStatus replicationStatus;
+
+  public static class TestModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      install(new ForwarderModule());
+      install(new CacheModule());
+      install(new RouterModule());
+      install(new IndexModule());
+      SharedRefDbConfiguration sharedRefDbConfig =
+          new SharedRefDbConfiguration(new Config(), "multi-site");
+      bind(SharedRefDbConfiguration.class).toInstance(sharedRefDbConfig);
+      bind(ProjectVersionLogger.class).to(Log4jProjectVersionLogger.class);
+      bind(SharedRefLogger.class).to(Log4jSharedRefLogger.class);
+    }
+  }
+
+  @Before
+  public void setUp() throws IOException {
+    replicationStatus = plugin.getSysInjector().getInstance(ReplicationStatus.class);
+  }
+
+  @Test
+  public void shouldSucceedForAdminUsers() throws Exception {
+    RestResponse result = adminRestSession.get(LAG_ENDPOINT);
+
+    result.assertOK();
+    assertThat(result.getHeader(CONTENT_TYPE)).contains(APPLICATION_JSON);
+  }
+
+  @Test
+  public void shouldFailWhenUserHasNoAdminServerCapability() throws Exception {
+    RestResponse result = userRestSession.get(LAG_ENDPOINT);
+    result.assertForbidden();
+    assertThat(result.getEntityContent()).contains("not permitted");
+  }
+
+  @Test
+  public void shouldReturnCurrentProjectLag() throws Exception {
+    replicationStatus.doUpdateLag(Project.nameKey("foo"), 123L);
+
+    RestResponse result = adminRestSession.get(LAG_ENDPOINT);
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result)).isEqualTo("{\"foo\":123}");
+  }
+
+  @Test
+  public void shouldReturnProjectsOrderedDescendinglyByLag() throws Exception {
+    replicationStatus.doUpdateLag(Project.nameKey("bar"), 123L);
+    replicationStatus.doUpdateLag(Project.nameKey("foo"), 3L);
+    replicationStatus.doUpdateLag(Project.nameKey("baz"), 52300L);
+
+    RestResponse result = adminRestSession.get(LAG_ENDPOINT);
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result)).isEqualTo("{\"baz\":52300,\"bar\":123,\"foo\":3}");
+  }
+
+  @Test
+  public void shouldHonourTheLimitParameter() throws Exception {
+    replicationStatus.doUpdateLag(Project.nameKey("bar"), 1L);
+    replicationStatus.doUpdateLag(Project.nameKey("foo"), 2L);
+    replicationStatus.doUpdateLag(Project.nameKey("baz"), 3L);
+
+    RestResponse result = adminRestSession.get(String.format("%s?limit=2", LAG_ENDPOINT));
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result)).isEqualTo("{\"baz\":3,\"foo\":2}");
+  }
+
+  private String contentWithoutMagicJson(RestResponse response) throws IOException {
+    return response.getEntityContent().substring(RestApiServlet.JSON_MAGIC.length);
+  }
+}