CI / CD with Jenkins

Jenkins logo

Introduction

Workflow diagram overview

git-branch-protection.png

Jenkins orchestration

No docker-compose, este é o serviço do Jenkins:

    jenkins:
        container_name: jgomes_jenkins
        user: "1000:1002"
        restart: always
        build:
            context: './prod-services/jenkins'
        ports:
            - "8891:8080"
            - "50001:50001"
        volumes:
            - jenkins-data:/var/jenkins_home
        depends_on:
            - phpmyadmin
        networks:
            - jgomes-site_prod-docker

No docker-compose, o Dockerfile está em /prod-services/jenkins

FROM jenkins/jenkins

USER root

# Adicionar o usuário Jenkins ao grupo sudo
RUN usermod -aG sudo jenkins

RUN echo 'jenkins:jenkins' | chpasswd && \
   mkdir -p /etc/sudoers.d/ && \
   echo 'jenkins ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/jenkins

# Adicional tool and extensions to PHP
RUN apt-get update \
   && apt-get install -y sudo vim curl iputils-ping nano php-cli php-curl php-xml php-json php-mbstring php-tokenizer php-xmlwriter libxml2-dev \
   && rm -rf /var/lib/apt/lists/*

RUN sudo apt-get install ca-certificates gnupg
RUN sudo install -m 0755 -d /etc/apt/keyrings
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
RUN sudo chmod a+r /etc/apt/keyrings/docker.gpg

# Add the repository to Apt sources:
RUN echo \
 "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
 $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
 sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

RUN sudo apt-get update

# Add composer
RUN curl -sS https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin --filename=composer

Jenkins VHosts details:

Jenkins service vhost
<IfModule mod_ssl.c>
       
       LoadModule proxy_module modules/mod_proxy.so
       LoadModule proxy_http_module modules/mod_proxy_http.so
	    LoadModule headers_module modules/mod_headers.so

       <VirtualHost *:443>
       
               ServerAdmin zx.gomes@gmail.com
               ServerName jjenkins.xyz
               ErrorLog /var/log/apache2/jenkins_error.log
               CustomLog ${APACHE_LOG_DIR}/jenkins_access.log combined
               SSLEngine on
               SSLCertificateFile /var/www/html/site-jgomes-prod-infra/certs/jenkins.crt
               SSLCertificateKeyFile /var/www/html/site-jgomes-prod-infra/certs/jenkins.key
               SSLCertificateChainFile /var/www/html/site-jgomes-prod-infra/certs/jenkins.ca-bundle

               ProxyRequests Off
               ProxyPass / http://localhost:8891/ nocanon
               ProxyPassReverse / http://localhost:8891/
       
               RequestHeader set X-Forwarded-Proto "https"
               RequestHeader set X-Forwarded-Port "443"

	        <Location />
	           Order allow,deny
		       Allow from all
		       AllowOverride all
	        </Location>
	        
       </VirtualHost>
       
</IfModule>
Jenkins redirect port 80->443 vhost
<VirtualHost *:80>
     ServerName jjenkins.xyz
     ServerAlias www.jjenkins.xyz
     Redirect permanent / https://jjenkins.xyz/
</VirtualHost>

Jenkins SSL

SSLEngine on
SSLCertificateFile  {path_to_file}/jenkins.crt
SSLCertificateKeyFile {path_to_file}/jenkins.key
SSLCertificateChainFile {path_to_file}/jenkins.ca-bundle

Jenkinsfile Laravel side

import groovy.json.JsonBuilder

def sshCredentials         = null
def remoteUser             = null
def remoteHost             = null
def remoteProjectDir       = null
def lastRemoteCommandError = null
def remoteCommandPrefix    = null

def executeRemoteCommand(command, remoteCommandPrefix)
{
   // Prepare full cmd
   def remoteCommandComplete = "${remoteCommandPrefix} ${command}'"

   // Tmp Jenkins to save thr logs
   def outputFile = "/tmp/${command.hashCode()}_output.txt"

   // Execute the remote command and redirect standard output and error to the file
   def result = sh(script: "${remoteCommandComplete} > ${outputFile} 2>&1", returnStatus: true)

   // Read standard output and error from the file
   def outputContent = readFile(file: outputFile).trim()

   // Return both output and exit code
   return [output: outputContent, exitCode: result]
}

pipeline
{
   agent any
   stages
   {
       stage('Get ENV vars')
       {
           steps
           {
               script
               {
                   sshCredentials      = env.SSH_CREDENTIALS
                   remoteUser          = env.REMOTE_USER
                   remoteHost          = env.REMOTE_HOST
                   remoteProjectDir    = env.REMOTE_PROJECT_DIR
                   remoteCommandPrefix = "ssh -o StrictHostKeyChecking=no ${remoteUser}@${remoteHost} 'cd ${remoteProjectDir} &&"
               }
           }
       }
       stage('Checkout')
       {
           steps
           {
              echo 'Do the checkout from the repo and put the code i this context.'
              checkout scm
           }
       }
       stage('Build')
       {
           steps
           {
               // Mandatory as I want to run unit tests using the phpunit from vendor
               echo 'Run composer'
               sh 'composer update'

               // .env file is mandatory to generate app key
               echo 'Copy dev .env file'
               sh 'cp .env.test .env'

           }
       }
       stage('Tests')
       {
           steps
           {
               echo 'Run tests'
               sh 'vendor/bin/phpunit'
           }
       }
       stage('Deploy')
       {
           when
           {
               // Only deploy to prod if master
               expression
               {
                   return (env.BRANCH_NAME == 'master')
               }
           }
           steps
           {
               script
               {
                   sshagent(credentials: [sshCredentials])
                   {
                       def commands = [

                            // Do deploy
                           'git reset --hard HEAD && git pull origin master',

                            // Do composer update, migration, and clean all backend caches
                           'APP_ENV=prod RABBIT_HOST=0.0.0.0 composer update && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan migrate && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan route:clear && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan config:clear && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan cache:clear',

                            // Do client files versioning
                           'npm cache clean --force && npm install && npm run production',

                            // Do phpunit report
                           'vendor/bin/phpunit --coverage-html storage/coverage-report && sed -i "s|<head>|<head><title>Coverage</title>|" "storage/coverage-report/index.html" && sed -i "s|<head>|<head><title>Dashboard</title>|" "storage/coverage-report/dashboard.html" && find "storage/coverage-report" -type f -exec sed -i "s#/var/www/html/site-jgomes-prod-infra/site-jgomes/app#(Coverage)#g" {} +'
                       ]

                       for (command in commands)
                       {
                           def commandResult = executeRemoteCommand(command, remoteCommandPrefix)
                           if (commandResult.exitCode != 0)
                           {
                               lastRemoteCommandError = commandResult.output
                               currentBuild.result    = 'FAILURE'
                               echo commandResult.output
                               error("The pipeline was interrupted during deployment while executing the command: ${command}")
                               return
                           }
                       }
                   }
               }
           }
       }
   }
   post
   {
       failure
       {
           script
           {
               // Check of the branch is master
               if (env.BRANCH_NAME == 'master')
               {
                   sshagent(credentials: [sshCredentials])
                   {
                       echo "Send pipeline failure notification with the error.."

                       // Prepare error message
                       def jsonError = new JsonBuilder(lastRemoteCommandError).toPrettyString().replaceAll('"', '\\"')

                       // Prepare command
                       command = "APP_ENV=prod php artisan pipeline:result --result=nok --msg=${jsonError}"

                       // Execute command
                       executeRemoteCommand(command, remoteCommandPrefix)
                   }
               }
           }
       }
       success
       {
           script
           {
               // Check of the branch is master
               if (env.BRANCH_NAME == 'master')
               {
                   sshagent(credentials: [sshCredentials])
                   {
                       echo "Send pipeline success notification.."

                       // Prepare command
                       command = 'APP_ENV=prod php artisan pipeline:result --result=ok --msg=ok'

                       // Execute command
                       executeRemoteCommand(command, remoteCommandPrefix)
                   }
               }
           }
       }
   }
}

Explanation of the Jenkinsfile

The Jenkins file above is a declarative script that defines a workflow for continuous integration and continuous delivery (CI/CD) of a PHP application.

Here's a summary of what each stage does:

In summary, this Jenkinsfile automates the process of continuous integration and delivery of a PHP application, from building to deployment in a production environment, using Jenkins and SSH for automation and status notification.

Jenkins interface configuration

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setupsudo iptables-save

jenkins setup

Jenkins plugins

jenkins setup

jenkins setup

jenkins setup

jenkins setup

jenkins setup

Jenkins gitHub configuration

jenkins setup

jenkins setup

Jenkins env vars configuration

jenkins setup

Extra notes

Demonstration

( Click on the image to watch the demo video )

Demonstration video

Update - 22/05/2024

The goal here was to create a testing DB for unit testing proposes:

1) Update the init.sql
-- Drop the production/test user and database if they exist
DROP USER IF EXISTS 'user_prod'@'%';
DROP USER IF EXISTS 'user_test'@'%'; <----- here

DROP DATABASE IF EXISTS jgomes_site_prod;
DROP DATABASE IF EXISTS jgomes_site_prod_test; <----- here

-- Create the production database
CREATE DATABASE jgomes_site_prod;
CREATE DATABASE jgomes_site_prod_test; <----- here

-- Create the production user and grant permissions
CREATE USER 'user_prod'@'%' IDENTIFIED BY '******';
GRANT ALL PRIVILEGES ON jgomes_site_prod.* TO 'user_prod'@'%';

-- Create test user and grant permissions
CREATE USER 'user_test'@'%' IDENTIFIED BY '*****'; <----- here
GRANT ALL PRIVILEGES ON jgomes_site_prod_test.* TO 'user_test'@'%'; <----- here

-- Flush privileges to apply changes
FLUSH PRIVILEGES;
2) Update the Jenkinsfile
import groovy.json.JsonBuilder

def sshCredentials         = null
def remoteUser             = null
def remoteHost             = null
def remoteProjectDir       = null
def lastRemoteCommandError = null
def remoteCommandPrefix    = null

def executeRemoteCommand(command, remoteCommandPrefix)
{
    // Prepare full cmd
    def remoteCommandComplete = "${remoteCommandPrefix} ${command}'"

    // Tmp Jenkins to save thr logs
    def outputFile = "/tmp/${command.hashCode()}_output.txt"

    // Execute the remote command and redirect standard output and error to the file
    def result = sh(script: "${remoteCommandComplete} > ${outputFile} 2>&1", returnStatus: true)

    // Read standard output and error from the file
    def outputContent = readFile(file: outputFile).trim()

    // Return both output and exit code
    return [output: outputContent, exitCode: result]
}

pipeline
{
    agent any
    stages
    {
        stage('Get ENV vars')
        {
            steps
            {
                script
                {
                    sshCredentials      = env.SSH_CREDENTIALS
                    remoteUser          = env.REMOTE_USER
                    remoteHost          = env.REMOTE_HOST
                    remoteProjectDir    = env.REMOTE_PROJECT_DIR
                    remoteCommandPrefix = "ssh -o StrictHostKeyChecking=no ${remoteUser}@${remoteHost} 'cd ${remoteProjectDir} &&"
                    remoteTestDB        = env.REMOTE_TEST_DB <----- here
                    remoteTestDBUser    = env.REMOTE_TEST_DB_USER <----- here
                    remoteTestDBPass    = env.REMOTE_TEST_DB_PASS <----- here
                    remoteTestCommandPrefix = "DB_DATABASE=${remoteTestDB} DB_USERNAME=${remoteTestDBUser} DB_PASSWORD=${remoteTestDBPass}" <----- here
                }
            }
        }
        stage('Checkout')
        {
            steps
            {
               echo 'Do the checkout from the repo and put the code i this context.'
               checkout scm
            }
        }
        stage('Build')
        {
            steps
            {
                // Mandatory as I want to run unit tests using the phpunit from vendor
                echo 'Run composer'
                sh 'composer update'

                // Start MySQL with skip-grant-tables
                sh 'sudo mysqld_safe --skip-grant-tables &'

                // .env file is mandatory to generate app key
                echo 'Copy dev .env file'
                sh 'cp .env.test .env'

                // App key is mandatory to run tests
                // echo 'Generate application key'
                // sh 'php artisan key:generate'

                sh 'php artisan migrate'
            }
        }
        stage('Tests')
        {
            steps
            {
                echo 'Run tests'
                sh 'vendor/bin/phpunit'
            }
        }
        stage('Deploy')
        {
            when
            {
                // Only deploy to prod if master
                expression
                {
                    return (env.BRANCH_NAME == 'master')
                }
            }
            steps
            {
                script
                {
                    sshagent(credentials: [sshCredentials])
                    {
                        def commands = [

                             // Do deploy
                            'git reset --hard HEAD && git pull origin master',

                             // Do composer update, migration, and clean all backend caches
                            'APP_ENV=prod RABBIT_HOST=0.0.0.0 composer update && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan migrate && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan route:clear && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan config:clear && APP_ENV=prod RABBIT_HOST=0.0.0.0 php artisan cache:clear',

                             // Do client files versioning
                            'npm cache clean --force && npm install && npm run production',

                             // Create testing DB to do phpunit report after
                            "${remoteTestCommandPrefix} php artisan migrate", <----- here

                            // Do phpunit report
                            "${remoteTestCommandPrefix} vendor/bin/phpunit --coverage-html storage/coverage-report && sed -i \"s|<head>|<head><title>Coverage</title>|\" \"storage/coverage-report/index.html\" && sed -i \"s|<head>|<head><title>Dashboard</title>|\" \"storage/coverage-report/dashboard.html\" && find \"storage/coverage-report\" -type f -exec sed -i \"s#/var/www/html/site-jgomes-prod-infra/site-jgomes/app#(Coverage)#g\" {} +" <----- here

                            ]

                        for (command in commands)
                        {
                            def commandResult = executeRemoteCommand(command, remoteCommandPrefix)
                            if (commandResult.exitCode != 0)
                            {
                                lastRemoteCommandError = commandResult.output
                                currentBuild.result    = 'FAILURE'
                                echo commandResult.output
                                error("The pipeline was interrupted during deployment while executing the command: ${command}")
                                return
                            }
                        }
                    }
                }
            }
        }
    }
    post
    {
        failure
        {
            script
            {
                // Check of the branch is master
                if (env.BRANCH_NAME == 'master')
                {
                    sshagent(credentials: [sshCredentials])
                    {
                        echo "Send pipeline failure notification with the error.."

                        // Prepare error message
                        def jsonError = new JsonBuilder(lastRemoteCommandError).toPrettyString().replaceAll('"', '\\"')

                        // Prepare command
                        command = "APP_ENV=prod php artisan pipeline:result --result=nok --msg=${jsonError}"

                        // Execute command
                        executeRemoteCommand(command, remoteCommandPrefix)
                    }
                }
            }
        }
        success
        {
            script
            {
                // Check of the branch is master
                if (env.BRANCH_NAME == 'master')
                {
                    sshagent(credentials: [sshCredentials])
                    {
                        echo "Send pipeline success notification.."

                        // Prepare command
                        command = 'APP_ENV=prod php artisan pipeline:result --result=ok --msg=ok'

                        // Execute command
                        executeRemoteCommand(command, remoteCommandPrefix)
                    }
                }
            }
        }
    }
}

3) Added the env vars to Jenkins

jenkins setup