If you landed here directly and want to know how to setup Jenkins master-slave architecture, please visit this post related to Setting-up the Jenkins Master-Slave Architecture.
The source code that we are using here is also a continuation of the code that was written in this GitHub Packer-Terraform-Jenkins repository.
Creating Jenkinsfile
We will create some Jenkinsfile to execute a job from our Jenkins master.
Here I will create two Jenkinsfile ideally, it is expected that your Jenkinsfile is present in source code repo but it can be passed directly in the job as well.
There are 2 ways of writing Jenkinsfile – Scripted and Declarative. You can find numerous points online giving their difference. We will be creating both of them to do a build so that we can get a hang of both of them.
Jenkinsfile for Angular App (Scripted)
As mentioned before we will be highlighting both formats of writing the Jenkinsfile. For the Angular app, we will be writing a scripted one but can be easily written in declarative format too.
We will be running this inside a docker container. Thus, the tests are also going to get executed in a headless manner.
Here is the Jenkinsfile for reference.
Here we are trying to leverage Docker volume to keep updating our source code on bare metal and use docker container for the environments.
Dissecting Node App’s Jenkinsfile
We are using CleanWs() to clear the workspace.
Next is the Main build in which we define our complete build process.
We are pulling the required images.
Highlighting the steps that we will be executing.
Checkout SCM: Checking out our code from Git
We are now starting the node container inside of which we will be running npm install and npm run lint.
Get test dependency: Here we are downloading chrome.json which will be used in the next step when starting the container.
Here we test our app. Specific changes for running the test are mentioned below.
Build: Finally we build the app.
Deploy: Once CI is completed we need to start with CD. The CD itself can be a blog of itself but wanted to highlight what basic deployment would do.
Here we are using Nginx container to host our application.
If the container does not exist it will create a container and use the “dist” folder for deployment.
If Nginx container exists, then it will ask for user input to recreate a container or not.
If you select not to create, don’t worry as we are using Nginx it will do a hot reload with new changes.
The angular application used here was created using the standard generate command given by the CLI itself. Although the build and install give no trouble in a bare metal some tweaks are required for running test in a container.
In karma.conf.js update browsers withChromeHeadless.
Next in protractor.conf.js update browserName with chrome and add
That’s it! And We have our CI pipeline setup for Angular based application.
Jenkinsfile for .Net App (Declarative)
For a .Net application, we have to setup MSBuild and MSDeploy. In the blog post mentioned above, we have already setup MSBuild and we will shortly discuss how to setup MSDeploy.
To do the Windows deployment we have two options. Either setup MSBuild in Jenkins Global Tool Configuration or use the full path of MSBuild on the slave machine.
Passing the path is fairly simple and here we will discuss how to use global tool configuration in a Jenkinsfile.
First, get the path of MSBuild from your server. If it is not the latest version then the path is different and is available in Current directory otherwise always in <version> directory.</version>
As we are using MSBuild 2017. Our MSBuild path is:
Now you have your configuration ready to be used in Jenkinsfile.
Jenkinsfile to build and test the app is given below.
As seen above the structure of Declarative syntax is almost same as that of Declarative. Depending upon which one you find easier to read you should opt the syntax.
Dissecting Dotnet App’s Jenkinsfile
In this case too we are cleaning the workspace as the first step.
Checkout: This is also the same as before.
Nuget Restore: We are downloading dependent required packages for both PrimeService and PrimeService.Tests
Build: Building the Dotnet app using MSBuild tool which we had configured earlier before writing the Jenkinsfile.
UnitTest: Here we have used dotnet test although we could’ve used MSTest as well here just wanted to highlight how easy dotnet utility makes it. We can even use dotnet build for the build as well.
Deploy: Deploying on the IIS server. Creation of IIS we are covering below.
From the above-given examples, you get a hang of what Jenkinsfile looks like and how it can be used for creating jobs. Above file highlights basic job creation but it can be extended to everything that old-style job creation could do.
Creating IIS Server
Unlike our Angular application where we just had to get another image and we were good to go. Here we will have to Packer to create our IIS server. We will be automating the creation process and will be using it to host applications.
Here is a Powershell script for IIS for reference.
We won’t be deploying any application on it as we have created a sample app for PrimeNumber. But in the real world, you might be deploying Web Based application and you will need IIS. We have covered here the basic idea of how to install IIS along with any dependency that might be required.
Conclusion
In this post, we have covered deploying Windows and Linux based applications using Jenkinsfile in both scripted and declarative format.
Automation is everywhere and it is better to adopt it as soon as possible. Today, in this blog post, we are going to discuss creating the infrastructure. For this, we will be using AWS for hosting our deployment pipeline. Packer will be used to create AMI’s and Terraform will be used for creating the master/slaves. We will be discussing different ways of connecting the slaves and will also run a sample application with the pipeline.
Please remember the intent of the blog is to accumulate all the different components together, this means some of the code which should be available in development code repo is also included here. Now that we have highlighted the required tools, 10000 ft view and intent of the blog. Let’s begin.
Using Packer to Create AMI’s for Jenkins Master and Linux Slave
Hashicorp has bestowed with some of the most amazing tools for simplifying our life. Packer is one of them. Packer can be used to create custom AMI from already available AMI’s. We just need to create a JSON file and pass installation script as part of creation and it will take care of developing the AMI for us. Install packer depending upon your requirement from Packer downloads page. For simplicity purpose, we will be using Linux machine for creating Jenkins Master and Linux Slave. JSON file for both of them will be same but can be separated if needed.
Note: user-data passed from terraform will be different which will eventually differentiate their usage.
We are using Amazon Linux 2 – JSON file for the same.
As you can see the file is pretty simple. The only thing of interest here is the install_amazon.bash script. In this blog post, we will deploy a Node-based application which is running inside a docker container. Content of the bash file is as follows:
#!/bin/bashset -x# For Nodecurl -sL https://rpm.nodesource.com/setup_10.x | sudo -E bash -# For xmlstarletsudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpmsudo yum update -ysleep 10# Setting up Dockersudo yum install -y dockersudo usermod -a -G docker ec2-user# Just to be safe removing previously available java if presentsudo yum remove -y javasudo yum install -y python2-pip jq unzip vim tree biosdevname nc mariadb bind-utils at screen tmux xmlstarlet git java-1.8.0-openjdk nc gcc-c++ make nodejssudo -H pip install awscli bcryptsudo -H pip install --upgrade awsclisudo -H pip install --upgrade aws-ec2-assign-elastic-ipsudo npm install -g @angular/clisudo systemctl enable dockersudo systemctl enable atdsudo yum clean allsudo rm -rf /var/cache/yum/exit 0@velotiotech
Now there are a lot of things mentioned let’s check them out. As mentioned earlier we will be discussing different ways of connecting to a slave and for one of them, we need xmlstarlet. Rest of the things are packages that we might need in one way or the other.
Update ami_users with actual user value. This can be found on AWS console Under Support and inside of it Support Center.
Validate what we have written is right or not by running packer validate amazon.json.
Once confirmed, build the packer image by running packer build amazon.json.
After completion check your AWS console and you will find a new AMI created in “My AMI’s”.
It’s now time to start using terraform for creating the machines.
Prerequisite:
1. Please make sure you create a provider.tf file.
provider "aws" { region ="us-east-1" shared_credentials_file ="~/.aws/credentials" profile ="dev"}
The ‘credentials file’ will contain aws_access_key_id and aws_secret_access_key.
2. Keep SSH keys handy for server/slave machines. Here is a nice article highlighting how to create it or else create them before hand on aws console and reference it in the code.
3. VPC:
# lookup for the "default"VPCdata "aws_vpc""default_vpc" {default=true}# subnet list in the "default"VPC# The "default"VPC has all "public subnets"data "aws_subnet_ids""default_public" { vpc_id ="${data.aws_vpc.default_vpc.id}"}
Creating Terraform Script for Spinning up Jenkins Master
Creating Terraform Script for Spinning up Jenkins Master. Get terraform from terraform download page.
We will need to set up the Security Group before setting up the instance.
# Security Group:resource "aws_security_group""jenkins_server" { name ="jenkins_server" description ="Jenkins Server: created by Terraform for [dev]" # legacy name ofVPCID vpc_id ="${data.aws_vpc.default_vpc.id}" tags { Name ="jenkins_server" env ="dev" }}################################################################################ ALLINBOUND################################################################################ sshresource "aws_security_group_rule""jenkins_server_from_source_ingress_ssh" { type ="ingress" from_port =22 to_port =22 protocol ="tcp" security_group_id ="${aws_security_group.jenkins_server.id}" cidr_blocks = ["<Your Public IP>/32", "172.0.0.0/8"] description ="ssh to jenkins_server"}# webresource "aws_security_group_rule""jenkins_server_from_source_ingress_webui" { type ="ingress" from_port =8080 to_port =8080 protocol ="tcp" security_group_id ="${aws_security_group.jenkins_server.id}" cidr_blocks = ["0.0.0.0/0"] description ="jenkins server web"}# JNLPresource "aws_security_group_rule""jenkins_server_from_source_ingress_jnlp" { type ="ingress" from_port =33453 to_port =33453 protocol ="tcp" security_group_id ="${aws_security_group.jenkins_server.id}" cidr_blocks = ["172.31.0.0/16"] description ="jenkins server JNLP Connection"}################################################################################ ALLOUTBOUND###############################################################################resource "aws_security_group_rule""jenkins_server_to_other_machines_ssh" { type ="egress" from_port =22 to_port =22 protocol ="tcp" security_group_id ="${aws_security_group.jenkins_server.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins servers to ssh to other machines"}resource "aws_security_group_rule""jenkins_server_outbound_all_80" { type ="egress" from_port =80 to_port =80 protocol ="tcp" security_group_id ="${aws_security_group.jenkins_server.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins servers for outbound yum"}resource "aws_security_group_rule""jenkins_server_outbound_all_443" { type ="egress" from_port =443 to_port =443 protocol ="tcp" security_group_id ="${aws_security_group.jenkins_server.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins servers for outbound yum"}
Now that we have a custom AMI and security groups for ourselves let’s use them to create a terraform instance.
# AMI lookup for this Jenkins Serverdata "aws_ami""jenkins_server" { most_recent =true owners = ["self"] filter { name ="name" values = ["amazon-linux-for-jenkins*"] }}resource "aws_key_pair""jenkins_server" { key_name ="jenkins_server" public_key ="${file("jenkins_server.pub")}"}# lookup the security group of the Jenkins Serverdata "aws_security_group""jenkins_server" { filter { name ="group-name" values = ["jenkins_server"] }}# userdata for the Jenkins server ...data "template_file""jenkins_server" { template ="${file("scripts/jenkins_server.sh")}" vars { env ="dev" jenkins_admin_password ="mysupersecretpassword" }}# the Jenkins server itselfresource "aws_instance""jenkins_server" { ami ="${data.aws_ami.jenkins_server.image_id}" instance_type ="t3.medium" key_name ="${aws_key_pair.jenkins_server.key_name}" subnet_id ="${data.aws_subnet_ids.default_public.ids[0]}" vpc_security_group_ids = ["${data.aws_security_group.jenkins_server.id}"] iam_instance_profile ="dev_jenkins_server" user_data ="${data.template_file.jenkins_server.rendered}" tags {"Name"="jenkins_server" } root_block_device { delete_on_termination =true }}output "jenkins_server_ami_name" { value ="${data.aws_ami.jenkins_server.name}"}output "jenkins_server_ami_id" { value ="${data.aws_ami.jenkins_server.id}"}output "jenkins_server_public_ip" { value ="${aws_instance.jenkins_server.public_ip}"}output "jenkins_server_private_ip" { value ="${aws_instance.jenkins_server.private_ip}"}
As mentioned before, we will be discussing multiple ways in which we can connect the slaves to Jenkins master. But it is already known that every time a new Jenkins comes up, it generates a unique password. Now there are two ways to deal with this, one is to wait for Jenkins to spin up and retrieve that password or just directly edit the admin password while creating Jenkins master. Here we will be discussing how to change the password when configuring Jenkins. (If you need the script to retrieve Jenkins password as soon as it gets created than comment and I will share that with you as well).
Below is the user data to install Jenkins master, configure its password and install required packages.
#!/bin/bashset -xfunctionwait_for_jenkins(){while (( 1 )); do echo "waiting for Jenkins to launch on port [8080] ..." nc -zv 127.0.0.18080if (( $?==0 )); then break fi sleep 10 done echo "Jenkins launched"}functionupdating_jenkins_master_password (){ cat >/tmp/jenkinsHash.py <<EOFimport bcryptimport sysif not sys.argv[1]: sys.exit(10)plaintext_pwd=sys.argv[1]encrypted_pwd=bcrypt.hashpw(sys.argv[1], bcrypt.gensalt(rounds=10, prefix=b"2a"))isCorrect=bcrypt.checkpw(plaintext_pwd, encrypted_pwd)if not isCorrect: sys.exit(20);print "{}".format(encrypted_pwd)EOF chmod +x /tmp/jenkinsHash.py # Wait till /var/lib/jenkins/users/admin* folder gets created sleep 10 cd /var/lib/jenkins/users/admin* pwd while (( 1 )); do echo "Waiting for Jenkins to generate admin user's config file ..." if [[ -f "./config.xml" ]]; then break fi sleep 10 done echo "Admin config file created" admin_password=$(python /tmp/jenkinsHash.py ${jenkins_admin_password} 2>&1) # Please do not remove alter quote as it keeps the hash syntax intact or else while substitution, $<character> will be replaced by null xmlstarlet -q ed --inplace -u "/user/properties/hudson.security.HudsonPrivateSecurityRealm_-Details/passwordHash" -v '#jbcrypt:'"$admin_password" config.xml # Restart systemctl restart jenkins sleep 10}function install_packages (){ wget -O /etc/yum.repos.d/jenkins.repo http://pkg.jenkins-ci.org/redhat-stable/jenkins.repo rpm --import https://jenkins-ci.org/redhat/jenkins-ci.org.key yum install -y jenkins # firewall #firewall-cmd --permanent --new-service=jenkins #firewall-cmd --permanent --service=jenkins --set-short="Jenkins Service Ports" #firewall-cmd --permanent --service=jenkins --set-description="Jenkins Service firewalld port exceptions" #firewall-cmd --permanent --service=jenkins --add-port=8080/tcp #firewall-cmd --permanent --add-service=jenkins #firewall-cmd --zone=public --add-service=http --permanent #firewall-cmd --reload systemctl enable jenkins systemctl restart jenkins sleep 10}function configure_jenkins_server (){ # Jenkins cli echo "installing the Jenkins cli ..." cp /var/cache/jenkins/war/WEB-INF/jenkins-cli.jar /var/lib/jenkins/jenkins-cli.jar # Getting initial password # PASSWORD=$(cat /var/lib/jenkins/secrets/initialAdminPassword) PASSWORD="${jenkins_admin_password}" sleep 10 jenkins_dir="/var/lib/jenkins" plugins_dir="$jenkins_dir/plugins" cd $jenkins_dir # Open JNLP port xmlstarlet -q ed --inplace -u "/hudson/slaveAgentPort" -v 33453 config.xml cd $plugins_dir || { echo "unable to chdir to [$plugins_dir]"; exit 1; } # List of plugins that are needed to be installed plugin_list="git-client git github-api github-oauth github MSBuild ssh-slaves workflow-aggregator ws-cleanup" # remove existing plugins, if any ... rm -rfv $plugin_list for plugin in $plugin_list; do echo "installing plugin [$plugin] ..." java -jar $jenkins_dir/jenkins-cli.jar -s http://127.0.0.1:8080/ -auth admin:$PASSWORD install-plugin $plugin done # Restart jenkins after installing plugins java -jar $jenkins_dir/jenkins-cli.jar -s http://127.0.0.1:8080 -auth admin:$PASSWORD safe-restart}### script starts here ###install_packageswait_for_jenkinsupdating_jenkins_master_passwordwait_for_jenkinsconfigure_jenkins_serverecho "Done"exit 0
There is a lot of stuff that has been covered here. But the most tricky bit is changing Jenkins password. Here we are using a python script which uses brcypt to hash the plain text in Jenkins encryption format and xmlstarlet for replacing that password in the actual location. Also, we are using xmstarlet to edit the JNLP port for windows slave. Do remember initial username for Jenkins is admin.
Command to run: Initialize terraform – terraform init , Check and apply – terraform plan -> terraform apply
After successfully running apply command go to AWS console and check for a new instance coming up. Hit the <public ip=””>:8080 and enter credentials as you had passed and you will have the Jenkins master for yourself ready to be used. </public>
Note: I will be providing the terraform script and permission list of IAM roles for the user at the end of the blog.
Creating Terraform Script for Spinning up Linux Slave and connect it to master
We won’t be creating a new image here rather use the same one that we used for Jenkins master.
VPC will be same and updated Security groups for slave are below:
resource "aws_security_group""dev_jenkins_worker_linux" { name ="dev_jenkins_worker_linux" description ="Jenkins Server: created by Terraform for [dev]"# legacy name ofVPCID vpc_id ="${data.aws_vpc.default_vpc.id}" tags { Name ="dev_jenkins_worker_linux" env ="dev" }}################################################################################ ALLINBOUND################################################################################ sshresource "aws_security_group_rule""jenkins_worker_linux_from_source_ingress_ssh" { type ="ingress" from_port =22 to_port =22 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_linux.id}" cidr_blocks = ["<Your Public IP>/32"] description ="ssh to jenkins_worker_linux"}# sshresource "aws_security_group_rule""jenkins_worker_linux_from_source_ingress_webui" { type ="ingress" from_port =8080 to_port =8080 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_linux.id}" cidr_blocks = ["0.0.0.0/0"] description ="ssh to jenkins_worker_linux"}################################################################################ ALLOUTBOUND###############################################################################resource "aws_security_group_rule""jenkins_worker_linux_to_all_80" { type ="egress" from_port =80 to_port =80 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_linux.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins worker to all 80"}resource "aws_security_group_rule""jenkins_worker_linux_to_all_443" { type ="egress" from_port =443 to_port =443 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_linux.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins worker to all 443"}resource "aws_security_group_rule""jenkins_worker_linux_to_other_machines_ssh" { type ="egress" from_port =22 to_port =22 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_linux.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins worker linux to jenkins server"}resource "aws_security_group_rule""jenkins_worker_linux_to_jenkins_server_8080" { type ="egress" from_port =8080 to_port =8080 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_linux.id}" source_security_group_id ="${aws_security_group.jenkins_server.id}" description ="allow jenkins workers linux to jenkins server"}
Now that we have the required security groups in place it is time to bring into light terraform script for linux slave.
And now the final piece of code, which is user-data of slave machine.
#!/bin/bashset -xfunctionwait_for_jenkins (){ echo "Waiting jenkins to launch on 8080..."while (( 1 )); do echo "Waiting for Jenkins" nc -zv ${server_ip} 8080if (( $?==0 )); then break fi sleep 10 done echo "Jenkins launched"}functionslave_setup(){ # Wait till jar file gets available ret=1while (( $ret !=0 )); do wget -O/opt/jenkins-cli.jar http://${server_ip}:8080/jnlpJars/jenkins-cli.jar ret=$? echo "jenkins cli ret [$ret]" done ret=1while (( $ret !=0 )); do wget -O/opt/slave.jar http://${server_ip}:8080/jnlpJars/slave.jar ret=$? echo "jenkins slave ret [$ret]" done mkdir -p /opt/jenkins-slave chown -R ec2-user:ec2-user /opt/jenkins-slave # Register_slaveJENKINS_URL="http://${server_ip}:8080"USERNAME="${jenkins_username}" # PASSWORD=$(cat /tmp/secret)PASSWORD="${jenkins_password}"SLAVE_IP=$(ip -o -4 addr list ${device_name} | head -n1 | awk '{print $4}'| cut -d/-f1)NODE_NAME=$(echo "jenkins-slave-linux-$SLAVE_IP"| tr '.''-')NODE_SLAVE_HOME="/opt/jenkins-slave"EXECUTORS=2SSH_PORT=22CRED_ID="$NODE_NAME"LABELS="build linux docker"USERID="ec2-user" cd /opt # Creating CMD utility for jenkins-cli commands jenkins_cmd="java -jar /opt/jenkins-cli.jar -s $JENKINS_URL -auth $USERNAME:$PASSWORD" # Waiting for Jenkins to load all pluginswhile (( 1 )); do count=$($jenkins_cmd list-plugins 2>/dev/null| wc -l) ret=$? echo "count [$count] ret [$ret]"if (( $count >0 )); then break fi sleep 30 done # Delete Credentials if present for respective slave machines $jenkins_cmd delete-credentials system::system::jenkins _ $CRED_ID # Generating cred.xml for creating credentials on Jenkins server cat >/tmp/cred.xml <<EOF<com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKeyplugin="ssh-credentials@1.16"> <scope>GLOBAL</scope> <id>$CRED_ID</id> <description>Generated via Terraform for $SLAVE_IP</description> <username>$USERID</username> <privateKeySourceclass="com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey\$DirectEntryPrivateKeySource"> <privateKey>${worker_pem}</privateKey> </privateKeySource></com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey>EOF # Creating credential usingcred.xml cat /tmp/cred.xml | $jenkins_cmd create-credentials-by-xml system::system::jenkins _ # For Deleting Node, used when testing $jenkins_cmd delete-node $NODE_NAME # Generating node.xml for creating node on Jenkins server cat >/tmp/node.xml <<EOF<slave> <name>$NODE_NAME</name> <description>Linux Slave</description> <remoteFS>$NODE_SLAVE_HOME</remoteFS> <numExecutors>$EXECUTORS</numExecutors> <mode>NORMAL</mode> <retentionStrategyclass="hudson.slaves.RetentionStrategy\$Always"/> <launcherclass="hudson.plugins.sshslaves.SSHLauncher"plugin="ssh-slaves@1.5"> <host>$SLAVE_IP</host> <port>$SSH_PORT</port> <credentialsId>$CRED_ID</credentialsId> </launcher> <label>$LABELS</label> <nodeProperties/> <userId>$USERID</userId></slave>EOF sleep 10 # Creating node usingnode.xml cat /tmp/node.xml | $jenkins_cmd create-node $NODE_NAME}### script begins here ###wait_for_jenkinsslave_setupecho "Done"exit 0
This will not only create a node on Jenkins master but also attach it.
Command to run: Initialize terraform – terraform init, Check and apply – terraform plan -> terraform apply
One drawback of this is, if by any chance slave gets disconnected or goes down, it will remain on Jenkins master as offline, also it will not manually attach itself to Jenkins master.
Some solutions for them are:
1. Create a cron job on the slave which will run user-data after a certain interval.
2. Use swarm plugin.
3. As we are on AWS, we can even use Amazon EC2 Plugin.
Maybe in a future blog, we will cover using both of these plugins as well.
Using Packer to create AMI’s for Windows Slave
Windows AMI will also be created using packer. All the pointers for Windows will remain as it were for Linux.
Now when it comes to windows one should know that it does not behave the same way Linux does. For us to be able to communicate with this image an essential component required is WinRM. We set it up at the very beginning as part of user_data_file. Also, windows require user input for a lot of things and while automating it is not possible to provide it as it will break the flow of execution so we disable UAC and enable RDP so that we can connect to that machine from our local desktop for debugging if needed. And at last, we will execute install_windows.ps1 file which will set up our slave. Please note at the last we are calling two PowerShell scripts to generate random password every time a new machine is created. It is mandatory to have them or you will never be able to login into your machines.
There are multiple user-data in the above code, let’s understand them in their order of appearance.
SetUpWinRM.ps1:
<powershell>write-output "Running User Data Script"write-host "(host) Running User Data Script"Set-ExecutionPolicy Unrestricted -Scope LocalMachine -Force -ErrorAction Ignore# Don't set this before Set-ExecutionPolicy as it throws an error$ErrorActionPreference = "stop"# Remove HTTP listenerRemove-Item -Path WSMan:\Localhost\listener\listener* -Recurse$Cert = New-SelfSignedCertificate -CertstoreLocation Cert:\LocalMachine\My -DnsName "packer"New-Item -Path WSMan:\LocalHost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $Cert.Thumbprint -Force# WinRMwrite-output "Setting up WinRM"write-host "(host) setting up WinRM"cmd.exe /c winrm quickconfig -qcmd.exe /c winrm set "winrm/config" '@{MaxTimeoutms="1800000"}'cmd.exe /c winrm set "winrm/config/winrs" '@{MaxMemoryPerShellMB="1024"}'cmd.exe /c winrm set "winrm/config/service" '@{AllowUnencrypted="true"}'cmd.exe /c winrm set "winrm/config/client" '@{AllowUnencrypted="true"}'cmd.exe /c winrm set "winrm/config/service/auth" '@{Basic="true"}'cmd.exe /c winrm set "winrm/config/client/auth" '@{Basic="true"}'cmd.exe /c winrm set "winrm/config/service/auth" '@{CredSSP="true"}'cmd.exe /c winrm set "winrm/config/listener?Address=*+Transport=HTTPS" "@{Port=`"5986`";Hostname=`"packer`";CertificateThumbprint=`"$($Cert.Thumbprint)`"}"cmd.exe /c netsh advfirewall firewall set rule group="remote administration" new enable=yescmd.exe /c netsh firewall add portopening TCP 5986 "Port 5986"cmd.exe /c net stop winrmcmd.exe /c sc config winrm start= autocmd.exe /c net start winrm</powershell>
The content is pretty straightforward as it is just setting up WInRM. The only thing that matters here is the <powershell> and </powershell>. They are mandatory as packer will not be able to understand what is the type of script. Next, we come across disable-uac.ps1 & enable-rdp.ps1, and we have discussed their purpose before. The last user-data is the actual user-data that we need to install all the required packages in the AMI.
Chocolatey: a blessing in disguise – Installing required applications in windows by scripting is a real headache as you have to write a lot of stuff just to install a single application but luckily for us we have chocolatey. It works as a package manager for windows and helps us install applications as we are installing packages on Linux. install_windows.ps1 has installation step for chocolatey and how it can be used to install other applications on windows.
See, such a small script and you can get all the components to run your Windows application in no time (Kidding… This script actually takes around 20 minutes to run :P)
Now that we have the image for ourselves let’s start with terraform script to make this machine a slave of your Jenkins master.
Creating Terraform Script for Spinning up Windows Slave and Connect it to Master
This time also we will first create the security groups and then create the slave machine from the same AMI that we developed above.
resource "aws_security_group""dev_jenkins_worker_windows" { name ="dev_jenkins_worker_windows" description ="Jenkins Server: created by Terraform for [dev]" # legacy name ofVPCID vpc_id ="${data.aws_vpc.default_vpc.id}" tags { Name ="dev_jenkins_worker_windows" env ="dev" }}################################################################################ ALLINBOUND################################################################################ sshresource "aws_security_group_rule""jenkins_worker_windows_from_source_ingress_webui" { type ="ingress" from_port =8080 to_port =8080 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" cidr_blocks = ["0.0.0.0/0"] description ="ssh to jenkins_worker_windows"}# rdpresource "aws_security_group_rule""jenkins_worker_windows_from_rdp" { type ="ingress" from_port =3389 to_port =3389 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" cidr_blocks = ["<Your Public IP>/32"] description ="rdp to jenkins_worker_windows"}################################################################################ ALLOUTBOUND###############################################################################resource "aws_security_group_rule""jenkins_worker_windows_to_all_80" { type ="egress" from_port =80 to_port =80 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins worker to all 80"}resource "aws_security_group_rule""jenkins_worker_windows_to_all_443" { type ="egress" from_port =443 to_port =443 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins worker to all 443"}resource "aws_security_group_rule""jenkins_worker_windows_to_jenkins_server_33453" { type ="egress" from_port =33453 to_port =33453 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" cidr_blocks = ["172.31.0.0/16"] description ="allow jenkins worker windows to jenkins server"}resource "aws_security_group_rule""jenkins_worker_windows_to_jenkins_server_8080" { type ="egress" from_port =8080 to_port =8080 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" source_security_group_id ="${aws_security_group.jenkins_server.id}" description ="allow jenkins workers windows to jenkins server"}resource "aws_security_group_rule""jenkins_worker_windows_to_all_22" { type ="egress" from_port =22 to_port =22 protocol ="tcp" security_group_id ="${aws_security_group.dev_jenkins_worker_windows.id}" cidr_blocks = ["0.0.0.0/0"] description ="allow jenkins worker windows to connect outbound from 22"}
Once security groups are in place we move towards creating the terraform file for windows machine itself. Windows can’t connect to Jenkins master using SSH the method we used while connecting the Linux slave instead we have to use JNLP. A quick recap, when creating Jenkins master we used xmlstarlet to modify the JNLP port and also added rules in sg group to allow connection for JNLP. Also, we have opened the port for RDP so that if any issue occurs you can get in the machine and debug it.
Terraform file:
# Setting Up Windows Slave data "aws_ami""jenkins_worker_windows" { most_recent =true owners = ["self"] filter { name ="name" values = ["windows-slave-for-jenkins*"] }}resource "aws_key_pair""jenkins_worker_windows" { key_name ="jenkins_worker_windows" public_key ="${file("jenkins_worker.pub")}"}data "template_file""userdata_jenkins_worker_windows" { template ="${file("scripts/jenkins_worker_windows.ps1")}" vars { env ="dev" region ="us-east-1" datacenter ="dev-us-east-1" node_name ="us-east-1-jenkins_worker_windows" domain ="" device_name ="eth0" server_ip ="${aws_instance.jenkins_server.private_ip}" worker_pem ="${data.local_file.jenkins_worker_pem.content}" jenkins_username ="admin" jenkins_password ="mysupersecretpassword" }}# lookup the security group of the Jenkins Serverdata "aws_security_group""jenkins_worker_windows" { filter { name ="group-name" values = ["dev_jenkins_worker_windows"] }}resource "aws_launch_configuration""jenkins_worker_windows" { name_prefix ="dev-jenkins-worker-" image_id ="${data.aws_ami.jenkins_worker_windows.image_id}" instance_type ="t3.medium" iam_instance_profile ="dev_jenkins_worker_windows" key_name ="${aws_key_pair.jenkins_worker_windows.key_name}" security_groups = ["${data.aws_security_group.jenkins_worker_windows.id}"] user_data ="${data.template_file.userdata_jenkins_worker_windows.rendered}" associate_public_ip_address =false root_block_device { delete_on_termination =true volume_size =100 } lifecycle { create_before_destroy =true }}resource "aws_autoscaling_group""jenkins_worker_windows" { name ="dev-jenkins-worker-windows" min_size ="1" max_size ="2" desired_capacity ="2" health_check_grace_period =60 health_check_type ="EC2" vpc_zone_identifier = ["${data.aws_subnet_ids.default_public.ids}"] launch_configuration ="${aws_launch_configuration.jenkins_worker_windows.name}" termination_policies = ["OldestLaunchConfiguration"] wait_for_capacity_timeout ="10m" default_cooldown =60 #lifecycle { # create_before_destroy =true #} ## on replacement, gives new service time to spin up before moving on to destroy #provisioner "local-exec" { # command ="sleep 60" #} tags = [ { key ="Name" value ="dev_jenkins_worker_windows" propagate_at_launch =true }, { key ="class" value ="dev_jenkins_worker_windows" propagate_at_launch =true }, ]}
Finally, we reach the user-data for the terraform plan. It will download the required jar file, create a node on Jenkins and register itself as a slave.
<powershell>function Wait-For-Jenkins { Write-Host "Waiting jenkins to launch on 8080..." Do { Write-Host "Waiting for Jenkins" Nc -zv ${server_ip} 8080 If( $? -eq $true ) { Break } Sleep 10 } While (1) Do { Write-Host "Waiting for JNLP" Nc -zv ${server_ip} 33453 If( $? -eq $true ) { Break } Sleep 10 } While (1) Write-Host "Jenkins launched"}function Slave-Setup(){ # Register_slave $JENKINS_URL="http://${server_ip}:8080" $USERNAME="${jenkins_username}" $PASSWORD="${jenkins_password}" $AUTH = -join ("$USERNAME", ":", "$PASSWORD") echo $AUTH # Below IP collection logic works for Windows Server 2016 edition and needs testing for windows server 2008 edition $SLAVE_IP=(ipconfig | findstr /r "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" | findstr "IPv4 Address").substring(39) | findstr /B "172.31" $NODE_NAME="jenkins-slave-windows-$SLAVE_IP" $NODE_SLAVE_HOME="C:\Jenkins\" $EXECUTORS=2 $JNLP_PORT=33453 $CRED_ID="$NODE_NAME" $LABELS="build windows" # Creating CMD utility for jenkins-cli commands # This is not working in windows therefore specify full path $jenkins_cmd = "java -jar C:\Jenkins\jenkins-cli.jar -s $JENKINS_URL -auth admin:$PASSWORD" Sleep 20 Write-Host "Downloading jenkins-cli.jar file" (New-Object System.Net.WebClient).DownloadFile("$JENKINS_URL/jnlpJars/jenkins-cli.jar", "C:\Jenkins\jenkins-cli.jar") Write-Host "Downloading slave.jar file" (New-Object System.Net.WebClient).DownloadFile("$JENKINS_URL/jnlpJars/slave.jar", "C:\Jenkins\slave.jar") Sleep 10 # Waiting for Jenkins to load all plugins Do { $count=(java -jar C:\Jenkins\jenkins-cli.jar -s $JENKINS_URL -auth $AUTH list-plugins | Measure-Object -line).Lines $ret=$? Write-Host "count [$count] ret [$ret]"If ( $count -gt 0 ) { Break } sleep 30 } While ( 1 ) # For Deleting Node, used when testing Write-Host "Deleting Node $NODE_NAME if present" java -jar C:\Jenkins\jenkins-cli.jar -s $JENKINS_URL -auth $AUTH delete-node $NODE_NAME # Generating node.xml for creating node on Jenkins server $NodeXml = @"<slave><name>$NODE_NAME</name><description>Windows Slave</description><remoteFS>$NODE_SLAVE_HOME</remoteFS><numExecutors>$EXECUTORS</numExecutors><mode>NORMAL</mode><retentionStrategyclass="hudson.slaves.RetentionStrategy`$Always`"/><launcherclass="hudson.slaves.JNLPLauncher"> <workDirSettings> <disabled>false</disabled> <internalDir>remoting</internalDir> <failIfWorkDirIsMissing>false</failIfWorkDirIsMissing> </workDirSettings></launcher><label>$LABELS</label><nodeProperties/></slave>"@ $NodeXml | Out-File -FilePath C:\Jenkins\node.xml type C:\Jenkins\node.xml # Creating node using node.xml Write-Host "Creating $NODE_NAME" Get-Content -Path C:\Jenkins\node.xml | java -jar C:\Jenkins\jenkins-cli.jar -s $JENKINS_URL -auth $AUTH create-node $NODE_NAME Write-Host "Registering Node $NODE_NAME via JNLP" Start-Process java -ArgumentList "-jar C:\Jenkins\slave.jar -jnlpCredentials $AUTH -jnlpUrl $JENKINS_URL/computer/$NODE_NAME/slave-agent.jnlp"}### script begins here ###Wait-For-JenkinsSlave-Setupecho "Done"</powershell><persist>true</persist>
Command to run: Initialize terraform – terraform init, Check and apply – terraform plan -> terraform apply
Same drawbacks are applicable here and the same solutions will work here as well.
Congratulations! You have a Jenkins master with Windows and Linux slave attached to it.
This blog tries to highlight one of the ways in which we can use packer and Terraform to create AMI’s which will serve as Jenkins master and slave. We not only covered their creation but also focused on how to associate security groups and checked some of the basic IAM roles that can be applied. Although we have covered almost all the possible scenarios but still depending on use case, the required changes would be very less and this can serve as a boiler plate code when beginning to plan your infrastructure on cloud.