package org.jenkinsci.plugins.github.webhook;
import com.cloudbees.jenkins.GitHubRepositoryName;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import hudson.model.Item;
import hudson.model.Job;
import hudson.util.Secret;
import org.apache.commons.lang.Validate;
import org.jenkinsci.plugins.github.GitHubPlugin;
import org.jenkinsci.plugins.github.admin.GitHubHookRegisterProblemMonitor;
import org.jenkinsci.plugins.github.extension.GHEventsSubscriber;
import org.jenkinsci.plugins.github.util.misc.NullSafeFunction;
import org.jenkinsci.plugins.github.util.misc.NullSafePredicate;
import org.kohsuke.github.GHEvent;
import org.kohsuke.github.GHException;
import org.kohsuke.github.GHHook;
import org.kohsuke.github.GHRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.net.URL;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import static com.cloudbees.jenkins.GitHubRepositoryNameContributor.parseAssociatedNames;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Predicates.notNull;
import static com.google.common.base.Predicates.or;
import static java.lang.String.format;
import static org.apache.commons.collections.CollectionUtils.isEqualCollection;
import static org.jenkinsci.plugins.github.config.GitHubServerConfig.allowedToManageHooks;
import static org.jenkinsci.plugins.github.extension.GHEventsSubscriber.extractEvents;
import static org.jenkinsci.plugins.github.extension.GHEventsSubscriber.isApplicableFor;
import static org.jenkinsci.plugins.github.util.FluentIterableWrapper.from;
/**
* Class to incapsulate manipulation with webhooks on GH
* Each manager works with only one hook url (created with {@link #forHookUrl(URL)})
*
* @author lanwen (Merkushev Kirill)
* @since 1.12.0
*/
public class WebhookManager {
private static final Logger LOGGER = LoggerFactory.getLogger(WebhookManager.class);
private final URL endpoint;
/**
* Use {@link #forHookUrl(URL)} to create new one
*
* @param endpoint url which will be created as hook on GH
*/
protected WebhookManager(URL endpoint) {
this.endpoint = endpoint;
}
/**
* @see #WebhookManager(URL)
*/
public static WebhookManager forHookUrl(URL endpoint) {
return new WebhookManager(endpoint);
}
/**
* Creates runnable with ability to create hooks for given project
* For each GH repo name contributed by {@link com.cloudbees.jenkins.GitHubRepositoryNameContributor},
* this runnable creates hook (with clean old one).
*
* Hook events job interested in, contributes to full set instances of {@link GHEventsSubscriber}.
* New events will be merged with old ones from existent hook.
*
* By default only push event is registered
*
* @param project to find for which repos we should create hooks
*
* @return runnable to create hooks on run
* @see #createHookSubscribedTo(List)
* @deprecated use {@link #registerFor(Item)}
*/
@Deprecated
public Runnable registerFor(final Job<?, ?> project) {
return registerFor((Item) project);
}
/**
* Creates runnable with ability to create hooks for given project
* For each GH repo name contributed by {@link com.cloudbees.jenkins.GitHubRepositoryNameContributor},
* this runnable creates hook (with clean old one).
*
* Hook events job interested in, contributes to full set instances of {@link GHEventsSubscriber}.
* New events will be merged with old ones from existent hook.
*
* By default only push event is registered
*
* @param item to find for which repos we should create hooks
*
* @return runnable to create hooks on run
* @see #createHookSubscribedTo(List)
* @since 1.25.0
*/
public Runnable registerFor(final Item item) {
final Collection<GitHubRepositoryName> names = parseAssociatedNames(item);
final List<GHEvent> events = from(GHEventsSubscriber.all())
.filter(isApplicableFor(item))
.transformAndConcat(extractEvents()).toList();
return new Runnable() {
public void run() {
if (events.isEmpty()) {
LOGGER.debug("No any subscriber interested in {}, but hooks creation launched, skipping...",
item.getFullName());
return;
}
LOGGER.info("GitHub webhooks activated for job {} with {} (events: {})",
item.getFullName(), names, events);
from(names)
.transform(createHookSubscribedTo(events))
.filter(notNull())
.filter(log("Created hook")).toList();
}
};
}
/**
* Used to cleanup old hooks in case of removed or reconfigured trigger
* since JENKINS-28138 this method permanently removes service hooks
*
* So if the trigger for given name was only reconfigured, this method filters only service hooks
* (with help of aliveRepos names list), otherwise this method removes all hooks for managed url
*
* @param name repository to clean hooks
* @param aliveRepos repository list which has enabled trigger in jobs
*/
public void unregisterFor(GitHubRepositoryName name, List<GitHubRepositoryName> aliveRepos) {
try {
GHRepository repo = checkNotNull(
from(name.resolve(allowedToManageHooks())).firstMatch(withAdminAccess()).orNull(),
"There is no credentials with admin access to manage hooks on %s", name
);
LOGGER.debug("Check {} for redundant hooks...", repo);
Predicate<GHHook> predicate = aliveRepos.contains(name)
? serviceWebhookFor(endpoint) // permanently clear service hooks (JENKINS-28138)
: or(serviceWebhookFor(endpoint), webhookFor(endpoint));
from(fetchHooks().apply(repo))
.filter(predicate)
.filter(deleteWebhook())
.filter(log("Deleted hook")).toList();
} catch (Throwable t) {
LOGGER.warn("Failed to remove hook from {}", name, t);
GitHubHookRegisterProblemMonitor.get().registerProblem(name, t);
}
}
/**
* Main logic of {@link #registerFor(Item)}.
* Updates hooks with replacing old ones with merged new ones
*
* @param events calculated events list to be registered in hook
*
* @return function to register hooks for given events
*/
protected Function<GitHubRepositoryName, GHHook> createHookSubscribedTo(final List<GHEvent> events) {
return new NullSafeFunction<GitHubRepositoryName, GHHook>() {
@Override
protected GHHook applyNullSafe(@Nonnull GitHubRepositoryName name) {
try {
GHRepository repo = checkNotNull(
from(name.resolve(allowedToManageHooks())).firstMatch(withAdminAccess()).orNull(),
"There is no credentials with admin access to manage hooks on %s", name
);
Validate.notEmpty(events, "Events list for hook can't be empty");
Set<GHHook> hooks = from(fetchHooks().apply(repo))
.filter(webhookFor(endpoint))
.toSet();
Set<GHEvent> alreadyRegistered = from(hooks)
.transformAndConcat(eventsFromHook()).toSet();
if (hooks.size() == 1 && isEqualCollection(alreadyRegistered, events)) {
LOGGER.debug("Hook already registered for events {}", events);
return null;
}
Set<GHEvent> merged = from(alreadyRegistered).append(events).toSet();
from(hooks)
.filter(deleteWebhook())
.filter(log("Replaced hook")).toList();
return createWebhook(endpoint, merged).apply(repo);
} catch (Exception e) {
LOGGER.warn("Failed to add GitHub webhook for {}", name, e);
GitHubHookRegisterProblemMonitor.get().registerProblem(name, e);
}
return null;
}
};
}
/**
* Mostly debug method. Logs hook manipulation result
*
* @param format prepended comment for log
*
* @return always true predicate
*/
protected Predicate<GHHook> log(final String format) {
return new NullSafePredicate<GHHook>() {
@Override
protected boolean applyNullSafe(@Nonnull GHHook input) {
LOGGER.debug(format("%s {} (events: {})", format), input.getUrl(), input.getEvents());
return true;
}
};
}
/**
* Filters repos with admin rights (to manage hooks)
*
* @return true if we have admin rights for repo
*/
protected Predicate<GHRepository> withAdminAccess() {
return new NullSafePredicate<GHRepository>() {
@Override
protected boolean applyNullSafe(@Nonnull GHRepository repo) {
return repo.hasAdminAccess();
}
};
}
/**
* Finds "Jenkins (GitHub)" service webhook
*
* @param url jenkins endpoint url
*
* @return true if hook is service hook
*/
protected Predicate<GHHook> serviceWebhookFor(final URL url) {
return new NullSafePredicate<GHHook>() {
protected boolean applyNullSafe(@Nonnull GHHook hook) {
return hook.getName().equals("jenkins")
&& hook.getConfig().get("jenkins_hook_url").equals(url.toExternalForm());
}
};
}
/**
* Finds hook with endpoint url
*
* @param url jenkins endpoint url
*
* @return true if hook is standard webhook
*/
protected Predicate<GHHook> webhookFor(final URL url) {
return new NullSafePredicate<GHHook>() {
protected boolean applyNullSafe(@Nonnull GHHook hook) {
return hook.getName().equals("web")
&& hook.getConfig().get("url").equals(url.toExternalForm());
}
};
}
/**
* @return converter to extract events from each hook
*/
protected Function<GHHook, Iterable<GHEvent>> eventsFromHook() {
return new NullSafeFunction<GHHook, Iterable<GHEvent>>() {
@Override
protected Iterable<GHEvent> applyNullSafe(@Nonnull GHHook input) {
return input.getEvents();
}
};
}
/*
* ACTIONS
*/
/**
* @return converter to fetch from GH hooks list for each repo
*/
protected Function<GHRepository, List<GHHook>> fetchHooks() {
return new NullSafeFunction<GHRepository, List<GHHook>>() {
@Override
protected List<GHHook> applyNullSafe(@Nonnull GHRepository repo) {
try {
return repo.getHooks();
} catch (IOException e) {
throw new GHException("Failed to fetch post-commit hooks", e);
}
}
};
}
/**
* @param url jenkins endpoint url
* @param events list of GH events jenkins interested in
*
* @return converter to create GH hook for given url with given events
*/
protected Function<GHRepository, GHHook> createWebhook(final URL url, final Set<GHEvent> events) {
return new NullSafeFunction<GHRepository, GHHook>() {
protected GHHook applyNullSafe(@Nonnull GHRepository repo) {
try {
final HashMap<String, String> config = new HashMap<>();
config.put("url", url.toExternalForm());
config.put("content_type", "json");
final Secret secret = GitHubPlugin.configuration().getHookSecretConfig().getHookSecret();
if (secret != null) {
config.put("secret", secret.getPlainText());
}
return repo.createHook("web", config, events, true);
} catch (IOException e) {
throw new GHException("Failed to create hook", e);
}
}
};
}
/**
* @return annihilator for hook, returns true if deletion was successful
*/
protected Predicate<GHHook> deleteWebhook() {
return new NullSafePredicate<GHHook>() {
protected boolean applyNullSafe(@Nonnull GHHook hook) {
try {
hook.delete();
return true;
} catch (IOException e) {
throw new GHException("Failed to delete post-commit hook", e);
}
}
};
}
}