All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.group.db.GroupNameNotes Maven / Gradle / Ivy

There is a newer version: 3.11.0-rc3
Show newest version
// 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.common.collect.ImmutableBiMap.toImmutableBiMap;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.HashMultiset;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Multiset;
import com.google.common.flogger.FluentLogger;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.server.git.meta.VersionedMetaData;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import java.io.IOException;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.notes.Note;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;

/**
 * An enforcer of unique names for groups in NoteDb.
 *
 * 

The way groups are stored in NoteDb (see {@link GroupConfig}) doesn't enforce unique names, * even though groups in Gerrit must not have duplicate names. The storage format doesn't allow to * quickly look up whether a name has already been used either. That's why we additionally keep a * map of name/UUID pairs and manage it with this class. * *

To claim the name for a new group, create an instance of {@code GroupNameNotes} via {@link * #forNewGroup(Project.NameKey, Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call * {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it. * For renaming, call {@link #forRename(Project.NameKey, Repository, AccountGroup.UUID, * AccountGroup.NameKey, AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}. * Both times, the creation of the {@code GroupNameNotes} will fail if the (new) name is already * used. Committing the {@code GroupNameNotes} is necessary to make the adjustments for real. * *

The map has an additional benefit: We can quickly iterate over all group name/UUID pairs * without having to load all groups completely (which is costly). * *

Internal details * *

The map of names is represented by Git {@link Note notes}. They are stored on the branch * {@link RefNames#REFS_GROUPNAMES}. Each commit on the branch reflects one moment in time of the * complete map. * *

As key for the notes, we use the SHA-1 of the name. As data, they contain a text version of a * JGit {@link Config} file. That config file has two entries: * *

    *
  • the name of the group (as clear text) *
  • the UUID of the group which currently has this name *
*/ public class GroupNameNotes extends VersionedMetaData { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final String SECTION_NAME = "group"; private static final String UUID_PARAM = "uuid"; private static final String NAME_PARAM = "name"; @VisibleForTesting static final String UNIQUE_REF_ERROR = "GroupReference collection must contain unique references"; /** * Creates an instance of {@code GroupNameNotes} for use when renaming a group. * *

Note: The returned instance of {@code GroupNameNotes} has to be committed * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in * order to claim the new name and free up the old one. * * @param projectName the name of the project which holds the commits of the notes * @param repository the repository which holds the commits of the notes * @param groupUuid the UUID of the group which is renamed * @param oldName the current name of the group * @param newName the new name of the group * @return an instance of {@code GroupNameNotes} configured for a specific renaming of a group * @throws IOException if the repository can't be accessed for some reason * @throws ConfigInvalidException if the note for the specified group doesn't exist or is in an * invalid state * @throws DuplicateKeyException if a group with the new name already exists */ public static GroupNameNotes forRename( Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey oldName, AccountGroup.NameKey newName) throws IOException, ConfigInvalidException, DuplicateKeyException { requireNonNull(oldName); requireNonNull(newName); GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName); groupNameNotes.load(projectName, repository); groupNameNotes.ensureNewNameIsNotUsed(); return groupNameNotes; } /** * Creates an instance of {@code GroupNameNotes} for use when creating a new group. * *

Note: The returned instance of {@code GroupNameNotes} has to be committed * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in * order to claim the new name. * * @param projectName the name of the project which holds the commits of the notes * @param repository the repository which holds the commits of the notes * @param groupUuid the UUID of the new group * @param groupName the name of the new group * @return an instance of {@code GroupNameNotes} configured for a specific group creation * @throws IOException if the repository can't be accessed for some reason * @throws ConfigInvalidException in no case so far * @throws DuplicateKeyException if a group with the new name already exists */ public static GroupNameNotes forNewGroup( Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName) throws IOException, ConfigInvalidException, DuplicateKeyException { requireNonNull(groupName); GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName); groupNameNotes.load(projectName, repository); groupNameNotes.ensureNewNameIsNotUsed(); return groupNameNotes; } /** * Loads the {@code GroupReference} (name/UUID pair) for the group with the specified name. * * @param repository the repository which holds the commits of the notes * @param groupName the name of the group * @return the corresponding {@code GroupReference} if a group/note with the given name exists * @throws IOException if the repository can't be accessed for some reason * @throws ConfigInvalidException if the note for the specified group is in an invalid state */ public static Optional loadGroup( Repository repository, AccountGroup.NameKey groupName) throws IOException, ConfigInvalidException { Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES); if (ref == null) { return Optional.empty(); } try (RevWalk revWalk = new RevWalk(repository); ObjectReader reader = revWalk.getObjectReader()) { RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId()); NoteMap noteMap = NoteMap.read(reader, notesCommit); ObjectId noteDataBlobId = noteMap.get(getNoteKey(groupName)); if (noteDataBlobId == null) { return Optional.empty(); } return Optional.of(getGroupReference(reader, noteDataBlobId)); } } /** * Loads the {@code GroupReference}s (name/UUID pairs) for all groups. * *

Even though group UUIDs should be unique, this class doesn't enforce it. For this reason, * it's technically possible that two of the {@code GroupReference}s have a duplicate UUID but a * different name. In practice, this shouldn't occur unless we introduce a bug in the future. * * @param repository the repository which holds the commits of the notes * @return the {@code GroupReference}s of all existing groups/notes * @throws IOException if the repository can't be accessed for some reason * @throws ConfigInvalidException if one of the notes is in an invalid state */ public static ImmutableList loadAllGroups(Repository repository) throws IOException, ConfigInvalidException { Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES); if (ref == null) { return ImmutableList.of(); } try (TraceTimer ignored = TraceContext.newTimer( "Loading all groups", Metadata.builder().noteDbRefName(RefNames.REFS_GROUPNAMES).build()); RevWalk revWalk = new RevWalk(repository); ObjectReader reader = revWalk.getObjectReader()) { RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId()); NoteMap noteMap = NoteMap.read(reader, notesCommit); Multiset groupReferences = HashMultiset.create(); for (Note note : noteMap) { GroupReference groupReference = getGroupReference(reader, note.getData()); int numOfOccurrences = groupReferences.add(groupReference, 1); if (numOfOccurrences > 1) { GroupsNoteDbConsistencyChecker.logConsistencyProblemAsWarning( "The UUID of group %s (%s) is duplicate in group name notes", groupReference.getName(), groupReference.getUUID()); } } return ImmutableList.copyOf(groupReferences); } } /** * Replaces the map of name/UUID pairs with a new version which matches exactly the passed {@code * GroupReference}s. * *

All old entries are discarded and replaced by the new ones. * *

This operation also works if the previous map has invalid entries or can't be read anymore. * *

Note: This method doesn't flush the {@code ObjectInserter}. It doesn't * execute the {@code BatchRefUpdate} either. * * @param repository the repository which holds the commits of the notes * @param inserter an {@code ObjectInserter} for that repository * @param bru a {@code BatchRefUpdate} to which this method adds commands * @param groupReferences all {@code GroupReference}s (name/UUID pairs) which should be contained * in the map of name/UUID pairs * @param ident the {@code PersonIdent} which is used as author and committer for commits * @throws IOException if the repository can't be accessed for some reason */ public static void updateAllGroups( Repository repository, ObjectInserter inserter, BatchRefUpdate bru, Collection groupReferences, PersonIdent ident) throws IOException { // Not strictly necessary for iteration; throws IAE if it encounters duplicates, which is nice. ImmutableBiMap biMap = toBiMap(groupReferences); try (ObjectReader reader = inserter.newReader(); RevWalk rw = new RevWalk(reader)) { // Always start from an empty map, discarding old notes. NoteMap noteMap = NoteMap.newEmptyMap(); Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES); RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null; for (Map.Entry e : biMap.entrySet()) { AccountGroup.NameKey nameKey = AccountGroup.nameKey(e.getValue()); ObjectId noteKey = getNoteKey(nameKey); noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter); } ObjectId newTreeId = noteMap.writeTree(inserter); if (oldCommit != null && newTreeId.equals(oldCommit.getTree())) { return; } CommitBuilder cb = new CommitBuilder(); if (oldCommit != null) { cb.addParentId(oldCommit); } cb.setTreeId(newTreeId); cb.setAuthor(ident); cb.setCommitter(ident); int n = groupReferences.size(); cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : "")); ObjectId newId = inserter.insert(cb).copy(); ObjectId oldId = ObjectIds.copyOrZero(oldCommit); bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES)); } } // Returns UUID <=> Name bimap. private static ImmutableBiMap toBiMap( Collection groupReferences) { try { return groupReferences.stream() .collect(toImmutableBiMap(GroupReference::getUUID, GroupReference::getName)); } catch (IllegalArgumentException e) { throw new IllegalArgumentException(UNIQUE_REF_ERROR, e); } } private final AccountGroup.UUID groupUuid; private Optional oldGroupName; private Optional newGroupName; private boolean nameConflicting; private GroupNameNotes( AccountGroup.UUID groupUuid, @Nullable AccountGroup.NameKey oldGroupName, @Nullable AccountGroup.NameKey newGroupName) { this.groupUuid = requireNonNull(groupUuid); if (Objects.equals(oldGroupName, newGroupName)) { this.oldGroupName = Optional.empty(); this.newGroupName = Optional.empty(); } else { this.oldGroupName = Optional.ofNullable(oldGroupName); this.newGroupName = Optional.ofNullable(newGroupName); } } @Override protected String getRefName() { return RefNames.REFS_GROUPNAMES; } @Override protected void onLoad() throws IOException, ConfigInvalidException { nameConflicting = false; logger.atFine().log("Reading group notes"); if (revision != null) { NoteMap noteMap = NoteMap.read(reader, revision); if (newGroupName.isPresent()) { ObjectId newNameId = getNoteKey(newGroupName.get()); nameConflicting = noteMap.contains(newNameId); } ensureOldNameIsPresent(noteMap); } } private void ensureOldNameIsPresent(NoteMap noteMap) throws IOException, ConfigInvalidException { if (oldGroupName.isPresent()) { AccountGroup.NameKey oldName = oldGroupName.get(); ObjectId noteKey = getNoteKey(oldName); ObjectId noteDataBlobId = noteMap.get(noteKey); if (noteDataBlobId == null) { throw new ConfigInvalidException( String.format("Group name '%s' doesn't exist in the list of all names", oldName)); } GroupReference group = getGroupReference(reader, noteDataBlobId); AccountGroup.UUID foundUuid = group.getUUID(); if (!Objects.equals(groupUuid, foundUuid)) { throw new ConfigInvalidException( String.format( "Name '%s' points to UUID '%s' and not to '%s'", oldName, foundUuid, groupUuid)); } } } private void ensureNewNameIsNotUsed() throws DuplicateKeyException { if (newGroupName.isPresent() && nameConflicting) { throw new DuplicateKeyException( String.format("Name '%s' is already used", newGroupName.get().get())); } } @Override protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException { if (!oldGroupName.isPresent() && !newGroupName.isPresent()) { return false; } logger.atFine().log("Updating group notes"); NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision); if (oldGroupName.isPresent()) { removeNote(noteMap, oldGroupName.get(), inserter); } if (newGroupName.isPresent()) { addNote(noteMap, newGroupName.get(), groupUuid, inserter); } commit.setTreeId(noteMap.writeTree(inserter)); commit.setMessage(getCommitMessage()); oldGroupName = Optional.empty(); newGroupName = Optional.empty(); return true; } private static void removeNote( NoteMap noteMap, AccountGroup.NameKey groupName, ObjectInserter inserter) throws IOException { ObjectId noteKey = getNoteKey(groupName); noteMap.set(noteKey, null, inserter); } private static void addNote( NoteMap noteMap, AccountGroup.NameKey groupName, AccountGroup.UUID groupUuid, ObjectInserter inserter) throws IOException { ObjectId noteKey = getNoteKey(groupName); noteMap.set(noteKey, getAsNoteData(groupUuid, groupName), inserter); } // Use the same approach as ExternalId.Key.sha1(). @SuppressWarnings("deprecation") @VisibleForTesting public static ObjectId getNoteKey(AccountGroup.NameKey groupName) { return ObjectId.fromRaw(Hashing.sha1().hashString(groupName.get(), UTF_8).asBytes()); } private static String getAsNoteData(AccountGroup.UUID uuid, AccountGroup.NameKey groupName) { Config config = new Config(); config.setString(SECTION_NAME, null, UUID_PARAM, uuid.get()); config.setString(SECTION_NAME, null, NAME_PARAM, groupName.get()); return config.toText(); } private static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId) throws IOException, ConfigInvalidException { byte[] noteData = reader.open(noteDataBlobId, OBJ_BLOB).getCachedBytes(); return getFromNoteData(noteData); } static GroupReference getFromNoteData(byte[] noteData) throws ConfigInvalidException { Config config = new Config(); config.fromText(new String(noteData, UTF_8)); String uuid = config.getString(SECTION_NAME, null, UUID_PARAM); String name = Strings.nullToEmpty(config.getString(SECTION_NAME, null, NAME_PARAM)); if (uuid == null) { throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name)); } return GroupReference.create(AccountGroup.uuid(uuid), name); } private String getCommitMessage() { if (oldGroupName.isPresent() && newGroupName.isPresent()) { return String.format( "Rename group from '%s' to '%s'", oldGroupName.get(), newGroupName.get()); } if (newGroupName.isPresent()) { return String.format("Create group '%s'", newGroupName.get()); } if (oldGroupName.isPresent()) { return String.format("Delete group '%s'", oldGroupName.get()); } return "No-op"; } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy