07 Feb

Tests con Selenide en Maven

Selenide es un wrapper sobre Selenium que simplifica de sobremanera el uso de éste. Por poner un ejemplo, para soportar un evento AJAX, con Selenium sería algo así:

FluentWait<By> fluentWait = new FluentWait<By>(By.tagName("TEXTAREA"));
fluentWait.pollingEvery(100, TimeUnit.MILLISECONDS);
fluentWait.withTimeout(1000, TimeUnit.MILLISECONDS);
fluentWait.until(new Predicate<By>() {
    public boolean apply(By by) {
        try {
            return browser.findElement(by).isDisplayed();
        } catch (NoSuchElementException ex) {
            return false;
        }
    }
});
assertEquals("John", browser.findElement(By.tagName("TEXTAREA")).getAttribute("value"));

Mientras que con Selenide:

$("TEXTAREA").shouldHave(value("John"));

y básicamente así con todo (en el Github del proyecto se pueden ver muchos más ejemplos).

Selenide logo

Por otra parte, dentro del mundo del testing funcional, existe un patrón muy conocido, Page Objects, que viene a decir que por cada página en la que se quiere llevar a cabo una simulación se crea una clase asociada (un Page Object). Esta clase implementa el acceso a los distintos elementos de la página, lo que ayuda a reducir en los tests el código duplicado, facilitando la reutilización. Con Selenide este patrón tiene esta pinta:

package com.sweftt.swdm.pages;

import org.openqa.selenium.By;

import static com.codeborne.selenide.Selenide.$;
import static com.codeborne.selenide.Selenide.page;


public class GooglePage {
    
    public SearchResultsPage searchFor( String text ) {
        $( By.name( "q" ) ).val( text ).pressEnter();
        return page( SearchResultsPage.class );
    }
    
}
package com.sweftt.swdm.pages;

import com.codeborne.selenide.ElementsCollection;
import com.codeborne.selenide.SelenideElement;

import static com.codeborne.selenide.Selenide.$;
import static com.codeborne.selenide.Selenide.$$;


public class SearchResultsPage {

    public ElementsCollection getResults() {
        return $$( "#ires .g" );
    }

    public SelenideElement getResult( int index ) {
        return $( "#ires .g", index );
    }
    
}
    @Test
    public void userCanSearch() {
        GooglePage page = open( "http://google.com", GooglePage.class );
        SearchResultsPage results = page.searchFor( "selenide" );
        results.getResults().shouldHave( size( 10 ) );
        results.getResult( 0 ).shouldHave( text( "Selenide: concise UI tests in Java" ) );
    }

(por defecto, el test se ejecuta usando Firefox, aunque puede cambiarse a través de propiedades de entorno, -Dselenide.browser=ie, o de la clase com.codeborne.selenide.Configuration)

El único problema es que, al ejecutar el test, en un entorno sin configuración adicional, saldrá algo como esto:

org.openqa.selenium.firefox.NotConnectedException: Unable to connect to host 127.0.0.1 on port 7055 after 45000 ms. Firefox console output:
pi	DEBUG	Calling bootstrap method startup on aushelper@mozilla.org version 1.0
1485199741216	addons.xpi	DEBUG	Registering manifest for XXXXXXXXXX\Mozilla Firefox\browser\features\e10srollout@mozilla.org.xpi
1485199741216	addons.xpi	DEBUG	Calling bootstrap method startup on e10srollout@mozilla.org version 1.5
1485199741217	addons.xpi	DEBUG	Registering manifest for XXXXXXXXXX\Mozilla Firefox\browser\features\firefox@getpocket.com.xpi
[omitidas alrededor de 40 líneas de trazas]
1485199759009	addons.manager	DEBUG	Calling shutdown blocker for LightweightThemeManager
1485199759009	addons.manager	DEBUG	Calling shutdown blocker for GMPProvider
1485199759012	addons.manager	DEBUG	Calling shutdown blocker for PluginProvider
1485199759013	addons.manager	DEBUG	Calling shutdown blocker for <unnamed-provider>
1485199759015	addons.manager	DEBUG	Calling shutdown blocker for PreviousExperimentProvider
1485199759018	addons.xpi	DEBUG	Notifying XPI shutdown observers
1485199759023	addons.manager	DEBUG	Async provider shutdown done

	at org.openqa.selenium.firefox.internal.NewProfileExtensionConnection.start(NewProfileExtensionConnection.java:113)
	at org.openqa.selenium.firefox.FirefoxDriver.startClient(FirefoxDriver.java:347)
	at org.openqa.selenium.remote.RemoteWebDriver.<init>(RemoteWebDriver.java:116)
	at org.openqa.selenium.firefox.FirefoxDriver.<init>(FirefoxDriver.java:259)
	at org.openqa.selenium.firefox.FirefoxDriver.<init>(FirefoxDriver.java:247)
	at org.openqa.selenium.firefox.FirefoxDriver.<init>(FirefoxDriver.java:242)
	at org.openqa.selenium.firefox.FirefoxDriver.<init>(FirefoxDriver.java:135)
	at com.codeborne.selenide.webdriver.WebDriverFactory.createFirefoxDriver(WebDriverFactory.java:123)
	at com.codeborne.selenide.webdriver.WebDriverFactory.createWebDriver(WebDriverFactory.java:47)
	at com.codeborne.selenide.impl.WebDriverThreadLocalContainer.createDriver(WebDriverThreadLocalContainer.java:240)
	at com.codeborne.selenide.impl.WebDriverThreadLocalContainer.getAndCheckWebDriver(WebDriverThreadLocalContainer.java:113)
	at com.codeborne.selenide.WebDriverRunner.getAndCheckWebDriver(WebDriverRunner.java:136)
	at com.codeborne.selenide.impl.Navigator.navigateToAbsoluteUrl(Navigator.java:68)
	at com.codeborne.selenide.impl.Navigator.open(Navigator.java:31)
	at com.codeborne.selenide.Selenide.open(Selenide.java:81)
	at com.codeborne.selenide.Selenide.open(Selenide.java:150)
	at com.codeborne.selenide.Selenide.open(Selenide.java:131)
	at com.sweftt.swdm.GoogleSearchTest.userCanSearch(GoogleSearchTest.java:20)

El problema es que, para poder ejecutar estos tests (o los de Selenium, que para el caso es lo mismo), es necesario descargar un binario que permite al WebDriver controlar el navegador. Además, es necesario indicar la ruta a estos binarios como propiedades de Java, algo así:

System.setProperty( "webdriver.ie.driver", "C:/absolute/path/to/binary/IEDriverServer.exe" );
System.setProperty( "webdriver.edge.driver", "C:/absolute/path/to/binary/MicrosoftWebDriver.exe" );
System.setProperty( "phantomjs.binary.path", "/absolute/path/to/binary/phantomjs" );

Desde un punto de vista de ejecución de los tests a través de Maven, con vistas a ejecutarlos en un entorno de integración/despliegue continuo, es problemático dado que los hace dependientes del entorno. Afortunadamente este problema puede evitarse, a través de una librería adicional, WebDriverManager; veamos cómo se integra con Selenide. El código fuente desarrollado está disponible en GitHub.

Lo primero de todo, es incluir la dependencia adecuada, en el caso de Maven:

<dependency>
  <groupId>io.github.bonigarcia</groupId>
  <artifactId>webdrivermanager</artifactId>
  <version>1.5.1</version>
</dependency>

y en el propio test, se configura esta librería:

package com.sweftt.swdm;

import static com.codeborne.selenide.CollectionCondition.size;
import static com.codeborne.selenide.Condition.text;
import static com.codeborne.selenide.Selenide.open;
import static com.codeborne.selenide.WebDriverRunner.setWebDriver;

import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;

import com.sweftt.swdm.pages.GooglePage;
import com.sweftt.swdm.pages.SearchResultsPage;

import io.github.bonigarcia.wdm.ChromeDriverManager;


/**
 * Google search test with Selenide.
 */
public class GoogleSearchTest {
    
    WebDriver driver;
    
    @BeforeClass
    public static void setupClass() {
        ChromeDriverManager.getInstance().setup();
    }
    
    @Before
    public void setupTest() {
        driver = new ChromeDriver();
        setWebDriver( driver );
    }

    // necesario, al no estar creado por Selenide, es necesario cerrarlo manualmente
    @After
    public void teardown() {
        if( driver != null ) {
            driver.quit();
        }
    }

    @Test
    public void userCanSearch() {
        GooglePage page = open( "http://google.com", GooglePage.class );
        SearchResultsPage results = page.searchFor( "selenide" );
        results.getResults().shouldHave( size( 10 ) );
        results.getResult( 0 ).shouldHave( text( "Selenide: concise UI tests in Java" ) );
    }

}

Y con esto se resuelve el problema. Sin embargo, puede hacerse mejor: hay mucha configuración que hay que repetir en cada test. Vamos a externalizar esta configuración a una regla JUnit. En primer lugar se define un enumerado en dónde se van a asociar las distintas clases de configuración de drivers de WebDriverManager con los WebDrivers correspondientes de Selenium:

package com.sweftt.swdm.rules;


import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.edge.EdgeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.ie.InternetExplorerDriver;
import org.openqa.selenium.opera.OperaDriver;
import org.openqa.selenium.phantomjs.PhantomJSDriver;

import io.github.bonigarcia.wdm.BrowserManager;
import io.github.bonigarcia.wdm.ChromeDriverManager;
import io.github.bonigarcia.wdm.EdgeDriverManager;
import io.github.bonigarcia.wdm.FirefoxDriverManager;
import io.github.bonigarcia.wdm.InternetExplorerDriverManager;
import io.github.bonigarcia.wdm.OperaDriverManager;
import io.github.bonigarcia.wdm.PhantomJsDriverManager;

public enum BrowserManagerEnum {
    
    CHROME( ChromeDriverManager.getInstance() ),
    FIREFOX( FirefoxDriverManager.getInstance() ),
    EDGE( EdgeDriverManager.getInstance() ),
    IE( InternetExplorerDriverManager.getInstance() ),
    MARIONETTE( FirefoxDriverManager.getInstance() ),
    OPERA( OperaDriverManager.getInstance() ),
    PHANTOMJS( PhantomJsDriverManager.getInstance() );
    
    private final BrowserManager bm;

    private BrowserManagerEnum( final BrowserManager bm ) {
        this.bm = bm;
    }
    
    public BrowserManager getBrowserManager() {
        return bm;
    }

    public WebDriver getDriver() {
        switch( this ) {
        case CHROME: return new ChromeDriver();
        case FIREFOX: return new FirefoxDriver();
        case EDGE: return new EdgeDriver();
        case IE: return new InternetExplorerDriver();
        case MARIONETTE: return new FirefoxDriver();
        case OPERA: return new OperaDriver();
        case PHANTOMJS: return new PhantomJSDriver();
        default: return null;
        }
    }

}

Hecho esto, sólo queda implementar la regla JUnit:

package com.sweftt.swdm.rules;

import static com.codeborne.selenide.WebDriverRunner.setWebDriver;

import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.openqa.selenium.WebDriver;


public class WebDriverManagerRule implements TestRule {
	
	protected final BrowserManagerEnum browser;
	protected WebDriver driver = null;
	
	/**
	 * Rule constructor.
	 * 
	 * @param browser see {@link BrowserManagerEnum} for available values.
	 */
	public WebDriverManagerRule( BrowserManagerEnum browser ) {
		this.browser = browser;
	}

	/** {@inheritDoc} */
	@Override
	public Statement apply( Statement base, Description description ) {
		return statement( base );
	}
	
	Statement statement( final Statement base ) {
        return new Statement() {
        	
        	/** {@inheritDoc} */
            @Override
            public void evaluate() throws Throwable {
            	if( driver == null ) {
            		beforeClass();
            		base.evaluate();
            		afterClass();
            	} else {
            		before();
                    try {
                        base.evaluate();
                    } finally {
                        after();
                    }
            	}
            }
        };
    }

    protected void beforeClass() throws Throwable {
    	browser.getBrowserManager().setup();
    	driver = browser.getDriver();
    }

    protected void before() throws Throwable {
    	setWebDriver( driver );
    }

    protected void after() {
    	driver.quit();
    }

    protected void afterClass() throws Throwable {
    }

}

Lo único un poco fuera de lo común es respecto a esta regla es que va a ser usada al mismo tiempo tanto como @Rule como @ClassRule, dado que tiene que hacer cosas distintas en distintos momentos de la ejecución de los tests: configurar WebDriverManager antes de la ejecución en si de los tests y establecer/terminar el WebDriver usado por Selenide, antes y después de la ejecución de cada test. Por último, para poder anotar una regla con @Rule y con @ClassRule al mismo tiempo, la versión a usar de JUnit tiene que ser como mínimo la 4.12.

El test unitario resultante queda del siguiente modo:

package com.sweftt.swdm;

import static com.codeborne.selenide.CollectionCondition.size;
import static com.codeborne.selenide.Condition.text;
import static com.codeborne.selenide.Selenide.open;

import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;

