In which I elaborate why the idomatic Ruby API is sometimes not enough,
and describe a method to harness the full power of the underlying
Jenkins API while still happily coding your extension in Ruby
The Ruby API
One of the primary goals of the effort to bring Ruby to Jenkins
is to enable developers to extend Jenkins in a way that looks and
feels like a normal Ruby environment. This means using rake, bundler
ERB, and plain old Ruby objects to roll your plugin.
For example, the native BuildStep class is made available through
the Ruby BuildStep. They are similar to be sure, but the Ruby
object is much less complicated, and actually bears no relation to its
Java equivalent inside the Java hiearchy of inheritance.
Exposing this simplicity is a worthy goal, but to do so requires
careful consideration of each Jenkins Java API and how to provide its
functionality in the Ruby way –no mean feat given the breadth of the
Jenkins.
Sadly, it means that these Ruby-friendly APIs must necessarily lag
behind their Java counterparts.
This can be a serious source of frustration for those looking to
dive into Jenkins Ruby development right now. Initial excitement is
quickly dulled when you find out that the extension point that you
wanted to implement is unavailable from Ruby land. You might get
discouraged and feel like you might as well be coding your plugin
with Java.
Well don’t lose heart! I’d like to share with you a super easy way to
write your extension points in Ruby even when there isn’t a friendly
wrapper. We’ll actually implement a very handy extension point calledRunListener
within a Ruby plugin even though it is not part of the
official Ruby API. We’ll do it by scripting the Java class directly
with JRuby’s Java integration feature, and then register it directly
with the plugin runtime.
The Extension Point
We’ll be working with the Jenkins RunListener interface. This is
a wonderful extension point that allows you to receive callbacks
at each point during the actual running of a build. There’s currently
no nice ruby API for it, but we won’t let that stop us.
First, let’s create a new plugin called my-listener. We’ll do this
with the jpi new
command.
legolas:Jenkins cowboyd$ jpi new my-listener
create my-listener/Gemfile
create my-listener/my-listener.pluginspec
Fun fact: ‘jpi’ is an acronym for (J)enkins (P)lug-(I)n. You can
install the tool with rubygems:gem install jpi
Next, we’ll cd into our new plugin and create our listener class
inside the models/ directory. Jenkins will automatically evaluate
everything in this directory on plugin initialization.
legolas:Jenkins cowboyd$ cd my-listener/
legolas:my-listener cowboyd$ mkdir models
legolas:my-listener cowboyd$ touch models/my_listener.rb
Our ultimate goal here is to implement a RunListener
, so let’s
go ahead and start our class definition inside that file.
class MyListener < Java.hudson.model.listeners.RunListener
def initialize()
super(Java.hudson.model.Run.java_class)
end
end
There’s a couple key takeaways here. First, notice that we use
JRuby integration to extend the classhudson.model.listeners.RunListener
directly. Second, and this is
a gotcha anytime you extend a Java class: you must invoke one of
the Java super constructors if it does not have a default
constructor. I can’t tell you how many times I’ve been bitten by this.
In our case, the RunListener
class filters which jobs
it will provide callbacks for by class. By providing a more specific
class to the constructor, you limit the scope of jobs you’ll receive
to subclasses of that class. For our purposes, we cast a pretty wide
net by selecting all builds via the AbstractBuild
Java class.
Pro Tip: when you’re implementing a native Java API, it really
helps to have the javadoc open in one window so that you
can view the documentation and crib from the source
Now that we’ve got our class defined, let’s implement some methods!
We’ll add callbacks for when a build is started and when it’s
completed.
class MyListener < Java.hudson.model.listeners.RunListener
def initialize()
super(Java.hudson.model.AbstractBuild.java_class)
end
def onStarted(run, listener)
listener.getLogger().println("onStarted(#{run})")
end
def onCompleted(run, listener)
listener.getLogger().println("onCompleted(#{run})")
end
end
Jenkins.plugin.register_extension MyListener.new
And finally, on the last line, we actually inform Jenkins about the
existence of our new Listener with the call toJenkins.plugin.register_extension
And that’s about it. We can start up our test server with our jpi
tool to see our listener in action.
legolas:my-listener cowboyd$ jpi server
Listening for transport dt_socket at address: 8000
Running from: /Users/cowboyd/.rvm/gems/jruby-1.6.5/gems/jenkins-war-1.446/lib/jenkins/jenkins.war
...
Jan 16, 2012 12:46:15 AM ruby.RubyRuntimePlugin start
INFO: Injecting JRuby into XStream
Loading /Users/cowboyd/Projects/Jenkins/my-listener/models/my_listener.rb
INFO: Prepared all plugins
...
INFO: Jenkins is fully up and running
To view the output, create a freestyle build called HelloWorld that
doesn’t have any build steps at all, build it and view the console
output. You should see something like this:
Started by user anonymous
onStarted(#<Java::HudsonModel::FreeStyleBuild:0x2870068a>)
onCompleted(#<Java::HudsonModel::FreeStyleBuild:0x2870068a>)
Finished: SUCCESS
The Sweet Reality
Even though we were dealing more directly with Java, we were
still able to use all of the simplicity that comes with developing
plugins in Ruby. Furthermore, even though our extension point was just
scripted Java, it doesn’t mean that inside its methods it can’t call
as much pure Ruby code as its heart desires.
I hope that if you’re considering writing your next (or your first!)
Jenkins plugin in Ruby, you’ll feel confident that you can always
fall back to the native APIs at any point.