Implement changes indexes lock files check

Detect the following failures of both open and closed changes indexes
write.lock files:
* write.lock file is missing
* write.lock file is not writeable by Gerrit

Notes:
* so far only changes indexes are subject of check
* in order to test it `UseLocalDisk` annotation has to be applied (and
  it is heavy operation) therefore `HealthCheckIT` was modified so that
  `changesindex` healthcheck is disabled for all test cases (or
  additionally disabled in case when some checks get disabled)
* `changesindex` healthcheck test cases were added to a dedicated
  `ChangesIndexHealthCheckIT` file
* common functions from `HealthCheckIT` and `ChangesIndexHealthCheckIT`
  were introduced in `AbstractHealthCheckIntegrationTest`

The write.lock ownership change detection was tested manually with the
following steps:
* docker run --rm -p 8080:8080 -p 29418:29418 -ti --name gerrit_3.9.1 gerritcodereview/gerrit:3.9.1
* docker cp bazel-bin/plugins/healthcheck/healthcheck.jar gerrit_3.9.1:/var/gerrit/plugins
* curl localhost:8080/config/server/healthcheck~status returned
  "changesindex": {
    "result": "passed",
    "ts": 1703147883563,
    "elapsed": 0
  }
* docker exec -u root -it gerrit_3.9.1 /bin/bash
* chown root:root /var/gerrit/index/changes_0084/open/write.lock
* curl localhost:8080/config/server/healthcheck~status returned
  "changesindex": {
    "result": "failed",
    "ts": 1703148022276,
    "elapsed": 0
  }

IOW it works as expected.

Bug: Issue 40015289
Change-Id: I66a4053483e2c10eb28c150782c33cfd10dc2b15
diff --git a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/check/ChangesIndexHealthCheck.java b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/check/ChangesIndexHealthCheck.java
index 584787a..176cea9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/check/ChangesIndexHealthCheck.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/check/ChangesIndexHealthCheck.java
@@ -20,32 +20,46 @@
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.healthcheck.HealthCheckConfig;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
 public class ChangesIndexHealthCheck extends AbstractHealthCheck {
   private static final Logger log = LoggerFactory.getLogger(QueryChangesHealthCheck.class);
+  private static final String lockFilename = "write.lock";
 
   private final boolean isLuceneIndex;
+  private final Optional<ChangesIndexLockFiles> changes;
 
   @Inject
   ChangesIndexHealthCheck(
       @GerritServerConfig Config cfg,
       ListeningExecutorService executor,
       HealthCheckConfig config,
-      MetricMaker metricMaker) {
+      MetricMaker metricMaker,
+      SitePaths sitePaths) {
     super(executor, config, CHANGES_INDEX, metricMaker);
     this.isLuceneIndex = isIndexTypeLucene(cfg);
+    this.changes = getChangesIndexLockFiles(sitePaths.index_dir);
   }
 
   @Override
   protected Result doCheck() throws Exception {
-    return isLuceneIndex ? Result.PASSED : Result.DISABLED;
+    return isLuceneIndex
+        ? changes.map(locks -> locks.valid() ? Result.PASSED : Result.FAILED).orElse(Result.FAILED)
+        : Result.DISABLED;
   }
 
   private static boolean isIndexTypeLucene(Config cfg) {
@@ -58,4 +72,52 @@
     }
     return isLucene;
   }
+
+  private static Optional<ChangesIndexLockFiles> getChangesIndexLockFiles(Path indexDir) {
+    FileBasedConfig cfg =
+        new FileBasedConfig(indexDir.resolve("gerrit_index.config").toFile(), FS.detect());
+    try {
+      cfg.load();
+      return getActiveIndexVersion(cfg, "changes")
+          .map(version -> getChangesLockFiles(indexDir, version));
+    } catch (IOException | ConfigInvalidException e) {
+      log.error("Getting changes index version from configuration failed", e);
+    }
+    return Optional.empty();
+  }
+
+  private static Optional<String> getActiveIndexVersion(Config cfg, String indexPrefix) {
+    String section = "index";
+    return cfg.getSubsections(section).stream()
+        .filter(
+            subsection ->
+                subsection.startsWith(indexPrefix)
+                    && cfg.getBoolean(section, subsection, "ready", false))
+        .findAny();
+  }
+
+  private static ChangesIndexLockFiles getChangesLockFiles(Path indexDir, String indexVersion) {
+    Path versionDir = indexDir.resolve(indexVersion);
+    return new ChangesIndexLockFiles(
+        versionDir.resolve("open").resolve(lockFilename).toFile(),
+        versionDir.resolve("closed").resolve(lockFilename).toFile());
+  }
+
+  private static class ChangesIndexLockFiles {
+    private final File openLock;
+    private final File closedLock;
+
+    private ChangesIndexLockFiles(File openLock, File closedLock) {
+      this.openLock = openLock;
+      this.closedLock = closedLock;
+    }
+
+    private boolean valid() {
+      return validLock(openLock) && validLock(closedLock);
+    }
+
+    private static boolean validLock(File writeLock) {
+      return writeLock.isFile() && writeLock.canWrite();
+    }
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/AbstractHealthCheckIntegrationTest.java b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/AbstractHealthCheckIntegrationTest.java
new file mode 100644
index 0000000..cd3862e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/AbstractHealthCheckIntegrationTest.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2023 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.healthcheck;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.inject.AbstractModule;
+import com.google.inject.Key;
+import com.googlesource.gerrit.plugins.healthcheck.check.HealthCheckNames;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.junit.Ignore;
+
+@Ignore
+public class AbstractHealthCheckIntegrationTest extends LightweightPluginDaemonTest {
+  public static class TestModule extends AbstractModule {
+
+    @Override
+    protected void configure() {
+      install(new HealthCheckExtensionApiModule());
+      install(new Module());
+    }
+  }
+
+  protected HealthCheckConfig config;
+  protected String healthCheckUriPath;
+
+  private final Gson gson = new Gson();
+
+  @Override
+  public void setUpTestPlugin() throws Exception {
+    super.setUpTestPlugin();
+
+    config = plugin.getSysInjector().getInstance(HealthCheckConfig.class);
+
+    int numChanges = config.getLimit(HealthCheckNames.QUERYCHANGES);
+    for (int i = 0; i < numChanges; i++) {
+      createChange("refs/for/master");
+    }
+    accountCreator.create(config.getUsername(HealthCheckNames.AUTH));
+
+    healthCheckUriPath =
+        String.format(
+            "/config/server/%s~status",
+            plugin.getSysInjector().getInstance(Key.get(String.class, PluginName.class)));
+  }
+
+  protected RestResponse getHealthCheckStatus() throws IOException {
+    return adminRestSession.get(healthCheckUriPath);
+  }
+
+  protected RestResponse getHealthCheckStatusAnonymously() throws IOException {
+    return anonymousRestSession.get(healthCheckUriPath);
+  }
+
+  protected void assertCheckResult(JsonObject respPayload, String checkName, String result) {
+    assertThat(respPayload.has(checkName)).isTrue();
+    JsonObject reviewDbStatus = respPayload.get(checkName).getAsJsonObject();
+    assertThat(reviewDbStatus.has("result")).isTrue();
+    assertThat(reviewDbStatus.get("result").getAsString()).isEqualTo(result);
+  }
+
+  protected void disableCheck(String check) throws ConfigInvalidException {
+    config.fromText(String.format("[healthcheck \"%s\"]\n" + "enabled = false", check));
+  }
+
+  protected JsonObject getResponseJson(RestResponse resp) throws IOException {
+    return gson.fromJson(resp.getReader(), JsonObject.class);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/ChangesIndexHealthCheckIT.java b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/ChangesIndexHealthCheckIT.java
new file mode 100644
index 0000000..33d3277
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/ChangesIndexHealthCheckIT.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2023 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.healthcheck;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.healthcheck.check.HealthCheckNames.CHANGES_INDEX;
+
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Iterator;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "healthcheck-test",
+    sysModule =
+        "com.googlesource.gerrit.plugins.healthcheck.AbstractHealthCheckIntegrationTest$TestModule",
+    httpModule = "com.googlesource.gerrit.plugins.healthcheck.HttpModule")
+@Sandboxed
+public class ChangesIndexHealthCheckIT extends AbstractHealthCheckIntegrationTest {
+  @Inject SitePaths sitePaths;
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "index.type", value = "lucene")
+  public void shouldReturnChangesIndexCheck() throws Exception {
+    RestResponse resp = getHealthCheckStatus();
+    resp.assertOK();
+
+    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "passed");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "index.type", value = "lucene")
+  public void shouldFailWhenOpenChangesIndexWriteLockIsMissing() throws Exception {
+    shouldFailWhenChangesIndexWriteLockIsMissing("open");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GerritConfig(name = "index.type", value = "lucene")
+  public void shouldFailWhenClosedChangesIndexWriteLockIsMissing() throws Exception {
+    shouldFailWhenChangesIndexWriteLockIsMissing("closed");
+  }
+
+  private void shouldFailWhenChangesIndexWriteLockIsMissing(String indexType) throws Exception {
+    RestResponse resp = getHealthCheckStatus();
+    resp.assertOK();
+
+    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "passed");
+
+    Path openChangesIndexLockPath = getIndexLockFile(sitePaths.index_dir, indexType);
+    assertThat(
+            openChangesIndexLockPath
+                .toFile()
+                .renameTo(
+                    openChangesIndexLockPath
+                        .getParent()
+                        .resolve(openChangesIndexLockPath.getFileName().toString() + ".backup")
+                        .toFile()))
+        .isTrue();
+
+    resp = getHealthCheckStatus();
+    resp.assertStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+
+    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "failed");
+  }
+
+  @Test
+  @GerritConfig(name = "index.type", value = "lucene")
+  public void shouldReturnChangesIndexCheckAsDisabled() throws Exception {
+    disableCheck(CHANGES_INDEX);
+    RestResponse resp = getHealthCheckStatus();
+
+    resp.assertOK();
+    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "disabled");
+  }
+
+  @Test
+  @GerritConfig(name = "index.type", value = "fake")
+  public void shouldReturnChangesIndexCheckAsDisabledWhenIndexIsNotLucene() throws Exception {
+    RestResponse resp = getHealthCheckStatus();
+
+    resp.assertOK();
+    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "disabled");
+  }
+
+  private Path getIndexLockFile(Path indexDir, String indexType) throws IOException {
+    try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(indexDir, "changes_*")) {
+      Iterator<Path> changesDir = dirStream.iterator();
+      if (changesDir.hasNext()) {
+        return changesDir.next().resolve(indexType).resolve("write.lock");
+      }
+      throw new RuntimeException(
+          String.format("there is no `changes_*` dir under [%s] index dir", indexDir));
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckIT.java b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckIT.java
index 1aa698c..a0c9885 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckIT.java
@@ -22,17 +22,11 @@
 import static com.googlesource.gerrit.plugins.healthcheck.check.HealthCheckNames.JGIT;
 import static com.googlesource.gerrit.plugins.healthcheck.check.HealthCheckNames.QUERYCHANGES;
 
-import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gson.Gson;
 import com.google.gson.JsonObject;
-import com.google.inject.AbstractModule;
-import com.google.inject.Key;
-import com.googlesource.gerrit.plugins.healthcheck.check.HealthCheckNames;
 import java.io.File;
 import java.io.IOException;
 import javax.servlet.http.HttpServletResponse;
@@ -42,43 +36,24 @@
 
 @TestPlugin(
     name = "healthcheck-test",
-    sysModule = "com.googlesource.gerrit.plugins.healthcheck.HealthCheckIT$TestModule",
+    sysModule =
+        "com.googlesource.gerrit.plugins.healthcheck.AbstractHealthCheckIntegrationTest$TestModule",
     httpModule = "com.googlesource.gerrit.plugins.healthcheck.HttpModule")
 @Sandboxed
-public class HealthCheckIT extends LightweightPluginDaemonTest {
-  Gson gson = new Gson();
-  HealthCheckConfig config;
-  String healthCheckUriPath;
-  String failFilePath;
-
-  public static class TestModule extends AbstractModule {
-
-    @Override
-    protected void configure() {
-      install(new HealthCheckExtensionApiModule());
-      install(new Module());
-    }
-  }
+public class HealthCheckIT extends AbstractHealthCheckIntegrationTest {
+  private String failFilePath = "/tmp/fail";
 
   @Override
   @Before
   public void setUpTestPlugin() throws Exception {
     super.setUpTestPlugin();
 
-    config = plugin.getSysInjector().getInstance(HealthCheckConfig.class);
-    failFilePath = "/tmp/fail";
     new File(failFilePath).delete();
 
-    int numChanges = config.getLimit(HealthCheckNames.QUERYCHANGES);
-    for (int i = 0; i < numChanges; i++) {
-      createChange("refs/for/master");
-    }
-    accountCreator.create(config.getUsername(HealthCheckNames.AUTH));
-
-    healthCheckUriPath =
-        String.format(
-            "/config/server/%s~status",
-            plugin.getSysInjector().getInstance(Key.get(String.class, PluginName.class)));
+    // disable `changesindex` check as it requires @UseLocalDisk annotation (so that all operations
+    // are persisted to local FS) to be applied and that would degrade this IT performance - see
+    // ChangesIndexHealthCheckIT for a changes index dedicated integration tests
+    super.disableCheck(CHANGES_INDEX);
   }
 
   @Test
@@ -225,30 +200,14 @@
     assertThat(respBody.get("reason").getAsString()).isEqualTo("Fail Flag File exists");
   }
 
-  @Test
-  public void shouldReturnChangesIndexCheck() throws Exception {
-    RestResponse resp = getHealthCheckStatus();
-    resp.assertOK();
-
-    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "passed");
-  }
-
-  @Test
-  public void shouldReturnChangesIndexCheckAsDisabled() throws Exception {
-    disableCheck(CHANGES_INDEX);
-    RestResponse resp = getHealthCheckStatus();
-
-    resp.assertOK();
-    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "disabled");
-  }
-
-  @Test
-  @GerritConfig(name = "index.type", value = "fake")
-  public void shouldReturnChangesIndexCheckAsDisabledWhenIndexIsNotLucene() throws Exception {
-    RestResponse resp = getHealthCheckStatus();
-
-    resp.assertOK();
-    assertCheckResult(getResponseJson(resp), CHANGES_INDEX, "disabled");
+  @Override
+  protected void disableCheck(String check) throws ConfigInvalidException {
+    // additionally disable `changesindex` healthcheck as it requires @UseLocalDisk annotation to
+    // run properly
+    config.fromText(
+        String.format(
+            "[healthcheck \"%s\"]\n enabled = false\n[healthcheck \"%s\"]\n enabled = false",
+            check, CHANGES_INDEX));
   }
 
   private void createFailFileFlag(String path) throws IOException {
@@ -257,31 +216,7 @@
     file.deleteOnExit();
   }
 
-  private RestResponse getHealthCheckStatus() throws IOException {
-    return adminRestSession.get(healthCheckUriPath);
-  }
-
-  private RestResponse getHealthCheckStatusAnonymously() throws IOException {
-    return anonymousRestSession.get(healthCheckUriPath);
-  }
-
-  private void assertCheckResult(JsonObject respPayload, String checkName, String result) {
-    assertThat(respPayload.has(checkName)).isTrue();
-    JsonObject reviewDbStatus = respPayload.get(checkName).getAsJsonObject();
-    assertThat(reviewDbStatus.has("result")).isTrue();
-    assertThat(reviewDbStatus.get("result").getAsString()).isEqualTo(result);
-  }
-
-  private void disableCheck(String check) throws ConfigInvalidException {
-    config.fromText(String.format("[healthcheck \"%s\"]\n" + "enabled = false", check));
-  }
-
   private void setFailFlagFilePath(String path) throws ConfigInvalidException {
     config.fromText(String.format("[healthcheck]\n" + "failFileFlagPath = %s", path));
   }
-
-  private JsonObject getResponseJson(RestResponse resp) throws IOException {
-    JsonObject respPayload = gson.fromJson(resp.getReader(), JsonObject.class);
-    return respPayload;
-  }
 }