| // 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. | |
| * | |
| * <p>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. | |
| * | |
| * <p>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. | |
| * | |
| * <p>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). | |
| * | |
| * <p><em>Internal details</em> | |
| * | |
| * <p>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. | |
| * | |
| * <p>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: | |
| * | |
| * <ul> | |
| * <li>the name of the group (as clear text) | |
| * <li>the UUID of the group which currently has this name | |
| * </ul> | |
| */ | |
| 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. | |
| * | |
| * <p><strong>Note: </strong>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. | |
| * | |
| * <p><strong>Note: </strong>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<GroupReference> 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. | |
| * | |
| * <p>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<GroupReference> 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<GroupReference> 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. | |
| * | |
| * <p>All old entries are discarded and replaced by the new ones. | |
| * | |
| * <p>This operation also works if the previous map has invalid entries or can't be read anymore. | |
| * | |
| * <p><strong>Note: </strong>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<GroupReference> groupReferences, | |
| PersonIdent ident) | |
| throws IOException { | |
| // Not strictly necessary for iteration; throws IAE if it encounters duplicates, which is nice. | |
| ImmutableBiMap<AccountGroup.UUID, String> 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<AccountGroup.UUID, String> 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<AccountGroup.UUID, String> toBiMap( | |
| Collection<GroupReference> 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<AccountGroup.NameKey> oldGroupName; | |
| private Optional<AccountGroup.NameKey> 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"; | |
| } | |
| } |