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

@ -1330,6 +1330,15 @@ Optional filename for the reviewer added hook, if not specified then
Optional filename for the CLA signed hook, if not specified then
`cla-signed` will be used.
[[hooks.refUpdateHook]]hooks.refUpdateHook::
+
Optional filename for the ref update hook, if not specified then
`ref-update` will be used.
[[hooks.syncHookTimeout]]hooks.syncHookTimeout::
+ Optional timeout value in seconds for synchronous hooks, if not specified
then 30 seconds will be used.
[[http]]Section http
~~~~~~~~~~~~~~~~~~~~

View File

@ -11,15 +11,33 @@ affected git repository so that git commands can be easily run.
Make sure your hook scripts are executable if running on *nix.
Hooks are run in the background after the relevant change has
taken place so are unable to affect the outcome of any given
change. Because of the fact the hooks are run in the background
after the activity, a hook might not be notified about an event if
the server is shutdown before the hook can be invoked.
With the exception of the ref-update hook, hooks are run in the background
after the relevant change has taken place so are unable to affect
the outcome of any given change. Because of the fact the hooks are
run in the background after the activity, a hook might not be notified
about an event if the server is shutdown before the hook can be invoked.
Supported Hooks
---------------
ref-update
~~~~~~~~~~
This is called when a push request is received by Gerrit. It allows
a push to be rejected before it is committed to the Gerrit repository.
If the script exits with non-zero return code the push will be rejected.
Any output from the script will be returned to the user, regardless of the
return code.
This hook is called synchronously so it is recommended that
it not block. A default timeout on the hook is set to 30 seconds to avoid
"runaway" hooks using up server threads. See link:config-gerrit.html#hooks.syncHookTimeout[hooks.syncHookTimeout]
for configuration details.
====
ref-update --project <project name> --refname <refname> --uploader <uploader> --oldrev <sha1> --newrev <sha1>
====
patchset-created
~~~~~~~~~~~~~~~~
@ -123,7 +141,7 @@ Gerrit will use the value of hooks.path for the hooks directory.
For the hook filenames, Gerrit will use the values of hooks.patchsetCreatedHook,
hooks.draftPublishedHook, hooks.commentAddedHook, hooks.changeMergedHook,
hooks.changeAbandonedHook, hooks.changeRestoredHook, hooks.refUpdatedHook,
hooks.reviewerAddedHook and hooks.claSignedHook.
hooks.refUpdateHook, hooks.reviewerAddedHook and hooks.claSignedHook.
Missing Change URLs
-------------------

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;
}
}

View File

@ -35,6 +35,7 @@ import com.google.common.util.concurrent.CheckedFuture;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.gerrit.common.ChangeHookRunner.HookResult;
import com.google.gerrit.common.ChangeHooks;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.common.data.Capable;
@ -760,6 +761,19 @@ public class ReceiveCommits {
continue;
}
HookResult result = hooks.doRefUpdateHook(project, cmd.getRefName(),
currentUser.getAccount(), cmd.getOldId(),
cmd.getNewId());
if (result != null) {
final String message = result.toString().trim();
if (result.getExitValue() != 0) {
reject(cmd, message);
continue;
}
rp.sendMessage(message);
}
if (MagicBranch.isMagicBranch(cmd.getRefName())) {
parseNewChangeCommand(cmd);
continue;