Deploying Pelican Blog with Bitbucket Commit Hooks

Schaffhausen Watch

Migrating from Wordpress

I recently migrated my blog from Wordpress to Pelican. There are a few incentives to do so. Not the least of which is using Markdown or reST for markup and free distributed backups with Git.

There are some drawbacks as well. The one I'd miss the most is the ability to easily sync off line changes to your blog. I was using MarsEdit for this purpose and am very happy with it overall. Though I've never been able to find a non-futzy way to edit in reST and publish to the blog. All the workflow always involved conversion steps or copy and pasting.

By migrating to Pelican + Git, I'm able to use Bitbucket's git service hooks to trigger a sync & build cycle on my web server. This article walks through steps required to set this up. [1]

The Method

On the back end, I set all this up by writing a little flask + uwsgi application that does one thing and one thing only: listens for a Bitbucket POST commit notification. It checks to see if the master branch changed and pulls down the blog updates and regenerates the static files. The static files are then served by nginx. I also proxy uwsgi through nginx so my api endpoint is a url like:

http://yourdomain.com/api
or
http://api.yourdomain.com/v1

Install

First, install the necessary packages on your server:

apt-get install nginx uwsgi uwsgi-plugin-http uwsgi-plugin-python python-pip \
                python-virtualenv

Pelican doesn't have deb packages. So I used pip instead:

pip install pelican

I found my theme in the pelican-themes repository. pelican-themes is a collection of about 25 CSS styles for pelican. This blog is styled using a variation of tuxlite_tbs. Whichever one you choose, it needs to be installed on your server:

pelican-themes -i the_theme

Configure

Configuration is based on the following assumptions. You may need to adapt the example files if yours varies. I'll use $fqdn as a substitute for your actual blog fqdn. For example, mine would be blog.jameskyle.org.

  • blog repository location
    • /var/www/$fqdn
  • static files location
    • /var/www/$fqdn/output/
  • default confiugraiton location for uwsgi sockets on ubuntu
    • /var/run/uwsgi/app/$fqdn/socket

Nginx

Nginx is used to serve our static files and also proxy to the uWSGI git service.

First, I disabled the default site:

rm /etc/nginx/sites-enabled/default

Next, I create a conf for my blog site:

vim /etc/nginx/conf.d/${fqdn}.conf
server {
     listen   80;
     root /var/www/${fqdn}/output;
     index index.html index.htm;
     server_name \*.${fqdn};
     location /api {
         include uwsgi_params;
         uwsgi_pass unix:///var/run/uwsgi/app/${fqdn}/socket;
         uwsgi_param UWSGI_SETENV PELICAN=/usr/local/bin/pelican;
     }

     location / {
             try_files $uri $uri/ /index.html;
     }
 }

This particular configuraion proxies to all requests made to the /api route to the backend server. However, you could modify the proxy to suit any number of configurations.

You may notice I pass a environment variable to uwsgi. The uWSGI process runs as the www-data user. In my testing, the user was unable to find the pelican binary when my server spawned a 'make regenerate'. To solve that problem, I set the PELICAN environment variable and changed the pelican Makefile to conditionally set its PELICAN var. You can do this by executing:

sed -i.bak 's|PELICAN=|PELICAN?=|g' Makefile

On the Makefile generated by pelican-quickstart.

virtualenv

I created a virtual environment with the necessary modules for my uwsgi server. It provides a nice segration between my system and application libraries. These config files assume the virtualenv is located at /var/www/$fqdn/env. Update them if you choose to drop it somewhere else.

virtualenv /var/www/$fqdn/env

uWSGI

Nginx needs something to proxy too and I used uwsgi. Gunicorn or any other wsgi compatible backend is perfectly acceptable. I created a file /etc/uwsgi/sites-available/$fqdn.ini with these contents

[uwsgi]
base = /var/www/$fqdn
gitpost = %(base)/utils/gitpost
module = gitpost
callable = app
app = app
# Generic Config
plugins = http,python
home = %(base)/env
pythonpath = %(base)/env
chdir = %(gitpost)

You must then link the file to the sites-enabled directory

ln -s /etc/uwsgi/sites-available/$fqdn.ini /etc/uwsgi/sites-enabled/

Bitbucket

Bitbucket is where the magic happens. Ok, it's not magic. It's just a POST hook on commits. But it's what sets everything in motion. To enable that feature you go to http://bitbucket.org/$username/$blog-reponame, then type 'r' and 'a' in quick succession (or click the little sprocket to the right). Next, in the left menu click "Services". You'll have a dropdown, select the "POST" service. Finally, enter your blog's fqdn and api url. Given a fqdn of blog.jameskyle.org, the examples above woudl produce an api endpoint of:

http://blog.jameskyle.org/api

My blog repo is public. However, if you wish to keep your raw source private you'll need to create a public/private keypair for checking out the blog. The private key should be stored in the www-data user's home directory. On Ubuntu systems, the key is stored in:

/var/www/.ssh/id_rsa

Or id_dsa, whichever.

gitpost

Of course, we need to have something listening on the other side of the POST hook or it's all for naught. I wrote a little server using flask to satisfy that function. Configurable variables are set at the top of the file. Specifically, you'll want to set the DOCROOT variable. In this case, the DOCROOT is the location of your blog repository.

My server also logs activity. By default, it does so to:

/var/log/gitpost/gitpost.log

This directory should be owned by the www-data user.

mkdir /var/log/gitpost
chown www-data:www-data -R /var/log/gitpost
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import os
import json
import logging

from flask import Flask
from flask import request
from subprocess import Popen
from subprocess import PIPE
from werkzeug.contrib.fixers import ProxyFix

DOCROOT  = "/var/www/$fqdn"
LOGLEVEL = logging.INFO
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
DATE_FORMAT = "%Y-%m-%d,%H:%M:%S"
LOGFILE = "/var/log/gitpost/gitpost.log"

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app)
app.logger.setLevel(LOGLEVEL)

formatter = logging.Formatter(fmt=LOG_FORMAT, datefmt=DATE_FORMAT)
fh = logging.FileHandler(LOGFILE)
fh.setFormatter(formatter)

app.logger.addHandler(fh)

app.logger.info("Starting gitpost api application")

def log_error(command, output, error, returncode):
    msg = """
    Command: {cmd} => {ret}
    --------------

    Output:
        {out}

    Error:
        {err}
    """.format(cmd=command, out=output, err=error, ret=returncode)
    app.logger.error(msg)

def pull():
    app.logger.info("Pulling changes from master")
    popen(["git", "pull", "origin", "master"])

def popen(cmd):
    app.logger.debug("Changing into {0}".format(DOCROOT))
    app.logger.debug("Executing: {0}".format(" ".join(cmd)))

    os.chdir(DOCROOT)
    p = Popen(cmd, stdout=PIPE, stderr=PIPE)
    p.wait()
    out, err = p.communicate()
    if p.returncode != 0:
        log_error(cmd, out, err, p.returncode)

def publish():
    app.logger.info("Publishing blog updates")
    popen(["make", "publish"])

def update():
    app.logger.info("Updating blog")
    pull()
    publish()

@app.route("/api", methods=["POST"])
def parse_request():
    app.logger.info("Parsing request")
    payload = json.loads(request.form["payload"])
    branch = payload["commits"][0]['branch']
    app.logger.debug("received commit from branch: {0!r}".format(branch))

    if branch == "master":
        app.logger.debug("Initiating master repo sync")
        pull()
        publish()

    app.logger.info("Finished publishing")
    return "Received:: {0}".format(request.form)

if __name__ == "__main__":
    app.run()

The uWSGI configuration files provided assume the gitpost script is located in:

/var/www/$fqdn/utils

And that the created virtualenv is located in:

/var/www/$fqdn/env

Final Steps

The last thing you need to do is checkout your git repository into the docroot directory. In our examples, this would be /var/www/$fqdn. As I mentioned before, I store all relevant files in my repository.

git clone git@bitbucket.org:username/reponame.git /var/www/$fqdn

Evaluation

I've found the following method to be quite low maintenance. After setting up, the workflow involves editing your local repo, then pushing the changes to master.

My blog and all modifications to the default pelican layout, Makefile, and the supporting scripts/configurations covered here are available at my public blog repository along with my pelicanconf and article organization.

What's Next

I'm using a modified git-flow work flow for my article posting where 'hotfixes' are 'articles' and features are, well, features. I want to modify the plugin to better reflect blog posting. For example, I'd like to

git blog article start deploy-pelican-with-bitbucket-service-hooks
# create article
git blog article finish deploy-pelican-with-bitbucket-service-hooks

If I do make those adaptations, it'll certainly be worthy of a followup post.

I'm also working on a management script that automates the steps above. For example, configuring a new application might look like

./manage setup
What is your blog's fqdn? > blog.jameskyle.org
What is your blog's git repository? > http://git.jameskyle.org/blog-jameskyle
Checking out blog-jameskyle into /var/www/blog.jameskyle.org.....

And so on.

Share on: TwitterFacebookGoogle+Email

Comments !