Deployment: make `setup` idempotent and part of `deploy`, automatically install DB...
[fnpdjango.git] / fnpdjango / deploy / __init__.py
1 """
2 Generic fabric deployment script.
3 Create a fabfile.py in the project and start it with:
4
5     from fnpdjango.deploy import *
6
7 Then set up some env properties:
8     project_name: slug-like project name
9     hosts: list of target host names
10     user: remote user name
11     app_path: where does the app go
12     services: list of tasks to run after deployment
13
14 """
15 from os.path import abspath, dirname, exists, join
16 from django.utils.crypto import get_random_string
17 from fabric.api import *
18 from fabric.contrib import files
19 from fabric.tasks import Task, execute
20
21 env.virtualenv = '/usr/bin/virtualenv'
22 env.services = None
23
24
25 @task
26 def setup():
27     """
28     Setup all needed directories.
29     """
30     require('hosts', 'app_path')
31
32     if not files.exists(env.app_path):
33         run('mkdir -p %(app_path)s' % env, pty=True)
34     with cd(env.app_path):
35         for subdir in 'releases', 'packages', 'log':
36             if not files.exists(subdir):
37                 run('mkdir -p %s' % subdir, pty=True)
38     with cd('%(app_path)s/releases' % env):
39         if not files.exists('current'):
40             run('ln -sfT . current', pty=True)
41         if not files.exists('previous'):
42             run('ln -sfT . previous', pty=True)
43     
44     upload_samples()
45
46
47 def check_localsettings():
48     if not files.exists('%(app_path)s/localsettings.py' % env):
49         abort('localsettings.py file missing.')
50
51
52 @task(default=True)
53 def deploy():
54     """
55     Deploy the latest version of the site to the servers,
56     install any required third party modules,
57     install the virtual host and then restart the webserver
58     """
59     require('hosts', 'app_path')
60
61     import time
62     env.release = time.strftime('%Y-%m-%dT%H%M')
63
64     setup()
65     check_localsettings()
66     upload_tar_from_git()
67     copy_localsettings()
68     install_requirements()
69     symlink_current_release()
70     migrate()
71     collectstatic()
72     restart()
73
74 @task
75 def rollback():
76     """
77     Limited rollback capability. Simple loads the previously current
78     version of the code. Rolling back again will swap between the two.
79     Warning: this will almost certainly go wrong, it there were any migrations
80     in the meantime!
81     """
82     require('hosts', 'app_path')
83     with cd(env.path):
84         run('mv releases/current releases/_previous;', pty=True)
85         run('mv releases/previous releases/current;', pty=True)
86         run('mv releases/_previous releases/previous;', pty=True)
87     collectstatic()
88     restart()
89
90 @task
91 def deploy_version(version):
92     """
93     Loads the specified version.
94     Warning: this will almost certainly go wrong, it there were any migrations
95     in the meantime!
96     """
97     "Specify a specific version to be made live"
98     require('hosts', 'app_path')
99     env.version = version
100     with cd(env.path):
101         run('rm releases/previous; mv releases/current releases/previous;', pty=True)
102         run('ln -s %(version)s releases/current' % env, pty=True)
103     collectstatic()
104     restart()
105
106 @task
107 def restart():
108     require('services')
109     for service in env.services:
110         execute(service)
111
112
113 # =====================================================================
114 # = Helpers. These are called by other functions rather than directly =
115 # =====================================================================
116
117 class Service(Task):
118     def upload_sample(self):
119         pass
120
121 class DebianGunicorn(Service):
122     def __init__(self, name):
123         super(Task, self).__init__()
124         self.name = name
125
126     def run(self):
127         print '>>> restart webserver using gunicorn-debian'
128         sudo('gunicorn-debian restart %s' % self.name, shell=False)
129
130     def upload_sample(self):
131         upload_sample('gunicorn')
132
133 class Apache(Service):
134     def run(self):
135         print '>>> restart webserver by touching WSGI'
136         with path('/sbin'):
137             run('touch %(app_path)s/%(project_name)s/wsgi.py' % env)
138
139 class Supervisord(Service):
140     def __init__(self, name):
141         super(Task, self).__init__()
142         self.name = name
143
144     def run(self):
145         print '>>> supervisord: restart %s' % self.name
146         sudo('supervisorctl restart %s' % self.name, shell=False)
147
148 def upload_samples():
149     upload_localsettings_sample()
150     upload_nginx_sample()
151     for service in env.services:
152         service.upload_sample()
153
154 def upload_sample(name):
155     require('app_path', 'project_name')
156     upload_path = '%(app_path)s/' % env + name + '.sample'
157     if files.exists(upload_path):
158         return
159     print '>>> upload %s template' % name
160     template = '%(project_name)s/' % env + name + '.template'
161     if not exists(template):
162         template = join(dirname(abspath(__file__)), 'templates/' + name + '.template')
163     files.upload_template(template, upload_path, env)
164
165 def upload_localsettings_sample():
166     "Fill out localsettings template and upload as a sample."
167     env.secret_key = get_random_string(50)
168     upload_sample('localsettings.py')
169
170 upload_nginx_sample = lambda: upload_sample('nginx')
171
172 def upload_tar_from_git():
173     "Create an archive from the current Git branch and upload it"
174     print '>>> upload tar from git'
175     require('release', provided_by=[deploy])
176     require('app_path')
177     local('git-archive-all.sh --format tar %(release)s.tar' % env)
178     local('gzip %(release)s.tar' % env)
179     run('mkdir -p %(app_path)s/releases/%(release)s' % env, pty=True)
180     run('mkdir -p %(app_path)s/packages' % env, pty=True)
181     put('%(release)s.tar.gz' % env, '%(app_path)s/packages/' % env)
182     run('cd %(app_path)s/releases/%(release)s && tar zxf ../../packages/%(release)s.tar.gz' % env, pty=True)
183     local('rm %(release)s.tar.gz' % env)
184
185 def install_requirements():
186     "Install the required packages from the requirements file using pip"
187     print '>>> install requirements'
188     require('release', provided_by=[deploy])
189     require('app_path')
190     if not files.exists('%(app_path)s/ve' % env):
191         require('virtualenv')
192         run('%(virtualenv)s %(app_path)s/ve' % env, pty=True)
193     with cd('%(app_path)s/releases/%(release)s' % env):
194         run('%(app_path)s/ve/bin/pip install -r requirements.txt' % env, pty=True)
195         # Install DB requirement
196         database_reqs = {
197             'django.db.backends.postgresql_psycopg2': 'psycopg2',
198             'django.db.backends.mysql': 'MySQL-python',
199         }
200         databases = run('''DJANGO_SETTINGS_MODULE=%(project_name)s.settings %(app_path)s/ve/bin/python -c 'from django.conf import settings;              print " ".join(set([d["ENGINE"] for d in settings.DATABASES.values()]))' ''' % env)
201         for database in databases.split():
202             if database in database_reqs:
203                 # TODO: set pip default pypi
204                 run('%(app_path)s/ve/bin/pip install ' % env + database_reqs[database])
205
206
207 def copy_localsettings():
208     "Copy localsettings.py from root directory to release directory (if this file exists)"
209     print ">>> copy localsettings"
210     require('release', provided_by=[deploy])
211     require('app_path', 'project_name')
212
213     with settings(warn_only=True):
214         run('cp %(app_path)s/localsettings.py %(app_path)s/releases/%(release)s/%(project_name)s' % env)
215
216 def symlink_current_release():
217     "Symlink our current release"
218     print '>>> symlink current release'
219     require('release', provided_by=[deploy])
220     require('app_path')
221     with cd(env.app_path):
222         run('rm releases/previous; mv releases/current releases/previous')
223         run('ln -s %(release)s releases/current' % env)
224
225 def migrate():
226     "Update the database"
227     print '>>> migrate'
228     require('app_path', 'project_name')
229     with cd('%(app_path)s/releases/current' % env):
230         run('%(app_path)s/ve/bin/python manage.py syncdb --noinput' % env, pty=True)
231         run('%(app_path)s/ve/bin/python manage.py migrate' % env, pty=True)
232
233 def collectstatic():
234     """Collect static files"""
235     print '>>> collectstatic'
236     require('app_path', 'project_name')
237     with cd('%(app_path)s/releases/current' % env):
238         run('%(app_path)s/ve/bin/python manage.py collectstatic --noinput' % env, pty=True)