Adrian
6 years ago
committed by
GitHub
11 changed files with 576 additions and 441 deletions
@ -0,0 +1,147 @@ |
|||||||
|
package com.englishtown.bitbucket.hook; |
||||||
|
|
||||||
|
import com.atlassian.bitbucket.concurrent.BucketProcessor; |
||||||
|
import com.atlassian.bitbucket.i18n.I18nService; |
||||||
|
import com.atlassian.bitbucket.permission.Permission; |
||||||
|
import com.atlassian.bitbucket.repository.Repository; |
||||||
|
import com.atlassian.bitbucket.repository.RepositoryService; |
||||||
|
import com.atlassian.bitbucket.scm.Command; |
||||||
|
import com.atlassian.bitbucket.scm.ScmCommandBuilder; |
||||||
|
import com.atlassian.bitbucket.scm.ScmService; |
||||||
|
import com.atlassian.bitbucket.scm.git.command.GitCommandExitHandler; |
||||||
|
import com.atlassian.bitbucket.server.ApplicationPropertiesService; |
||||||
|
import com.atlassian.bitbucket.user.SecurityService; |
||||||
|
import com.google.common.base.Strings; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import javax.annotation.Nonnull; |
||||||
|
import java.net.URI; |
||||||
|
import java.net.URISyntaxException; |
||||||
|
import java.time.Duration; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Locale; |
||||||
|
|
||||||
|
import static com.englishtown.bitbucket.hook.MirrorRepositoryHook.PROP_PREFIX; |
||||||
|
|
||||||
|
public class MirrorBucketProcessor implements BucketProcessor<MirrorRequest> { |
||||||
|
|
||||||
|
static final String PROP_TIMEOUT = PROP_PREFIX + "timeout"; |
||||||
|
|
||||||
|
private static final String DEFAULT_REFSPEC = "+refs/heads/*:refs/heads/*"; |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MirrorBucketProcessor.class); |
||||||
|
|
||||||
|
private final I18nService i18nService; |
||||||
|
private final PasswordEncryptor passwordEncryptor; |
||||||
|
private final RepositoryService repositoryService; |
||||||
|
private final ScmService scmService; |
||||||
|
private final SecurityService securityService; |
||||||
|
private final Duration timeout; |
||||||
|
|
||||||
|
public MirrorBucketProcessor(I18nService i18nService, PasswordEncryptor passwordEncryptor, |
||||||
|
ApplicationPropertiesService propertiesService, RepositoryService repositoryService, |
||||||
|
ScmService scmService, SecurityService securityService) { |
||||||
|
this.i18nService = i18nService; |
||||||
|
this.passwordEncryptor = passwordEncryptor; |
||||||
|
this.repositoryService = repositoryService; |
||||||
|
this.scmService = scmService; |
||||||
|
this.securityService = securityService; |
||||||
|
|
||||||
|
timeout = Duration.ofSeconds(propertiesService.getPluginProperty(PROP_TIMEOUT, 120L)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void process(@Nonnull String key, @Nonnull List<MirrorRequest> requests) { |
||||||
|
if (requests.isEmpty()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
// Every request is for the same mirror URL, and the same repository ID. In case the
|
||||||
|
// settings (e.g. username/password) have been changed since the first request was
|
||||||
|
// queued, we process the _last_ request in the list. Since mirroring pushes all of
|
||||||
|
// the configured refspecs, any single request should roll up changes from any number
|
||||||
|
// of requests
|
||||||
|
MirrorRequest request = requests.get(requests.size() - 1); |
||||||
|
|
||||||
|
securityService.withPermission(Permission.REPO_READ, "Mirror changes") |
||||||
|
.call(() -> { |
||||||
|
Repository repository = repositoryService.getById(request.getRepositoryId()); |
||||||
|
if (repository == null) { |
||||||
|
log.debug("{}: Repository has been deleted", request.getRepositoryId()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (repositoryService.isEmpty(repository)) { |
||||||
|
log.debug("{}: The repository is empty", repository); |
||||||
|
return null; |
||||||
|
} |
||||||
|
runMirrorCommand(request.getSettings(), repository); |
||||||
|
|
||||||
|
return null; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private void runMirrorCommand(MirrorSettings settings, Repository repository) { |
||||||
|
log.warn("{}: Preparing to push changes to mirror", repository); |
||||||
|
|
||||||
|
String password = passwordEncryptor.decrypt(settings.password); |
||||||
|
String authenticatedUrl = getAuthenticatedUrl(settings.mirrorRepoUrl, settings.username, 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
|
||||||
|
ScmCommandBuilder<?> builder = scmService.createBuilder(repository) |
||||||
|
.command("push") |
||||||
|
.argument("--prune") // this deletes locally deleted branches
|
||||||
|
.argument(authenticatedUrl) |
||||||
|
.argument("--force"); |
||||||
|
|
||||||
|
// Use an atomic transaction to have a consistent state
|
||||||
|
if (settings.atomic) { |
||||||
|
builder.argument("--atomic"); |
||||||
|
} |
||||||
|
|
||||||
|
// Add refspec args
|
||||||
|
String refspecs = Strings.isNullOrEmpty(settings.refspec) ? DEFAULT_REFSPEC : settings.refspec; |
||||||
|
for (String refspec : refspecs.split("\\s|\\n")) { |
||||||
|
if (!Strings.isNullOrEmpty(refspec)) { |
||||||
|
builder.argument(refspec); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add tags refspec
|
||||||
|
if (settings.tags) { |
||||||
|
builder.argument("+refs/tags/*:refs/tags/*"); |
||||||
|
} |
||||||
|
// Add notes refspec
|
||||||
|
if (settings.notes) { |
||||||
|
builder.argument("+refs/notes/*:refs/notes/*"); |
||||||
|
} |
||||||
|
|
||||||
|
PasswordHandler passwordHandler = new PasswordHandler(settings.password, |
||||||
|
new GitCommandExitHandler(i18nService, repository)); |
||||||
|
|
||||||
|
Command<String> command = builder.errorHandler(passwordHandler) |
||||||
|
.exitHandler(passwordHandler) |
||||||
|
.build(passwordHandler); |
||||||
|
command.setTimeout(timeout); |
||||||
|
|
||||||
|
Object result = command.call(); |
||||||
|
log.warn("{}: Push completed with the following output:\n{}", repository, result); |
||||||
|
} |
||||||
|
|
||||||
|
String getAuthenticatedUrl(String mirrorRepoUrl, String username, String password) { |
||||||
|
// Only http(s) has username/password
|
||||||
|
if (!mirrorRepoUrl.toLowerCase(Locale.ROOT).startsWith("http")) { |
||||||
|
return mirrorRepoUrl; |
||||||
|
} |
||||||
|
|
||||||
|
URI uri = URI.create(mirrorRepoUrl); |
||||||
|
String userInfo = username + ":" + password; |
||||||
|
|
||||||
|
try { |
||||||
|
return new URI(uri.getScheme(), userInfo, uri.getHost(), uri.getPort(), |
||||||
|
uri.getPath(), uri.getQuery(), uri.getFragment()).toString(); |
||||||
|
} catch (URISyntaxException e) { |
||||||
|
throw new IllegalStateException("The configured mirror URL (" + mirrorRepoUrl + ") is invalid", e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
package com.englishtown.bitbucket.hook; |
||||||
|
|
||||||
|
import com.atlassian.bitbucket.repository.Repository; |
||||||
|
|
||||||
|
import java.io.Serializable; |
||||||
|
|
||||||
|
class MirrorRequest implements Serializable { |
||||||
|
|
||||||
|
private final int repositoryId; |
||||||
|
private final MirrorSettings settings; |
||||||
|
|
||||||
|
MirrorRequest(Repository repository, MirrorSettings settings) { |
||||||
|
this(repository.getId(), settings); |
||||||
|
} |
||||||
|
|
||||||
|
MirrorRequest(int repositoryId, MirrorSettings settings) { |
||||||
|
this.repositoryId = repositoryId; |
||||||
|
this.settings = settings; |
||||||
|
} |
||||||
|
|
||||||
|
int getRepositoryId() { |
||||||
|
return repositoryId; |
||||||
|
} |
||||||
|
|
||||||
|
MirrorSettings getSettings() { |
||||||
|
return settings; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String toString() { |
||||||
|
return repositoryId + ":" + settings.mirrorRepoUrl; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
package com.englishtown.bitbucket.hook; |
||||||
|
|
||||||
|
import java.io.Serializable; |
||||||
|
|
||||||
|
class MirrorSettings implements Serializable { |
||||||
|
|
||||||
|
String mirrorRepoUrl; |
||||||
|
String username; |
||||||
|
String password; |
||||||
|
String suffix; |
||||||
|
String refspec; |
||||||
|
boolean tags; |
||||||
|
boolean notes; |
||||||
|
boolean atomic; |
||||||
|
} |
@ -0,0 +1,155 @@ |
|||||||
|
package com.englishtown.bitbucket.hook; |
||||||
|
|
||||||
|
import com.atlassian.bitbucket.i18n.I18nService; |
||||||
|
import com.atlassian.bitbucket.i18n.SimpleI18nService; |
||||||
|
import com.atlassian.bitbucket.repository.Repository; |
||||||
|
import com.atlassian.bitbucket.repository.RepositoryService; |
||||||
|
import com.atlassian.bitbucket.scm.ScmService; |
||||||
|
import com.atlassian.bitbucket.scm.git.command.GitCommand; |
||||||
|
import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder; |
||||||
|
import com.atlassian.bitbucket.server.ApplicationPropertiesService; |
||||||
|
import com.atlassian.bitbucket.user.DummySecurityService; |
||||||
|
import com.atlassian.bitbucket.user.SecurityService; |
||||||
|
import org.junit.Before; |
||||||
|
import org.junit.Rule; |
||||||
|
import org.junit.Test; |
||||||
|
import org.mockito.Mock; |
||||||
|
import org.mockito.Spy; |
||||||
|
import org.mockito.junit.MockitoJUnit; |
||||||
|
import org.mockito.junit.MockitoRule; |
||||||
|
|
||||||
|
import java.time.Duration; |
||||||
|
import java.util.Collections; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
import static com.atlassian.bitbucket.mockito.MockitoUtils.returnArg; |
||||||
|
import static com.atlassian.bitbucket.mockito.MockitoUtils.returnFirst; |
||||||
|
import static com.atlassian.bitbucket.mockito.MockitoUtils.returnsSelf; |
||||||
|
import static com.englishtown.bitbucket.hook.MirrorBucketProcessor.PROP_TIMEOUT; |
||||||
|
import static org.junit.Assert.assertEquals; |
||||||
|
import static org.mockito.Mockito.*; |
||||||
|
|
||||||
|
/** |
||||||
|
* Unit tests for {@link MirrorBucketProcessor}. |
||||||
|
*/ |
||||||
|
public class MirrorBucketProcessorTest { |
||||||
|
|
||||||
|
private static final String URL_HTTP = "https://bitbucket-mirror.englishtown.com/scm/test/test.git"; |
||||||
|
private static final String URL_SSH = "ssh://git@bitbucket-mirror.englishtown.com/scm/test/test.git"; |
||||||
|
|
||||||
|
private static final MirrorSettings SETTINGS = new MirrorSettings() { |
||||||
|
{ |
||||||
|
mirrorRepoUrl = URL_SSH; |
||||||
|
password = "test-password"; |
||||||
|
refspec = "+refs/heads/master:refs/heads/master +refs/heads/develop:refs/heads/develop"; |
||||||
|
username = "test-user"; |
||||||
|
|
||||||
|
atomic = true; |
||||||
|
notes = true; |
||||||
|
tags = true; |
||||||
|
} |
||||||
|
}; |
||||||
|
private static final MirrorRequest REQUEST = new MirrorRequest(1, SETTINGS); |
||||||
|
private static final List<MirrorRequest> REQUESTS = Collections.singletonList(REQUEST); |
||||||
|
|
||||||
|
@Rule |
||||||
|
public MockitoRule mockitoRule = MockitoJUnit.rule().silent(); |
||||||
|
|
||||||
|
@Mock |
||||||
|
private GitScmCommandBuilder builder; |
||||||
|
@Mock |
||||||
|
private GitCommand<String> command; |
||||||
|
@Spy |
||||||
|
private I18nService i18nService = new SimpleI18nService(); |
||||||
|
@Mock |
||||||
|
private PasswordEncryptor passwordEncryptor; |
||||||
|
private MirrorBucketProcessor processor; |
||||||
|
@Mock |
||||||
|
private ApplicationPropertiesService propertiesService; |
||||||
|
@Mock |
||||||
|
private Repository repository; |
||||||
|
@Mock |
||||||
|
private ScmService scmService; |
||||||
|
@Mock |
||||||
|
private RepositoryService repositoryService; |
||||||
|
@Spy |
||||||
|
private SecurityService securityService = new DummySecurityService(); |
||||||
|
|
||||||
|
@Before |
||||||
|
public void setup() { |
||||||
|
when(builder.command(anyString())).thenAnswer(returnsSelf()); |
||||||
|
when(builder.argument(anyString())).thenAnswer(returnsSelf()); |
||||||
|
when(builder.errorHandler(any())).thenAnswer(returnsSelf()); |
||||||
|
when(builder.exitHandler(any())).thenAnswer(returnsSelf()); |
||||||
|
when(builder.<String>build(any())).thenReturn(command); |
||||||
|
|
||||||
|
when(passwordEncryptor.decrypt(anyString())).thenAnswer(returnFirst()); |
||||||
|
when(propertiesService.getPluginProperty(eq(PROP_TIMEOUT), anyLong())).thenAnswer(returnArg(1)); |
||||||
|
|
||||||
|
doReturn(builder).when(scmService).createBuilder(any()); |
||||||
|
|
||||||
|
processor = new MirrorBucketProcessor(i18nService, passwordEncryptor, |
||||||
|
propertiesService, repositoryService, scmService, securityService); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void testProcess() { |
||||||
|
when(repositoryService.getById(eq(1))).thenReturn(repository); |
||||||
|
|
||||||
|
processor.process("ignored", REQUESTS); |
||||||
|
|
||||||
|
verify(builder).command(eq("push")); |
||||||
|
verify(builder).argument(eq("--prune")); |
||||||
|
verify(builder).argument(eq("--force")); |
||||||
|
verify(builder).argument(eq(URL_SSH)); |
||||||
|
verify(builder).argument(eq("--atomic")); |
||||||
|
verify(builder).argument(eq("+refs/heads/master:refs/heads/master")); |
||||||
|
verify(builder).argument(eq("+refs/heads/develop:refs/heads/develop")); |
||||||
|
verify(builder).argument(eq("+refs/tags/*:refs/tags/*")); |
||||||
|
verify(builder).argument(eq("+refs/notes/*:refs/notes/*")); |
||||||
|
verify(command).call(); |
||||||
|
verify(command).setTimeout(eq(Duration.ofSeconds(120L))); |
||||||
|
verify(passwordEncryptor).decrypt(eq(SETTINGS.password)); |
||||||
|
verify(scmService).createBuilder(same(repository)); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void testProcessWithDeletedRepository() { |
||||||
|
processor.process("ignored", REQUESTS); |
||||||
|
|
||||||
|
verify(repositoryService).getById(eq(1)); |
||||||
|
verifyNoMoreInteractions(repositoryService); |
||||||
|
verifyZeroInteractions(scmService); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void testProcessWithEmptyRepository() { |
||||||
|
when(repositoryService.getById(eq(1))).thenReturn(repository); |
||||||
|
when(repositoryService.isEmpty(same(repository))).thenReturn(true); |
||||||
|
|
||||||
|
processor.process("ignored", REQUESTS); |
||||||
|
|
||||||
|
verify(repositoryService).getById(eq(1)); |
||||||
|
verify(repositoryService).isEmpty(same(repository)); |
||||||
|
verifyZeroInteractions(passwordEncryptor, scmService); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void testProcessWithoutRequests() { |
||||||
|
processor.process("ignored", Collections.emptyList()); |
||||||
|
|
||||||
|
verifyZeroInteractions(repositoryService, scmService); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void testGetAuthenticatedUrlForHttp() { |
||||||
|
String url = processor.getAuthenticatedUrl(URL_HTTP, "user", "password"); |
||||||
|
assertEquals("https://user:password@bitbucket-mirror.englishtown.com/scm/test/test.git", url); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void testGetAuthenticatedUrlForSsh() { |
||||||
|
String url = processor.getAuthenticatedUrl(URL_SSH, "user", "password"); |
||||||
|
assertEquals(URL_SSH, url); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue