Terraform Chef Provisioner

This article is a proof of concept to explore using the Terraform Chef Provisioner and Chef Vault to deploy a Django App on AWS.

It should be noted that these technologies have significant caveats.

Regarding Terraform Chef Provisioner: “Provisioners should only be used as a last resort. For most common situations there are better alternatives.” (from https://www.terraform.io/docs/provisioners/chef.html)

The Terraform Chef Provisioner does not support auto-scaling since it’s provisioning individual nodes.

Regarding Chef Vault: “However granting or revoking new servers requires human interaction. This means chef-vault is incompatible with auto-scaling or self-healing systems.” (from https://coderanger.net/chef-secrets/ )

Create 3 servers:

Jenkins
Chef Infra Server
Chef Workstation

Jenkins

Launch a small EC2 instance. Tag the machine Name=Jenkins

Create a security group called “jenkins”. Allow 8080, HTTPS, HTTP, SSH. Assign this to the server.

Once it’s up, ssh in.

# Set a servername
hostnamectl set-hostname jenkins

# Install java
apt install openjdk-8-jre-headless

# Install Jenkins
wget -q -O – https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add –
sudo sh -c ‘echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list’
sudo apt-get update
sudo apt-get install -y jenkins

bug fix, if necessary:
inside /var/lib/jenkins/secrets/initialAdminPassword, add passwd: . For example,
passwd:qwjewlkjdflwlf

Create a DNS entry for jenkins.example.com

Connect to the server at jenkins.example.com:8080. Complete installation.

Set up an Nginx Front-end Proxy for Jenkins

apt install nginx ssl-cert

in nginx/sites-available/default , add the following sections

server {
listen 80 default_server;
listen [::]:80 default_server;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
include snippets/snakeoil.conf;
location / {
include /etc/nginx/proxy_params;
proxy_pass http://localhost:8080;
proxy_read_timeout 90s;
}
}

Set the URL inside of Jenkins->Manage Jenkins->Configure System to be https://_url_ , replacing _url_ with the IP address or hostname.

Chef Server

Launch a small EC2 instance. Tag the machine Name=Chef-Server

Create a security group called “chef-server”. Allow HTTPS, SSH. Assign this to the server.

Once it’s up, ssh in.

# Set a servername
hostnamectl set-hostname chefserver

Follow the instructions at https://docs.chef.io/install_server.html to install.

A review of the steps:

Download Chef Server locally. Then copy the package to the chef server.

scp -i key.pem chef-server-core_13.0.17-1_amd64.deb ubuntu@_server_ip_:/tmp/
mv /tmp/chef-server-core_13.0.17-1_amd64.deb /opt/downloads/
cd /opt/downloads
dpkg -i chef-server-core_13.0.17-1_amd64.deb

Sidebar: Should the Chef infrastructure be based on public or private IP’s? Your AWS network might have private instances in a private subnet. Therefore, use private IP’s…

sudo chef-server-ctl reconfigure

#sudo chef-server-ctl user-create USER_NAME FIRST_NAME LAST_NAME EMAIL 'PASSWORD' --filename FILE_NAME

mkdir /root/.chef
sudo chef-server-ctl user-create sdarwin sam darwin [email protected] 'password' --filename /root/.chef/sdarwin.pem

#sudo chef-server-ctl org-create short_name 'full_organization_name' --association_user user_name --filename ORGANIZATION-validator.pem
sudo chef-server-ctl org-create testorg 'Test Corporation' --association_user sdarwin --filename /root/.chef/testorg-validator.pem

Chef Workstation

Launch a small EC2 instance. Tag the machine Chef-Workstation

Create a security group called “chef-workstation”. Allow SSH. Assign this to the server.

Once it’s up, ssh in.

# Set a servername
hostnamectl set-hostname chefworkstation

add the chefserver IP address to /etc/hosts

172.31.6.99 chefserver
# Install Chef Workstation
mkdir -p /opt/downloads
cd /opt/downloads
wget https://packages.chef.io/files/stable/chef-workstation/0.8.7/ubuntu/18.04/chef-workstation_0.8.7-1_amd64.deb
dpkg -i chef-workstation_0.8.7-1_amd64.deb
#Please enter the chef server URL: [https://chefworkstation/organizations/myorg]
https://chefserver/organizations/testorg
# copy over sdarwin.pem and testorg-validator.pem to /root/.chef on the workstation
mkdir /root/.chef
cd /root/.chef
knife ssl fetch
knife node list

Django

In order to have a Django website, create a new one from scratch using the steps which are already thoroughly documented here:
https://docs.djangoproject.com/en/2.2/intro/install/
https://docs.djangoproject.com/en/2.2/intro/tutorial01/
https://docs.djangoproject.com/en/2.2/intro/tutorial02/
https://docs.djangoproject.com/en/2.2/intro/tutorial03/
https://docs.djangoproject.com/en/2.2/intro/tutorial04/
https://docs.djangoproject.com/en/2.2/intro/tutorial05/
https://docs.djangoproject.com/en/2.2/intro/tutorial06/
https://docs.djangoproject.com/en/2.2/intro/tutorial07/

The final results of that are available here:
https://github.com/sdarwin/django-website

Database

Create a MySQL database in RDS:
https://console.aws.amazon.com/rds/home?region=us-east-1
Set the same username/password/database which will be configured on the Chef side.
The resulting endpoint such as djangodatabase.c4sb5x9i4nxh.us-east-1.rds.amazonaws.com will be the “host”.
Assign an aws security group for the DB permitting 3306 to the relevant servers.

Customizations

Database credentials and secrets will be stored in Chef Vault. How are those value propagated all the way from Vault to Django? As follows:

In Django, the database credentials are stored in mysite/settings.py.

Change them to read in environment variables instead of literal values.

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DATABASE_NAME'),
'USER': os.environ.get('DATABASE_USER'),
'PASSWORD': os.environ.get('DATABASE_PASSWORD'),
'HOST': os.environ.get('DATABASE_HOST'),
'PORT': '3306'
}
}

