Personnaliser Jenkins

Jenkins est un serveur d'intégration continue qui a la particularité d'être extensible, permettant une grande liberté dans la mise en place de cycles de déploiement adaptés à chaque besoin : ajout de phases lors des builds, création de rapports personnalisés...

Je vais donc présenter un exemple de plugin montrant comment il est possible d'ajouter d'une part une action globale à un projet et d'autre part un rapport personnalisé pour chaque construction (build).

Avant tout, un peu de concret, voici un aperçu de ce que cela donne :

Action du projetRapport personnalisé de build

Qu'est-ce qu'un plugin Jenkins ?

Packagé sous la forme d'un fichier *.hpi, il contient essentiellement :

  • Les classes Java d'extension
  • Les vues (tout ou parties de pages, fichiers *.jelly)
  • Les ressources (images...)

En mode développement, il est possible de créer un fichier *.hpl qui simule un lien direct vers l'environnement de développement, permettant les modifications à chaud sans avoir à redémarrer le serveur.

Jelly...?

Pour faire court, ce langage d'Apache est à mi-chemin entre XML et JSP : il rend possible, en XML, de décrire une page web (à base de HTML) et d'y insérer des appels à des beans Java.

Premier aperçu

Comme les choses sont bien faites, il est possible d'utiliser Maven pour générer un artefact contenant la structure par défaut d'un plugin. Rien de plus simple, en trois étapes (décrites en détails ici) :

  • Configurer Maven pour accéder aux repositories de Jenkins (pour récupérer les goals)
  • utiliser mvn hpi:create
  • "eclipsifier" le projet

Survolons de la structure du projet :

  • src/main/java : classique, les sources du plugin
  • src/main/ressources : contiendra essentiellement les fichiers *.jelly de nos vues
  • src/main/webapp : est le répertoire des ressources (images...)

La mise en place d'un plugin se base essentiellement sur quelques normes de codage :

  1. La classe principale du plugin doit être annotée avec @Extension. Elle servira de point d'entrée.
  2. Une page Jelly associée à une action com.mypackage.MyAction doit se situer dans le répertoire src/main/ressources/com/mypackage/MyAction
  3. Les noms des pages Jelly ont une incidence sur le interprétation : index.jelly sera considérée comme la page d'accueil de l'action, summary.jelly sera utilisée dans la page de présentation d'un build.
  4. L'API de Jenkins fournit une série de classes facilement extensibles qui déterminent à elles seules le rôle du code ajouté :
    • Etendre hudson.model.Action signifie mettre en place en classe agissant comme un Controlleur lors du clic sur un lien dans une page
    • Etendre hudson.tasks.BuildStepDescriptor permet d'ajouter des étapes aux builds
    • Implémenter hudson.model.RootAction ajoute un lien dans le menu statique de gauche...

En mode développement, pour tester votre plugin, il suffit de lancer la commande suivante sur votre projet :

mvn clean hpi:run

Maven va recompiler les sources et créer le fichier *.hpl qui sera déployé sur l'instance locale de Jenkins après son démarrage. Rien d'autre à faire! (Il arrivera parfois que toutes les sources ne soient pas actualisées, il faut donc supprimer manuellement ce fichier dans les répertoires de Jenkins)

En avant!

Mettons maintenant en oeuvre ces éléments :

Créer une action sur un projet

Le menu contextuel à un projet est créé à partir de hudson.model.TransientProjectActionFactory. Nous allons donc créer une nouvelle extension à partir de cette classe afin d'ajouter notre action :


import hudson.Extension;
import hudson.model.Action;
import hudson.model.TransientProjectActionFactory;
import hudson.model.AbstractProject;

import java.util.Collection;
import java.util.Collections;

@Extension
public class MyProjectsReportActionFactory extends TransientProjectActionFactory {

 @Override
 public Collection createFor(AbstractProject arg0) {
  return Collections.singleton(new MyProjectAction(arg0));
 }

}

Tout simplement! Notez la présence de la fameuse annotation @Extension.

Et maintenant, le code de l'action :


package com.mypackage; 

import hudson.model.Action;
import hudson.model.AbstractProject;

import java.text.SimpleDateFormat;

public class MyProjectAction implements Action {
 
