Jenkins logo

Criando plugins para o Jenkins – Parte 1

Jenkins é um servidor de integração contínua open source escrito em java e compativel com várias linguagens de programação que possui uma vasta coleção de plugins disponíveis para deixa-lo ainda mais poderoso. Ele define pontos de extensão através de interfaces ou classes abstratas. Basicamente tudo o que você precisa fazer é criar uma classe de implementação e colocar a anotação @Extension. Um dos principais pontos de extensão são as Actions, responsáveis por criar uma URL adicional dentro do Jenkins para exibir alguma informação na tela.
Jenkins utiliza o Jelly na camada de apresentação, uma tecnologia semelhante ao JSP+JSTL, e o Stapler para associar URLs à objetos criando uma hierarquia intuitiva de URLs. Os dados são persistidos em arquivos xml usando o XStream que é uma biblioteca para fazer o binding de objetos para xml.
Existem vários tipos de plugins disponíveis no site do projeto em http://goo.gl/gbzVOp.

Nesse artigo vou mostar como construir plugins implementando as actions RootAction, TransientProjectActionFactory e TransientBuildActionFactory.

Configurando o ambiente

Para desenvolver o plugin utilize o Apache Maven 3. Crie o arquivo $HOME/.m2/settings.xml (no Windows é %USERPROFILE%\.m2\settings.xml) ou modifique caso exista conforme abaixo:

<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
  <profiles>
    <profile>
      <id>jenkins</id>
      <repositories>
        <repository>
          <id>java.net-Public</id>
          <name>Maven Java Net Snapshots and Releases</name>
          <url>https://maven.java.net/content/groups/public/</url>
        </repository>
        <repository>
          <id>central</id>
          <url>http://repo1.maven.org/maven2/</url>
        </repository>
        <repository>
          <id>m.g.o-public</id>
          <url>http://repo1.maven.org/maven2</url>
        </repository>
        <repository>
          <id>repo.jenkins-ci.org</id>
          <url>http://repo.jenkins-ci.org/public/</url>
        </repository>
      </repositories>
      <pluginRepositories>
        <pluginRepository>
          <id>repo.jenkins-ci.org</id>
          <url>http://repo.jenkins-ci.org/public/</url>
        </pluginRepository>
      </pluginRepositories>
    </profile>
  </profiles>
  <mirrors>
    <mirror>
      <id>repo.jenkins-ci.org</id>
      <url>http://repo.jenkins-ci.org/public/</url>
      <mirrorOf>m.g.o-public</mirrorOf>
    </mirror>
  </mirrors>
  <pluginGroups>
    <pluginGroup>org.jvnet.hudson.tools</pluginGroup>
    <pluginGroup>org.jvnet.hudson.plugins</pluginGroup>
    <pluginGroup>org.jvnet.hudson.winstone</pluginGroup>
    <pluginGroup>org.jvnet.hudson.main</pluginGroup>
  </pluginGroups>
  <activeProfiles>
    <activeProfile>jenkins</activeProfile>
  </activeProfiles>
</settings>

Criando um novo plugin

Abra o terminal e execute o maven:

$ mvn -U org.jenkins-ci.tools:maven-hpi-plugin:create -DgroupId=org.jenkinsci.plugins -DartifactId=rootaction-example-plugin
$ cd rootaction-example-plugin

Para configurar o projeto para IDE Eclipse

$ mvn -DdownloadSources=true -DdownloadJavadocs=true -DoutputDirectory=target/eclipse-classes eclipse:eclipse

Importe o projeto no eclipse em File > Import… > Existing Projects into Workspace.

Outra maneira de criar o esqueleto do plugin é pelo site http://plugin-generator.jenkins-ci.org

Remova os arquivos .java, .jelly e .html que foram criados por padrão pelo comando do maven. Apague também o pacote org.jenkinsci.plugins.infodeploy.HelloWorldBuilder em src/main/resources. Assim você vai ter um projeto limpo antes de começar.

Transient Actions

As Actions utilizadas como exemplo nesse artigo são transient, ou seja, é necessário escrever seus próprios métodos utilizando o XStream para persistir os dados. Para facilitar o entendimento e a legibilidade do código, serão criados DAOs para realizarem essas tarefas de persistência.

RootAction

A implementação da interface RootAction cria um link no menu principal do Jenkins. Para persistir os dados vai ser criado um DAO que vai salvar os valores de Person no arquivo person.xml.

Comece criando uma classe de modelo chamada Person, que é um POJO com 2 atributos.

package org.jenkinsci.plugins.rootactionexampleplugin;
 
import java.util.List;
 
public class Person {
 
    private String name;
    private List<String> phones;
 
    public String getName() {
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
    public List<String> getPhones() {
        return phones;
    }
 
    public void setPhones(List<String> phones) {
        this.phones = phones;
    }
}

Em seguida um DAO que tem como objetivo ler e salvar os dados. Esse DAO foi criado apenas para separar o código de persistência.

package org.jenkinsci.plugins.rootactionexampleplugin;
 
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
 
import jenkins.model.Jenkins;
 
public class PersonDao {
 
    public Person getOrCreateObject() {
        try {
            final String path = getFilePath();
            return (Person) Jenkins.XSTREAM.fromXML(new FileInputStream(path));
        }
        catch (final Exception e) {
            return new Person();
        }
    }
 
    public void save(Person object) {
        try {
            Jenkins.XSTREAM.toXML(object, getFile());
        } catch (final Exception e) {
            e.printStackTrace();
        }
    }
 
    private static String getFilePath() {
        return Jenkins.getInstance().getRootDir() + System.getProperty("file.separator", "/") + "person.xml";
    }
 
    private static OutputStreamWriter getFile() throws Exception {
        FileOutputStream outputStream = new FileOutputStream(getFilePath());
        return new OutputStreamWriter(outputStream, "UTF-8");
    }
}

Jenkins utiliza o XStrem para lidar com XML. No DAO temos 2 métodos públicos. Um tenta obter o objeto a partir do arquivo xml e caso não consiga retorna um novo objeto. O outro método recebe o objeto e gera o arquivo xml contendo os valores desse objeto.
Exemplo do arquivo person.xml:

<org.jenkinsci.plugins.rootactionexampleplugin.Person plugin="rootaction-example-plugin@1.0-SNAPSHOT">
  <name>Gustavo Henrique</name>
  <phones>
    <string>2171456895</string>
    <string>2198965472</string>
  </phones>
</org.jenkinsci.plugins.rootactionexampleplugin.Person>

Crie a classe MyRootAction em src/main/java no pacote org.jenkinsci.plugins.infodeploy contendo:

package org.jenkinsci.plugins.rootactionexampleplugin;
 
import hudson.Extension;
import hudson.model.RootAction;
 
import java.util.ArrayList;
import java.util.List;
 
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
 
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
 
@Extension
public class MyRootAction implements RootAction {
 
    private final PersonDao dao = new PersonDao();
 
    public Person getPerson() {
        return dao.getOrCreateObject();
    }
 
    public void doSave(final StaplerRequest request, final StaplerResponse response) throws Exception {
        JSONObject form = request.getSubmittedForm();
 
        Person person = new Person();
        person.setName(form.getString("name"));
        person.setPhones(bindToList(form.get("phones")));
 
        dao.save(person);
 
        response.sendRedirect(request.getContextPath());
    }
 
    private List bindToList(Object object) {
        List<String> list = new ArrayList<String>();
 
        if (object == null) {
            return list;
        }
 
        String key = "phone";
        if (object.getClass() == JSONObject.class) {
            JSONObject obj = (JSONObject) object;
            list.add(obj.getString(key));
        }
 
        if (object.getClass() == JSONArray.class) {
            JSONArray array = (JSONArray) object;
            for (int i = 0; i < array.size(); i++) {
                JSONObject obj = (JSONObject) array.get(i);
                list.add(obj.getString(key));
            }
        }
        return list;
    }
 
