blob: a8848efda3a2a0e8ff8ca8c9a1315b029bd69054 [file] [log] [blame]
// Copyright (C) 2016 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.supermanifest;
import static com.google.gerrit.reviewdb.client.RefNames.REFS_HEADS;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.config.DownloadScheme;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.PluginConfigFactory;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.plugincontext.PluginMapContext;
import com.google.gerrit.server.project.BranchResource;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.Closeable;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidRemoteException;
import org.eclipse.jgit.api.errors.RefNotFoundException;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.errors.RevisionSyntaxException;
import org.eclipse.jgit.gitrepo.RepoCommand;
import org.eclipse.jgit.gitrepo.RepoCommand.RemoteFile;
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.treewalk.TreeWalk;
/**
* This plugin will listen for changes to XML files in manifest repositories. When it finds such
* changes, it will trigger an update of the associated superproject.
*/
@Singleton
public class SuperManifestRefUpdatedListener
implements GitReferenceUpdatedListener,
LifecycleListener,
RestModifyView<BranchResource, BranchInput> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final GitRepositoryManager repoManager;
private final URI canonicalWebUrl;
private final PluginConfigFactory cfgFactory;
private final String pluginName;
private final AllProjectsName allProjectsName;
private final ProjectCache projectCache;
private final Provider<PersonIdent> serverIdent;
private final Provider<IdentifiedUser> identifiedUser;
private final PermissionBackend permissionBackend;
private final PluginMapContext<DownloadScheme> downloadScheme;
// Mutable.
private Set<ConfigEntry> config;
@Inject
SuperManifestRefUpdatedListener(
AllProjectsName allProjectsName,
@CanonicalWebUrl String canonicalWebUrl,
@PluginName String pluginName,
PluginMapContext<DownloadScheme> downloadScheme,
PluginConfigFactory cfgFactory,
ProjectCache projectCache,
@GerritPersonIdent Provider<PersonIdent> serverIdent,
GitRepositoryManager repoManager,
Provider<IdentifiedUser> identifiedUser,
PermissionBackend permissionBackend) {
this.pluginName = pluginName;
this.serverIdent = serverIdent;
this.allProjectsName = allProjectsName;
this.repoManager = repoManager;
try {
this.canonicalWebUrl = new URI(canonicalWebUrl);
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
this.downloadScheme = downloadScheme;
this.cfgFactory = cfgFactory;
this.projectCache = projectCache;
this.identifiedUser = identifiedUser;
this.permissionBackend = permissionBackend;
}
private void warn(String formatStr, Object... args) {
logger.atWarning().log("%s: %s", canonicalWebUrl, String.format(formatStr, args));
}
private void error(String formatStr, Object... args) {
logger.atSevere().log("%s: %s", canonicalWebUrl, String.format(formatStr, args));
}
private void info(String formatStr, Object... args) {
logger.atInfo().log("%s: %s", canonicalWebUrl, String.format(formatStr, args));
}
/*
[superproject "submodules:refs/heads/nyc"]
srcRepo = platforms/manifest
srcRef = refs/heads/nyc
srcPath = manifest.xml
*/
private Set<ConfigEntry> parseConfiguration(PluginConfigFactory cfgFactory, String name)
throws NoSuchProjectException {
Config cfg = cfgFactory.getProjectPluginConfig(allProjectsName, name);
Set<ConfigEntry> newConf = new HashSet<>();
Set<String> destinations = new HashSet<>();
Set<String> wildcardDestinations = new HashSet<>();
Set<String> sources = new HashSet<>();
for (String sect : cfg.getSections()) {
if (!sect.equals(ConfigEntry.SECTION_NAME)) {
warn("%s.config: ignoring invalid section %s", name, sect);
}
}
for (String subsect : cfg.getSubsections(ConfigEntry.SECTION_NAME)) {
try {
ConfigEntry configEntry = new ConfigEntry(cfg, subsect);
if (destinations.contains(configEntry.srcRepoKey.get())
|| sources.contains(configEntry.destRepoKey.get())) {
// Don't want cyclic dependencies.
throw new ConfigInvalidException(
String.format("repo in entry %s cannot be both source and destination", configEntry));
}
if (configEntry.destBranch.equals("*")) {
if (wildcardDestinations.contains(configEntry.destRepoKey.get())) {
throw new ConfigInvalidException(
String.format(
"repo %s already has a wildcard destination branch.", configEntry.destRepoKey));
}
wildcardDestinations.add(configEntry.destRepoKey.get());
}
sources.add(configEntry.srcRepoKey.get());
destinations.add(configEntry.destRepoKey.get());
newConf.add(configEntry);
} catch (ConfigInvalidException e) {
error("invalid configuration: %s", e);
}
}
return newConf;
}
private boolean checkRepoExists(Project.NameKey id) {
return projectCache.get(id) != null;
}
@Override
public void stop() {}
@Override
public void start() {
try {
updateConfiguration();
} catch (NoSuchProjectException e) {
warn("can't read configuration: %s", e.getMessage());
}
}
/** for debugging. */
private String configurationToString() {
StringBuilder b = new StringBuilder();
b.append("Supermanifest config (").append(config.size()).append(") {\n");
for (ConfigEntry c : config) {
b.append(" ").append(c).append("\n");
}
b.append("}\n");
return b.toString();
}
private void updateConfiguration() throws NoSuchProjectException {
Set<ConfigEntry> entries = parseConfiguration(cfgFactory, pluginName);
Set<ConfigEntry> filtered = new HashSet<>();
for (ConfigEntry e : entries) {
if (!checkRepoExists(e.srcRepoKey)) {
error("source repo '%s' does not exist", e.srcRepoKey);
} else if (!checkRepoExists(e.destRepoKey)) {
error("destination repo '%s' does not exist", e.destRepoKey);
} else {
filtered.add(e);
}
}
config = filtered;
}
@Override
public synchronized void onGitReferenceUpdated(Event event) {
if (event.getProjectName().equals(allProjectsName.get())) {
if (event.getRefName().equals("refs/meta/config")) {
try {
updateConfiguration();
} catch (NoSuchProjectException e) {
throw new IllegalStateException(e);
}
}
return;
}
try {
update(event.getProjectName(), event.getRefName(), true);
} catch (Exception e) {
// no exceptions since we set continueOnError = true.
}
}
@Override
public Response<?> apply(BranchResource resource, BranchInput input)
throws IOException, ConfigInvalidException, GitAPIException, AuthException,
PermissionBackendException {
permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
info(
"manual trigger for %s:%s by %d. Config: %s",
resource.getBranchKey().getParentKey().get(),
resource.getBranchKey().get(),
identifiedUser.get().getAccountId().get(),
configurationToString());
update(resource.getProjectState().getProject().getName(), resource.getRef(), false);
return Response.none();
}
/**
* Updates projects in response to update in given project/ref. Only throws exceptions if
* continueOnError is false.
*/
private void update(String project, String refName, boolean continueOnError)
throws IOException, GitAPIException, ConfigInvalidException {
for (ConfigEntry c : config) {
if (!c.srcRepoKey.get().equals(project)) {
continue;
}
if (!(c.destBranch.equals("*") || c.srcRef.equals(refName))) {
continue;
}
if (c.destBranch.equals("*") && !refName.startsWith(REFS_HEADS)) {
continue;
}
try {
updateForConfig(c, refName);
} catch (ConfigInvalidException | IOException | GitAPIException e) {
if (!continueOnError) {
throw e;
}
// We only want the trace up to here. We could recurse into the exception, but this at least
// trims the very common jgit.gitrepo.RepoCommand.RemoteUnavailableException.
StackTraceElement here = Thread.currentThread().getStackTrace()[1];
e.setStackTrace(trimStack(e.getStackTrace(), here));
// We are in an asynchronously called listener, so there is no user action to give
// feedback to. We log the error, but it would be nice if we could surface these logs
// somewhere. Perhaps we could store these as commits in some special branch (but in
// what repo?).
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
error("update for %s (ref %s) failed: %s", c.toString(), refName, sw);
}
}
}
private void updateForConfig(ConfigEntry c, String refName)
throws ConfigInvalidException, IOException, GitAPIException {
SubModuleUpdater subModuleUpdater;
switch (c.getToolType()) {
case Repo:
subModuleUpdater = new RepoUpdater(serverIdent.get());
break;
case Jiri:
subModuleUpdater = new JiriUpdater(serverIdent.get(), canonicalWebUrl, downloadScheme);
break;
default:
throw new ConfigInvalidException(
String.format("invalid toolType: %s", c.getToolType().name()));
}
try (GerritRemoteReader reader = new GerritRemoteReader()) {
subModuleUpdater.update(reader, c, refName);
}
}
/**
* Remove boring stack frames. This retains the innermost frames up to and including the {@code
* class#method} passed in {@code ref}.
*/
@VisibleForTesting
static StackTraceElement[] trimStack(StackTraceElement[] trace, StackTraceElement ref) {
List<StackTraceElement> trimmed = new ArrayList<>();
for (StackTraceElement e : trace) {
trimmed.add(e);
if (e.getClassName().equals(ref.getClassName())
&& e.getMethodName().equals(ref.getMethodName())) {
break;
}
}
return trimmed.toArray(new StackTraceElement[trimmed.size()]);
}
// GerritRemoteReader is for injecting Gerrit's Git implementation into JGit.
class GerritRemoteReader implements RepoCommand.RemoteReader, Closeable {
private final Map<String, Repository> repos;
GerritRemoteReader() {
this.repos = new HashMap<>();
}
@Override
public ObjectId sha1(String uriStr, String refName) throws GitAPIException {
URI url;
try {
url = new URI(uriStr);
} catch (URISyntaxException e) {
// TODO(hanwen): is there a better exception for this?
throw new InvalidRemoteException(e.getMessage());
}
String repoName = url.getPath();
while (repoName.startsWith("/")) {
repoName = repoName.substring(1);
}
// This is a (mis)feature of JGit, which ignores SHA1s but only if ignoreRemoteFailures
// is set.
if (ObjectId.isId(refName)) {
return ObjectId.fromString(refName);
}
try {
Repository repo = openRepository(repoName);
Ref ref = repo.findRef(refName);
if (ref == null || ref.getObjectId() == null) {
warn("in repo %s: cannot resolve ref %s", uriStr, refName);
return null;
}
ref = repo.getRefDatabase().peel(ref);
ObjectId id = ref.getPeeledObjectId();
return id != null ? id : ref.getObjectId();
} catch (RepositoryNotFoundException e) {
warn("failed to open repository %s: %s", repoName, e);
return null;
} catch (IOException io) {
RefNotFoundException e =
new RefNotFoundException(String.format("cannot open %s to read %s", repoName, refName));
e.initCause(io);
throw e;
}
}
@Override
public RemoteFile readFileWithMode(String repoName, String ref, String path)
throws GitAPIException, IOException {
Repository repo = openRepository(repoName);
Ref r = repo.findRef(ref);
if (r == null || r.getObjectId() == null) {
throw new RevisionSyntaxException(
String.format("repo %s does not have ref %s", repo.toString(), ref), ref);
}
RevCommit commit = repo.parseCommit(r.getObjectId());
TreeWalk tw = TreeWalk.forPath(repo, path, commit.getTree());
return new RemoteFile(
tw.getObjectReader().open(tw.getObjectId(0)).getCachedBytes(Integer.MAX_VALUE),
tw.getFileMode(0));
}
public Repository openRepository(String name) throws IOException {
name = urlToRepoKey(canonicalWebUrl, name);
if (repos.containsKey(name)) {
return repos.get(name);
}
Repository repo = repoManager.openRepository(new Project.NameKey(name));
repos.put(name, repo);
return repo;
}
@Override
public void close() {
for (Repository repo : repos.values()) {
repo.close();
}
repos.clear();
}
}
@VisibleForTesting
static String urlToRepoKey(URI baseUri, String name) {
if (name.startsWith(baseUri.toString())) {
// It would be nice to parse the URL and do relativize on the Path, but
// I am lazy, and nio.Path considers the file system and symlinks.
name = name.substring(baseUri.toString().length());
while (name.startsWith("/")) {
name = name.substring(1);
}
}
return name;
}
}