blob: 682fd15f27440ead5539afcc7c538e12e5783138 [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.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Streams;
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.entities.RefNames;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.git.meta.VersionedMetaData;
import com.google.gerrit.server.util.time.TimeUtil;
import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevSort;
/**
* A representation of a group in NoteDb.
*
* <p>Groups in NoteDb can be created by following the descriptions of {@link
* #createForNewGroup(Project.NameKey, Repository, InternalGroupCreation)}. For reading groups from
* NoteDb or updating them, refer to {@link #loadForGroup(Project.NameKey, Repository,
* AccountGroup.UUID)} or {@link #loadForGroupSnapshot(Project.NameKey, Repository,
* AccountGroup.UUID, ObjectId)}.
*
* <p><strong>Note:</strong> Any modification (group creation or update) only becomes permanent (and
* hence written to NoteDb) if {@link #commit(MetaDataUpdate)} is called.
*
* <p><strong>Warning:</strong> This class is a low-level API for groups in NoteDb. Most code which
* deals with internal Gerrit groups should use {@link Groups} or {@link GroupsUpdate} instead.
*
* <h2>Internal details</h2>
*
* <p>Each group is represented by a commit on a branch as defined by {@link
* RefNames#refsGroups(AccountGroup.UUID)}. Previous versions of the group exist as older commits on
* the same branch and can be reached by following along the parent references. New commits for
* updates are only created if a real modification occurs.
*
* <p>The commit messages of all commits on that branch form the audit log for the group. The
* messages mention any important modifications which happened for the group to avoid costly
* computations.
*
* <p>Within each commit, the properties of a group are spread across three files:
*
* <ul>
* <li><em>group.config</em>, which holds all basic properties of a group (further specified by
* {@link GroupConfigEntry}), formatted as a JGit {@link Config} file
* <li><em>members</em>, which lists all members (accounts) of a group, formatted as one numeric
* ID per line
* <li><em>subgroups</em>, which lists all subgroups of a group, formatted as one UUID per line
* </ul>
*
* <p>The files <em>members</em> and <em>subgroups</em> need not exist, which means that the group
* doesn't have any members or subgroups.
*/
public class GroupConfig extends VersionedMetaData {
public static final String GROUP_CONFIG_FILE = "group.config";
public static final String MEMBERS_FILE = "members";
public static final String SUBGROUPS_FILE = "subgroups";
private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
/**
* Creates a {@link GroupConfig} for a new group from the {@link InternalGroupCreation} blueprint.
* Further, optional properties can be specified by setting a {@link GroupDelta} via {@link
* #setGroupDelta(GroupDelta, AuditLogFormatter)} on the returned {@link GroupConfig}.
*
* <p><strong>Note:</strong> The returned {@link GroupConfig} has to be committed via {@link
* #commit(MetaDataUpdate)} in order to create the group for real.
*
* @param projectName the name of the project which holds the NoteDb commits for groups
* @param repository the repository which holds the NoteDb commits for groups
* @param groupCreation an {@link InternalGroupCreation} specifying all properties which are
* required for a new group
* @return a {@link GroupConfig} for a group creation
* @throws IOException if the repository can't be accessed for some reason
* @throws ConfigInvalidException if a group with the same UUID already exists but can't be read
* due to an invalid format
* @throws DuplicateKeyException if a group with the same UUID already exists
*/
public static GroupConfig createForNewGroup(
Project.NameKey projectName, Repository repository, InternalGroupCreation groupCreation)
throws IOException, ConfigInvalidException, DuplicateKeyException {
GroupConfig groupConfig = new GroupConfig(groupCreation.getGroupUUID());
groupConfig.load(projectName, repository);
groupConfig.setGroupCreation(groupCreation);
return groupConfig;
}
/**
* Creates a {@link GroupConfig} for an existing group.
*
* <p>The group is automatically loaded within this method and can be accessed via {@link
* #getLoadedGroup()}.
*
* <p>It's safe to call this method for non-existing groups. In that case, {@link
* #getLoadedGroup()} won't return any group. Thus, the existence of a group can be easily tested.
*
* <p>The group represented by the returned {@link GroupConfig} can be updated by setting an
* {@link GroupDelta} via {@link #setGroupDelta(GroupDelta, AuditLogFormatter)} and committing the
* {@link GroupConfig} via {@link #commit(MetaDataUpdate)}.
*
* @param projectName the name of the project which holds the NoteDb commits for groups
* @param repository the repository which holds the NoteDb commits for groups
* @param groupUuid the UUID of the group
* @return a {@link GroupConfig} for the group with the specified UUID
* @throws IOException if the repository can't be accessed for some reason
* @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
*/
public static GroupConfig loadForGroup(
Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid)
throws IOException, ConfigInvalidException {
return loadForGroup(projectName, repository, groupUuid, null);
}
/**
* Load the group for a specific revision.
*
* @see GroupConfig#loadForGroup(Project.NameKey, Repository, AccountGroup.UUID)
*/
public static GroupConfig loadForGroup(
Project.NameKey projectName,
Repository repository,
AccountGroup.UUID groupUuid,
@Nullable ObjectId groupRefObjectId)
throws IOException, ConfigInvalidException {
GroupConfig groupConfig = new GroupConfig(groupUuid);
if (groupRefObjectId == null) {
groupConfig.load(projectName, repository);
} else {
groupConfig.load(projectName, repository, groupRefObjectId);
}
return groupConfig;
}
/**
* Creates a {@link GroupConfig} for an existing group at a specific revision of the repository.
*
* <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
* AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
* Repository, AccountGroup.UUID)} loads the group from the current state of the repository
* whereas this method loads the group at a specific (maybe past) revision.
*
* @param projectName the name of the project which holds the NoteDb commits for groups
* @param repository the repository which holds the NoteDb commits for groups
* @param groupUuid the UUID of the group
* @param commitId the revision of the repository at which the group should be loaded
* @return a {@link GroupConfig} for the group with the specified UUID
* @throws IOException if the repository can't be accessed for some reason
* @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
*/
public static GroupConfig loadForGroupSnapshot(
Project.NameKey projectName,
Repository repository,
AccountGroup.UUID groupUuid,
ObjectId commitId)
throws IOException, ConfigInvalidException {
GroupConfig groupConfig = new GroupConfig(groupUuid);
groupConfig.load(projectName, repository, commitId);
return groupConfig;
}
private final AccountGroup.UUID groupUuid;
private final String ref;
private Optional<InternalGroup> loadedGroup = Optional.empty();
private Optional<InternalGroupCreation> groupCreation = Optional.empty();
private Optional<GroupDelta> groupDelta = Optional.empty();
private AuditLogFormatter auditLogFormatter = AuditLogFormatter.createPartiallyWorkingFallBack();
private boolean isLoaded = false;
private boolean allowSaveEmptyName;
private GroupConfig(AccountGroup.UUID groupUuid) {
this.groupUuid = requireNonNull(groupUuid);
ref = RefNames.refsGroups(groupUuid);
}
/**
* Returns the group loaded from NoteDb.
*
* <p>If not any NoteDb commits exist for the group represented by this {@link GroupConfig}, no
* group is returned.
*
* <p>After {@link #commit(MetaDataUpdate)} was called on this {@link GroupConfig}, this method
* returns a group which is in line with the latest NoteDb commit for this group. So, after
* creating a {@link GroupConfig} for a new group and committing it, this method can be used to
* retrieve a representation of the created group. The same holds for the representation of an
* updated group.
*
* @return the loaded group, or an empty {@link Optional} if the group doesn't exist
*/
public Optional<InternalGroup> getLoadedGroup() {
checkLoaded();
return loadedGroup;
}
/**
* Specifies how the current group should be updated.
*
* <p>If the group is newly created, the {@link GroupDelta} can be used to specify optional
* properties.
*
* <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
* instructions for the update. To apply the update for real and write the result back to NoteDb,
* call {@link #commit(MetaDataUpdate)} on this {@link GroupConfig}.
*
* @param groupDelta a {@link GroupDelta} with the modifications to be applied
* @param auditLogFormatter an {@link AuditLogFormatter} for formatting the commit message in a
* parsable way
*/
public void setGroupDelta(GroupDelta groupDelta, AuditLogFormatter auditLogFormatter) {
this.groupDelta = Optional.of(groupDelta);
this.auditLogFormatter = auditLogFormatter;
}
/**
* Allows the new name of a group to be empty during creation or update.
*
* <p><strong>Note:</strong> This method exists only to support the migration of legacy groups
* which don't always necessarily have a name. Nowadays, we enforce that groups always have names.
* When we remove the migration code, we can probably remove this method as well.
*/
public void setAllowSaveEmptyName() {
this.allowSaveEmptyName = true;
}
private void setGroupCreation(InternalGroupCreation groupCreation) throws DuplicateKeyException {
checkLoaded();
if (loadedGroup.isPresent()) {
throw new DuplicateKeyException(String.format("Group %s already exists", groupUuid.get()));
}
this.groupCreation = Optional.of(groupCreation);
}
@Override
public String getRefName() {
return ref;
}
@Override
protected void onLoad() throws IOException, ConfigInvalidException {
if (revision != null) {
rw.reset();
rw.markStart(revision);
rw.sort(RevSort.REVERSE);
RevCommit earliestCommit = rw.next();
Instant createdOn = Instant.ofEpochSecond(earliestCommit.getCommitTime());
Config config = readConfig(GROUP_CONFIG_FILE);
ImmutableSet<Account.Id> members = readMembers();
ImmutableSet<AccountGroup.UUID> subgroups = readSubgroups();
loadedGroup =
Optional.of(
createFrom(groupUuid, config, members, subgroups, createdOn, revision.toObjectId()));
}
isLoaded = true;
}
@Override
public RevCommit commit(MetaDataUpdate update) throws IOException {
RevCommit c = super.commit(update);
loadedGroup = Optional.of(loadedGroup.get().toBuilder().setRefState(c.toObjectId()).build());
return c;
}
@Override
protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
checkLoaded();
if (!groupCreation.isPresent() && !groupDelta.isPresent()) {
// Group was neither created nor changed. -> A new commit isn't necessary.
return false;
}
if (!allowSaveEmptyName && getNewName().equals(Optional.of(""))) {
throw new ConfigInvalidException(
String.format("Name of the group %s must be defined", groupUuid.get()));
}
// Commit timestamps are internally truncated to seconds. To return the correct 'createdOn' time
// for new groups, we explicitly need to truncate the timestamp here.
Instant commitTimestamp =
TimeUtil.truncateToSecond(
groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
InternalGroup updatedGroup = updateGroup(commitTimestamp);
String commitMessage = createCommitMessage(loadedGroup, updatedGroup);
commit.setMessage(commitMessage);
loadedGroup = Optional.of(updatedGroup);
groupCreation = Optional.empty();
groupDelta = Optional.empty();
return true;
}
private void checkLoaded() {
checkState(isLoaded, "Group %s not loaded yet", groupUuid.get());
}
private Optional<String> getNewName() {
if (groupDelta.isPresent()) {
return groupDelta.get().getName().map(n -> Strings.nullToEmpty(n.get()));
}
if (groupCreation.isPresent()) {
return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get()));
}
return Optional.empty();
}
private InternalGroup updateGroup(Instant commitTimestamp)
throws IOException, ConfigInvalidException {
Config config = updateGroupProperties();
ImmutableSet<Account.Id> originalMembers =
loadedGroup.map(InternalGroup::getMembers).orElseGet(ImmutableSet::of);
Optional<ImmutableSet<Account.Id>> updatedMembers = updateMembers(originalMembers);
ImmutableSet<AccountGroup.UUID> originalSubgroups =
loadedGroup.map(InternalGroup::getSubgroups).orElseGet(ImmutableSet::of);
Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups = updateSubgroups(originalSubgroups);
Instant createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
return createFrom(
groupUuid,
config,
updatedMembers.orElse(originalMembers),
updatedSubgroups.orElse(originalSubgroups),
createdOn,
null);
}
private Config updateGroupProperties() throws IOException, ConfigInvalidException {
Config config = readConfig(GROUP_CONFIG_FILE);
groupCreation.ifPresent(
internalGroupCreation ->
Arrays.stream(GroupConfigEntry.values())
.forEach(configEntry -> configEntry.initNewConfig(config, internalGroupCreation)));
groupDelta.ifPresent(
delta ->
Arrays.stream(GroupConfigEntry.values())
.forEach(configEntry -> configEntry.updateConfigValue(config, delta)));
saveConfig(GROUP_CONFIG_FILE, config);
return config;
}
private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers)
throws IOException {
Optional<ImmutableSet<Account.Id>> updatedMembers =
groupDelta
.map(GroupDelta::getMemberModification)
.map(memberModification -> memberModification.apply(originalMembers))
.map(ImmutableSet::copyOf)
.filter(members -> !originalMembers.equals(members));
if (updatedMembers.isPresent()) {
saveMembers(updatedMembers.get());
}
return updatedMembers;
}
private Optional<ImmutableSet<AccountGroup.UUID>> updateSubgroups(
ImmutableSet<AccountGroup.UUID> originalSubgroups) throws IOException {
Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups =
groupDelta
.map(GroupDelta::getSubgroupModification)
.map(subgroupModification -> subgroupModification.apply(originalSubgroups))
.map(ImmutableSet::copyOf)
.filter(subgroups -> !originalSubgroups.equals(subgroups));
if (updatedSubgroups.isPresent()) {
saveSubgroups(updatedSubgroups.get());
}
return updatedSubgroups;
}
private void saveMembers(ImmutableSet<Account.Id> members) throws IOException {
saveToFile(MEMBERS_FILE, members, member -> String.valueOf(member.get()));
}
private void saveSubgroups(ImmutableSet<AccountGroup.UUID> subgroups) throws IOException {
saveToFile(SUBGROUPS_FILE, subgroups, AccountGroup.UUID::get);
}
private <E> void saveToFile(
String filePath, ImmutableSet<E> elements, Function<E, String> toStringFunction)
throws IOException {
String fileContent = elements.stream().map(toStringFunction).collect(joining("\n"));
saveUTF8(filePath, fileContent);
}
private ImmutableSet<Account.Id> readMembers() throws IOException, ConfigInvalidException {
return readFromFile(MEMBERS_FILE, entry -> Account.id(Integer.parseInt(entry)));
}
private ImmutableSet<AccountGroup.UUID> readSubgroups()
throws IOException, ConfigInvalidException {
return readFromFile(SUBGROUPS_FILE, AccountGroup::uuid);
}
private <E> ImmutableSet<E> readFromFile(String filePath, Function<String, E> fromStringFunction)
throws IOException, ConfigInvalidException {
String fileContent = readUTF8(filePath);
try {
Iterable<String> lines =
Splitter.on(LINE_SEPARATOR_PATTERN).trimResults().omitEmptyStrings().split(fileContent);
return Streams.stream(lines).map(fromStringFunction).collect(toImmutableSet());
} catch (NumberFormatException e) {
throw new ConfigInvalidException(
String.format("Invalid file %s for commit %s", filePath, revision.name()), e);
}
}
private static InternalGroup createFrom(
AccountGroup.UUID groupUuid,
Config config,
ImmutableSet<Account.Id> members,
ImmutableSet<AccountGroup.UUID> subgroups,
Instant createdOn,
ObjectId refState)
throws ConfigInvalidException {
InternalGroup.Builder group = InternalGroup.builder();
group.setGroupUUID(groupUuid);
for (GroupConfigEntry configEntry : GroupConfigEntry.values()) {
configEntry.readFromConfig(groupUuid, group, config);
}
group.setMembers(members);
group.setSubgroups(subgroups);
group.setCreatedOn(createdOn);
group.setRefState(refState);
return group.build();
}
private String createCommitMessage(
Optional<InternalGroup> originalGroup, InternalGroup updatedGroup) {
GroupConfigCommitMessage commitMessage =
new GroupConfigCommitMessage(auditLogFormatter, updatedGroup);
originalGroup.ifPresent(commitMessage::setOriginalGroup);
return commitMessage.create();
}
}