Procés d’integració contínua basat en Github flow amb Jenkins

En la següent entrada mostrarem el disseny d’un procés de integració contínua basant en el branch workflow de github i les pipelines de Jenkins. En aquest procés també s’ha afegit l’anàlisi estàtic de codi amb l’eina Sonarqube1 i el repositori de binaris Nexus2.
Github Flow i personalització
Per dissenyar el procés d'integració contínua s'ha utilitzat el paradigma de model de branques proposat per Github : github Flow
https://guides.github.com/introduction/flow/
Crear una branca ( Branching)

Quan es treballa en un projecte, hi haurà diverses funcions o idees diferents en progrés en qualsevol moment donat, algunes de les quals estaran preparades per desplegar i’d’altres no. El concepte de branching existeix per tal d’ajudar a gestionar aquest flux de treball.
Quan es crea una branca, s’està creant un entorn per provar idees noves. Els canvis que es fan en una branca no afecten la branca mestra, sabent que la branca no es fusionarà fins que estigui preparada per ser revisada per algú amb qui s’està col·laborant. .
Afegir commits

Un cop creada la branca, és el moment de començar a fer canvis. Cada vegada que s’afegeix, edita o suprimeix un fitxer, cal fer un commit per tal d’afegir lo a la branca. És possible (obligatori) afegir missatges per cada commit per tal de realitzar un seguiment del progrés mentre es treballa en una branca.
Els commits també creen una història transparent del treball que els altres poden seguir per entendre el que heu fet i per què. Cada Commits té un missatge de Commit associat, que és una descripció que explica per què es va fer un canvi concret. A més, cada commit es considera una unitat separada de canvi. Aquesta característica permetrà recuperar els canvis si es troba un error, o si es decideix apuntar en una direcció diferent de desenvolupament.
Obrir Pull Request

Els Pull Requests inicien una discussió sobre els commits. Com que aquests estan molt integrats amb el repositori Git subjacent, qualsevol pot veure exactament quins canvis es fusionaran si s’accepta la petició.
Es pot obrir un Pull Requests en qualsevol moment durant el procés de desenvolupament.
Comentar i revisar el codi

Una vegada que s’ha obert un Pull Request, es realitza una revisió de codi de forma sistemàtica. Es designa un revisor (que pot ser una persona o un equip) i juntament amb la persona que ha fet la branca, es revisa el codi. L’ equip que revisa els canvis pot tenir preguntes o comentaris. Potser l’estil de codificació no coincideixi amb les directrius del projecte, no existeixen proves unitàries, o potser tot està correcte. Els Pull Requests estan dissenyats per encoratjar i capturar aquest tipus de converses.
També es pot continuar pujant a la branca a la llum de la discussió i comentaris sobre el pull request. Si algú fa un comentari que indica que s’ha oblidat de fer alguna cosa o si hi ha un error en el codi, es pot arreglar a la branca en qüestió i pujar el canvi. GitHub mostrarà els nous commits i els comentaris addicionals que es pugin rebre a la vista unificada de Pull Requests
Build / Unit Test / Static Code Analysys/ Deploy / Automatic Functional Tests / Manual Functional Tests
Github Webhooks

Els webhooks de github3 permeten a aplicacions subscriure’s a certs esdeveniments que es produeixen en Github.
En el nostre cas, l’aplicació de Jenkins està subscrita a l’event de Pull Request del Github. És a dir, cada vegada que un desenvolupador realitza un pull request, Jenkins inicia l’execució d’ un pipeline.
Jenkins Pipeline
El Pipeline de Jenkins 4 és un conjunt de plugins que donen suport a implementar i integrar continuous delivery pipelines a Jenkins.
Una continuous delivery (CD) pipeline és una expressió automatitzada del procés per obtenir el software des del control de versions fins al l’entrega als usuaris i clients.
Sistemes que intervenen el procés de CI:

Pipeline Pull Request:
El pipeline que s’ha dissenyat en aquest projecte quan es produeix l’event consta de les següents fases:
- 1.- Checkout
- 2.-Build. (npm & nodejs)
- 3.-Execució tests unitaris. (Jasmine & Karma)
- 3.-Anàlisi Estàtic de Codi ( Sonaqube).
- 4.- Desplegament en entorn de test ( En el nostre cas en Docker Containers). En el cas del front-end es desplega en una instància de nginx. En el cas dels serveis de Mock amb Dropwizard, sobre un Apache Tomcat Server.
- 5.- Execució proves automàtiques Funcionals sobre l’entorn de test. (Jasmine & Karma)
- 6.- Proves funcionals manuals.
- 7.-Fi del procés.

