Building a CI Pipeline for PetClinic Application Project: Step-by-Step Instructions

Building a CI Pipeline for PetClinic Application Project: Step-by-Step Instructions

Feature Branch Workflow with Docker Image Testing and Slack Notification

Why Test the Docker Image in the Feature Branch Pipeline?

  1. Catch Issues Early:

    • If there are bugs in the Docker image or environment setup, it’s much better to catch them at the feature branch level. This way, you don’t introduce broken code into the develop branch.

    • Running the image in the feature branch gives you immediate feedback about whether your feature is functioning as expected within its containerized environment.

  2. Reduce Feedback Loop Time:

    • By running and testing the Docker image directly in the feature branch, you reduce the number of cycles between identifying an issue and fixing it.

    • If something goes wrong, the feedback will come earlier, and you can fix it before the code ever makes it to the develop branch.

  3. Improved Code Quality in Develop Branch:

    • When the PR is raised, the code is more likely to be in a stable state since the image has already been tested. This reduces the number of issues that could pop up in the develop branch pipeline.

Best Practice Suggestion:

Incorporate Testing in the Feature Branch Pipeline:

  • In addition to building the Docker image in the feature branch pipeline, you can run the Docker container and test it in a temporary environment or use a test service.

  • For example, after building the Docker image:

    1. Run the Docker container using docker run or docker-compose.

    2. Run a basic integration test suite against the running container (e.g., check if endpoints are working, database connections, etc.).

    3. If everything passes, mark the feature as stable and ready for PR.

Feature Branch Workflow (CI Pipeline on Feature Branch)

  1. Developer Work:

    • Developers work on new features in the feature branch.

    • The changes are pushed to the feature branch in the Git repository.

  2. Create Pull Request (PR):

    • Developers create a Pull Request (PR) to merge the feature branch into the develop branch.

    • This initiates the review process by peers or leads.

  3. CI Pipeline for Feature Branch:

    • Jenkins pipeline is triggered automatically upon a code push or PR creation.

    • Key CI Steps:

      • Git Checkout: Pulls the latest feature branch code.

      • Trivy Scan (Repository): Scans the source code for vulnerabilities.

      • Run Unit Tests: Runs unit tests to verify code integrity.

      • Generate JaCoCo Coverage Report: Generates a code coverage report.

      • SonarQube Analysis: Performs static code analysis for code quality.

      • Build Docker Image: Builds the Docker image for the application.

      • Test Docker Image: Starts the MySQL container, runs the Docker image of the PetClinic app, checks the health, and ensures everything works.

      • Trivy Scan (Docker Image): Scans the Docker image for vulnerabilities.

      • Push Docker Image to AWS ECR: If the Docker image passes all tests, it is pushed to the AWS ECR.

  4. Slack Notification:

    • Before the PR Review, a Slack notification is sent to inform the team that the feature branch code is working fine, the Docker image has been tested, and the application is functional. This ensures that the team has visibility into the status of the feature before they begin the code review.
  5. Merge PR Review:

    • Reviewers review the code and pipeline results.

    • If the code passes all quality checks (unit tests, security scans, Docker image testing), and the PR is approved, the feature branch is merged into the develop branch.

Pre-Requisites:

  1. Set up a Jenkins Server with necessary plugins

  2. Install AWS CLI on Jenkins Server

  3. Create Private repository using Amazon Elastic Container Registry(ECR)

  4. Install Docker on Jenkins Server

  5. Install Trivy on Jenkins Server

  6. Set up a SonarQube Server

  7. Configure Jenkins Credentials for AWS, SonarQube Token, GitHub, Slack Token:

Complete Pipeline script:

pipeline {
    agent any

    tools {
        jdk 'JDK 17'  // Name that matches your Jenkins configuration
        maven 'maven 3.9.8'  // Make sure this matches the Maven name configured in Global Tool Configuration
    }

    environment {
        GIT_REPO = '<https://github.com/SubbuTechTutorials/spring-petclinic.git>'
        GIT_BRANCH = 'feature'
        GIT_CREDENTIALS_ID = 'github-credentials'
        TRIVY_PAT_CREDENTIALS_ID = 'github-pat'

        // SonarQube settings
        SONARQUBE_HOST_URL = '<http://44.201.120.105:9000/>'  // Replace with your SonarQube URL
        SONARQUBE_PROJECT_KEY = 'PetClinic'
        SONARQUBE_TOKEN = credentials('sonar-credentials')

        // AWS ECR settings
        AWS_ACCOUNT_ID = '905418425077'  // Replace with your AWS Account ID
        ECR_REPO_URL = "${AWS_ACCOUNT_ID}.dkr.ecr.ap-south-1.amazonaws.com/dev/petclinic"
        AWS_REGION_ECR = 'ap-south-1'  // ECR region

        // EKS Cluster name and region
        EKS_CLUSTER_NAME = 'devops-petclinicapp-dev-ap-south-1'
        AWS_REGION_EKS = 'ap-south-1'  // EKS region

        // Set local directory to cache Trivy DB
        TRIVY_DB_CACHE = "/var/lib/jenkins/trivy-db"
    }

    options {
        // Skip stages after unstable or failure
        skipStagesAfterUnstable()
    }

    stages {
        stage('Checkout Code') {
            steps {
                git branch: "${GIT_BRANCH}", url: "${GIT_REPO}", credentialsId: "${GIT_CREDENTIALS_ID}"
                stash name: 'source-code', includes: '**/*'
            }
        }

        stage('Trivy Scan Repository') {
            steps {
                script {
                    if (!fileExists('trivy-scan-success')) {
                        sh "mkdir -p ${TRIVY_DB_CACHE}"
                        withCredentials([string(credentialsId: "${TRIVY_PAT_CREDENTIALS_ID}", variable: 'GITHUB_TOKEN')]) {
                            sh 'export TRIVY_AUTH_TOKEN=$GITHUB_TOKEN'
                            def dbExists = sh(script: "test -f ${TRIVY_DB_CACHE}/db.lock && echo 'true' || echo 'false'", returnStdout: true).trim()
                            if (dbExists == 'true') {
                                sh "trivy fs --cache-dir ${TRIVY_DB_CACHE} --skip-db-update --exit-code 1 --severity HIGH,CRITICAL ."
                            } else {
                                sh "trivy fs --cache-dir ${TRIVY_DB_CACHE} --exit-code 1 --severity HIGH,CRITICAL ."
                            }
                        }
                        writeFile file: 'trivy-scan-success', text: ''
                    }
                }
            }
        }

        stage('Run Unit Tests') {
            steps {
                script {
                    if (!fileExists('unit-tests-success')) {
                        sh 'mvn test -DskipTests=false'
                        writeFile file: 'unit-tests-success', text: ''
                    }
                }
            }
        }

        stage('Generate JaCoCo Coverage Report') {
            steps {
                script {
                    if (!fileExists('jacoco-report-success')) {
                        sh 'mvn jacoco:report'
                        writeFile file: 'jacoco-report-success', text: ''
                    }
                }
            }
        }

        stage('SonarQube Analysis') {
            steps {
                script {
                    if (!fileExists('sonarqube-analysis-success')) {
                        withSonarQubeEnv('SonarQube') {
                            sh """
                            mvn clean verify sonar:sonar \\
                            -Dsonar.projectKey=${SONARQUBE_PROJECT_KEY} \\
                            -Dsonar.host.url=${SONARQUBE_HOST_URL} \\
                            -Dsonar.login=${SONARQUBE_TOKEN}
                            """
                        }
                        writeFile file: 'sonarqube-analysis-success', text: ''
                    }
                }
            }
        }

        stage('Build Docker Image') {
            steps {
                script {
                    if (!fileExists('docker-build-success')) {
                        // Get the short Git commit hash and define DOCKER_IMAGE here
                        def COMMIT_HASH = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
                        def IMAGE_TAG = "${COMMIT_HASH}-${env.BUILD_NUMBER}"
                        env.DOCKER_IMAGE = "${ECR_REPO_URL}:${IMAGE_TAG}"  // Defined DOCKER_IMAGE here

                        // Build the Docker image and tag it with Git commit hash and build number
                        sh "docker build -t ${env.DOCKER_IMAGE} . --progress=plain"
                        writeFile file: 'docker-build-success', text: ''
                    }
                }
            }
        }

        stage('Test Docker Image with MySQL') {
            steps {
                script {
                    def mysqlContainerName = "mysql-test"
                    def petclinicContainerName = "petclinic-test"
                    def petclinicImage = "${env.DOCKER_IMAGE}"

                    try {
                        // Start MySQL container with version 8.4
                        sh """
                        docker run -d --name ${mysqlContainerName} \\
                        -e MYSQL_ROOT_PASSWORD=root \\
                        -e MYSQL_DATABASE=petclinic \\
                        -e MYSQL_USER=petclinic \\
                        -e MYSQL_PASSWORD=petclinic \\
                        mysql:8.4
                        """

                        // Wait for MySQL to be ready (simple loop to wait for a healthy state)
                        def maxRetries = 10
                        def retryInterval = 10
                        def isMysqlReady = false

                        for (int i = 0; i < maxRetries; i++) {
                            echo "Waiting for MySQL to be ready (Attempt ${i + 1}/${maxRetries})..."
                            def mysqlStatus = sh(script: "docker exec ${mysqlContainerName} mysqladmin ping -u root -proot", returnStatus: true)
                            if (mysqlStatus == 0) {
                                isMysqlReady = true
                                echo "MySQL is ready."
                                break
                            }
                            sleep retryInterval
                        }

                        if (!isMysqlReady) {
                            error('MySQL container did not become ready.')
                        }

                        // Run PetClinic container with MySQL as the backend
                        sh """
                        docker run -d --name ${petclinicContainerName} \\
                        --link ${mysqlContainerName}:mysql \\
                        -e MYSQL_URL=jdbc:mysql://mysql:3306/petclinic \\
                        -e MYSQL_USER=petclinic \\
                        -e MYSQL_PASSWORD=petclinic \\
                        -e MYSQL_ROOT_PASSWORD=root \\
                        -p 8082:8081 ${petclinicImage}
                        """

                        // Wait for PetClinic to be ready (Check the health endpoint on port 8082)
                        def petclinicHealth = false
                        for (int i = 0; i < maxRetries; i++) {
                            echo "Checking PetClinic health (Attempt ${i + 1}/${maxRetries})..."
                            def healthStatus = sh(script: "curl -s <http://localhost:8082/actuator/health> | grep UP", returnStatus: true)
                            if (healthStatus == 0) {
                                petclinicHealth = true
                                echo "PetClinic is healthy."
                                break
                            }
                            sleep retryInterval
                        }

                        if (!petclinicHealth) {
                            echo 'Collecting logs from PetClinic container...'
                            sh "docker logs ${petclinicContainerName}"
                            error('PetClinic application did not become healthy.')
                        }

                        echo "PetClinic and MySQL containers are running and healthy."
                    } finally {
                        // Clean up containers (always clean up whether successful or not)
                        sh "docker stop ${mysqlContainerName} ${petclinicContainerName} || true"
                        sh "docker rm ${mysqlContainerName} ${petclinicContainerName} || true"
                    }
                }
            }
        }

        stage('Scan Docker Image with Trivy') {
            steps {
                script {
                    // Scanning the built Docker image with Trivy using cached DB
                    sh "trivy image --cache-dir ${TRIVY_DB_CACHE} --skip-db-update ${env.DOCKER_IMAGE}"
                }
            }
        }

        stage('Push Docker Image to AWS ECR') {
            steps {
                script {
                    if (!fileExists('docker-push-success')) {
                        withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'aws-eks-credentials']]) {
                            sh """
                            # Login to AWS ECR
                            aws ecr get-login-password --region ${AWS_REGION_ECR} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION_ECR}.amazonaws.com

                            # Push the Docker image to AWS ECR
                            docker push ${env.DOCKER_IMAGE}
                            """
                        }
                        writeFile file: 'docker-push-success', text: ''  // Mark Docker Push as successful
                    }
                }
            }
        }
    }

    post {
        always {
            cleanWs()  // Clean up the workspace after the build
        }
        success {
            slackSend (channel: '#project-petclinic', color: 'good', message: "SUCCESS: Job '${env.JOB_NAME}' build #${currentBuild.number} succeeded.")
        }
        failure {
            slackSend (channel: '#project-petclinic', color: 'danger', message: "FAILURE: Job '${env.JOB_NAME} build [${currentBuild.number}]' failed.")
        }
        unstable {
            slackSend (channel: '#project-petclinic', color: 'warning', message: "UNSTABLE: Job '${env.JOB_NAME} build [${currentBuild.number}]' is unstable.")
        }
    }
}

