Jenkins As Code

Hello everyone! 

After reading Infrastructure As Code (IaC) then I was influenced by a concept "(*) as Code" from Cloudbees. So, this article is a part of an effort to modernize our environment into IaC, Jenkins is one kind of machine.


Use Case

Simple, create an immutable Jenkins master from code

Solution

From my point of view, for an application there is 2 parts:

  • The application binaries
  • The application data
This is also how we work with Docker concept, mount the volume data into the container, so the container is the binary/process application and the volume is the persistent application data which we can maintain it in the host. So with this idea, we will:
  • Automate the Jenkins installation
  • Maintain Jenkins data to be re-generated in other instance. This will be located in JENKINS_HOME
The first task is quite simple as we already done with Ansible. Take a deep drive into Jenkins data, there should be
  • Job configuration
  • Build data and history
  • Plugin binary files
  • System custom scripts (for ex: under groovy.init.d)
  • Jenkins configuration
  • Jenkins credentials (in credential plugin)

For the job configurations, actually, they are just the folder structure with a config.xml for the job configuration, so we can keep the 'jobs' folder into Git with gitignore file to keep the xml configuration only.

The build data and history, this is only the run time data so we will not need them. The build data output is surely to be promoted into artifact storage.

Plugin binaries, we have to list out all the installed plugins and keep this list only, we can re-install them at the time to install Jenkins. How to retrieve the plugin list, use this script in your script console

Jenkins.instance.pluginManager.plugins.each { plugin ->
  println "${plugin.getDisplayName()}-${plugin.getShortName()}: ${plugin.getVersion()}"
}

You can get a list of plugin short name then store it into Git as seed data for your plugin installation (mention about it later). There are other way to use REST api to retrieve the json/xml list but you have to deal with crumb access from remote via REST api.

Custom script for Jenkins initialization, you can keep those script in Git with the same mechanism with job configuration

Jenkins configuration, with the old school manner, you can store the config.xml file under JENKINS_HOME because it will have every thing. However, it also has the unique id with your template Jenkins, so there is a new project from Cloudbees which supports this need is called Jenkins Configuration as Code. This will help you to save the configuration as a yaml file then this plugin will load this yaml file into Jenkins configuration. This is an alternative/enhancement of writing the initialization code for Jenkins configuration.

Jenkins credentials, you have to get all the current credentials from Jenkins and set them up into the new system. As usual, Jenkins naively stores them in credentials.xml under JENKINS_HOME, you can bring it over but same with the configuration, the credentials will have the same unique id. So you can set them update with JCasC plugin too (or with groovy script). How to retrieve the current credentials

import com.cloudbees.plugins.credentials.Credentials
Set<Credentials> allCredentials = new HashSet<Credentials>();
def creds = com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(com.cloudbees.plugins.credentials.Credentials.class)

allCredentials.addAll(creds)

Jenkins.instance.getAllItems(com.cloudbees.hudson.plugins.folder.Folder.class).each{ f ->
 creds = com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(
      com.cloudbees.plugins.credentials.Credentials.class, f)
  allCredentials.addAll(creds)
}

for (c in allCredentials) {
   println(c.id + ": " + c.description)

}

You can get the encrypted token with credential.secret (for string type) or apiToken (for GitlabApiToken), then you can also decrypt them by this line

println(hudson.util.Secret.decrypt("${encrypted}"))

Alright, so each component is defined, now come to the whole picture. I will have 3 parts

  • Jenkins installation script
  • Job and custom script will be regularly stored into Git as a source of data
  • Create a configuration yaml file for Jenkins configuration and load this configuration from first start
With the current trend, the popular usage is Jenkins docker, so I try with Jenkins docker from docker hub (make sure use jenkins/jenkins the other images are deprecated)

Docker

So, a simple usage is run docker from the public image on docker hub, however, we have a domain user named 'jenkins' with different uid from the default 'jenkins' with uid = 1000 then when I mount the data volume into the docker, the jenkins user id 1000 can't create data into the data volume and vs the user id 1000 with different credential so I can't access with it to manipulate data within the container. So I have to customize the docker image to use the same jenkins user of my domain.

