Skip to content

Latest commit

 

History

History
1681 lines (1368 loc) · 63.9 KB

ch06-mezzanine-playbook.asciidoc

File metadata and controls

1681 lines (1368 loc) · 63.9 KB

Deploying Mezzanine with Ansible

It’s time to write an Ansible playbook to deploy Mezzanine to a server. We’ll go through it step by step, but if you’re the type of person who starts off by reading the last page of a book to see how it ends,[1] you can find the full playbook at the end of this chapter as mezzanine.yml: the complete playbook. It’s also available on GitHub. Check out the README file before trying to run it directly.

I’ve tried to hew as closely as possible to the original Fabric scripts that Mezzanine author Stephen McDonald wrote.[2]

Listing Tasks in a Playbook

Before we dive into the guts of our playbook, let’s get a high-level view. The ansible-playbook command-line tool supports a flag called --list-tasks. This flag prints out the names of all the tasks in a playbook. It’s a handy way to summarize what a playbook is going to do. Here’s how you use it:

$ ansible-playbook --list-tasks mezzanine.yml

List of tasks in Mezzanine playbook shows the output for the mezzanine.yml playbook in mezzanine.yml: the complete playbook.

Example 1. List of tasks in Mezzanine playbook
playbook: mezzanine.yml

  play #1 (web): Deploy mezzanine	TAGS: []
    tasks:
      install apt packages	TAGS: []
      create project path	TAGS: []
      create a logs directory	TAGS: []
      check out the repository on the host	TAGS: []
      install Python requirements globally via pip	TAGS: []
      create project locale	TAGS: []
      create a DB user	TAGS: []
      create the database	TAGS: []
      ensure config path exists	TAGS: []
      create tls certificates	TAGS: []
      remove the default nginx config file	TAGS: []
      set the nginx config file	TAGS: []
      enable the nginx config file	TAGS: []
      set the supervisor config file	TAGS: []
      install poll twitter cron job	TAGS: []
      set the gunicorn config file	TAGS: []
      generate the settings file	TAGS: []
      install requirements.txt	TAGS: []
      install required python packages	TAGS: []
      apply migrations to create the database, collect static content	TAGS: []
      set the site id	TAGS: []
      set the admin password	TAGS: []

Organization of Deployed Files

As we discussed earlier, Mezzanine is built atop Django. In Django, a web app is called a project. We get to choose what to name our project, and I’ve chosen to name it mezzanine_example.

Our playbook deploys into a Vagrant machine, and will deploy the files into the home directory of the Vagrant user’s account.

Directory structure under /home/vagrant shows the relevant directories underneath /home/vagrant:

  • /home/vagrant/mezzanine/mezzanine-example will contain the source code that will be cloned from a source code repository on GitHub.

  • /home/vagrant/.virtualenvs/mezzanine_example is the virtualenv directory, which means that we’re going to install all of the Python packages into that directory.

  • /home/vagrant/logs will contain log files generated by Mezzanine.

Example 2. Directory structure under /home/vagrant
.
├── logs
├── mezzanine
│   └── mezzanine_example
└── .virtualenvs
    └── mezzanine_example

Variables and Secret Variables

As you can see in Defining the variables, this playbook defines quite a few variables.

Example 3. Defining the variables
  vars:
    user: "{{ ansible_user }}"
    proj_app: mezzanine_example
    proj_name: "{{ proj_app }}"
    venv_home: "{{ ansible_env.HOME }}/.virtualenvs"
    venv_path: "{{ venv_home }}/{{ proj_name }}"
    proj_path: "{{ ansible_env.HOME }}/mezzanine/{{ proj_name }}"
    settings_path: "{{ proj_path }}/{{ proj_name }}"
    reqs_path: requirements.txt
    manage: "{{ python }} {{ proj_path }}/manage.py"
    live_hostname: 192.168.33.10.xip.io
    domains:
      - 192.168.33.10.xip.io
      - www.192.168.33.10.xip.io
    repo_url: git@github.com:ansiblebook/mezzanine_example.git
    locale: en_US.UTF-8
    # Variables below don't appear in Mezzanine's fabfile.py
    # but I've added them for convenience
    conf_path: /etc/nginx/conf
    tls_enabled: True
    python: "{{ venv_path }}/bin/python"
    database_name: "{{ proj_name }}"
    database_user: "{{ proj_name }}"
    database_host: localhost
    database_port: 5432
    gunicorn_procname: gunicorn_mezzanine
    num_workers: "multiprocessing.cpu_count() * 2 + 1"
  vars_files:
    - secrets.yml

I’ve tried for the most part to use the same variable names that the Mezzanine Fabric script uses. I’ve also added some extra variables to make things a little clearer. For example, the Fabric scripts directly use proj_name as the database name and database username. I prefer to define intermediate variables named database_name and database_user and define these in terms of proj_name.

It’s worth noting a few things here. First off, we can define one variable in terms of another. For example, we define venv_path in terms of venv_home and proj_name.

Also, note how we can reference Ansible facts in these variables. For example, venv_home is defined in terms of the ansible_env fact collected from each host.

Finally, note that we have specified some of our variables in a separate file, called secrets.yml, by doing this:

  vars_files:
    - secrets.yml

This file contains credentials such as passwords and tokens that need to remain private. The repository on GitHub does not actually contain this file. Instead, it contains a file called secrets.yml.example that looks like this:

db_pass: e79c9761d0b54698a83ff3f93769e309
admin_pass: 46041386be534591ad24902bf72071B
secret_key: b495a05c396843b6b47ac944a72c92ed
nevercache_key: b5d87bb4e17c483093296fa321056bdc
# You need to create a Twitter application at https://dev.twitter.com
# in order to get the credentials required for Mezzanine's
# twitter integration.
#
# See http://mezzanine.jupo.org/docs/twitter-integration.html
# for details on Twitter integration
twitter_access_token_key: 80b557a3a8d14cb7a2b91d60398fb8ce
twitter_access_token_secret: 1974cf8419114bdd9d4ea3db7a210d90
twitter_consumer_key: 1f1c627530b34bb58701ac81ac3fad51
twitter_consumer_secret: 36515c2b60ee4ffb9d33d972a7ec350a

To use this repo, you need to copy secrets.yml.example to secrets.yml and edit it so that it contains the credentials specific to your site. Also note that secrets.yml is included in the .gitignore file in the Git repository to prevent someone from accidentally committing these credentials.

It’s best to avoid committing unencrypted credentials into your version-control repository because of the security risks involved. This is just one possible strategy for maintaining secret credentials. We also could have passed them as environment variables. Another option, which we will describe in [more_on_playbooks_a], is to commit an encrypted version of the secrets.yml file by using Ansible’s vault functionality.

Using Iteration (with_items) to Install Multiple Packages

We’re going to need two types of packages for our Mezzanine deployment. We need to install some system-level packages, and because we’re going to deploy on Ubuntu, we use apt as our package manager for the system packages. We also need to install some Python packages, and we’ll use pip to install the Python packages.

System-level packages are generally easier to deal with than Python packages, because system-level packages are designed specifically to work with the operating system. However, the system package repositories often don’t have the newest versions of the Python libraries we need, so we turn to the Python packages to install those. It’s a trade-off between stability and running the latest and greatest.

Installing system packages shows the task we’ll use to install the system packages.

Example 4. Installing system packages
- name: install apt packages
  apt: pkg={{ item }} update_cache=yes cache_valid_time=3600
  become: True
  with_items:
    - git
    - libjpeg-dev
    - libpq-dev
    - memcached
    - nginx
    - postgresql
    - python-dev
    - python-pip
    - python-psycopg2
    - python-setuptools
    - python-virtualenv
    - supervisor

Because we’re installing multiple packages, we use Ansible’s iteration functionality, the with_items clause. We could have installed the packages one at a time, like this:

- name: install git
  apt: pkg=git

- name: install libjpeg-dev
  apt: pkg=libjpeg-dev
...

However, it’s easier to write the playbook if we group the packages in a list. When we invoke the apt module, we pass it {{ item }}. This is a placeholder variable that will be populated by each of the elements in the list of the with_items clause.

Note

By default, Ansible uses item as the name of the loop iteration variable. In [more_on_playbooks_a], we’ll show how to change this variable name.

In addition, the apt module contains an optimization making it more efficient to install multiple packages by using the with_items clause. Ansible will pass the entire list of packages to the apt module, and the module will invoke the apt program only once, passing it the entire list of packages to be installed. Some modules, like apt, have been designed to handle lists intelligently like this. If a module doesn’t have native support for lists, Ansible will simply invoke the module multiple times, once for each element of the list.

You can tell that the apt module is intelligent enough to handle multiple packages at once, because the output looks like this:

TASK: [install apt packages] **************************************************
ok: [web] => (item=[u'git', u'libjpeg-dev', u'libpq-dev', u'memcached',
u'nginx', u'postgresql', u'python-dev', u'python-pip', u'python-psycopg2',
u'python-setuptools', u'python-virtualenv', u'supervisor'])

On the other hand, the pip module does not handle lists intelligently, so Ansible must invoke it once for each element of the list, and the output looks like this:

TASK [install required python packages] ****************************************
ok: [web] => (item=gunicorn)
ok: [web] => (item=setproctitle)
ok: [web] => (item=psycopg2)
ok: [web] => (item=django-compressor)
ok: [web] => (item=python-memcached)

Adding the Become Clause to a Task

In the playbook examples of [playbooks_a_beginning], we wanted the whole playbook to run as root, so we added the become: True clause to the play. When we deploy Mezzanine, most of the tasks will be run as the user who is SSHing to the host, rather than root. Therefore, we don’t want to run as root for the entire play, only for select tasks.

We can accomplish this by adding become: True to the tasks that do need to run as root, such as Installing system packages.

Updating the Apt Cache

Note

All of the example commands in this subsection are run on the (Ubuntu) remote host, not the control machine.

Ubuntu maintains a cache with the names of all of the apt packages that are available in the Ubuntu package archive. Let’s say you try to install the package named libssl-dev. We can use the apt-cache program to query the local cache to see what version it knows about:

$ apt-cache policy libssl-dev

The output is shown in apt-cache output.

Example 5. apt-cache output
libssl-dev:
  Installed: (none)
  Candidate: 1.0.1f-1ubuntu2.21
  Version table:
     1.0.1f-1ubuntu2.21 0
        500 http://archive.ubuntu.com/ubuntu/ trusty-updates/main amd64 Packages
        500 http://security.ubuntu.com/ubuntu/ trusty-security/main amd64 Packages
     1.0.1f-1ubuntu2 0
        500 http://archive.ubuntu.com/ubuntu/ trusty/main amd64 Packages

As we can see, this package is not installed locally. According to the local cache, the latest version is 1.0.1f-1ubuntu2.21. We also see some information about the location of the package archive.

In some cases, when the Ubuntu project releases a new version of a package, it removes the old version from the package archive. If the local apt cache of an Ubuntu server hasn’t been updated, then it will attempt to install a package that doesn’t exist in the package archive.

To continue with our example, let’s say we attempt to install the libssl-dev package:

$ apt-get install libssl-dev

If version 1.0.1f-1ubuntu2.21 is no longer available in the package archive, we’ll see the following error:

Err http://archive.ubuntu.com/ubuntu/ trusty-updates/main libssl-dev amd64
1.0.1f-1ubuntu2.21
  404  Not Found [IP: 91.189.88.153 80]
Err http://security.ubuntu.com/ubuntu/ trusty-security/main libssl-dev amd64
1.0.1f-1ubuntu2.21
  404  Not Found [IP: 91.189.88.149 80]
Err http://security.ubuntu.com/ubuntu/ trusty-security/main libssl-doc all
1.0.1f-1ubuntu2.21
  404  Not Found [IP: 91.189.88.149 80]
E: Failed to fetch
http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl-dev_1.0.1f-1ubuntu2.
21_amd64.deb
404  Not Found [IP: 91.189.88.149 80]

E: Failed to fetch
http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl-doc_1.0.1f-1ubuntu2.
21_all.deb
404  Not Found [IP: 91.189.88.149 80]

E: Unable to fetch some archives, maybe run apt-get update or try with
--fix-missing?

On the command line, the way to bring the local apt cache up-to-date is to run apt-get update. When using the apt Ansible module, the way to bring the local apt cache up-to-date is to pass the update_cache=yes argument when invoking the module, as shown in Installing system packages.

Because updating the cache takes additional time, and because we might be running a playbook multiple times in quick succession in order to debug it, we can avoid paying the cache update penalty by using the cache_valid_time argument to the module. This instructs to update the cache only if it’s older than a certain threshold. The example in Installing system packages uses cache_valid_time=3600, which updates the cache only if it’s older than 3,600 seconds (1 hour).

Checking Out the Project by Using Git

Although Mezzanine can be used without writing any custom code, one of its strengths is that it is written on top of the Django platform, and Django is a great web application platform if you know Python. If you just wanted a CMS, you’d likely just use something like WordPress. But if you’re writing a custom application that incorporates CMS functionality, Mezzanine is a good way to go.

As part of the deployment, you need to check out the Git repository that contains your Django applications. In Django terminology, this repository must contain a project. I’ve created a repository on GitHub that contains a Django project with the expected files. That’s the project that gets deployed in this playbook.

I created these files using the mezzanine-project program that ships with Mezzanine, like this:

$ mezzanine-project mezzanine_example
$ chmod +x mezzanine_example/manage.py

Note that I don’t have any custom Django applications in my repository, just the files that are required for the project. In a real Django deployment, this repository would contain subdirectories that contain additional Django applications.

Checking out the Git repository shows how we use the git module to check out a Git repository onto a remote host.

Example 6. Checking out the Git repository
- name: check out the repository on the host
  git: repo={{ repo_url }} dest={{ proj_path }} accept_hostkey=yes

I’ve made the project repository public so that readers can access it, but in general, you’ll be checking out private Git repositories over SSH. For this reason, I’ve set the repo_url variable to use the scheme that will clone the repository over SSH:

repo_url: git@github.com:ansiblebook/mezzanine_example.git

If you’re following along at home, to run this playbook you must have the following:

  • A GitHub account

  • A public SSH key associated with your GitHub account

  • An SSH agent running on your control machine, with agent forwarding enabled

  • Your SSH key added to your SSH agent

Once your SSH agent is running, add your key:

$ ssh-add

If successful, the following command will output the public key of the SSH you just added:

$ ssh-add -l

The output should look like something this:

2048 SHA256:o7H/I9rRZupXHJ7JnDi10RhSzeAKYiRVrlH9L/JFtfA /Users/lorin/.ssh/id_rsa

To enable agent forwarding, add the following to your ansible.cfg:

