blob: 61bd436e461e059b34c60f015c44943b983b30c1 [file] [log] [blame]
// 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.exceptions.DuplicateKeyException;
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 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 DuplicateKeyException if a checker with the same UUID already exists
*/
public static CheckerConfig createForNewChecker(
Project.NameKey projectName, Repository repository, CheckerCreation checkerCreation)
throws IOException, ConfigInvalidException, DuplicateKeyException {
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 DuplicateKeyException {
checkLoaded();
if (loadedChecker.isPresent()) {
throw new DuplicateKeyException(
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;
}
}