Line data Source code
1 : // Copyright (C) 2017 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.group.db;
16 :
17 : import static com.google.common.base.Preconditions.checkState;
18 : import static com.google.common.collect.ImmutableSet.toImmutableSet;
19 : import static java.util.Objects.requireNonNull;
20 : import static java.util.stream.Collectors.joining;
21 :
22 : import com.google.common.annotations.VisibleForTesting;
23 : import com.google.common.base.Splitter;
24 : import com.google.common.base.Strings;
25 : import com.google.common.collect.ImmutableSet;
26 : import com.google.common.collect.Streams;
27 : import com.google.gerrit.common.Nullable;
28 : import com.google.gerrit.entities.Account;
29 : import com.google.gerrit.entities.AccountGroup;
30 : import com.google.gerrit.entities.InternalGroup;
31 : import com.google.gerrit.entities.Project;
32 : import com.google.gerrit.entities.RefNames;
33 : import com.google.gerrit.exceptions.DuplicateKeyException;
34 : import com.google.gerrit.server.git.meta.MetaDataUpdate;
35 : import com.google.gerrit.server.git.meta.VersionedMetaData;
36 : import com.google.gerrit.server.util.time.TimeUtil;
37 : import java.io.IOException;
38 : import java.time.Instant;
39 : import java.util.Arrays;
40 : import java.util.Optional;
41 : import java.util.function.Function;
42 : import java.util.regex.Pattern;
43 : import org.eclipse.jgit.errors.ConfigInvalidException;
44 : import org.eclipse.jgit.lib.CommitBuilder;
45 : import org.eclipse.jgit.lib.Config;
46 : import org.eclipse.jgit.lib.ObjectId;
47 : import org.eclipse.jgit.lib.PersonIdent;
48 : import org.eclipse.jgit.lib.Repository;
49 : import org.eclipse.jgit.revwalk.RevCommit;
50 : import org.eclipse.jgit.revwalk.RevSort;
51 :
52 : /**
53 : * A representation of a group in NoteDb.
54 : *
55 : * <p>Groups in NoteDb can be created by following the descriptions of {@link
56 : * #createForNewGroup(Project.NameKey, Repository, InternalGroupCreation)}. For reading groups from
57 : * NoteDb or updating them, refer to {@link #loadForGroup(Project.NameKey, Repository,
58 : * AccountGroup.UUID)} or {@link #loadForGroupSnapshot(Project.NameKey, Repository,
59 : * AccountGroup.UUID, ObjectId)}.
60 : *
61 : * <p><strong>Note:</strong> Any modification (group creation or update) only becomes permanent (and
62 : * hence written to NoteDb) if {@link #commit(MetaDataUpdate)} is called.
63 : *
64 : * <p><strong>Warning:</strong> This class is a low-level API for groups in NoteDb. Most code which
65 : * deals with internal Gerrit groups should use {@link Groups} or {@link GroupsUpdate} instead.
66 : *
67 : * <h2>Internal details</h2>
68 : *
69 : * <p>Each group is represented by a commit on a branch as defined by {@link
70 : * RefNames#refsGroups(AccountGroup.UUID)}. Previous versions of the group exist as older commits on
71 : * the same branch and can be reached by following along the parent references. New commits for
72 : * updates are only created if a real modification occurs.
73 : *
74 : * <p>The commit messages of all commits on that branch form the audit log for the group. The
75 : * messages mention any important modifications which happened for the group to avoid costly
76 : * computations.
77 : *
78 : * <p>Within each commit, the properties of a group are spread across three files:
79 : *
80 : * <ul>
81 : * <li><em>group.config</em>, which holds all basic properties of a group (further specified by
82 : * {@link GroupConfigEntry}), formatted as a JGit {@link Config} file
83 : * <li><em>members</em>, which lists all members (accounts) of a group, formatted as one numeric
84 : * ID per line
85 : * <li><em>subgroups</em>, which lists all subgroups of a group, formatted as one UUID per line
86 : * </ul>
87 : *
88 : * <p>The files <em>members</em> and <em>subgroups</em> need not exist, which means that the group
89 : * doesn't have any members or subgroups.
90 : */
91 : public class GroupConfig extends VersionedMetaData {
92 : @VisibleForTesting public static final String GROUP_CONFIG_FILE = "group.config";
93 : @VisibleForTesting static final String MEMBERS_FILE = "members";
94 : @VisibleForTesting static final String SUBGROUPS_FILE = "subgroups";
95 152 : private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
96 :
97 : /**
98 : * Creates a {@link GroupConfig} for a new group from the {@link InternalGroupCreation} blueprint.
99 : * Further, optional properties can be specified by setting a {@link GroupDelta} via {@link
100 : * #setGroupDelta(GroupDelta, AuditLogFormatter)} on the returned {@link GroupConfig}.
101 : *
102 : * <p><strong>Note:</strong> The returned {@link GroupConfig} has to be committed via {@link
103 : * #commit(MetaDataUpdate)} in order to create the group for real.
104 : *
105 : * @param projectName the name of the project which holds the NoteDb commits for groups
106 : * @param repository the repository which holds the NoteDb commits for groups
107 : * @param groupCreation an {@link InternalGroupCreation} specifying all properties which are
108 : * required for a new group
109 : * @return a {@link GroupConfig} for a group creation
110 : * @throws IOException if the repository can't be accessed for some reason
111 : * @throws ConfigInvalidException if a group with the same UUID already exists but can't be read
112 : * due to an invalid format
113 : * @throws DuplicateKeyException if a group with the same UUID already exists
114 : */
115 : public static GroupConfig createForNewGroup(
116 : Project.NameKey projectName, Repository repository, InternalGroupCreation groupCreation)
117 : throws IOException, ConfigInvalidException, DuplicateKeyException {
118 152 : GroupConfig groupConfig = new GroupConfig(groupCreation.getGroupUUID());
119 152 : groupConfig.load(projectName, repository);
120 152 : groupConfig.setGroupCreation(groupCreation);
121 152 : return groupConfig;
122 : }
123 :
124 : /**
125 : * Creates a {@link GroupConfig} for an existing group.
126 : *
127 : * <p>The group is automatically loaded within this method and can be accessed via {@link
128 : * #getLoadedGroup()}.
129 : *
130 : * <p>It's safe to call this method for non-existing groups. In that case, {@link
131 : * #getLoadedGroup()} won't return any group. Thus, the existence of a group can be easily tested.
132 : *
133 : * <p>The group represented by the returned {@link GroupConfig} can be updated by setting an
134 : * {@link GroupDelta} via {@link #setGroupDelta(GroupDelta, AuditLogFormatter)} and committing the
135 : * {@link GroupConfig} via {@link #commit(MetaDataUpdate)}.
136 : *
137 : * @param projectName the name of the project which holds the NoteDb commits for groups
138 : * @param repository the repository which holds the NoteDb commits for groups
139 : * @param groupUuid the UUID of the group
140 : * @return a {@link GroupConfig} for the group with the specified UUID
141 : * @throws IOException if the repository can't be accessed for some reason
142 : * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
143 : */
144 : public static GroupConfig loadForGroup(
145 : Project.NameKey projectName, Repository repository, AccountGroup.UUID groupUuid)
146 : throws IOException, ConfigInvalidException {
147 152 : return loadForGroup(projectName, repository, groupUuid, null);
148 : }
149 :
150 : /**
151 : * Load the group for a specific revision.
152 : *
153 : * @see GroupConfig#loadForGroup(Project.NameKey, Repository, AccountGroup.UUID)
154 : */
155 : public static GroupConfig loadForGroup(
156 : Project.NameKey projectName,
157 : Repository repository,
158 : AccountGroup.UUID groupUuid,
159 : @Nullable ObjectId groupRefObjectId)
160 : throws IOException, ConfigInvalidException {
161 152 : GroupConfig groupConfig = new GroupConfig(groupUuid);
162 152 : if (groupRefObjectId == null) {
163 152 : groupConfig.load(projectName, repository);
164 : } else {
165 151 : groupConfig.load(projectName, repository, groupRefObjectId);
166 : }
167 152 : return groupConfig;
168 : }
169 :
170 : /**
171 : * Creates a {@link GroupConfig} for an existing group at a specific revision of the repository.
172 : *
173 : * <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
174 : * AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
175 : * Repository, AccountGroup.UUID)} loads the group from the current state of the repository
176 : * whereas this method loads the group at a specific (maybe past) revision.
177 : *
178 : * @param projectName the name of the project which holds the NoteDb commits for groups
179 : * @param repository the repository which holds the NoteDb commits for groups
180 : * @param groupUuid the UUID of the group
181 : * @param commitId the revision of the repository at which the group should be loaded
182 : * @return a {@link GroupConfig} for the group with the specified UUID
183 : * @throws IOException if the repository can't be accessed for some reason
184 : * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
185 : */
186 : public static GroupConfig loadForGroupSnapshot(
187 : Project.NameKey projectName,
188 : Repository repository,
189 : AccountGroup.UUID groupUuid,
190 : ObjectId commitId)
191 : throws IOException, ConfigInvalidException {
192 2 : GroupConfig groupConfig = new GroupConfig(groupUuid);
193 2 : groupConfig.load(projectName, repository, commitId);
194 2 : return groupConfig;
195 : }
196 :
197 : private final AccountGroup.UUID groupUuid;
198 : private final String ref;
199 :
200 152 : private Optional<InternalGroup> loadedGroup = Optional.empty();
201 152 : private Optional<InternalGroupCreation> groupCreation = Optional.empty();
202 152 : private Optional<GroupDelta> groupDelta = Optional.empty();
203 152 : private AuditLogFormatter auditLogFormatter = AuditLogFormatter.createPartiallyWorkingFallBack();
204 152 : private boolean isLoaded = false;
205 : private boolean allowSaveEmptyName;
206 :
207 152 : private GroupConfig(AccountGroup.UUID groupUuid) {
208 152 : this.groupUuid = requireNonNull(groupUuid);
209 152 : ref = RefNames.refsGroups(groupUuid);
210 152 : }
211 :
212 : /**
213 : * Returns the group loaded from NoteDb.
214 : *
215 : * <p>If not any NoteDb commits exist for the group represented by this {@link GroupConfig}, no
216 : * group is returned.
217 : *
218 : * <p>After {@link #commit(MetaDataUpdate)} was called on this {@link GroupConfig}, this method
219 : * returns a group which is in line with the latest NoteDb commit for this group. So, after
220 : * creating a {@link GroupConfig} for a new group and committing it, this method can be used to
221 : * retrieve a representation of the created group. The same holds for the representation of an
222 : * updated group.
223 : *
224 : * @return the loaded group, or an empty {@link Optional} if the group doesn't exist
225 : */
226 : public Optional<InternalGroup> getLoadedGroup() {
227 152 : checkLoaded();
228 152 : return loadedGroup;
229 : }
230 :
231 : /**
232 : * Specifies how the current group should be updated.
233 : *
234 : * <p>If the group is newly created, the {@link GroupDelta} can be used to specify optional
235 : * properties.
236 : *
237 : * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
238 : * instructions for the update. To apply the update for real and write the result back to NoteDb,
239 : * call {@link #commit(MetaDataUpdate)} on this {@link GroupConfig}.
240 : *
241 : * @param groupDelta a {@link GroupDelta} with the modifications to be applied
242 : * @param auditLogFormatter an {@link AuditLogFormatter} for formatting the commit message in a
243 : * parsable way
244 : */
245 : public void setGroupDelta(GroupDelta groupDelta, AuditLogFormatter auditLogFormatter) {
246 152 : this.groupDelta = Optional.of(groupDelta);
247 152 : this.auditLogFormatter = auditLogFormatter;
248 152 : }
249 :
250 : /**
251 : * Allows the new name of a group to be empty during creation or update.
252 : *
253 : * <p><strong>Note:</strong> This method exists only to support the migration of legacy groups
254 : * which don't always necessarily have a name. Nowadays, we enforce that groups always have names.
255 : * When we remove the migration code, we can probably remove this method as well.
256 : */
257 : public void setAllowSaveEmptyName() {
258 1 : this.allowSaveEmptyName = true;
259 1 : }
260 :
261 : private void setGroupCreation(InternalGroupCreation groupCreation) throws DuplicateKeyException {
262 152 : checkLoaded();
263 152 : if (loadedGroup.isPresent()) {
264 0 : throw new DuplicateKeyException(String.format("Group %s already exists", groupUuid.get()));
265 : }
266 :
267 152 : this.groupCreation = Optional.of(groupCreation);
268 152 : }
269 :
270 : @Override
271 : public String getRefName() {
272 152 : return ref;
273 : }
274 :
275 : @Override
276 : protected void onLoad() throws IOException, ConfigInvalidException {
277 152 : if (revision != null) {
278 152 : rw.reset();
279 152 : rw.markStart(revision);
280 152 : rw.sort(RevSort.REVERSE);
281 152 : RevCommit earliestCommit = rw.next();
282 152 : Instant createdOn = Instant.ofEpochSecond(earliestCommit.getCommitTime());
283 :
284 152 : Config config = readConfig(GROUP_CONFIG_FILE);
285 152 : ImmutableSet<Account.Id> members = readMembers();
286 152 : ImmutableSet<AccountGroup.UUID> subgroups = readSubgroups();
287 152 : loadedGroup =
288 152 : Optional.of(
289 152 : createFrom(groupUuid, config, members, subgroups, createdOn, revision.toObjectId()));
290 : }
291 :
292 152 : isLoaded = true;
293 152 : }
294 :
295 : @Override
296 : public RevCommit commit(MetaDataUpdate update) throws IOException {
297 152 : RevCommit c = super.commit(update);
298 152 : loadedGroup = Optional.of(loadedGroup.get().toBuilder().setRefState(c.toObjectId()).build());
299 152 : return c;
300 : }
301 :
302 : @Override
303 : protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
304 152 : checkLoaded();
305 152 : if (!groupCreation.isPresent() && !groupDelta.isPresent()) {
306 : // Group was neither created nor changed. -> A new commit isn't necessary.
307 1 : return false;
308 : }
309 :
310 152 : if (!allowSaveEmptyName && getNewName().equals(Optional.of(""))) {
311 1 : throw new ConfigInvalidException(
312 1 : String.format("Name of the group %s must be defined", groupUuid.get()));
313 : }
314 :
315 : // Commit timestamps are internally truncated to seconds. To return the correct 'createdOn' time
316 : // for new groups, we explicitly need to truncate the timestamp here.
317 152 : Instant commitTimestamp =
318 152 : TimeUtil.truncateToSecond(
319 152 : groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
320 152 : commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
321 152 : commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
322 :
323 152 : InternalGroup updatedGroup = updateGroup(commitTimestamp);
324 :
325 152 : String commitMessage = createCommitMessage(loadedGroup, updatedGroup);
326 152 : commit.setMessage(commitMessage);
327 :
328 152 : loadedGroup = Optional.of(updatedGroup);
329 152 : groupCreation = Optional.empty();
330 152 : groupDelta = Optional.empty();
331 :
332 152 : return true;
333 : }
334 :
335 : private void checkLoaded() {
336 152 : checkState(isLoaded, "Group %s not loaded yet", groupUuid.get());
337 152 : }
338 :
339 : private Optional<String> getNewName() {
340 152 : if (groupDelta.isPresent()) {
341 152 : return groupDelta.get().getName().map(n -> Strings.nullToEmpty(n.get()));
342 : }
343 1 : if (groupCreation.isPresent()) {
344 1 : return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get()));
345 : }
346 0 : return Optional.empty();
347 : }
348 :
349 : private InternalGroup updateGroup(Instant commitTimestamp)
350 : throws IOException, ConfigInvalidException {
351 152 : Config config = updateGroupProperties();
352 :
353 152 : ImmutableSet<Account.Id> originalMembers =
354 152 : loadedGroup.map(InternalGroup::getMembers).orElseGet(ImmutableSet::of);
355 152 : Optional<ImmutableSet<Account.Id>> updatedMembers = updateMembers(originalMembers);
356 :
357 152 : ImmutableSet<AccountGroup.UUID> originalSubgroups =
358 152 : loadedGroup.map(InternalGroup::getSubgroups).orElseGet(ImmutableSet::of);
359 152 : Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups = updateSubgroups(originalSubgroups);
360 :
361 152 : Instant createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
362 :
363 152 : return createFrom(
364 : groupUuid,
365 : config,
366 152 : updatedMembers.orElse(originalMembers),
367 152 : updatedSubgroups.orElse(originalSubgroups),
368 : createdOn,
369 : null);
370 : }
371 :
372 : private Config updateGroupProperties() throws IOException, ConfigInvalidException {
373 152 : Config config = readConfig(GROUP_CONFIG_FILE);
374 152 : groupCreation.ifPresent(
375 : internalGroupCreation ->
376 152 : Arrays.stream(GroupConfigEntry.values())
377 152 : .forEach(configEntry -> configEntry.initNewConfig(config, internalGroupCreation)));
378 152 : groupDelta.ifPresent(
379 : delta ->
380 152 : Arrays.stream(GroupConfigEntry.values())
381 152 : .forEach(configEntry -> configEntry.updateConfigValue(config, delta)));
382 152 : saveConfig(GROUP_CONFIG_FILE, config);
383 152 : return config;
384 : }
385 :
386 : private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers)
387 : throws IOException {
388 152 : Optional<ImmutableSet<Account.Id>> updatedMembers =
389 : groupDelta
390 152 : .map(GroupDelta::getMemberModification)
391 152 : .map(memberModification -> memberModification.apply(originalMembers))
392 152 : .map(ImmutableSet::copyOf)
393 152 : .filter(members -> !originalMembers.equals(members));
394 152 : if (updatedMembers.isPresent()) {
395 152 : saveMembers(updatedMembers.get());
396 : }
397 152 : return updatedMembers;
398 : }
399 :
400 : private Optional<ImmutableSet<AccountGroup.UUID>> updateSubgroups(
401 : ImmutableSet<AccountGroup.UUID> originalSubgroups) throws IOException {
402 152 : Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups =
403 : groupDelta
404 152 : .map(GroupDelta::getSubgroupModification)
405 152 : .map(subgroupModification -> subgroupModification.apply(originalSubgroups))
406 152 : .map(ImmutableSet::copyOf)
407 152 : .filter(subgroups -> !originalSubgroups.equals(subgroups));
408 152 : if (updatedSubgroups.isPresent()) {
409 8 : saveSubgroups(updatedSubgroups.get());
410 : }
411 152 : return updatedSubgroups;
412 : }
413 :
414 : private void saveMembers(ImmutableSet<Account.Id> members) throws IOException {
415 152 : saveToFile(MEMBERS_FILE, members, member -> String.valueOf(member.get()));
416 152 : }
417 :
418 : private void saveSubgroups(ImmutableSet<AccountGroup.UUID> subgroups) throws IOException {
419 8 : saveToFile(SUBGROUPS_FILE, subgroups, AccountGroup.UUID::get);
420 8 : }
421 :
422 : private <E> void saveToFile(
423 : String filePath, ImmutableSet<E> elements, Function<E, String> toStringFunction)
424 : throws IOException {
425 152 : String fileContent = elements.stream().map(toStringFunction).collect(joining("\n"));
426 152 : saveUTF8(filePath, fileContent);
427 152 : }
428 :
429 : private ImmutableSet<Account.Id> readMembers() throws IOException, ConfigInvalidException {
430 152 : return readFromFile(MEMBERS_FILE, entry -> Account.id(Integer.parseInt(entry)));
431 : }
432 :
433 : private ImmutableSet<AccountGroup.UUID> readSubgroups()
434 : throws IOException, ConfigInvalidException {
435 152 : return readFromFile(SUBGROUPS_FILE, AccountGroup::uuid);
436 : }
437 :
438 : private <E> ImmutableSet<E> readFromFile(String filePath, Function<String, E> fromStringFunction)
439 : throws IOException, ConfigInvalidException {
440 152 : String fileContent = readUTF8(filePath);
441 : try {
442 152 : Iterable<String> lines =
443 152 : Splitter.on(LINE_SEPARATOR_PATTERN).trimResults().omitEmptyStrings().split(fileContent);
444 152 : return Streams.stream(lines).map(fromStringFunction).collect(toImmutableSet());
445 1 : } catch (NumberFormatException e) {
446 1 : throw new ConfigInvalidException(
447 1 : String.format("Invalid file %s for commit %s", filePath, revision.name()), e);
448 : }
449 : }
450 :
451 : private static InternalGroup createFrom(
452 : AccountGroup.UUID groupUuid,
453 : Config config,
454 : ImmutableSet<Account.Id> members,
455 : ImmutableSet<AccountGroup.UUID> subgroups,
456 : Instant createdOn,
457 : ObjectId refState)
458 : throws ConfigInvalidException {
459 152 : InternalGroup.Builder group = InternalGroup.builder();
460 152 : group.setGroupUUID(groupUuid);
461 152 : for (GroupConfigEntry configEntry : GroupConfigEntry.values()) {
462 152 : configEntry.readFromConfig(groupUuid, group, config);
463 : }
464 152 : group.setMembers(members);
465 152 : group.setSubgroups(subgroups);
466 152 : group.setCreatedOn(createdOn);
467 152 : group.setRefState(refState);
468 152 : return group.build();
469 : }
470 :
471 : private String createCommitMessage(
472 : Optional<InternalGroup> originalGroup, InternalGroup updatedGroup) {
473 152 : GroupConfigCommitMessage commitMessage =
474 : new GroupConfigCommitMessage(auditLogFormatter, updatedGroup);
475 152 : originalGroup.ifPresent(commitMessage::setOriginalGroup);
476 152 : return commitMessage.create();
477 : }
478 : }
|