Build-Deploy-Pipeline of a springboot app using Jenkinsfile

With Jenkins 2 the concept of pipelines became one of the core features of this great tool. In combination with job and pipeline dsl, it’s getting quite easy to code your build and deployment pipeline in Groovy instead of providing static config xmls or rigid job generators. Although some people might think, separation between build team and dev team has to be established due to responsibility concerns, actually the guys that write the application (no matter of dev, build, ops or others) should know best how to get the application through the pipeline.

Versions

component version download
Ubuntu 16.04 http://www.ubuntu.com/download/desktop
Jenkins 2.2.19 https://jenkins.io/
JDK Hotspot 1.8.0_91 http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html
Maven 3.3.9 https://maven.apache.org/download.cgi

First step: the example application that is based on springboot and inspired by the official get-starting-guide of springboot that (by the way) is well documented: https://spring.io/guides/gs/spring-boot/

.
├── Jenkinsfile
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── eu
    │   │       └── christophburmeister
    │   │           └── playground
    │   │               └── springbootexample
    │   │                   ├── Application.java
    │   │                   └── HelloController.java
    │   └── resources
    │       └── application.properties
    └── test
        └── java
            └── eu
                └── christophburmeister
                    └── playground
                        └── springbootexample
                            └── HelloControllerTest.java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.4.0.RELEASE</version>
    </parent>

    <groupId>eu.christophburmeister.playground</groupId>
    <artifactId>springbootexample</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <description>example project for springboot</description>

    <build>
        <resources>
            <!-- used for variable substitution in application.properties -->
            <!-- https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-1.3-Release-Notes#maven-resources-filtering -->
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
            </resource>
        </resources>

        <plugins>
            <plugin>
                <!-- required for building executable jars -->
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <!-- used for metrics like status, health etc -->
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <!-- used for unit tests -->
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>
package eu.christophburmeister.playground.springbootexample;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}
package eu.christophburmeister.playground.springbootexample;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;

@RestController
public class HelloController {
    @RequestMapping("/")
    public String index() {
        return "Greetings from Spring Boot!";
    }
}
package eu.christophburmeister.playground.springbootexample;

import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class HelloControllerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    public void getHello() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("Greetings from Spring Boot!")));
    }
}
server.port=10000
info.app.name=@project.name@
info.app.description=@project.description@
info.app.version=@project.version@
info.app.commitid=@commitid@
endpoints.shutdown.enabled=true

That’s all for the base project. After building and starting the app via

mvn clean install
java -jar target/springbootexample-0.0.1-SNAPSHOT.jar

The app will respond on the following URLs:
http://localhost:10000/
This will return the HelloController output „Greetings from Spring Boot!“

http://localhost:10000/info
This will return the json with values that are generated while build process (via maven filtering in application.properties):

{
   "app": {
      "description": "example project for springboot",
      "commitid": "@commitid@",
      "name": "springbootexample",
      "version": "0.0.1-SNAPSHOT"
   }
}

Note, that commitid is not set as we provide no „-Dcommitid“ parameter while maven build yet. This will be done later in Jenkins.

http://localhost:10000/shutdown
Invoking this adress via POST (e.g. with curl) stops the application:

curl -X POST http://localhost:10000/shutdown
{"message":"Shutting down, bye..."}

Note the corresponding parameter „endpoints.shutdown.enabled“ in application.properties

There are a lot more endpoints that are provided by the actuator module of springboot, but not relevant for this example.

Ok, this was the „dev“ part of this example. Let’s come to the „ops“ part that basically is the provision of the jenkins infrastructure. For this example I choose the latest version 2.19 with all default/suggested plugins. At a minimum you have to configure a jdk tool named „jdk1.8.0_91“ and a maven tool named „apache-maven-3.3.9“.

That’s all for the Jenkins setup. Now you have to create a bootstrap job for our example:

  1. „New item“ >> „Pipeline“ named „springbootexample“
  2. „Pipeline Definition“ >> „Pipeline script from SCM“ >> „Git“ >> add the repository url
  3. „Additional Behaviours“ >> „Clean before checkout“
  4. later you can fine-tune triggers and other settings, but that should it be for now

This job now checks out / clones the entered repository and looks by default for a file called „Jenkinsfile“ that has to contain build pipeline dsl: https://jenkins.io/doc/pipeline/

import groovy.json.JsonSlurper;

node{
    stage 'Build, Test and Package'
    env.PATH = "${tool 'apache-maven-3.3.9'}/bin:${env.PATH}"
    git url: "https://github.com/muthai/springbootexample.git"
    // workaround, taken from https://github.com/jenkinsci/pipeline-examples/blob/master/pipeline-examples/gitcommit/gitcommit.groovy
    def commitid = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
    def workspacePath = pwd()
    sh "echo ${commitid} > ${workspacePath}/expectedCommitid.txt"
    sh "mvn clean package -Dcommitid=${commitid}"
}

node{
    stage 'Stop, Deploy and Start'
    // shutdown
    sh 'curl -X POST http://localhost:10000/shutdown || true'
    // copy file to target location
    sh 'cp target/*.jar /tmp/'
    // start the application
    sh 'nohup java -jar /tmp/*.jar &'
    // wait for application to respond
    sh 'while ! httping -qc1 http://localhost:10000 ; do sleep 1 ; done'
}

node{
    stage 'Smoketest'
    def workspacePath = pwd()
    sh "curl --retry-delay 10 --retry 5 http://localhost:10000/info -o ${workspacePath}/info.json"
    if (deploymentOk()){
        return 0
    } else {
        return 1
    }
}

def deploymentOk(){
    def workspacePath = pwd()
    expectedCommitid = new File("${workspacePath}/expectedCommitid.txt").text.trim()
    actualCommitid = readCommitidFromJson()
    println "expected commitid from txt: ${expectedCommitid}"
    println "actual commitid from json: ${actualCommitid}"
    return expectedCommitid == actualCommitid
}

def readCommitidFromJson() {
    def workspacePath = pwd()
    def slurper = new JsonSlurper()
    def json = slurper.parseText(new File("${workspacePath}/info.json").text)
    def commitid = json.app.commitid
    return commitid
}

This script has thre steps:
Build, Test and Package: determination of the git commit id and running the maven build with -Dcommitid parameter (so the information is available at runtime in /info endpoint)

Stop, Deploy and Start: using the shutdown hook to stop the app, copy the artifact and start it

Smoketest: retrieve commitid from /info endpoint and compare it with the one that was used for building the artifact.

After committing this file to the repository and triggering the job, the job will pick up the file and run the pipeline. For security reasons all the groovy scripts are sandboxed and so the build will fail with sth like

org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException: Scripts not permitted to use staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods getText java.io.File
	at org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.StaticWhitelist.rejectStaticMethod(StaticWhitelist.java:190)
	at org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor$8.reject(SandboxInterceptor.java:272)
	at org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor.onGetProperty(SandboxInterceptor.java:363)

This shall avoid damages on the Jenkins infrastructure by executing invasive operations through groovy code. Administrators can approve usage of specific method signatures under „Manage Jenkins“ >> „In-process script-aproval“ or in config xml „scriptApproval.xml“

So with this pipelines you combine the sources of the application with the complete build process in a revision-safe system and devs can individually modify or extend their build-process without any changes on jenkins itself.