Jenkins: Creating a custom pipeline step in your library

Pipelines in Jenkins, especially the declarative kind, can be very powerful and expressive at the same time.
As a caveat, it is also very easy to get overly verbose by (mis)using the script-tag to write all business logic not provided by Jenkins by default or by a plugin.

This post is part of a Series in which I elaborate on my best practices for running Jenkins at scale which might benefit Agile teams and CI/CD efforts.

The Jenkins Shared Library has a solution for this, by enabling you to write your own custom pipeline steps, without creating a plugin.

 

Creating your custom step

Shared Libraries can define global variables which behave similarly to built-in steps, like sh or git. These Global variables defined in Shared Libraries must be named with all lower-case or “camelCased” in order to be loaded properly by a pipeline. If you fail to comply with this, you’ll be greeted by a org.codehaus.groovy.control.MultipleCompilationErrorsException.

Remember the layout of your library?
Your custom step needs to be in the folder vars. Including the other folders, you should have a layout like this:

├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── resources
├── settings.gradle
├── src
├── test
└── vars

For example, to define your custom step logError, the file vars/logError.groovy should be created and should implement a call method. The call method allows the global variable to be invoked in a manner similar to a step:

// vars/logError.groovy
def call(String message) {
    // Any valid steps can be called from this code, just like
    // in a Scripted Pipeline
    echo "[ERROR]: ${message}"
}

A pipeline would be able to invoke this custom step like it would any regular step:

steps {
    logError 'Something went awfully wrong here.'
}

Creating a wrapper

The ability to define Global Variables in your Shared Library can also be extended to create a wrapper. The requirements are similar, but you’ll have to call the function with a block. The call method will receive a Closure. The type should be defined explicitly to clarify the intent of the step, for example:

// vars/ubuntu.groovy
def call(Closure body) {
    node('ubuntu-linux') {
        body()
    }
}

The pipeline can then use this wrapper like any built-in step which accepts a block:

ubuntu {
    sh "cat /etc/*-release"
}

Creating a module

A Global Variable can also be used to create a ‘module’ of sorts, where you can have a set of functions to call from a pipeline. An example of a module containing a collection of utility functions could be:

// vars/module_Utilities.groovy
import groovy.json.JsonSlurperClassic

Map parseJSONString(String json) {
    def jsonSlurper = new JsonSlurperClassic()
    return jsonSlurper.parseText(json) as Map
}

Calling a function within such a module differs slightly from what we’ve seen before as it needs to be wrapped by a script block:

steps {
    script {
        module_Utilities.parseJSONString("{foo: 'bar'}")
    }
}

Next steps

Now that you know how to create a Global Variable in your library which can act as a pipeline step, a wrapper, or even a set of globally available functions, the next step is to start validating the functionality within your library. The code in the library is much like any other code: it performs certain tasks and should, therefore, be subject to testing, to minimize the risk of issues occurring due to the introduction of bugs.