diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e4d5625 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d3f2dea..d8257c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ + pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup @@ -7,7 +8,11 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties -.idea -jenkins-client.iml + # Exclude maven wrapper !/.mvn/wrapper/maven-wrapper.jar + +# Intellij Idea files +.idea +*.iml + diff --git a/README.md b/README.md index fd4eb7e..8fb7cea 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,18 @@ Create job: client.createJob("java-client-job1","https://github.com/wtrocki/helloworld-android-gradle","master"); ``` +Trigger a job: + +``` + ... + BuildStatus buildStatus = client.build("java-client-job1"); +``` + ## Requirements Client works with Java6 and above. ## Building -`mvn clean package` \ No newline at end of file +`mvn clean package` + diff --git a/pom.xml b/pom.xml index d6b3e62..0d09fa4 100644 --- a/pom.xml +++ b/pom.xml @@ -18,16 +18,48 @@ + com.offbytwo.jenkins jenkins-client - 0.3.6 + 0.3.7-SNAPSHOT org.jtwig jtwig-core 5.65 + + org.slf4j + slf4j-api + 1.7.21 + + + org.slf4j + slf4j-log4j12 + 1.7.21 + + + + + junit + junit + 4.12 + test + + + + org.mockito + mockito-core + 1.10.19 + test + + + org.assertj + assertj-core + 3.6.1 + test + @@ -35,6 +67,7 @@ https://jcenter.bintray.com/ + @@ -46,6 +79,11 @@ 1.6 + + org.apache.maven.plugins + maven-surefire-plugin + 2.12.4 + - \ No newline at end of file + diff --git a/src/main/java/com/redhat/digkins/DiggerClient.java b/src/main/java/com/redhat/digkins/DiggerClient.java index ac28a97..5181816 100644 --- a/src/main/java/com/redhat/digkins/DiggerClient.java +++ b/src/main/java/com/redhat/digkins/DiggerClient.java @@ -1,20 +1,27 @@ package com.redhat.digkins; import com.offbytwo.jenkins.JenkinsServer; +import com.redhat.digkins.model.BuildStatus; import com.redhat.digkins.services.CreateJobService; -import com.redhat.digkins.util.JenkinsAuth; +import com.redhat.digkins.services.TriggerBuildService; import com.redhat.digkins.util.DiggerClientException; +import com.redhat.digkins.util.JenkinsAuth; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; /** - * Digger Java Client - *

