How to Make Your Mac a Jenkins Server and Agent

You may be working on a personal project. Maybe your company has a different CI/CD system from what you are used to or it’s hard to be onboarded with the CI/CD system. I’ve come up with a way to create your own personal Jenkins server and agent all in one macOS. The following diagram shows what I am envisioning.

This whole things can easily fit on a laptop or a desktop. Obviously the architecture could be applied to Windows and Linux as well. I have Mac as my main development machine, so I will use Mac as an example here.

Install Docker on Mac

Installing Docker on macOS is very easy.

  • First, download Docker for Mac from here.
  • (Double-)click the downloaded Docker.dmg file.
  • Drag and drop the Docker application file to /Applications directory.
  • Double-click Docker.app file in /Applications directory.
  • You may have to reboot your machine to get the daemon to work correctly.
  • Open Terminal.
  • Execute docker run -d -p 80:80 docker/getting-started
  • Once it’s successful, it means your Docker installation was successful.

One thing to note is that you don’t have to add your account to docker group so you don’t have to sudo for every docker command to execute and docker-compose is bundled with Docker.app on Mac, which is nicer than the installation process on Linux.

Jenkins Master

We will spin up the Jenkins master using docker-compose. Create a directory such as ~/jenkins.

Copy and paste the following contents in docker-compose.yaml file in ~/jenkins.

version: '3.8'
services:
    jenkins:
        image: jenkins/jenkins:2.263.1
        container_name: jenkins
        user: '1000'
        volumes:
            - ./jenkins_home:/var/jenkins_home
        ports:
            - '8080:8080'
            - '50000:50000'
        networks:
            - docker_network
        restart: unless-stopped
        environment:
            - JENKINS_JAVA_OPTIONS=-Djava.awt.headless=true
            - JENKINS_LOG=/var/jenkins_home/logs/jenkins.log
        #command: --enable-future-java
networks:
    docker_network:
        external:
            name: custom_network

Execute the following command to create a network for your Docker containers.

docker network create custom_network

After that, execute the following command from at ~/jenkins

docker-compose up

When you see the output like the following, you have Jenkins up and running

jenkins    | This may also be found at: /var/jenkins_home/secrets/initialAdminPassword
jenkins    |
jenkins    | *************************************************************
jenkins    | *************************************************************
jenkins    | *************************************************************
jenkins    |
jenkins    | 2021-01-08 07:21:22.224+0000 [id=44]	INFO	h.m.DownloadService$Downloadable#load: Obtained the updated data file for hudson.tasks.Maven.MavenInstaller
jenkins    | 2021-01-08 07:21:22.231+0000 [id=44]	INFO	hudson.util.Retrier#start: Performed the action check updates server successfully at the attempt #1
jenkins    | 2021-01-08 07:21:22.248+0000 [id=44]	INFO	hudson.model.AsyncPeriodicWork#lambda$doRun$0: Finished Download metadata. 9,056 ms
jenkins    | 2021-01-08 07:21:23.526+0000 [id=31]	INFO	jenkins.InitReactorRunner$1#onAttained: Completed initialization
jenkins    | 2021-01-08 07:21:23.554+0000 [id=20]	INFO	hudson.WebAppMain$3#run: Jenkins is fully up and running

At this point, you could hit the Jenkins master by navigating your browser to http://localhost:8080 but this is not what I want. I would like to have Dockerized NGINX in front of it and route the traffic to the Jenkins so that it is easy to implement SSL certs for it.

You can Ctrl+C to stop Jenkins and then…

docker-compose down

to completely remove the container.

NGINX as Reverse Proxy

As the diagram indicates, my plan is to have NGINX accept HTTP(S) traffic and route the traffic to the Jenkins instance via the Docker’s virtual network.

Please follow the steps below.

Create a directory like ~/nginx

mkdir ~/nginx

Copy and paste the following YAML to ~/nginx/docker-compose.yaml file and save the file.

version: '3.8'
services:
    reverse:
        image: nginx:latest
        volumes:
            - ./data/nginx.conf:/etc/nginx/nginx.conf
            - ./data/conf.d:/etc/nginx/conf.d
        ports:
            - "80:80"
            - "443:443"
        networks:
            proxynet:
        restart: always
networks:
    proxynet:
        name: custom_network

Next, create ~/nginx/data directory. While you are there, let’s create conf.d directory.

mkdir data
cd data
mkdir conf.d

Copy and paste the following contents to ~/nginx/data/nginx.conf file and save it.

user  nginx;
worker_processes  2;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
   worker_connections  1024;
   use epoll;
   accept_mutex off;
}

http {
   include       /etc/nginx/mime.types;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

   default_type  application/octet-stream;

   log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                     '$status $body_bytes_sent "$http_referer" '
                     '"$http_user_agent" "$http_x_forwarded_for"';

   access_log  /var/log/nginx/access.log  main;

   sendfile        on;
   #tcp_nopush     on;

   keepalive_timeout  65;

   client_max_body_size 300m;
   client_body_buffer_size 128k;

   gzip  on;
   gzip_http_version 1.0;
   gzip_comp_level 6;
   gzip_min_length 0;
   gzip_buffers 16 8k;
   gzip_proxied any;
   gzip_types text/plain text/css text/xml text/javascript application/xml application/xml+rss application/javascript application/json;
   gzip_disable "MSIE [1-6]\.";
   gzip_vary on;

   server {
       listen       80 default_server;
       listen       [::]:80 default_server;
       server_name  _;
       sendfile        on;
       #tcp_nopush     on;

       keepalive_timeout  65;

      client_max_body_size 300m;
      client_body_buffer_size 128k;

      gzip  on;
      gzip_http_version 1.0;
      gzip_comp_level 6;
      gzip_min_length 0;
      gzip_buffers 16 8k;
      gzip_proxied any;
      gzip_types text/plain text/css text/xml text/javascript application/xml application/xml+rss application/javascript application/json;
      gzip_disable "MSIE [1-6]\.";
      gzip_vary on;
   }
    include /etc/nginx/conf.d/*.conf;
}

Next, paste the following configuration to ~/nginx/conf.d/ssl.conf file.

upstream jenkins_upstream {
    server jenkins:8080;
}

server {
    server_name jenkins.local;
    listen 443 ssl;
    ssl_certificate /etc/nginx/conf.d/ssl/jenkins.local.crt;
    ssl_certificate_key /etc/nginx/conf.d/ssl/jenkins.local.key;

    location / {
        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;
        resolver 127.0.0.11;
        #proxy_redirect http:// https://;
        proxy_pass http://jenkins_upstream;
        # Required for new HTTP-based CLI
        proxy_http_version 1.1;
        proxy_request_buffering off;
        proxy_buffering off; # Required for HTTP-based CLI to work over SSL
        # workaround for https://issues.jenkins-ci.org/browse/JENKINS-45651
        add_header 'X-SSH-Endpoint' 'jenkins.local:50022' always;
    }
}

SSL Support

Since I’m just planning to run this locally, there isn’t a need for SSL but I would like to implement this for a good practice. And you never know, you may want to create a Jenkins instance in a real environment eventually.

I am planning to make the URL https://jenkins.local

Place the following files for SSL support. If you need to know how to create self-signed cert, please take a look my previous article.

To summarize, here are the files you have to create for NGINX to work in the nginx root directory.

├── data
│   ├── conf.d
│   │   ├── ssl
│   │   │   ├── jenkins.local.crt
│   │   │   └── jenkins.local.key
│   │   └── ssl.conf
│   └── nginx.conf
└── docker-compose.yml

Next, let’s edit /etc/hosts file so that https://jenkins.local will resolve to your local NGINX.

sudo vim /etc/hosts

Add the following line in the file.

127.0.0.1       jenkins.local

Now navigate to ~/jenkins and execute the following command.

docker-compose up -d

This starts the instance of Jenkins. Now navigate to ~/nginx and execute the following command.

docker-compose up -d

This starts the instance of NGINX as a reverse proxy that routes the traffic to the containerized Jenkins.

Access Jenkins UI and Initial Configuration

If you open your browser, and navigate it to https://jenkins.local/ you should see the following screen.

Beware that if you try to get the Administrator password from /var/jenkins_home/secrets/initialAdminPassword, your host side does not have the file at that location. Instead, you should cat the file at the following location.

~/jenkins/jenkins_home/secrets/initialAdminPassword

Once you get the admin password from the location, enter it to pass the initial screen to start the containerized Jenkins master.

Connect Jenkins Slave

If you take a look at the UI, you see that the Jenkins master has 2 executors by default. If you want your Mac to run builds, you can do so. Here are the steps.

Navigate to Manage Jenkins -> Manage Nodes and Clouds and then click New Node.

Enter mac for Node name and click Permanent Agent and then click OK.

And click Save on the next screen.

When you go back to Manage Nodes and Clouds, you will see the following screen.

Click mac (or whatever you created) and you will see the following screen.

You can definitely follow the instruction on the screen to connect your mac as a Jenkins slave. First download the agent.jar file from the same page and store it in ~/jenkins-slave directory.

Next execute the following command.

echo 15ef91ec4499d7be20f64fdca93995ab37cd23182982d8814ccb35b6df8254e7 > secret-file

This writes the secret to the secret-file in the directory. And then try to execute the following command to get your mac connected to Jenkins master as a slave.

java -jar agent.jar -jnlpUrl https://jenkins.local/computer/mac/slave-agent.jnlp -secret @secret-file -workDir "~/jenkins-slave"

You would expect that this should be the last step but you would get an error like the following.

ava -jar agent.jar -jnlpUrl https://jenkins.local/computer/mac/slave-agent.jnlp -secret @secret-file -workDir "~/dev/jenkins-slave"
Jan 13, 2021 10:31:43 PM org.jenkinsci.remoting.engine.WorkDirManager initializeWorkDir
INFO: Using ~/dev/jenkins-slave/remoting as a remoting work directory
Jan 13, 2021 10:31:43 PM org.jenkinsci.remoting.engine.WorkDirManager setupLogging
INFO: Both error and output logs will be printed to ~/dev/jenkins-slave/remoting
Exception in thread "main" java.io.IOException: Failed to validate a server certificate. If you are using a self-signed certificate, you can use the -noCertificateCheck option to bypass this check.
	at hudson.remoting.Launcher.parseJnlpArguments(Launcher.java:571)
	at hudson.remoting.Launcher.run(Launcher.java:347)
	at hudson.remoting.Launcher.main(Launcher.java:298)

This means that Java does not trust the self signed SSL certificate you created. I blogged about this in the past, so please refer to this blog article to resolve this issue.

Once the SSL cert issue is resolved, it connects to Jenkins master and it’s ready like the image below.

INFO: Agent discovery successful
  Agent address: jenkins.local
  Agent port:    50000
  Identity:      5c:25:1c:e8:1d:35:f1:e8:d1:34:b6:52:ac:e9:e5:ca
Jan 13, 2021 11:13:33 PM hudson.remoting.jnlp.Main$CuiListener status
INFO: Handshaking
Jan 13, 2021 11:13:33 PM hudson.remoting.jnlp.Main$CuiListener status
INFO: Connecting to jenkins.local:50000
Jan 13, 2021 11:13:33 PM hudson.remoting.jnlp.Main$CuiListener status
INFO: Trying protocol: JNLP4-connect
Jan 13, 2021 11:13:33 PM hudson.remoting.jnlp.Main$CuiListener status
INFO: Remote identity confirmed: 5c:25:1c:e8:1d:35:f1:e8:d1:34:b6:52:ac:e9:e5:ca
Jan 13, 2021 11:13:33 PM hudson.remoting.jnlp.Main$CuiListener status
INFO: Connected

Recap

It took me a few nights to write up this article. To recap…

  • Dockerizing Jenkins master
  • Using NGINX as a reverse proxy
  • Implementing SSL cert
  • Jenkins slave

This give you an environment where you can manage your own Jenkins in Docker and do whatever you want!

checksum for files

Each file can have a finger print depending on its content. The finger print is called checksum. If 2 files have exactly the same content, those 2 files would have the same checksum. We can use md5 on Mac and md5sum on Linux.

I have a file “~/tmp/data.txt”. It’s checksum can be calculated with the following command.

md5 data.txt

Result

MD5 (data.txt) = e1e1415d2433143ff43103b384df7402

I’m going to copy the file as data2.txt and have it calculate the checksum.

To copy…

cp data.txt data2.txt

Then I’m going to pass 2 files to md5 command.

md5 data.txt data2.txt

Result

MD5 (data.txt) = e1e1415d2433143ff43103b384df7402
MD5 (data2.txt) = e1e1415d2433143ff43103b384df7402

As you can see, the hashes are exactly the same. This is really useful to see if the files are exactly the same or not.

You could implement a process to calculate the hash locally and then the files are uploaded to the production systems, you could have another process on the server side to make sure the uploaded files have the same hash.

You could alternatively combine find command and md5 command to have it calculate the selected files like the following.

find . -name 'data*' | xargs md5

Result

MD5 (./data2.txt) = e1e1415d2433143ff43103b384df7402
MD5 (./data.txt) = e1e1415d2433143ff43103b384df7402

And when the same file is copied to another system from Mac to Linux (I have a Linux-Mint machine) and execute md5sum on the file, I get exactly the same md5 hash.

How to Check the Size of Directories

If you want to see how much data a directory contains. Here is the command you can use.

du -sh dev

Result

746M dev

If you want to see the size of all the directory sizes…

du -sh *

Result

4.0K	2020-07-21-06-12-49.081-VBoxSVC-7899.log
34M	apps
241M	bin
48K	CA
175M	data
4.0K	Desktop
746M	dev
86G	Documents
4.6G	Downloads
4.0K	id_rsa
4.0K	id_rsa.pub
4.0K	Music
4.0K	Pictures
4.0K	Public
4.0K	Templates
90M	tmp
4.0K	Videos
0	VMs

Let’s take it a little further. What if you want to check the sizes of directories up to the second level? Here is the command you can use.

sudo du -h --max-depth=2

Result

8.0K ./.vim
4.0K ./.gnupg/private-keys-v1.d
8.0K ./.gnupg
4.0K ./Videos
620M ./dev/docker
4.0K ./Public
4.0K ./Templates
2.6G ./.local/share
2.6G ./.local
4.0K ./Pictures
<SNIP>
19M ./.mozilla/firefox
19M ./.mozilla
8.0K ./.hplip
4.0K ./Desktop
428K ./.mozc
7.1M ./tmp/nginx-1.18.0
34M ./tmp/sublime_text_3
50M ./tmp/docker-rootless-extras
90M ./tmp
96G .

How to Use awk

awk is one of the most used commands in bash environment. I can’t possibly cover everything about awk but I will write about an example that can be applied to many situations.

When I list files and directories in a directory, I execute…

ls -lah

That shows outputs the list like below.

total 296
drwxr-xr-x+ 50 hiriumi  staff   1.6K Aug 27 21:44 .
drwxr-xr-x   5 root     admin   160B Dec  5  2019 ..
-r--------   1 hiriumi  staff     7B Aug 18 22:06 .CFUserTextEncoding
-rw-r--r--@  1 hiriumi  staff    20K Aug 26 20:47 .DS_Store
drwx------  86 hiriumi  staff   2.7K Aug 26 16:57 .Trash
-rw-------   1 hiriumi  staff    36K Aug 29 17:44 .bash_history
-rw-r--r--   1 hiriumi  staff   788B Jul 19 17:31 .bash_profile
-rw-r--r--   1 hiriumi  staff   272B Mar 10 23:35 .bash_profile.backup
drwx------   3 hiriumi  staff    96B Apr 14 22:46 .cache
drwx------   5 hiriumi  staff   160B May 21 19:54 .config
drwx------   3 hiriumi  staff    96B Mar  7 13:40 .cups
drwxr-xr-x  14 hiriumi  staff   448B Mar  6 22:17 .dropbox
drwxr-xr-x  16 hiriumi  staff   512B Aug  1 14:25 .dvdcss
-rw-r--r--   1 hiriumi  staff    56B Mar  9 00:01 .gitconfig
drwxr-xr-x  15 hiriumi  staff   480B May 10 09:09 .iterm2
-rwxr-xr-x   1 hiriumi  staff    22K May 10 09:10 .iterm2_shell_integration.bash
drwxr-xr-x   6 hiriumi  staff   192B Mar  9 00:26 .kivy
<SNIP>

As you can see, if it’s a file, the line starts with “-” and if it’s a directory, it starts with “d”. Let’s just list the files using grep. I will show an example in awk after that.

ls -lah | grep '^-'

The following command outputs exactly the same list.

ls -lah | awk '/^-/ {print}'
-r--------   1 hiriumi  staff     7B Aug 18 22:06 .CFUserTextEncoding
-rw-r--r--@  1 hiriumi  staff    20K Aug 26 20:47 .DS_Store
-rw-------   1 hiriumi  staff    36K Aug 29 17:44 .bash_history
-rw-r--r--   1 hiriumi  staff   788B Jul 19 17:31 .bash_profile
-rw-r--r--   1 hiriumi  staff   272B Mar 10 23:35 .bash_profile.backup
-rw-r--r--   1 hiriumi  staff    56B Mar  9 00:01 .gitconfig
-rwxr-xr-x   1 hiriumi  staff    22K May 10 09:10 .iterm2_shell_integration.bash
-rw-------   1 hiriumi  staff    62B Jul 28 13:44 .lesshst
-rw-------   1 hiriumi  staff    29K Aug 27 21:44 .viminfo
-rw-r--r--   1 hiriumi  staff    10B Jun 29 21:40 .vimrc
-rw-r--r--   1 hiriumi  staff    32B May 22 20:29 .vuerc
-rw-------   1 hiriumi  staff    35B Mar  6 23:26 .zsh_history

What if you want to just output the file names? Here is what you can do. The command means print the 9th column separated by space.

ls -lah | awk '/^-/ {print $9}'
.CFUserTextEncoding
.DS_Store
.bash_history
.bash_profile
.bash_profile.backup
.gitconfig
.iterm2_shell_integration.bash
.lesshst
.viminfo
.vimrc
.vuerc
.zsh_history

The following command outputs the 1st and the 9th columns.

ls -lah | awk '/^-/ {print $1,$9}'
-r-------- .CFUserTextEncoding
-rw-r--r--@ .DS_Store
-rw------- .bash_history
-rw-r--r-- .bash_profile
-rw-r--r-- .bash_profile.backup
-rw-r--r-- .gitconfig
-rwxr-xr-x .iterm2_shell_integration.bash
-rw------- .lesshst
-rw------- .viminfo
-rw-r--r-- .vimrc
-rw-r--r-- .vuerc
-rw------- .zsh_history

What if you want a custom output for your documentation? I just want to list the file names with vertical bars. This can be used as a table in JIRA.

ls -lah | awk '/^-/ {print "|"$9"|"}'

Here is the sample output.

|.CFUserTextEncoding|
|.DS_Store|
|.bash_history|
|.bash_profile|
|.bash_profile.backup|
|.gitconfig|
|.iterm2_shell_integration.bash|
|.lesshst|
|.viminfo|
|.vimrc|
|.vuerc|
|.zsh_history|

Recap

awk is an old but very useful command on Linux/Mac. There is much more to this command so I will write in this blog as I come across more usages.