    public String getIconFileName() {
        return "/plugin/rootaction-example-plugin/images/icon.png";
    }
 
    public String getDisplayName() {
        return "RootAction Example Plugin";
    }
 
    public String getUrlName() {
        return "rootactionExamplePlugin";
    }
 
}

Ao usar a interface RootAction deve-se implementar os métodos getDisplayName, getUrlName e getIconFileName. O método getIconFileName retorna o caminho do ícone que aparece no menu principal. Nesse caso o arquivo icon.png deve estar dentro de src/main/webapps/images. Se esse método (ou o getDisplayName) retornar null então não será exibido nenhum link.

O método doSave é chamado ao submeter o form html. Métodos que começam com do no nome podem ser invocados via GET ou POST. Esse método obtém os dados preenchidos no form e cria um novo objeto. Se for preenchido apenas 1 campo para Phone no form, essa informação será do tipo JSONobject e se houver mais de 1 será do tipo JSONArray. O método privado bindToList verifica o tipo de objeto e cria uma lista de strings de acordo com os valores.

O próximo passo é criar a interface html. Jenkins vai procurar pelo arquivo index.jelly dentro de uma pasta cujo nome é o mesmo da classe incluindo o pacote. Por exemplo, se a classe que implementa uma action se chama MyRootAction e está dentro do pacote com.mycompany então o Jenkins vai procurar pelo arquivo index.jelly dentro de src/main/resources/com/mycompany/MyRAction/index.jelly.

Crie o arquivo index.jelly dentro de src/main/resources/org/jenkinsci/plugins/rootactionexampleplugin/MyRootAction:

<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout" xmlns:f="/lib/form">
<l:layout norefresh="true">
 
    <l:main-panel>
 
        <h1>${it.displayName}</h1>
 
        <f:form method="POST" action="save">
            <table>
                <tr>
                    <td><h2>Name</h2></td>
                </tr>
                <tr>
                    <td>
                         <f:textbox name="name" value="${it.person.name}"/>
                    </td>
                </tr>
 
                <tr>
                    <td><h2>Phones</h2></td>
                </tr>
                <tr>
                    <td>
                         <f:repeatable name="phones" var="phone" items="${it.person.phones}" add="${Add}" minimum="1">
                             <table cellpadding="2" cellspacing="2">
                                 <tbody>
                                     <tr>
                                         <td>Phone:</td>
                                         <td width="200"><f:textbox name="phone" value="${phone}"/></td>
                                         <td width="50"><f:repeatableDeleteButton/></td>
                                     </tr>
                                 </tbody>
                             </table>
                         </f:repeatable>
                    </td>
                </tr>
            </table>       
            <p>
                <f:submit value="Save"/>
            </p>
 
        </f:form>
 
    </l:main-panel>
  </l:layout>
</j:jelly>

A tag f:form cria um form html e envia os dados via POST para o método doSave da classe MyRootAction. Reparem que na action é omitido o do do nome do método. A tag f:repeatable cria um componente visual para exibir vários elementos do mesmo tipo. É similar ao forEach, percorre uma lista de objetos e os exibe dentro do loop. O get dos nomes dos métodos também pode ser omitido na chamada.
Variáveis são acessadas por ${}. A variável ${it}, por convenção, é uma instância de MyRootAction.

Execute o comando maven abaixo e acesse o projeto pela url http://localhost:8080/jenkins/:

$ mvn clean hpi:run

Para utilizar o debug do eclipse:

$ export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,address=8000,suspend=n"
$ mvn clean hpi:run

E depois vá no eclipse em Debug Configurations\Remote Java Application.

TransientProjectActionFactory

Execute novamente o maven para criar um novo plugin.

$ mvn -U org.jenkins-ci.tools:maven-hpi-plugin:create -DgroupId=org.jenkinsci.plugins -DartifactId=transientprojectactionfactory-example-plugin

TransientProjectActionFactory permite adicionar actions aos projetos sem necessidade de configura-los. Na inicialização do Jenkis o método e createFor é executado e nele é possível adicionar outras actions.

Comece criando o POJO MyProject:

package org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin;
 
import org.kohsuke.stapler.DataBoundConstructor;
 
public class MyProject {
 
