Add the ability to run a ref update hook

Allow the user to provide an update (similar to git's update hook) hook
run before a push is accepted by Gerrit. This allows the exclusion of
certain commits or any other update checks.

The hook takes the following form:

ref-update --project <project name> --refname <refname> --uploader <uploader>
--oldrev <sha1> --newrev <sha1>

If the script exits with non zero return code the push will be rejected.
The output of the script will be returned to the user as the reason for
the rejection.

This hook is called synchronously so shouldn't block or wait. A timeout
on the hook is set to 30 seconds to avoid "runaway" hooks using up server
threads. This value can be configured using "syncHookTimeout" in the
"hooks" stanza. It is an integer value in seconds.

Change-Id: Ibed5dc5c18e59db465511520f76fac93acc561e0
This commit is contained in:
Chris Harris
2012-11-21 09:35:56 -05:00
parent 327048b612
commit f736d6cd9f
6 changed files with 304 additions and 24 deletions

View File

@@ -17,6 +17,8 @@ package com.google.gerrit.common;
import com.google.gerrit.common.data.ApprovalType;
import com.google.gerrit.common.data.ApprovalTypes;
import com.google.gerrit.common.data.ContributorAgreement;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.lifecycle.LifecycleModule;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.ApprovalCategory;
import com.google.gerrit.reviewdb.client.ApprovalCategoryValue;
@@ -49,7 +51,6 @@ import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectControl;
import com.google.gerrit.server.project.ProjectState;
import com.google.gwtorm.server.OrmException;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -63,24 +64,34 @@ import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** Spawns local executables when a hook action occurs. */
@Singleton
public class ChangeHookRunner implements ChangeHooks {
public class ChangeHookRunner implements ChangeHooks, LifecycleListener {
/** A logger for this class. */
private static final Logger log = LoggerFactory.getLogger(ChangeHookRunner.class);
public static class Module extends AbstractModule {
public static class Module extends LifecycleModule {
@Override
protected void configure() {
bind(ChangeHookRunner.class);
bind(ChangeHooks.class).to(ChangeHookRunner.class);
listener().to(ChangeHookRunner.class);
}
}
@@ -94,6 +105,53 @@ public class ChangeHookRunner implements ChangeHooks {
}
}
/** Container class used to hold the return code and output of script hook execution */
public static class HookResult {
private int exitValue = -1;
private String output;
private String executionError;
private HookResult(int exitValue, String output) {
this.exitValue = exitValue;
this.output = output;
}
private HookResult(String output, String executionError) {
this.output = output;
this.executionError = executionError;
}
public int getExitValue() {
return exitValue;
}
public void setExitValue(int exitValue) {
this.exitValue = exitValue;
}
public String getOutput() {
return output;
}
public String toString() {
StringBuilder sb = new StringBuilder();
if (output != null && output.length() != 0) {
sb.append(output);
if (executionError != null) {
sb.append(" - ");
}
}
if (executionError != null ) {
sb.append(executionError);
}
return sb.toString();
}
}
/** Listeners to receive changes as they happen. */
private final Map<ChangeListener, ChangeListenerHolder> listeners =
new ConcurrentHashMap<ChangeListener, ChangeListenerHolder>();
@@ -128,6 +186,9 @@ public class ChangeHookRunner implements ChangeHooks {
/** Filename of the cla signed hook. */
private final File claSignedHook;
/** Filename of the update hook. */
private final File refUpdateHook;
private final String anonymousCowardName;
/** Repository Manager. */
@@ -146,6 +207,12 @@ public class ChangeHookRunner implements ChangeHooks {
private final SitePaths sitePaths;
/** Thread pool used to monitor sync hooks */
private final ExecutorService syncHookThreadPool = Executors.newCachedThreadPool();
/** Timeout value for synchronous hooks */
private final int syncHookTimeout;
/**
* Create a new ChangeHookRunner.
*
@@ -184,6 +251,8 @@ public class ChangeHookRunner implements ChangeHooks {
refUpdatedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "refUpdatedHook", "ref-updated")).getPath());
reviewerAddedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "reviewerAddedHook", "reviewer-added")).getPath());
claSignedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "claSignedHook", "cla-signed")).getPath());
refUpdateHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "refUpdateHook", "ref-update")).getPath());
syncHookTimeout = config.getInt("hooks", "syncHookTimeout", 30);
}
public void addChangeListener(ChangeListener listener, IdentifiedUser user) {
@@ -230,6 +299,38 @@ public class ChangeHookRunner implements ChangeHooks {
}
}
/**
* Fire the update hook
*
*/
public HookResult doRefUpdateHook(final Project project, final String refname,
final Account uploader, final ObjectId oldId, final ObjectId newId) {
final List<String> args = new ArrayList<String>();
addArg(args, "--project", project.getName());
addArg(args, "--refname", refname);
addArg(args, "--uploader", getDisplayName(uploader));
addArg(args, "--oldrev", oldId.getName());
addArg(args, "--newrev", newId.getName());
HookResult hookResult;
try {
hookResult = runSyncHook(project.getNameKey(), refUpdateHook, args);
} catch (TimeoutException e) {
hookResult = new HookResult(-1, "Synchronous hook timed out");
}
return hookResult;
}
/**
* Fire the Patchset Created Hook.
*
* @param change The change itself.
* @param patchSet The Patchset that was created.
* @throws OrmException
*/
public void doPatchsetCreatedHook(final Change change, final PatchSet patchSet,
final ReviewDb db) throws OrmException {
final PatchSetCreatedEvent event = new PatchSetCreatedEvent();
@@ -534,31 +635,84 @@ public class ChangeHookRunner implements ChangeHooks {
private synchronized void runHook(Project.NameKey project, File hook,
List<String> args) {
if (project != null && hook.exists()) {
hookQueue.execute(new HookTask(project, hook, args));
hookQueue.execute(new AsyncHookTask(project, hook, args));
}
}
private synchronized void runHook(File hook, List<String> args) {
if (hook.exists()) {
hookQueue.execute(new HookTask(null, hook, args));
hookQueue.execute(new AsyncHookTask(null, hook, args));
}
}
private final class HookTask implements Runnable {
private HookResult runSyncHook(Project.NameKey project,
File hook, List<String> args) throws TimeoutException {
if (!hook.exists()) {
return null;
}
SyncHookTask syncHook = new SyncHookTask(project, hook, args);
FutureTask<HookResult> task = new FutureTask<HookResult>(syncHook);
syncHookThreadPool.execute(task);
String message;
try {
return task.get(syncHookTimeout, TimeUnit.SECONDS);
} catch (TimeoutException e) {
message = "Synchronous hook timed out " + hook.getAbsolutePath();
log.error(message);
} catch (Exception e) {
message = "Error running hook " + hook.getAbsolutePath();
log.error(message, e);
}
task.cancel(true);
syncHook.cancel();
return new HookResult(syncHook.getOutput(), message);
}
@Override
public void start() {
}
@Override
public void stop() {
syncHookThreadPool.shutdown();
boolean isTerminated;
do {
try {
isTerminated = syncHookThreadPool.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException ie) {
isTerminated = false;
}
} while (!isTerminated);
}
private class HookTask {
private final Project.NameKey project;
private final File hook;
private final List<String> args;
private StringWriter output;
private Process ps;
private HookTask(Project.NameKey project, File hook, List<String> args) {
protected HookTask(Project.NameKey project, File hook, List<String> args) {
this.project = project;
this.hook = hook;
this.args = args;
}
@Override
public void run() {
public String getOutput() {
return output != null ? output.toString() : null;
}
protected HookResult runHook() {
Repository repo = null;
HookResult result = null;
try {
final List<String> argv = new ArrayList<String>(1 + args.size());
argv.add(hook.getAbsolutePath());
argv.addAll(args);
@@ -579,23 +733,22 @@ public class ChangeHookRunner implements ChangeHooks {
env.put("GIT_DIR", repo.getDirectory().getAbsolutePath());
}
Process ps = pb.start();
ps = pb.start();
ps.getOutputStream().close();
BufferedReader br =
new BufferedReader(new InputStreamReader(ps.getInputStream()));
InputStream is = ps.getInputStream();
String output = null;
try {
String line;
while ((line = br.readLine()) != null) {
log.info("hook[" + hook.getName() + "] output: " + line);
}
output = readOutput(is);
} finally {
try {
br.close();
is.close();
} catch (IOException closeErr) {
}
ps.waitFor();
result = new HookResult(ps.exitValue(), output);
}
} catch (InterruptedException iex) {
// InterruptedExeception - timeout or cancel
} catch (Throwable err) {
log.error("Error running hook " + hook.getAbsolutePath(), err);
} finally {
@@ -603,11 +756,74 @@ public class ChangeHookRunner implements ChangeHooks {
repo.close();
}
}
log.info("hook[" + getName() + "] exitValue:" + result.getExitValue());
BufferedReader br =
new BufferedReader(new StringReader(result.getOutput()));
try {
String line;
while ((line = br.readLine()) != null) {
log.info("hook[" + getName() + "] output: " + line);
}
}
catch(IOException iox) {
log.error("Error writing hook output", iox);
}
return result;
}
private String readOutput(InputStream is) throws IOException {
output = new StringWriter();
InputStreamReader input = new InputStreamReader(is);
char[] buffer = new char[4096];
int n = 0;
while ((n = input.read(buffer)) != -1) {
output.write(buffer, 0, n);
}
return output.toString();
}
protected String getName() {
return hook.getName();
}
@Override
public String toString() {
return "hook " + hook.getName();
}
public void cancel() {
ps.destroy();
}
}
/** Callable type used to run synchronous hooks */
private final class SyncHookTask extends HookTask
implements Callable<HookResult> {
private SyncHookTask(Project.NameKey project, File hook, List<String> args) {
super(project, hook, args);
}
@Override
public HookResult call() throws Exception {
return super.runHook();
}
}
/** Runable type used to run async hooks */
private final class AsyncHookTask extends HookTask implements Runnable {
private AsyncHookTask(Project.NameKey project, File hook, List<String> args) {
super(project, hook, args);
}
@Override
public void run() {
super.runHook();
}
}
}

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.common;
import com.google.gerrit.common.ChangeHookRunner.HookResult;
import com.google.gerrit.common.data.ContributorAgreement;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.ApprovalCategory;
@@ -22,6 +23,7 @@ import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gwtorm.server.OrmException;
@@ -148,4 +150,17 @@ public interface ChangeHooks {
PatchSet patchSet, ReviewDb db) throws OrmException;
public void doClaSignupHook(Account account, ContributorAgreement cla);
/**
* Fire the Ref update Hook
*
* @param project The target project
* @param refName The Branch.NameKey of the ref provided by client
* @param uploader The gerrit user running the command
* @param oldId The ref's old id
* @param newId The ref's new id
* @param account The gerrit user who moved the ref
*/
public HookResult doRefUpdateHook(Project project, String refName,
Account uploader, ObjectId oldId, ObjectId newId);
}

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.common;
import com.google.gerrit.common.ChangeHookRunner.HookResult;
import com.google.gerrit.common.data.ContributorAgreement;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.ApprovalCategory;
@@ -21,6 +22,7 @@ import com.google.gerrit.reviewdb.client.ApprovalCategoryValue;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Branch.NameKey;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.IdentifiedUser;
@@ -93,4 +95,10 @@ public final class DisabledChangeHooks implements ChangeHooks {
@Override
public void removeChangeListener(ChangeListener listener) {
}
@Override
public HookResult doRefUpdateHook(Project project, String refName,
Account uploader, ObjectId oldId, ObjectId newId) {
return null;
}
}