/*
 * Copyright 2019 Google LLC. All Rights Reserved.
 *
 * 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.cloud.hadoop.fs.gcs;

import static com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystemConfiguration.GCS_CONFIG_PREFIX;
import static com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystemConfiguration.GCS_COOPERATIVE_LOCKING_EXPIRATION_TIMEOUT_MS;
import static com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystemConfiguration.GCS_PROJECT_ID;
import static com.google.cloud.hadoop.gcsio.cooplock.CoopLockOperationType.DELETE;
import static com.google.cloud.hadoop.gcsio.cooplock.CoopLockOperationType.RENAME;
import static com.google.cloud.hadoop.gcsio.cooplock.CoopLockRecordsDao.LOCK_DIRECTORY;
import static com.google.cloud.hadoop.gcsio.cooplock.CoopLockRecordsDao.LOCK_PATH;
import static com.google.cloud.hadoop.util.HadoopCredentialConfiguration.ENABLE_SERVICE_ACCOUNTS_SUFFIX;
import static com.google.cloud.hadoop.util.HadoopCredentialConfiguration.SERVICE_ACCOUNT_EMAIL_SUFFIX;
import static com.google.cloud.hadoop.util.HadoopCredentialConfiguration.SERVICE_ACCOUNT_KEYFILE_SUFFIX;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.common.util.concurrent.Uninterruptibles.sleepUninterruptibly;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;
import static org.junit.Assert.assertThrows;

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.http.HttpExecuteInterceptor;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.cloud.hadoop.gcsio.FileInfo;
import com.google.cloud.hadoop.gcsio.GoogleCloudStorageFileSystem;
import com.google.cloud.hadoop.gcsio.GoogleCloudStorageFileSystemIntegrationHelper;
import com.google.cloud.hadoop.gcsio.GoogleCloudStorageFileSystemOptions;
import com.google.cloud.hadoop.gcsio.GoogleCloudStorageImpl;
import com.google.cloud.hadoop.gcsio.GoogleCloudStorageIntegrationHelper;
import com.google.cloud.hadoop.gcsio.GoogleCloudStorageOptions;
import com.google.cloud.hadoop.gcsio.cooplock.CoopLockRecords;
import com.google.cloud.hadoop.gcsio.cooplock.CoopLockRecordsDao;
import com.google.cloud.hadoop.gcsio.cooplock.CooperativeLockingOptions;
import com.google.cloud.hadoop.gcsio.cooplock.DeleteOperation;
import com.google.cloud.hadoop.gcsio.cooplock.RenameOperation;
import com.google.cloud.hadoop.gcsio.integration.GoogleCloudStorageTestHelper;
import com.google.cloud.hadoop.gcsio.testing.TestConfiguration;
import com.google.cloud.hadoop.util.RetryHttpInitializer;
import com.google.common.base.Ascii;
import com.google.common.collect.Iterables;
import com.google.gson.Gson;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
import org.apache.hadoop.conf.Configuration;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Integration tests for Cooperative Locking FSCK tool. */
@RunWith(JUnit4.class)
public class CoopLockRepairIntegrationTest {

  private static final Gson GSON = CoopLockRecordsDao.createGson();

  private static final Duration COOP_LOCK_TIMEOUT = Duration.ofSeconds(30);

  private static final String OPERATION_FILENAME_PATTERN_FORMAT =
      "[0-9]{8}T[0-9]{6}\\.[0-9]{3}Z_%s_[a-z0-9\\-]+";

  private static GoogleCloudStorageOptions gcsOptions;
  private static RetryHttpInitializer httpRequestInitializer;
  private static GoogleCloudStorageFileSystemIntegrationHelper gcsfsIHelper;

  @BeforeClass
  public static void before() throws Throwable {
    String projectId =
        checkNotNull(TestConfiguration.getInstance().getProjectId(), "projectId can not be null");
    String appName = GoogleCloudStorageIntegrationHelper.APP_NAME;
    Credential credential =
        checkNotNull(GoogleCloudStorageTestHelper.getCredential(), "credential must not be null");

    gcsOptions =
        GoogleCloudStorageOptions.builder().setAppName(appName).setProjectId(projectId).build();
    httpRequestInitializer =
        new RetryHttpInitializer(credential, gcsOptions.toRetryHttpInitializerOptions());

    GoogleCloudStorageFileSystem gcsfs =
        new GoogleCloudStorageFileSystem(
            credential,
            GoogleCloudStorageFileSystemOptions.builder()
                .setBucketDeleteEnabled(true)
                .setCloudStorageOptions(gcsOptions)
                .build());

    gcsfsIHelper = new GoogleCloudStorageFileSystemIntegrationHelper(gcsfs);
    gcsfsIHelper.beforeAllTests();
  }

  @AfterClass
  public static void afterClass() throws Throwable {
    gcsfsIHelper.afterAllTests();
    GoogleCloudStorageFileSystem gcsfs = gcsfsIHelper.gcsfs;
    assertThat(gcsfs.exists(new URI("gs://" + gcsfsIHelper.sharedBucketName1))).isFalse();
    assertThat(gcsfs.exists(new URI("gs://" + gcsfsIHelper.sharedBucketName2))).isFalse();
  }

  @Test
  public void emptyArgs() {
    String[] args = {};
    IllegalArgumentException e =
        assertThrows(IllegalArgumentException.class, () -> CoopLockFsck.main(args));
    assertThat(e).hasMessageThat().isEqualTo("No arguments are specified");
  }

  @Test
  public void helpCommand() throws Exception {
    CoopLockFsck.main(new String[] {"--help"});
  }

  @Test
  public void validRepairCommand_withoutBucketParameter() {
    String[] args = {"--check"};
    IllegalArgumentException e =
        assertThrows(IllegalArgumentException.class, () -> CoopLockFsck.main(args));
    assertThat(e).hasMessageThat().contains("2 arguments should be specified");
  }

  @Test
  public void validRepairCommand_withoutBucketAndOperationIdParameters() {
    String[] args = {"--rollBack"};
    IllegalArgumentException e =
        assertThrows(IllegalArgumentException.class, () -> CoopLockFsck.main(args));
    assertThat(e).hasMessageThat().contains("3 arguments should be specified");
  }

  @Test
  public void validRepairCommand_withoutOperationIdParameter() {
    String[] args = {"--rollForward", "gs://bucket"};
    IllegalArgumentException e =
        assertThrows(IllegalArgumentException.class, () -> CoopLockFsck.main(args));
    assertThat(e).hasMessageThat().contains("3 arguments should be specified");
  }

  @Test
  public void validRepairCommand_withInvalidBucketParameter() {
    String[] args = {"--rollBack", "bucket", "operation-id"};
    IllegalArgumentException e =
        assertThrows(IllegalArgumentException.class, () -> CoopLockFsck.main(args));
    assertThat(e).hasMessageThat().contains("bucket parameter should have 'gs://' scheme");
  }

  @Test
  public void invalidRepairCommand_withValidParameter() {
    String[] args = {"--invalidCommand", "gs://bucket", "operation-id"};
    IllegalArgumentException e =
        assertThrows(IllegalArgumentException.class, () -> CoopLockFsck.main(args));
    assertThat(e).hasMessageThat().contains("Unknown --invalidCommand command");
  }

