├── .gitignore ├── helm └── acme │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── service.yaml │ └── deployment.yaml │ ├── Chart.yaml │ ├── .helmignore │ └── values.yaml ├── src ├── images │ ├── helm-logo.png │ ├── jenkins-logo.png │ └── kubernetes-logo.png ├── css │ └── style.css └── index.html ├── docker └── Dockerfile ├── README.md └── Jenkinsfile /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vagrant 3 | tmp 4 | logs 5 | build 6 | -------------------------------------------------------------------------------- /helm/acme/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | You have successfully installed ACME! 2 | -------------------------------------------------------------------------------- /helm/acme/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | description: A Helm chart for Kubernetes 3 | name: acme 4 | version: 0.1.0 5 | -------------------------------------------------------------------------------- /src/images/helm-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eldada/jenkins-pipeline-kubernetes/HEAD/src/images/helm-logo.png -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.15.8 2 | 3 | LABEL Author "eldada@jfrog.com" 4 | 5 | COPY site /usr/share/nginx/html 6 | -------------------------------------------------------------------------------- /src/images/jenkins-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eldada/jenkins-pipeline-kubernetes/HEAD/src/images/jenkins-logo.png -------------------------------------------------------------------------------- /src/images/kubernetes-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eldada/jenkins-pipeline-kubernetes/HEAD/src/images/kubernetes-logo.png -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | font-size: 200%; 3 | font-family: Helvetica; 4 | } 5 | 6 | * { 7 | font-size: 100%; 8 | font-family: Arial; 9 | } 10 | -------------------------------------------------------------------------------- /helm/acme/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | -------------------------------------------------------------------------------- /helm/acme/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for acme. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | replicaCount: 1 5 | imagePullSecrets: 6 | image: 7 | repository: acme 8 | tag: dev 9 | pullPolicy: Always 10 | service: 11 | type: ClusterIP 12 | externalPort: 80 13 | internalPort: 80 14 | resources: 15 | limits: 16 | cpu: 100m 17 | memory: 128Mi 18 | requests: 19 | cpu: 100m 20 | memory: 128Mi 21 | -------------------------------------------------------------------------------- /helm/acme/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "acme.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | */}} 13 | {{- define "acme.fullname" -}} 14 | {{- $name := default .Chart.Name .Values.nameOverride -}} 15 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 16 | {{- end -}} 17 | -------------------------------------------------------------------------------- /helm/acme/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "acme.fullname" . }} 5 | labels: 6 | app: {{ template "acme.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.service.type }} 12 | ports: 13 | - port: {{ .Values.service.externalPort }} 14 | targetPort: {{ .Values.service.internalPort }} 15 | protocol: TCP 16 | name: {{ .Values.service.name }} 17 | selector: 18 | app: {{ template "acme.name" . }} 19 | release: {{ .Release.Name }} 20 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ACME 6 | 7 | 8 | 9 | 10 |

ACME is up and running!

11 | 12 |

Using the following tools:

13 |
Jenkins 14 |
Kubernetes 15 |
Helm 16 | 17 |


ACME (eldada) version: __APP_VERSION__

18 | 19 | 20 | -------------------------------------------------------------------------------- /helm/acme/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "acme.fullname" . }} 5 | labels: 6 | app: {{ template "acme.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | template: 13 | metadata: 14 | labels: 15 | app: {{ template "acme.name" . }} 16 | release: {{ .Release.Name }} 17 | spec: 18 | {{- if .Values.imagePullSecrets }} 19 | imagePullSecrets: 20 | - name: {{ .Values.imagePullSecrets }} 21 | {{- end }} 22 | containers: 23 | - name: {{ .Chart.Name }} 24 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 25 | imagePullPolicy: {{ .Values.image.pullPolicy }} 26 | ports: 27 | - containerPort: {{ .Values.service.internalPort }} 28 | livenessProbe: 29 | httpGet: 30 | path: / 31 | port: {{ .Values.service.internalPort }} 32 | readinessProbe: 33 | httpGet: 34 | path: / 35 | port: {{ .Values.service.internalPort }} 36 | resources: 37 | {{ toYaml .Values.resources | indent 12 }} 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CI/CD to Kubernetes using Jenkins and Helm 2 | This project is an example of a complete CI/CD pipeline of a simple static web application from sources to deployed Kubernetes pods. 3 | 4 | ## Artifactory as Docker registry 5 | This project uses [Artifactory](https://jfrog.com/integration/artifactory-docker-registry/) as its Docker registry. 6 | You can [get a free trial](https://www.jfrog.com/artifactory/free-trial/) and try it out! 7 | 8 | Follow the documentation for setting up your Docker registry. 9 | 10 | ## Artifactory as Helm repository 11 | This project uses [Artifactory](https://jfrog.com/integration/helm/) as its Helm repository. 12 | You can [get a free trial](https://www.jfrog.com/artifactory/free-trial/) and try it out! 13 | 14 | Follow the documentation for setting up your Helm repository. 15 | 16 | ## Jenkins 17 | Setup a [Jenkins](https://jenkins.io/) running with 18 | - [Docker](https://www.docker.com/). Can build and push images 19 | - [Kubectl](https://kubernetes.io/). Kubernetes CLI that will link Jenkins with the Kubernetes cluster 20 | - [Helm](https://helm.sh/). Kubernetes package manager to simplify deployment of your Docker containers to Kubernetes 21 | 22 | There is a [GitHub example](https://github.com/eldada/jenkins-in-kubernetes) of such a Docker image, to be used in Kubernetes. 23 | 24 | ### Jenkins in Kubernetes 25 | Jenkins running in Kubernetes can be found in this [GitHub example](https://github.com/eldada/jenkins-in-kubernetes). 26 | - This project is used to build a Jenkins master that has the required tools (`docker`, `kubectl` and `helm`) already installed 27 | - You can deploy this Jenkins to Kubernetes using the helm chart in the same repository 28 | - Notice that this is an example, and should not be used for production 29 | 30 | ### General notes 31 | - The Jenkins pipeline example ([Jenkinsfile](Jenkinsfile)) is using calls to external cli tools such as `docker`, `kubectl`, `curl` and other shell commands. 32 | Some of these can be replaced with groovy code and functions or built in pipeline steps, but are implemented like this to demonstrate the simple use of these tools. 33 | - The `kubectl` and `helm` clients are **not** configured in this example. It's assumed the Jenkins instance is pre-configured, or run it in your Kubernetes cluster, 34 | where it picks up the local pod credentials to access the Kubernetes API. 35 | 36 | ## Build the web application 37 | You can build the web application directly by running `build.sh`. You can create the Docker image and run it locally. See the [build.sh](build.sh) options. 38 | ```bash 39 | # See options 40 | $ ./build.sh --help 41 | ``` 42 | 43 | You can also pack and push the Docker image and Helm chart. 44 | 45 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | /* 2 | This is an example pipeline that implement full CI/CD for a simple static web site packed in a Docker image. 3 | 4 | The pipeline is made up of 6 main steps 5 | 1. Git clone and setup 6 | 2. Build and local tests 7 | 3. Publish Docker and Helm 8 | 4. Deploy to dev and test 9 | 5. Deploy to staging and test 10 | 6. Optionally deploy to production and test 11 | */ 12 | 13 | /* 14 | Create the kubernetes namespace 15 | */ 16 | def createNamespace (namespace) { 17 | echo "Creating namespace ${namespace} if needed" 18 | 19 | sh "[ ! -z \"\$(kubectl get ns ${namespace} -o name 2>/dev/null)\" ] || kubectl create ns ${namespace}" 20 | } 21 | 22 | /* 23 | Helm install 24 | */ 25 | def helmInstall (namespace, release) { 26 | echo "Installing ${release} in ${namespace}" 27 | 28 | script { 29 | release = "${release}-${namespace}" 30 | sh "helm repo add helm ${HELM_REPO}; helm repo update" 31 | sh """ 32 | helm upgrade --install --namespace ${namespace} ${release} \ 33 | --set imagePullSecrets=${IMG_PULL_SECRET} \ 34 | --set image.repository=${DOCKER_REG}/${IMAGE_NAME},image.tag=${DOCKER_TAG} helm/acme 35 | """ 36 | sh "sleep 5" 37 | } 38 | } 39 | 40 | /* 41 | Helm delete (if exists) 42 | */ 43 | def helmDelete (namespace, release) { 44 | echo "Deleting ${release} in ${namespace} if deployed" 45 | 46 | script { 47 | release = "${release}-${namespace}" 48 | sh "[ -z \"\$(helm ls --short ${release} 2>/dev/null)\" ] || helm delete --purge ${release}" 49 | } 50 | } 51 | 52 | /* 53 | Run a curl against a given url 54 | */ 55 | def curlRun (url, out) { 56 | echo "Running curl on ${url}" 57 | 58 | script { 59 | if (out.equals('')) { 60 | out = 'http_code' 61 | } 62 | echo "Getting ${out}" 63 | def result = sh ( 64 | returnStdout: true, 65 | script: "curl --output /dev/null --silent --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 5 --retry-max-time 30 --write-out \"%{${out}}\" ${url}" 66 | ) 67 | echo "Result (${out}): ${result}" 68 | } 69 | } 70 | 71 | /* 72 | Test with a simple curl and check we get 200 back 73 | */ 74 | def curlTest (namespace, out) { 75 | echo "Running tests in ${namespace}" 76 | 77 | script { 78 | if (out.equals('')) { 79 | out = 'http_code' 80 | } 81 | 82 | // Get deployment's service IP 83 | def svc_ip = sh ( 84 | returnStdout: true, 85 | script: "kubectl get svc -n ${namespace} | grep ${ID} | awk '{print \$3}'" 86 | ) 87 | 88 | if (svc_ip.equals('')) { 89 | echo "ERROR: Getting service IP failed" 90 | sh 'exit 1' 91 | } 92 | 93 | echo "svc_ip is ${svc_ip}" 94 | url = 'http://' + svc_ip 95 | 96 | curlRun (url, out) 97 | } 98 | } 99 | 100 | /* 101 | This is the main pipeline section with the stages of the CI/CD 102 | */ 103 | pipeline { 104 | 105 | options { 106 | // Build auto timeout 107 | timeout(time: 60, unit: 'MINUTES') 108 | } 109 | 110 | // Some global default variables 111 | environment { 112 | IMAGE_NAME = 'acme' 113 | TEST_LOCAL_PORT = 8817 114 | DEPLOY_PROD = false 115 | PARAMETERS_FILE = "${JENKINS_HOME}/parameters.groovy" 116 | } 117 | 118 | parameters { 119 | string (name: 'GIT_BRANCH', defaultValue: 'master', description: 'Git branch to build') 120 | booleanParam (name: 'DEPLOY_TO_PROD', defaultValue: false, description: 'If build and tests are good, proceed and deploy to production without manual approval') 121 | 122 | 123 | // The commented out parameters are for optionally using them in the pipeline. 124 | // In this example, the parameters are loaded from file ${JENKINS_HOME}/parameters.groovy later in the pipeline. 125 | // The ${JENKINS_HOME}/parameters.groovy can be a mounted secrets file in your Jenkins container. 126 | /* 127 | string (name: 'DOCKER_REG', defaultValue: 'docker-artifactory.my', description: 'Docker registry') 128 | string (name: 'DOCKER_TAG', defaultValue: 'dev', description: 'Docker tag') 129 | string (name: 'DOCKER_USR', defaultValue: 'admin', description: 'Your helm repository user') 130 | string (name: 'DOCKER_PSW', defaultValue: 'password', description: 'Your helm repository password') 131 | string (name: 'IMG_PULL_SECRET', defaultValue: 'docker-reg-secret', description: 'The Kubernetes secret for the Docker registry (imagePullSecrets)') 132 | string (name: 'HELM_REPO', defaultValue: 'https://artifactory.my/artifactory/helm', description: 'Your helm repository') 133 | string (name: 'HELM_USR', defaultValue: 'admin', description: 'Your helm repository user') 134 | string (name: 'HELM_PSW', defaultValue: 'password', description: 'Your helm repository password') 135 | */ 136 | } 137 | 138 | // In this example, all is built and run from the master 139 | agent { node { label 'master' } } 140 | 141 | // Pipeline stages 142 | stages { 143 | 144 | ////////// Step 1 ////////// 145 | stage('Git clone and setup') { 146 | steps { 147 | echo "Check out acme code" 148 | git branch: "master", 149 | credentialsId: 'eldada-bb', 150 | url: 'https://github.com/eldada/jenkins-pipeline-kubernetes.git' 151 | 152 | // Validate kubectl 153 | sh "kubectl cluster-info" 154 | 155 | // Init helm client 156 | sh "helm init" 157 | 158 | // Make sure parameters file exists 159 | script { 160 | if (! fileExists("${PARAMETERS_FILE}")) { 161 | echo "ERROR: ${PARAMETERS_FILE} is missing!" 162 | } 163 | } 164 | 165 | // Load Docker registry and Helm repository configurations from file 166 | load "${JENKINS_HOME}/parameters.groovy" 167 | 168 | echo "DOCKER_REG is ${DOCKER_REG}" 169 | echo "HELM_REPO is ${HELM_REPO}" 170 | 171 | // Define a unique name for the tests container and helm release 172 | script { 173 | branch = GIT_BRANCH.replaceAll('/', '-').replaceAll('\\*', '-') 174 | ID = "${IMAGE_NAME}-${DOCKER_TAG}-${branch}" 175 | 176 | echo "Global ID set to ${ID}" 177 | } 178 | } 179 | } 180 | 181 | ////////// Step 2 ////////// 182 | stage('Build and tests') { 183 | steps { 184 | echo "Building application and Docker image" 185 | sh "${WORKSPACE}/build.sh --build --registry ${DOCKER_REG} --tag ${DOCKER_TAG} --docker_usr ${DOCKER_USR} --docker_psw ${DOCKER_PSW}" 186 | 187 | echo "Running tests" 188 | 189 | // Kill container in case there is a leftover 190 | sh "[ -z \"\$(docker ps -a | grep ${ID} 2>/dev/null)\" ] || docker rm -f ${ID}" 191 | 192 | echo "Starting ${IMAGE_NAME} container" 193 | sh "docker run --detach --name ${ID} --rm --publish ${TEST_LOCAL_PORT}:80 ${DOCKER_REG}/${IMAGE_NAME}:${DOCKER_TAG}" 194 | 195 | script { 196 | host_ip = sh(returnStdout: true, script: '/sbin/ip route | awk \'/default/ { print $3 ":${TEST_LOCAL_PORT}" }\'') 197 | } 198 | } 199 | } 200 | 201 | // Run the 3 tests on the currently running ACME Docker container 202 | stage('Local tests') { 203 | parallel { 204 | stage('Curl http_code') { 205 | steps { 206 | curlRun ("http://${host_ip}", 'http_code') 207 | } 208 | } 209 | stage('Curl total_time') { 210 | steps { 211 | curlRun ("http://${host_ip}", 'total_time') 212 | } 213 | } 214 | stage('Curl size_download') { 215 | steps { 216 | curlRun ("http://${host_ip}", 'size_download') 217 | } 218 | } 219 | } 220 | } 221 | 222 | ////////// Step 3 ////////// 223 | stage('Publish Docker and Helm') { 224 | steps { 225 | echo "Stop and remove container" 226 | sh "docker stop ${ID}" 227 | 228 | echo "Pushing ${DOCKER_REG}/${IMAGE_NAME}:${DOCKER_TAG} image to registry" 229 | sh "${WORKSPACE}/build.sh --push --registry ${DOCKER_REG} --tag ${DOCKER_TAG} --docker_usr ${DOCKER_USR} --docker_psw ${DOCKER_PSW}" 230 | 231 | echo "Packing helm chart" 232 | sh "${WORKSPACE}/build.sh --pack_helm --push_helm --helm_repo ${HELM_REPO} --helm_usr ${HELM_USR} --helm_psw ${HELM_PSW}" 233 | } 234 | } 235 | 236 | ////////// Step 4 ////////// 237 | stage('Deploy to dev') { 238 | steps { 239 | script { 240 | namespace = 'development' 241 | 242 | echo "Deploying application ${ID} to ${namespace} namespace" 243 | createNamespace (namespace) 244 | 245 | // Remove release if exists 246 | helmDelete (namespace, "${ID}") 247 | 248 | // Deploy with helm 249 | echo "Deploying" 250 | helmInstall(namespace, "${ID}") 251 | } 252 | } 253 | } 254 | 255 | // Run the 3 tests on the deployed Kubernetes pod and service 256 | stage('Dev tests') { 257 | parallel { 258 | stage('Curl http_code') { 259 | steps { 260 | curlTest (namespace, 'http_code') 261 | } 262 | } 263 | stage('Curl total_time') { 264 | steps { 265 | curlTest (namespace, 'time_total') 266 | } 267 | } 268 | stage('Curl size_download') { 269 | steps { 270 | curlTest (namespace, 'size_download') 271 | } 272 | } 273 | } 274 | } 275 | 276 | stage('Cleanup dev') { 277 | steps { 278 | script { 279 | // Remove release if exists 280 | helmDelete (namespace, "${ID}") 281 | } 282 | } 283 | } 284 | 285 | ////////// Step 5 ////////// 286 | stage('Deploy to staging') { 287 | steps { 288 | script { 289 | namespace = 'staging' 290 | 291 | echo "Deploying application ${IMAGE_NAME}:${DOCKER_TAG} to ${namespace} namespace" 292 | createNamespace (namespace) 293 | 294 | // Remove release if exists 295 | helmDelete (namespace, "${ID}") 296 | 297 | // Deploy with helm 298 | echo "Deploying" 299 | helmInstall (namespace, "${ID}") 300 | } 301 | } 302 | } 303 | 304 | // Run the 3 tests on the deployed Kubernetes pod and service 305 | stage('Staging tests') { 306 | parallel { 307 | stage('Curl http_code') { 308 | steps { 309 | curlTest (namespace, 'http_code') 310 | } 311 | } 312 | stage('Curl total_time') { 313 | steps { 314 | curlTest (namespace, 'time_total') 315 | } 316 | } 317 | stage('Curl size_download') { 318 | steps { 319 | curlTest (namespace, 'size_download') 320 | } 321 | } 322 | } 323 | } 324 | 325 | stage('Cleanup staging') { 326 | steps { 327 | script { 328 | // Remove release if exists 329 | helmDelete (namespace, "${ID}") 330 | } 331 | } 332 | } 333 | 334 | ////////// Step 6 ////////// 335 | // Waif for user manual approval, or proceed automatically if DEPLOY_TO_PROD is true 336 | stage('Go for Production?') { 337 | when { 338 | allOf { 339 | environment name: 'GIT_BRANCH', value: 'master' 340 | environment name: 'DEPLOY_TO_PROD', value: 'false' 341 | } 342 | } 343 | 344 | steps { 345 | // Prevent any older builds from deploying to production 346 | milestone(1) 347 | input 'Proceed and deploy to Production?' 348 | milestone(2) 349 | 350 | script { 351 | DEPLOY_PROD = true 352 | } 353 | } 354 | } 355 | 356 | stage('Deploy to Production') { 357 | when { 358 | anyOf { 359 | expression { DEPLOY_PROD == true } 360 | environment name: 'DEPLOY_TO_PROD', value: 'true' 361 | } 362 | } 363 | 364 | steps { 365 | script { 366 | DEPLOY_PROD = true 367 | namespace = 'production' 368 | 369 | echo "Deploying application ${IMAGE_NAME}:${DOCKER_TAG} to ${namespace} namespace" 370 | createNamespace (namespace) 371 | 372 | // Deploy with helm 373 | echo "Deploying" 374 | helmInstall (namespace, "${ID}") 375 | } 376 | } 377 | } 378 | 379 | // Run the 3 tests on the deployed Kubernetes pod and service 380 | stage('Production tests') { 381 | when { 382 | expression { DEPLOY_PROD == true } 383 | } 384 | 385 | parallel { 386 | stage('Curl http_code') { 387 | steps { 388 | curlTest (namespace, 'http_code') 389 | } 390 | } 391 | stage('Curl total_time') { 392 | steps { 393 | curlTest (namespace, 'time_total') 394 | } 395 | } 396 | stage('Curl size_download') { 397 | steps { 398 | curlTest (namespace, 'size_download') 399 | } 400 | } 401 | } 402 | } 403 | } 404 | } 405 | --------------------------------------------------------------------------------