com.google.gerrit.server.group.db.GroupsUpdate Maven / Gradle / Ivy
// Copyright (C) 2017 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.group.db;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupCache;
import com.google.gerrit.server.account.GroupIncludeCache;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.group.GroupAuditService;
import com.google.gerrit.server.index.group.GroupIndexer;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
/**
 * A database accessor for write calls related to groups.
 *
 * All calls which write group related details to the database are gathered here. Other classes
 * should always use this class instead of accessing the database directly. There are a few
 * exceptions though: schema classes, wrapper classes, and classes executed during init. The latter
 * ones should use {@link com.google.gerrit.pgm.init.GroupsOnInit} instead.
 *
 * 
If not explicitly stated, all methods of this class refer to internal groups.
 */
public class GroupsUpdate {
  public interface Factory {
    /**
     * Creates a {@link GroupsUpdate} which uses the identity of the specified user to mark database
     * modifications executed by it. For NoteDb, this identity is used as author and committer for
     * all related commits.
     *
     * 
Note: Please use this method with care and consider using the {@link
     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@link GroupsUpdate}
     * instead.
     *
     * @param currentUser the user to which modifications should be attributed
     */
    GroupsUpdate create(IdentifiedUser currentUser);
    /**
     * Creates a {@link GroupsUpdate} which uses the server identity to mark database modifications
     * executed by it. For NoteDb, this identity is used as author and committer for all related
     * commits.
     *
     * 
Note: Please use this method with care and consider using the {@link
     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@link
     * GroupsUpdate} instead.
     */
    GroupsUpdate createWithServerIdent();
  }
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
  private final GitRepositoryManager repoManager;
  private final AllUsersName allUsersName;
  private final GroupCache groupCache;
  private final GroupIncludeCache groupIncludeCache;
  private final Provider indexer;
  private final GroupAuditService groupAuditService;
  private final RenameGroupOp.Factory renameGroupOpFactory;
  private final Optional currentUser;
  private final AuditLogFormatter auditLogFormatter;
  private final PersonIdent authorIdent;
  private final MetaDataUpdateFactory metaDataUpdateFactory;
  private final GitReferenceUpdated gitRefUpdated;
  private final RetryHelper retryHelper;
  @AssistedInject
  GroupsUpdate(
      GitRepositoryManager repoManager,
      AllUsersName allUsersName,
      GroupBackend groupBackend,
      GroupCache groupCache,
      GroupIncludeCache groupIncludeCache,
      Provider indexer,
      GroupAuditService auditService,
      AccountCache accountCache,
      RenameGroupOp.Factory renameGroupOpFactory,
      @GerritServerId String serverId,
      @GerritPersonIdent PersonIdent serverIdent,
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      GitReferenceUpdated gitRefUpdated,
      RetryHelper retryHelper) {
    this(
        repoManager,
        allUsersName,
        groupBackend,
        groupCache,
        groupIncludeCache,
        indexer,
        auditService,
        accountCache,
        renameGroupOpFactory,
        serverId,
        serverIdent,
        metaDataUpdateInternalFactory,
        gitRefUpdated,
        retryHelper,
        Optional.empty());
  }
  @AssistedInject
  GroupsUpdate(
      GitRepositoryManager repoManager,
      AllUsersName allUsersName,
      GroupBackend groupBackend,
      GroupCache groupCache,
      GroupIncludeCache groupIncludeCache,
      Provider indexer,
      GroupAuditService auditService,
      AccountCache accountCache,
      RenameGroupOp.Factory renameGroupOpFactory,
      @GerritServerId String serverId,
      @GerritPersonIdent PersonIdent serverIdent,
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      GitReferenceUpdated gitRefUpdated,
      RetryHelper retryHelper,
      @Assisted IdentifiedUser currentUser) {
    this(
        repoManager,
        allUsersName,
        groupBackend,
        groupCache,
        groupIncludeCache,
        indexer,
        auditService,
        accountCache,
        renameGroupOpFactory,
        serverId,
        serverIdent,
        metaDataUpdateInternalFactory,
        gitRefUpdated,
        retryHelper,
        Optional.of(currentUser));
  }
  private GroupsUpdate(
      GitRepositoryManager repoManager,
      AllUsersName allUsersName,
      GroupBackend groupBackend,
      GroupCache groupCache,
      GroupIncludeCache groupIncludeCache,
      Provider indexer,
      GroupAuditService auditService,
      AccountCache accountCache,
      RenameGroupOp.Factory renameGroupOpFactory,
      @GerritServerId String serverId,
      @GerritPersonIdent PersonIdent serverIdent,
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      GitReferenceUpdated gitRefUpdated,
      RetryHelper retryHelper,
      Optional currentUser) {
    this.repoManager = repoManager;
    this.allUsersName = allUsersName;
    this.groupCache = groupCache;
    this.groupIncludeCache = groupIncludeCache;
    this.indexer = indexer;
    this.groupAuditService = auditService;
    this.renameGroupOpFactory = renameGroupOpFactory;
    this.gitRefUpdated = gitRefUpdated;
    this.retryHelper = retryHelper;
    this.currentUser = currentUser;
    auditLogFormatter = AuditLogFormatter.createBackedBy(accountCache, groupBackend, serverId);
    metaDataUpdateFactory =
        getMetaDataUpdateFactory(
            metaDataUpdateInternalFactory, currentUser, serverIdent, auditLogFormatter);
    authorIdent = getAuthorIdent(serverIdent, currentUser);
  }
  private static MetaDataUpdateFactory getMetaDataUpdateFactory(
      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
      Optional currentUser,
      PersonIdent serverIdent,
      AuditLogFormatter auditLogFormatter) {
    return (projectName, repository, batchRefUpdate) -> {
      MetaDataUpdate metaDataUpdate =
          metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
      metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
      PersonIdent authorIdent;
      if (currentUser.isPresent()) {
        metaDataUpdate.setAuthor(currentUser.get());
        authorIdent =
            auditLogFormatter.getParsableAuthorIdent(currentUser.get().getAccount(), serverIdent);
      } else {
        authorIdent = serverIdent;
      }
      metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
      return metaDataUpdate;
    };
  }
  private static PersonIdent getAuthorIdent(
      PersonIdent serverIdent, Optional currentUser) {
    return currentUser.map(user -> createPersonIdent(serverIdent, user)).orElse(serverIdent);
  }
  private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
    return user.newCommitterIdent(ident);
  }
  /**
   * Creates the specified group for the specified members (accounts).
   *
   * @param groupCreation an {@link InternalGroupCreation} which specifies all mandatory properties
   *     of the group
   * @param groupDelta a {@link GroupDelta} which specifies optional properties of the group. If
   *     this {@link GroupDelta} updates a property which was already specified by the {@link
   *     InternalGroupCreation}, the value of this {@link GroupDelta} wins.
   * @throws DuplicateKeyException if a group with the chosen name already exists
   * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
   * @return the created {@link InternalGroup}
   */
  @CanIgnoreReturnValue
  public InternalGroup createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
      throws DuplicateKeyException, IOException, ConfigInvalidException {
    try (TraceTimer ignored =
        TraceContext.newTimer(
            "Creating group",
            Metadata.builder()
                .groupName(groupDelta.getName().orElseGet(groupCreation::getNameKey).get())
                .build())) {
      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupDelta);
      evictCachesOnGroupCreation(createdGroup);
      dispatchAuditEventsOnGroupCreation(createdGroup);
      return createdGroup;
    }
  }
  /**
   * Updates the specified group.
   *
   * @param groupUuid the UUID of the group to update
   * @param groupDelta a {@link GroupDelta} which indicates the desired updates on the group
   * @throws DuplicateKeyException if the new name of the group is used by another group
   * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
   * @throws NoSuchGroupException if the specified group doesn't exist
   */
  public void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
      throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
    try (TraceTimer ignored =
        TraceContext.newTimer(
            "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
      Optional updatedOn = groupDelta.getUpdatedOn();
      if (!updatedOn.isPresent()) {
        updatedOn = Optional.of(TimeUtil.now());
        groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
      }
      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupDelta);
      updateNameInProjectConfigsIfNecessary(result);
      evictCachesOnGroupUpdate(result);
      dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
    }
  }
  private InternalGroup createGroupInNoteDbWithRetry(
      InternalGroupCreation groupCreation, GroupDelta groupDelta)
      throws IOException, ConfigInvalidException, DuplicateKeyException {
    try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
      try {
        return retryHelper
            .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
            .call();
      } catch (Exception e) {
        Throwables.throwIfUnchecked(e);
        Throwables.throwIfInstanceOf(e, IOException.class);
        Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
        Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
        throw new IOException(e);
      }
    }
  }
  @VisibleForTesting
  public InternalGroup createGroupInNoteDb(
      InternalGroupCreation groupCreation, GroupDelta groupDelta)
      throws IOException, ConfigInvalidException, DuplicateKeyException {
    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
      AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
      GroupNameNotes groupNameNotes =
          GroupNameNotes.forNewGroup(
              allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
      GroupConfig groupConfig =
          GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
      commit(allUsersRepo, groupConfig, groupNameNotes);
      return groupConfig
          .getLoadedGroup()
          .orElseThrow(
              () -> new IllegalStateException("Created group wasn't automatically loaded"));
    }
  }
  private UpdateResult updateGroupInNoteDbWithRetry(
      AccountGroup.UUID groupUuid, GroupDelta groupDelta)
      throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
    try {
      return retryHelper
          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupDelta))
          .call();
    } catch (Exception e) {
      Throwables.throwIfUnchecked(e);
      Throwables.throwIfInstanceOf(e, IOException.class);
      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
      Throwables.throwIfInstanceOf(e, DuplicateKeyException.class);
      Throwables.throwIfInstanceOf(e, NoSuchGroupException.class);
      throw new IOException(e);
    }
  }
  @VisibleForTesting
  @CanIgnoreReturnValue
  public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
      throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
    try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
        groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
        if (!groupConfig.getLoadedGroup().isPresent()) {
          throw new NoSuchGroupException(groupUuid);
        }
        InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
        GroupNameNotes groupNameNotes = null;
        if (groupDelta.getName().isPresent()) {
          AccountGroup.NameKey oldName = originalGroup.getNameKey();
          AccountGroup.NameKey newName = groupDelta.getName().get();
          groupNameNotes =
              GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
        }
        commit(allUsersRepo, groupConfig, groupNameNotes);
        InternalGroup updatedGroup =
            groupConfig
                .getLoadedGroup()
                .orElseThrow(
                    () -> new IllegalStateException("Updated group wasn't automatically loaded"));
        return getUpdateResult(originalGroup, updatedGroup);
      }
    }
  }
  private static UpdateResult getUpdateResult(
      InternalGroup originalGroup, InternalGroup updatedGroup) {
    Set addedMembers =
        Sets.difference(updatedGroup.getMembers(), originalGroup.getMembers());
    Set deletedMembers =
        Sets.difference(originalGroup.getMembers(), updatedGroup.getMembers());
    Set addedSubgroups =
        Sets.difference(updatedGroup.getSubgroups(), originalGroup.getSubgroups());
    Set deletedSubgroups =
        Sets.difference(originalGroup.getSubgroups(), updatedGroup.getSubgroups());
    UpdateResult.Builder resultBuilder =
        UpdateResult.builder()
            .setGroupUuid(updatedGroup.getGroupUUID())
            .setGroupId(updatedGroup.getId())
            .setGroupName(updatedGroup.getNameKey())
            .setAddedMembers(addedMembers)
            .setDeletedMembers(deletedMembers)
            .setAddedSubgroups(addedSubgroups)
            .setDeletedSubgroups(deletedSubgroups);
    if (!Objects.equals(originalGroup.getNameKey(), updatedGroup.getNameKey())) {
      resultBuilder.setPreviousGroupName(originalGroup.getNameKey());
    }
    return resultBuilder.build();
  }
  private void commit(
      Repository allUsersRepo, GroupConfig groupConfig, @Nullable GroupNameNotes groupNameNotes)
      throws IOException {
    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
    try (MetaDataUpdate metaDataUpdate =
        metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
      groupConfig.commit(metaDataUpdate);
    }
    if (groupNameNotes != null) {
      // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
      try (MetaDataUpdate metaDataUpdate =
          metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
        groupNameNotes.commit(metaDataUpdate);
      }
    }
    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
    gitRefUpdated.fire(
        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
  }
  private void evictCachesOnGroupCreation(InternalGroup createdGroup) {
    logger.atFine().log("evict caches on creation of group %s", createdGroup.getGroupUUID());
    // By UUID is used for the index and hence should be evicted before refreshing the index.
    groupCache.evict(createdGroup.getGroupUUID());
    indexer.get().index(createdGroup.getGroupUUID());
    // These caches use the result from the index and hence must be evicted after refreshing the
    // index.
    groupCache.evict(createdGroup.getId());
    groupCache.evict(createdGroup.getNameKey());
    createdGroup.getMembers().forEach(groupIncludeCache::evictGroupsWithMember);
    createdGroup.getSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
  }
  private void evictCachesOnGroupUpdate(UpdateResult result) {
    logger.atFine().log("evict caches on update of group %s", result.getGroupUuid());
    // By UUID is used for the index and hence should be evicted before refreshing the index.
    groupCache.evict(result.getGroupUuid());
    indexer.get().index(result.getGroupUuid());
    // These caches use the result from the index and hence must be evicted after refreshing the
    // index.
    groupCache.evict(result.getGroupId());
    groupCache.evict(result.getGroupName());
    result.getPreviousGroupName().ifPresent(groupCache::evict);
    result.getAddedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
    result.getDeletedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
    result.getAddedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
    result.getDeletedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf);
  }
  private void updateNameInProjectConfigsIfNecessary(UpdateResult result) {
    if (result.getPreviousGroupName().isPresent()) {
      AccountGroup.NameKey previousName = result.getPreviousGroupName().get();
      @SuppressWarnings("unused")
      Future> possiblyIgnoredError =
          renameGroupOpFactory
              .create(
                  authorIdent,
                  result.getGroupUuid(),
                  previousName.get(),
                  result.getGroupName().get())
              .start(0, TimeUnit.MILLISECONDS);
    }
  }
  private void dispatchAuditEventsOnGroupCreation(InternalGroup createdGroup) {
    if (!currentUser.isPresent()) {
      return;
    }
    if (!createdGroup.getMembers().isEmpty()) {
      groupAuditService.dispatchAddMembers(
          currentUser.get().getAccountId(),
          createdGroup.getGroupUUID(),
          createdGroup.getMembers(),
          createdGroup.getCreatedOn());
    }
    if (!createdGroup.getSubgroups().isEmpty()) {
      groupAuditService.dispatchAddSubgroups(
          currentUser.get().getAccountId(),
          createdGroup.getGroupUUID(),
          createdGroup.getSubgroups(),
          createdGroup.getCreatedOn());
    }
  }
  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Instant updatedOn) {
    if (!currentUser.isPresent()) {
      return;
    }
    if (!result.getAddedMembers().isEmpty()) {
      groupAuditService.dispatchAddMembers(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getAddedMembers(),
          updatedOn);
    }
    if (!result.getDeletedMembers().isEmpty()) {
      groupAuditService.dispatchDeleteMembers(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getDeletedMembers(),
          updatedOn);
    }
    if (!result.getAddedSubgroups().isEmpty()) {
      groupAuditService.dispatchAddSubgroups(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getAddedSubgroups(),
          updatedOn);
    }
    if (!result.getDeletedSubgroups().isEmpty()) {
      groupAuditService.dispatchDeleteSubgroups(
          currentUser.get().getAccountId(),
          result.getGroupUuid(),
          result.getDeletedSubgroups(),
          updatedOn);
    }
  }
  @FunctionalInterface
  private interface MetaDataUpdateFactory {
    MetaDataUpdate create(
        Project.NameKey projectName, Repository repository, BatchRefUpdate batchRefUpdate)
        throws IOException;
  }
  @AutoValue
  abstract static class UpdateResult {
    abstract AccountGroup.UUID getGroupUuid();
    abstract AccountGroup.Id getGroupId();
    abstract AccountGroup.NameKey getGroupName();
    abstract Optional getPreviousGroupName();
    abstract ImmutableSet getAddedMembers();
    abstract ImmutableSet getDeletedMembers();
    abstract ImmutableSet getAddedSubgroups();
    abstract ImmutableSet getDeletedSubgroups();
    static Builder builder() {
      return new AutoValue_GroupsUpdate_UpdateResult.Builder();
    }
    @AutoValue.Builder
    abstract static class Builder {
      abstract Builder setGroupUuid(AccountGroup.UUID groupUuid);
      abstract Builder setGroupId(AccountGroup.Id groupId);
      abstract Builder setGroupName(AccountGroup.NameKey name);
      abstract Builder setPreviousGroupName(AccountGroup.NameKey previousName);
      abstract Builder setAddedMembers(Set addedMembers);
      abstract Builder setDeletedMembers(Set deletedMembers);
      abstract Builder setAddedSubgroups(Set addedSubgroups);
      abstract Builder setDeletedSubgroups(Set deletedSubgroups);
      abstract UpdateResult build();
    }
  }
}