As long as we have a jenkins docker up then we will have the Jenkins application, so the rest is mount a volume data on host which will have the jobs and custom scripts from Git as the JENKINS_HOME for docker. Set the environment variable for JCasC point to the configuration yaml file. Here is my sample Dockerfile


FROM jenkins/jenkins:2.121.3

USER root

RUN apt-get update && apt-get install -y \
  sudo \
  git \
  openssh-server

ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false"

COPY jenkins.yaml /usr/share/jenkins/ref/jenkins.yaml
ENV CASC_JENKINS_CONFIG=/var/jenkins_home/jenkins.yaml

COPY init.groovy /usr/share/jenkins/ref/init.groovy.d/init.groovy
COPY slack_config.groovy /usr/share/jenkins/ref/init.groovy.d/slack_config.groovy

COPY plugins.list /usr/share/jenkins/ref/plugins.list
RUN /usr/local/bin/install-plugins.sh < /usr/share/jenkins/ref/plugins.list

RUN deluser --remove-home jenkins &&  adduser --disabled-password --gecos "" --uid $domain_id jenkins && adduser jenkins sudo && adduser jenkins shadow && echo "jenkins ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && chown jenkins /usr/share/jenkins/ref

USER jenkins

1: use a specific LTS version
2: by default Jenkins docker is set with jenkins (id: 1000) user, switch to root
3: install some essential packages
4: turn of the Jenkins installation wizar
5: copy the CasC yaml file to docker, when Jenkins start there is a symlink of /var/lib/jenkins to /usr/share/jenkins/ref
6: add environment variable CASC_JENKINS_CONFIG point to the CasC file, this is a requirement of JCasC plugin
7-8: copy custom script to init.groovy.d, this can be done by copy the custom script to mounted volume from docker host
9: copy the plugin list to docker
10: install plugin by install-plugins.sh tool from this docker
11: remove the default jenkins user id 1000 and add a new jenkins user with same id of the domain jenkins and make it as sudoer passwordless, make sure scripts or files within symlink directory will be accessible with this new user
12: active with jenkins user

Then now, you can build a docker image with this docker file

docker build -t myjenkins .

To use the job configuration from Git, you will create a folder on your host and use it as JENKINS_HOME, so you can create a directory within your workspace, for ex: myjenkins_home, copy the 'jobs' folder from Git into this directory (and custom script into init.groovy.d) then mount this directory as a volume to the container. Another item for my case, I use jenkins user  both on the host machine and the container to run the job, so I also mount the ssh key of my jenkins user on the host to the container. Start the container with the command below

docker run -td --name myjenkinscontainer -v /home/jenkins/jenkins_home:/var/jenkins_home -v /home/jenkins/.ssh:/home/jenkins/.ssh -p 8080:8080 -p 50000:50000 myjenkins:latest

So now, you can have almost the things you need, however, there are some hiccups that we have to deal. First thing is JCasC is a young plugin and it's in progress of adding support to a huge of plugins for Jenkins so obviously there are some plugins still out of the list, such as Slack Notification, although on its Github site there is a code for CasC, but you can notice that the code to support CasC is checked in recently and the hpi release about year ago (jump into detail, due to a process to run the automation test for Jenkins plugin, the update code couldn't pass so it still can't have the automation build of this plugin). So for this plugin, you have to use the groovy script for automate configuration in its main page, I post a simplified version of that code here with the credential was created by the JCasC setting

import jenkins.model.Jenkins
import net.sf.json.JSONObject

def slackParameters = [
        slackBotUser:             'true',
        slackRoom:                '#mychannel',
        slackSendAs:              'Jenkins',
        slackTeamDomain:          'mydomain',
        slackTokenCredentialId:   'Slack-API-Token'
]

// get Jenkins instance
Jenkins jenkins = Jenkins.getInstance()

// get Slack plugin
def slack = jenkins.getExtensionList(jenkins.plugins.slack.SlackNotifier.DescriptorImpl.class)[0]

// define form and request
JSONObject formData = ['slack': ['tokenCredentialId': slackParameters.slackTokenCredentialId]] as JSONObject
def request = [getParameter: { name -> slackParameters[name] }] as org.kohsuke.stapler.StaplerRequest

// add Slack configuration to Jenkins
slack.configure(request, formData)

// save to disk
slack.save()

jenkins.save()