[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ForwardAgent=yes

You can verify that agent forwarding is working by using Ansible to list the known keys:

$ ansible web -a "ssh-add -l"

You should see the same output as when you run ssh-add -l on your local machine.

Another useful check is to verify that you can reach GitHub’s SSH server:

$ ansible web -a "ssh -T git@github.com"

If successful, the output should look like this:

web | FAILED | rc=1 >>
Hi lorin! You've successfully authenticated, but GitHub does not provide shell
access.

Even though the word FAILED appears in the output, if the message from GitHub appears, then it was successful.

In addition to specifying the repository URL with the repo parameter and the destination path of the repository as the dest parameter, we also pass an additional parameter, accept_hostkey, which is related to host-key checking. We discuss SSH agent forwarding and host-key checking in more detail in [Appendix].

Installing Mezzanine and Other Packages into a virtualenv

As mentioned earlier in this chapter, we’re going to install some of the packages as Python packages because we can get more recent versions of those than if we installed the equivalent apt package.

We can install Python packages systemwide as the root user, but it’s better practice to install these packages in an isolated environment to avoid polluting the system-level Python packages. In Python, these types of isolated package environments are called virtualenvs. A user can create multiple virtualenvs, and can install Python packages into a virtualenv without needing root access.

Ansible’s pip module has support for installing packages into a virtualenv and for creating the virtualenv if it is not available.

Install Python requirements shows how to use pip to install several packages globally. Note that this requires become: True.

Example 7. Install Python requirements
- name: install Python requirements globally via pip
  pip: name={{ item }} state=latest
  with_items:
    - pip
    - virtualenv
    - virtualenvwrapper
  become: True

Install Python packages shows the two tasks that we use to install Python packages into the virtualenv, both of which use the pip module, although in different ways.

Example 8. Install Python packages
- name: install requirements.txt
  pip: requirements={{ proj_path }}/{{ reqs_path }} virtualenv={{ venv_path }}

- name: install required python packages
  pip: name={{ item }} virtualenv={{ venv_path }}
  with_items:
    - gunicorn
    - setproctitle
    - psycopg2
    - django-compressor
    - python-memcached

A common pattern in Python projects is to specify the package dependencies in a file called requirements.txt. And, indeed, the repository in our Mezzanine example contains a requirements.txt file. It looks like requirements.txt.

Example 9. requirements.txt
Mezzanine==4.2.2

The requirements.txt file is missing several other Python packages that we need for the deployment, so we explicitly specify these as a separate task.

Note that the Mezzanine Python package in requirements.txt is pinned to a specific version (4.2.2), whereas the other packages aren’t pinned; we just grab the latest versions of those. If we did not want to pin Mezzanine, we simply could have added Mezzanine to the list of packages, like this:

- name: install python packages
  pip: name={{ item }} virtualenv={{ venv_path }}
  with_items:
    - mezzanine
    - gunicorn
    - setproctitle
    - south
    - psycopg2
    - django-compressor
    - python-memcached

Alternately, if we wanted to pin all of the packages, we have several options. We could have specified all the packages in the requirements.txt file. This file contains information about the packages and the dependencies. An example file looks like Example requirements.txt.

Example 10. Example requirements.txt
beautifulsoup4==4.5.3
bleach==1.5.0
chardet==2.3.0
Django==1.10.4
django-appconf==1.0.2
django-compressor==2.1
django-contrib-comments==1.7.3
filebrowser-safe==0.4.6
future==0.16.0
grappelli-safe==0.4.5
gunicorn==19.6.0
html5lib==0.9999999
Mezzanine==4.2.2
oauthlib==2.0.1
olefile==0.43
Pillow==4.0.0
psycopg2==2.6.2
python-memcached==1.58
pytz==2016.10
rcssmin==1.0.6
requests==2.12.4
requests-oauthlib==0.7.0
rjsmin==1.0.12
setproctitle==1.1.10
six==1.10.0
tzlocal==1.3

If you have an existing virtualenv with the packages installed, you can use the pip freeze command to print out a list of installed packages. For example, if your virtualenv is in ~/mezzanine_example, you can activate your virtualenv and print out the packages in the virtualenv like this:

$ source ~/mezzanine_example/bin/activate
$ pip freeze > requirements.txt

Installing from requirements.txt shows how we could have installed all the packages by using a requirements.txt file.

Example 11. Installing from requirements.txt
- name: copy requirements.txt file
  copy: src=files/requirements.txt dest=~/requirements.txt
- name: install packages
  pip: requirements=~/requirements.txt virtualenv={{ venv_path }}

Alternatively, we could have specified both the package names and their versions in the list, as shown in Specifying package names and version. We pass a list of dictionaries, and dereference the elements with item.name and item.version.

Example 12. Specifying package names and version
- name: python packages
  pip: name={{ item.name }} version={{ item.version }} virtualenv={{ venv_path }}
  with_items:
    - {name: mezzanine, version: 4.2.2 }
    - {name: gunicorn, version: 19.6.0 }
    - {name: setproctitle, version: 1.1.10 }
    - {name: psycopg2, version: 2.6.2 }
    - {name: django-compressor, version: 2.1 }
    - {name: python-memcached, version: 1.58 }

Complex Arguments in Tasks: A Brief Digression

Up until this point in the book, every time we have invoked a module, we have passed the argument as a string. Taking the pip example from Specifying package names and version, we passed the pip module a string as an argument:

- name: install package with pip
  pip: name={{ item.name }} version={{ item.version }} virtualenv={{ venv_path }}

If we don’t like long lines in our files, we could break up the argument string across multiple lines by using YAML’s line folding, which we originally wrote about in [line_folding]:

- name: install package with pip
  pip: >
    name={{ item.name }}
    version={{ item.version }}
    virtualenv={{ venv_path }}

Ansible also provides another option for breaking up a module invocation across multiple lines. Instead of passing a string, we can pass a dictionary in which the keys are the variable names. This means we can invoke Specifying package names and version like this instead:

- name: install package with pip
  pip:
    name: "{{ item.name }}"
    version: "{{ item.version }}"
    virtualenv: "{{ venv_path }}"

The dictionary-based approach to passing arguments is also useful when invoking modules that take complex arguments. A complex argument is an argument to a module that is a list or a dictionary. The ec2 module, which creates new servers on Amazon EC2, is a good example of a module that takes complex arguments. Calling a module with complex arguments shows how to call a module that takes a list as an argument for the group parameter, and a dictionary as an argument to the instance_tags parameter. We’ll cover this module in more detail in [cloud].

Example 13. Calling a module with complex arguments
- name: create an ec2 instance
  ec2:
    image: ami-8caa1ce4
    instance_type: m3.medium
    key_name: mykey
    group:
      - web
      - ssh
    instance_tags:
      type: web
      env: production

You can even mix it up by passing some arguments as a string and others as a dictionary, by using the args clause to specify some of the variables as a dictionary. We could rewrite our preceding example as follows:

- name: create an ec2 instance
  ec2: image=ami-8caa1ce4 instance_type=m3.medium key_name=mykey
  args:
    group:
      - web
      - ssh
    instance_tags:
      type: web
      env: production

If you’re using the local_action clause (we’ll cover this in more detail in [more_on_playbooks_b]), the syntax for complex args changes slightly. You need to add module: <modulename> as shown here:

- name: create an ec2 instance
  local_action:
    module: ec2
    image: ami-8caa1ce4
    instance_type: m3.medium
    key_name: mykey
    group:
      - web
      - ssh
    instance_tags:
      type: web
      env: production

You can also mix simple arguments and complex arguments when using local_action:

- name: create an ec2 instance
  local_action: ec2 image=ami-8caa1ce4 instance_type=m3.medium key_name=mykey
  args:
    image: ami-8caa1ce4
    instance_type: m3.medium
    key_name: mykey
    group:
      - web
      - ssh
    instance_tags:
      type: web
      env: production
Warning

Ansible allows you to specify file permissions, which are used by several modules, including file, copy, and template. If you are specifying an octal value as a complex argument, it must either start the value with a 0 or quote it as a string.

For example, note how the mode argument starts with a 0:

- name: copy index.html
  copy:
    src: files/index.html
    dest: /usr/share/nginx/html/index.html
    mode: "0644"

If you do not start the mode argument with a 0 or quote it as a string, Ansible will interpret the value as a decimal number instead of an octal, and will not set the file permissions the way you expect. For details, see GitHub.

If you want to break your arguments across multiple lines, and you aren’t passing complex arguments, which form you choose is a matter of taste. I generally prefer dictionaries to multiline strings, but in this book I use both forms.

Configuring the Database

When Django runs in development mode, it uses the SQLite backend. This backend will create the database file if the file does not exist.

When using a database management system such as Postgres, we need to first create the database inside Postgres and then create the user account that owns the database. Later, we will configure Mezzanine with the credentials of this user.

Ansible ships with the postgresql_user and postgresql_db modules for creating users and databases inside Postgres. Creating the database and database user shows how we invoke these modules in our playbook.

When creating the database, we specify locale information through the lc_ctype and lc_collate parameters. We use the locale_gen module to ensure that the locale we are using is installed in the operating system.

Example 14. Creating the database and database user
- name: create project locale
  locale_gen: name={{ locale }}
  become: True

- name: create a DB user
  postgresql_user:
    name: "{{ database_user }}"
    password: "{{ db_pass }}"
  become: True
  become_user: postgres

- name: create the database
  postgresql_db:
    name: "{{ database_name }}"
    owner: "{{ database_user }}"
    encoding: UTF8
    lc_ctype: "{{ locale }}"
    lc_collate: "{{ locale }}"
    template: template0
  become: True
  become_user: postgres

Note the use of become: True and become_user: postgres on the last two tasks. When you install Postgres on Ubuntu, the installation process creates a user named postgres that has administrative privileges for the Postgres installation. Note that the root account does not have administrative privileges in Postgres by default, so in the playbook, we need to become the Postgres user in order to perform administrative tasks, such as creating users and databases.

When we create the database, we set the encoding (UTF8) and locale categories (LC_CTYPE, LC_COLLATE) associated with the database. Because we are setting locale information, we use template0 as the template.[3]

Generating the local_settings.py File from a Template

Django expects to find project-specific settings in a file called settings.py. Mezzanine follows the common Django idiom of breaking these settings into two groups:

  • Settings that are the same for all deployments (settings.py)

  • Settings that vary by deployment (local_settings.py)

We define the settings that are the same for all deployments in the settings.py file in our project repository. You can find that file on GitHub.

The settings.py file contains a Python snippet that loads a local_settings.py file that contains deployment-specific settings. The .gitignore file is configured to ignore the local_settings.py file, since developers will commonly create this file and configure it for local development.

As part of our deployment, we need to create a local_settings.py file and upload it to the remote host. local_settings.py.j2 shows the Jinja2 template that we use.

Example 15. local_settings.py.j2
from __future__ import unicode_literals

SECRET_KEY = "{{ secret_key }}"
NEVERCACHE_KEY = "{{ nevercache_key }}"
ALLOWED_HOSTS = [{% for domain in domains %}"{{ domain }}",{% endfor %}]

DATABASES = {
    "default": {
        # Ends with "postgresql_psycopg2", "mysql", "sqlite3" or "oracle".
        "ENGINE": "django.db.backends.postgresql_psycopg2",
        # DB name or path to database file if using sqlite3.
        "NAME": "{{ proj_name }}",
        # Not used with sqlite3.
        "USER": "{{ proj_name }}",
        # Not used with sqlite3.
        "PASSWORD": "{{ db_pass }}",
        # Set to empty string for localhost. Not used with sqlite3.
        "HOST": "127.0.0.1",
        # Set to empty string for default. Not used with sqlite3.
        "PORT": "",
    }
}

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTOCOL", "https")

CACHE_MIDDLEWARE_SECONDS = 60

CACHE_MIDDLEWARE_KEY_PREFIX = "{{ proj_name }}"

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
        "LOCATION": "127.0.0.1:11211",
    }
}