Un cop s’ha revisat el Pull Request i tots els processos del pipeline s’han executat satisfactòriament, es procedirà a realitzar un merge del codi en la branca master.
En cas que hi hagi algun error, s’aturarà l’execució del pipeline i s’enviarà un email a l’administrador de la plataforma indicant la naturalesa de l’error.
Merge / Promote
Un cop els canvis s’han verificat, és procedeix a realitzar el Merge del codi a la branca master.
Un cop les branques s’han fusionat, els Pull Requests conserven un registre dels canvis històrics del codi. Com que aquests canvis es poden cercar, es permet que qualsevol usuari torni enrere en el temps a entendre els motius de la decisió que s’ha realitzat.

Pipeline Branca Master
Un cop s’ha realitzat el Merge amb la branca màster, s’iniciarà un nova instància del procés de CI amb Jenkins. En aquest cas, el pipeline executarà els següents processos:
- 1.- Checkout del codi.
- 2.- Build
- 3.- Execució tests unitaris.
- 3.- Anàlisi Estàtic de Codi ( Sonaqube)
- 4.- Desplegament en entorn de Test ( En el nostre cas en Containers Docker)
- 5.- Execució proves automàtiques Funcionals
- 6.- Proves funcionals manuals.
- 7.- Promoció: en aquest punt, si totes les validacions s’han realitzat correctament, el procés restarà a l’espera de la promoció a QA. El responsable de promoció podrà realitzar l aprovació. Si es decideix promocionar el buid, s’executaran les tasques que es detallen a continuació:
- 8.-Número de control de versió: S’assignarà un número de versió del codi i dels objectes del projecte presents a la branca màster, fer servir l’estratègia de Semantic Versioning5
En aquest cas, el procés de CI restarà a l’espera que l’usuari responsable del número de versió o release mànager, esculli una estratègia de versionament seleccionant una de les següents opcions que apareixeran en un formulari:
Major,Minor,Patch,Premajor,Preminor,Prepatch,Prerelease,from-git
Cada una d’aquestes opcions realitzarà un canvi en l’atribut version del fitxer del projecte package.json seguint les directrius proposades per l’esquema semver6 .
En aquesta fase del procés de CI també es persistirà de nou aquest fitxer en el repositori i a continuació es procedirà a realitzar automàticament un tag en el repositori amb el número de versió per tal que es permeti recuperar de forma fàcil qualsevol configuració (codi, artefactes) de forma unívoca.
9.- Desplegament dels binaris : Es desplegaran / emmagatzemaran els binaris i artefactes resultants de l’execució del build en el repositori Nexus amb el número de versió assignat anteriorment.
10. Fi del procés.