- * Interact with digger jenkins api! + * Digger Java Client interact with Digger Jenkins api. */ public class DiggerClient { + private static final Logger LOG = LoggerFactory.getLogger(DiggerClient.class); + + public static final long DEFAULT_BUILD_TIMEOUT = 60 * 1000; + private final JenkinsServer jenkins; public DiggerClient(JenkinsAuth auth) throws URISyntaxException { @@ -22,11 +29,30 @@ public DiggerClient(JenkinsAuth auth) throws URISyntaxException { } /** - * Create new digger job on jenkins platform + * Create client using provided url and credentials + * + * @param url Jenkins url + * @param user Jenkins user + * @param password Jenkins password + * @return client instance + * @throws DiggerClientException if something goes wrong + */ + public static DiggerClient from(String url, String user, String password) throws DiggerClientException { + try { + JenkinsAuth jenkinsAuth = new JenkinsAuth(url, user, password); + return new DiggerClient(jenkinsAuth); + } catch (URISyntaxException e) { + throw new DiggerClientException("Invalid jenkins url format."); + } + } + + /** + * Create new Digger job on Jenkins platform * - * @param name - job name that can be used later to reference job - * @param gitRepo - git repository url (full git repository url. e.g git@github.com:wtrocki/helloworld-android-gradle.git - * @param gitBranch - git repository branch (default branch used to checkout source code) + * @param name job name that can be used later to reference job + * @param gitRepo git repository url (full git repository url. e.g git@github.com:wtrocki/helloworld-android-gradle.git + * @param gitBranch git repository branch (default branch used to checkout source code) + * @throws DiggerClientException if something goes wrong */ public void createJob(String name, String gitRepo, String gitBranch) throws DiggerClientException { CreateJobService service = new CreateJobService(this.jenkins); @@ -38,19 +64,54 @@ public void createJob(String name, String gitRepo, String gitBranch) throws Digg } /** - * Create client using provided url and credentials + * Triggers a build for the given job and waits until it leaves the queue and actually starts. + *

+ * Jenkins puts the build requests in a queue and once there is a slave available, it starts building + * it and a build number is assigned to the build. + *

+ * This method will block until there is a build number, or the given timeout period is passed. If the build is still in the queue + * after the given timeout period, a {@code BuildStatus} is returned with state {@link BuildStatus.State#TIMED_OUT}. + *

+ * Please note that timeout period is never meant to be very precise. It has the resolution of {@link TriggerBuildService#POLL_PERIOD} because + * timeout is checked before every pull. + *

+ * Similarly, {@link BuildStatus.State#CANCELLED_IN_QUEUE} is returned if the build is cancelled on Jenkins side and + * {@link BuildStatus.State#STUCK_IN_QUEUE} is returned if the build is stuck. * - * @param url - jenkins url - * @param user - jenkins user - * @param password - jenkins password - * @return client instance + * @param jobName name of the job to trigger the build + * @param timeout how many milliseconds should this call block before returning {@link BuildStatus.State#TIMED_OUT}. + * Should be larger than {@link TriggerBuildService#FIRST_CHECK_DELAY} + * @return the build status + * @throws DiggerClientException if connection problems occur during connecting to Jenkins */ - public static DiggerClient from(String url, String user, String password) throws DiggerClientException { + public BuildStatus build(String jobName, long timeout) throws DiggerClientException { + final TriggerBuildService triggerBuildService = new TriggerBuildService(jenkins); try { - JenkinsAuth jenkinsAuth = new JenkinsAuth(url, user, password); - return new DiggerClient(jenkinsAuth); - } catch (URISyntaxException e) { - throw new DiggerClientException("Invalid jenkins url format."); + return triggerBuildService.build(jobName, timeout); + } catch (IOException e) { + LOG.debug("Exception while connecting to Jenkins", e); + throw new DiggerClientException(e); + } catch (InterruptedException e) { + LOG.debug("Exception while waiting on Jenkins", e); + throw new DiggerClientException(e); + } catch (Throwable e) { + LOG.debug("Exception while triggering a build", e); + throw new DiggerClientException(e); } } + + /** + * Triggers a build for the given job and waits until it leaves the queue and actually starts. + *

+ * Calls {@link #build(String, long)} with a default timeout of {@link #DEFAULT_BUILD_TIMEOUT}. + * + * @param jobName name of the job + * @return the build status + * @throws DiggerClientException if connection problems occur during connecting to Jenkins + * @see #build(String, long) + */ + public BuildStatus build(String jobName) throws DiggerClientException { + return this.build(jobName, DEFAULT_BUILD_TIMEOUT); + } + } diff --git a/src/main/java/com/redhat/digkins/model/BuildStatus.java b/src/main/java/com/redhat/digkins/model/BuildStatus.java new file mode 100644 index 0000000..00bfca1 --- /dev/null +++ b/src/main/java/com/redhat/digkins/model/BuildStatus.java @@ -0,0 +1,67 @@ +package com.redhat.digkins.model; + +/** + * Represents the status of a build. + *

+ * The field {@link #buildNumber} will only be set if the + * {@link #state} is {@link State#BUILDING}. + **/ +public class BuildStatus { + + public enum State { + /** + * Build is out of the queue and it is currently being executed. + */ + BUILDING, + + /** + * The max time to wait for the build get executed has passed. + * This state doesn't have to mean build is stuck or etc. + * It just means, the max waiting time has passed on the client side. + */ + TIMED_OUT, + + /** + * The build is cancelled in Jenkins before it started being executed. + */ + CANCELLED_IN_QUEUE, + + /** + * The build is stuck on Jenkins queue. + */ + STUCK_IN_QUEUE + } + + private final State state; + private final int buildNumber; + + public BuildStatus(State state, int buildNumber) { + this.state = state; + this.buildNumber = buildNumber; + } + + /** + * @return state of the build + */ + public State getState() { + return state; + } + + /** + * This should only be valid if the + * {@link #state} is {@link State#BUILDING}. + * + * @return the build number assigned by Jenkins + */ + public int getBuildNumber() { + return buildNumber; + } + + @Override + public String toString() { + return "BuildStatus{" + + "state=" + state + + ", buildNumber=" + buildNumber + + '}'; + } +} diff --git a/src/main/java/com/redhat/digkins/services/CreateJobService.java b/src/main/java/com/redhat/digkins/services/CreateJobService.java index b9b7a0f..6d37a48 100644 --- a/src/main/java/com/redhat/digkins/services/CreateJobService.java +++ b/src/main/java/com/redhat/digkins/services/CreateJobService.java @@ -1,11 +1,9 @@ package com.redhat.digkins.services; import com.offbytwo.jenkins.JenkinsServer; -import org.apache.commons.io.FileUtils; import org.jtwig.JtwigModel; import org.jtwig.JtwigTemplate; -import java.io.File; import java.io.IOException; /** @@ -13,12 +11,13 @@ */ public class CreateJobService { - private JenkinsServer jenkins; + private final static String GIT_REPO_URL = "GIT_REPO_URL"; + private final static String GIT_REPO_BRANCH = "GIT_REPO_BRANCH"; - private final static String GIT_REPO_URL = "GIT_REPO_URL", GIT_REPO_BRANCH = "GIT_REPO_BRANCH"; + private JenkinsServer jenkins; /** - * @param jenkins - jenkins api instance + * @param jenkins jenkins api instance */ public CreateJobService(JenkinsServer jenkins) { this.jenkins = jenkins; @@ -27,9 +26,9 @@ public CreateJobService(JenkinsServer jenkins) { /** * Create new digger job on jenkins platform * - * @param name - job name that can be used later to reference job - * @param gitRepo - git repository url (full git repository url. e.g git@github.com:digger/helloworld.git - * @param gitBranch - git repository branch (default branch used to checkout source code) + * @param name job name that can be used later to reference job + * @param gitRepo git repository url (full git repository url. e.g git@github.com:digger/helloworld.git + * @param gitBranch git repository branch (default branch used to checkout source code) */ public void create(String name, String gitRepo, String gitBranch) throws IOException { JtwigTemplate template = JtwigTemplate.classpathTemplate("templates/job.xml"); diff --git a/src/main/java/com/redhat/digkins/services/TriggerBuildService.java b/src/main/java/com/redhat/digkins/services/TriggerBuildService.java new file mode 100644 index 0000000..3857dc2 --- /dev/null +++ b/src/main/java/com/redhat/digkins/services/TriggerBuildService.java @@ -0,0 +1,120 @@ +package com.redhat.digkins.services; + +import com.offbytwo.jenkins.JenkinsServer; +import com.offbytwo.jenkins.model.Executable; +import com.offbytwo.jenkins.model.JobWithDetails; +import com.offbytwo.jenkins.model.QueueItem; +import com.offbytwo.jenkins.model.QueueReference; +import com.redhat.digkins.model.BuildStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Provides functionality to trigger a build. + **/ +public class TriggerBuildService { + + private static final Logger LOG = LoggerFactory.getLogger(TriggerBuildService.class); + + /** + * How long should we wait before we start checking the queue item status. + */ + public static final long FIRST_CHECK_DELAY = 5 * 1000L; + + /** + * How long should we wait before checking the queue item status for next time. + */ + public static final long POLL_PERIOD = 2 * 1000L; + + + private JenkinsServer jenkinsServer; + + /** + * @param jenkinsServer jenkins api instance + */ + public TriggerBuildService(JenkinsServer jenkinsServer) { + this.jenkinsServer = jenkinsServer; + } + + /** + * See the documentation in {@link com.redhat.digkins.DiggerClient#build(String, long)} + * + * @param jobName name of the job + * @param timeout timeout + * @return the build status + * @throws IOException if connection problems occur during connecting to Jenkins + * @throws InterruptedException if a problem occurs during sleeping between checks + * @see com.redhat.digkins.DiggerClient#build(String, long) + */ + public BuildStatus build(String jobName, long timeout) throws IOException, InterruptedException { + final long whenToTimeout = System.currentTimeMillis() + timeout; + + LOG.debug("Going to build job with name: {}", jobName); + LOG.debug("Going to timeout in {} msecs if build didn't start executing", timeout); + + JobWithDetails job = jenkinsServer.getJob(jobName); + if (job == null) { + LOG.debug("Unable to find job for name '{}'", jobName); + throw new IllegalArgumentException("Unable to find job for name '" + jobName + "'"); + } + + final QueueReference queueReference = job.build(); + if (queueReference == null) { + // this is probably an implementation problem we have here + LOG.debug("Queue reference cannot be null!"); + throw new IllegalStateException("Queue reference cannot be null!"); + } + LOG.debug("Build triggered; queue item reference: {}", queueReference.getQueueItemUrlPart()); + + // wait for N seconds, then fetch the queue item. + // do it until we have an executable. + // we would have an executable when the build leaves queue and starts building. + + LOG.debug("Going to sleep {} msecs", FIRST_CHECK_DELAY); + Thread.sleep(FIRST_CHECK_DELAY); + + QueueItem queueItem; + while (true) { + queueItem = jenkinsServer.getQueueItem(queueReference); + LOG.debug("Queue item : {}", queueItem); + + if (queueItem == null) { + // this is probably an implementation problem we have here + LOG.debug("Queue item cannot be null!"); + throw new IllegalStateException("Queue item cannot be null!"); + } + + LOG.debug("Build item cancelled:{}, blocked:{}, buildable:{}, stuck:{}", queueItem.isCancelled(), queueItem.isBlocked(), queueItem.isBuildable(), queueItem.isStuck()); + + if (queueItem.isCancelled()) { + LOG.debug("Queue item is cancelled. Returning CANCELLED_IN_QUEUE"); + return new BuildStatus(BuildStatus.State.CANCELLED_IN_QUEUE, -1); + } else if (queueItem.isStuck()) { + LOG.debug("Queue item is stuck. Returning STUCK_IN_QUEUE"); + return new BuildStatus(BuildStatus.State.STUCK_IN_QUEUE, -1); + } + + // do not return -1 if blocked. + // we will wait until it is unblocked. + + final Executable executable = queueItem.getExecutable(); + + if (executable != null) { + LOG.debug("Build has an executable. Returning build number: {}", executable.getNumber()); + return new BuildStatus(BuildStatus.State.BUILDING, executable.getNumber().intValue()); + } else { + LOG.debug("Build did not start executing yet."); + if (whenToTimeout > System.currentTimeMillis()) { + LOG.debug("Timeout period has not exceeded yet. Sleeping for {} msecs", POLL_PERIOD); + Thread.sleep(POLL_PERIOD); + } else { + LOG.debug("Timeout period has exceeded. Returning TIMED_OUT."); + return new BuildStatus(BuildStatus.State.TIMED_OUT, -1); + } + } + + } + } +} diff --git a/src/main/java/com/redhat/digkins/util/DiggerClientException.java b/src/main/java/com/redhat/digkins/util/DiggerClientException.java index ebfd2ce..64c4b77 100644 --- a/src/main/java/com/redhat/digkins/util/DiggerClientException.java +++ b/src/main/java/com/redhat/digkins/util/DiggerClientException.java @@ -1,7 +1,5 @@ package com.redhat.digkins.util; -import java.util.TreeMap; - /** * Represents internal client exception */ diff --git a/src/main/java/com/redhat/digkins/util/JenkinsAuth.java b/src/main/java/com/redhat/digkins/util/JenkinsAuth.java index 8904a1f..459d96e 100644 --- a/src/main/java/com/redhat/digkins/util/JenkinsAuth.java +++ b/src/main/java/com/redhat/digkins/util/JenkinsAuth.java @@ -2,6 +2,8 @@ /** * Jenkins authentication object. + *

+ * Holds credentials in it. */ public class JenkinsAuth { diff --git a/src/main/resources/log4j.xml b/src/main/resources/log4j.xml new file mode 100644 index 0000000..64cc21f --- /dev/null +++ b/src/main/resources/log4j.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/redhat/digkins/services/TriggerBuildServiceTest.java b/src/test/java/com/redhat/digkins/services/TriggerBuildServiceTest.java new file mode 100644 index 0000000..52fd7f6 --- /dev/null +++ b/src/test/java/com/redhat/digkins/services/TriggerBuildServiceTest.java @@ -0,0 +1,134 @@ +package com.redhat.digkins.services; + +import com.offbytwo.jenkins.JenkinsServer; +import com.offbytwo.jenkins.model.Executable; +import com.offbytwo.jenkins.model.JobWithDetails; +import com.offbytwo.jenkins.model.QueueItem; +import com.offbytwo.jenkins.model.QueueReference; +import com.redhat.digkins.model.BuildStatus; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; + +import static org.assertj.core.api.Assertions.*; + +import org.mockito.runners.MockitoJUnitRunner; + + +@RunWith(MockitoJUnitRunner.class) +public class TriggerBuildServiceTest { + + private TriggerBuildService service; + + @Mock + JenkinsServer jenkinsServer; + + @Mock + JobWithDetails mockJob; + + QueueReference queueReference = new QueueReference("https://jenkins.example.com/queue/item/123/"); + + @Before + public void setUp() throws Exception { + service = new TriggerBuildService(jenkinsServer); + + Mockito.when(jenkinsServer.getJob("TEST")).thenReturn(mockJob); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowExceptionIfJobCannotBeFound() throws Exception { + service.build("UNKNOWN", 10000); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowExceptionIfJenkinsDoesNotReturnQueueReference() throws Exception { + Mockito.when(mockJob.build()).thenReturn(null); + service.build("TEST", 10000); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowExceptionIfQueueItemIsNullForReference() throws Exception { + Mockito.when(mockJob.build()).thenReturn(queueReference); + Mockito.when(jenkinsServer.getQueueItem(queueReference)).thenReturn(null); + service.build("TEST", 10000); + } + + @Test + public void shouldReturnCancelledStatus() throws Exception { + final QueueItem queueItem = new QueueItem(); + queueItem.setCancelled(true); + + Mockito.when(mockJob.build()).thenReturn(queueReference); + Mockito.when(jenkinsServer.getQueueItem(queueReference)).thenReturn(queueItem); + + final BuildStatus buildStatus = service.build("TEST", 10000); + assertThat(buildStatus).isNotNull(); + assertThat(buildStatus.getState()).isEqualTo(BuildStatus.State.CANCELLED_IN_QUEUE); + } + + @Test + public void shouldReturnStuckStatus() throws Exception { + final QueueItem queueItem = new QueueItem(); + queueItem.setStuck(true); + + Mockito.when(mockJob.build()).thenReturn(queueReference); + Mockito.when(jenkinsServer.getQueueItem(queueReference)).thenReturn(queueItem); + + final BuildStatus buildStatus = service.build("TEST", 10000); + assertThat(buildStatus).isNotNull(); + assertThat(buildStatus.getState()).isEqualTo(BuildStatus.State.STUCK_IN_QUEUE); + } + + @Test + public void shouldReturnBuildNumber() throws Exception { + final QueueItem queueItem = new QueueItem(); + final Executable executable = new Executable(); + executable.setNumber(98L); + queueItem.setExecutable(executable); + + Mockito.when(mockJob.build()).thenReturn(queueReference); + Mockito.when(jenkinsServer.getQueueItem(queueReference)).thenReturn(queueItem); + final BuildStatus buildStatus = service.build("TEST", 10000); + + assertThat(buildStatus).isNotNull(); + assertThat(buildStatus.getState()).isEqualTo(BuildStatus.State.BUILDING); + assertThat(buildStatus.getBuildNumber()).isEqualTo(98); + } + + @Test + public void shouldReturnBuildNumber_whenDidNotStartExecutingImmediately() throws Exception { + final QueueItem queueItemNotBuildingYet = new QueueItem(); + + final QueueItem queueItemBuilding = new QueueItem(); + queueItemBuilding.setExecutable(new Executable()); + queueItemBuilding.getExecutable().setNumber(98L); + + Mockito.when(mockJob.build()).thenReturn(queueReference); + // return `not-building` for the first 2 checks, then return `building` + Mockito.when(jenkinsServer.getQueueItem(queueReference)).thenReturn(queueItemNotBuildingYet, queueItemNotBuildingYet, queueItemBuilding); + final BuildStatus buildStatus = service.build("TEST", 20000L); + + assertThat(buildStatus).isNotNull(); + assertThat(buildStatus.getState()).isEqualTo(BuildStatus.State.BUILDING); + assertThat(buildStatus.getBuildNumber()).isEqualTo(98); + + Mockito.verify(jenkinsServer, Mockito.times(3)).getQueueItem(queueReference); + } + + @Test + public void shouldReturnTimeout() throws Exception { + final QueueItem queueItemNotBuildingYet = new QueueItem(); + + Mockito.when(mockJob.build()).thenReturn(queueReference); + Mockito.when(jenkinsServer.getQueueItem(queueReference)).thenReturn(queueItemNotBuildingYet); + final BuildStatus buildStatus = service.build("TEST", 10000L); + + assertThat(buildStatus).isNotNull(); + assertThat(buildStatus.getState()).isEqualTo(BuildStatus.State.TIMED_OUT); + + Mockito.verify(jenkinsServer, Mockito.atLeast(2)).getQueueItem(queueReference); + } + +} diff --git a/src/test/resources/log4j.xml b/src/test/resources/log4j.xml new file mode 100644 index 0000000..48b4abc --- /dev/null +++ b/src/test/resources/log4j.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + +