| // 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.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; |
| 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.Sets; |
| import com.google.common.collect.Streams; |
| import com.google.gerrit.common.TimeUtil; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.AccountGroup; |
| import com.google.gerrit.reviewdb.client.RefNames; |
| import com.google.gerrit.server.git.MetaDataUpdate; |
| import com.google.gerrit.server.git.VersionedMetaData; |
| import com.google.gerrit.server.group.InternalGroup; |
| import com.google.gwtorm.server.OrmDuplicateKeyException; |
| import java.io.IOException; |
| import java.sql.Timestamp; |
| import java.util.Arrays; |
| import java.util.Optional; |
| import java.util.StringJoiner; |
| import java.util.function.Function; |
| import java.util.regex.Pattern; |
| import java.util.stream.Stream; |
| 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.FooterKey; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevSort; |
| |
| /** |
| * Holds code for reading and writing internal group data for a single group to/from NoteDB. |
| * |
| * <p>The configuration is spread across three files: 'group.config', which holds global properties, |
| * 'members', which has one numberic account ID per line, and 'subgroups', which has one group UUID |
| * per line. The code that does the work of parsing 'group.config' is in {@link GroupConfigEntry}. |
| * |
| * <p>TODO(aliceks): expand docs. |
| */ |
| public class GroupConfig extends VersionedMetaData { |
| public static final String GROUP_CONFIG_FILE = "group.config"; |
| |
| static final FooterKey FOOTER_ADD_MEMBER = new FooterKey("Add"); |
| static final FooterKey FOOTER_REMOVE_MEMBER = new FooterKey("Remove"); |
| static final FooterKey FOOTER_ADD_GROUP = new FooterKey("Add-group"); |
| static final FooterKey FOOTER_REMOVE_GROUP = new FooterKey("Remove-group"); |
| |
| private static final String MEMBERS_FILE = "members"; |
| private static final String SUBGROUPS_FILE = "subgroups"; |
| private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R"); |
| |
| private final AccountGroup.UUID groupUuid; |
| private final String ref; |
| |
| private Optional<InternalGroup> loadedGroup = Optional.empty(); |
| private Optional<InternalGroupCreation> groupCreation = Optional.empty(); |
| private Optional<InternalGroupUpdate> groupUpdate = Optional.empty(); |
| private Function<Account.Id, String> accountNameEmailRetriever = Account.Id::toString; |
| private Function<AccountGroup.UUID, String> groupNameRetriever = AccountGroup.UUID::get; |
| private boolean isLoaded = false; |
| private boolean allowSaveEmptyName; |
| |
| private GroupConfig(AccountGroup.UUID groupUuid) { |
| this.groupUuid = checkNotNull(groupUuid); |
| ref = RefNames.refsGroups(groupUuid); |
| } |
| |
| public static GroupConfig createForNewGroup( |
| Repository repository, InternalGroupCreation groupCreation) |
| throws IOException, ConfigInvalidException, OrmDuplicateKeyException { |
| GroupConfig groupConfig = new GroupConfig(groupCreation.getGroupUUID()); |
| groupConfig.load(repository); |
| groupConfig.setGroupCreation(groupCreation); |
| return groupConfig; |
| } |
| |
| public static GroupConfig loadForGroup(Repository repository, AccountGroup.UUID groupUuid) |
| throws IOException, ConfigInvalidException { |
| GroupConfig groupConfig = new GroupConfig(groupUuid); |
| groupConfig.load(repository); |
| return groupConfig; |
| } |
| |
| /** Loads a group at a specific revision. */ |
| public static GroupConfig loadForGroupSnapshot( |
| Repository repository, AccountGroup.UUID groupUuid, ObjectId commitId) |
| throws IOException, ConfigInvalidException { |
| GroupConfig groupConfig = new GroupConfig(groupUuid); |
| groupConfig.load(repository, commitId); |
| return groupConfig; |
| } |
| |
| public Optional<InternalGroup> getLoadedGroup() { |
| checkLoaded(); |
| return loadedGroup; |
| } |
| |
| void setGroupCreation(InternalGroupCreation groupCreation) throws OrmDuplicateKeyException { |
| checkLoaded(); |
| if (loadedGroup.isPresent()) { |
| throw new OrmDuplicateKeyException(String.format("Group %s already exists", groupUuid.get())); |
| } |
| |
| this.groupCreation = Optional.of(groupCreation); |
| } |
| |
| void setAllowSaveEmptyName() { |
| this.allowSaveEmptyName = true; |
| } |
| |
| public void setGroupUpdate( |
| InternalGroupUpdate groupUpdate, |
| Function<Account.Id, String> accountNameEmailRetriever, |
| Function<AccountGroup.UUID, String> groupNameRetriever) { |
| this.groupUpdate = Optional.of(groupUpdate); |
| this.accountNameEmailRetriever = accountNameEmailRetriever; |
| this.groupNameRetriever = groupNameRetriever; |
| } |
| |
| @Override |
| protected 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(); |
| Timestamp createdOn = new Timestamp(earliestCommit.getCommitTime() * 1000L); |
| |
| 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; |
| } |
| |
| private static InternalGroup createFrom( |
| AccountGroup.UUID groupUuid, |
| Config config, |
| ImmutableSet<Account.Id> members, |
| ImmutableSet<AccountGroup.UUID> subgroups, |
| Timestamp 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(); |
| } |
| |
| @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() && !groupUpdate.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())); |
| } |
| |
| Timestamp commitTimestamp = |
| groupUpdate.flatMap(InternalGroupUpdate::getUpdatedOn).orElseGet(TimeUtil::nowTs); |
| commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp)); |
| commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp)); |
| |
| 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); |
| |
| Timestamp createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp); |
| |
| String commitMessage = |
| createCommitMessage(originalMembers, updatedMembers, originalSubgroups, updatedSubgroups); |
| commit.setMessage(commitMessage); |
| |
| loadedGroup = |
| Optional.of( |
| createFrom( |
| groupUuid, |
| config, |
| updatedMembers.orElse(originalMembers), |
| updatedSubgroups.orElse(originalSubgroups), |
| createdOn, |
| null)); |
| groupCreation = Optional.empty(); |
| |
| return true; |
| } |
| |
| private void checkLoaded() { |
| checkState(isLoaded, "Group %s not loaded yet", groupUuid.get()); |
| } |
| |
| private Optional<String> getNewName() { |
| if (groupUpdate.isPresent()) { |
| return groupUpdate.get().getName().map(n -> Strings.nullToEmpty(n.get())); |
| } |
| if (groupCreation.isPresent()) { |
| return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get())); |
| } |
| return Optional.empty(); |
| } |
| |
| 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))); |
| groupUpdate.ifPresent( |
| internalGroupUpdate -> |
| Arrays.stream(GroupConfigEntry.values()) |
| .forEach( |
| configEntry -> configEntry.updateConfigValue(config, internalGroupUpdate))); |
| saveConfig(GROUP_CONFIG_FILE, config); |
| return config; |
| } |
| |
| private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers) |
| throws IOException { |
| Optional<ImmutableSet<Account.Id>> updatedMembers = |
| groupUpdate |
| .map(InternalGroupUpdate::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 = |
| groupUpdate |
| .map(InternalGroupUpdate::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 -> new Account.Id(Integer.parseInt(entry))); |
| } |
| |
| private ImmutableSet<AccountGroup.UUID> readSubgroups() |
| throws IOException, ConfigInvalidException { |
| return readFromFile(SUBGROUPS_FILE, AccountGroup.UUID::new); |
| } |
| |
| 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 String createCommitMessage( |
| ImmutableSet<Account.Id> originalMembers, |
| Optional<ImmutableSet<Account.Id>> updatedMembers, |
| ImmutableSet<AccountGroup.UUID> originalSubgroups, |
| Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups) { |
| String summaryLine = groupCreation.isPresent() ? "Create group" : "Update group"; |
| |
| StringJoiner footerJoiner = new StringJoiner("\n", "\n\n", ""); |
| footerJoiner.setEmptyValue(""); |
| getCommitFooterForRename().ifPresent(footerJoiner::add); |
| updatedMembers.ifPresent( |
| newMembers -> |
| getCommitFootersForMemberModifications(originalMembers, newMembers) |
| .forEach(footerJoiner::add)); |
| updatedSubgroups.ifPresent( |
| newSubgroups -> |
| getCommitFootersForSubgroupModifications(originalSubgroups, newSubgroups) |
| .forEach(footerJoiner::add)); |
| String footer = footerJoiner.toString(); |
| |
| return summaryLine + footer; |
| } |
| |
| private Optional<String> getCommitFooterForRename() { |
| if (!loadedGroup.isPresent() |
| || !groupUpdate.isPresent() |
| || !groupUpdate.get().getName().isPresent()) { |
| return Optional.empty(); |
| } |
| |
| String originalName = loadedGroup.get().getName(); |
| String newName = groupUpdate.get().getName().get().get(); |
| if (originalName.equals(newName)) { |
| return Optional.empty(); |
| } |
| return Optional.of("Rename from " + originalName + " to " + newName); |
| } |
| |
| private Stream<String> getCommitFootersForMemberModifications( |
| ImmutableSet<Account.Id> oldMembers, ImmutableSet<Account.Id> newMembers) { |
| Stream<String> removedMembers = |
| Sets.difference(oldMembers, newMembers) |
| .stream() |
| .map(accountNameEmailRetriever) |
| .map((FOOTER_REMOVE_MEMBER.getName() + ": ")::concat); |
| Stream<String> addedMembers = |
| Sets.difference(newMembers, oldMembers) |
| .stream() |
| .map(accountNameEmailRetriever) |
| .map((FOOTER_ADD_MEMBER.getName() + ": ")::concat); |
| return Stream.concat(removedMembers, addedMembers); |
| } |
| |
| private Stream<String> getCommitFootersForSubgroupModifications( |
| ImmutableSet<AccountGroup.UUID> oldSubgroups, ImmutableSet<AccountGroup.UUID> newSubgroups) { |
| Stream<String> removedMembers = |
| Sets.difference(oldSubgroups, newSubgroups) |
| .stream() |
| .map(groupNameRetriever) |
| .map((FOOTER_REMOVE_GROUP.getName() + ": ")::concat); |
| Stream<String> addedMembers = |
| Sets.difference(newSubgroups, oldSubgroups) |
| .stream() |
| .map(groupNameRetriever) |
| .map((FOOTER_ADD_GROUP.getName() + ": ")::concat); |
| return Stream.concat(removedMembers, addedMembers); |
| } |
| } |