| // Copyright (C) 2019 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.plugins.checks.db; |
| |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.common.base.Preconditions.checkState; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Strings; |
| import com.google.gerrit.plugins.checks.Checker; |
| import com.google.gerrit.plugins.checks.CheckerCreation; |
| import com.google.gerrit.plugins.checks.CheckerRef; |
| import com.google.gerrit.plugins.checks.CheckerUpdate; |
| import com.google.gerrit.plugins.checks.CheckerUuid; |
| import com.google.gerrit.plugins.checks.Checkers; |
| import com.google.gerrit.plugins.checks.CheckersUpdate; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.reviewdb.client.Project.NameKey; |
| 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 com.google.gwtorm.server.OrmDuplicateKeyException; |
| import java.io.IOException; |
| import java.sql.Timestamp; |
| import java.util.Arrays; |
| import java.util.Optional; |
| 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.Ref; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevSort; |
| |
| /** |
| * A representation of a checker in NoteDb. |
| * |
| * <p>Checkers in NoteDb can be created by following the descriptions of {@link |
| * #createForNewChecker(Project.NameKey, Repository, CheckerCreation)}. For reading checkers from |
| * NoteDb or updating them, refer to {@link #loadForChecker(Project.NameKey, Repository, |
| * CheckerUuid)}. |
| * |
| * <p><strong>Note:</strong> Any modification (checker 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 checkers in NoteDb. Most code |
| * which deals with checkers should use {@link Checkers} or {@link CheckersUpdate} instead. |
| * |
| * <h2>Internal details</h2> |
| * |
| * <p>Each checker is represented by a commit on a branch as defined by {@link |
| * CheckerUuid#toRefName()}. Previous versions of the checker 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>Within each commit, the properties of a checker are stored in <em>checker.config</em> file |
| * (further specified by {@link CheckerConfigEntry}). The <em>checker.config</em> file is formatted |
| * as a JGit {@link Config} file. |
| */ |
| @VisibleForTesting |
| public class CheckerConfig extends VersionedMetaData { |
| @VisibleForTesting public static final String CHECKER_CONFIG_FILE = "checker.config"; |
| |
| /** |
| * Creates a {@code CheckerConfig} for a new checker from the {@code CheckerCreation} blueprint. |
| * Further, optional properties can be specified by setting an {@code CheckerUpdate} via {@link |
| * #setCheckerUpdate(CheckerUpdate)} on the returned {@code CheckerConfig}. |
| * |
| * <p><strong>Note:</strong> The returned {@code CheckerConfig} has to be committed via {@link |
| * #commit(MetaDataUpdate)} in order to create the checker for real. |
| * |
| * @param projectName the name of the project which holds the NoteDb commits for checkers |
| * @param repository the repository which holds the NoteDb commits for checkers |
| * @param checkerCreation a {@code CheckerCreation} specifying all properties which are required |
| * for a new checker |
| * @return a {@code CheckerConfig} for a checker creation |
| * @throws IOException if the repository can't be accessed for some reason |
| * @throws ConfigInvalidException if a checker with the same UUID already exists but can't be read |
| * due to an invalid format |
| * @throws OrmDuplicateKeyException if a checker with the same UUID already exists |
| */ |
| public static CheckerConfig createForNewChecker( |
| Project.NameKey projectName, Repository repository, CheckerCreation checkerCreation) |
| throws IOException, ConfigInvalidException, OrmDuplicateKeyException { |
| CheckerConfig checkerConfig = |
| loadForChecker(projectName, repository, checkerCreation.getCheckerUuid()); |
| checkerConfig.setCheckerCreation(checkerCreation); |
| return checkerConfig; |
| } |
| |
| /** |
| * Creates a {@code CheckerConfig} for an existing checker. |
| * |
| * <p>The checker is automatically loaded within this method and can be accessed via {@link |
| * #getLoadedChecker()}. |
| * |
| * <p>It's safe to call this method for non-existing checkers. In that case, {@link |
| * #getLoadedChecker()} won't return any checker. Thus, the existence of a checker can be easily |
| * tested. |
| * |
| * <p>The checker represented by the returned {@code CheckerConfig} can be updated by setting an |
| * {@code CheckerUpdate} via {@link #setCheckerUpdate(CheckerUpdate)} and committing the {@code |
| * CheckerConfig} via {@link #commit(MetaDataUpdate)}. |
| * |
| * @param projectName the name of the project which holds the NoteDb commits for checkers |
| * @param repository the repository which holds the NoteDb commits for checkers |
| * @param checkerUuid the UUID of the checker |
| * @return a {@code CheckerConfig} for the checker with the specified UUID |
| * @throws IOException if the repository can't be accessed for some reason |
| * @throws ConfigInvalidException if the checker exists but can't be read due to an invalid format |
| */ |
| public static CheckerConfig loadForChecker( |
| Project.NameKey projectName, Repository repository, CheckerUuid checkerUuid) |
| throws IOException, ConfigInvalidException { |
| CheckerConfig checkerConfig = new CheckerConfig(checkerUuid); |
| checkerConfig.load(projectName, repository); |
| return checkerConfig; |
| } |
| |
| /** |
| * Creates a {@code CheckerConfig} for a known checker ref. |
| * |
| * <p>The checker is automatically loaded within this method and can be accessed via {@link |
| * #getLoadedChecker()}. |
| * |
| * <p>It's safe to call this method for non-existing checkers. In that case, {@link |
| * #getLoadedChecker()} won't return any checker. Thus, the existence of a checker can be easily |
| * tested. |
| * |
| * <p>The checker represented by the returned {@code CheckerConfig} can be updated by setting an |
| * {@code CheckerUpdate} via {@link #setCheckerUpdate(CheckerUpdate)} and committing the {@code |
| * CheckerConfig} via {@link #commit(MetaDataUpdate)}. |
| * |
| * @param projectName the name of the project which holds the NoteDb commits for checkers |
| * @param repository the repository which holds the NoteDb commits for checkers |
| * @param ref the checker ref; the refname must pass {@link CheckerRef#isRefsCheckers(String)}. |
| * @return a {@code CheckerConfig} for the checker with the specified UUID |
| * @throws IOException if the repository can't be accessed for some reason |
| * @throws ConfigInvalidException if the checker exists but can't be read due to an invalid format |
| */ |
| public static CheckerConfig loadForChecker(NameKey projectName, Repository repository, Ref ref) |
| throws IOException, ConfigInvalidException { |
| CheckerConfig checkerConfig = new CheckerConfig(ref.getName()); |
| checkerConfig.load(projectName, repository); |
| return checkerConfig; |
| } |
| |
| private final String ref; |
| |
| private Optional<CheckerUuid> checkerUuid; |
| private Optional<Checker> loadedChecker = Optional.empty(); |
| private Optional<CheckerCreation> checkerCreation = Optional.empty(); |
| private Optional<CheckerUpdate> checkerUpdate = Optional.empty(); |
| private Optional<Checker.Builder> updatedCheckerBuilder = Optional.empty(); |
| private Optional<Config> loadedConfig = Optional.empty(); |
| private boolean isLoaded = false; |
| |
| private CheckerConfig(String ref) { |
| checkArgument(CheckerRef.isRefsCheckers(ref), "expected checker ref: %s", ref); |
| this.ref = ref; |
| this.checkerUuid = Optional.empty(); |
| } |
| |
| private CheckerConfig(CheckerUuid checkerUuid) { |
| this(checkerUuid.toRefName()); |
| this.checkerUuid = Optional.of(checkerUuid); |
| } |
| |
| /** |
| * Returns the checker loaded from NoteDb. |
| * |
| * <p>If not any NoteDb commits exist for the checker represented by this {@code CheckerConfig}, |
| * no checker is returned. |
| * |
| * <p>After {@link #commit(MetaDataUpdate)} was called on this {@code CheckerConfig}, this method |
| * returns a checker which is in line with the latest NoteDb commit for this checker. So, after |
| * creating a {@code CheckerConfig} for a new checker and committing it, this method can be used |
| * to retrieve a representation of the created checker. The same holds for the representation of |
| * an updated checker. |
| * |
| * @return the loaded checker, or an empty {@code Optional} if the checker doesn't exist |
| */ |
| public Optional<Checker> getLoadedChecker() { |
| checkLoaded(); |
| |
| if (updatedCheckerBuilder.isPresent()) { |
| // There have been updates to the checker that have not been applied to the loaded checker |
| // yet, apply them now. This has to be done here because in the onSave(CommitBuilder) method |
| // where the checker updates are committed we do not know the new SHA1 for the ref state |
| // yet. |
| loadedChecker = Optional.of(updatedCheckerBuilder.get().setRefState(revision).build()); |
| updatedCheckerBuilder = Optional.empty(); |
| } |
| |
| return loadedChecker; |
| } |
| |
| /** |
| * Specifies how the current checker should be updated. |
| * |
| * <p>If the checker is newly created, the {@code CheckerUpdate} 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 {@code CheckerConfig}. |
| * |
| * @param checkerUpdate an {@code CheckerUpdate} outlining the modifications which should be |
| * applied |
| */ |
| public void setCheckerUpdate(CheckerUpdate checkerUpdate) { |
| this.checkerUpdate = Optional.of(checkerUpdate); |
| } |
| |
| private void setCheckerCreation(CheckerCreation checkerCreation) throws OrmDuplicateKeyException { |
| checkLoaded(); |
| if (loadedChecker.isPresent()) { |
| throw new OrmDuplicateKeyException( |
| String.format("Checker %s already exists", loadedChecker.get().getUuid())); |
| } |
| |
| this.checkerCreation = Optional.of(checkerCreation); |
| } |
| |
| @Override |
| protected String getRefName() { |
| return ref; |
| } |
| |
| @VisibleForTesting |
| public Optional<Config> getConfigForTesting() { |
| return loadedConfig; |
| } |
| |
| @Override |
| protected void onLoad() throws IOException, ConfigInvalidException { |
| if (revision != null) { |
| rw.reset(); |
| rw.markStart(revision); |
| rw.sort(RevSort.REVERSE); |
| RevCommit earliestCommit = rw.next(); |
| Timestamp created = new Timestamp(earliestCommit.getCommitTime() * 1000L); |
| Timestamp updated = new Timestamp(rw.parseCommit(revision).getCommitTime() * 1000L); |
| |
| Config checkerConfig = readConfig(CHECKER_CONFIG_FILE); |
| Checker checker = createFrom(checkerConfig, created, updated, revision.toObjectId()); |
| loadedChecker = Optional.of(checker); |
| loadedConfig = Optional.of(checkerConfig); |
| checkerUuid = Optional.of(checker.getUuid()); |
| } |
| |
| isLoaded = true; |
| } |
| |
| @Override |
| protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException { |
| checkLoaded(); |
| if (!checkerCreation.isPresent() && !checkerUpdate.isPresent()) { |
| // Checker was neither created nor changed. -> A new commit isn't necessary. |
| return false; |
| } |
| |
| ensureThatMandatoryPropertiesAreSet(); |
| |
| // Commit timestamps are internally truncated to seconds. To return the correct 'created' time |
| // for new checkers, we explicitly need to truncate the timestamp here. |
| Timestamp commitTimestamp = |
| TimeUtil.truncateToSecond(new Timestamp(commit.getCommitter().getWhen().getTime())); |
| commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp)); |
| commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp)); |
| |
| Config oldConfig = copyOrElseNew(loadedConfig); |
| Config newConfig = copyOrElseNew(loadedConfig); |
| |
| Checker.Builder checker = updateChecker(newConfig, commitTimestamp); |
| |
| if (oldConfig.toText().equals(newConfig.toText())) { |
| // Don't create a new commit if nothing was changed. |
| return false; |
| } |
| |
| updatedCheckerBuilder = Optional.of(checker); |
| checkerUuid = Optional.of(checker.getUuid()); |
| loadedConfig = Optional.of(newConfig); |
| |
| String commitMessage = createCommitMessage(loadedChecker); |
| commit.setMessage(commitMessage); |
| |
| checkerCreation = Optional.empty(); |
| checkerUpdate = Optional.empty(); |
| |
| return true; |
| } |
| |
| private String describeForError() { |
| return checkerUuid.map(CheckerUuid::get).orElse(ref); |
| } |
| |
| private void ensureThatMandatoryPropertiesAreSet() throws ConfigInvalidException { |
| if (getNewRepository().equals(Optional.of(""))) { |
| throw new ConfigInvalidException( |
| String.format("Repository of the checker %s must be defined", describeForError())); |
| } |
| } |
| |
| private void checkLoaded() { |
| checkState(isLoaded, "Checker %s not loaded yet", describeForError()); |
| } |
| |
| private Optional<String> getNewRepository() { |
| if (checkerUpdate.isPresent()) { |
| return checkerUpdate |
| .get() |
| .getRepository() |
| .map(Project.NameKey::get) |
| .map(Strings::nullToEmpty) |
| .map(String::trim); |
| } |
| if (checkerCreation.isPresent()) { |
| return Optional.of(Strings.nullToEmpty(checkerCreation.get().getRepository().get()).trim()); |
| } |
| return Optional.empty(); |
| } |
| |
| private Checker.Builder updateChecker(Config config, Timestamp commitTimestamp) |
| throws IOException, ConfigInvalidException { |
| updateCheckerProperties(config); |
| Timestamp created = loadedChecker.map(Checker::getCreated).orElse(commitTimestamp); |
| return createBuilderFrom(config, created, commitTimestamp); |
| } |
| |
| private void updateCheckerProperties(Config config) throws IOException { |
| checkerCreation.ifPresent( |
| checkerCreation -> |
| Arrays.stream(CheckerConfigEntry.values()) |
| .forEach(configEntry -> configEntry.initNewConfig(config, checkerCreation))); |
| checkerUpdate.ifPresent( |
| checkerUpdate -> |
| Arrays.stream(CheckerConfigEntry.values()) |
| .forEach(configEntry -> configEntry.updateConfigValue(config, checkerUpdate))); |
| saveConfig(CHECKER_CONFIG_FILE, config); |
| } |
| |
| private Checker.Builder createBuilderFrom(Config config, Timestamp created, Timestamp updated) |
| throws ConfigInvalidException { |
| Checker.Builder checker = Checker.builder(); |
| |
| // Populate UUID first so it can be used while reading other fields. The checkerUuid field may |
| // or may not be present at this point; passing it in causes readFromConfig to double-check |
| // equality. |
| CheckerConfigEntry.UUID.readFromConfig(checkerUuid.orElse(null), checker, config); |
| |
| for (CheckerConfigEntry configEntry : CheckerConfigEntry.values()) { |
| if (configEntry == CheckerConfigEntry.UUID) { |
| continue; |
| } |
| configEntry.readFromConfig(checker.getUuid(), checker, config); |
| } |
| return checker.setCreated(created).setUpdated(updated); |
| } |
| |
| private Checker createFrom(Config config, Timestamp created, Timestamp updated, ObjectId refState) |
| throws ConfigInvalidException { |
| return createBuilderFrom(config, created, updated).setRefState(refState).build(); |
| } |
| |
| private static String createCommitMessage(Optional<Checker> originalChecker) { |
| return originalChecker.isPresent() ? "Update checker" : "Create checker"; |
| } |
| |
| private static Config copyOrElseNew(Optional<Config> config) throws ConfigInvalidException { |
| return config.isPresent() ? copyConfig(config.get()) : new Config(); |
| } |
| |
| /** |
| * Copies the provided config. |
| * |
| * <p><strong>Note:</strong> This method only works for configs that have no base config. |
| * Unfortunately we cannot check that the provided config has no base config, so callers must take |
| * care to use this method only for configs without base config. |
| * |
| * @param config the config that should be copied |
| * @return the copy of the config |
| */ |
| private static Config copyConfig(Config config) throws ConfigInvalidException { |
| Config copiedConfig = new Config(); |
| copiedConfig.fromText(config.toText()); |
| return copiedConfig; |
| } |
| } |