  @Test
  public void noOperations_checkSucceeds() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-no-op-check-succeeds");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    fsck.run(new String[] {"--check", "gs://" + bucketName});

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    assertThat(gcsFs.exists(bucketUri.resolve(LOCK_DIRECTORY))).isFalse();
  }

  @Test
  public void failedDirectoryDelete_checkSucceeds() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-delete-check-failed");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    failDeleteOperation(gcsFsOptions, bucketName, dirUri);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    fsck.run(new String[] {"--check", "gs://" + bucketName});

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(3);
    assertThat(matchFile(lockFiles, "all\\.lock")).isNotNull();
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    URI lockFileUri = matchFile(lockFiles, filenamePattern + "\\.lock").get();
    URI logFileUri = matchFile(lockFiles, filenamePattern + "\\.log").get();
    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, DeleteOperation.class).setLockExpiration(null))
        .isEqualTo(new DeleteOperation().setLockExpiration(null).setResource(dirUri.toString()));
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(dirUri.resolve(fileName) + "\n" + dirUri + "\n");
  }

  @Test
  public void failedDirectoryDelete_noLockFile_checkSucceeds() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-delete-check-no-lock-failed");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    failDeleteOperation(gcsFsOptions, bucketName, dirUri);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    // delete operation lock file
    List<URI> lockFile =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .filter(p -> !p.toString().endsWith("/all.lock") && p.toString().endsWith(".lock"))
            .collect(toImmutableList());
    gcsFs.delete(Iterables.getOnlyElement(lockFile), /* recursive */ false);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    fsck.run(new String[] {"--check", "gs://" + bucketName});

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(2);
    assertThat(matchFile(lockFiles, "all\\.lock")).isNotNull();
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    URI logFileUri = matchFile(lockFiles, filenamePattern + "\\.log").get();
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(dirUri.resolve(fileName) + "\n" + dirUri + "\n");
  }

  @Test
  public void failedDirectoryDelete_noLogFile_checkSucceeds() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-delete-check-no-log-failed");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    failDeleteOperation(gcsFsOptions, bucketName, dirUri);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    // delete operation log file
    List<URI> logFile =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .filter(p -> p.toString().endsWith(".log"))
            .collect(toImmutableList());
    gcsFs.delete(Iterables.getOnlyElement(logFile), /* recursive */ false);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    fsck.run(new String[] {"--check", "gs://" + bucketName});

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(2);
    assertThat(matchFile(lockFiles, "all\\.lock")).isNotNull();
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    assertThat(matchFile(lockFiles, filenamePattern + "\\.log")).isEmpty();
  }

  @Test
  public void failedDirectoryDelete_rollForward_withWrongId_fails() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-delete-fwd-fail-bad-id");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    failDeleteOperation(gcsFsOptions, bucketName, dirUri);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    IllegalArgumentException e =
        assertThrows(
            IllegalArgumentException.class,
            () -> fsck.run(new String[] {"--rollForward", "gs://" + bucketName, "wrong-op-id"}));

    assertThat(e).hasMessageThat().isEqualTo("wrong-op-id operation not found");

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(3);
    assertThat(matchFile(lockFiles, "all\\.lock")).isNotNull();
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    URI lockFileUri = matchFile(lockFiles, filenamePattern + "\\.lock").get();
    URI logFileUri = matchFile(lockFiles, filenamePattern + "\\.log").get();
    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, DeleteOperation.class).setLockExpiration(null))
        .isEqualTo(new DeleteOperation().setLockExpiration(null).setResource(dirUri.toString()));
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(dirUri.resolve(fileName) + "\n" + dirUri + "\n");
  }

  @Test
  public void failedDirectoryDelete_rollForward_withCorrectId_succeeds() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-delete-fwd-fail-id");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    failDeleteOperation(gcsFsOptions, bucketName, dirUri);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    FileInfo lockInfo = gcsFs.getFileInfo(bucketUri.resolve(LOCK_PATH));
    String locks = new String(lockInfo.getAttributes().get("lock"), UTF_8);
    CoopLockRecords lockRecords = GSON.fromJson(locks, CoopLockRecords.class);
    String operationId = Iterables.getOnlyElement(lockRecords.getLocks()).getOperationId();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    // Wait until lock will expire
    sleepUninterruptibly(COOP_LOCK_TIMEOUT);

    fsck.run(new String[] {"--rollForward", "gs://" + bucketName, operationId});

    assertThat(gcsFs.exists(dirUri)).isFalse();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isFalse();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(2);
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    URI lockFileUri = matchFile(lockFiles, filenamePattern + "\\.lock").get();
    URI logFileUri = matchFile(lockFiles, filenamePattern + "\\.log").get();
    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, DeleteOperation.class).setLockExpiration(null))
        .isEqualTo(new DeleteOperation().setLockExpiration(null).setResource(dirUri.toString()));
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(dirUri.resolve(fileName) + "\n" + dirUri + "\n");
  }

  @Test
  public void successfulDirectoryDelete_rollForward() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-delete-forward-successful");

    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    gcsFs.delete(dirUri, /* recursive= */ true);

    assertThat(gcsFs.exists(dirUri)).isFalse();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isFalse();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    fsck.run(new String[] {"--rollForward", "gs://" + bucketName, "all"});

    assertThat(gcsFs.exists(dirUri)).isFalse();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isFalse();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(2);
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    URI lockFileUri = matchFile(lockFiles, filenamePattern + "\\.lock").get();
    URI logFileUri = matchFile(lockFiles, filenamePattern + "\\.log").get();
    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, DeleteOperation.class).setLockExpiration(null))
        .isEqualTo(new DeleteOperation().setLockExpiration(null).setResource(dirUri.toString()));
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(dirUri.resolve(fileName) + "\n" + dirUri + "\n");
  }

  @Test
  public void failedDirectoryRename_noLogFile_successfullyRepaired() throws Exception {
    String bucketName = gcsfsIHelper.createUniqueBucket("coop-rename-back-failed-copy-nolog");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String dirName = "rename_" + UUID.randomUUID();
    String fileName = "file";
    URI srcDirUri = bucketUri.resolve(dirName + "_src/");
    URI dstDirUri = bucketUri.resolve(dirName + "_dst/");

    // create file to rename
    gcsfsIHelper.writeTextFile(bucketName, srcDirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    // fail rename operation during log file creation
    failRenameOperation(
        srcDirUri,
        dstDirUri,
        gcsFsOptions,
        r ->
            "POST".equals(r.getRequestMethod())
                && r.getUrl().toString().contains(".log")
                && !r.getUrl().toString().contains("all.log"));

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(srcDirUri)).isTrue();
    assertThat(gcsFs.exists(srcDirUri.resolve(fileName))).isTrue();
    assertThat(gcsFs.exists(dstDirUri)).isFalse();
    assertThat(gcsFs.exists(dstDirUri.resolve(fileName))).isFalse();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    // Wait until lock will expire
    sleepUninterruptibly(COOP_LOCK_TIMEOUT);

    fsck.run(new String[] {"--rollBack", "gs://" + bucketName, "all"});

    assertThat(gcsFs.exists(dstDirUri)).isFalse();
    assertThat(gcsFs.exists(dstDirUri.resolve(fileName))).isFalse();
    assertThat(gcsFs.exists(srcDirUri)).isTrue();
    assertThat(gcsFs.exists(srcDirUri.resolve(fileName))).isTrue();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(1);
    String filenameFormat = String.format(OPERATION_FILENAME_PATTERN_FORMAT, RENAME);
    URI lockFileUri = matchFile(lockFiles, filenameFormat + "\\.lock").get();

    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, RenameOperation.class).setLockExpiration(null))
        .isEqualTo(
            new RenameOperation()
                .setLockExpiration(null)
                .setSrcResource(srcDirUri.toString())
                .setDstResource(dstDirUri.toString())
                .setCopySucceeded(false));
    assertThat(matchFile(lockFiles, filenameFormat + "\\.log")).isEmpty();
  }

  @Test
  public void failedDirectoryRename_successfullyRolledForward_afterFailedCopy() throws Exception {
    failedDirectoryRename_successfullyRepaired("--rollForward", /* failCopy= */ true);
  }

  @Test
  public void failedDirectoryRename_successfullyRolledBack_afterFailedCopy() throws Exception {
    failedDirectoryRename_successfullyRepaired("--rollBack", /* failCopy= */ true);
  }

  @Test
  public void failedDirectoryRename_successfullyRolledForward_afterFailedDelete() throws Exception {
    failedDirectoryRename_successfullyRepaired("--rollForward", /* failCopy= */ false);
  }

  @Test
  public void failedDirectoryRename_successfullyRolledBack_afterFailedDelete() throws Exception {
    failedDirectoryRename_successfullyRepaired("--rollBack", /* failCopy= */ false);
  }

  private static void failedDirectoryRename_successfullyRepaired(String command, boolean failCopy)
      throws Exception {
    String commandSuffix = Ascii.toLowerCase(command).replace("--roll", "");
    String bucketName =
        gcsfsIHelper.createUniqueBucket(
            String.format("coop-rename-%s-failed-%s", commandSuffix, failCopy ? "copy" : "delete"));
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String dirName = "rename_" + UUID.randomUUID();
    String fileName = "file";
    URI srcDirUri = bucketUri.resolve(dirName + "_src/");
    URI dstDirUri = bucketUri.resolve(dirName + "_dst/");

    // create file to rename
    gcsfsIHelper.writeTextFile(bucketName, srcDirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    Predicate<HttpRequest> failPredicate =
        failCopy
            ? r -> "POST".equals(r.getRequestMethod()) && r.getUrl().toString().contains("/copyTo/")
            : r ->
                "DELETE".equals(r.getRequestMethod())
                    && r.getUrl().toString().contains("/b/" + bucketName + "/o/");
    failRenameOperation(srcDirUri, dstDirUri, gcsFsOptions, failPredicate);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(srcDirUri)).isTrue();
    assertThat(gcsFs.exists(srcDirUri.resolve(fileName))).isTrue();
    assertThat(gcsFs.exists(dstDirUri)).isTrue();
    assertThat(gcsFs.exists(dstDirUri.resolve(fileName))).isEqualTo(!failCopy);

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    // Wait until lock will expire
    sleepUninterruptibly(COOP_LOCK_TIMEOUT);

    fsck.run(new String[] {command, "gs://" + bucketName, "all"});

    URI deletedDirUri = "--rollForward".equals(command) ? srcDirUri : dstDirUri;
    URI repairedDirUri = "--rollForward".equals(command) ? dstDirUri : srcDirUri;
    assertThat(gcsFs.exists(deletedDirUri)).isFalse();
    assertThat(gcsFs.exists(deletedDirUri.resolve(fileName))).isFalse();
    assertThat(gcsFs.exists(repairedDirUri)).isTrue();
    assertThat(gcsFs.exists(repairedDirUri.resolve(fileName))).isTrue();

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize(2);
    String filenameFormat = String.format(OPERATION_FILENAME_PATTERN_FORMAT, RENAME);
    URI lockFileUri = matchFile(lockFiles, filenameFormat + "\\.lock").get();
    URI logFileUri = matchFile(lockFiles, filenameFormat + "\\.log").get();

    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, RenameOperation.class).setLockExpiration(null))
        .isEqualTo(
            new RenameOperation()
                .setLockExpiration(null)
                .setSrcResource(srcDirUri.toString())
                .setDstResource(dstDirUri.toString())
                .setCopySucceeded("--rollForward".equals(command)));
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(
            String.format(
                "{\"src\":\"%s\",\"dst\":\"%s\"}\n",
                srcDirUri.resolve(fileName), dstDirUri.resolve(fileName)));
  }

  private static void failRenameOperation(
      URI srcDirUri,
      URI dstDirUri,
      GoogleCloudStorageFileSystemOptions options,
      Predicate<HttpRequest> failPredicate)
      throws IOException {
    HttpRequestInitializer failingRequestInitializer = newFailingRequestInitializer(failPredicate);
    GoogleCloudStorageFileSystem failingGcsFs = newGcsFs(options, failingRequestInitializer);

    Exception e = assertThrows(Exception.class, () -> failingGcsFs.rename(srcDirUri, dstDirUri));
    assertThat(e).hasCauseThat().hasCauseThat().hasMessageThat().endsWith("Injected failure");
  }

  @Test
  public void failedDirectoryDelete_successfullyRolledForward() throws Exception {
    failedDirectoryDelete_successfullyRepaired("--rollForward");
  }

  @Test
  public void failedDirectoryDelete_successfullyRolledBack() throws Exception {
    failedDirectoryDelete_successfullyRepaired("--rollBack");
  }

  private static void failedDirectoryDelete_successfullyRepaired(String command) throws Exception {
    String bucketName =
        gcsfsIHelper.createUniqueBucket(
            "coop-delete-" + Ascii.toLowerCase(command).replace("--roll", "") + "-failed");
    URI bucketUri = new URI("gs://" + bucketName + "/");
    String fileName = "file";
    URI dirUri = bucketUri.resolve("delete_" + UUID.randomUUID() + "/");

    // create file to delete
    gcsfsIHelper.writeTextFile(bucketName, dirUri.resolve(fileName).getPath(), "file_content");

    GoogleCloudStorageFileSystemOptions gcsFsOptions = newGcsFsOptions();

    failDeleteOperation(gcsFsOptions, bucketName, dirUri);

    GoogleCloudStorageFileSystem gcsFs = newGcsFs(gcsFsOptions, httpRequestInitializer);

    assertThat(gcsFs.exists(dirUri)).isTrue();
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isTrue();

    CoopLockFsck fsck = new CoopLockFsck();
    fsck.setConf(getTestConfiguration());

    // Wait until lock will expire
    sleepUninterruptibly(COOP_LOCK_TIMEOUT);

    fsck.run(new String[] {command, "gs://" + bucketName, "all"});

    assertThat(gcsFs.exists(dirUri)).isEqualTo(!"--rollForward".equals(command));
    assertThat(gcsFs.exists(dirUri.resolve(fileName))).isEqualTo(!"--rollForward".equals(command));

    // Validate lock files
    List<URI> lockFiles =
        gcsFs.listFileInfo(bucketUri.resolve(LOCK_DIRECTORY)).stream()
            .map(FileInfo::getPath)
            .collect(toList());

    assertThat(lockFiles).hasSize("--rollForward".equals(command) ? 2 : 3);
    String filenamePattern = String.format(OPERATION_FILENAME_PATTERN_FORMAT, DELETE);
    URI lockFileUri = matchFile(lockFiles, filenamePattern + "\\.lock").get();
    URI logFileUri = matchFile(lockFiles, filenamePattern + "\\.log").get();
    String lockContent = gcsfsIHelper.readTextFile(bucketName, lockFileUri.getPath());
    assertThat(GSON.fromJson(lockContent, DeleteOperation.class).setLockExpiration(null))
        .isEqualTo(new DeleteOperation().setLockExpiration(null).setResource(dirUri.toString()));
    assertThat(gcsfsIHelper.readTextFile(bucketName, logFileUri.getPath()))
        .isEqualTo(dirUri.resolve(fileName) + "\n" + dirUri + "\n");
  }

  private static void failDeleteOperation(
      GoogleCloudStorageFileSystemOptions gcsFsOptions, String bucketName, URI dirUri)
      throws Exception {
    HttpRequestInitializer failingRequestInitializer =
        newFailingRequestInitializer(
            request ->
                "DELETE".equals(request.getRequestMethod())
                    && request.getUrl().toString().contains("/b/" + bucketName + "/o/"));
    GoogleCloudStorageFileSystem failingGcsFs = newGcsFs(gcsFsOptions, failingRequestInitializer);

    IOException e =
        assertThrows(IOException.class, () -> failingGcsFs.delete(dirUri, /* recursive= */ true));
    assertThat(e).hasCauseThat().hasCauseThat().hasMessageThat().endsWith("Injected failure");
  }

  private static HttpRequestInitializer newFailingRequestInitializer(
      Predicate<HttpRequest> failurePredicate) {
    return request -> {
      httpRequestInitializer.initialize(request);
      HttpExecuteInterceptor executeInterceptor = checkNotNull(request.getInterceptor());
      request.setInterceptor(
          interceptedRequest -> {
            executeInterceptor.intercept(interceptedRequest);
            if (failurePredicate.test(interceptedRequest)) {
              throw new RuntimeException("Injected failure");
            }
          });
    };
  }

  private static Configuration getTestConfiguration() {
    Configuration conf = new Configuration();
    conf.set("fs.gs.impl", GoogleHadoopFileSystem.class.getName());
    conf.setBoolean(GCS_CONFIG_PREFIX + ENABLE_SERVICE_ACCOUNTS_SUFFIX.getKey(), true);
    conf.setLong(
        GCS_COOPERATIVE_LOCKING_EXPIRATION_TIMEOUT_MS.getKey(), COOP_LOCK_TIMEOUT.toMillis());

    // Configure test authentication
    TestConfiguration testConf = TestConfiguration.getInstance();
    conf.set(GCS_PROJECT_ID.getKey(), testConf.getProjectId());
    if (testConf.getServiceAccount() != null && testConf.getPrivateKeyFile() != null) {
      conf.set(
          GCS_CONFIG_PREFIX + SERVICE_ACCOUNT_EMAIL_SUFFIX.getKey(), testConf.getServiceAccount());
      conf.set(
          GCS_CONFIG_PREFIX + SERVICE_ACCOUNT_KEYFILE_SUFFIX.getKey(),
          testConf.getPrivateKeyFile());
    }
    return conf;
  }

  private static Optional<URI> matchFile(List<URI> files, String pattern) {
    return files.stream().filter(f -> f.toString().matches("^gs://.*/" + pattern + "$")).findAny();
  }

  private static GoogleCloudStorageFileSystemOptions newGcsFsOptions() {
    CooperativeLockingOptions coopLockOptions =
        CooperativeLockingOptions.builder()
            .setLockExpirationTimeoutMilli(COOP_LOCK_TIMEOUT.toMillis())
            .build();
    return GoogleCloudStorageFileSystemOptions.builder()
        .setCloudStorageOptions(
            gcsOptions.toBuilder().setCooperativeLockingOptions(coopLockOptions).build())
        .setCooperativeLockingEnabled(true)
        .build();
  }

  private static GoogleCloudStorageFileSystem newGcsFs(
      GoogleCloudStorageFileSystemOptions gcsFsOptions, HttpRequestInitializer requestInitializer)
      throws IOException {
    GoogleCloudStorageImpl gcs =
        new GoogleCloudStorageImpl(gcsFsOptions.getCloudStorageOptions(), requestInitializer);
    return new GoogleCloudStorageFileSystem(gcs, gcsFsOptions);
  }
}