Jenkins with with PythonEnv

It took me quite sometime to learn the ideosyncracy of withPythonEnv. If you are trying to do something similar to mine, I hope this post helps you. I went through normal channels like StackOverflow.com, Meidum and Google search, I didn’t find much help so I had to bang my head on keyboard to try&error for many times.

Background

First, the prodject structure. This is a Django project. It seems to me that the most of Django project directory found on the internet puts the Django’s directory as root of project. This is pretty unrealistic. On the toplevel, you want to have non-web related things like README, the housekeeping shell scripts, and testing fixures outside of it. The most notable file for this is my “passenger” file which the website DreamHost provides to run WSGI. This is essential to setting up the Django before diving into Django app.

MyProject
  - passenger_wsgi.py
  - DjangoApp
    - DjangoApp
      - settings.py
  - Housekeeping
  - Makefile
  - venv
  - etc.

This Makefile is used to set up Python virtual environment (venv). In order to run the Django app, it needs django and other packages. venv gets created and populated by necessary packages by this Makefile. I wanted vent to be right next to DjangoApp so the passenger_wsgi.py can set up the virtual env and then run the Django requests.
I therefore wanted to do the same for Jenkins. I looked at the withPythonEnv, I thought I can run “make venv” and off I go. Use withPythonEnv(‘venv/bin/python3’) allows me to run Django with Jenkins workspace once I bootstrap the “venv”. I was very wrong.
I create the virutal env, then try to use, but it had different idea. It used the Python I designated to set up its own venv in the workspace.
Also, this means, I cannot use single Makefile verb to do the deployment and Jenkins workspace. I attempted a few things like making Makefile to take care of build/test, dot-include venv/bin/activate for every “sh” comamnd in the jenkinsfile stage. None of this worked.
Using “sh” was most surprising for a noob like me. It created a temp sh file and run it in a sub/sub directory so any of working directory relative includes and executions work.
IOW, I tried to do it without withPythonEnv, and I could not find a good way other than write everything in Makefile and let make to do the work including test. I did not like this idea.
So, I went with withPythonEnv, and ate up the limitation that comes with it. It creates ProjectRoot/.pyenv-<Python> where is the name of Python in Global Tools. I also tried a few permutation of entries in Global Tools, and after some frustrating attempts, I ended up having just a very plain entry.

Python in Jenkins Global Tools Configuration

Albeit this is named for the project, the entry is the most basic.

  - Python
    Name: Python3
    Home or executable: /usr/bin/python3

That’s it. Ignore the yellow warning for the home or executable part. I tried other things like auto-install, and didn’t make sense of it. As withPythonEnv creating its own venv, using venv in Global Tools Configuration makes very little sense. You do need to install “venv” package to the system before doing this. If you don’t have the perm to do so, but if you can become “jenkins” user, you could install “venv using pip so it’s installed under the home directory of user “jenkins”. In other word, using virutal env for this entry makes sense only if you have no root perm or jenkins auth so you must create your own venv to create venv. Since python3 should exist, the simplest solution is to use python3, and install venv package for it.

Jenkinsfile for the project

Here is my Jenkinsfile now.

pipeline {
    agent any

    environment {
        PYTHONPATH = "${env.WORKSPACE}/cworg"
        DJANGO_SETTINGS_MODULE='DjangoApp.settings'
        BUILD_NUMBER = "${env.BUILD_NUMBER}"

        GIT_URL="ntai@git.my-server.com:~/git/MyProject.git"
    }

    options {
        buildDiscarder(logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '10', numToKeepStr: '20'))
        timestamps()
        retry(1)
        timeout time:10, unit:'MINUTES'
    }

    parameters {
        string(defaultValue: "master", description: 'Branch Specifier', name: 'SPECIFIER')
    }

    stages {
        stage("Initialize") {
            steps {
            script {
                    echo "${BUILD_NUMBER} - ${env.BUILD_ID} on ${env.JENKINS_URL}"
                    echo "Branch Specifier :: ${params.SPECIFIER}"
            }
            }
        }

        stage('Checkout') {
            steps {
            git branch: "${params.SPECIFIER}", url: "${GIT_URL}"
            }
        }

        stage('Make Virtual Env') {
            steps {
                withPythonEnv('Python3') {
                    sh 'pip install -r requirements.txt'
                }
            }
        }

        stage('Bootstrap') {
            steps {
            dir ("cworg") {
                withPythonEnv('Python3') {
                sh "make bootstrap"
                }
                }
            }
        }

        stage('Build') {
            steps {
            dir("cworg") {
                withPythonEnv('Python3') {
                        sh "make static"
                }
                }
            }
        }

        stage('Test') {
            steps {
                dir('./') {
                withPythonEnv('Python3') {
                        sh "python3 -m pytest"
                }
                }
            }
        }

        stage('Deploy') {
        steps {
        sh "ssh webapp@my-server.com'~/deploy-my-django-app.sh'"
        }
        }
    }
}

