Merge branch 'stable-2.15'

* stable-2.15:
  Bazel: Include eclipse-out directory in .bazelignore
  Add explanatory comment to empty BUILD file(s)
  Upgrade bazlets to latest stable-2.15 to build with 2.15.7 API
  Upgrade bazlets to latest stable-2.14 to build with 2.14.17 API
  Upgrade bazlets to latest stable-2.15 to build with 2.15.6 API
  WORKSPACE: Make commented out local_path line spaces indent consistent
  Upgrade bazlets to latest stable-2.14 to build with 2.14.16 API
  Align Eclipse compiler settings with core Gerrit's
  bazlets: Replace native.git_repository with skylark rule
  Upgrade bazlets to latest stable-2.15 to build with 2.15.5 API
  Upgrade bazlets to latest stable-2.14 to build with 2.14.15 API
  Update bazlets to latest stable-2.15 to build with 2.15.4 API
  Migrate (i.e. move) `tools/bazel.rc` to `.bazelrc`
  Update bazlets to latest stable-2.14 to build with 2.14.14 API
  Update bazlets to latest stable-2.14 to build with 2.14.13 API
  Update bazlets to latest stable-2.14 to use 2.14.12 API
  Allow login during readonly
  Rename ssh commands from put/delete/get to enable/disable/status
  ReadOnlyByHttpIT: Add assertion that REST call worked as expected
  Add tests for enabling/disabling read-only mode by SSH command
  Remove custom servlet and associated tests
  Use RestApiServlet and SSH commands for plugin actions
  Allow to switch read only mode on/off
  Update bazlets to latest stable-2.14 to use 2.14.11 API
  ReadOnly: Use String#equals to compare strings
  Format bzl files with buildifier
  Update bazlets to latest stable-2.14 to use 2.14.10 API
  Update bazlets to latest revision on stable-2.14

Change-Id: I23c7d8af83de47982ebee122a1eeed1d4e08a009
diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000..30f1613
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1 @@
+eclipse-out
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000..40e022d
--- /dev/null
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,126 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=disabled
+org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
+org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
+org.eclipse.jdt.core.compiler.annotation.nonnull.secondary=
+org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
+org.eclipse.jdt.core.compiler.annotation.nonnullbydefault.secondary=
+org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
+org.eclipse.jdt.core.compiler.annotation.nullable.secondary=
+org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
+org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
+org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
+org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.debug.lineNumber=generate
+org.eclipse.jdt.core.compiler.debug.localVariable=generate
+org.eclipse.jdt.core.compiler.debug.sourceFile=generate
+org.eclipse.jdt.core.compiler.doc.comment.support=enabled
+org.eclipse.jdt.core.compiler.problem.APILeak=warning
+org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
+org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
+org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
+org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
+org.eclipse.jdt.core.compiler.problem.deadCode=warning
+org.eclipse.jdt.core.compiler.problem.deprecation=warning
+org.eclipse.jdt.core.compiler.problem.deprecationInDeprecatedCode=disabled
+org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
+org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
+org.eclipse.jdt.core.compiler.problem.emptyStatement=warning
+org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
+org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=warning
+org.eclipse.jdt.core.compiler.problem.fallthroughCase=warning
+org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled
+org.eclipse.jdt.core.compiler.problem.fieldHiding=warning
+org.eclipse.jdt.core.compiler.problem.finalParameterBound=warning
+org.eclipse.jdt.core.compiler.problem.finallyBlockNotCompletingNormally=warning
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.problem.hiddenCatchBlock=warning
+org.eclipse.jdt.core.compiler.problem.includeNullInfoFromAsserts=disabled
+org.eclipse.jdt.core.compiler.problem.incompatibleNonInheritedInterfaceMethod=warning
+org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning
+org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=ignore
+org.eclipse.jdt.core.compiler.problem.invalidJavadoc=warning
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTags=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsDeprecatedRef=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsNotVisibleRef=enabled
+org.eclipse.jdt.core.compiler.problem.invalidJavadocTagsVisibility=private
+org.eclipse.jdt.core.compiler.problem.localVariableHiding=ignore
+org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning
+org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore
+org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore
+org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=enabled
+org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=warning
+org.eclipse.jdt.core.compiler.problem.missingJavadocComments=ignore
+org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocCommentsVisibility=public
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagDescription=return_tag
+org.eclipse.jdt.core.compiler.problem.missingJavadocTags=ignore
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsMethodTypeParameters=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.missingJavadocTagsVisibility=protected
+org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=warning
+org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled
+org.eclipse.jdt.core.compiler.problem.missingSerialVersion=warning
+org.eclipse.jdt.core.compiler.problem.missingSynchronizedOnInheritedMethod=ignore
+org.eclipse.jdt.core.compiler.problem.noEffectAssignment=warning
+org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=warning
+org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=ignore
+org.eclipse.jdt.core.compiler.problem.nonnullParameterAnnotationDropped=warning
+org.eclipse.jdt.core.compiler.problem.nonnullTypeVariableFromLegacyInvocation=warning
+org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error
+org.eclipse.jdt.core.compiler.problem.nullReference=warning
+org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error
+org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
+org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning
+org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore
+org.eclipse.jdt.core.compiler.problem.pessimisticNullAnalysisForFreeTypeVariables=warning
+org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=warning
+org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore
+org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore
+org.eclipse.jdt.core.compiler.problem.rawTypeReference=warning
+org.eclipse.jdt.core.compiler.problem.redundantNullAnnotation=warning
+org.eclipse.jdt.core.compiler.problem.redundantNullCheck=warning
+org.eclipse.jdt.core.compiler.problem.redundantSpecificationOfTypeArguments=warning
+org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
+org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
+org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
+org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
+org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
+org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
+org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
+org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=disabled
+org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
+org.eclipse.jdt.core.compiler.problem.terminalDeprecation=warning
+org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
+org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
+org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
+org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
+org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
+org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentType=warning
+org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentTypeStrict=disabled
+org.eclipse.jdt.core.compiler.problem.unlikelyEqualsArgumentType=warning
+org.eclipse.jdt.core.compiler.problem.unnecessaryElse=warning
+org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=warning
+org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=warning
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionExemptExceptionAndThrowable=enabled
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionIncludeDocCommentReference=enabled
+org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionWhenOverriding=disabled
+org.eclipse.jdt.core.compiler.problem.unusedExceptionParameter=ignore
+org.eclipse.jdt.core.compiler.problem.unusedImport=warning
+org.eclipse.jdt.core.compiler.problem.unusedLabel=warning
+org.eclipse.jdt.core.compiler.problem.unusedLocal=warning
+org.eclipse.jdt.core.compiler.problem.unusedObjectAllocation=ignore
+org.eclipse.jdt.core.compiler.problem.unusedParameter=warning
+org.eclipse.jdt.core.compiler.problem.unusedParameterIncludeDocCommentReference=enabled
+org.eclipse.jdt.core.compiler.problem.unusedParameterWhenImplementingAbstract=disabled
+org.eclipse.jdt.core.compiler.problem.unusedParameterWhenOverridingConcrete=disabled
+org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
+org.eclipse.jdt.core.compiler.problem.unusedTypeParameter=ignore
+org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
+org.eclipse.jdt.core.compiler.processAnnotations=enabled
+org.eclipse.jdt.core.compiler.source=1.8
diff --git a/BUILD b/BUILD
index d4fda80..416a3b6 100644
--- a/BUILD
+++ b/BUILD
@@ -1,4 +1,10 @@
-load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+load(
+    "//tools/bzl:plugin.bzl",
+    "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
+)
+load("//tools/bzl:junit.bzl", "junit_tests")
 
 gerrit_plugin(
     name = "readonly",
@@ -9,5 +15,23 @@
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.readonly.SshModule",
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.readonly.HttpModule",
     ],
-    resources = glob(["src/main/**/*"]),
+    resources = glob(["src/main/resources/**/*"]),
+)
+
+junit_tests(
+    name = "readonly_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    resources = glob(["src/test/resources/**/*"]),
+    deps = [
+        ":readonly__plugin_test_deps",
+    ],
+)
+
+java_library(
+    name = "readonly__plugin_test_deps",
+    testonly = 1,
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+        ":readonly__plugin",
+    ],
 )
diff --git a/bazlets.bzl b/bazlets.bzl
index e14e488..f089af4 100644
--- a/bazlets.bzl
+++ b/bazlets.bzl
@@ -1,17 +1,18 @@
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
+
 NAME = "com_googlesource_gerrit_bazlets"
 
 def load_bazlets(
-    commit,
-    local_path = None
-  ):
-  if not local_path:
-      native.git_repository(
-          name = NAME,
-          remote = "https://gerrit.googlesource.com/bazlets",
-          commit = commit,
-      )
-  else:
-      native.local_repository(
-          name = NAME,
-          path = local_path,
-      )
+        commit,
+        local_path = None):
+    if not local_path:
+        git_repository(
+            name = NAME,
+            remote = "https://gerrit.googlesource.com/bazlets",
+            commit = commit,
+        )
+    else:
+        native.local_repository(
+            name = NAME,
+            path = local_path,
+        )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommand.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommand.java
index 46cb254..c6736cd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommand.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 
-@CommandMetaData(name = "disable", description = "Disable ssh commands", runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "disabled", description = "Disable ssh commands", runsAt = MASTER_OR_SLAVE)
 class DisableCommand extends SshCommand {
   @Inject ReadOnlyConfig config;
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommandInterceptor.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommandInterceptor.java
index 7bab398..1e422c8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommandInterceptor.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableCommandInterceptor.java
@@ -29,14 +29,19 @@
   private static final Logger log = LoggerFactory.getLogger(DisableCommandInterceptor.class);
   private static final String PATTERN = "^gerrit plugin (\\brm\\b|\\bremove\\b) %s$";
 
+  private final ReadOnlyState state;
   private final String disableCommand;
   private final List<Pattern> allowPatterns = new ArrayList<>();
   private final List<String> allowPrefixes = new ArrayList<>();
 
   @Inject
-  DisableCommandInterceptor(@PluginName String pluginName, ReadOnlyConfig config) {
-    this.disableCommand = pluginName + " disable";
+  DisableCommandInterceptor(
+      @PluginName String pluginName, ReadOnlyConfig config, ReadOnlyState state) {
+    this.state = state;
+    this.disableCommand = pluginName + " disabled";
     allowPatterns.add(Pattern.compile(String.format(PATTERN, pluginName)));
+    // Allow all SSH commands from this plugin
+    allowPrefixes.add(pluginName);
     for (String allow : config.allowSshCommands()) {
       if (allow.startsWith("^")) {
         allowPatterns.add(Pattern.compile(allow));
@@ -48,7 +53,8 @@
 
   @Override
   public String intercept(String in) {
-    if (allowPrefixes.stream().anyMatch(p -> in.startsWith(p))
+    if (!state.isReadOnly()
+        || allowPrefixes.stream().anyMatch(p -> in.startsWith(p))
         || allowPatterns.stream().anyMatch(p -> p.matcher(in).matches())) {
       return in;
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableReadOnlyCommand.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableReadOnlyCommand.java
new file mode 100644
index 0000000..a64d0b1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/DisableReadOnlyCommand.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+
+@RequiresAnyCapability({ADMINISTRATE_SERVER, MAINTAIN_SERVER})
+@CommandMetaData(name = "disable", description = "Disable read only mode")
+class DisableReadOnlyCommand extends SshCommand {
+  @Inject ReadOnlyEndpoint.Delete delete;
+
+  @Override
+  protected void run() throws IOException {
+    delete.apply(null, null);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/EnableReadOnlyCommand.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/EnableReadOnlyCommand.java
new file mode 100644
index 0000000..322a9f9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/EnableReadOnlyCommand.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+
+@RequiresAnyCapability({ADMINISTRATE_SERVER, MAINTAIN_SERVER})
+@CommandMetaData(name = "enable", description = "Enable read only mode")
+class EnableReadOnlyCommand extends SshCommand {
+  @Inject ReadOnlyEndpoint.Put put;
+
+  @Override
+  protected void run() throws IOException {
+    put.apply(null, null);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/GetReadOnlyStatusCommand.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/GetReadOnlyStatusCommand.java
new file mode 100644
index 0000000..06a41e5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/GetReadOnlyStatusCommand.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+
+@RequiresAnyCapability({ADMINISTRATE_SERVER, MAINTAIN_SERVER})
+@CommandMetaData(name = "status", description = "Show read only mode state")
+class GetReadOnlyStatusCommand extends SshCommand {
+  @Inject ReadOnlyEndpoint.Get get;
+
+  @Override
+  protected void run() {
+    stdout.println(get.apply(null));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/Module.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/Module.java
index 88f681a..69fa804 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/readonly/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/Module.java
@@ -14,7 +14,10 @@
 
 package com.googlesource.gerrit.plugins.readonly;
 
+import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
+
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestApiModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.inject.AbstractModule;
 
@@ -22,5 +25,14 @@
   @Override
   protected void configure() {
     DynamicSet.bind(binder(), CommitValidationListener.class).to(ReadOnly.class);
+    install(
+        new RestApiModule() {
+          @Override
+          protected void configure() {
+            put(CONFIG_KIND, "readonly").to(ReadOnlyEndpoint.Put.class);
+            delete(CONFIG_KIND, "readonly").to(ReadOnlyEndpoint.Delete.class);
+            get(CONFIG_KIND, "readonly").to(ReadOnlyEndpoint.Get.class);
+          }
+        });
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnly.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnly.java
index c651474..f84101c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnly.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnly.java
@@ -16,6 +16,8 @@
 
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -35,32 +37,51 @@
 @Singleton
 class ReadOnly extends AllRequestFilter implements CommitValidationListener {
   private static final String GIT_UPLOAD_PACK_PROTOCOL = "/git-upload-pack";
+  private static final String LOGIN_PREFIX = "/login";
+  private static final String LOGIN_INFIX = LOGIN_PREFIX + "/";
+
+  private final ReadOnlyState state;
   private final ReadOnlyConfig config;
+  private final String endpoint;
 
   @Inject
-  ReadOnly(ReadOnlyConfig config) {
+  ReadOnly(ReadOnlyState state, ReadOnlyConfig config, @PluginName String pluginName) {
+    this.state = state;
     this.config = config;
+    this.endpoint = String.format("/config/server/%s~readonly", pluginName);
   }
 
   @Override
   public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException {
-    throw new CommitValidationException(config.message());
+    if (state.isReadOnly()) {
+      throw new CommitValidationException(config.message());
+    }
+    return ImmutableList.of();
   }
 
   @Override
   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
-    if ((request instanceof HttpServletRequest) && (response instanceof HttpServletResponse)) {
-      String method = ((HttpServletRequest) request).getMethod();
-      String uri = ((HttpServletRequest) request).getRequestURI();
-      if ((method == "POST" && !uri.endsWith(GIT_UPLOAD_PACK_PROTOCOL))
-          || method == "PUT"
-          || method == "DELETE") {
-        ((HttpServletResponse) response).sendError(SC_SERVICE_UNAVAILABLE, config.message());
-        return;
-      }
+    if (state.isReadOnly()
+        && request instanceof HttpServletRequest
+        && response instanceof HttpServletResponse
+        && shouldBlock((HttpServletRequest) request)) {
+      ((HttpServletResponse) response).sendError(SC_SERVICE_UNAVAILABLE, config.message());
+      return;
     }
     chain.doFilter(request, response);
   }
+
+  private boolean shouldBlock(HttpServletRequest request) {
+    String method = request.getMethod();
+    String servletPath = request.getServletPath();
+    return !servletPath.endsWith(endpoint)
+        && (("POST".equals(method)
+                && !servletPath.endsWith(GIT_UPLOAD_PACK_PROTOCOL)
+                && !servletPath.equals(LOGIN_PREFIX)
+                && !servletPath.contains(LOGIN_INFIX))
+            || "PUT".equals(method)
+            || "DELETE".equals(method));
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyConfig.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyConfig.java
index ba87301..28ef6c5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyConfig.java
@@ -38,7 +38,7 @@
   ReadOnlyConfig(PluginConfigFactory pluginConfigFactory, @PluginName String pluginName) {
     Config cfg = pluginConfigFactory.getGlobalPluginConfig(pluginName);
     this.message = firstNonNull(cfg.getString(pluginName, null, MESSAGE_KEY), DEFAULT_MESSAGE);
-    allowSshCommands = ImmutableList.copyOf(cfg.getStringList(pluginName, null, SSH_ALLOW));
+    this.allowSshCommands = ImmutableList.copyOf(cfg.getStringList(pluginName, null, SSH_ALLOW));
   }
 
   String message() {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyEndpoint.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyEndpoint.java
new file mode 100644
index 0000000..caabb57
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyEndpoint.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+public class ReadOnlyEndpoint {
+  static class Input {}
+
+  @RequiresAnyCapability({ADMINISTRATE_SERVER, MAINTAIN_SERVER})
+  @Singleton
+  public static class Get implements RestReadView<ConfigResource> {
+    private final ReadOnlyState state;
+
+    @Inject
+    Get(ReadOnlyState state) {
+      this.state = state;
+    }
+
+    @Override
+    public String apply(ConfigResource resource) {
+      return state.isReadOnly() ? "on" : "off";
+    }
+  }
+
+  @RequiresAnyCapability({ADMINISTRATE_SERVER, MAINTAIN_SERVER})
+  @Singleton
+  public static class Put implements RestModifyView<ConfigResource, Input> {
+    private final ReadOnlyState state;
+
+    @Inject
+    Put(ReadOnlyState state) {
+      this.state = state;
+    }
+
+    @Override
+    public Response<String> apply(ConfigResource resource, Input input) throws IOException {
+      state.setReadOnly(true);
+      return Response.ok("");
+    }
+  }
+
+  @RequiresAnyCapability({ADMINISTRATE_SERVER, MAINTAIN_SERVER})
+  @Singleton
+  public static class Delete implements RestModifyView<ConfigResource, Input> {
+    private final ReadOnlyState state;
+
+    @Inject
+    Delete(ReadOnlyState state) {
+      this.state = state;
+    }
+
+    @Override
+    public Response<String> apply(ConfigResource resource, Input input) throws IOException {
+      state.setReadOnly(false);
+      return Response.ok("");
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyState.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyState.java
new file mode 100644
index 0000000..05c7a91
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyState.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2018 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.readonly;
+
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+
+@Singleton
+public class ReadOnlyState {
+  private static final String GERRIT_READONLY = "gerrit.readonly";
+
+  private final File marker;
+
+  @Inject
+  ReadOnlyState(SitePaths sitePaths) {
+    this.marker = sitePaths.etc_dir.resolve(GERRIT_READONLY).toFile();
+  }
+
+  public boolean isReadOnly() {
+    return marker.exists();
+  }
+
+  public void setReadOnly(boolean readOnly) throws IOException {
+    if (readOnly && !marker.exists()) {
+      Files.newOutputStream(marker.toPath(), StandardOpenOption.CREATE).close();
+    } else if (!readOnly && marker.exists()) {
+      Files.delete(marker.toPath());
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/readonly/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/readonly/SshModule.java
index 8067516..357c2f7 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/readonly/SshModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/readonly/SshModule.java
@@ -24,5 +24,14 @@
     DynamicItem.bind(binder(), SshCreateCommandInterceptor.class)
         .to(DisableCommandInterceptor.class);
     command(DisableCommand.class);
+
+    command(EnableReadOnlyCommand.class);
+    alias("on", EnableReadOnlyCommand.class);
+
+    command(DisableReadOnlyCommand.class);
+    alias("off", DisableReadOnlyCommand.class);
+
+    command(GetReadOnlyStatusCommand.class);
+    alias("get", GetReadOnlyStatusCommand.class);
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/readonly/AbstractReadOnlyTest.java b/src/test/java/com/googlesource/gerrit/plugins/readonly/AbstractReadOnlyTest.java
new file mode 100644
index 0000000..f0bf1cf
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/readonly/AbstractReadOnlyTest.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_SERVICE_UNAVAILABLE;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.server.change.PutTopic;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+@TestPlugin(
+    name = "readonly",
+    sysModule = "com.googlesource.gerrit.plugins.readonly.Module",
+    httpModule = "com.googlesource.gerrit.plugins.readonly.HttpModule",
+    sshModule = "com.googlesource.gerrit.plugins.readonly.SshModule")
+public abstract class AbstractReadOnlyTest extends LightweightPluginDaemonTest {
+  @Test
+  @UseLocalDisk
+  public void restRequestsAreRejectedWhenReadOnly() throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = "master";
+    in.subject = "test";
+    ChangeInfo change = gApi.changes().create(in).get();
+
+    // GET should be allowed
+    String url = "/changes/" + change.changeId;
+    adminRestSession.get(url).assertOK();
+
+    // PUT should be allowed
+    PutTopic.Input topic = new PutTopic.Input();
+    topic.topic = "topic";
+    adminRestSession.put(url + "/topic", topic).assertOK();
+
+    // DELETE should be allowed
+    adminRestSession.delete(url + "/topic").assertNoContent();
+
+    // POST should be allowed
+    adminRestSession.post(url + "/abandon").assertOK();
+
+    // Enable read-only
+    setReadOnly(true);
+
+    // GET should be allowed
+    adminRestSession.get(url).assertOK();
+
+    // PUT should be blocked
+    adminRestSession.put(url + "/topic", topic).assertStatus(SC_SERVICE_UNAVAILABLE);
+
+    // DELETE should be blocked
+    adminRestSession.delete(url + "/topic").assertStatus(SC_SERVICE_UNAVAILABLE);
+
+    // POST should be blocked
+    adminRestSession.post(url + "/restore").assertStatus(SC_SERVICE_UNAVAILABLE);
+
+    // Disable read-only
+    setReadOnly(false);
+
+    // GET should be allowed
+    adminRestSession.get(url).assertOK();
+
+    // PUT should be allowed
+    adminRestSession.put(url + "/topic", topic).assertOK();
+
+    // DELETE should be allowed
+    adminRestSession.delete(url + "/topic").assertNoContent();
+
+    // POST should be allowed
+    adminRestSession.post(url + "/restore").assertOK();
+  }
+
+  @Test
+  @UseLocalDisk
+  @UseSsh
+  public void sshCommandsAreRejectedWhenReadOnly() throws Exception {
+    String command = "gerrit ls-projects";
+
+    // Command should succeed
+    adminSshSession.exec(command);
+    adminSshSession.assertSuccess();
+
+    // Enable read-only
+    setReadOnly(true);
+
+    // Command should be blocked
+    adminSshSession.exec(command);
+    adminSshSession.assertFailure("READ ONLY");
+
+    // Disable read-only
+    setReadOnly(false);
+
+    // Command should succeed
+    adminSshSession.exec(command);
+    adminSshSession.assertSuccess();
+  }
+
+  @Test
+  @UseLocalDisk
+  @UseSsh
+  public void pushBySshIsRejectedWhenReadOnly() throws Exception {
+    pushForReview(true);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void pushByHttpIsRejectedWhenReadOnly() throws Exception {
+    pushForReview(false);
+  }
+
+  private void pushForReview(boolean ssh) throws Exception {
+    String url = ssh ? adminSshSession.getUrl() : admin.getHttpUrl(server);
+    if (!ssh) {
+      CredentialsProvider.setDefault(
+          new UsernamePasswordCredentialsProvider(admin.username, admin.httpPassword));
+    }
+    testRepo = GitUtil.cloneProject(project, url + "/" + project.get());
+
+    // Push should succeed
+    pushTo("refs/for/master").assertOkStatus();
+
+    // Enable read-only
+    setReadOnly(true);
+
+    // Push should fail
+    try {
+      pushTo("refs/for/master");
+      fail("expected TransportException");
+    } catch (TransportException e) {
+      assertThat(e).hasMessageThat().contains("READ ONLY");
+    }
+
+    // Disable read-only
+    setReadOnly(false);
+
+    // Push should succeed
+    pushTo("refs/for/master").assertOkStatus();
+  }
+
+  protected abstract void setReadOnly(boolean readOnly) throws Exception;
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyByHttpIT.java b/src/test/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyByHttpIT.java
new file mode 100644
index 0000000..43f241c
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyByHttpIT.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.RestResponse;
+
+public class ReadOnlyByHttpIT extends AbstractReadOnlyTest {
+  @Override
+  protected void setReadOnly(boolean readOnly) throws Exception {
+    if (readOnly) {
+      adminRestSession.put("/config/server/readonly~readonly").assertOK();
+    } else {
+      adminRestSession.delete("/config/server/readonly~readonly").assertOK();
+    }
+    RestResponse response = adminRestSession.get("/config/server/readonly~readonly");
+    response.assertOK();
+    String expectedStatus = readOnly ? "on" : "off";
+    assertThat(response.getEntityContent()).contains(expectedStatus);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyBySshIT.java b/src/test/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyBySshIT.java
new file mode 100644
index 0000000..83d6c68
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/readonly/ReadOnlyBySshIT.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2018 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.readonly;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.UseSsh;
+
+@UseSsh
+public class ReadOnlyBySshIT extends AbstractReadOnlyTest {
+  @Override
+  protected void setReadOnly(boolean readOnly) throws Exception {
+    String command = readOnly ? "enable" : "disable";
+    String expectedStatus = readOnly ? "on" : "off";
+
+    adminSshSession.exec("readonly " + command);
+    adminSshSession.assertSuccess();
+
+    String result = adminSshSession.exec("readonly status");
+    assertThat(result).contains(expectedStatus);
+  }
+}
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index e69de29..c5ed0b7 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -0,0 +1 @@
+# Empty file required by Bazel
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
index dfcbe9c..d5764f7 100644
--- a/tools/bzl/classpath.bzl
+++ b/tools/bzl/classpath.bzl
@@ -1,2 +1,4 @@
-load("@com_googlesource_gerrit_bazlets//tools:classpath.bzl",
-     "classpath_collector")
+load(
+    "@com_googlesource_gerrit_bazlets//tools:classpath.bzl",
+    "classpath_collector",
+)
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
new file mode 100644
index 0000000..3af7e58
--- /dev/null
+++ b/tools/bzl/junit.bzl
@@ -0,0 +1,4 @@
+load(
+    "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
+    "junit_tests",
+)
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
new file mode 100644
index 0000000..2eabedb
--- /dev/null
+++ b/tools/bzl/maven_jar.bzl
@@ -0,0 +1 @@
+load("@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl", "maven_jar")
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 956dd59..0b25d23 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,5 +1,6 @@
 load(
     "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl",
-    "gerrit_plugin",
     "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
 )
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 757747c..e9b0ad2 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -3,7 +3,8 @@
 
 classpath_collector(
     name = "main_classpath_collect",
-    deps = PLUGIN_DEPS + [
-        "//:readonly__plugin",
+    testonly = 1,
+    deps = [
+        "//:readonly__plugin_test_deps",
     ],
 )