REUTILIZANDO PIPELINES ENTRE PROYECTOS CON GROOVY
Si trabajas con Jenkins y tienes más de dos o tres proyectos, seguro que te ha pasado: copias el mismo Jenkinsfile de un repo a otro, cambias cuatro cosas, y a partir de ahí cada uno vive su vida por separado. El día que necesitas cambiar algo común (añadir una notificación a Slack, cambiar cómo se hace el build, meter un paso de seguridad) te toca ir repo por repo, copiar y pegar otra vez. Ahí es donde entran las Shared Libraries.
Una Shared Library no deja de ser un repositorio de Groovy que Jenkins carga dentro de tus pipelines, y que te permite centralizar toda esa lógica repetida en un solo sitio. Cambias la librería una vez, y todos los pipelines que la usan se enteran.
Ahora bien, hay dos maneras de usarlas, y la diferencia es grande. La más habitual que ves en tutoriales es usar la librería solo para pasos sueltos (un notifySlack(), un buildAndDeploy()) que llamas desde un Jenkinsfile que sigue viviendo en cada repo. Nosotros fuimos un paso más allá: el pipeline entero vivía en la librería, y ningún repo de aplicación tenía Jenkinsfile. Te cuento las dos, pero sobre todo la segunda, que es la que de verdad nos cambió el día a día.
Estructura del repositorio
Jenkins espera una estructura de carpetas muy concreta para que la librería funcione:
(root)
├── vars/
│ ├── fullPipeline.groovy
│ └── notifySlack.groovy
├── src/
│ └── org/mallotore/
│ └── PipelineUtils.groovy
└── resources/
└── org/mallotore/
└── config.json
- vars/: aquí van los "pasos" que vas a poder llamar directamente desde un pipeline, como si fueran funciones globales. Cada archivo
.groovyque metas aquí, si se llamafullPipeline.groovy, lo invocas comofullPipeline(). - src/: código Groovy normal y corriente, con paquetes como en Java. Aquí metes clases, helpers, lo que necesites que no sea directamente un "paso" invocable.
- resources/: archivos estáticos que tu librería pueda necesitar cargar en tiempo de ejecución (plantillas, JSON, lo que sea), vía
libraryResource.
El pipeline entero como un paso
Aquí está la diferencia real con lo que se suele enseñar por ahí: en vez de meter solo trozos sueltos en vars/, metimos el pipeline completo. vars/fullPipeline.groovy tenía todas las stages (checkout, build, test, deploy según entorno) y recibía por parámetro qué repo, qué rama y contra qué entorno tocaba desplegar. Un detalle antes de verlo: los agentes eran Windows, pero teníamos Cygwin instalado, así que en vez de usar bat usábamos sh tal cual, como si estuviéramos en Linux. Eso nos permitía escribir las tareas Rake sin pensar en si el agente de turno era Windows o no.
def call(Map config) {
pipeline {
agent any
stages {
stage('Checkout CI scripts') {
steps {
dir('ci-scripts') {
git url: config.ciScriptsRepoUrl, branch: 'main'
}
}
}
stage('Checkout') {
steps {
git url: config.repoUrl, branch: config.branch
}
}
stage('Build') {
steps {
sh 'rake -f ci-scripts/Rakefile build'
}
}
stage('Test') {
steps {
sh 'rake -f ci-scripts/Rakefile test'
}
}
stage('Deploy') {
steps {
script {
deployToEnvironment(config.targetEnv)
}
}
}
}
}
}
Fíjate también en deployToEnvironment(config.targetEnv): no es nada de Jenkins, es otro paso más de la librería, un vars/deployToEnvironment.groovy que no pongo aquí para no alargar el post. La forma de desplegar era la misma para todas las aplicaciones, y por eso tenía sentido compartirla: un único sitio que sabe cómo se despliega a dev, uat, staging o prod, y que llaman todos los pipelines. Si en tu caso cada aplicación se despliega de una manera, este paso será el primero que no te convenga compartir.
Otro detalle importante: las tareas Rake no vivían en el repo de la aplicación, sino en un repo aparte dedicado solo a scripts de CI. Por eso el pipeline empieza con un checkout de ese repo antes de tocar el de la aplicación; así, igual que con el pipeline en sí, las tareas de build/test también estaban centralizadas y versionadas en un único sitio, en vez de repetidas (o distintas) en cada uno de los repos de aplicación.
Y el job en Jenkins, en vez de apuntar a un Jenkinsfile dentro del repo de la aplicación, tenía un pipeline script mínimo, prácticamente estas dos líneas:
@Library('mallotore-shared-lib') _
fullPipeline(
repoUrl: params.REPO_URL,
branch: params.BRANCH_NAME,
targetEnv: params.TARGET_ENV
)
Los parámetros REPO_URL, BRANCH_NAME y TARGET_ENV (dev, uat, staging, prod) se definían en el propio job como parámetros de Jenkins. Esto quiere decir que ningún repo de aplicación necesitaba saber nada de Jenkins ni mantener su propio Jenkinsfile: toda la lógica de CI/CD vivía en un único sitio, versionada aparte, y los repos de aplicación se limitaban a tener su código.
Por qué esto lo cambia todo a la hora de crear jobs
La consecuencia más práctica de este enfoque es que crear un job nuevo dejó de ser un evento. No hacía falta tocar el repo de la aplicación, ni añadir un Jenkinsfile, ni pelearte con sintaxis de pipeline la primera vez. Un job nuevo era: crear el job, pegar esas mismas cuatro líneas de siempre, rellenar los parámetros (repo, rama, entorno), y listo. Cualquiera del equipo podía montar un pipeline nuevo para un servicio nuevo en cinco minutos, sin necesitar saber Groovy ni cómo funcionaba Jenkins por dentro.
Y por qué también facilita las migraciones
Este mismo diseño tiene una ventaja añadida que se nota sobre todo cuando toca hacer un upgrade importante de Jenkins (un cambio de versión mayor, un cambio de plugin gordo, lo que sea que te dé respeto hacer directamente en producción). Como toda la lógica de los pipelines está en un repositorio Git aparte y los jobs son casi triviales de recrear, montar un Jenkins nuevo en paralelo era sencillo: instalas el Jenkins limpio, registras la misma Shared Library apuntando a la misma rama o a un tag concreto, y recreas los jobs con los mismos cuatro parámetros de siempre. En un rato tienes un Jenkins nuevo funcionando en paralelo al viejo, sin arriesgarte a romper nada en el que está en producción, y cuando ya confías en el nuevo, apagas el viejo.
Con el modelo clásico de un Jenkinsfile por repo esto es mucho más tedioso: tienes la lógica desperdigada en N repos distintos, y montar un Jenkins en paralelo implica asegurarte de que todos esos Jenkinsfile son compatibles con la nueva versión antes de poder confiar en nada.
Cuando no todos los proyectos son iguales
Todo esto funciona bien porque partíamos de una base concreta: un solo equipo, con unas 25 aplicaciones .NET Framework que seguían el mismo patrón, con Rake orquestando las tareas de build y test por encima de MSBuild. Pero teníamos otro grupo de unas 5 aplicaciones en .NET Core, que usaban el propio tooling de dotnet (también envuelto en Rake, pero con tareas distintas por debajo) y no encajaban en el fullPipeline pensado para MSBuild. En vez de forzarlas a base de if/else dentro del mismo paso, la solución fue simplemente tener otro paso, fullPipelineNetCore o similar, con sus propias stages, viviendo en la misma librería.
Al final cada job elegía, de los pasos disponibles en la librería, el que mejor encajaba con su tipo de aplicación. La librería no era "un pipeline", era "un catálogo de pipelines", y crear un job seguía siendo trivial: eliges cuál de los pasos te corresponde, rellenas los parámetros de siempre y listo. El límite razonable de este enfoque no es el número de aplicaciones, sino el número de patrones distintos que tengas: mientras las variaciones quepan en "un paso más en vars/", el catálogo escala sin volverse un infierno de condicionales.
Versionado sencillo con pipelines experimentales
Para probar cambios en un pipeline sin arriesgarte a romper el que ya está en producción para 25 aplicaciones, la solución que usábamos era tan sencilla como efectiva: en vez de complicarse con ramas o tags de Git para cada prueba, creábamos directamente un paso nuevo con el nombre de la versión siguiente, tipo fullPipelineV2, fullPipelineV3, y así. Ojo, que esto no es lo mismo que fijar la versión de la librería con @Library('...@v1.2.0'), que te cuento más abajo: la etiqueta de Git congela toda la librería, mientras que fullPipelineV2 te deja probar un pipeline nuevo conviviendo con el antiguo dentro de la misma versión de la librería. Los jobs que querías usar como conejillo de indias apuntaban a esa versión concreta; el resto seguía tranquilamente en la anterior. Cuando la nueva versión ya estaba probada, ibas migrando el resto de jobs uno a uno (o todos de golpe si el cambio lo permitía), sin que un cambio a medio probar afectara a nadie que no hubiera decidido probarlo.
Otros pasos útiles en vars/
Para cosas puntuales que no forman parte del flujo principal pero que quieres invocar desde cualquier sitio, como una notificación, un paso suelto sigue teniendo sentido:
def call(String message, String channel = '#builds') {
slackSend(
channel: channel,
color: currentBuild.currentResult == 'SUCCESS' ? 'good' : 'danger',
message: "${message} - ${env.JOB_NAME} #${env.BUILD_NUMBER}"
)
}
Y se usa igual, tanto desde el pipeline completo como desde cualquier otro sitio: notifySlack('Deploy completado'). Ese call es lo que se ejecuta cuando invocas el paso directamente por su nombre, sin necesidad de instanciar nada; es simplemente una convención de Groovy: si un script define call(), puedes invocarlo como si fuera una función.
Registrar la librería en Jenkins
Antes de poder usarla, tienes que decirle a Jenkins dónde está. Esto se hace en Manage Jenkins > System > Global Pipeline Libraries (en versiones antiguas de Jenkins, el menú se llama Configure System), dándole un nombre (por ejemplo mallotore-shared-lib), la URL del repositorio Git, y opcionalmente una rama por defecto. También puedes fijar una versión concreta directamente desde el pipeline script del job, cosa que recomiendo bastante para no llevarte sorpresas si alguien cambia la librería en main sin avisar mientras tienes despliegues en curso:
@Library('mallotore-shared-lib@v1.2.0') _
Y si necesitas lógica con más estado que un simple paso (resolver credenciales o configuración según el entorno, por ejemplo), la metes en src/ como una clase Groovy normal, eso sí, implementando Serializable: Jenkins necesita poder serializar el estado del pipeline en cualquier momento, y sin esa interfaz te encontrarás excepciones bastante crípticas.
Por qué merece la pena el esfuerzo
La primera vez que montas una Shared Library así cuesta más que el enfoque clásico de "cuatro pasos sueltos", porque tienes que pensar el pipeline completo como algo parametrizable para N repos y N entornos distintos. Pero la recompensa es enorme: jobs triviales de crear, cero Jenkinsfile que mantener repo por repo, y la tranquilidad de poder levantar un Jenkins nuevo en paralelo el día que toque un upgrade gordo sin que se te note el sudor frío.