With using ‘Python3’ tool thing, it creates jenkins/workspace/MyProject/.pyenv-Python3 I can now run the test with the virutal env and against the test database for the Django project.

Running Jenkins behind nginx

Jenkins does not use HTTPS. It’s a mistery why it does not. So, in order to run this behind HTTPS, you need a reverse HTTP proxy server in order to add “S” to HTTP.
I spent some time looking for ways to set up HTTPS for Jenkins, and the answer was negative. 🙁
Since you don’t want to expose HTTP over network, make sure Jenkins only answers to the localhost. Then, the nginx must be on the same host, or else there is no point of this exercise.

First, Jenkins is working at jenkins_host:9000 and want https runs on 8000. (I just realized the port number choices are kind of weird.)

Install nginx

This is an easy part – “sudo apt install -y nginx”

Configure nginx

This is a little harder part but here is my current config file.

upstream jenkins_host {
  server localhost:9000 fail_timeout=0; # jenkins_host ip and port
}

server {
  listen 8000 ssl;       # Listen on port 8000 for IPv4 requests with ssl
  server_name     jenkins_host.cleanwinner.com;

  ssl_certificate     /etc/ssl/cleanwinner/jenkins_host-nginx-selfsigned.crt;
  ssl_certificate_key /etc/ssl/cleanwinner/jenkins_host-nginx-selfsigned.key;

  access_log      /var/log/nginx/jenkins/access.log;
  error_log       /var/log/nginx/jenkins/error.log;

  location ^~ /jenkins {
    proxy_pass          http://localhost:9000;
    proxy_read_timeout  30;

    # Fix the "It appears that your reverse proxy set up is broken" error.
    proxy_redirect      http://localhost:9000 $scheme://jenkins_host:8000;
  }

  location / {
    # Don't send any file out
    sendfile off;

    #
    proxy_pass              http://jenkins_host;
    proxy_redirect http:// https://;

    # Required for new HTTP-based CLI
    proxy_http_version 1.1;

    # Don't want any buffering
    proxy_request_buffering off;
    proxy_buffering off;         # Required for HTTP-based CLI to work over SSL

    #this is the maximum upload size
    client_max_body_size       10m;
    client_body_buffer_size    128k;

    proxy_set_header        Host $host:$server_port;
    proxy_set_header        X-Real-IP $remote_addr;
    proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header        X-Forwarded-Proto $scheme;

    # workaround for https://issues.jenkins-ci.org/browse/JENKINS-45651
    add_header 'X-SSH-Endpoint' 'jenkins_host.cleanwinner.com:50022' always;
  }
}

So have this file as /etc/nginx/avaialbe-site/jenkins. You need to a link from /etc/nginx/enabled-site to this file in order for this setting to work. “sudo ln -s ../site-available/jenkins” in /etc/nginx/site-enabled is good.

cert files

As you can see, for SSL, you need a SSL certificate. You can create a self-signed, or get something real. For this exercise, it’s not quite relevant so I’ll leave it to you. I’ll talk about making one with pfSense. Stay tuned.

Memo to myself:

sudo openssl req -x509 -nodes -days 999 -newkey rsa:2048 -keyout /etc/ssl/cleanwinner/jenkins_host-nginx-selfsigned.key -out  /etc/ssl/cleanwinner/jenkins_host-nginx-selfsigned.crt

Jenkins session timeout

