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.