Creating Jenkins pipelines with Ansible, part 1

Introduction

Infrastructure as code, continuous integration (CI), continuous delivery (CD), and version control (also known as source control management, or SCM) are good things. In this post and the next we'll automate the deployment of a system to do CI/CD in what I consider a proper way, where the definition of your build and deployment pipelines live alongside your code in git repositories on some server you have SSH access to.

We'll use Jenkins for CI/CD and Ansible to install it. Jenkins has been around for more than a decade, created before anyone had ever uttered the word "devops". Since it's old, Jenkins has a lot of features, most of them provided as plugins. The core itself isn't bad, and is stable and relatively free from bugs because it has been in use by a lot of people for such a long time. All those years have created a graveyard of plugins, though, yet some plugins are essential if you want to use Jenkins "properly". Want a timestamp for your builds? There's a plugin for that. Actually, there's more than one plugin for that.

Jenkins comes from a time when people thought it was great to have easy to use web interfaces to configure their build systems. The web interface gets in the way of infrastructure as code, where configuration is version controlled and changed by editing text files, not by clicking buttons.

Pipeline is a "new" feature of Jenkins 2.0, but it is based on a plugin which used to be known as Workflow. Using Pipeline we can describe how to test, build and deploy our project using text. That description is typically saved in a file named Jenkinsfile. We'll also use the job-dsl plugin to create the Pipeline jobs, but everything else will be done with shell scripts and kept as simple as possible.

An Ansible role for Jenkins

We'll create an Ansible role which avoids plugins as much as possible and doesn't use the web interface for configuration. It will use the Jenkins REST API to install plugins and change configuration.

To get all the details you can read the source on github. We'll skip the boring parts, and omit some details, and get straight to the interesting bits of the Ansible role.

To disable the setup wizard we need to pass jenkins.install.runSetupWizard=false to Jenkins. Setup wizards may make it easier to get started, but they get in the way of infrastructure as code.

- name: Set Jenkins JAVA_ARGS
  lineinfile:
    dest: "{{ jenkins_defaults_file }}"
    insertbefore: "^JENKINS_ARGS.*"
    line: "JAVA_ARGS=\"-Djava.awt.headless=true -Djenkins.install.runSetupWizard=false\""
  register: jenkins_defaults

Next, we need to create an admin password. Jenkins supports different password hashing algorithms, and we'll use SHA256 with the salt set to "jenkins". You may want to modify the salt to something unique, or look into stronger hashes, if you're feeling exposed.

- name: Create Jenkins admin password hash
  shell: echo -n "{{ jenkins_pass }}{jenkins}" | sha256sum - | awk '{ print $1; }'
  register: jenkins_pass_hash

We use jenkins_pass_hash.stdout in the admin-config.xml.j2 Jinja2 template to set the password for the admin user, setting force=no when creating the admin user's config file. Jenkins unfortunately saves some other information about the user, such as last login time, in this file and we don't want to always overwrite it. Consequently, our role will fail if the user changes the admin password. If that happens, users can delete user/admin/config.xml and let Ansible recreate it or change the password variable used for this role.

- name: Create admin user directory
  file:
    path: "~jenkins/users/admin"
    owner: jenkins
    group: jenkins
    mode: 0755
    state: directory
    recurse: yes

- name: Create admin
  template: src=admin-config.xml.j2 dest="~jenkins/users/admin/config.xml" force=no
  register: jenkins_admin_config

- name: Create config
  copy: src=config.xml dest="~jenkins/config.xml"
  register: jenkins_config

register is used in the last two commands in order to be able to restart Jenkins if the configuration was changed, which we can act on with the |changed filter. Restarts are typically done in handlers, which run only once at the end. We can't wait that long since the following commands will only work if the updated password is active, which requires a restart of Jenkins.

- name: Restart Jenkins if necessary
  service: name=jenkins state=restarted
  when: jenkins_defaults|changed or jenkins_admin_config|changed or jenkins_config|changed

- name: Wait for Jenkins to become available
  wait_for: port=8080

Here's the most complicated part. The Jenkins API uses a "crumb" to prevent Cross Site Request Forgery (CSRF) exploits. In order to use the API we need to retrieve this crumb. In addition, while we did use wait_for to wait for Jenkins start, it may still be initializing. We use until, retries and delay to get around that issue.

- name: Get Jenkins crumb
  uri:
    user: admin
    password: "{{ jenkins_admin_pass }}"
    force_basic_auth: yes
    url: "http://127.0.0.1:8080/crumbIssuer/api/json"
    return_content: yes
  register: crumb_token
  until: crumb_token.content.find('Please wait while Jenkins is getting ready') == -1
  retries: 10
  delay: 5

- name: Set crumb token
  set_fact:
    crumb: "{{ crumb_token.json.crumbRequestField }}={{ crumb_token.json.crumb }}"

Now we're ready to use the REST API to install some plugins we need to enable our automation based on checking out projects and letting their Jenkinsfile do the rest. We'll make a POST request to install each plugin, regardless of whether or not it has been installed already. You can take a look at the git repository for a more verbose solution which checks the list of installed plugins and only makes POSTs for plugins that are not installed.

The only plugins we need are git, job-dsl, workflow-aggregator, and workflow-cps.

- name: Install plugins
  uri:
    user: admin
    password: "{{ jenkins_admin_pass }}"
    force_basic_auth: yes
    url: "http://127.0.0.1:8080/pluginManager/install?plugin.{{ item }}.default=on&{{ crumb }}"
    method: POST
    status_code: [200, 302]
  with_items: "{{ jenkins_plugins }}"

We need to wait for Jenkins to finish installing the plugins. Some plugins require Jenkins to be restarted, so we need to look out for that as well. Plugins that are being installed have installStatus set to Pending. We'll give Jenkins up to 10 minutes to finish installing plugins, checking if it's done every 10 seconds.

Every time we use the API we need to specify credentials and the crumb, as above, but we'll omit those details from here on.

- name: Wait for plugins to be installed
  uri:
    url: "http://127.0.0.1:8080/updateCenter/installStatus?{{ crumb }}"
    return_content: yes
  register: plugin_status
  until: "'Pending' not in plugin_status.json.data.jobs|map(attribute='installStatus')"
  retries: 60
  delay: 10

- name: Check if we need to restart Jenkins to activate plugins
  uri:
    url: "http://127.0.0.1:8080/updateCenter/api/json?tree=restartRequiredForCompletion&{{ crumb }}"
    return_content: yes
  register: jenkins_restart_required

- name: Restart Jenkins to activate new plugins
  service: name=jenkins state=restarted
  when: jenkins_restart_required.json.restartRequiredForCompletion|bool

- name: Wait for Jenkins to become available
  wait_for: port=8080

You can now login to Jenkins at http://127.0.0.1:8080 as admin with the password you chose. You'll find that while it's nice and clean, it doesn't actually do anything yet. The plugins we installed enable us to check out code from git repositories and run build pipelines described in the Jenkinsfile at the root of the repository. We'll make that happen in the next post.