On Ubuntu, if you are using Jenkins package, you can change the session timeout in /etc/default/jenkins.

JENKINS_ARGS=" BLA BLA -- --sessionEviction=604800"

I tried –sessionTimeout and it does not work.

Where BLA BLA is the existing args and --sessionEviction=604800 is the new session timeout. The default is 30 minutes and I was timing out a lot while testing Jenkinsfile. Unlke sessionTimeout, sessionEviction’s unit is in seconds not minutes. 604800 is 60*60*24*7 so the timeout is a week.

Wiring webhook from GitHub to Jenkins

I was using email hook for Jenkins to build the release build of WCE Triage UI but I wanted to “modernize” it by using webhook.
For cold Sunday morning with a cup of coffee, I started poking around.
I have a local Jenkins instance. First order is to expose this to wild through https.

Step 1 Jenkins with nginx https

Not going into super details here about running Jenkins behind https. As you know, Jenkins runs with http only. In order to use https, you need a reverse proxy. Since this is a common practice, there is a template for running Jenkins behind nginx.
Making this to work is a task of itself.
First and foremost, I had to change the /etc/default/jenkins.
JENKINS_ARGS="--webroot=/var/cache/$NAME/war --httpPort=$HTTP_PORT
to
JENKINS_ARGS="--webroot=/var/cache/$NAME/war --httpPort=$HTTP_PORT --prefix=$PREFIX"

Create a reverse proxy with nginx. This is my current nginx site file.
You need to replace jenkinshost.cleanwinner.com with your machine. Put this file as /etc/nginx/site-available, and symblic link from /etc/nginx/site-enabled Then reload/restart nginx.

Step 2 Punching a hole through firewall

Now, my Jenkins is inside, and github.com is outside. Webhook needs to go through it. I took a look at ngrok.com, and it works great but I will risk my home server for $5/month. 😛

How you punch through the firewall is up to you/your firewall. In my case, it’s a pfSense, so you go into NAT and set it up. With pfSense, you should use the source IP range to be for github.com.

The github IP address range is posted here but you should know that there is an API to get this automagically.
Since I don’t know how to set up the IP range for pfSense from the API, for now, I will use the available setting for now.

Step 3 Configure GitHub Webhook

Go to the project’s settings.
For here, I need a token string. I used uuidgen to create a random string. Let’s call it “MY_SECRET_TOKEN”.

Webhook / Manage webhook
Payload URL
https://jenkinshost.cleanwinner.com/jenkins/generic-webhook-triger/invoke?token=MY_SECRET_TOKEN

Content type
application/x-www-from-urlencoded
Secret:

Empty here. I’m not sure what I can do with this.

Event:
* Just the push event

Active checked.

Making it go and you should see an 404 error as Jenkins is not configured yet.

Step 4 Configure Jenkins webhook

Go into the Build Triggers.

Check Generic Webhook Trigger

Now, you want to make sure the right item is triggered. I know the repo ID (it’s like 123456789).

Add “Post content parameters”:
Variable is “$.repository.id” and Expression is “123456789”. Pick JSONPath. Here “$” is the root element. .repository is the first level of JSON, and .id is the second level. The matching JSON looks like

{ "repository": { "id": "1234567898" } }

This is enough to ID the repo but I’m a kind of person when the bridge is not broken, just bang it again to make sure it’s not broken.

Add another “Post content parameters”:
This time, variable is “$.repository.html_url” and value is “https://github/mygithubaccount/project-name”

Now, really, you don’t have to do this, but I did:

Header parameters:
* Request Header: x_github_event
* Value filter: ping

Then the most important part:

Token: YOUR_SECRET_TOKEN

Cause: Github Webhook push

Step 5

Okay. Once this is done, it’s time to test this. If you did step 3, and push a trigger and fail, you can see that the github project page shows the failed push. In it, you can see the header and payload. My Github webhook’s set up is done by looking at the header and payload. WIthout it, I had no clue as to how I can set it up. The reason why setting up Github first gives you this clue.

I can report that this is working happily. Hope this helps someone.

Possible Step 6

If you don’t mind paying $5/month, you can use ngrok.com so that you can do this without punching a hole through firewall.