Jenkins : Manage global settings and tools installations

This is the 2nd part of the Plugin Developer tutorial.

Our plugin introduced a new build step to be configured on FreeStyle jobs. We will now add global, system-wide configuration (i.e. Jenkins > Manage Jenkins > Configure)

Global configuration

Do you know GRaveN can run your builds on the Cloud ? We will let user enable this option as a global setting. In our hudson.plugins.graven.GravenBuilder resource package we add a global.jelly view file. As the name suggest, such file will be used to configure global settings for the plugin component, that are managed by the Descriptor. This one then acts as a metadata container.

<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">

  <f:section title="${%GRaveN}" name="graven">
      <f:entry title="${%Send builds to the Cloud}" field="enableCloud">
        <f:checkbox/>
      </f:entry>
  </f:section>

</j:jelly>

Noticed in this global view that the section is set with a name="graven". As our form fields may collide with other plugin, especially if we use common names like name or path, we use a dedicated namespace to isolate plugin attributes form other plugins. The effect is that all form elements will be packaged in a dedicated graven JSON structure when sent back to the server, so we can easily filter the form data that match our component. 

As for config.jelly, we can define help tooltips as companion html files and support i18n with a bundle.

The Descriptor will get populated by this view if we override the configure method to retrieve data from the global configuration form submission

    @Extension
    public static class Descriptor extends BuildStepDescriptor<Builder> {

        private boolean enableCloud;

        public Descriptor() {
            load();
        }

        @Override
        public boolean configure(StaplerRequest req, JSONObject json) throws FormException {
            json = json.getJSONObject("graven");
            enableCloud = json.getBoolean("enableCloud");
            save();
            return true;
        }

        public boolean isEnableCloud() {
            return enableCloud;
        }
(...)

We can also use DataBinding, as we did with DataBoundConstructors, to avoid picking all form fields using a json.getXXX call, but for this to happen we have to either create setter method for the enableCloud attribute or declare it as public :

        @Override
        public boolean configure(StaplerRequest req, JSONObject json) throws FormException {
            req.bindJSON(this, json.getJSONObject("graven"));
            save();
            return true;
        }

        public void setEnableCloud(boolean enableCloud) {...}

Persist configuration

Just after binding the form submission to Descriptor attributes, we call the save method so that Jenkins will store the data in a dedicated XML file hudson.plugins.graven.GravenBuilder.xml in JENKINS_HOME. XStream is used to serialize data to configuration files, compared to other Java to XML binding solution, it has the benefits to be very flexible, so that we can easily support legacy configuration file when some attribute name have been refactored. To retrieve the saved data on Jenkins startup, we call the load method from the default constructor.

For GravenBuilder instances to access metadata in a simple way, we will use Java 5 covariant return type and override getDescriptor so that we can simply access our Descriptor singleton :

    public boolean isEnableCloud() {
        return getDescriptor().isEnableCloud();
    }

    @Override
    public Descriptor getDescriptor() {
        return (Descriptor) super.getDescriptor();
    }

Tools Installation

We could use this global configuration to manage GRaveN installation, so that user can choose the GRaveN version they want to build project with. This is such a common case in Jenkins, that there is already a pre-define canvas to support configuring installation and even auotmatic installation from a downloadable package.

We create a new Jenkins component to support GRaveN installation, with the adequate Descriptor :

/**
 * @author <a href="mailto:nicolas.deloof@cloudbees.com">Nicolas De loof</a>
 */
public class GravenInstallation extends ToolInstallation {

    @DataBoundConstructor
    public GravenInstallation(String name, String home, List<? extends ToolProperty<?>> properties) {
        super(name, home, properties);
    }

    @Extension
    public static final class Descriptor extends ToolDescriptor<GravenInstallation> {

        public Descriptor() {
            setInstallations();
            load();
        }

        @Override
        public String getDisplayName() {
            return "GRaveN";
        }

        @Override
        public GravenInstallation newInstance(StaplerRequest req, JSONObject formData) throws FormException {
            return (GravenInstallation) super.newInstance(req, formData.getJSONObject("gravenInstallation"));
        }
    }
}

Notice the call to setInstallation on Descriptor constructor to initialize a default empty installation set for new Jenkins instance, followed by a call to load to retrieve persisted configuration.

The newInstance method - that would by default search for a DataBoundConstructor to match the JSON form data - is overridden to extract the subset that matches our global configuration namespace for tool installation.

QUESTION : is there a way to only declare the namespace ? maybe this could be a valuable improvement to only override some JSONObject getJsonDataRoot(formData) or even just return the "namespace"

The associated view for this component is a config.jelly file. You may wonder this is not a global.jelly one, but note our Descriptor inherit from ToolDescriptor; Stapler view inherit from their parent view, and our descriptor will automatically be integrated in the ToolInstallation canvas that handles multiple ("repeatable") entries, additional properties, and more...

<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">

  <f:entry title="${%Name}" field="name">
    <f:textbox/>
  </f:entry>

  <f:entry title="${%Home directory}" field="home">
      <f:textbox />
  </f:entry>

</j:jelly>


Do you notice the "install automatically" link ? This is a benefit from reusing ToolInstallation : as much tools can be installed from a downloadable zip and/or using some scripting, this configuration is provided by default, with extension point to provide your own ToolInstaller strategy when required. You can for example extend DownloadFromURLInstaller so that the tool will be downloaded from it's distribution site and installed on demand.

Path on slave

How to configure our tool installation if we distribute builds on slave executors ? They may use distinct location for tools or maybe even distinct Operatin Systems. As described in ToolInstallation javadoc, you are encouraged (but not required) to implement NodeSpecific and EnvironmentSpecific to help Jenkins translate the configured path to match the node/environment where the build will be executed. Let's do :

public class GravenInstallation extends ToolInstallation
       implements NodeSpecific<GravenInstallation>, EnvironmentSpecific<GravenInstallation> {

    public GravenInstallation forEnvironment(EnvVars environment) {
        return new GravenInstallation(getName(), environment.expand(getHome()), getProperties().toList());
    }

    public GravenInstallation forNode(Node node, TaskListener log) throws IOException, InterruptedException {
        return new GravenInstallation(getName(), translateFor(node, log), getProperties().toList());
    }
(...)

Those two methods allow to use environment variable and let Jenkins "expand" them at runtime to get execution path, as well as to translate path for a node, using node specific properties. This will aslo be used by automatic ToolInstaller to install the tools on node where it is not yet available.