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.collect.ImmutableBiMap.toImmutableBiMap;
18 : import static java.nio.charset.StandardCharsets.UTF_8;
19 : import static java.util.Objects.requireNonNull;
20 : import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
21 :
22 : import com.google.common.annotations.VisibleForTesting;
23 : import com.google.common.base.Strings;
24 : import com.google.common.collect.HashMultiset;
25 : import com.google.common.collect.ImmutableBiMap;
26 : import com.google.common.collect.ImmutableList;
27 : import com.google.common.collect.Multiset;
28 : import com.google.common.flogger.FluentLogger;
29 : import com.google.common.hash.Hashing;
30 : import com.google.gerrit.common.Nullable;
31 : import com.google.gerrit.entities.AccountGroup;
32 : import com.google.gerrit.entities.GroupReference;
33 : import com.google.gerrit.entities.Project;
34 : import com.google.gerrit.entities.RefNames;
35 : import com.google.gerrit.exceptions.DuplicateKeyException;
36 : import com.google.gerrit.git.ObjectIds;
37 : import com.google.gerrit.server.git.meta.VersionedMetaData;
38 : import com.google.gerrit.server.logging.Metadata;
39 : import com.google.gerrit.server.logging.TraceContext;
40 : import com.google.gerrit.server.logging.TraceContext.TraceTimer;
41 : import java.io.IOException;
42 : import java.util.Collection;
43 : import java.util.Map;
44 : import java.util.Objects;
45 : import java.util.Optional;
46 : import org.eclipse.jgit.errors.ConfigInvalidException;
47 : import org.eclipse.jgit.lib.BatchRefUpdate;
48 : import org.eclipse.jgit.lib.CommitBuilder;
49 : import org.eclipse.jgit.lib.Config;
50 : import org.eclipse.jgit.lib.ObjectId;
51 : import org.eclipse.jgit.lib.ObjectInserter;
52 : import org.eclipse.jgit.lib.ObjectReader;
53 : import org.eclipse.jgit.lib.PersonIdent;
54 : import org.eclipse.jgit.lib.Ref;
55 : import org.eclipse.jgit.lib.Repository;
56 : import org.eclipse.jgit.notes.Note;
57 : import org.eclipse.jgit.notes.NoteMap;
58 : import org.eclipse.jgit.revwalk.RevCommit;
59 : import org.eclipse.jgit.revwalk.RevWalk;
60 : import org.eclipse.jgit.transport.ReceiveCommand;
61 :
62 : /**
63 : * An enforcer of unique names for groups in NoteDb.
64 : *
65 : * <p>The way groups are stored in NoteDb (see {@link GroupConfig}) doesn't enforce unique names,
66 : * even though groups in Gerrit must not have duplicate names. The storage format doesn't allow to
67 : * quickly look up whether a name has already been used either. That's why we additionally keep a
68 : * map of name/UUID pairs and manage it with this class.
69 : *
70 : * <p>To claim the name for a new group, create an instance of {@code GroupNameNotes} via {@link
71 : * #forNewGroup(Project.NameKey, Repository, AccountGroup.UUID, AccountGroup.NameKey)} and call
72 : * {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} on it.
73 : * For renaming, call {@link #forRename(Project.NameKey, Repository, AccountGroup.UUID,
74 : * AccountGroup.NameKey, AccountGroup.NameKey)} and also commit the returned {@code GroupNameNotes}.
75 : * Both times, the creation of the {@code GroupNameNotes} will fail if the (new) name is already
76 : * used. Committing the {@code GroupNameNotes} is necessary to make the adjustments for real.
77 : *
78 : * <p>The map has an additional benefit: We can quickly iterate over all group name/UUID pairs
79 : * without having to load all groups completely (which is costly).
80 : *
81 : * <p><em>Internal details</em>
82 : *
83 : * <p>The map of names is represented by Git {@link Note notes}. They are stored on the branch
84 : * {@link RefNames#REFS_GROUPNAMES}. Each commit on the branch reflects one moment in time of the
85 : * complete map.
86 : *
87 : * <p>As key for the notes, we use the SHA-1 of the name. As data, they contain a text version of a
88 : * JGit {@link Config} file. That config file has two entries:
89 : *
90 : * <ul>
91 : * <li>the name of the group (as clear text)
92 : * <li>the UUID of the group which currently has this name
93 : * </ul>
94 : */
95 : public class GroupNameNotes extends VersionedMetaData {
96 152 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
97 :
98 : private static final String SECTION_NAME = "group";
99 : private static final String UUID_PARAM = "uuid";
100 : private static final String NAME_PARAM = "name";
101 :
102 : @VisibleForTesting
103 : static final String UNIQUE_REF_ERROR = "GroupReference collection must contain unique references";
104 :
105 : /**
106 : * Creates an instance of {@code GroupNameNotes} for use when renaming a group.
107 : *
108 : * <p><strong>Note: </strong>The returned instance of {@code GroupNameNotes} has to be committed
109 : * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
110 : * order to claim the new name and free up the old one.
111 : *
112 : * @param projectName the name of the project which holds the commits of the notes
113 : * @param repository the repository which holds the commits of the notes
114 : * @param groupUuid the UUID of the group which is renamed
115 : * @param oldName the current name of the group
116 : * @param newName the new name of the group
117 : * @return an instance of {@code GroupNameNotes} configured for a specific renaming of a group
118 : * @throws IOException if the repository can't be accessed for some reason
119 : * @throws ConfigInvalidException if the note for the specified group doesn't exist or is in an
120 : * invalid state
121 : * @throws DuplicateKeyException if a group with the new name already exists
122 : */
123 : public static GroupNameNotes forRename(
124 : Project.NameKey projectName,
125 : Repository repository,
126 : AccountGroup.UUID groupUuid,
127 : AccountGroup.NameKey oldName,
128 : AccountGroup.NameKey newName)
129 : throws IOException, ConfigInvalidException, DuplicateKeyException {
130 4 : requireNonNull(oldName);
131 4 : requireNonNull(newName);
132 :
133 4 : GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
134 4 : groupNameNotes.load(projectName, repository);
135 4 : groupNameNotes.ensureNewNameIsNotUsed();
136 4 : return groupNameNotes;
137 : }
138 :
139 : /**
140 : * Creates an instance of {@code GroupNameNotes} for use when creating a new group.
141 : *
142 : * <p><strong>Note: </strong>The returned instance of {@code GroupNameNotes} has to be committed
143 : * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in
144 : * order to claim the new name.
145 : *
146 : * @param projectName the name of the project which holds the commits of the notes
147 : * @param repository the repository which holds the commits of the notes
148 : * @param groupUuid the UUID of the new group
149 : * @param groupName the name of the new group
150 : * @return an instance of {@code GroupNameNotes} configured for a specific group creation
151 : * @throws IOException if the repository can't be accessed for some reason
152 : * @throws ConfigInvalidException in no case so far
153 : * @throws DuplicateKeyException if a group with the new name already exists
154 : */
155 : public static GroupNameNotes forNewGroup(
156 : Project.NameKey projectName,
157 : Repository repository,
158 : AccountGroup.UUID groupUuid,
159 : AccountGroup.NameKey groupName)
160 : throws IOException, ConfigInvalidException, DuplicateKeyException {
161 152 : requireNonNull(groupName);
162 :
163 152 : GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
164 152 : groupNameNotes.load(projectName, repository);
165 152 : groupNameNotes.ensureNewNameIsNotUsed();
166 152 : return groupNameNotes;
167 : }
168 :
169 : /**
170 : * Loads the {@code GroupReference} (name/UUID pair) for the group with the specified name.
171 : *
172 : * @param repository the repository which holds the commits of the notes
173 : * @param groupName the name of the group
174 : * @return the corresponding {@code GroupReference} if a group/note with the given name exists
175 : * @throws IOException if the repository can't be accessed for some reason
176 : * @throws ConfigInvalidException if the note for the specified group is in an invalid state
177 : */
178 : public static Optional<GroupReference> loadGroup(
179 : Repository repository, AccountGroup.NameKey groupName)
180 : throws IOException, ConfigInvalidException {
181 152 : Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
182 152 : if (ref == null) {
183 2 : return Optional.empty();
184 : }
185 :
186 152 : try (RevWalk revWalk = new RevWalk(repository);
187 152 : ObjectReader reader = revWalk.getObjectReader()) {
188 152 : RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
189 152 : NoteMap noteMap = NoteMap.read(reader, notesCommit);
190 152 : ObjectId noteDataBlobId = noteMap.get(getNoteKey(groupName));
191 152 : if (noteDataBlobId == null) {
192 1 : return Optional.empty();
193 : }
194 152 : return Optional.of(getGroupReference(reader, noteDataBlobId));
195 1 : }
196 : }
197 :
198 : /**
199 : * Loads the {@code GroupReference}s (name/UUID pairs) for all groups.
200 : *
201 : * <p>Even though group UUIDs should be unique, this class doesn't enforce it. For this reason,
202 : * it's technically possible that two of the {@code GroupReference}s have a duplicate UUID but a
203 : * different name. In practice, this shouldn't occur unless we introduce a bug in the future.
204 : *
205 : * @param repository the repository which holds the commits of the notes
206 : * @return the {@code GroupReference}s of all existing groups/notes
207 : * @throws IOException if the repository can't be accessed for some reason
208 : * @throws ConfigInvalidException if one of the notes is in an invalid state
209 : */
210 : public static ImmutableList<GroupReference> loadAllGroups(Repository repository)
211 : throws IOException, ConfigInvalidException {
212 146 : Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
213 146 : if (ref == null) {
214 1 : return ImmutableList.of();
215 : }
216 146 : try (TraceTimer ignored =
217 146 : TraceContext.newTimer(
218 : "Loading all groups",
219 146 : Metadata.builder().noteDbRefName(RefNames.REFS_GROUPNAMES).build());
220 146 : RevWalk revWalk = new RevWalk(repository);
221 146 : ObjectReader reader = revWalk.getObjectReader()) {
222 146 : RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
223 146 : NoteMap noteMap = NoteMap.read(reader, notesCommit);
224 :
225 146 : Multiset<GroupReference> groupReferences = HashMultiset.create();
226 146 : for (Note note : noteMap) {
227 146 : GroupReference groupReference = getGroupReference(reader, note.getData());
228 146 : int numOfOccurrences = groupReferences.add(groupReference, 1);
229 146 : if (numOfOccurrences > 1) {
230 0 : GroupsNoteDbConsistencyChecker.logConsistencyProblemAsWarning(
231 : "The UUID of group %s (%s) is duplicate in group name notes",
232 0 : groupReference.getName(), groupReference.getUUID());
233 : }
234 146 : }
235 :
236 146 : return ImmutableList.copyOf(groupReferences);
237 : }
238 : }
239 :
240 : /**
241 : * Replaces the map of name/UUID pairs with a new version which matches exactly the passed {@code
242 : * GroupReference}s.
243 : *
244 : * <p>All old entries are discarded and replaced by the new ones.
245 : *
246 : * <p>This operation also works if the previous map has invalid entries or can't be read anymore.
247 : *
248 : * <p><strong>Note: </strong>This method doesn't flush the {@code ObjectInserter}. It doesn't
249 : * execute the {@code BatchRefUpdate} either.
250 : *
251 : * @param repository the repository which holds the commits of the notes
252 : * @param inserter an {@code ObjectInserter} for that repository
253 : * @param bru a {@code BatchRefUpdate} to which this method adds commands
254 : * @param groupReferences all {@code GroupReference}s (name/UUID pairs) which should be contained
255 : * in the map of name/UUID pairs
256 : * @param ident the {@code PersonIdent} which is used as author and committer for commits
257 : * @throws IOException if the repository can't be accessed for some reason
258 : */
259 : public static void updateAllGroups(
260 : Repository repository,
261 : ObjectInserter inserter,
262 : BatchRefUpdate bru,
263 : Collection<GroupReference> groupReferences,
264 : PersonIdent ident)
265 : throws IOException {
266 : // Not strictly necessary for iteration; throws IAE if it encounters duplicates, which is nice.
267 1 : ImmutableBiMap<AccountGroup.UUID, String> biMap = toBiMap(groupReferences);
268 :
269 1 : try (ObjectReader reader = inserter.newReader();
270 1 : RevWalk rw = new RevWalk(reader)) {
271 : // Always start from an empty map, discarding old notes.
272 1 : NoteMap noteMap = NoteMap.newEmptyMap();
273 1 : Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
274 1 : RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;
275 :
276 1 : for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
277 1 : AccountGroup.NameKey nameKey = AccountGroup.nameKey(e.getValue());
278 1 : ObjectId noteKey = getNoteKey(nameKey);
279 1 : noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
280 1 : }
281 :
282 1 : ObjectId newTreeId = noteMap.writeTree(inserter);
283 1 : if (oldCommit != null && newTreeId.equals(oldCommit.getTree())) {
284 1 : return;
285 : }
286 1 : CommitBuilder cb = new CommitBuilder();
287 1 : if (oldCommit != null) {
288 1 : cb.addParentId(oldCommit);
289 : }
290 1 : cb.setTreeId(newTreeId);
291 1 : cb.setAuthor(ident);
292 1 : cb.setCommitter(ident);
293 1 : int n = groupReferences.size();
294 1 : cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
295 1 : ObjectId newId = inserter.insert(cb).copy();
296 :
297 1 : ObjectId oldId = ObjectIds.copyOrZero(oldCommit);
298 1 : bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
299 1 : }
300 1 : }
301 :
302 : // Returns UUID <=> Name bimap.
303 : private static ImmutableBiMap<AccountGroup.UUID, String> toBiMap(
304 : Collection<GroupReference> groupReferences) {
305 : try {
306 1 : return groupReferences.stream()
307 1 : .collect(toImmutableBiMap(GroupReference::getUUID, GroupReference::getName));
308 1 : } catch (IllegalArgumentException e) {
309 1 : throw new IllegalArgumentException(UNIQUE_REF_ERROR, e);
310 : }
311 : }
312 :
313 : private final AccountGroup.UUID groupUuid;
314 : private Optional<AccountGroup.NameKey> oldGroupName;
315 : private Optional<AccountGroup.NameKey> newGroupName;
316 :
317 : private boolean nameConflicting;
318 :
319 : private GroupNameNotes(
320 : AccountGroup.UUID groupUuid,
321 : @Nullable AccountGroup.NameKey oldGroupName,
322 152 : @Nullable AccountGroup.NameKey newGroupName) {
323 152 : this.groupUuid = requireNonNull(groupUuid);
324 :
325 152 : if (Objects.equals(oldGroupName, newGroupName)) {
326 1 : this.oldGroupName = Optional.empty();
327 1 : this.newGroupName = Optional.empty();
328 : } else {
329 152 : this.oldGroupName = Optional.ofNullable(oldGroupName);
330 152 : this.newGroupName = Optional.ofNullable(newGroupName);
331 : }
332 152 : }
333 :
334 : @Override
335 : protected String getRefName() {
336 152 : return RefNames.REFS_GROUPNAMES;
337 : }
338 :
339 : @Override
340 : protected void onLoad() throws IOException, ConfigInvalidException {
341 152 : nameConflicting = false;
342 :
343 152 : logger.atFine().log("Reading group notes");
344 :
345 152 : if (revision != null) {
346 152 : NoteMap noteMap = NoteMap.read(reader, revision);
347 152 : if (newGroupName.isPresent()) {
348 152 : ObjectId newNameId = getNoteKey(newGroupName.get());
349 152 : nameConflicting = noteMap.contains(newNameId);
350 : }
351 152 : ensureOldNameIsPresent(noteMap);
352 : }
353 152 : }
354 :
355 : private void ensureOldNameIsPresent(NoteMap noteMap) throws IOException, ConfigInvalidException {
356 152 : if (oldGroupName.isPresent()) {
357 4 : AccountGroup.NameKey oldName = oldGroupName.get();
358 4 : ObjectId noteKey = getNoteKey(oldName);
359 4 : ObjectId noteDataBlobId = noteMap.get(noteKey);
360 4 : if (noteDataBlobId == null) {
361 1 : throw new ConfigInvalidException(
362 1 : String.format("Group name '%s' doesn't exist in the list of all names", oldName));
363 : }
364 4 : GroupReference group = getGroupReference(reader, noteDataBlobId);
365 4 : AccountGroup.UUID foundUuid = group.getUUID();
366 4 : if (!Objects.equals(groupUuid, foundUuid)) {
367 1 : throw new ConfigInvalidException(
368 1 : String.format(
369 : "Name '%s' points to UUID '%s' and not to '%s'", oldName, foundUuid, groupUuid));
370 : }
371 : }
372 152 : }
373 :
374 : private void ensureNewNameIsNotUsed() throws DuplicateKeyException {
375 152 : if (newGroupName.isPresent() && nameConflicting) {
376 2 : throw new DuplicateKeyException(
377 2 : String.format("Name '%s' is already used", newGroupName.get().get()));
378 : }
379 152 : }
380 :
381 : @Override
382 : protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
383 152 : if (!oldGroupName.isPresent() && !newGroupName.isPresent()) {
384 1 : return false;
385 : }
386 :
387 152 : logger.atFine().log("Updating group notes");
388 :
389 152 : NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
390 152 : if (oldGroupName.isPresent()) {
391 4 : removeNote(noteMap, oldGroupName.get(), inserter);
392 : }
393 :
394 152 : if (newGroupName.isPresent()) {
395 152 : addNote(noteMap, newGroupName.get(), groupUuid, inserter);
396 : }
397 :
398 152 : commit.setTreeId(noteMap.writeTree(inserter));
399 152 : commit.setMessage(getCommitMessage());
400 :
401 152 : oldGroupName = Optional.empty();
402 152 : newGroupName = Optional.empty();
403 :
404 152 : return true;
405 : }
406 :
407 : private static void removeNote(
408 : NoteMap noteMap, AccountGroup.NameKey groupName, ObjectInserter inserter) throws IOException {
409 4 : ObjectId noteKey = getNoteKey(groupName);
410 4 : noteMap.set(noteKey, null, inserter);
411 4 : }
412 :
413 : private static void addNote(
414 : NoteMap noteMap,
415 : AccountGroup.NameKey groupName,
416 : AccountGroup.UUID groupUuid,
417 : ObjectInserter inserter)
418 : throws IOException {
419 152 : ObjectId noteKey = getNoteKey(groupName);
420 152 : noteMap.set(noteKey, getAsNoteData(groupUuid, groupName), inserter);
421 152 : }
422 :
423 : // Use the same approach as ExternalId.Key.sha1().
424 : @SuppressWarnings("deprecation")
425 : @VisibleForTesting
426 : public static ObjectId getNoteKey(AccountGroup.NameKey groupName) {
427 152 : return ObjectId.fromRaw(Hashing.sha1().hashString(groupName.get(), UTF_8).asBytes());
428 : }
429 :
430 : private static String getAsNoteData(AccountGroup.UUID uuid, AccountGroup.NameKey groupName) {
431 152 : Config config = new Config();
432 152 : config.setString(SECTION_NAME, null, UUID_PARAM, uuid.get());
433 152 : config.setString(SECTION_NAME, null, NAME_PARAM, groupName.get());
434 152 : return config.toText();
435 : }
436 :
437 : private static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId)
438 : throws IOException, ConfigInvalidException {
439 152 : byte[] noteData = reader.open(noteDataBlobId, OBJ_BLOB).getCachedBytes();
440 152 : return getFromNoteData(noteData);
441 : }
442 :
443 : static GroupReference getFromNoteData(byte[] noteData) throws ConfigInvalidException {
444 152 : Config config = new Config();
445 152 : config.fromText(new String(noteData, UTF_8));
446 :
447 152 : String uuid = config.getString(SECTION_NAME, null, UUID_PARAM);
448 152 : String name = Strings.nullToEmpty(config.getString(SECTION_NAME, null, NAME_PARAM));
449 152 : if (uuid == null) {
450 0 : throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
451 : }
452 :
453 152 : return GroupReference.create(AccountGroup.uuid(uuid), name);
454 : }
455 :
456 : private String getCommitMessage() {
457 152 : if (oldGroupName.isPresent() && newGroupName.isPresent()) {
458 4 : return String.format(
459 4 : "Rename group from '%s' to '%s'", oldGroupName.get(), newGroupName.get());
460 : }
461 152 : if (newGroupName.isPresent()) {
462 152 : return String.format("Create group '%s'", newGroupName.get());
463 : }
464 0 : if (oldGroupName.isPresent()) {
465 0 : return String.format("Delete group '%s'", oldGroupName.get());
466 : }
467 0 : return "No-op";
468 : }
469 : }
|