Our job pipeline was successfully executed after resolving initial port conflicts between the Petclinic application container and the Jenkins server.

Stage-1: Checkout Code

Stage-2: Trivy Scan Repository

Stage-3: Run Unit Tests

Stage-4: Generate JaCoCo Coverage Report

Stage-5: SonarQube Analysis

Stage-6: Build Docker Image

Stage-7: Test Application Docker Image with MySQL

Stage-8: Scan Docker Image with Trivy

Stage-9: Push Docker Image to AWS ECR

Manual Testing:

Step-by-Step Guide for Terminal Testing:

  1. Verify MySQL Container Readiness:
docker exec mysql-test mysqladmin ping -u root -proot

It should return mysqld is alive if MySQL is ready.

  1. Test Application Health:
curl <http://localhost:8082/actuator/health>

It should return something like:

{
  "status": "UP"
}

  1. Check Application Logs for Errors:
[ec2-user@ip-172-31-22-32 ~]$ docker logs petclinic-test

              |\\      _,,,--,,_
             /,`.-'`'   ._  \\-;;,_
  _______ __|,4-  ) )_   .;.(__`'-'__     ___ __    _ ___ _______
 |       | '---''(_/._)-'(_\\_)   |   |   |   |  |  | |   |       |
 |    _  |    ___|_     _|       |   |   |   |   |_| |   |       | __ _ _
 |   |_| |   |___  |   | |       |   |   |   |       |   |       | \\ \\ \\ \\
 |    ___|    ___| |   | |      _|   |___|   |  _    |   |      _|  \\ \\ \\ \\
 |   |   |   |___  |   | |     |_|       |   | | |   |   |     |_    ) ) ) )
 |___|   |_______| |___| |_______|_______|___|_|  |__|___|_______|  / / / /
 ==================================================================/_/_/_/

:: Built with Spring Boot :: 3.3.3

2024-10-12T11:08:20.300Z  INFO 1 --- [main] o.s.s.petclinic.PetClinicApplication     : Starting PetClinicApplication v3.3.0-SNAPSHOT using Java 17.0.12 with PID 1 (/app/app.jar started by root in /app)
2024-10-12T11:08:20.311Z  INFO 1 --- [main] o.s.s.petclinic.PetClinicApplication     : No active profile set, falling back to 1 default profile: "default"
2024-10-12T11:08:22.719Z  INFO 1 --- [main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2024-10-12T11:08:22.816Z  INFO 1 --- [main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 85 ms. Found 2 JPA repository interfaces.
2024-10-12T11:08:24.169Z  INFO 1 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8081 (http)
2024-10-12T11:08:24.185Z  INFO 1 --- [main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2024-10-12T11:08:24.186Z  INFO 1 --- [main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.28]
2024-10-12T11:08:24.237Z  INFO 1 --- [main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2024-10-12T11:08:24.238Z  INFO 1 --- [main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 3798 ms
2024-10-12T11:08:24.662Z  INFO 1 --- [main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2024-10-12T11:08:25.110Z  INFO 1 --- [main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@72110818
2024-10-12T11:08:25.112Z  INFO 1 --- [main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2024-10-12T11:08:25.763Z  INFO 1 --- [main] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
2024-10-12T11:08:25.855Z  INFO 1 --- [main] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 6.5.2.Final
2024-10-12T11:08:25.902Z  INFO 1 --- [main] o.h.c.internal.RegionFactoryInitiator    : HHH000026: Second-level cache disabled
[ec2-user@ip-172-31-22-32 ~]$ docker exec -it mysql-test mysql -u root -proot
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \\g.
Your MySQL connection id is 45
Server version: 8.4.2 MySQL Community Server - GPL

Copyright (c) 2000, 2024, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\\h' for help. Type '\\c' to clear the current input statement.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| petclinic          |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

mysql> USE petclinic;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> SHOW TABLES;
+---------------------+
| Tables_in_petclinic |
+---------------------+
| owners              |
| pets                |
| specialties         |
| types               |
| vet_specialties     |
| vets                |
| visits              |
+---------------------+
7 rows in set (0.01 sec)

mysql> SELECT * FROM owners;
+----+------------+-----------+-----------------------+-------------+------------+
| id | first_name | last_name | address               | city        | telephone  |
+----+------------+-----------+-----------------------+-------------+------------+
|  1 | George     | Franklin  | 110 W. Liberty St.    | Madison     | 6085551023 |
|  2 | Betty      | Davis     | 638 Cardinal Ave.     | Sun Prairie | 6085551749 |
|  3 | Eduardo    | Rodriquez | 2693 Commerce St.     | McFarland   | 6085558763 |
|  4 | Harold     | Davis     | 563 Friendly St.      | Windsor     | 6085553198 |
|  5 | Peter      | McTavish  | 2387 S. Fair Way      | Madison     | 6085552765 |
|  6 | Jean       | Coleman   | 105 N. Lake St.       | Monona      | 6085552654 |
|  7 | Jeff       | Black     | 1450 Oak Blvd.        | Monona      | 6085555387 |
|  8 | Maria      | Escobito  | 345 Maple St.         | Madison     | 6085557683 |
|  9 | David      | Schroeder | 2749 Blackhawk Trail  | Madison     | 6085559435 |
| 10 | Carlos     | Estaban   | 2335 Independence La. | Waunakee    | 6085555487 |
| 11 | SUBBA      | REDDY     | 1-2-3                 | ABC         | 1234567890 |
+----+------------+-----------+-----------------------+-------------+------------+
11 rows in set (0.00 sec)

mysql> SELECT * FROM pets;
+----+----------+------------+---------+----------+
| id | name     | birth_date | type_id | owner_id |
+----+----------+------------+---------+----------+
|  1 | Leo      | 2000-09-07 |       1 |        1 |
|  2 | Basil    | 2002-08-06 |       6 |        2 |
|  3 | Rosy     | 2001-04-17 |       2 |        3 |
|  4 | Jewel    | 2000-03-07 |       2 |        3 |
|  5 | Iggy     | 2000-11-30 |       3 |        4 |
|  6 | George   | 2000-01-20 |       4 |        5 |
|  7 | Samantha | 1995-09-04 |       1 |        6 |
|  8 | Max      | 1995-09-04 |       1 |        6 |
|  9 | Lucky    | 1999-08-06 |       5 |        7 |
| 10 | Mulligan | 1997-02-24 |       2 |        8 |
| 11 | Freddy   | 2000-03-09 |       5 |        9 |
| 12 | Lucky    | 2000-06-24 |       2 |       10 |
| 13 | Sly      | 2002-06-08 |       1 |       10 |
| 14 | SIMBA    | 2023-02-01 |       2 |       11 |
+----+----------+------------+---------+----------+
14 rows in set (0.00 sec)

Our application is running smoothly, and we can use this Docker image in development, QA, release, and production environments unless new issues arise.

To send a Slack notification at the completion of your Jenkins pipeline, follow these steps:

1. Set Up Jenkins Slack Notification Plugin using Manage Jenkins > Manage Plugins

  1. Slack Workspace Integration with Jenkins:

<your-workspace>--> tools and settings --> manage apps --> search with "jenkins ci" --> click on "Add to slack" --> select a channel --> Add jenkins ci integration -→

Credentials: Add credentials --> Secret text --> paste the secret[your-secret-text] --> add the description

Add this to your pipeline script:

ost {
    always {
        cleanWs()  // Clean up the workspace after the build
    }
    success {
        slackSend (channel: '#project-petclinic', color: 'good', message: "SUCCESS: Job '${env.JOB_NAME}' build #${currentBuild.number} succeeded.")
    }
    failure {
        slackSend (channel: '#project-petclinic', color: 'danger', message: "FAILURE: Job '${env.JOB_NAME} build [${currentBuild.number}]' failed.")
    }
    unstable {
        slackSend (channel: '#project-petclinic', color: 'warning', message: "UNSTABLE: Job '${env.JOB_NAME} build [${currentBuild.number}]' is unstable.")
    }
}

You can check the Amazon Elastic Container Registry for uploaded images:

⇒PetClinic-CI/CD on Develop Branch