Tutorial: Jenkins Plugin Development - codecentric AG Blog

:

Some time ago I thought about developing a plugin for Jenkins. Inspired by the Performance plugin, I wanted to develop a plugin for AppDynamics, making use of the REST interface it provides. It would enable to execute performance tests in e.g. an acceptance environment, and fetch certain performance measurements via the AD REST interface by supplying the start and end time of the test. My adventure however would be more of a challenge than I anticipated beforehand.

I started off trying to modify the Jenkins Performance plugin, this would be a troublesome exercise. The Performance plugin is based on reading files generated by JMeter. But because we can use a REST interface, less parsing and saving of files is necessary.

screen-capture-appdynamics-overall-responsetime

Marcel Birkner already did a great post about a Jenkins plugin for Nexus, which I’m not going to repeat. The Jenkins Wiki is also a good starting point. There are also some other tutorials you can find on the Internet, I found them somewhat limited however, so I’ll try to go more in-depth and hopefully add another valueable tutorial.

The sources can be found on GitHub: AppDynamics Jenkins Plugin

Project Setup

The following will be a summary, for more detail see Marcel’s tutorial.

First create the Maven project:

To easily test the plugin, I wrote the following bash script (in case you’re using Linux or Mac), named run-fast.sh:

#! /bin/sh
rm -rf work/plugins
mvn -Dmaven.test.skip=true -DskipTests=true clean hpi:run

#! /bin/sh rm -rf work/plugins mvn -Dmaven.test.skip=true -DskipTests=true clean hpi:run

I wanted to easily reload the plugin while developing, without restarting Jenkins every time. Unfortunately I haven’t found a way that the Jetty container / Jenkins will correctly reload the updated class files for the plugin. If you find a way (other than JRebel), please let me know. In the mean time, after code changes are made, exit the Jenkins run script (^c) and restart the script again.

Necessary Plugin Objects

For your plugin to hook into the Jenkins build system, certain classes need to be extended so that they are discovered automatically. I will first give an overview of the main classes our plugin is using and will later describe them in more detail.

  • AppDynamicsResultsPublisher  – extends –  Recorder
    The Recorder is a specific BuildStep intended to run after the build completed, collects statistics from the build and marks it as unstable / failed.
  • AppDynamicsBuildAction  – implements –  Action and StaplerProxy
    Actions are exposed and create an additional URL subspace. They can appear e.g. in the left-hand menu of a build and these objects are persisted to disk. By adding the StaplerProxy interface we can point to a different ModelObject which is our BuildActionResultsDisplay.
  • BuildActionResultsDisplay  – implements –  ModelObject
    A ModelObject is some object referenced by an URL. It can be referenced e.g. by Jelly pages which we’ll describe later.
  • AppDynamicsProjectAction  – implements –  Action
    Another Action but this time on project level instead of individual build level.

screen-capture-appdynamics-buildresult
To expose the above objects on the Jenkins web interface, corresponding Jelly files are necessary. There are various types and to map them to certain classes (for retrieving the actual data or graphs) the paths need to match. For example to display the outcome of a specific build, we have a Jelly file on the following path:

/nl/codecentric/jenkins/appd/BuildActionResultsDisplay/index.jelly

This file maps to the BuildActionResultsDisplay class (remember, proxied by AppDynamicsBuildAction) and can show its data.
Other types of Jelly files are (which can live besides each other in the same directory):

  • config.jelly – for configuration, mainly on Publisher (our Recorder) level to configure the plugin
  • floatingBox.jelly – can show a floating graph on a build or project page
  • summary.jelly – can show a summary on a build or project page

Now let’s go into more detail…

Publisher

The Publisher object or Recorder is the base of our plugin. It needs a BuildStepDescriptor to provide certain information to Jenkins, but this descriptor also makes it possible to provide defaults for certain configuration fields and even to validate data. Below is a snippet of the descriptor.

  public static class DescriptorImpl extends BuildStepDescriptor {
 
    @Override
    public String getDisplayName() {
      return PUBLISHER_DISPLAYNAME.toString();
    }
 
    public String getDefaultUsername() {
      return DEFAULT_USERNAME;
    }
 
    public ListBoxModel doFillThresholdMetricItems() {
      ListBoxModel model = new ListBoxModel();
 
      for (String value : AppDynamicsDataCollector.getAvailableMetricPaths()) {
        model.add(value);
      }
 
      return model;
    }
 
    public FormValidation doTestAppDynamicsConnection(@QueryParameter("appdynamicsRestUri") final String appdynamicsRestUri,
                                                      @QueryParameter("username") final String username,
                                                      @QueryParameter("password") final String password,
                                                      @QueryParameter("applicationName") final String applicationName) {
      FormValidation validationResult;
      RestConnection connection = new RestConnection(appdynamicsRestUri, username, password, applicationName);
 
      if (connection.validateConnection()) {
        validationResult = FormValidation.ok("Connection successful");
      } else {
        validationResult = FormValidation.warning("Connection with AppDynamics RESTful interface could not be established");
      }
 
      return validationResult;
    }

  public static class DescriptorImpl extends BuildStepDescriptor {    @Override     public String getDisplayName() {       return PUBLISHER_DISPLAYNAME.toString();     }    public String getDefaultUsername() {       return DEFAULT_USERNAME;     }    public ListBoxModel doFillThresholdMetricItems() {       ListBoxModel model = new ListBoxModel();      for (String value : AppDynamicsDataCollector.getAvailableMetricPaths()) {         model.add(value);       }      return model;     }    public FormValidation doTestAppDynamicsConnection(@QueryParameter("appdynamicsRestUri") final String appdynamicsRestUri,                                                       @QueryParameter("username") final String username,                                                       @QueryParameter("password") final String password,                                                       @QueryParameter("applicationName") final String applicationName) {       FormValidation validationResult;       RestConnection connection = new RestConnection(appdynamicsRestUri, username, password, applicationName);      if (connection.validateConnection()) {         validationResult = FormValidation.ok("Connection successful");       } else {         validationResult = FormValidation.warning("Connection with AppDynamics RESTful interface could not be established");       }      return validationResult;     }

The methods that return a FormValidation object provide a nice way of validating input, or in this case verifying that the connection to the AppDynamics REST interface is valid. It is coupled to our Jelly file in the following way (snippet from config.jelly):

  <f:entry title="${%appdynamics.rest.username.title}" description="${%appdynamics.rest.username.description}">
    <f:textbox field="username" default="${descriptor.defaultUsername}" />
  </f:entry>
 
  <f:validateButton
      title="${%appdynamics.connection.test.title}" progress="${%appdynamics.connection.test.progress}"
      method="testAppDynamicsConnection" with="appdynamicsRestUri,username,password,applicationName" />

  <f:entry title="${%appdynamics.rest.username.title}" description="${%appdynamics.rest.username.description}">     <f:textbox field="username" default="${descriptor.defaultUsername}" />   </f:entry>  <f:validateButton       title="${%appdynamics.connection.test.title}" progress="${%appdynamics.connection.test.progress}"       method="testAppDynamicsConnection" with="appdynamicsRestUri,username,password,applicationName" />

screen-capture-appdynamics-configuration

The “validateButton” gives a separate button that will invoke the doTestAppDynamicsConnection method of our AppDynamicsResultsPublisher class. For validation of fields, it is sufficient to add a doCheckxxx method where xxx maps to the correct parameter name and where you add an @QueryParam annotation to the method input parameter.The descriptor needs to be bound to the corresponding class as follows:

  @Extension
  public static final DescriptorImpl DESCRIPTOR = new DescriptorImpl();
 
  @Override
  public BuildStepDescriptor<Publisher> getDescriptor() {
    return DESCRIPTOR;
  }

  @Extension   public static final DescriptorImpl DESCRIPTOR = new DescriptorImpl();  @Override   public BuildStepDescriptor<Publisher> getDescriptor() {     return DESCRIPTOR;   }

Finally, the perform method will be executed whenever a build is started. From here, also the AppDynamicsBuildAction must be instantiated, shown in the following snippet:

  @Override
  public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener)
      throws InterruptedException, IOException {
    PrintStream logger = listener.getLogger();
 
    AppDynamicsDataCollector dataCollector = new AppDynamicsDataCollector(connection, build,
        minimumMeasureTimeInMinutes);
    AppDynamicsReport report = dataCollector.createReportFromMeasurements();
 
    AppDynamicsBuildAction buildAction = new AppDynamicsBuildAction(build, report);
    build.addAction(buildAction);
 
    ...
 
    Result result;
    if (performanceFailedThreshold >= 0
        && performanceAsPercentageOfAverage - performanceFailedThreshold < 0) {
       build.setResult(Result.FAILURE);
     } else if (performanceUnstableThreshold >= 0
        && performanceAsPercentageOfAverage - performanceUnstableThreshold < 0) {
      result = Result.UNSTABLE;
      if (result.isWorseThan(build.getResult())) {
        build.setResult(result);
      }
    }

  @Override   public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener)       throws InterruptedException, IOException {     PrintStream logger = listener.getLogger();    AppDynamicsDataCollector dataCollector = new AppDynamicsDataCollector(connection, build,         minimumMeasureTimeInMinutes);     AppDynamicsReport report = dataCollector.createReportFromMeasurements();    AppDynamicsBuildAction buildAction = new AppDynamicsBuildAction(build, report);     build.addAction(buildAction);...    Result result;     if (performanceFailedThreshold >= 0         && performanceAsPercentageOfAverage - performanceFailedThreshold < 0) {       build.setResult(Result.FAILURE);     } else if (performanceUnstableThreshold >= 0         && performanceAsPercentageOfAverage - performanceUnstableThreshold < 0) {       result = Result.UNSTABLE;       if (result.isWorseThan(build.getResult())) {         build.setResult(result);       }     }

Everything written to the logger will show up in the build console output.

Build Action

The most important thing to know about the AppDynamicsBuildAction is to provide a weak reference to our BuildActionResultsDisplay object and return it as target so it can be shown on the build page:

  private transient WeakReference buildActionResultsDisplay;
 
  public BuildActionResultsDisplay getTarget() {
    return getBuildActionResultsDisplay();
  }
 
  public BuildActionResultsDisplay getBuildActionResultsDisplay() {
    BuildActionResultsDisplay buildDisplay = null;
    WeakReference wr = this.buildActionResultsDisplay;
    if (wr != null) {
      buildDisplay = wr.get();
      if (buildDisplay != null)
        return buildDisplay;
    }
 
    try {
      buildDisplay = new BuildActionResultsDisplay(this, StreamTaskListener.fromStdout());
    } catch (IOException e) {
      logger.log(Level.SEVERE, "Error creating new BuildActionResultsDisplay()", e);
    }
    this.buildActionResultsDisplay = new WeakReference(buildDisplay);
    return buildDisplay;
  }
 
  public void setBuildActionResultsDisplay(WeakReference buildActionResultsDisplay) {
    this.buildActionResultsDisplay = buildActionResultsDisplay;
  }

  private transient WeakReference buildActionResultsDisplay;  public BuildActionResultsDisplay getTarget() {     return getBuildActionResultsDisplay();   }  public BuildActionResultsDisplay getBuildActionResultsDisplay() {     BuildActionResultsDisplay buildDisplay = null;     WeakReference wr = this.buildActionResultsDisplay;     if (wr != null) {       buildDisplay = wr.get();       if (buildDisplay != null)         return buildDisplay;     }    try {       buildDisplay = new BuildActionResultsDisplay(this, StreamTaskListener.fromStdout());     } catch (IOException e) {       logger.log(Level.SEVERE, "Error creating new BuildActionResultsDisplay()", e);     }     this.buildActionResultsDisplay = new WeakReference(buildDisplay);     return buildDisplay;   }  public void setBuildActionResultsDisplay(WeakReference buildActionResultsDisplay) {     this.buildActionResultsDisplay = buildActionResultsDisplay;   }

Our BuildActionResultsDisplay will actually expose the data for a certain build. It is fed with a AppDynamics report containing all information for / from the build. The class will parse the data and generate e.g. a graph as shown in the code and corresponding Jelly:

  public void doSummarizerGraph(final StaplerRequest request,
                                final StaplerResponse response) throws IOException {
    final String metricKey = request.getParameter("metricDataKey");
    final MetricData metricData = this.currentReport.getMetricByKey(metricKey);
 
    final Graph graph = new GraphImpl(metricKey, metricData.getFrequency()) {
    ....
    };
 
    graph.doPng(request, response);
  }

  public void doSummarizerGraph(final StaplerRequest request,                                 final StaplerResponse response) throws IOException {     final String metricKey = request.getParameter("metricDataKey");     final MetricData metricData = this.currentReport.getMetricByKey(metricKey);    final Graph graph = new GraphImpl(metricKey, metricData.getFrequency()) { ....     };    graph.doPng(request, response);   }

       <j:set var="report" value="${it.getAppDynamicsReport()}"/>
      <j:forEach var="metricData" items="${report.metricsList}">
              
      </j:forEach>

       <j:set var="report" value="${it.getAppDynamicsReport()}"/>       <j:forEach var="metricData" items="${report.metricsList}">               </j:forEach>

Project Action

Finally the AppDynamicsProjectAction is quite similar to the previous objects. One thing that is interesting though is how to get a list of all previous reports, as we would like to show some overall statistics. The following code shows how the AbstractProject can be used to fetch a list of builds and from each build grab the stored AppDynamicsReport object:

  private List getExistingReportsList() {
    final List adReportList = new ArrayList();
 
    if (null == this.project) {
      return adReportList;
    }
 
    final List<? extends AbstractBuild<?, ?>> builds = project.getBuilds();
    for (AbstractBuild<?, ?> currentBuild : builds) {
      final AppDynamicsBuildAction performanceBuildAction = currentBuild.getAction(AppDynamicsBuildAction.class);
      if (performanceBuildAction == null) {
        continue;
      }
      final AppDynamicsReport report = performanceBuildAction.getBuildActionResultsDisplay().getAppDynamicsReport();
      if (report == null) {
        continue;
      }
 
      adReportList.add(report);
    }
 
    return adReportList;
  }

  private List getExistingReportsList() {     final List adReportList = new ArrayList();    if (null == this.project) {       return adReportList;     }    final List<? extends AbstractBuild<?, ?>> builds = project.getBuilds();     for (AbstractBuild<?, ?> currentBuild : builds) {       final AppDynamicsBuildAction performanceBuildAction = currentBuild.getAction(AppDynamicsBuildAction.class);       if (performanceBuildAction == null) {         continue;       }       final AppDynamicsReport report = performanceBuildAction.getBuildActionResultsDisplay().getAppDynamicsReport();       if (report == null) {         continue;       }      adReportList.add(report);     }    return adReportList;   }

 

Conclusion

What seemed like a quite daunting task eventually (after lots of struggeling) turned out to be quite easy. I hope by writing this blog post that some ideas and principles behind Jenkins become a bit more clear. And without adding too much other stuff, you have gotten a nice overview of which basic classes are necessary for a Jenkins plugin.

Now go and write your own plugin!!