 private AbstractProject project;

 private SimpleDateFormat sdf;

 public MyProjectAction(AbstractProject p) {
  this.setProject(p);
  sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
 }

 public String getDisplayName() {
  return "Rapport perso";
 }

 public String getIconFileName() {
  return "/plugin/<artifact_id>/icon.jpg";
 }

 public String getUrlName() {
  return "MyProjectAction";
 }
 

 public AbstractProject getProject() {
  return project;
 }

 public void setProject(AbstractProject project) {
  this.project = project;
 }

 public String getLastBuildDate() {
  return sdf.format(project.getLastBuild().getTime());
 }
 
 public String getProjectURL() {
  return Jenkins.getInstance().getRootUrl() + project.getUrl();
 }
}

Petite description de cette classe :

  • getDisplayName() : est le libellé du lien hypertexte de l'action
  • getIconFileName() : est le chemin vers l'icône associée au lien. Important : comme spécifié plus haut, les ressources sont placées dans le répertoire webapp, mais une fois le plugin déployé, tout ce qui est sous ce répertoire se retrouve dans le dossier plugin de Jenkins, dans un répertoire nommé avec de l'artifact_id Maven du projet!
  • Enfin, les méthodes getProject(), getProjectURL() et getLastBuildDate() vont être utilisées dans la page Jelly.

Pour terminer, la vue Jelly de notre action (située à src/main/resources/com/mypackage/MyProjectAction/index.jelly) :


<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">

    <l:layout>
        <l:side-panel>
            <p>
            <a href="${it.projectURL}"><img src="/plugin/<artifactid>/up.png"/> Retour au Projet</a>
            </p>
        </l:side-panel>
        <!--<st:include it="${it.project}" page="sidepanel.jelly"/>-->
        <l:main-panel>
            <h1><img src="/plugin/<artifactid>/icon.jpg"/>Rapport perso</h1>
            <h2>Projet ${it.project.displayName}</h2>
            <p>Résultat : ${it.project.buildHealth.description}</p>
            <h3>Dernier build</h3>
            <p>Date : ${it.lastBuildDate}</p>
            <p>Etat : ${it.project.lastBuild.buildStatusSummary.message}</p>
        </l:main-panel>
    </l:layout>

</j:jelly>

Voici donc le résultat :

Personnaliser le détail d'un build

Imaginons que le build de votre projet produise un fichier personnalisé dont il serait bon d'afficher une présentation sur la page du résumé. Pour permettre l'analyse de ce fichier au moment imparti, il faut ajouter une étape au build pour exécuter notre action de parcours du rapport. Cette action sera alors disponible dans la configuration du projet :

Voici comment ajouter cette étape :


import hudson.Extension;
import hudson.model.AbstractProject;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.Publisher;

@Extension
public class MyPluginDescriptor extends BuildStepDescriptor<Publisher> {
 
 public MyPluginDescriptor() {
  super(MyPluginPublisher.class);
 }

 @Override
 public boolean isApplicable(Class<? extends AbstractProject> arg0) {
  return true;
 }

 @Override
 public String getDisplayName() {
  return "My Plugin Descriptor";
 }

}

Cette classe permet simplement d'indquer une référence vers l'objet Publisher permettant le déclenchement d'une action particulière lors de la publication du résultat du build. Ci-dessous le publisher :


import org.kohsuke.stapler.DataBoundConstructor;

import hudson.Launcher;
import hudson.model.AbstractBuild;
import hudson.model.BuildListener;
import hudson.tasks.BuildStepMonitor;
import hudson.tasks.Recorder;

public class MyPluginPublisher extends Recorder {
 
 @DataBoundConstructor
 public MyPluginPublisher() {
 }

 public BuildStepMonitor getRequiredMonitorService() {
  return BuildStepMonitor.BUILD;
 }
 
 @Override
 public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) 
   throws InterruptedException, IOException {
  MyBuildAction action = new MyBuildAction(build);
  build.addAction(action);
  return true;
 }

}

On voit donc ici l'ajout de l'action qui va réaliser notre traitement :


import hudson.model.Action;
import hudson.model.AbstractBuild;

import java.util.ArrayList;
import java.util.List;