Binaris desplegats en Nexus
Check the pipeline code:
pipeline {
environment {
REGISTRY_URL='${YOUR_DOCKER_REGISTRY_URL}'
SONAR_SCANNER='SonarScanner'
}
agent none
tools {
maven 'maven3'
jdk 'JDK 8'
nodejs 'NodeJS'
}
stages {
stage('Checkout') {
agent { label 'master' }
steps{
echo "Starting checkout"
checkout scm
}
}
stage('SonarQube Analysis'){
agent { label 'master' }
steps{
//typescript installed to avoid following error
//https://github.com/SonarSource/SonarTS/issues/453
sh 'npm install -g typescript'
script {
def props = readJSON file: 'frontend/package.json'
echo "${props['version']}"
def properties = readProperties file: 'app.properties'
def scannerHome = tool 'SonarScanner';
withSonarQubeEnv('SonarQube-7.0') {
sh "${scannerHome}/bin/sonar-scanner -Dsonar.projectName='Project Name' -Dsonar.projectKey=projectKey -Dsonar.sourceEncoding=utf-8 -Dsonar.sources=frontend/src -Dsonar.projectVersion=${props['version']} -Dsonar.branch=${env.BRANCH_NAME} "
}
}
}
}
stage('SonarQube Quality Gate'){
agent { label 'master' }
options {
timeout(time: 1, unit: 'HOURS')
}
steps{
script {
def qg = waitForQualityGate() // Reuse taskId previously collected by withSonarQubeEnv
if (qg.status != 'OK') {
emailext (
subject: "Pipeline aborted due to quality gate failure: ${qg.status}",
mimeType: 'text/html',
body: """<p>STARTED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
<p>Check console output at "<a href='/ca/${env.BUILD_URL}/'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>"</p>""",
recipientProviders: [[$class: 'DevelopersRecipientProvider']]
)
// error "Pipeline aborted due to quality gate failure: ${qg.status}"
}
}
}
}
stage ('Build Artifact'){
agent {
docker {
image 'node:9.11.1'
args '-v /etc/passwd:/etc/passwd -v /etc/group:/etc/group -v /var/lib/jenkins/.config:/var/lib/jenkins/.config -v /var/lib/jenkins/.npmrc:/var/lib/jenkins/.npmrc -v /var/lib/jenkins/.npm:/var/lib/jenkins/.npm'
}
}
steps{
dir ('frontend/') {
sh 'node --version'
sh 'npm -v'
sh 'npm install'
sh 'npm run build-test'
}
}
}
stage('Build Docker Image') {
agent { label 'master' }
steps{
script{
def properties = readProperties file: 'app.properties'
def props = readJSON file: 'frontend/package.json'
// STOP DOCKER IMAGE
def idContainer = sh returnStdout: true, script: "docker ps -a -q --filter name=${properties.serviceName} --format=\"{{.ID}}\""
if (idContainer?.trim()) {
sh "docker rm \$(docker stop ${idContainer})"
}
// BUILD DOCKER IMAGE
sh "docker build -t ${properties.serviceName}:${props['version']} ."
sh "docker run -d --name ${properties.serviceName} -p 8096:80 ${properties.serviceName}:${props['version']}"
}
}
}
stage ('Build Artifact RC'){
agent {
docker {
image 'node:9.11.1'
args '-v /etc/passwd:/etc/passwd -v /etc/group:/etc/group -v /var/lib/jenkins/.config:/var/lib/jenkins/.config -v /var/lib/jenkins/.npmrc:/var/lib/jenkins/.npmrc -v /var/lib/jenkins/.npm:/var/lib/jenkins/.npm'
}
}
when { branch "master" }
steps{
dir ('frontend/') {
sh 'npm install -registry http://registry.npmjs.org/'
sh 'npm run build'
}
}
}
stage('SonarQube Transpiled Code Analysis'){
agent { label 'master' }
when { branch "master" }
steps{
script{
def userInput = true
def didTimeout = false
try {
timeout(time: 3600, unit: 'SECONDS') { // change to a convenient timeout for you
userInput = input(
id: 'Proceed1', message: 'Do you want to analize transpiled dist code?', parameters: [
[$class: 'BooleanParameterDefinition', defaultValue: true, description: '', name: 'Please confirm you agree with this']
])
}
} catch(err) { // timeout reached or input false
// Note: This code will require you to approve the getCauses() method inside of script security under Manage Jenkins> In-process Script Approval:
def user = err.getCauses()[0].getUser()
if('SYSTEM' == user.toString()) { // SYSTEM means timeout.
didTimeout = true
} else {
userInput = false
echo "Aborted by: [${user}]"
}
}
if (didTimeout) {
// do something on timeout
echo "no input was received before timeout"
} else if (userInput == true) {
// do something
def props = readJSON file: 'frontend/package.json'
echo "${props['version']}"
def properties = readProperties file: 'app.properties'
def scannerHome = tool 'SonarScanner';
withSonarQubeEnv('SonarQube-7.0') {
sh "${scannerHome}/bin/sonar-scanner -Dsonar.projectName='Project Name' -Dsonar.projectKey=projectKey -Dsonar.sourceEncoding=utf-8 -Dsonar.sources=frontend/dist -Dsonar.projectVersion=${props['version']} -Dsonar.branch=${env.BRANCH_NAME} "
}
} else {
// do something else
echo "no transpiled code analysis"
//currentBuild.result = 'FAILURE'
}
}
}
}
stage('Promotion Service'){
agent { label 'master' }
when { branch "master" }
steps{
script{
def userInput = true
def didTimeout = false
try {
timeout(time: 3600, unit: 'SECONDS') { // change to a convenient timeout for you
userInput = input(
id: 'Proceed1', message: 'Do you want to promote?', parameters: [
[$class: 'BooleanParameterDefinition', defaultValue: true, description: '', name: 'Please confirm you agree with this']
])
}
} catch(err) { // timeout reached or input false
// Note: This code will require you to approve the getCauses() method inside of script security under Manage Jenkins> In-process Script Approval:
def user = err.getCauses()[0].getUser()
if('SYSTEM' == user.toString()) { // SYSTEM means timeout.
didTimeout = true
} else {
userInput = false
echo "Aborted by: [${user}]"
}
}
if (didTimeout) {
// do something on timeout
echo "no input was received before timeout"
} else if (userInput == true) {
// do something
def properties = readProperties file: 'app.properties'
def props = readJSON file: 'frontend/package.json'
// STOP DOCKER IMAGE
def idContainer = sh returnStdout: true, script: "docker ps -a -q --filter name=${properties.serviceName}-rc --format=\"{{.ID}}\""
if (idContainer?.trim()) {
sh "docker rm \$(docker stop ${idContainer})";
}
// BUILD DOCKER IMAGE
sh "docker build -t ${properties.serviceName}:${props['version']}-rc ."
sh "docker run -d --name ${properties.serviceName}-rc -p 8097:80 ${properties.serviceName}:${props['version']}-rc"
} else {
// do something else
echo "this was not successful"
currentBuild.result = 'FAILURE'
}
}
}
}
stage ('Deploy Artifact'){
agent {
docker {
image 'timbru31/node-alpine-git'
args '-v /etc/passwd:/etc/passwd -v /etc/group:/etc/group -v /var/lib/jenkins/.gitconfig:/var/lib/jenkins/.gitconfig -v /var/lib/jenkins/.config:/var/lib/jenkins/.config -v /var/lib/jenkins/.npmrc:/var/lib/jenkins/.npmrc -v /var/lib/jenkins/.npm:/var/lib/jenkins/.npm'
}
}
when { branch "master" }
options {
timeout(time: 1, unit: 'HOURS')
}
steps{
script {
def distribute = input(
id: 'userInput', message: "Do you want to distibute code? ", parameters: [
[$class: 'ChoiceParameterDefinition', description: 'delimiters within string', ,choices: 'yes\nno\n', name: 'select']
])
switch (distribute) {
case "yes":
def typeVersion = input(
id: 'userInput', message: "Which Version? ", parameters: [
[$class: 'ChoiceParameterDefinition', description: 'delimiters within string',choices: 'major\nminor\npatch\npremajor\npreminor\nprepatch\nprerelease\nfrom-git\n', name: 'select']
])
def workspace = pwd();
dir ('frontend/') {
sh "npm version ${typeVersion}";
sh "cp package.json ${workspace}/frontend/dist/";
sh "cp .npmrc ${workspace}/frontend/dist/";
}
withCredentials([usernamePassword(credentialsId: 'token', passwordVariable: 'GIT_PASSWORD', usernameVariable: 'GIT_USERNAME')]) {
sh "git add ${workspace}/frontend/package.json";
sh "git commit -m \"Set version number\"";
sh 'git push https://${GIT_USERNAME}:${GIT_PASSWORD}@{YOUR GIT URL} HEAD:master';
}
dir ('frontend/dist/') {
try {
sh "npm publish";
} catch (e){
sh "cp /root/.npm/_logs/* ."
error "Pipeline aborted due to error publishing package"
}
}
break
case "no":
break
}
}
}
}
stage('Cleanup'){
agent {
docker {
image 'node:9.11.1'
args '-v /etc/passwd:/etc/passwd -v /etc/group:/etc/group -v /var/lib/jenkins/.config:/var/lib/jenkins/.config -v /var/lib/jenkins/.npmrc:/var/lib/jenkins/.npmrc -v /var/lib/jenkins/.npm:/var/lib/jenkins/.npm'
}
}
steps{
dir ('frontend/') {
echo 'prune and cleanup'
sh 'npm prune'
//sh 'rm node_modules -rf'
}
}
}
}
}
def setNewVersion(version){
// Run the maven build
sh "mvn clean versions:set -DnewVersion=${version}"
}
String getShortCommit(){
sh "git rev-parse --short HEAD > .git/commit-id"
def commit_id = readFile('.git/commit-id')
echo "Git commit $commit_id"
def shortGitCommit = commit_id[0..7]
return shortGitCommit;
}
private String getCommitIdCmd() {
String _cmd = "git rev-parse --short HEAD > .git/commit-id";
return _cmd;
}
private String createEnvLabels() {
String _envLabels = "";
for (property in this.getEnvPropertiesByEnvironment()) {
_envLabels += "-e " + property.key + "=" + property.value + " ";
}
return _envLabels;
}
public boolean serviceExists() {
String _cmd = this.serviceExistsCmd();
this.runCmd(_cmd);
boolean _created = false;
// Read status file.
def _out = this.pipeline.readFile('status').trim();
// If status value is not 0, service was created.
if (Integer.parseInt(_out) > 0) {
_created = true;
}
this.logger.info "created = " + _created;
return _created;
}
private String serviceExistsCmd(serviceName) {
String _cmd = "docker service ls";
_cmd += " | grep -e \"[[:space:]]" + serviceName + "[[:space:]]\"";
_cmd += " | wc -l";
_cmd += " > status";
return _cmd;
}
private Properties getEnvPropertiesByEnvironment(serviceEnvironment) {
String _envKey = serviceEnvironment != "rc" ? "qa" : "rc";
def _environmentVars = new Properties();
for (property in this.serviceEnvironmentsVars) {
String key = property.key;
String[] envKeySet = key.tokenize('.');
String envK = envKeySet[0];
String elem = envKeySet[1];
if (envK == _envKey) {
this.logger.info(elem + "=" + property.value);
_environmentVars.setProperty(elem, property.value);
}
}
return _environmentVars;
}
- https://www.sonarqube.org/ [↩]
- https://www.sonatype.com/product-nexus-repository [↩]
- https://developer.github.com/webhooks/ [↩]
- https://jenkins.io/doc/book/pipeline/ [↩]
- https://docs.npmjs.com/getting-started/semantic-versioning [↩]
- https://semver.org/ [↩]
Related Posts