package com.englishtown.bitbucket.hook; import com.atlassian.bitbucket.concurrent.BucketedExecutor; import com.atlassian.bitbucket.concurrent.BucketedExecutorSettings; import com.atlassian.bitbucket.concurrent.ConcurrencyPolicy; import com.atlassian.bitbucket.concurrent.ConcurrencyService; import com.atlassian.bitbucket.hook.repository.*; import com.atlassian.bitbucket.repository.Repository; import com.atlassian.bitbucket.scm.git.GitScm; import com.atlassian.bitbucket.scope.RepositoryScope; import com.atlassian.bitbucket.scope.Scope; import com.atlassian.bitbucket.scope.ScopeVisitor; import com.atlassian.bitbucket.server.ApplicationPropertiesService; import com.atlassian.bitbucket.setting.Settings; import com.atlassian.bitbucket.setting.SettingsValidationErrors; import com.atlassian.bitbucket.setting.SettingsValidator; import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import java.net.URI; import java.util.*; import java.util.concurrent.TimeUnit; public class MirrorRepositoryHook implements PostRepositoryHook<RepositoryHookRequest>, SettingsValidator { static final String PROP_PREFIX = "plugin.com.englishtown.stash-hook-mirror.push."; static final String PROP_ATTEMPTS = PROP_PREFIX + "attempts"; static final String PROP_THREADS = PROP_PREFIX + "threads"; static final String SETTING_MIRROR_REPO_URL = "mirrorRepoUrl"; static final String SETTING_USERNAME = "username"; static final String SETTING_PASSWORD = "password"; static final String SETTING_REFSPEC = "refspec"; static final String SETTING_TAGS = "tags"; static final String SETTING_NOTES = "notes"; static final String SETTING_ATOMIC = "atomic"; /** * Trigger types that don't cause a mirror to happen */ private static Set<RepositoryHookTrigger> TRIGGERS_TO_IGNORE = ImmutableSet.of( StandardRepositoryHookTrigger.UNKNOWN ); private final PasswordEncryptor passwordEncryptor; private final SettingsReflectionHelper settingsReflectionHelper; private final BucketedExecutor<MirrorRequest> pushExecutor; private static final Logger logger = LoggerFactory.getLogger(MirrorRepositoryHook.class); public MirrorRepositoryHook(ConcurrencyService concurrencyService, PasswordEncryptor passwordEncryptor, ApplicationPropertiesService propertiesService, MirrorBucketProcessor pushProcessor, SettingsReflectionHelper settingsReflectionHelper) { logger.debug("MirrorRepositoryHook: init started"); this.passwordEncryptor = passwordEncryptor; this.settingsReflectionHelper = settingsReflectionHelper; int attempts = propertiesService.getPluginProperty(PROP_ATTEMPTS, 5); int threads = propertiesService.getPluginProperty(PROP_THREADS, 3); pushExecutor = concurrencyService.getBucketedExecutor(getClass().getSimpleName(), new BucketedExecutorSettings.Builder<>(MirrorRequest::toString, pushProcessor) .batchSize(Integer.MAX_VALUE) // Coalesce all requests into a single push .maxAttempts(attempts) .maxConcurrency(threads, ConcurrencyPolicy.PER_NODE) .build()); logger.debug("MirrorRepositoryHook: init completed"); } /** * Schedules pushes to apply the latest changes to any configured mirrors. * * @param context provides hook settings and a way to obtain the commits added/removed * @param request provides details about the refs that have been updated */ @Override public void postUpdate(@Nonnull PostRepositoryHookContext context, @Nonnull RepositoryHookRequest request) { if (TRIGGERS_TO_IGNORE.contains(request.getTrigger())) { logger.trace("MirrorRepositoryHook: skipping trigger {}", request.getTrigger()); return; } Repository repository = request.getRepository(); if (!GitScm.ID.equalsIgnoreCase(repository.getScmId())) { return; } List<MirrorSettings> mirrorSettings = getMirrorSettings(context.getSettings()); if (mirrorSettings.isEmpty()) { logger.debug("{}: Mirroring is not configured", repository); } else { logger.debug("{}: Scheduling pushes for {} remote(s) after {}", repository, mirrorSettings.size(), request.getTrigger()); schedulePushes(repository, mirrorSettings); } } /** * Validate the given {@code settings} before they are persisted., and encrypts any user-supplied password. * * @param settings to be validated * @param errors callback for reporting validation errors. * @param scope the context {@code Repository} the settings will be associated with */ @Override public void validate(@Nonnull Settings settings, @Nonnull SettingsValidationErrors errors, @Nonnull Scope scope) { Repository repository = scope.accept(new ScopeVisitor<Repository>() { @Override public Repository visit(@Nonnull RepositoryScope scope) { return scope.getRepository(); } }); if (repository == null) { return; } try { boolean ok = true; logger.debug("MirrorRepositoryHook: validate started."); List<MirrorSettings> mirrorSettings = getMirrorSettings(settings, false, false, false); for (MirrorSettings ms : mirrorSettings) { if (!validate(ms, errors)) { ok = false; } } // If no errors, run the mirror command if (ok) { updateSettings(mirrorSettings, settings); schedulePushes(repository, mirrorSettings); } } catch (Exception e) { logger.error("Error running MirrorRepositoryHook validate.", e); errors.addFormError(e.getMessage()); } } private List<MirrorSettings> getMirrorSettings(Settings settings) { return getMirrorSettings(settings, true, true, true); } private List<MirrorSettings> getMirrorSettings(Settings settings, boolean defTags, boolean defNotes, boolean defAtomic) { Map<String, Object> allSettings = settings.asMap(); int count = 0; List<MirrorSettings> results = new ArrayList<>(); 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.refspec = (settings.getString(SETTING_REFSPEC + suffix, "")); ms.tags = (settings.getBoolean(SETTING_TAGS + suffix, defTags)); ms.notes = (settings.getBoolean(SETTING_NOTES + suffix, defNotes)); ms.atomic = (settings.getBoolean(SETTING_ATOMIC + suffix, defAtomic)); ms.suffix = String.valueOf(count++); results.add(ms); } } return results; } private void schedulePushes(Repository repository, List<MirrorSettings> list) { list.forEach(settings -> pushExecutor.schedule(new MirrorRequest(repository, settings), 5L, TimeUnit.SECONDS)); } private boolean validate(MirrorSettings ms, 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 = ""; } if (!ms.refspec.isEmpty()) { if (!ms.refspec.contains(":")) { result = false; errors.addFieldError(SETTING_REFSPEC + ms.suffix, "A refspec should be in the form <src>:<dest>."); } } return result; } private void updateSettings(List<MirrorSettings> mirrorSettings, Settings settings) { Map<String, Object> values = new HashMap<>(); 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))); values.put(SETTING_REFSPEC + ms.suffix, ms.refspec); values.put(SETTING_TAGS + ms.suffix, ms.tags); values.put(SETTING_NOTES + ms.suffix, ms.notes); values.put(SETTING_ATOMIC + ms.suffix, ms.atomic); } // Unfortunately the settings are stored in an immutable map, so need to cheat with reflection settingsReflectionHelper.set(values, settings); } }