import com.sweftt.swdm.pages.GooglePage;
import com.sweftt.swdm.pages.SearchResultsPage;
import com.sweftt.swdm.rules.BrowserManagerEnum;
import com.sweftt.swdm.rules.WebDriverManagerRule;


/**
 * Google search test with Selenide.
 */
public class GoogleSearchWithRuleTest {
    
    @ClassRule @Rule
    public static WebDriverManagerRule testRule = new WebDriverManagerRule( BrowserManagerEnum.FIREFOX );
    
    @Test
    public void userCanSearch() {
        GooglePage page = open( "http://google.com", GooglePage.class );
        SearchResultsPage results = page.searchFor( "selenide" );
        results.getResults().shouldHave( size( 10 ) );
        results.getResult( 0 ).shouldHave( text( "Selenide: concise UI tests in Java" ) );
    }

}

Mucho mejor que el test anterior.

19 Nov

Extensión de Spring Boot para el framework Stripes

Stripes y Spring Boot

Hemos tenido oportunidad recientemente de incorporar Spring Boot dentro de nuestro stack tecnológico y hemos podido comprobar de primera mano por qué ha ganado este año el premio a la contribución más innovadora al ecosistema Java en los JAX Innovation Awards.

Por otra parte, mucho menos conocido pero no por ello exento de calidad es el framework Stripes, un framework web, que sigue el patrón MVC, se apoya fuertemente en la configuración por convención y que sobretodo es tremendamente sencillo. No tiene esa infinidad de posibilidades que aporta, por ejemplo, Spring MVC, pero para el 99.9% de las funcionalidades que va a tener un portal web típico, (para nosotros) es más que suficiente. Otro de los puntos fuertes de este framework es lo «natural» que resulta, cómo todo funciona como se espera, sin sorpresas desagradables, y lo fácil que es extenderlo.

Precisamente por ser un framework cuyo uso comparado con su competencia es muy residual, no existe un módulo que de integración con Spring Boot, lo que nos ha supuesto una excusa inmejorable para desarrollarlo y, de paso, aprender su funcionamiento interno. El código resultante está disponible en github.

Desarrollo de una extensión Spring Boot

La guía de referencia de Spring Boot es un excelente recurso de partida para acometer nuevas extensiones de este framework, que será de dostres módulos maven:

  • starter: en realidad, un jar (casi)vacío, que fundamentalmente sirve para proveer las dependencias por defecto que se traerá el módulo spring-boot. Al margen de esto, contiene un fichero ./META-INF/spring.provides dónde se referencia al siguiente módulo,
  • autoconfigure: que contiene la magia de Spring Boot; se encarga de instanciar y configurar lo que sea necesario y, opcionalmente, ofrecer un namespace a partir del cual obtener valores de configuración. Las dependencias de este módulo deben ser marcadas como opcionales, de tal modo que no sean arrastradas por éste.
  • sample: no es realmente necesario, pero tener un ejemplo que integre el módulo desarrollado favorece el uso de la extensión. En nuestro caso convertiremos la calculadora del tutorial de inicio con Stripes a una aplicación Spring Boot.

Contenido del módulo autoconfigure

El punto de entrada se encuentra en el fichero ./META-INF/spring.factories que referencia a una clase de configuración de Spring a partir de la entrada org.springframework.boot.autoconfigure.EnableAutoConfiguration:

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=net.sourceforge.stripes.springboot.autoconfigure.StripesAutoConfiguration

La clase StripesAutoConfiguration es una clase de configuración de Spring (por tanto, anotada con @Configuration), que se encarga de instanciar dos FilterRegistrationBeans que contienen tanto el StripesFilter como el DynamicMappingFilter de Stripes. Para este caso en concreto, se definen además otros beans de Spring que sobrescriben y desactivan el uso de Spring MVC.

@Configuration
@ConditionalOnClass( name="net.sourceforge.stripes.springboot.autoconfigure.SpringBootVFS" ) // @see http://stackoverflow.com/a/25790672
@ConditionalOnProperty( name = "stripes.enabled", matchIfMissing = true )
@EnableConfigurationProperties( StripesProperties.class )
public class StripesAutoConfiguration {

private static final Log LOG = Log.getInstance( StripesAutoConfiguration.class );
[...]