SESSION_ENGINE = "django.contrib.sessions.backends.cache"

Most of this template is straightforward; it uses the {{ variable }} syntax to insert the values of variables such as secret_key, nevercache_key, proj_name, and db_pass. The only nontrivial bit of logic is the line shown in Using a for loop in a Jinja2 template.

Example 16. Using a for loop in a Jinja2 template
ALLOWED_HOSTS = [{% for domain in domains %}"{{ domain }}",{% endfor %}]

If we look back at our variable definition, we have a variable called domains that’s defined like this:

domains:
  - 192.168.33.10.xip.io
  - www.192.168.33.10.xip.io

Our Mezzanine app is going to respond only to requests that are for one of the hostnames listed in the domains variable: http://192.168.33.10.xip.io or http://www.192.168.33.10.xip.io in our case. If a request reaches Mezzanine but the host header is something other than those two domains, the site will return "Bad Request (400)."

We want this line in the generated file to look like this:

ALLOWED_HOSTS = ["192.168.33.10.xip.io", "www.192.168.33.10.xip.io"]

We can achieve this by using a for loop, as shown in Using a for loop in a Jinja2 template. Note that it doesn’t do exactly what we want. Instead, it will have a trailing comma, like this:

ALLOWED_HOSTS = ["192.168.33.10.xip.io", "www.192.168.33.10.xip.io",]

However, Python is perfectly happy with trailing commas in lists, so we can leave it like this.

What’s xip.io?

You might have noticed that the domains we are using look a little strange: 192.168.33.10.xip.io and www.192.168.33.10.xip.io. They are domain names, but they have the IP address embedded within them.

When you access a website, you pretty much always point your browser to a domain name such as http://www.ansiblebook.com, instead of an IP address such as http://151.101.192.133. When we write our playbook to deploy Mezzanine to Vagrant, we want to configure the application with the domain name or names that it should be accessible by.

The problem is that we don’t have a DNS record that maps to the IP address of our Vagrant box. In this case, that’s 192.168.33.10. There’s nothing stopping us from setting up a DNS entry for this. For example, I could create a DNS entry from mezzanine-internal.ansiblebook.com that points to 192.168.33.10.

However, if we want to create a DNS name that resolves to a particular IP address, there’s a convenient service called xip.io, provided free of charge by Basecamp, that we can use so that we don’t have to avoid creating our own DNS records. If AAA.BBB.CCC.DDD is an IP address, the DNS entry AAA.BBB.CCC.DDD.xip.io will resolve to AAA.BBB.CCC.DDD. For example, 192.168.33.10.xip.io resolves to 192.168.33.10. In addition, www.192.168.33.10.xip.io also resolves to 192.168.33.10.

I find xip.io to be a great tool when I’m deploying web applications to private IP addresses for testing purposes. Alternatively, you can simply add entries to the /etc/hosts file on your local machine, which also works when you’re offline.

Let’s examine the Jinja2 for loop syntax. To make things a little easier to read, we’ll break it up across multiple lines, like this:

ALLOWED_HOSTS = [
{% for domain in domains %}
                 "{{ domain }}",
{% endfor %}
                ]

The generated config file looks like this, which is still valid Python.

ALLOWED_HOSTS = [
                 "192.168.33.10.xip.io",
                 "www.192.168.33.10.xip.io",
                ]

Note that the for loop has to be terminated by an {% endfor %} statement. Also note that the for statement and the endfor statement are surrounded by {% %} delimiters, which are different from the {{ }} delimiters that we use for variable substitution.

All variables and facts that have been defined in a playbook are available inside Jinja2 templates, so we never need to explicitly pass variables to templates.

Running django-manage Commands

Django applications use a special script called manage.py that performs administrative actions for Django applications such as the following:

  • Creating database tables

  • Applying database migrations

  • Loading fixtures from files into the database

  • Dumping fixtures from the database to files

  • Copying static assets to the appropriate directory

In addition to the built-in commands that manage.py supports, Django applications can add custom commands. Mezzanine adds a custom command called createdb that is used to initialize the database and copy the static assets to the appropriate place. The official Fabric scripts do the equivalent of this:

$ manage.py createdb --noinput --nodata

Ansible ships with a django_manage module that invokes manage.py commands. We could invoke it like this:

- name: initialize the database
  django_manage:
    command: createdb --noinput --nodata
    app_path: "{{ proj_path }}"
    virtualenv: "{{ venv_path }}"

Unfortunately, the custom createdb command that Mezzanine adds isn’t idempotent. If invoked a second time, it will fail like this:

TASK: [initialize the database] ***********************************************
failed: [web] => {"cmd": "python manage.py createdb --noinput --nodata", "failed"
: true, "path": "/home/vagrant/mezzanine_example/bin:/usr/local/sbin:/usr/local/b
in:/usr/sbin: /usr/bin:/sbin:/bin:/usr/games:/usr/local/games", "state": "absent"
, "syspath": ["", "/usr/lib/python2.7", "/usr/lib/python2.7/plat-x86_64-linux-gnu
", "/usr/lib/python2.7/lib-tk", "/usr/lib/python2.7/lib-old", "/usr/lib/python2.7
/lib-dynload", "/usr/local/lib/python2.7/dist-packages", "/usr/lib/python2.7/dist
-packages"]}
msg:
:stderr: CommandError: Database already created, you probably want the syncdb or
migrate command

Fortunately, the custom createdb command is effectively equivalent to two idempotent built-in manage.py commands:

migrate

Create and update database tables for Django models

collectstatic

Copy the static assets to the appropriate directories

By invoking these commands, we get an idempotent task:

- name: apply migrations to create the database, collect static content
  django_manage:
    command: "{{ item }}"
    app_path: "{{ proj_path }}"
    virtualenv: "{{ venv_path }}"
  with_items:
    - syncdb
    - collectstatic

Running Custom Python Scripts in the Context of the Application

To initialize our application, we need to make two changes to our database:

  • We need to create a Site model object that contains the domain name of our site (in our case, that’s 192.168.33.10.xip.io).

  • We need to set the administrator username and password.

Although we could make these changes with raw SQL commands or Django data migrations, the Mezzanine Fabric scripts use Python scripts, so that’s how we’ll do it.

There are two tricky parts here. The Python scripts need to run in the context of the virtualenv that we’ve created, and the Python environment needs to be set up properly so that the script will import the settings.py file that’s in ~/mezzanine/mezzanine_example/mezzanine_example.

In most cases, if we needed some custom Python code, I’d write a custom Ansible module. However, as far as I know, Ansible doesn’t let you execute a module in the context of a virtualenv, so that’s out.

I used the script module instead. This will copy over a custom script and execute it. I wrote two scripts, one to set the Site record, and the other to set the admin username and password.

You can pass command-line arguments to script modules and parse them out, but I decided to pass the arguments as environment variables instead. I didn’t want to pass passwords via command-line argument (those show up in the process list when you run the ps command), and it’s easier to parse out environment variables in the scripts than it is to parse command-line arguments.

Note

You can set environment variables with an environment clause on a task, passing it a dictionary that contains the environment variable names and values. You can add an environment clause to any task; it doesn’t have to be a script.

In order to run these scripts in the context of the virtualenv, I also needed to set the path variable so that the first Python executable in the path would be the one inside the virtualenv. Using the script module to invoke custom Python code shows how I invoked the two scripts.

Example 17. Using the script module to invoke custom Python code
- name: set the site id
  script: scripts/setsite.py
  environment:
    PATH: "{{ venv_path }}/bin"
    PROJECT_DIR: "{{ proj_path }}"
    PROJECT_APP: "{{ proj_app }}"
    WEBSITE_DOMAIN: "{{ live_hostname }}"

- name: set the admin password
  script: scripts/setadmin.py
  environment:
    PATH: "{{ venv_path }}/bin"
    PROJECT_DIR: "{{ proj_path }}"
    PROJECT_APP: "{{ proj_app }}"
    ADMIN_PASSWORD: "{{ admin_pass }}"

The scripts themselves are shown in Examples 6-18 and 6-19. I put these in a scripts subdirectory.

Example 18. scripts/setsite.py
#!/usr/bin/env python
# A script to set the site domain
# Assumes two environment variables
#
# WEBSITE_DOMAIN: the domain of the site (e.g., www.example.com)
# PROJECT_DIR: root directory of the project
# PROJECT_APP: name of the project app
import os
import sys

# Add the project directory to system path
proj_dir = os.path.expanduser(os.environ['PROJECT_DIR'])
sys.path.append(proj_dir)

proj_app = os.environ['PROJECT_APP']
os.environ['DJANGO_SETTINGS_MODULE'] = proj_app + '.settings'
import django
django.setup()
from django.conf import settings
from django.contrib.sites.models import Site
domain = os.environ['WEBSITE_DOMAIN']
Site.objects.filter(id=settings.SITE_ID).update(domain=domain)
Site.objects.get_or_create(domain=domain)
Example 19. scripts/setadmin.py
#!/usr/bin/env python
# A script to set the admin credentials
# Assumes two environment variables
#
# PROJECT_DIR: the project directory (e.g., ~/projname)
# PROJECT_APP: name of the project app
# ADMIN_PASSWORD: admin user's password

import os
import sys

# Add the project directory to system path
proj_dir = os.path.expanduser(os.environ['PROJECT_DIR'])
sys.path.append(proj_dir)

proj_app = os.environ['PROJECT_APP']
os.environ['DJANGO_SETTINGS_MODULE'] = proj_app + '.settings'
import django
django.setup()
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(username='admin')
u.is_staff = u.is_superuser = True
u.set_password(os.environ['ADMIN_PASSWORD'])
u.save()

Setting Service Configuration Files

Next, we set the configuration file for Gunicorn (our application server), Nginx (our web server), and Supervisor (our process manager), as shown in Setting configuration files. The template for the Gunicorn configuration file is shown in templates/gunicorn.conf.py.j2, and the template for the Supervisor configuration file is shown in templates/supervisor.conf.j2.

Example 20. Setting configuration files
- name: set the gunicorn config file
  template:
      src: templates/gunicorn.conf.py.j2
      dest: "{{ proj_path }}/gunicorn.conf.py"

- name: set the supervisor config file
  template:
      src: templates/supervisor.conf.j2
      dest: /etc/supervisor/conf.d/mezzanine.conf
  become: True
  notify: restart supervisor

- name: set the nginx config file
  template:
      src: templates/nginx.conf.j2
      dest: /etc/nginx/sites-available/mezzanine.conf
  notify: restart nginx
  become: True

In all three cases, we generate the config files by using templates. The Supervisor and Nginx processes are started by root (although they drop down to nonroot users when running), so we need to sudo so that we have the appropriate permissions to write their configuration files.

If the Supervisor config file changes, Ansible will fire the restart supervisor handler. If the Nginx config file changes, Ansible will fire the restart nginx handler, as shown in Handlers.

Example 21. Handlers
handlers:
  - name: restart supervisor
    supervisorctl: name=gunicorn_mezzanine state=restarted
    sudo: True

  - name: restart nginx
    service: name=nginx state=restarted
    sudo: True
Example 22. templates/gunicorn.conf.py.j2
from __future__ import unicode_literals
import multiprocessing

bind = "127.0.0.1:{{ gunicorn_port }}"
workers = multiprocessing.cpu_count() * 2 + 1
loglevel = "error"
proc_name = "{{ proj_name }}"
Example 23. templates/supervisor.conf.j2
[program:{{ gunicorn_procname }}]
command={{ venv_path }}/bin/gunicorn -c gunicorn.conf.py -p gunicorn.pid \
    {{ proj_app }}.wsgi:application
directory={{ proj_path }}
user={{ user }}
autostart=true
stdout_logfile = /home/{{ user }}/logs/{{ proj_name }}_supervisor
autorestart=true
redirect_stderr=true
environment=LANG="{{ locale }}",LC_ALL="{{ locale }}",LC_LANG="{{ locale }}"

The only template that has any template logic (other than variable substitution) is templates/nginx.conf.j2. It has conditional logic to enable TLS if the tls_enabled variable is set to true. You’ll see some if statements scattered about the templates that look like this:

{% if tls_enabled %}
...
{% endif %}

It also uses the join Jinja2 filter here:

    server_name {{ domains|join(", ") }};

This code snippet expects the variable domains to be a list. It will generate a string with the elements of domains connected together, separated by commas. Recall that in our case, the domains list is defined as follows:

domains:
  - 192.168.33.10.xip.io
  - www.192.168.33.10.xip.io

When the template renders, the line looks like this:

server_name 192.168.33.10.xip.io, www.192.168.33.10.xip.io;
Example 24. templates/nginx.conf.j2
upstream {{ proj_name }} {
    server unix:{{ proj_path }}/gunicorn.sock fail_timeout=0;
}

server {

    listen 80;

    {% if tls_enabled %}
    listen 443 ssl;
    {% endif %}
    server_name {{ domains|join(", ") }};
    client_max_body_size 10M;
    keepalive_timeout    15;

    {% if tls_enabled %}
    ssl_certificate      conf/{{ proj_name }}.crt;
    ssl_certificate_key  conf/{{ proj_name }}.key;
    ssl_session_cache    shared:SSL:10m;
    ssl_session_timeout  10m;
    # ssl_ciphers entry is too long to show in this book
    # See https://github.com/ansiblebook/ansiblebook
    #     ch06/playbooks/templates/nginx.conf.j2
    ssl_prefer_server_ciphers on;
    {% endif %}

    location / {
        proxy_redirect      off;
        proxy_set_header    Host                    $host;
        proxy_set_header    X-Real-IP               $remote_addr;
        proxy_set_header    X-Forwarded-For         $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Protocol    $scheme;
        proxy_pass          http://{{ proj_name }};
    }

    location /static/ {
        root            {{ proj_path }};
        access_log      off;
        log_not_found   off;
    }

    location /robots.txt {
        root            {{ proj_path }}/static;
        access_log      off;
        log_not_found   off;
    }

    location /favicon.ico {
        root            {{ proj_path }}/static/img;
        access_log      off;
        log_not_found   off;
    }
}

Enabling the Nginx Configuration

The convention with Nginx configuration files is to put your configuration files in /etc/nginx/sites-available and enable them by symlinking them into /etc/nginx/sites-enabled.

The Mezzanine Fabric scripts just copy the configuration file directly into sites-enabled, but I’m going to deviate from how Mezzanine does it because it gives me an excuse to use the file module to create a symlink. We also need to remove the default configuration file that the Nginx package sets up in /etc/nginx/sites-enabled/default.

As shown in Enabling Nginx configuration, we use the file module to create the symlink and to remove the default config file. This module is useful for creating directories, symlinks, and empty files; deleting files, directories, and symlinks; and setting properties such as permissions and ownership.

Example 25. Enabling Nginx configuration
- name: enable the nginx config file
  file:
    src: /etc/nginx/sites-available/mezzanine.conf
    dest: /etc/nginx/sites-enabled/mezzanine.conf
    state: link
  become: True

- name: remove the default nginx config file
  file: path=/etc/nginx/sites-enabled/default state=absent
  notify: restart nginx
  become: True

Installing TLS Certificates

Our playbook defines a variable named tls_enabled. If this variable is set to true, the playbook will install TLS certificates. In our example, we use self-signed certificates, so the playbook will create the certificate if it doesn’t exist.

In a production deployment, you would copy an existing TLS certificate that you obtained from a certificate authority.

Installing TLS certificates shows the two tasks involved in configuring for TLS certificates. We use the file module to ensure that the directory that will house the TLS certificates exists.

Example 26. Installing TLS certificates
- name: ensure config path exists
  file: path={{ conf_path }} state=directory
  sudo: True
  when: tls_enabled

- name: create tls certificates
  command: >
    openssl req -new -x509 -nodes -out {{ proj_name }}.crt
    -keyout {{ proj_name }}.key -subj '/CN={{ domains[0] }}' -days 3650
    chdir={{ conf_path }}
    creates={{ conf_path }}/{{ proj_name }}.crt
  sudo: True
  when: tls_enabled
  notify: restart nginx

Note that both tasks contain this clause:

when: tls_enabled

If tls_enabled evaluates to false, Ansible will skip the task.

Ansible doesn’t ship with modules for creating TLS certificates, so we need to use the command module to invoke the openssl command in order to create the self-signed certificate. Since the command is very long, we use YAML line-folding syntax (see [line_folding]) so that we can break the command across multiple lines.

These two lines at the end of the command are additional parameters that are passed to the module; they are not passed to the command line:

    chdir={{ conf_path }}
    creates={{ conf_path }}/{{ proj_name }}.crt

The chdir parameter changes the directory before running the command. The creates parameter implements idempotence: Ansible will first check whether the file {{ conf_path }}/{{ proj_name }}.crt exists on the host. If it already exists, Ansible will skip this task.

Installing Twitter Cron Job

If you run manage.py poll_twitter, Mezzanine will retrieve tweets associated with the configured accounts and show them on the home page. The Fabric scripts that ship with Mezzanine keep these tweets up-to-date by installing a cron job that runs every five minutes.

If we followed the Fabric scripts exactly, we’d copy a cron script into the /etc/cron.d directory that had the cron job. We could use the template module to do this. However, Ansible ships with a cron module that allows us to create or delete cron jobs, which I find more elegant. Installing cron job for polling Twitter shows the task that installs the cron job.

Example 27. Installing cron job for polling Twitter
- name: install poll twitter cron job
  cron: name="poll twitter" minute="*/5" user={{ user }} job="{{ manage }} \
  poll_twitter"

If you manually SSH to the box, you can see the cron job that gets installed by using crontab -l to list the jobs. Here’s what it looks like for me when I deploy as the Vagrant user:

#Ansible: poll twitter
*/5 * * * * /home/vagrant/.virtualenvs/mezzanine_example/bin/python \
/home/vagrant/mezzanine/mezzanine_example/manage.py poll_twitter

Notice the comment at the first line. That’s how the Ansible module supports deleting cron jobs by name. If you were to do this:

- name: remove cron job
  cron: name="poll twitter" state=absent

the cron module would look for the comment line that matches the name and delete the job associated with that comment.

The Full Playbook

mezzanine.yml: the complete playbook shows the complete playbook in all its glory.

Example 28. mezzanine.yml: the complete playbook
link:mezzanine.yml[role=include]

Running the Playbook Against a Vagrant Machine

The live_hostname and domains variables in our playbook assume that the host we are going to deploy to is accessible at 192.168.33.10. The Vagrantfile shown in Vagrantfile configures a Vagrant machine with that IP address.

Example 29. Vagrantfile
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "ubuntu/trusty64"
  config.vm.network "private_network", ip: "192.168.33.10"
end

Deploy Mezzanine into the Vagrant machine:

$ ansible-playbook mezzanine.yml

You can then reach your newly deployed Mezzanine site at any of the following URLs:

Troubleshooting

You might hit a few speed bumps when trying to run this playbook on your local machine. This section describes how to overcome some common obstacles.

Cannot Check Out Git Repository

You may see the task named "check out the repository on the host" fail with this error:

fatal: Could not read from remote repository.

A likely fix is to remove a preexisting entry for 192.168.33.10 in your ~/.ssh/known_hosts file. See [badhostkey_sb] for more details.

Cannot Reach 192.168.33.10.xip.io

Some WiFi routers ship with DNS servers that won’t resolve the hostname 192.168.33.10.xip.io. You can check whether yours does by typing on the command line:

dig +short 192.168.33.10.xip.io

The output should be as follows:

192.168.33.10

If the output is blank, your DNS server is refusing to resolve xip.io hostnames. If this is the case, a workaround is to add the following to your /etc/hosts file:

192.168.33.10 192.168.33.10.xip.io

Bad Request (400)

If your browser returns the error "Bad Request (400)," it is likely that you are trying to reach the Mezzanine site by using a hostname or IP address that is not in the ALLOWED_HOSTS list in the Mezzanine configuration file. This list is populated using the domains Ansible variable in the playbook:

    domains:
      - 192.168.33.10.xip.io
      - www.192.168.33.10.xip.io

Deploying Mezzanine on Multiple Machines

In this scenario, we’ve deployed Mezzanine entirely on a single machine. However, it’s common to deploy the database service on a separate host from the web service. In [roles], we’ll show a playbook that deploys across the database and web services on separate hosts.

You’ve now seen what it’s like to deploy a real application with Mezzanine. The next chapter covers some more advanced features of Ansible that didn’t come up in our example.


1. My wife, Stacy, is notorious for doing this.
2. You can find the Fabric scripts that ship with Mezzanine on GitHub.
3. See the Postgres documentation for more details about template databases.