How do you get environment variables into django?
In this case, python-dotenv was used. There are other choices such as django-dotenv and django-environ.

– Add python-dotenv to requirements.txt
– Add this code to settings.py and wsgi.py

import os
from dotenv import load_dotenv
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(dotenv_path)

Create a template in the chef cookbook (to be discussed next) which will create the /home/django/repo/mysite/.env file.

DATABASE_USER=<%= @database_user %>
DATABASE_PASSWORD=<%= @database_password %>
DATABASE_NAME=<%= @database_name %>
DATABASE_HOST=<%= @database_host %>
SECRET_KEY=<%= @secret_key %>

Chef will read these values from Chef Vault.

Chef

Bootstrap a target node for testing:
Launch an EC2 instance. Get it’s IP address.
Add chefserver to the target’s /etc/hosts file.

knife bootstrap 172.31.15.79 -N “django” -i /root/.ssh/id_rsa -U ubuntu –sudo

Create a chef repo and cookbook.

chef generate repo chef-repo
cd chef-repo
cd cookbooks
chef generate cookbook django-website
cd django-website

The cookbook code is available here: https://github.com/sdarwin/django-website-cookbook

It’s a wrapper cookbook around django_platform : https://github.com/ualaska-it/django_platform

If you read the wrapper cookbook, you will see that it only installs a few things beyond what ualaska-it/django_platform does. Namely, the mysql client libraries, and the .env template mentioned earlier.

Many attributes are set in the wrapper cookbook’s attributes/default.rb as required by django_platform.

The cookbook utilizes a Policyfile workflow as an alternative to Berkshelf. See Policyfile.rb in the cookbook. “production” is the policy group.

chef install
chef push production

chef update
chef push production

#knife node policy set NODE POLICY_GROUP POLICY_NAME (options)
knife node policy set django production django-website
ssh django
sudo chef-client

“Cookbooks that you upload with policyfile commands via chef are stored in a separate API, so you won’t see them with knife cookbook list.”

Make sure metadata.rb in the tutorial cookbook has
depends “django_platform”

Sidebar: The fqdn of a host can be set in it’s /etc/hosts file: 1.2.3.4 django.example.com django

Chef Vault

export EDITOR=vi
knife vault create vaultbag gitkey -M client
knife vault edit vaultbag gitkey -M client
{ "thekey" : "____" }

to get the key without newlines, run this locally:

sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g' originalkey.pem > newkey.txt

or

sed -i ':a;N;$!ba;s/\n/\\n/g' newkey.txt

