Roughly a year ago, I worked on a project to simplify the numerous Jenkins build configurations we had to maintain using Jenkins Pipelines , which greatly improved our overall build quality and ease of introducing new builds/projects. However, one thing that repeatedly popped up was our reliance on cURL for making calls to external APIs - escaping quotes in the wrong place, checking whether the build was running on a Linux or Windows slave, and various others. I decided to fix this by adding a Groovy REST client to the workflow-libs, so that Jenkins could GET/POST/DELETE without dropping to a shell, and along the way ran into a number of problems that I've decided to write down for the next time I forget all about them.
If you're just looking for the code, check out this repo .
Jenkins Setup
First of all, you'll need Jenkins, naturally. I prefer using Docker for local development, so let's fetch the latest release:
docker pull jenkinsci/jenkins:2.68-alpine
Make a directory for the Jenkins home folder, so we can keep our changes on restarts, expose a port for the web interface, and let's get started:
mkdir -p ~/jenkins
docker run -p 8080:8080 -d -v /home/incognitjoe/jenkins:/var/jenkins_home --name jenkins jenkins
Note: if you intend to use workflow-libs as a git repository, expose the SSHD port you configure in the Jenkins control panel as well as 8080.
Use docker logs jenkins
to check the initial password it sets on
startup, log in, and install the recommended plugins, which include all
of the Pipeline plugins. After creating your user account, you should
be greeted with a totally empty Jenkins. Restart the docker container
just to make sure everything's applied correctly.
Now, if you browse to ~/jenkins, you should see a workflow-libs folder. Read the pipeline docs for more information about this, but basically, we're going to adding some Groovy scripts in here that can be used from Jenkins jobs.
Development setup
See the gradle-related files here for my setup. We're going to be using Jodd as the base HttpClient.
Why Gradle?
I don't like Maven.
Why Jodd, instead of the much more common HttpBuilder?
The Jenkins runtime is a harsh and unforgiving land. Initially I used HttpBuilder, and while GETs performed totally fine, have a snippet of the stack trace that happened when I tried to POST something:
[Pipeline] End of Pipeline
java.lang.UnsupportedOperationException: Refusing to marshal org.codehaus.groovy.runtime.MethodClosure for security reasons
at hudson.util.XStream2$BlacklistedTypesConverter.marshal(XStream2.java:452)
at com.thoughtworks.xstream.core.AbstractReferenceMarshaller.convert(AbstractReferenceMarshaller.java:69)
at com.thoughtworks.xstream.core.TreeMarshaller.convertAnother(TreeMarshaller.java:58)
at com.thoughtworks.xstream.core.TreeMarshaller.convertAnother(TreeMarshaller.java:43)
at com.thoughtworks.xstream.core.AbstractReferenceMarshaller$1.convertAnother(AbstractReferenceMarshaller.java:88)
at com.thoughtworks.xstream.converters.reflection.SerializableConverter$1.writeToStream(SerializableConverter.java:140)
at com.thoughtworks.xstream.core.util.CustomObjectOutputStream.writeObjectOverride(CustomObjectOutputStream.java:84)
at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:344)
at java.util.HashMap.internalWriteEntries(HashMap.java:1785)
at java.util.HashMap.writeObject(HashMap.java:1362)
at sun.reflect.GeneratedMethodAccessor148.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.thoughtworks.xstream.converters.reflection.SerializationMethodInvoker.callWriteObject(SerializationMethodInvoker.java:135)
Caused: com.thoughtworks.xstream.converters.ConversionException: Could not call groovyx.net.http.StringHashMap.writeObject() : Refusing to marshal org.codehaus.groovy.runtime.MethodClosure for security reasons
---- Debugging information ----
message : Could not call groovyx.net.http.StringHashMap.writeObject()
cause-exception : java.lang.UnsupportedOperationException
cause-message : Refusing to marshal org.codehaus.groovy.runtime.MethodClosure for security reasons
-------------------------------
I couldn't figure out any way around this without an enormous amount of effort, so finding Jodd was a lifesaver.
Where on earth are your tests?
So, here's the thing: Jenkins will allow us to use @Grab
to install
dependencies at runtime, since workflow-libs isn't compiled ahead of time.
However, it seems that there's a
slight issue
with trying to test scripts that use @Grab
with Spock. Considering
you'll likely encounter errors within the Jenkins runtime that you
can't reproduce locally no matter what(see above: that marshalling
error is never going to happen on your local machine), any tests you
write for this stuff are going to be limited in value - that's why
we're using a Jenkins instance as our testbed instead.
Workflow-libs Explained
Let's create two directories in workflow-libs, src/
and vars/
. Anything we add to src
is a Library
function, while vars
are Globals that can be
accessed from any Jenkins Pipeline job. Libraries
can be any valid Groovy code(mostly - nested closures
seem to cause endless amounts of grief, including
silently failing to execute - your mileage may vary),
but cannot access the Jenkins DSL - so, if you want to
drop to the shell with a Jenkins sh
command, that
should go into a script in vars
. More information
is available here .
In particular, check the note about vars
loading
as singletons, since that can lead to surprising behaviour.
With all that said, our HttpClient should only need
URLs and content to send, without needing to access
environment variables and the like, so let's create
src/org/incognitjoe/JenkinsHttpClient.groovy
and
add the following:
#! /usr/bin/groovy
package org.incognitjoe
import groovy.json.JsonBuilder
@Grab("org.jodd:jodd-http:3.8.5")
import jodd.http.HttpRequest
/**
* Helper class for making REST calls from a Jenkins Pipeline job.
*/
class JenkinsHttpClient {
private HttpRequest httpRequest
private String userAgent = 'Jenkins'
JenkinsHttpClient() {
httpRequest = new HttpRequest()
}
/**
* GET method
* @param url
* @return response body as String
*/
def get(String url) {
def resp = httpRequest.get(url)
.header("User-Agent", userAgent)
.send()
return resp.bodyText()
}
/**
* POST method, convert body Map to application/json.
* @param url
* @param body
* @return response body as String
*/
def postJson(String url, Map<?, ?> body) {
String jsonbody = new JsonBuilder(body).toString()
def resp = httpRequest.post(url)
.header("User-Agent", userAgent)
.contentType('application/json')
.body(jsonbody)
.send()
return resp.bodyText()
}
/**
* DELETE method
* @param url
* @return
*/
def delete(String url) {
def resp = httpRequest.delete(url)
.header("User-Agent", userAgent)
.send()
return resp
}
}
Nothing enormously complicated here. Now, to actually
use this, we'll create vars/testingStuff.groovy
and
add the following:
// Change your package name to match your src structure
import org.incognitjoe.JenkinsHttpClient
def print_ghibli_films() {
JenkinsHttpClient http = new JenkinsHttpClient()
println(http.get('https://ghibliapi.herokuapp.com/films/'))
}
def notifySlack(String slackHookUrl, Map<?, ?> postBody) {
JenkinsHttpClient http = new JenkinsHttpClient()
http.postJson(slackHookUrl, postBody)
}
Might as well create vars/testingStuff.txt
as well,
and add whatever help text you want - this file is
used to display help information in the Jenkins interface.
...and we're done. Now, how do we actually use these
functions? Well, the filename of anything in vars
is
available to Pipeline jobs as a step, so we can create
a Pipeline job in Jenkins, and enter this script:
// Master node
node() {
testingStuff.print_ghibli_films()
}
If everything's lined up correctly (see Common Errors and Gotchas below), running that build should print out a bunch of information from the Studio Ghibli API. Replace and extend as required.
Common Errors and Gotchas
If you're reading this far, presumably you're making changes to the Pipeline files since film data isn't all that useful to you, so there's a couple of things you should watch out for:
- script security settings: Jenkins will block a pretty considerable number of method calls by default, which you'll either need to review and approve as they happen in the control panel, or you can throw caution to the wind and install the Permissive Script Security plugin.
- closures not executing: this comes up in odd places, but if you have a few nested closures and you're getting null values where you'd expect anything else at all, try rewriting your code to not use closure composition.
- 'No such DSL method': this error can be a bit overwhelming, but if you're absolutely sure you haven't made a typo when calling your custom functions, then you're likely looking for an argument mismatch, e.g. you tried to call a function that requires two args but only gave one, or you passed a List where a String was expected.
- SSL errors: remember, any certs you need to trust have to be added to the Jenkins JVM keystore, not the system trust.