So now, you can have almost the setting, but again, life is not so easy. I'm using NIS Unix user base for Jenkins, which we will need to have nis service within the jenkins container and I haven't found a solution yet. I got an advice to take a look on docker compose to create a docker farm for this need but I haven't tried to dig into detail. Then, I have to give up with docker container solution. Again, this will come back to my belief when using Docker.


VM

With the unsuccessful try with docker then we will come back to the legacy solution with a VM, where we already had a mechanism with Ansible in previous post. So now, in this post we will refine the previous script a little bit to complete the full picture. 

First thing is the one that kick me out of docker idea, NIS user base, you have to make sure your jenkins user can access to sshd of nis service, this user has to be in 'shadow' group, then add this task into your playbook

  - name: add 'jenkins' user to shadow group
    user:
     name: jenkins
     groups: shadow

     append: yes

A fun thing is I don't know how to setup an environment variable when starting jenkins service. At first, I tried with this guide from Cloudbees but, when you install jenkins from debian package, the package already create a daemon service, so if you edit/create the jenkins service again, your jenkins will be messed up. I intended to give up JCasC and use the groovy script for configuration like we have done with Slack Notification, here are some sample which I found for create credential

import com.cloudbees.plugins.credentials.*
import com.cloudbees.plugins.credentials.common.*
import com.cloudbees.plugins.credentials.domains.Domain
import com.cloudbees.plugins.credentials.impl.*
import org.jenkinsci.plugins.plaincredentials.impl.*
import hudson.util.Secret
import jenkins.model.Jenkins


Jenkins jenkins = Jenkins.getInstance()
def domain = Domain.global()
def store = jenkins.getExtensionList('com.cloudbees.plugins.credentials.SystemCredentialsProvider')[0].getStore()

// parameters
def artifactoryCredentialParameters = [
        description:  'Artifactory Token for Jenkins',
        id:           'Artifactory-API-Token',
        secret:       'AtifactoryTokenAPI'
]

// create credential
def artifactoryToken = new StringCredentialsImpl(
        CredentialsScope.SYSTEM,
        artifactoryCredentialParameters.id,
        artifactoryCredentialParameters.description,
        Secret.fromString(artifactoryCredentialParameters.secret)
)

// add credential to Jenkins credentials store
store.addCredentials(domain, artifactoryToken)

// parameters
def gitlabCredentialParameters = [
        description:  'GitLab Token for Jenkins',
        id:           'GitLab-API-Token',
        token:       'GitLabTokenAPI'
]

def gitlabToken = new GitLabApiTokenImpl(
        CredentialsScope.SYSTEM,
        gitlabCredentialParameters.id,
        gitlabCredentialParameters.description,
        Secret.fromString(gitlabCredentialParameters.token)
)

// add credential to Jenkins credentials store
store.addCredentials(domain, gitlabToken)

Configure GitLab connection

import com.dabsquared.gitlabjenkins.connection.*

GitLabConnectionConfig descriptor = (GitLabConnectionConfig) Jenkins.getInstance().getDescriptor(GitLabConnectionConfig.class)
GitLabConnection gitLabConnection = new GitLabConnection('GitLab',
                                        'myGitLaburl',
                                        gitlabCredentialParameters.id,
                                        false,
                                        10,
                                        10)
descriptor.getConnections().clear()
descriptor.addConnection(gitLabConnection)

descriptor.save()

Configure Artifactory connection

import org.jfrog.*
import org.jfrog.hudson.*
import org.jfrog.hudson.util.Credentials;

def artifactoryDesc = jenkins.getDescriptor("org.jfrog.hudson.ArtifactoryBuilder")

CredentialsConfig deployerCredentials = new CredentialsConfig("", "", artifactoryCredentialParameters.id, false)

def ArtInst = [new ArtifactoryServer(
  "artifactoryID",
  "myaAtifactoryurl",
  deployerCredentials,
  null,
  300,
  false,
  3 )
]

artifactoryDesc.setArtifactoryServers(ArtInst)
artifactoryDesc.setUseCredentialsPlugin(true)
artifactoryDesc.save()

If you want to add some global properties in Jenkins

import hudson.slaves.EnvironmentVariablesNodeProperty
import jenkins.model.Jenkins

