Building a CI Pipeline for PetClinic Application Project: Step-by-Step Instructions
Why Test the Docker Image in the Feature Branch Pipeline?
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.
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.
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:
Run the Docker container using
docker run
ordocker-compose
.Run a basic integration test suite against the running container (e.g., check if endpoints are working, database connections, etc.).
If everything passes, mark the feature as stable and ready for PR.
Feature Branch Workflow (CI Pipeline on Feature Branch)
Developer Work:
Developers work on new features in the feature branch.
The changes are pushed to the feature branch in the Git repository.
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.
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.
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.
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:
Set up a Jenkins Server with necessary plugins
Install AWS CLI on Jenkins Server
Create Private repository using Amazon Elastic Container Registry(ECR)
Install Docker on Jenkins Server
Install Trivy on Jenkins Server
Set up a SonarQube Server
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:
- Verify MySQL Container Readiness:
docker exec mysql-test mysqladmin ping -u root -proot
It should return mysqld is alive
if MySQL is ready.
- Test Application Health:
curl <http://localhost:8082/actuator/health>
It should return something like:
{
"status": "UP"
}
- 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
- 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
Subscribe to my newsletter
Read articles from Subbu Tech Tutorials directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by