    private String projectName;
    private String owner;
    private String url;
 
    public MyProject() {}
 
    @DataBoundConstructor
    public MyProject(final String owner, final String url) {
        this.owner = owner;
        this.url = url;
    }
 
    public String getProjectName() {
        return projectName;
    }
 
    public void setProjectName(String projectName) {
        this.projectName = projectName;
    }
 
    public String getOwner() {
        return owner;
    }
 
    public void setOwner(String owner) {
        this.owner = owner;
    }
 
    public String getUrl() {
        return url;
    }
 
    public void setUrl(String url) {
        this.url = url;
    }
}

Esse POJO possui um construtor padrão e um anotado com @DataBoundConstructor. Essa anotação é necessária quando se usa request.bindJSON para criar um objeto contendo os dados do form.

Agora mais um DAO para persitência:

package org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin;
 
import hudson.model.AbstractProject;
 
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
 
import jenkins.model.Jenkins;
 
public class ProjectDao {
 
    private final AbstractProject project;
 
    public ProjectDao(AbstractProject project) {
        this.project = project;
    }
 
    public MyProject getOrCreateObject() {
        try {
            final String path = getFilePath();
            return (MyProject) Jenkins.XSTREAM.fromXML(new FileInputStream(path));
        }
        catch (final Exception e) {
            return new MyProject();
        }
    }
 
    public void save(MyProject object) {
        try {
            Jenkins.XSTREAM.toXML(object, getFile());
        } catch (final Exception e) {
            e.printStackTrace();
        }
    }
 
    private String getFilePath() {
        return project.getRootDir() + System.getProperty("file.separator", "/") + "myproject.xml";
    }
 
    private OutputStreamWriter getFile() throws Exception {
        FileOutputStream outputStream = new FileOutputStream(getFilePath());
        return new OutputStreamWriter(outputStream, "UTF-8");
    }
}

Esse DAO vai persistir os valores de myProject no arquivo xml dentro do diretório do projeto. Exemplo do myproject.xml:

<org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin.MyProject plugin="transientprojectactionfactory-example-plugin@1.0-SNAPSHOT">
  <projectName>teste</projectName>
  <owner>Gustavo Henrique</owner>
  <url>http://tipsforlinux.com</url>
</org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin.MyProject>

Crie a classe MyAction:

package org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin;
 
import hudson.model.Action;
import hudson.model.AbstractProject;
 
import java.util.ArrayList;
import java.util.List;
 
import net.sf.json.JSONObject;
 
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
 
public class MyAction implements Action {
 
    private final AbstractProject job;
    private final ProjectDao dao;
 
    public MyAction(AbstractProject job) {
        this.job = job;
        this.dao = new ProjectDao(job);
    }
 
    public MyProject getMyProject() {
        return dao.getOrCreateObject();
    }
 
    public void doSave(final StaplerRequest request, final StaplerResponse response) throws Exception {
        final JSONObject form = request.getSubmittedForm();
        MyProject myProject = request.bindJSON(MyProject.class, form.getJSONObject("myProject"));
        myProject.setProjectName(job.getName());
 
        dao.save(myProject);
 
        String url = request.getRootPath() + "/" + job.getUrl();
        response.sendRedirect(url);
    }
 
    public List<String> getUrls() {
        List<String> urls = new ArrayList<String>();
        urls.add("http://www.gustavohenrique.net");
        urls.add("http://tipsforlinux.com");
        urls.add("http://gustavohenrique.com");
        return urls;
    }
 
