| // 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.annotations.VisibleForTesting; | |
| 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 { | |
| @VisibleForTesting public static final String GROUP_CONFIG_FILE = "group.config"; | |
| @VisibleForTesting public static final String MEMBERS_FILE = "members"; | |
| @VisibleForTesting 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(); | |
| } | |
| } |