Support flushing a set of caches at once via REST

Change-Id: I2e51e3138eea15b305c3fc5029e91aa6b2f0b4cb
Signed-off-by: Edwin Kempin <edwin.kempin@sap.com>
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 6f9ef46..8d9b34c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -366,6 +366,28 @@
   HTTP/1.1 200 OK
 ----
 
+[[flush-several-caches]]
+==== Flush Several Caches At Once
+
+.Request
+----
+  POST /config/server/caches/ HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "operation": "FLUSH"
+    "caches": [
+      "projects",
+      "project_list"
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
 [[get-cache]]
 === Get Cache
 --
@@ -621,14 +643,20 @@
 The `CacheOperationInput` entity contains information about an
 operation that should be executed on caches.
 
-[options="header",width="50%",cols="1,6"]
-|=================================
-|Field Name           |Description
-|`operation`          |
+[options="header",width="50%",cols="1,^1,5"]
+|==================================
+|Field Name           ||Description
+|`operation`          ||
 The cache operation that should be executed:
 
 `FLUSH_ALL`: Flushes all caches, except the `web_sessions` cache.
-|=================================
+
+`FLUSH`: Flushes the specified caches.
+|`caches`             |optional|
+A list of cache names. This list defines the caches on which the
+specified operation should be executed. Whether this list must be
+specified depends on the operation being executed.
+|==================================
 
 [[entries-info]]
 === EntriesInfo
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 0038a54..d2174bc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -14,24 +14,44 @@
 
 package com.google.gerrit.acceptance.rest.config;
 
+import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH;
 import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH_ALL;
-
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.Util.allow;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PostCaches;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
 
 import org.apache.http.HttpStatus;
 import org.junit.Test;
 
 import java.io.IOException;
+import java.util.Arrays;
 
 public class CacheOperationsIT extends AbstractDaemonTest {
 
+  @Inject
+  private ProjectCache projectCache;
+
+  @Inject
+  private AllProjectsName allProjects;
+
+  @Inject
+  private MetaDataUpdate.Server metaDataUpdateFactory;
+
   @Test
   public void flushAll() throws IOException {
     RestResponse r = adminSession.get("/config/server/caches/project_list");
@@ -53,4 +73,94 @@
         new PostCaches.Input(FLUSH_ALL));
     assertEquals(HttpStatus.SC_FORBIDDEN, r.getStatusCode());
   }
+
+  @Test
+  public void flushAll_BadRequest() throws IOException {
+    RestResponse r = adminSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")));
+    assertEquals(HttpStatus.SC_BAD_REQUEST, r.getStatusCode());
+  }
+
+  @Test
+  public void flush() throws IOException {
+    RestResponse r = adminSession.get("/config/server/caches/project_list");
+    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertTrue(cacheInfo.entries.mem.longValue() > 0);
+
+    r = adminSession.get("/config/server/caches/projects");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertTrue(cacheInfo.entries.mem.longValue() > 1);
+
+    r = adminSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
+    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
+    r.consume();
+
+    r = adminSession.get("/config/server/caches/project_list");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertNull(cacheInfo.entries.mem);
+
+    r = adminSession.get("/config/server/caches/projects");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertTrue(cacheInfo.entries.mem.longValue() > 1);
+  }
+
+  @Test
+  public void flush_Forbidden() throws IOException {
+    RestResponse r = userSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH, Arrays.asList("projects")));
+    assertEquals(HttpStatus.SC_FORBIDDEN, r.getStatusCode());
+  }
+
+  @Test
+  public void flush_BadRequest() throws IOException {
+    RestResponse r = adminSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH));
+    assertEquals(HttpStatus.SC_BAD_REQUEST, r.getStatusCode());
+  }
+
+  @Test
+  public void flush_UnprocessableEntity() throws IOException {
+    RestResponse r = adminSession.get("/config/server/caches/projects");
+    CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertTrue(cacheInfo.entries.mem.longValue() > 0);
+
+    r = adminSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
+    assertEquals(HttpStatus.SC_UNPROCESSABLE_ENTITY, r.getStatusCode());
+    r.consume();
+
+    r = adminSession.get("/config/server/caches/projects");
+    cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
+    assertTrue(cacheInfo.entries.mem.longValue() > 0);
+  }
+
+  @Test
+  public void flushWebSessions_Forbidden() throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    allow(cfg, GlobalCapability.VIEW_CACHES, registeredUsers);
+    allow(cfg, GlobalCapability.FLUSH_CACHES, registeredUsers);
+    saveProjectConfig(cfg);
+
+    RestResponse r = userSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH, Arrays.asList("projects")));
+    assertEquals(HttpStatus.SC_OK, r.getStatusCode());
+    r.consume();
+
+    r = userSession.post("/config/server/caches/",
+        new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")));
+    assertEquals(HttpStatus.SC_FORBIDDEN, r.getStatusCode());
+  }
+
+  private void saveProjectConfig(ProjectConfig cfg) throws IOException {
+    MetaDataUpdate md = metaDataUpdateFactory.create(allProjects);
+    try {
+      cfg.commit(md);
+    } finally {
+      md.close();
+    }
+    projectCache.evict(allProjects);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
index 9019f4d..afb972b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
@@ -31,6 +31,15 @@
     this.cacheProvider = cacheProvider;
   }
 