Or from http://jtimberman.housepub.org/blog/2013/09/10/managing-secrets-with-chef-vault/

ruby -rjson -e 'puts JSON.generate({"thekey" => File.read("originalkey.pem")})' > output.json

Next,

knife vault create vaultbag auth -M client
knife vault edit vaultbag auth -M client

The contents should be something like this:

{
"database_user": "deploy",
"database_password": "test1234",
"database_name": "tutorial",
"database_host": "tutorialserver.c4sb5x9i4nxh.us-east-1.rds.amazonaws.com",
"secret_key": "f(2ppgp&bcr&92d1gy47jpgw*s65gh!uj()-%m9&&$ot#2v_e3"
}
#Add the node as a client for the vault bag:
knife vault update vaultbag auth -C "django.example.com" -M client
knife vault update vaultbag gitkey -C "django.example.com" -M client

#Or after setting the node's environment:
knife vault edit vaultbag auth -S "chef_environment:production" -M client
knife vault edit vaultbag gitkey -S "chef_environment:production" -M client

django_platform is using python virtual environments on the chef client machine:

virtualenv env
source env/bin/activate

Terraform

Download and install terraform from their website
https://releases.hashicorp.com/terraform/0.12.10/terraform_0.12.10_linux_amd64.zip

The terraform code is available at https://github.com/sdarwin/django-terraform

add to .bashrc:

export AWS_ACCESS_KEY_ID="_id_"
export AWS_SECRET_ACCESS_KEY="_key_"
export AWS_DEFAULT_REGION="us-east-1"

To review what the terraform code does:

– Create two django web server instances.
– Set chefserver in the /etc/hosts file
– Run the chef provisioner which installs and runs chef-client the first time
– Create a null resource which can be run for all subsequent iterations. Use “terraform taint” to cause this to be re-run.
– Add ALB resources so the web servers will be behind a load balancer

Chef provisioner has settings to access chef vault. It will add each node as a vault client.

# To re-run chef-client
terraform apply # it works
terraform apply # a second time. No action.
terraform state list
terraform taint null_resource.ProvisionRemoteHosts[0]
terraform taint null_resource.ProvisionRemoteHosts[1]
terraform apply # it works

Jenkins

Install terraform on the Jenkins machine:

cd /usr/local/bin
wget https://releases.hashicorp.com/terraform/0.12.10/terraform_0.12.10_linux_amd64.zip
apt install unzip
unzip terraform_0.12.10_linux_amd64.zip

mkdir -p /opt/github
cd /opt/github
git clone https://github.com/sdarwin/django-terraform

Create the file /var/lib/jenkins/load_env.sh

Add contents:

export AWS_ACCESS_KEY_ID="_id_"
export AWS_SECRET_ACCESS_KEY="_key_"
export AWS_DEFAULT_REGION="us-east-1"
export SECRET_KEY="123"

#for local testing only. Not the official rds instance.
export DATABASE_NAME="xyz"
export DATABASE_USER="jenkins"
export DATABASE_PASSWORD="test123"
export DATABASE_HOST="localhost"

Terraform has been configured to use environment variables which will be read from load_env.sh during a Jenkins job as:

. ~/load_env.sh

copy over chef cert to Jenkins machine.

#for root
mkdir /root/.chef/
vi /root/.chef/sdarwin.pem
chmod 700 /root/.chef/sdarwin.pem

#and for jenkins
cp -rp .chef/ /var/lib/jenkins
chown -R jenkins:jenkins /var/lib/jenkins

copy over ssh cert to Jenkins machine.

vi /root/.ssh/id_rsa
chmod 600 /root/.ssh/id_rsa

Install SSH Agent plugin in Jenkins.

In Jenkins, create a job. “Django”

Choose the second option which is Pipeline script.
– Pipeline script from SCM
– git
[email protected]:sdarwin/django-terraform.git

Add a credential – an ssh key to access the github repo.

Testing

Let’s compose a script which Jenkins will run in the test stage.

pre-requisites:
as root:

apt install python3-pip
pip3 install virtualenv
apt install mysql-client libmysqlclient-dev
pip3 install mysqlclient #not needed at this step, it's in requirements.txt

mysql:

CREATE USER 'jenkins'@'%' IDENTIFIED BY 'test123';
GRANT ALL PRIVILEGES ON *.* TO 'jenkins'@'%';