public class MyBuildAction implements Action {

 private AbstractBuild<?, ?> build;

 public MyBuildAction(AbstractBuild<?, ?> p) {
  this.setBuild(p);
 }

 /**
  * Returns null to not display action link
  */
 public String getDisplayName() {
  return null;
 }

 /**
  * Returns null to not display action link
  */
 public String getIconFileName() {
  return null;
 }

 /**
  * Returns null to not display action link
  */
 public String getUrlName() {
  return null;
 }

 public AbstractBuild<?, ?> getBuild() {
  return build;
 }

 public void setBuild(AbstractBuild<?, ?> build) {
  this.build = build;
 }

 public List<String[]> getBuildRapport() throws Exception {
  List<String[]> rapports = new ArrayList<String[]>();
  File rapport = new File(build.getProject().getWorkspace().child(sdf2.format(build.getTime()) + "rapport-jenkins.txt").toURI());
  ...
        return rapports;
 }

}

Pour entrer dans le détail :

  • Cette fois-ci, les méthodes getDisplayName(), getIconFileName() et getUrlName() renvoient vide afin de ne pas faire apparaitre de lien dans le menu de la page.
  • getBuildRapport() renvoie, après analyse du fichier produit, un tableau utilisé dans la page Jelly ci-dessous

Il est important de noter ici que toutes ressources produites par les builds sont situées dans le répertoire workspace de l'instance de Jenkins :

Pour terminer, la vue Jelly de notre résumé (située à src/main/resources/com/mypackage/MyBuildAction/summary.jelly) :


<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">

     <table style="width:100%" border="1">
        <h1><img src="/plugin/artifact_id/icon.jpg"/> Résumé :</h1>
        <tr>
        <th>Enquête</th>
        <th>Résultat</th>
        <th>Erreur</th>
        </tr>
        <j:forEach items="${it.buildRapport}" var="data">
        <tr>
         <td style="vertical-align:middle"><b>${data[0]}</b></td>
       <j:if test="${empty(data[2])}">
        <j:set var="color" value="#00AA00" />
        <j:set var="image" value="icon_cool.jpg" />
        </j:if>
        <j:if test="${!empty(data[2])}">
            <j:set var="color" value="#AA0000" />
            <j:set var="image" value="icon_angry.jpg" />
        </j:if>
         <td style="text-align:center"><img height="150px" src="/plugin/artifact_id/${image}" /><br/>
            <font style="color:${color};font-size:2em"><b>${data[1]}</b></font>
         </td>
         <td style="vertical-align:middle"><j:if test="${!empty(data[2])}">${data[2]}</j:if></td>
        </tr>
       </j:forEach>
     </table>
     <hr/>
</j:jelly>

L'aperçu de cette page est présenté plus haut.

C'est terminé! En quelques classes et presque encore moins de lignes de code, voici un beau plugin pour maîtriser Jenkins!

P.S.: Merci à Michaël Pailloncy pour sa présentation au Toulouse JUG.

Sources :


Fichier(s) joint(s) :



Valider des fichiers XML en Java

La vérification de fichiers XML en fonction de schémas XSD peut vite devenir plus complexe qu'elle n'en a l'air...

Imaginons une application Java qui utilise des schémas imbriqués situés dans un certain répertoire pour valider des fichiers situer dans un autre répertoire :

Dans C:\schemas\ :

schema-parent.xsd


<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="3.0">
 <xs:include schemaLocation="schema-fils.xsd"/>
 <xs:element name="OPS">
  <xs:annotation>
   <xs:documentation>bla bla.</xs:documentation>
  </xs:annotation>
  <xs:complexType>
   <xs:choice>
    ...
</xs:schema>

schema-fils.xsd


<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="3.0">
 <xs:include schemaLocation="schema-petit-fils.xsd"/>
 <xs:element name="TOT">
  <xs:annotation>
   <xs:documentation>bla bla.</xs:documentation>
  </xs:annotation>
  <xs:complexType>
   <xs:choice>
    ...
</xs:schema>

schema-petit-fils.xsd


<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="3.0">
 <xs:element name="XDF">
  <xs:annotation>
   <xs:documentation>bla bla.</xs:documentation>
  </xs:annotation>
  <xs:complexType>
   <xs:choice>
    ...
</xs:schema>

Dans C:\xml\ :

avalider.xml


<?xml version="1.0" encoding="UTF-8"?>
<OPS xmlns="..." xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="schema-parent.xsd" AD="FRA" FR="AA"
 ON="540" OD="2012-11-12" OT="15:08">
 <DAT TM="CU">
  ...
 </DAT>
</OPS>

Et ci-dessous le code permettant d'exécuter la validation :


...
import org.xml.sax.helpers.DefaultHandler;
...

XSDHandler handler = new XSDHandler();

SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setNamespaceAware(true);
spf.setValidating(true);
SAXParser sp = spf.newSAXParser();

sp.getParser().setLocale(Locale.ENGLISH);

sp.setProperty("http://java.sun.com/xml/jaxp/properties/schemaLanguage", "http://www.w3.org/2001/XMLSchema");

InputStream schemaIs = getClass().getResourceAsStream("schema-parent.xsd");
sp.setProperty("http://java.sun.com/xml/jaxp/properties/schemaSource", schemaIs);
sp.parse(fichierAvalider, handler);

...

class XSDHandler extends DefaultHandler {

 private final static Logger logger = Logger.getLogger(XSDHandler.class);

 @Override
 public void fatalError(SAXParseException e) {
  logger.error("Erreur fatale :" + message);
 }

 @Override
 public void error(SAXParseException e) {
  logger.error("Erreur :" + message);
 }

 @Override
 public void warning(SAXParseException e) {
  logger.warn("Warning :" + message);
 }
}

Lors de l'exécution, l'erreur suivante va être levée :

Echec de la lecture du document de schéma 'schema-fils.xsd' pour les raisons suivantes : 1) Le document est introuvable ; 2) Le document n'a pas pu être lu ; 3) L'élément racine du document n'est pas .

Ceci est du à la présence de l'attribut schemaLocation au sein même du fichier XML, qui prévaut sur toute autre déclaration schemaLocation dans les XSD. Le SAXParser va donc tenter de trouver le schéma relativement au fichier en cours de vérification (dans C:\xml\)... Il est donc préférable de supprimer cet attribut.

Cependant, même après cette correction, l'erreur persiste... En effet, dans ce cas de schémas imbriqués, le SAXParser utilise l'attribut schemaLocation de schema-parent.xsd contenant normalement le chemin relatif vers le schéma fils, mais recherche toujours de manière relative au fichier XML!

Pour régler ce problème, sans avoir à indiquer de chemin absolu dans les schémas, il est possible d'indiquer au SAXParser comment retrouver ses petits. Pour ce faire, il faut modifier le XSDHandler qui lui est passé pour surcharger la méthode resolveEntity :


class XSDHandler extends DefaultHandler {

 private final static Logger logger = Logger.getLogger(XSDHandler.class);

 @Override
 public void fatalError(SAXParseException e) {
  logger.error("Erreur fatale :" + message);
 }

 @Override
 public void error(SAXParseException e) {
  logger.error("Erreur :" + message);
 }

 @Override
 public void warning(SAXParseException e) {
  logger.warn("Warning :" + message);
 }

 @Override
 public InputSource resolveEntity(String arg0, String arg1)
   throws IOException, SAXException {
  File tmp = new File(arg1);
  return new InputSource(new FileInputStream(new File(getClass().getResource("/v3/"+tmp.getName()).toURI())));
 }
}

Le deuxième argument arg1 contient le chemin complet où le parser cherche le schéma imbriqué. Il suffit donc d'un petit tour de passe-passe pour renvoyer le bon fichier!

Hope this helps!


Fichier(s) joint(s) :



Développeur président!

Dans la série des articles "Fier d'être développeur", en voici un très bon expliquant les rôles et enjeux de notre si beau métier :

We Need a Programmer for President

Et si on ne devait retenir qu'une phrase :

There is data indicating that we are automating jobs faster than we can create them. This is, in large part, due to software. It's the software developers who are now in demand and that industry may be where many of the jobs are shifting

Prenez le temps de suivre les liens indiqués au fil de l'article, ils sont tout aussi intéressants.


Fichier(s) joint(s) :