DevOps 101: Chef Cookbook with Testing
11 minute read
Introduction
Yet another chef tutorial
You can of course skip this step if you are familiar with Chef to a great enough degree, but in the spirit of making this a self-contained walkthrough, we will start from the ground up.
Getting started
Installing ChefDK
It is widely considered best practice to use ChefDK, and this walkthrough uses ChefDK exclusively.
- On Mac, install HomeBrew and run
brew cask install chefdk
- On Linux, download a package corresponding to your distro and install accordingly.
- On Windows: Run Linux as a Virtual Machine, get Docker for Windows, or try using Bash on Windows, but this walkthrough doesn’t support Windows-based development.
Initiating your cookbook
Let’s start with the following command:
chef generate cookbook c9_ide_chef
This command generates a template for a cookbook. Note that we use and underline in the name instead of a hyphen as hyphens can be problematic for chef in some circumstances. We now have a folder with the following content:
├── .gitignore
├── .kitchen.yml
├── Berksfile
├── README.md
├── chefignore
├── metadata.rb
├── recipes
│ └── default.rb
├── spec
│ ├── spec_helper.rb
│ └── unit
│ └── recipes
│ └── default_spec.rb
└── test
└── smoke
└── default
└── default_test.rb
It also creates a .delivery
and a .git
folder whose contents are omitted here for clarity.
The second command creates an attribute folder which we can populate with default attributes for our cookbook, and the third line creates a default template for a “message of the day” to be displayed when first logging in.
The .git
directory is useful and we will use it as the basis for a new repo in github repo.
Pushing to GitHub
Now that we have the skeleton for our cookbook, we should start pushing to GitHub for version-control. On GitHub, create a public repo called c9_ide. Then run the following commands:
git add .
git commit -m "First commit"
git remote add origin git@github.com:YOUR_GITHUB_USERNAME/c9_ide_chef.git
git push -u origin master
Now the stage is set and we are ready to make the cookbook actually do something.
Create the core configuration
Creating a story for the cookbook
So far we’ve seen how to set up the cookbook, but we don’t quite know what we are going to do with it. Now is a good time to set up some goals for our cookbook:
- It should update all packages before doing anything else
- It should create a message of the day that gives basic information about the server to a user logging in
- It should install basic security packages and other utilities
- It should install the packages needed to run Cloud 9 IDE
- It should pull and install Cloud 9 IDE
Updating all packages
Just for fun, we are going to make our cookbook work for both Debian and RHEL variations of Linux. Under the recipes folder, let’s create a recipe file called update.rb
and give it contents:
# Update system to start with
case node['platform']
when 'debian', 'ubuntu'
execute 'AptUpdateUpgrade' do
command "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
end
when 'redhat', 'centos', 'fedora'
# Update all packages
execute 'UpdateYum' do
command "yum -y update"
end
# Add Fedora's Extra Packages for Enterprise Linux
execute 'GetEPELRepo' do
command "rpm -iUvh http://dl.fedoraproject.org/pub/epel/7/x86_64/e/epel-release-7-9.noarch.rpm"
not_if "rpm -qa | grep -qx 'epel-release-7-9.noarch'"
end
end
in the default.rb
recipe, add the line:
include_recipe 'c9_ide::update'
That’s it for now! Before we go any further, let’s test this. Chef has automatically added some basis for testing to your repo. Let’s start with kitchen. This assumes you have VirtualBox and Vagrant installed (if you don’t, install them). At the base directory, run:
kitchen create
kitchen converge
The first command creates the virtual machines for us to work with, and teh second command cooks Chef inside of them. It is a very convenient way to do a full test of your code before pushing to github. The converge command may take quite a while as we are updating all packages on the systems and doing it for two VMs, and Ubuntu one and a CentOS one. If all goes well, the final output looks like:
Chef Client finished, 1/1 resources updated in 02 minutes 21 seconds
Creating a C9 User
It is rarely a good idea to run a program as root. As such, our next step is to create a user for running the Cloud 9 IDE. We start by creating a data bag. First, install knife-solo and knife-solo data bag:
chef gem install knife-solo
chef gem install knife-solo_data_bag
This is not entirely necessary, but that tool can be useful in other ways for making chef solo cookbooks. Then we can generate a databag and databag item:
mkdir data_bags
export EDITOR="vi"
knife solo data bag create users c9ide
In the editor that pops up, paste the following content:
{
"id": "c9ide",
"comment": "c9ide User",
"ssh_keys": [
""
],
"home": "/home/c9ide",
"ssh_keygen": false
}
Next, back to recipe/default.rb
, we can invoke the external chef recipe “users” to have this user installed on our system:
include_recipe 'user::data_bag'
To create an attributes file:
chef generate attribute default
and in attributes/default.rb
add the line:
default['users'] = 'c9ide'
This of course shows the fantastic power and utility of Chef since we can call upon a wide range of libraries already created by the chef community. Now we need to tell Chef that we are using an external library, so in our metadata.rb
file at the top level we add the line at the end:
depends 'user'
At the same level there is a Berksfile with the content:
source 'https://supermarket.chef.io'
metadata
which tells chef to check this metadata.rb
file for dependencies (such as the user dependency we just added) that we need to pull and make available during cooking. We’ll show how this is done manually a bit later, but for now kitchen converge
takes care of that.
Now run kitchen converge
one more time, and you should see that the user is created.
Let’s create a quick smoke test item, add:
describe user('c9ide') do
it { should exist }
end
to test/default/default_test.rb
and run
kitchen verify
if all went well you will see:
User c9ide
✔ should exist
Now is a good time to push to github:
git add .
git commit -m "Adds update and users"
git push origin master
Installing MOTD
First, let’s generate a blank template file:
chef generate template motd
fill with content:
<%= node['motd-attributes']['message'] %>
The hostname of this node is <%= node['hostname'] %>
The IP address of this node is <%= node['ipaddress'] %>
and create a recipe file recipes/motd.rb
with contents:
template '/etc/motd' do
source 'motd.erb'
mode '0644'
end
and finally add this line to the attributes/default.rb
file:
default['motd-attributes']['message'] = "A Cloud 9 IDE server"
We add the following tests at test/smoke/default/motd_test.rb
:
describe directory('/etc/motd') do # describe this directory
its(:content) { should match /A Cloud 9 IDE server/ }
end
We run kitchen converge
and kitchen verify
again to make sure the recipe is fine, and then push to github as before.
Installing Packages
We are getting close to installing the Cloud9 IDE. Before doing so, we need to install some packages. Some of these packages are for security purposes, or convenience in administration, while others are needed for the Cloud 9 IDE system.
Create a file recipe/packages.rb
with content:
case node['platform']
when 'debian', 'ubuntu'
req_packages = ['fail2ban',
'gcc',
'g++',
'git',
'htop',
'make',
'nodejs',
'nmap',
'npm',
'sysstat',
'unattended-upgrades',
'apt-listchanges']
req_packages.each do |packs|
Chef::Log.info("Installing: " + packs)
package packs do
action :install
end
end
template '/etc/apt/apt.conf.d/50unattended-upgrades' do
owner 'root'
group 'root'
mode '0644'
source '50unattended-upgrades.erb'
end
template '/etc/apt/apt.conf.d/20auto-upgrades' do
owner 'root'
group 'root'
mode '0644'
source '20auto-upgrades.erb'
end
when 'redhat', 'centos', 'fedora'
req_packages = ['fail2ban',
'gcc-c++',
'git',
'glibc-static',
'htop',
'make',
'nodejs',
'nmap',
'npm',
'sysstat',
'yum-cron']
req_packages.each do |packs|
Chef::Log.info("Installing: " + packs)
package packs do
action :install
end
end
template '/etc/yum/yum-cron.conf' do
owner 'root'
group 'root'
mode '0644'
source 'yum-cron.conf.erb'
end
execute 'systemctl enable yum-cron' do
end
execute 'systemctl start yum-cron' do
end
end
Notice that we have sneaked in automated security updates as well. For that, we require the following templates:
templates/50unattended-upgrades.erb
:
// Automatically upgrade packages from these (origin, archive) pairs
Unattended-Upgrade::Allowed-Origins {
<% node['unattended-upgrades']['origins'].each do |origin| %>
"<%= origin %>";
<% end %>
};
// List of packages to not update
Unattended-Upgrade::Package-Blacklist {
"apache2";
"nginx";
// "libc6-dev";
// "libc6-i686";
};
// Send email to this address for problems or packages upgrades
// If empty or unset then no email is sent, make sure that you
// have a working mail setup on your system. The package 'mailx'
// must be installed or anything that provides /usr/bin/mail.
<%= node['unattended-upgrades']['send_email'] ? "" : "// " %>Unattended-Upgrade::Mail "<%= node['unattended-upgrades']['email_address'] %>";
// Do automatic removal of new unused dependencies after the upgrade
// (equivalent to apt-get autoremove)
Unattended-Upgrade::Remove-Unused-Dependencies "<%= node['unattended-upgrades']['auto_remove'] ? "true" : "false" %>";
// Automatically reboot *WITHOUT CONFIRMATION* if a
// the file /var/run/reboot-required is found after the upgrade
Unattended-Upgrade::Automatic-Reboot "<%= node['unattended-upgrades']['auto_reboot'] ? "true" : "false" %>";
templates/20auto-upgrades.erb
:
APT::Periodic::Update-Package-Lists "<%=node['unattended-upgrades']['update_package_lists_interval']%>";
APT::Periodic::Unattended-Upgrade "<%=node['unattended-upgrades']['upgrade_interval']%>";
and
templates/yum-cron.conf.erb
:
update_cmd = security
apply_updates = yes
default['unattended-upgrades']['update_package_lists_interval'] = "1"
default['unattended-upgrades']['upgrade_interval'] = "1"
default['unattended-upgrades']['origins'] = ['${distro_id} ${distro_codename}-security']
default['unattended-upgrades']['send_email'] = false
default['unattended-upgrades']['email_address'] = "test@example.com"
default['unattended-upgrades']['auto_remove'] = false
default['unattended-upgrades']['auto_reboot'] = false
and of course, our inspec test in test/smoke/default/packages_test.rb
:
if os[:family] == 'debian'
req_packages = ['fail2ban',
'gcc',
'g++',
'git',
'htop',
'make',
'nodejs',
'nmap',
'npm',
'sysstat',
'unattended-upgrades',
'apt-listchanges']
req_packages.each do |packs|
describe package(packs) do
it { should be_installed }
end
end
else
req_packages = ['fail2ban',
'gcc-c++',
'git',
'glibc-static',
'htop',
'make',
'nodejs',
'nmap',
'npm',
'sysstat',
'yum-cron']
req_packages.each do |packs|
describe package(packs) do
it { should be_installed }
end
end
end
Installing Cloud 9 IDE
Chef has a built-in git resource that we can use to pull the Cloud 9 IDE code.
The test for this section will be rather brief:
describe directory('/home/c9ide/core/README.md') do # describe this directory
its(:content) { should match /^Cloud9/ }
end
and the reason is that we can really verify whether or not this section succeeded in the next section on running via supervisor.
The code for this section, in /recipes/c9ide.rb
, is:
git "/home/c9ide/core" do
repository 'https://github.com/c9/core.git'
reference 'master'
action :sync
end
case node['platform']
when 'debian', 'ubuntu'
bash 'create nodejs link' do
code <<-EOH
ln -s /usr/bin/nodejs /usr/bin/node
EOH
not_if { ::File.exist?('/usr/bin/node') }
end
end
bash 'install_cloud9ide' do
cwd '/home/c9ide/core/'
user 'root'
code <<-EOH
scripts/install-sdk.sh
EOH
environment 'PREFIX' => '/usr/local'
end
Installing Supervisor
Finally, we want to leave our server running Cloud 9 IDE after provisioning it. Supervisor is a fantastic way to do so, though of course not the only way and perhaps not even the best way, but it is a convenient and production-ready solution (though this development kit version of Cloud 9 IDE is not production material and is meant for development purposes).
In recipes/supervisor.rb
:
package 'supervisor' do
action :install
end
case node['platform']
when 'debian', 'ubuntu'
cookbook_file '/etc/supervisor/conf.d/cloud9ide.conf' do
source 'c9ide.conf'
owner 'root'
group 'root'
mode '0755'
action :create
end
execute 'start supervisor' do
command 'service supervisor start'
end
execute 'reload supervisor' do
command 'supervisorctl update'
end
when 'redhat', 'centos', 'fedora'
cookbook_file '/etc/supervisord.d/cloud9ide.ini' do
source 'c9ide.conf'
owner 'root'
group 'root'
mode '0755'
action :create
end
execute 'start supervisor' do
command 'service supervisord start'
end
execute 'reload supervisor' do
command 'supervisorctl update'
end
end
and finally add this to the c9ide_test.rb
test file:
describe port(9999) do
it { should be_listening }
end
Putting it all together
The default recipe should now look like this:
include_recipe 'c9_ide::update'
include_recipe 'user::data_bag'
include_recipe 'c9_ide::motd'
include_recipe 'c9_ide::packages'
include_recipe 'c9_ide::cloud9ide'
include_recipe 'c9_ide::supervisor'
we run a final kitchen converge
; if all goes well, we follow up with a final kitchen verify
:
-----> Starting Kitchen (v1.14.2)
-----> Setting up <default-ubuntu-1604>...
Finished setting up <default-ubuntu-1604> (0m0.00s).
-----> Verifying <default-ubuntu-1604>...
Loaded
Target: ssh://vagrant@127.0.0.1:2222
File /home/c9ide/core/README.md
✔ content should match /^Cloud9/
Port 9999
✔ should be listening
User c9ide
✔ should exist
File /etc/motd
✔ content should match /A Cloud 9 IDE server/
System Package
✔ fail2ban should be installed
System Package
✔ gcc should be installed
System Package
✔ g++ should be installed
System Package
✔ git should be installed
System Package
✔ htop should be installed
System Package
✔ make should be installed
System Package
✔ nodejs should be installed
System Package
✔ nmap should be installed
System Package
✔ npm should be installed
System Package
✔ sysstat should be installed
System Package
✔ unattended-upgrades should be installed
System Package
✔ apt-listchanges should be installed
Test Summary: 16 successful, 0 failures, 0 skipped
Finished verifying <default-ubuntu-1604> (0m1.28s).
-----> Setting up <default-centos-72>...
Finished setting up <default-centos-72> (0m0.00s).
-----> Verifying <default-centos-72>...
Loaded
Target: ssh://vagrant@127.0.0.1:2200
File /home/c9ide/core/README.md
✔ content should match /^Cloud9/
Port 9999
✔ should be listening
User c9ide
✔ should exist
File /etc/motd
✔ content should match /A Cloud 9 IDE server/
System Package
✔ fail2ban should be installed
System Package
✔ gcc-c++ should be installed
System Package
✔ git should be installed
System Package
✔ glibc-static should be installed
System Package
✔ htop should be installed
System Package
✔ make should be installed
System Package
✔ nodejs should be installed
System Package
✔ nmap should be installed
System Package
✔ npm should be installed
System Package
✔ sysstat should be installed
System Package
✔ yum-cron should be installed
Test Summary: 15 successful, 0 failures, 0 skipped
Finished verifying <default-centos-72> (0m13.80s).
-----> Kitchen is finished. (0m16.47s)
Congratulations, you have a basic working chef recipe for installing Cloud 9 IDE, tested and provisioned on both major families of Linux.
Source Code
You can find the source code for this recipe on my github account here.
Future Steps
We took some shortcuts and avoided some best practices for convenience here. Ideally we would like some unit tests and to pull in more community recipes. For example, the supervisor chef cookbook could be used here instead of dealing with that in a platform case.
In the forthcoming Part II, we will use Puppet, a widely used alternative to Chef, to provision a server that runs the Cloud 9 IDE SDK as we have here with Chef.