as jenkins:

virtualenv env

The test script:

set -e
. ~/env/bin/activate
. ~/load_env.sh
git clone [email protected]:sdarwin/django-website.git || true
cd django-website
git fetch --all
git reset --hard origin/master
pip3 install -r requirements.txt
python manage.py test polls
cd ..

That script is added as the Test stage in the Jenkinsfile.
It can be simplified by removing a few of those git steps because Jenkins automatically fetches the latest version during each checkout.

Structuring

For the Jenkins job, set the Build Trigger to “GitHub hook trigger for GITScm polling”

In github.com, create a webhook for the terraform repo:
https://jenkins.logchart.com/github-webhook/

This will trigger based on the terraform repository. Generally, a Jenkins job is triggered from only one repository.

However, this project has three repositories, not one:
– Django
– Terraform
– Chef

We’d like to trigger a Jenkins build when any of those changes.

One possibility is to encode all the Jenkins functionality into a single Jenkinsfile in the Terraform codebase. Create “stub” jobs for the other repos. Small jenkins jobs which do very little except trigger the main build to occur, since one Jenkins job can trigger another Jenkins job.

Another possibility is to merge the Django code and the Terraform code into one single git repo. Have a subdirectory of the django repo called “terraform/”. A Jenkinsfile in the root of this single codebase will handle all the logic.

A third possibility, and the one explored further here, is to keep Django and Terraform separate. This is appealing for logistical reasons, to keep the infrastructure and the web code separated. Have the Jenkinsfile in the Django codebase run the Tests. After that succeeds, it will trigger the Terraform job. Observe the Jenkinsfiles for both Django and Terraform, with this setup in place:

https://github.com/sdarwin/django-website/blob/master/Jenkinsfile
https://github.com/sdarwin/django-terraform/blob/master/Jenkinsfile

Environments

Create pipeline Jenkins jobs for:
django-production
django-staging
terraform-production
terraform-staging

Each will be based on a Jenkinsfile, and point to the corresponding github project and github branch.
The jobs should be parameterized. Add a string parameter ENVIRONMENT, and set the default value as “staging” or “production”

Manually add webhooks in github, for example to https://jenkins.example.com/github-webhook/

What code changes were required to have two environments, both staging and production?

– Of course, create the two branches in each repository for staging and production. Their contents should actually be identical though, to allow merges.

– Create two backend databases in RDS.

– Create two vault data bags in chef:
vaultbag-production
vaultbag-staging

Each will have different database credentials. It also turns out that with Terraform’s implementation of Chef Vault, there might be a conflict if you don’t have separate data bags for prod and staging.

– Parameterize the Jenkinsfiles. Notice how they refer to $ENVIRONMENT now. And send this parameter from the front-end in Jenkins.

– In the Terraform code, parameterize items that will be different between environments. For example, there will be two load balancers. They are now called “django-${var.environment}” to distinguish between them. Add a “variable” called “environment”.

– In the Chef cookbook, a convenient way to handle multiple environments is to keep the variables in the attributes file of the cookbook, and not in the Policy, Role, or Environment. Here is how that’s done, in attributes/default.rb

case node.policy_group
  when "production"
    default['http_platform']['www']['additional_aliases'] = {'django.logchart.com' => { 'log_level' => 'info' }}
    default['django_platform']['git_ssh_key']['vault_data_bag'] = 'vaultbag-production'
    default['django_platform']['app_repo']['git_revision'] = 'master'
  when "staging"
    default['http_platform']['www']['additional_aliases'] = {'staging.logchart.com' => { 'log_level' => 'info' }}
    default['django_platform']['git_ssh_key']['vault_data_bag'] = 'vaultbag-staging'
    default['django_platform']['app_repo']['git_revision'] = 'staging'
  else
    default['http_platform']['www']['additional_aliases'] = {'dev.logchart.com' => { 'log_level' => 'info' }}
end

Conclusion

That’s a good start to deploying Django with the Terraform Chef Provisioner. I hope this documentation was helpful, let me know if you have any feedback or questions.

The example code is available at:
https://github.com/sdarwin/django-website
https://github.com/sdarwin/django-website-cookbook
https://github.com/sdarwin/django-terraform

Leave a Reply

Your email address will not be published. Required fields are marked *