blob: 7c4fb16f959d0d1b3beb44d775b87e4dfcac036b [file] [log] [blame]
// 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";
}
}