blob: 11b3a3a145df96b8ff5e99e69feb06784542fafe [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.googlesource.gerrit.plugins.copyright;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
import com.google.gerrit.extensions.api.changes.ReviewResult;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
import com.google.gerrit.extensions.events.RevisionCreatedListener;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Description.Units;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.PluginConfigFactory;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.validators.CommitValidationException;
import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
/** Listener to manage configuration for enforcing review of copyright declarations and licenses. */
@Singleton
class CopyrightConfig
implements CommitValidationListener, RevisionCreatedListener, GitReferenceUpdatedListener {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final long DEFAULT_MAX_ELAPSED_SECONDS = 8;
private final Metrics metrics;
private final AllProjectsName allProjectsName;
private final String pluginName;
private final GitRepositoryManager repoManager;
private final ProjectCache projectCache;
private final PluginConfigFactory pluginConfigFactory;
private final CopyrightReviewApi reviewApi;
private PluginConfig gerritConfig;
private CheckConfig checkConfig;
static AbstractModule module() {
return new AbstractModule() {
@Override
protected void configure() {
DynamicSet.bind(binder(), CommitValidationListener.class).to(CopyrightConfig.class);
DynamicSet.bind(binder(), RevisionCreatedListener.class).to(CopyrightConfig.class);
DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(CopyrightConfig.class);
}
};
}
@Singleton
private static class Metrics {
final Timer0 readConfigTimer;
final Timer0 checkConfigTimer;
final Timer0 testConfigTimer;
@Inject
Metrics(MetricMaker metricMaker) {
readConfigTimer =
metricMaker.newTimer(
"plugins/copyright/read_config_latency",
new Description("Time spent reading and parsing plugin configurations")
.setCumulative()
.setUnit(Units.MICROSECONDS));
checkConfigTimer =
metricMaker.newTimer(
"plugins/copyright/check_config_latency",
new Description("Time spent testing proposed plugin configurations")
.setCumulative()
.setUnit(Units.MICROSECONDS));
testConfigTimer =
metricMaker.newTimer(
"plugins/copyright/test_config_latency",
new Description("Time spent testing configurations against difficult file pattern")
.setCumulative()
.setUnit(Units.MICROSECONDS));
}
}
@Inject
CopyrightConfig(
Metrics metrics,
AllProjectsName allProjectsName,
@PluginName String pluginName,
GitRepositoryManager repoManager,
ProjectCache projectCache,
PluginConfigFactory pluginConfigFactory,
CopyrightReviewApi reviewApi)
throws IOException, ConfigInvalidException {
this.metrics = metrics;
this.allProjectsName = allProjectsName;
this.pluginName = pluginName;
this.repoManager = repoManager;
this.projectCache = projectCache;
this.pluginConfigFactory = pluginConfigFactory;
this.reviewApi = reviewApi;
long nanoStart = System.nanoTime();
try {
checkConfig = readConfig(projectCache.getAllProjects().getProject().getConfigRefState());
} finally {
long elapsedMicros = (System.nanoTime() - nanoStart) / 1000;
metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
}
}
private CopyrightConfig(
MetricMaker metricMaker, CopyrightReviewApi reviewApi, String projectConfigContents)
throws ConfigInvalidException {
metrics = new Metrics(metricMaker);
allProjectsName = new AllProjectsName("All-Projects");
pluginName = "copyright";
repoManager = null;
projectCache = null;
pluginConfigFactory = null;
this.reviewApi = reviewApi;
checkConfig = new CheckConfig(pluginName, projectConfigContents);
}
@VisibleForTesting
static CopyrightConfig createTestInstance(
MetricMaker metricMaker, CopyrightReviewApi reviewApi, String projectConfigContents)
throws ConfigInvalidException {
return new CopyrightConfig(metricMaker, reviewApi, projectConfigContents);
}
ScannerConfig getScannerConfig() {
return checkConfig.scannerConfig;
}
/** Listens for merges to /refs/meta/config on All-Projects to reload plugin configuration. */
@Override
public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
return;
}
if (!event.getProjectName().equals(allProjectsName.get())) {
return;
}
long nanoStart = System.nanoTime();
try {
clearConfig();
checkConfig = readConfig(event.getNewObjectId());
} catch (IOException | ConfigInvalidException e) {
logger.atSevere().withCause(e).log("%s plugin unable to load configuration", pluginName);
checkConfig = null;
return;
} finally {
long elapsedMicros = (System.nanoTime() - nanoStart) / 1000;
metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
}
}
/** Blocks upload of bad plugin configurations to /refs/meta/config on All-Projects. */
@Override
public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
throws CommitValidationException {
if (!event.getBranchNameKey().get().equals(RefNames.REFS_CONFIG)) {
return Collections.emptyList();
}
if (!event.getProjectNameKey().equals(allProjectsName)) {
return Collections.emptyList();
}
long readStart = System.nanoTime();
long checkStart = -1;
long elapsedMicros = -1;
CheckConfig trialConfig = null;
try {
trialConfig = readConfig(event.commit.getName());
elapsedMicros = (System.nanoTime() - readStart) / 1000;
metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
if (Objects.equals(trialConfig.scannerConfig, checkConfig.scannerConfig)) {
return Collections.emptyList();
}
checkStart = System.nanoTime();
long maxElapsedSeconds =
gerritConfig == null
? DEFAULT_MAX_ELAPSED_SECONDS
: gerritConfig.getLong(ScannerConfig.KEY_TIME_TEST_MAX, DEFAULT_MAX_ELAPSED_SECONDS);
if (maxElapsedSeconds > 0
&& CheckConfig.hasScanner(trialConfig)
&& !CheckConfig.scannersEqual(trialConfig, checkConfig)) {
String commitMessage = event.commit.getFullMessage();
ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings =
trialConfig.checkCommitMessage(commitMessage);
if (CheckConfig.mustReportFindings(findings, maxElapsedSeconds)) {
throw reviewApi.getCommitMessageException(pluginName, findings, maxElapsedSeconds);
}
}
boolean pluginEnabled =
gerritConfig != null && gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
CheckConfig.checkProjectConfig(reviewApi, pluginEnabled, trialConfig);
return trialConfig == null || trialConfig.scannerConfig == null
? Collections.emptyList()
: trialConfig.scannerConfig.messages;
} catch (IOException e) {
logger.atSevere().withCause(e).log(
"failed to read new project.config for %s plugin", pluginName);
throw new CommitValidationException("failed to read new project.config", e);
} catch (ConfigInvalidException e) {
logger.atSevere().withCause(e).log("unable to parse %s plugin config", pluginName);
if (trialConfig != null && trialConfig.scannerConfig != null) {
trialConfig.scannerConfig.messages.add(ScannerConfig.errorMessage(e.getMessage()));
return trialConfig.scannerConfig.messages;
} else {
throw new CommitValidationException("unable to parse new project.config", e);
}
} finally {
if (elapsedMicros < 0) {
elapsedMicros = (System.nanoTime() - readStart) / 1000;
metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
} else if (checkStart >= 0) {
long elapsedNanos = System.nanoTime() - checkStart;
metrics.checkConfigTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);
}
if (trialConfig != null
&& trialConfig.scannerConfig != null
&& trialConfig.scannerConfig.hasErrors()) {
StringBuilder sb = new StringBuilder();
sb.append("\nerror in ");
sb.append(pluginName);
sb.append(" plugin configuration");
trialConfig.scannerConfig.appendMessages(sb);
throw new CommitValidationException(sb.toString());
}
}
}
/** Warns on review thread about suspect plugin configurations. */
@Override
public void onRevisionCreated(RevisionCreatedListener.Event event) {
String project = event.getChange().project;
String branch = event.getChange().branch;
if (!branch.equals(RefNames.REFS_CONFIG)) {
return;
}
if (!project.equals(allProjectsName.get())) {
return;
}
if (!event.getRevision().files.keySet().contains(ProjectConfig.PROJECT_CONFIG)) {
return;
}
// passed onCommitReceived so expect at worst only warnings here
long readStart = System.nanoTime();
long checkStart = -1;
long elapsedMicros = -1;
CheckConfig trialConfig = null;
try {
trialConfig = readConfig(event.getChange().currentRevision);
elapsedMicros = (System.nanoTime() - readStart) / 1000;
metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
if (Objects.equals(trialConfig, checkConfig.scannerConfig)) {
return;
}
checkStart = System.nanoTime();
if (CheckConfig.hasScanner(trialConfig)
&& !CheckConfig.scannersEqual(trialConfig, checkConfig)) {
long maxElapsedSeconds =
gerritConfig == null
? DEFAULT_MAX_ELAPSED_SECONDS
: gerritConfig.getLong(
ScannerConfig.KEY_TIME_TEST_MAX, DEFAULT_MAX_ELAPSED_SECONDS);
if (maxElapsedSeconds > 0) {
String commitMessage = event.getRevision().commitWithFooters;
ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings =
trialConfig.checkCommitMessage(commitMessage);
ReviewResult result =
reviewApi.reportCommitMessageFindings(
pluginName,
allProjectsName.get(),
checkConfig == null ? null : checkConfig.scannerConfig,
trialConfig.scannerConfig,
event,
findings,
maxElapsedSeconds);
logReviewResultErrors(event, result);
}
}
boolean pluginEnabled =
gerritConfig != null && gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
CheckConfig.checkProjectConfig(reviewApi, pluginEnabled, trialConfig);
return;
} catch (RestApiException | ConfigInvalidException | IOException e) {
logger.atSevere().withCause(e).log("%s plugin unable to read new configuration", pluginName);
// throw IllegalStateException? RestApiException?
return;
} finally {
if (trialConfig != null
&& trialConfig.scannerConfig != null
&& !trialConfig.scannerConfig.messages.isEmpty()
&& !trialConfig.scannerConfig.hasErrors()) {
try {
ReviewResult result =
reviewApi.reportConfigMessages(
pluginName,
project,
ProjectConfig.PROJECT_CONFIG,
checkConfig.scannerConfig,
trialConfig.scannerConfig,
event);
logReviewResultErrors(event, result);
} catch (RestApiException e) {
logger.atSevere().withCause(e).log(
"%s plugin unable to read new configuration", pluginName);
// throw IllegalStateException? RestApiException?
return;
}
}
if (elapsedMicros < 0) {
elapsedMicros = (System.nanoTime() - readStart) / 1000;
metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
} else if (checkStart >= 0) {
long elapsedNanos = System.nanoTime() - checkStart;
metrics.checkConfigTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);
}
}
}
/** Returns true if copyright validation enabled for {@code project}. */
boolean isProjectEnabled(ScannerConfig scannerConfig, String project) {
// scan all projects when missing
if (!scannerConfig.matchProjects.isEmpty()
&& !ScannerConfig.matchesAny(project, scannerConfig.matchProjects)) {
// doesn't match == isn't checked
return false;
}
// exclude no projects when missing
if (!scannerConfig.excludeProjects.isEmpty()
&& ScannerConfig.matchesAny(project, scannerConfig.excludeProjects)) {
// does match == isn't checked
return false;
}
ProjectState projectState;
try {
projectState = projectCache.checkedGet(new Project.NameKey(project));
} catch (IOException e) {
logger.atSevere().withCause(e).log("error getting project state of %s", project);
// throw IllegalStateException? RestApiException?
return scannerConfig.defaultEnable;
}
if (projectState == null) {
logger.atSevere().log("error getting project state of %s", project);
// throw IllegalStateException? RestApiException?
return scannerConfig.defaultEnable;
}
ProjectConfig projectConfig = projectState.getConfig();
if (projectConfig == null) {
logger.atWarning().log("error getting project config of %s", project);
// throw IllegalStateException? RestApiException? return?
return scannerConfig.defaultEnable;
}
PluginConfig pluginConfig = projectConfig.getPluginConfig(pluginName);
if (pluginConfig == null) {
// no plugin config section in project so use default
return scannerConfig.defaultEnable;
}
return pluginConfig.getBoolean(ScannerConfig.KEY_ENABLE, scannerConfig.defaultEnable);
}
/**
* Loads and compiles configured patterns from {@code ref/meta/All-Projects/project.config} and
* {@code gerrit.config}.
*
* @param projectConfigObjectId identifies the version of project.config to load and to compile
* @return the new scanner configuration to check
* @throws IOException if accessing the repository fails
*/
private CheckConfig readConfig(String projectConfigObjectId)
throws IOException, ConfigInvalidException {
CheckConfig checkConfig = null;
// new All-Projects project.config not yet in cache -- read from repository
ObjectId id = ObjectId.fromString(projectConfigObjectId);
if (ObjectId.zeroId().equals(id)) {
return checkConfig;
}
try (Repository repo = repoManager.openRepository(allProjectsName)) {
checkConfig =
new CheckConfig(pluginName, readFileContents(repo, id, ProjectConfig.PROJECT_CONFIG));
}
gerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName, true);
if (gerritConfig == null) {
// throw IllegalStateException? RestApiException?
checkConfig.scannerConfig.messages.add(
ScannerConfig.hintMessage(
"missing [plugin \"" + pluginName + "\"] section in gerrit.config"));
} else {
checkConfig.scannerConfig.defaultEnable =
gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
}
return checkConfig;
}
/** Erases any prior configuration state. */
private void clearConfig() {
checkConfig = null;
}
private void logReviewResultErrors(RevisionCreatedListener.Event event, ReviewResult result) {
if (!Strings.isNullOrEmpty(result.error)) {
logger.atSevere().log(
"%s plugin revision %s: error posting review: %s",
pluginName, event.getChange().currentRevision, result.error);
}
for (Map.Entry<String, AddReviewerResult> entry : result.reviewers.entrySet()) {
AddReviewerResult arr = entry.getValue();
if (!Strings.isNullOrEmpty(arr.error)) {
logger.atSevere().log(
"%s plugin revision %s: error adding reviewer %s: %s",
pluginName, event.getChange().currentRevision, entry.getKey(), arr.error);
}
}
}
private String readFileContents(Repository repo, ObjectId objectId, String filename)
throws IOException {
RevWalk rw = new RevWalk(repo);
RevTree tree = rw.parseTree(objectId);
try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filename, tree)) {
ObjectLoader loader = repo.open(tw.getObjectId(0), Constants.OBJ_BLOB);
return new String(loader.getCachedBytes(), UTF_8);
}
}
}