    public String getIconFileName() {
        return "/plugins/transientprojectactionfactory-example-plugin/images/icon.png";
    }
 
    public String getDisplayName() {
        return "TransienteProjectActionFactory Example";
    }
 
    public String getUrlName() {
        return "transientprojectactionfactoryExamplePlugin";
    }
}

Os métodos getIconFileName, getDisplayName e getUrlName ja foram explicados. O método getMyProject retorna um objeto do tipo MyProject com os dados armazenados no arquivo. O método doSave recebe os dados submetidos pelo form e instancia o DAO para persistir no arquivo xml.

Mais uma classe, MyTransientProjectActionFactory:

package org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin;
 
import hudson.Extension;
import hudson.model.Action;
import hudson.model.TransientProjectActionFactory;
import hudson.model.AbstractProject;
 
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
 
@Extension
public class MyTransientProjectActionFactory extends TransientProjectActionFactory {
 
    @Override
    public Collection<? extends Action> createFor(AbstractProject project) {
        final List<MyAction> actions = new ArrayList<MyAction>();
        actions.add(new MyAction(project));
        return actions;
    }
}

Na inicialização do Jenkins o método createFor será executado e a action MyAction será adicionada para todos os projetos. Será exibido o ícone e URL para essa action no menu de cada projeto.

E para camada de visualização, crie os arquivos index.jelly, sidepanel.jelly e jobMain.jelly no pacote org.jenkinsci.plugins.transientprojectactionfactoryexampleplugin.MyAction.

O conteúdo do sidepanel.jelly aparece no menu lateral do projeto:

<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout" xmlns:f="/lib/form">
  <l:side-panel>
    <l:tasks>
      <l:task icon="${it.iconFileName}" href="${it.urlName}" title="${it.displayName}"/>
    </l:tasks>
 </l:side-panel>
</j:jelly>

O index.jelly contém o form para cadastro das informações sobre o projeto:

<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:l="/lib/layout" xmlns:f="/lib/form">
<l:layout norefresh="true">
    <st:include page="sidepanel.jelly" />
 
    <l:main-panel>
 
      <h1>${it.displayName}</h1>
 
      <f:form action="save" method="POST">
 
          <table name="myProject" cellspacing="2" cellpadding="2">
              <tbody>
                  <tr>
                      <td>Owner</td>
                      <td>
                          <f:textbox name="owner" value="${it.myProject.owner}"/>
                      </td>
                  </tr>
                  <tr>
                      <td>URL</td>
                      <td>
                          <select name="url">
                              <j:forEach var="url" items="${it.urls}">
                                  <f:option value="${url}" selected="${it.myProject.url == url}">${url}</f:option>
                              </j:forEach>
                          </select>
                      </td>
                  </tr>
              </tbody>
          </table>
          <p>
              <f:submit value="Save"/>
          </p>
      </f:form>
    </l:main-panel>
</l:layout>
</j:jelly>

E finalmente o jobMain.jelly que é exibido na home do projeto:

<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout" xmlns:f="/lib/form">
 
<h2>${it.displayName}</h2>
 
<table border="1" class="pane bigtable hover">
  <thead>
      <tr>
          <th>Owner</th>
          <th>URL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>${it.myProject.owner}</td>
          <td>${it.myProject.url}</td>
      </tr>
  </tbody>
</table>
</j:jelly>

TransientBuildActionFactory

A classe TransientBuildActionFactory é similar à classe TransientProjectActionFactory, sendo a única diferença que as actions adicionadas no método createFor serão visíveis apenas no menu do build.

Conclusão

As actions aqui apresentadas são as mais simples de serem implementadas. Nos próximos artigos pretendo apresentar outras Extension Points e como criar testes automatizados.
Os códigos desse artigo estão disponíveis na minha conta do github (http://github.com/gustavohenrique).

Links