    @Bean( name = "stripesDynamicFilter" )
    @ConditionalOnMissingBean( name = "stripesDynamicFilter" )
    public FilterRegistrationBean stripesDynamicFilter() {
        final DynamicMappingFilter filter = new DynamicMappingFilter();

        final List< String > urlPatterns = new ArrayList< String >();
        urlPatterns.add( "/*" );

        final FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter( filter );
        registration.setUrlPatterns( urlPatterns );
        registration.setDispatcherTypes( DispatcherType.REQUEST, DispatcherType.INCLUDE, DispatcherType.FORWARD, DispatcherType.ERROR );
        registration.setOrder( Ordered.LOWEST_PRECEDENCE );
        return registration;
}

[...]

Se observa además que esta clase se encuentra anotada a nivel de clase y métodos con distintas anotaciones de tipo @ConditionalOnXXX, que son las que consiguen que la configuración sea opcional en función del entorno, la existencia o no de otros beans… Son el centro de la «magia» de Spring Boot. Es un mecanismo extremadamente sencillo y potente que, junto con una elección inteligente de unos valores por defecto (y que pueden ser fácilmente sustituidos), son la base del éxito de este framework. Chapeau por las mentes pensantes detrás de este invento.

Sumado a todo esto, esta clase se encuentra anotada con @EnableConfigurationProperties( StripesProperties.class ), que indica la clase a través de la cuál se reserva, en este caso, el namespace stripes en el fichero application.properties. Esta clase es un POJO normal y corriente, cuyas variables de clase se corresponderán con las equivalentes del fichero application.properties y cuya única particularidad es que se encuentra anotada con @ConfigurationProperties, dónde se indica el namespace a reservar:

@ConfigurationProperties(prefix = StripesProperties.STRIPES_PREFIX )
public class StripesProperties {

    public static final String STRIPES_PREFIX = "stripes";

    /** value for: {@code ActionBeanPropertyBinder.Class} */
    private String actionBeanPropertyBinder;

    /** value for: {@code ActionBeanContextFactory.Class} */
    private String actionBeanContextFactory;

    /** value for: {@code ActionBeanContext.Class} */
    private String actionBeanContext;

[...]
    /** placeholder for custom configuration */
    private Map< String, String > customConf = new HashMap< String, String >();

[...]

// getter & setters

[...]
}

Como curiosidad, en esta clase se incluye un Map a través del cuál se recogen todo tipo de valores que serán añadidos a la configuración del filtro de Stripes de tal modo que una entrada del tipo stripes.custom-conf.MY_CUSTOM_KEY=MY_CUSTOM_VALUE en el fichero application.properties de Spring Boot será añadido al filtro de Stripes como un parámetro de configuración con clave MY_CUSTOM_KEY y valor MY_CUSTOM_VALUE.

Contenido del módulo sample

Aunque no es estrictamente necesario, y la guía de referencia de Spring Boot no lo menciona, es muy de agradecer disponer de un ejemplo funcionando de la extensión desarrollada, y la mejor manera de promover el uso del mismo. En este caso, la aplicación se corresponde con la calculadora del tutorial de inicio al framework Stripes. El módulo es un módulo maven normal, cuyo pom.xml incluye la ejecución del plugin org.springframework.boot:spring-boot-maven-plugin y que contiene la clase java y jsp del tutorial tal cuál están en el ejemplo. Por último se añade un fichero application.properties y se indican las configuraciones de Stripes necesarias para arrancar la aplicación (nota: la extensión es lo suficientemente inteligente como para configurar el framework con unos valores por defecto lo suficientemente razonables como para poder arrancar una aplicación sin necesidad de proveer ningún valor específico).

Una vez compilados todos los módulos, puede arrancarse la aplicación desde el mismo módulo sample bien ejecutando un mvn spring-boot:run, o bien un java -jar target/stripes-spring-boot-sample-1.0.0.jar, y la aplicación quedará accesible en http://localhost:8080/index.jsp.

Una vez realizado todo el desarrollo, se anunció en la lista de distribución de Stripes, con la intención de que fuese integrado dentro de la distribución principal por lo que, con un poco de suerte estará disponible en la versión 1.7.0 del framework.

29 Oct

Industrializar el entorno CI/CD (1): Jenkins

Industrializar software significa, entre otras cosas, que éste debe poder ser reconstruible a partir de un script que automatiza todo el proceso. En el caso de un entorno de CI/CD, también es interesante que esté industrializado: permite verificar la configuración de cada sistema, introducir cambios en un entorno controlado, volver a una versión que se sabe que funciona, etc. La alternativa a esto es tener lo que Martin Fowler denomina un «Snowflake Server«.

En el caso concreto de Jenkins, no disponer de un mecanismo que permita reconstruir el sistema tal cuál estaba puede ser peliagudo, especialmente si se trabaja con la weekly release: la versión actualizada puede tener algún problema, o los plugins ser incompatibles. En estos casos, es complicado volver a la versión anterior, y probar a instalar de nuevo la versión, con los plugins, los jobs, los usuarios, los nodos, etc. y dejarlo todo tal como estaba puede ser realmente complicado; por ejemplo, no es descabellado que al actualizar un plugin, éste decida migrar su configuración en todos los jobs. Si, por lo que sea, es necesario dar marcha atrás al cambio en una situación así, va a haber dolor. Mucho dolor.

Además, no ayuda que no haya un proceso más o menos estándar para poder realizar esta tarea. A día de hoy, la documentación oficial al respecto es básicamente nula. Es cierto que hay algunos plugins de Jenkins que intentan ayudar en esta tarea: Backup Plugin, thinBackup y SCM Sync Backup, pero o están descontinuados, o no se puede programar el backup, o guardan también los workspaces, o no guardan toda la configuración (p.ej., si usas el plugin de promotions ninguno de estos plugins te guardará la configuración de las promociones).

En el trabajo la manera que hemos encontrado de solucionarlo es con dos scripts, el primero de ellos para guardar toda la configuración en el control de versiones (en nuestro caso, svn):

#!/bin/bash
echo '*****************************'
echo '0.- Change into $JENKINS_HOME'
echo '*****************************'
cd /path/to/your/jenkins_home


echo '************************'
echo '1.- update plugins list '
echo '************************'
curl -sSL "http://JENKINS_HOST:JENKINS_PORT/jenkins/pluginManager/api/xml?depth=1&xpath=/*/*/shortName|/*/*/version&wrapper=plugins" | perl -pe 's/.*?<shortName>([\w-]+).*?<version>([^<]+)()(<\/\w+>)+/\1 \2\n/g'|sed 's/ /:/' > plugins.txt


echo '*****************************************************************************'
echo '2.- Template saves wipe out promotions folder, so restore it with local data '
echo '*****************************************************************************'
# IFS weirdness to cd into directories whose names contain spaces, ref. http://www.cyberciti.biz/tips/handling-filenames-with-spaces-in-bash.html
cd jobs
SAVEIFS=$IFS
IFS=$(echo -en "\n\b")
for dir in `find ./* -type d -prune`;
do
  echo "svn update promotions on $dir" && cd $dir && svn update promotions --force --accept=mine-full && cd ..
done
IFS=$SAVEIFS
cd ..


echo '********************************************************************************************************************'
echo '3.- Add any new conf files, jobs with promotions, users, userContent, secrets, nodes and list of installed plugins. '
echo '********************************************************************************************************************'
svn add --force --parents *.xml jobs/*/config.xml jobs/*/promotions/*/config.xml users/*/config.xml userContent/* nodes/* secret.key secret.key.not-so-secret secrets/* plugins.txt


echo '***************************************************'
echo "4.- Ignore things in the root we don't care about. "
echo '***************************************************'
echo "warnlog\n*.log\n*.tmpn*.old\n*.bak\n*.jar\n*.json" > jenkins_ignores && svn propset svn:ignore -F jenkins_ignores . && rm jenkins_ignores 


echo '***************************************************'
echo "5.- Ignore things inside jobs we don't care about. "
echo '***************************************************'
echo "builds\nlast*\nnext*\n*.txt\n*.log\nworkspace*\ncobertura\njavadoc\nhtmlreports\nncover\ndoclinks" > jenkins_ignores && svn propset svn:ignore -F jenkins_ignores jobs/* && rm jenkins_ignores 


echo '***************************************************************'
echo '6.- Remove anything from SVN that no longer exists in Jenkins. '
echo '***************************************************************'
svn status | grep '!' | awk '{print $2;}' | xargs -r svn rm 


echo '**********************************************************************************'
echo '7.- And finally, check in of course, showing status before and after for logging. '
echo '**********************************************************************************'
svn st && svn ci --non-interactive --username=SVN_USER --password=SVN_PASSWORD -m "automated commit of Jenkins configuration" && svn st

exit 0

El punto 2 es bastante específico, dado que su objeto es tener en cuenta las interacciones entre los plugins EZ Templates y promotions; si no tienes instalados ambos, no es problema, este paso no hará nada.

Usamos el propio Jenkins para ejecutar este script con cadencia semanal y almacenar la configuración en el control de versiones. Para recuperar la instalación de Jenkins a partir del control de versiones realizamos estos pasos:

  • check out de la configuración del control de versiones en un directorio, que será el nuevo JENKINS_HOME.
  • descargar, mediante wget o cómo se prefiera, el war apropiado de Jenkins
  • ejecutar el siguiente script, adaptado de la imagen Docker oficial de Jenkins:
#! /bin/bash

# Parse a support-core plugin -style txt file as specification for jenkins plugins to be installed
# in the reference directory, so user can define a derived Docker image with just :
#
# FROM jenkins
# COPY plugins.txt /plugins.txt
# RUN /usr/local/bin/plugins.sh /plugins.txt
#
# Note: Plugins already installed are skipped
#

set -e

JENKINS_WAR=../jenkins.war
JENKINS_HOME=.
JENKINS_PLUGINS_DIR=$JENKINS_HOME/plugins
REF=$JENKINS_HOME/ref
# jenkins update center
JENKINS_UC=${JENKINS_UC:-https://updates.jenkins-ci.org}

if [ -z "$1" ]
then
    echo "
USAGE:
  Parse a support-core plugin -style txt file as specification for jenkins plugins to be installed
  in the reference directory, so user can define a derived Docker image with just :

  FROM jenkins
  COPY plugins.txt /plugins.txt
  RUN /usr/local/bin/plugins.sh /plugins.txt

  Note: Plugins already installed are skipped

"
    exit 1
else
    JENKINS_INPUT_JOB_LIST=$1
    if [ ! -f $JENKINS_INPUT_JOB_LIST ]
    then
        echo "ERROR File not found: $JENKINS_INPUT_JOB_LIST"
        exit 1
    fi
fi

# the war includes a # of plugins, to make the build efficient filter out
# the plugins so we dont install 2x - there about 17!
if [ -d $JENKINS_HOME ]
then
    TEMP_ALREADY_INSTALLED=$JENKINS_HOME/preinstalled.plugins.$$.txt
else
    echo "ERROR $JENKINS_HOME not found"
    exit 1
fi

if [ -d $JENKINS_PLUGINS_DIR ]
then
    echo "Analyzing: $JENKINS_PLUGINS_DIR"
    for i in `ls -pd1 $JENKINS_PLUGINS_DIR/*|egrep '\/$'`
    do
        JENKINS_PLUGIN=`basename $i`
        JENKINS_PLUGIN_VER=`egrep -i Plugin-Version "$i/META-INF/MANIFEST.MF"|cut -d\: -f2|sed 's/ //'`
        echo "$JENKINS_PLUGIN:$JENKINS_PLUGIN_VER"
    done > $TEMP_ALREADY_INSTALLED
else
    #JENKINS_WAR=/usr/share/jenkins/jenkins.war
    if [ -f $JENKINS_WAR ]
    then
        echo "Analyzing war: $JENKINS_WAR"
        TEMP_PLUGIN_DIR=/tmp/plugintemp.$$
        echo "TEMP_PLUGIN_DIR: $TEMP_PLUGIN_DIR"
        for i in `jar tf $JENKINS_WAR|egrep 'plugins'|egrep -v '\/$'|sort`
        do
            echo "TEMP_PLUGIN_DIR: $TEMP_PLUGIN_DIR"
            rm -fr $TEMP_PLUGIN_DIR
            mkdir -p $TEMP_PLUGIN_DIR
            PLUGIN=`basename $i|cut -f1 -d'.'`
            echo "$PLUGIN"
            (cd $TEMP_PLUGIN_DIR;jar xf $JENKINS_WAR "$i";jar xvf $TEMP_PLUGIN_DIR/$i META-INF/MANIFEST.MF >/dev/null 2>&1)
            VER=`egrep -i Plugin-Version "$TEMP_PLUGIN_DIR/META-INF/MANIFEST.MF"|cut -d\: -f2|sed 's/ //'`
            echo "$PLUGIN:$VER"
        done > $TEMP_ALREADY_INSTALLED
        rm -fr $TEMP_PLUGIN_DIR
    else
        rm -f $TEMP_ALREADY_INSTALLED
        echo "ERROR file not found: $JENKINS_WAR"
        exit 1
    fi
fi

#REF=/usr/share/jenkins/ref/plugins
mkdir -p $REF
COUNT_PLUGINS_INSTALLED=0
while read spec || [ -n "$spec" ]; do

    plugin=(${spec//:/ });
    [[ ${plugin[0]} =~ ^# ]] && continue
    [[ ${plugin[0]} =~ ^\s*$ ]] && continue
    [[ -z ${plugin[1]} ]] && plugin[1]="latest"

    if [ -z "$JENKINS_UC_DOWNLOAD" ]; then
      JENKINS_UC_DOWNLOAD=$JENKINS_UC/download
    fi

    if ! grep -q "${plugin[0]}:${plugin[1]}" $TEMP_ALREADY_INSTALLED
    then
        echo "Downloading ${plugin[0]}:${plugin[1]}"
        curl --retry 3 --retry-delay 5 -sSL -f ${JENKINS_UC_DOWNLOAD}/plugins/${plugin[0]}/${plugin[1]}/${plugin[0]}.hpi -o $REF/${plugin[0]}.jpi
        unzip -qqt $REF/${plugin[0]}.jpi
        COUNT_PLUGINS_INSTALLED=`expr $COUNT_PLUGINS_INSTALLED + 1`
    else
        echo "  ... skipping already installed:  ${plugin[0]}:${plugin[1]}"
    fi
done  < $JENKINS_INPUT_JOB_LIST

echo "---------------------------------------------------"
if [ $COUNT_PLUGINS_INSTALLED -gt 0 ]
then
    echo "INFO: Successfully installed $COUNT_PLUGINS_INSTALLED plugins."

    if [ -d $JENKINS_PLUGINS_DIR ]
    then
        echo "INFO: Please restart the container for changes to take effect!"
    fi
else
    echo "INFO: No changes, all plugins previously installed."

fi
echo "---------------------------------------------------"

#cleanup
rm $TEMP_ALREADY_INSTALLED
exit 0

¡Profit!

16 Oct

La complejidad del software explicada a un perfil no-técnico

Cuándo empiezas en un cliente nuevo, si no tiene un background técnico, no suele llevar muy bien esas situaciones en que le trasladas que lo que te ha pedido es en realidad un poco más complicado de llevar a cabo de lo que él tiene en mente. Obviamente esto no es siempre así, pero ha habido veces que he tenido la necesidad de trasladar por qué es desarrollar software es una actividad compleja a alguien que no tiene tiempo ni ganas de leer el paper de No Silver Bullet, y que bastante tiene ya con realizar el trabajo de su día a día, y además lidiar con esos molestos informáticos que no hacen más que poner pegas a todo lo que se les pide.

Una manera que me gusta mucho de explicar por qué el desarrollo de software es algo complejo a una persona de perfil no técnico es recurrir a una analogía con la película Indiana Jones en Busca del Arca Perdida. No en el sentido de que desarrollar software es cambiar un ídolo por un saco de arena y salir pitando antes de que dardos envenenados, piedras gigantes, fosos y mil trampas acaben con el pobre desarrollador, sino proponiendo un reto: tratar de adivinar la que probablemente es la escena más cara de la película. Tiempo.

¿Ya? ¿el submarino Nazi? ¿Esa gran piedra rodante apunto de aplastar al bueno de Indy? Que va, es seguro que estabas pensando en esto ¿verdad?:

Raiders of the lost ark - Cairo scenes

Raiders of the lost ark – Cairo scenes

(imagen recogida de http://indianajones.wikia.com/wiki/Cairo)

¿Las escenas de El Cairo en la terraza de Sallah? ¿Me estás tomando el pelo? Dos / tres personas totalmente tranquilas, sin ningún tipo de acción, un mono amaestrado… Es decir, ¿qué? ¿eso es más caro que un submarino nazi? ¿cuánto cuesta amaestrar un mono?

Cuando se rodó la película, los efectos especiales eran más bien artesanos, en el sentido opuesto a que los efectos especiales son generados por ordenador. Con esto en mente, ¿has visto dónde están las antenas de televisión de todos los tejados? En la década de 1930 no había televisión, y por tanto las terrazas eran como las de la imagen. En 1981, cuando se rueda la película, todos esos tejados tienen antenas de televisión, que hubo que ir quitando para poder realizar esas tomas.

Lo bueno es que llegado a este punto la relación con el cliente cambia, ya no piensa que estás intentando timarle y lo que suele hacer es ayudarte o facilitarte todo lo que pueda a que se puedan retirar esas antenas.