Background

Due to the lack of CI, I have to run RSpec manually every time I am about to merge my feature branch into master branch. It’s an exhausting and forgettable yet important chore. People are unreliable, however, it is just a matter of time before something goes wrong. That’s why I decided to build our CI pipeline to run RSpec.

CI Options

I have CircleCI and Jenkins under consideration. However, we have to pay to use CircleCI. The powerful Jenkins becomes the only option.

Jenkins Installation

Google it.

Initial Build Plan

The initial build plan was every straightforward. I’ve just installed all the dependencies our project needs (rbenv, Ruby, Gems, PostgreSQL, Redis, etc.) on the instance to run RSpec. It worked, though it’s not elegant.

The instance is not only to run RSpec, and there would be an incremental chance of conflicts among dependencies if I install every dependency in need in the future.

The ideal way to do this is to create an isolated environment to build the CI.

Docker is the choice.

Build Plan Improvement

The improvement is to build a docker base image in which includes all the necessary dependencies our project needs to run RSpec. During every CI build, a new docker image (let’s call it test image) is built from that base image. And the test image will copy the merged project, execute bundle install and run RSpec. After the test, the test image will be deleted to save disk space.

This is a complete build cycle:

image-20200929162337900

Docker Configuration

Build the Base Image

We could save a lot of trouble by using the official Ruby image.

The base image Dockerfile:

FROM ruby:2.4.3
LABEL maintainer="Emilio"
# set app home env
ENV APP_HOME /usr/src/app_home/
# copy Gemfile to install Gems
COPY Gemfile Gemfile.lock $APP_HOME

WORKDIR $APP_HOME
# configure gem sources
RUN gem sources --add https://gems.ruby-china.com/ --remove https://rubygems.org/
# designate bundler version
RUN gem install bundler -v 1.17.3
# install PostgreSQL 12 and so on
RUN apt-get update && apt-get install -y lsb-release && apt-get clean all
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y postgresql-12 postgresql-client-12 nodejs redis-server libsndfile-dev && apt-get clean all
# install ffmpeg
RUN echo "deb http://www.deb-multimedia.org jessie main non-free" >> /etc/apt/sources.list
RUN echo "deb-src http://www.deb-multimedia.org jessie main non-free" >> /etc/apt/sources.list
RUN apt-get update
RUN apt-get install -y --force-yes deb-multimedia-keyring
RUN apt-get update
RUN apt-get install -y ffmpeg
# install Gems
RUN bundle install
RUN rm -rf $APP_HOME

Build the base image, let’s name it rspec-base :

sudo docker image build -t rspec-base:1.0.0 .

Built successfully:

image-20200929164913912

Build the Test Image

Now we can build the test image from rspec-base image:

FROM rspec-base:1.0.0
LABEL maintainer="Emilio"
# set app home env
ENV APP_HOME /usr/src/app_home/
# ./project is where project locates every time Jenkins pulls the branch
COPY ./project $APP_HOME
# the entrypoint script
COPY docker-entrypoint.sh /etc/docker-entrypoint.sh
RUN chmod +x /etc/docker-entrypoint.sh

WORKDIR $APP_HOME

ENTRYPOINT ["/etc/docker-entrypoint.sh"]

The docker-entrypoint.sh:

#!/bin/bash
# start PostgreSQL
/etc/init.d/postgresql start
# create user and database
su - postgres -c "createuser -s root" && su - root -c "createdb 'test'"
# start redis
/etc/init.d/redis-server start
# install Gems
bundle install
bundle exec rails db:migrate:reset RAILS_ENV=test > /dev/null
# it's not necessary for everyone
psql -d test < db/sql/20200326_partition/01_create_tables.sql > /dev/null
psql -d test < db/sql/20200326_partition/02_create_stats_table.sql > /dev/null
psql -d test < db/sql/20200326_partition/04_create_index.sql > /dev/null
# run RSpec and make JUnit reports
bundle exec rake -r 'ci/reporter/rake/rspec' ci:setup:rspec spec RAILS_ENV=test

Build test image and call it rspec-build :

sudo docker image build -t rspec-build:1 .

Built successfully:

image-20200929171006412

Run the Test

Try to run the test:

sudo docker run --rm -t 2e828daf7fd2

image-20200929173416158

After installing all the necessary Gems, it successfully finished running RSpec.

There’s a potential problem though.

A Potential Problem

So far, all the Gems are installed in the rspec-base image, so the bundle install in the rspec-build image could reuse all the Gems.

However, if we add new Gems in the future and these Gems are not in the rspec-base image, every time rspec-build runs, all the new added Gems will be downloaded and installed again, which is extremely time consuming and unacceptable.

Solution

We could use volume to solve the problem.

Store all the Gems after bundle install in a file on the host instance, and mount that file on the container every time rspec-build image runs. So the new added Gems will be downloaded and installed only once and the following bundle install could reuse them in the future.

Create a volume and name it docker-bundle :

sudo docker volume create docker-bundle

Get into the rspec-build container and check where Gems are installed:

sudo docker run --rm -it --entrypoint=/bin/bash rpsec-build:1

image-20200929174944998

Mount docker-bundle on /usr/local/bundle when run the rspec-build image:

sudo docker run --rm -t -v docker-bundle:/usr/local/bundle rpsec-build:1

Jenkins Configuration

Build Triggers

We use coding.net of Tencent as our code repository (Visiting GitHub from Mainland China is too slow), which requires a plugin called Coding Webhook Plugin to handle the webhook from coding.net:

image-20200929180614641

Configure Build Triggers:

image-20200929180941425

Configure Webhook:

image-20200929181111722

Pipeline Script Configuration

pipeline {
    agent {
        label 'master'
    }

    options {
        timestamps()
        ansiColor('xterm')
    }

    stages {
        stage('Copy Scripts') {
            steps {
                // clean directory before build
                deleteDir()
                sh "cp ../Dockerfile ../docker-entrypoint.sh ."
                // make reports to store junit reports
                sh "mkdir reports"
            }
        }
        stage('Checkout Branch') {
            steps {
                dir('project') {
                    // pull branches
                    checkout changelog: false, poll: false, scm: [$class: 'GitSCM', branches: [[name: 'master']], doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'LocalBranch'], [$class: 'CheckoutOption', timeout: 60], [$class: 'CloneOption', noTags: true, reference: '', shallow: false, timeout: 60]], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'credentialsId', name: 'origin', refspec: 'git refspec', url: 'git url']]]
                }
            }
        }
        stage('Build RSpec Image') {
            steps {
                // build test image from base image, set tag to BUILD_NUMBER
                sh "docker image build -t rspec-build:${env.BUILD_NUMBER} ."
            }
        }
        stage('Run RSpec') {
            steps {
                // run rspec
                // besides docker-bundle, reports file in workspace is also mounted to store junit reports
                sh "docker run --rm -t -v docker-bundle:/usr/local/bundle -v ${env.WORKSPACE}/reports:/usr/src/app_home/spec/reports rspec-build:${env.BUILD_NUMBER}"
            }
        }
    }

    post {
        always {
            // ensure test image is deleted after every build
            sh "docker image rm rspec-build:${env.BUILD_NUMBER}"
            // junit reports
            junit 'reports/*.xml'
        }
    }
}

Result

Created some pull requests which triggered the CI build successfully, as well as the JUnit reports:

image-20200929182859757

image-20200929182815281

Reference

https://blog.niclin.tw/2019/08/18/using-jenkins-and-docker-to-run-rails-rspec-ci/