instance = Jenkins.getInstance()
globalNodeProperties = instance.getGlobalNodeProperties()
envVarsNodePropertyList = globalNodeProperties.getAll(EnvironmentVariablesNodeProperty.class)

newEnvVarsNodeProperty = null
envVars = null

if ( envVarsNodePropertyList == null || envVarsNodePropertyList.size() == 0 ) {
  newEnvVarsNodeProperty = new EnvironmentVariablesNodeProperty();
  globalNodeProperties.add(newEnvVarsNodeProperty)
  envVars = newEnvVarsNodeProperty.getEnvVars()
} else {
  envVars = envVarsNodePropertyList.get(0).getEnvVars()
}


envVars.put("MY_PROP, "myvalue")

However, this is too much work and research if you want to have a complex instance and it's not a scalable solution, so I have to find a way of using JCasC, then I start to dig into detail how the jenkins service created by debian package is running. You can see that jenkins will launch as daemon with this script /etc/init.d/jenkins then you can have a twist in this script by adding a daemon argument to set the environment variable for jenkins daemon, find this line

DAEMON_ARGS="--name=$NAME --inherit --env=JENKINS_HOME=$JENKINS_HOME --output=$JENKINS_LOG --pidfile=$PIDFILE"

then add the CasC environment variable


DAEMON_ARGS="--name=$NAME --inherit --env=JENKINS_HOME=$JENKINS_HOME --env=CASC_JENKINS_CONFIG=$JENKINS_HOME/jenkins.yml  --output=$JENKINS_LOG --pidfile=$PIDFILE"

You have to have this yaml file in that location, so you either copy it together with the jobs configuration from Git or copy it separately. For me, I use it separately because in this configuration file, I will create the credential for Jenkins so I will keep some secret data here and I will use ansible vault to keep it then substitute them into the yaml configuration.

  - name: update CasC yml file
    template:
     src: "jenkins.casc.j2"

     dest: "{{ jenkins_home }}/jenkins.yml"

Make sure to install those jenkins plugin in prior of using JCasC

 - configuration-as-code
 - configuration-as-code-support
 - jdk-tool

Then now you will copy the job configuration from Git into JENKINS_HOME/jobs then start jenkins service to apply the JCasC. You can run some configuration script after your jenkins has started, a quick note here, because you already finish the installation wizard (please see the detail implementation of my Jenkins installation in the previous post) so you can't use the initial administrator password anymore but using the new admin user that you have just created in the complete setup wizard.

Another note, if you have the CASC environment variable for jenkins daemon then every time the service restart, it will run the configuration as code and apply the configuration yaml, so after your initial configuration, you can either:
  • Remove the configuration yaml file, this might lead to the error when loading (I haven't tried yet)
  • Remove the CASC environment variable out of the daemon service
I go with the second option and remember to reload the daemon because we have change the daemon input


  - name: remove CasC config environment out off jenkins service /etc/init.d/jenkins

    lineinfile:

     path: '/etc/init.d/jenkins'
     regexp: '^DAEMON_ARGS='
     line: 'DAEMON_ARGS="--name=$NAME --inherit --env=JENKINS_HOME=$JENKINS_HOME --output=$JENKINS_LOG --pidfile=$PIDFILE"'
     backrefs: yes

  - name: restart jenkins service
    systemd:
     name: jenkins
     state: restarted
     daemon_reload: yes

And below is my workable CasC yaml

jenkins:
  systemMessage: "Jenkins configured automatically\n\n"
  numExecutors: 5
  scmCheckoutRetryCount: 2
  mode: NORMAL
  agentProtocols:
  - "JNLP4-connect"
  - "Ping"
  crumbIssuer:
    standard:
      excludeClientIPFromCrumb: false
  disableRememberMe: false
  globalNodeProperties:
  - envVars:
      env:
      - key: PROP1
        value: "value1"
      - key: PROP2
        value: "value2"
  securityRealm:
    pam:
      serviceName: "sshd"
  slaveAgentPort: 0
#  authorizationStrategy:
#    projectMatrix:
#      grantedPermissions:
#      - "Overall/Read:anonymous"
#      - "Overall/Administer:authenticated"
#      - "Overall/Administer:jenkins"
#      - "Overall/Read:jenkins"
#      - "Credentials/View:jenkins"
#      - "Credentials/Update:jenkins"
#      - "Credentials/ManageDomains:jenkins"
#      - "Credentials/Delete:jenkins"
#      - "Credentials/Create:jenkins"
#      - "Agent/Disconnect:jenkins"
#      - "Agent/Delete:jenkins"
#      - "Agent/Create:jenkins"
#      - "Agent/Connect:jenkins"
#      - "Agent/Configure:jenkins"
#      - "Agent/Build:jenkins"
#      - "Job/Workspace:jenkins"
#      - "Job/Read:jenkins"
#      - "Job/Move:jenkins"
#      - "Job/Discover:jenkins"
#      - "Job/Delete:jenkins"
#      - "Job/Create:jenkins"
#      - "Job/Configure:jenkins"
#      - "Job/Cancel:jenkins"
#      - "Job/Build:jenkins"
#      - "Run/Update:jenkins"
#      - "Run/Replay:jenkins"
#      - "Run/Delete:jenkins"
#      - "View/Read:jenkins"
#      - "View/Delete:jenkins"
#      - "View/Create:jenkins"
#      - "View/Configure:jenkins"
#      - "SCM/Tag:jenkins"
#      - "Artifactory/Release:jenkins"
#      - "Artifactory/PushToBintray:jenkins"
#      - "Artifactory/Promote:jenkins"

credentials:
  system:
    domainCredentials:
    - credentials:
      - gitLabApiTokenImpl:
          scope: SYSTEM
          id: "Gitlab-API-Token"
          apiToken: "GitLabTokenAPI"
          description: "Gitlab Token for Jenkins"
      - string:
          scope: SYSTEM
          id: "Artifactory-API-Token"
          secret: "ArtifactoryTokenAPI"
          description: "Artifactory Token for Jenkins"
      - string:
          scope: SYSTEM
          id: "Slack-API-Token"
          secret: "SlackTokenAPI"
          description: "Slack Token for Jenkins"

tool:
  git:
    installations:
    - name: git
      home: /usr/local/bin/git

  jdk:
    defaultProperties:
    - installSource:
        installers:
        - jdkInstaller:
            acceptLicense: false

security:
  remotingCLI:
    enabled: true

unclassified:

  gitlabconnectionconfig:
    connections:
    - apiTokenId: "Gitlab-API-Token"
      clientBuilderId: "autodetect"
      connectionTimeout: 10
      ignoreCertificateErrors: true
      name: "gitlab"
      readTimeout: 10
      url: "myGitLaburl"

  artifactorybuilder:
    useCredentialsPlugin: true
    artifactoryServers:
    - serverId: "artifactoryID"
      artifactoryUrl: "myArtifactoryurl"
      deployerCredentialsConfig:
        credentialsId: "Artifactory-API-Token"
      resolverCredentialsConfig:
        credentialsId: "Artifactory-API-Token"

  globalLibraries:
    libraries:
    - name: "mylib"
      defaultVersion: "gitbranch"
      includeInChangesets: false
      retriever:
        modernSCM:
          scm:
            git:
              remote: "gitrepo/path"

  location:
    adminAddress: no-reply@myorg.com
    url: "http://{{ ansible_host }}:8080/"

  mailer:
    adminAddress: no-reply@myorg.com
    replyToAddress: no-reply@myorg.com
    # Note that this does not work right now
    #smtpHost: smtp.acme.org

    smtpPort: 4441

I don't use the authorization strategy because I got a weird issue in jenkins, when we turn on this option (on UI) then you have to save the configuration once, afterward you can add NIS Unix user based into the project matrix configuration if not, jenkins can't find that user in NIS. So when I turn this setting on, seems jenkins can't find my admin/jenkins user in NIS and I will be locked out of jenkins after restart the jenkins service.

Upgrade

Using jenkins debian package so, it's quite easy to upgrade it with

sudo apt-get update
sudo apt-get install jenkins

or 

sudo apt-get install jenkins=LTS.version

Or you can update jenkins with aptitude (this solution is quite out of date)

aptitude update
aptitude install jenkins


Alright, at this time, you can have a jenkins instance much ready to operate, which we might consider this is a Jenkins As Code!

Comments