package com.englishtown.bitbucket.hook; import com.atlassian.bitbucket.hook.repository.AsyncPostReceiveRepositoryHook; import com.atlassian.bitbucket.hook.repository.RepositoryHookContext; import com.atlassian.bitbucket.i18n.I18nService; import com.atlassian.bitbucket.repository.RefChange; import com.atlassian.bitbucket.repository.Repository; import com.atlassian.bitbucket.repository.RepositoryService; import com.atlassian.bitbucket.scm.CommandExitHandler; import com.atlassian.bitbucket.scm.DefaultCommandExitHandler; import com.atlassian.bitbucket.scm.ScmCommandBuilder; import com.atlassian.bitbucket.scm.ScmService; import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder; import com.atlassian.bitbucket.setting.RepositorySettingsValidator; import com.atlassian.bitbucket.setting.Settings; import com.atlassian.bitbucket.setting.SettingsValidationErrors; import com.atlassian.sal.api.pluginsettings.PluginSettings; import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import java.lang.reflect.Method; import java.net.URI; import java.net.URISyntaxException; import java.util.*; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, RepositorySettingsValidator { protected static class MirrorSettings { String mirrorRepoUrl; String username; String password; String suffix; } public static final String PLUGIN_SETTINGS_KEY = "com.englishtown.stash.hook.mirror"; static final String SETTING_MIRROR_REPO_URL = "mirrorRepoUrl"; static final String SETTING_USERNAME = "username"; static final String SETTING_PASSWORD = "password"; static final int MAX_ATTEMPTS = 5; private final ScmService scmService; private final I18nService i18nService; private final ScheduledExecutorService executor; private final PasswordEncryptor passwordEncryptor; private final SettingsReflectionHelper settingsReflectionHelper; private final RepositoryService repositoryService; private static final Logger logger = LoggerFactory.getLogger(MirrorRepositoryHook.class); public MirrorRepositoryHook( ScmService scmService, I18nService i18nService, ScheduledExecutorService executor, PasswordEncryptor passwordEncryptor, SettingsReflectionHelper settingsReflectionHelper, PluginSettingsFactory pluginSettingsFactory, RepositoryService repositoryService ) { logger.debug("MirrorRepositoryHook: init started"); // Set fields this.scmService = scmService; this.i18nService = i18nService; this.executor = executor; this.passwordEncryptor = passwordEncryptor; this.settingsReflectionHelper = settingsReflectionHelper; this.repositoryService = repositoryService; // Init password encryptor PluginSettings pluginSettings = pluginSettingsFactory.createSettingsForKey(PLUGIN_SETTINGS_KEY); passwordEncryptor.init(pluginSettings); logger.debug("MirrorRepositoryHook: init completed"); } /** * Calls the remote bitbucket instance(s) to push the latest changes * <p> * Callback method that is called just after a push is completed (or a pull request accepted). * This hook executes <i>after</i> the processing of a push and will not block the user client. * <p> * Despite being asynchronous, the user who initiated this change is still available from * * @param context the context which the hook is being run with * @param refChanges the refs that have just been updated */ @Override public void postReceive( @Nonnull RepositoryHookContext context, @Nonnull Collection<RefChange> refChanges) { logger.debug("MirrorRepositoryHook: postReceive started."); List<MirrorSettings> mirrorSettings = getMirrorSettings(context.getSettings()); for (MirrorSettings settings : mirrorSettings) { runMirrorCommand(settings, context.getRepository()); } } void runMirrorCommand(MirrorSettings settings, final Repository repository) { if (repositoryService.isEmpty(repository)) { return; } try { final String password = passwordEncryptor.decrypt(settings.password); final String authenticatedUrl = getAuthenticatedUrl(settings.mirrorRepoUrl, settings.username, password); executor.submit(new Runnable() { int attempts = 0; @Override public void run() { try { ScmCommandBuilder obj = scmService.createBuilder(repository); if (!(obj instanceof GitScmCommandBuilder)) { logger.warn("Repository " + repository.getName() + " is not a git repo, cannot mirror"); return; } GitScmCommandBuilder builder = (GitScmCommandBuilder) obj; PasswordHandler passwordHandler = getPasswordHandler(builder, password); // Call push command with the prune flag and refspecs for heads and tags // Do not use the mirror flag as pull-request refs are included String result = builder .command("push") .argument("--prune") // this deletes locally deleted branches .argument(authenticatedUrl) .argument("--force") // Canonical repository should always take precedence over mirror .argument("+refs/heads/*:refs/heads/*") // Only mirror heads .argument("+refs/tags/*:refs/tags/*") // and tags .errorHandler(passwordHandler) .exitHandler(passwordHandler) .build(passwordHandler) .call(); logger.debug("MirrorRepositoryHook: postReceive completed with result '{}'.", result); } catch (Exception e) { if (++attempts >= MAX_ATTEMPTS) { logger.error("Failed to mirror repository " + repository.getName() + " after " + attempts + " attempts.", e); } else { logger.warn("Failed to mirror repository " + repository.getName() + ", " + "retrying in 1 minute (attempt {} of {}).", attempts, MAX_ATTEMPTS); executor.schedule(this, 1, TimeUnit.MINUTES); } } } }); } catch (Exception e) { logger.error("MirrorRepositoryHook: Error running mirror hook", e); } } protected String getAuthenticatedUrl(String mirrorRepoUrl, String username, String password) throws URISyntaxException { // Only http(s) has username/password if (!mirrorRepoUrl.toLowerCase().startsWith("http")) { return mirrorRepoUrl; } URI uri = URI.create(mirrorRepoUrl); String userInfo = username + ":" + password; return new URI(uri.getScheme(), userInfo, uri.getHost(), uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment()).toString(); } /** * Validate the given {@code settings} before they are persisted. * * @param settings to be validated * @param errors callback for reporting validation errors. * @param repository the context {@code Repository} the settings will be associated with */ @Override public void validate( @Nonnull Settings settings, @Nonnull SettingsValidationErrors errors, @Nonnull Repository repository) { try { boolean ok = true; logger.debug("MirrorRepositoryHook: validate started."); List<MirrorSettings> mirrorSettings = getMirrorSettings(settings); for (MirrorSettings ms : mirrorSettings) { if (!validate(ms, settings, errors)) { ok = false; } } // If no errors, run the mirror command if (ok) { updateSettings(mirrorSettings, settings); for (MirrorSettings ms : mirrorSettings) { runMirrorCommand(ms, repository); } } } catch (Exception e) { logger.error("Error running MirrorRepositoryHook validate.", e); errors.addFormError(e.getMessage()); } } protected List<MirrorSettings> getMirrorSettings(Settings settings) { List<MirrorSettings> results = new ArrayList<>(); Map<String, Object> allSettings = settings.asMap(); int count = 0; for (String key : allSettings.keySet()) { if (key.startsWith(SETTING_MIRROR_REPO_URL)) { String suffix = key.substring(SETTING_MIRROR_REPO_URL.length()); MirrorSettings ms = new MirrorSettings(); ms.mirrorRepoUrl = settings.getString(SETTING_MIRROR_REPO_URL + suffix, ""); ms.username = settings.getString(SETTING_USERNAME + suffix, ""); ms.password = settings.getString(SETTING_PASSWORD + suffix, ""); ms.suffix = String.valueOf(count++); results.add(ms); } } return results; } protected boolean validate(MirrorSettings ms, Settings settings, SettingsValidationErrors errors) { boolean result = true; boolean isHttp = false; if (ms.mirrorRepoUrl.isEmpty()) { result = false; errors.addFieldError(SETTING_MIRROR_REPO_URL + ms.suffix, "The mirror repo url is required."); } else { try { URI uri = URI.create(ms.mirrorRepoUrl); String scheme = uri.getScheme().toLowerCase(); if (scheme.startsWith("http")) { isHttp = true; if (ms.mirrorRepoUrl.contains("@")) { result = false; errors.addFieldError(SETTING_MIRROR_REPO_URL + ms.suffix, "The username and password should not be included."); } } } catch (Exception ex) { // Not a valid url, assume it is something git can read } } // HTTP must have username and password if (isHttp) { if (ms.username.isEmpty()) { result = false; errors.addFieldError(SETTING_USERNAME + ms.suffix, "The username is required when using http(s)."); } if (ms.password.isEmpty()) { result = false; errors.addFieldError(SETTING_PASSWORD + ms.suffix, "The password is required when using http(s)."); } } else { // Only http should have username or password ms.password = ms.username = ""; } return result; } protected void updateSettings(List<MirrorSettings> mirrorSettings, Settings settings) { Map<String, Object> values = new HashMap<String, Object>(); // Store each mirror setting for (MirrorSettings ms : mirrorSettings) { values.put(SETTING_MIRROR_REPO_URL + ms.suffix, ms.mirrorRepoUrl); values.put(SETTING_USERNAME + ms.suffix, ms.username); values.put(SETTING_PASSWORD + ms.suffix, (ms.password.isEmpty() ? ms.password : passwordEncryptor.encrypt(ms.password))); } // Unfortunately the settings are stored in an immutable map, so need to cheat with reflection settingsReflectionHelper.set(values, settings); } protected PasswordHandler getPasswordHandler(GitScmCommandBuilder builder, String password) { try { Method method = builder.getClass().getDeclaredMethod("createExitHandler"); method.setAccessible(true); CommandExitHandler exitHandler = (CommandExitHandler) method.invoke(builder); return new PasswordHandler(password, exitHandler); } catch (Throwable t) { logger.warn("Unable to create exit handler", t); } return new PasswordHandler(password, new DefaultCommandExitHandler(i18nService)); } }