+  public CacheResource(String pluginName, String cacheName, final Cache<?, ?> cache) {
+    this(pluginName, cacheName, new Provider<Cache<?, ?>>() {
+      @Override
+      public Cache<?, ?> get() {
+        return cache;
+      }
+    });
+  }
+
   public String getName() {
     return name;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 6a4856b..7302ea1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -23,26 +23,36 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.PostCaches.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.util.ArrayList;
+import java.util.List;
+
 @RequiresCapability(GlobalCapability.FLUSH_CACHES)
 @Singleton
 public class PostCaches implements RestModifyView<ConfigResource, Input> {
   public static class Input {
     public Operation operation;
+    public List<String> caches;
 
     public Input() {
     }
 
     public Input(Operation op) {
+      this(op, null);
+    }
+
+    public Input(Operation op, List<String> c) {
       operation = op;
+      caches = c;
     }
   }
 
   public static enum Operation {
-    FLUSH_ALL;
+    FLUSH_ALL, FLUSH;
   }
 
   private final DynamicMap<Cache<?, ?>> cacheMap;
@@ -57,25 +67,68 @@
 
   @Override
   public Object apply(ConfigResource rsrc, Input input) throws AuthException,
-      ResourceNotFoundException, BadRequestException {
+      ResourceNotFoundException, BadRequestException,
+      UnprocessableEntityException {
     if (input == null || input.operation == null) {
       throw new BadRequestException("operation must be specified");
     }
 
     switch (input.operation) {
       case FLUSH_ALL:
-        for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-          CacheResource cacheResource =
-              new CacheResource(e.getPluginName(), e.getExportName(),
-                  e.getProvider());
-          if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
-            continue;
-          }
-          flushCache.apply(cacheResource, null);
+        if (input.caches != null) {
+          throw new BadRequestException(
+              "specifying caches is not allowed for operation 'FLUSH_ALL'");
         }
+        flushAll();
+        return Response.ok("");
+      case FLUSH:
+        if (input.caches == null || input.caches.isEmpty()) {
+          throw new BadRequestException(
+              "caches must be specified for operation 'FLUSH'");
+        }
+        flush(input.caches);
         return Response.ok("");
       default:
         throw new BadRequestException("unsupported operation: " + input.operation);
     }
   }
+
+  private void flushAll() throws AuthException {
+    for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+      CacheResource cacheResource =
+          new CacheResource(e.getPluginName(), e.getExportName(),
+              e.getProvider());
+      if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
+        continue;
+      }
+      flushCache.apply(cacheResource, null);
+    }
+  }
+
+  private void flush(List<String> cacheNames)
+      throws UnprocessableEntityException, AuthException {
+    List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
+
+    for (String n : cacheNames) {
+      String pluginName = "gerrit";
+      String cacheName = n;
+      int i = cacheName.lastIndexOf('-');
+      if (i != -1) {
+        pluginName = cacheName.substring(0, i);
+        cacheName = cacheName.length() > i + 1 ? cacheName.substring(i + 1) : "";
+      }
+
+      Cache<?, ?> cache = cacheMap.get(pluginName, cacheName);
+      if (cache != null) {
+        cacheResources.add(new CacheResource(pluginName, cacheName, cache));
+      } else {
+        throw new UnprocessableEntityException(String.format(
+            "cache %s not found", n));
+      }
+    }
+
+    for (CacheResource rsrc : cacheResources) {
+      flushCache.apply(rsrc, null);
+    }
+  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CacheCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CacheCommand.java
deleted file mode 100644
index f16dcd1..0000000
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CacheCommand.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd.commands;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-
-abstract class CacheCommand extends SshCommand {
-  @Inject
-  protected DynamicMap<Cache<?, ?>> cacheMap;
-
-  protected String cacheNameOf(String plugin, String name) {
-    if ("gerrit".equals(plugin)) {
-      return name;
-    } else {
-      return plugin + "-" + name;
-    }
-  }
-}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
index bc6687a..6dfcb51 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -14,21 +14,19 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH;
 import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH_ALL;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.common.cache.Cache;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.config.CacheResource;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.FlushCache;
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.OutputFormat;
 import com.google.gerrit.server.config.PostCaches;
 import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -41,7 +39,7 @@
 @RequiresCapability(GlobalCapability.FLUSH_CACHES)
 @CommandMetaData(name = "flush-caches", description = "Flush some/all server caches from memory",
   runsAt = MASTER_OR_SLAVE)
-final class FlushCaches extends CacheCommand {
+final class FlushCaches extends SshCommand {
   @Option(name = "--cache", usage = "flush named cache", metaVar = "NAME")
   private List<String> caches = new ArrayList<>();
 
@@ -52,9 +50,6 @@
   private boolean list;
 
   @Inject
-  private FlushCache flushCache;
-
-  @Inject
   private Provider<ListCaches> listCaches;
 
   @Inject
@@ -84,54 +79,25 @@
         postCaches.apply(new ConfigResource(),
             new PostCaches.Input(FLUSH_ALL));
       } else {
-        List<String> names = cacheNames();
-        for (String n : caches) {
-          if (!names.contains(n)) {
-            throw error("error: cache \"" + n + "\" not recognized");
-          }
-        }
-        doBulkFlush();
+        postCaches.apply(new ConfigResource(),
+            new PostCaches.Input(FLUSH, caches));
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
     }
   }
 
-  private static UnloggedFailure error(final String msg) {
+  private static UnloggedFailure error(String msg) {
     return new UnloggedFailure(1, msg);
   }
 
   @SuppressWarnings("unchecked")
-  private List<String> cacheNames() {
-    return (List<String>) listCaches.get().setFormat(OutputFormat.LIST)
-        .apply(new ConfigResource());
-  }
-
-  private void doList() throws RestApiException {
-    for (String name : cacheNames()) {
+  private void doList() {
+    for (String name : (List<String>) listCaches.get()
+        .setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
       stderr.print(name);
       stderr.print('\n');
     }
     stderr.flush();
   }
-
-  private void doBulkFlush() {
-    try {
-      for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-        String n = cacheNameOf(e.getPluginName(), e.getExportName());
-        if (caches.contains(n)) {
-          try {
-            flushCache.apply(
-                new CacheResource(e.getPluginName(), e.getExportName(),
-                    e.getProvider()), null);
-          } catch (RestApiException err) {
-            stderr.println("error: cannot flush cache \"" + n + "\": "
-                + err.getMessage());
-          }
-        }
-      }
-    } finally {
-      stderr.flush();
-    }
-  }
 }