Add BofhStorage;
[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     django_root_path (optional): path to the directory
14         containing django project, relative to the
15         root of the repository (defaults to '.')
16     localsettings_dst_path (optional): path indicating
17         where to copy the localsettings file, relative
18         to django_root_path (defaults to project_name/localsettings.py)
19     skip_collect_static (optional): if True, Django collectstatic command is not called
20 """
21 from subprocess import check_output
22 from os.path import abspath, dirname, exists, join
23 from django.utils.crypto import get_random_string
24 from fabric.api import *
25 from fabric.contrib import files
26 from fabric.tasks import Task, execute
27
28 env.virtualenv = '/usr/bin/virtualenv'
29 env.services = None
30
31
32 @task
33 def setup():
34     """
35     Setup all needed directories.
36     """
37     require('hosts', 'app_path')
38
39     if not files.exists(env.app_path):
40         run('mkdir -p %(app_path)s' % env, pty=True)
41     with cd(env.app_path):
42         for subdir in 'releases', 'packages', 'log', 'samples':
43             if not files.exists(subdir):
44                 run('mkdir -p %s' % subdir, pty=True)
45     with cd('%(app_path)s/releases' % env):
46         if not files.exists('current'):
47             run('ln -sfT . current', pty=True)
48         if not files.exists('previous'):
49             run('ln -sfT . previous', pty=True)
50     
51     upload_samples()
52
53
54 def check_localsettings():
55     return files.exists('%(app_path)s/localsettings.py' % env)
56
57
58 @task(default=True)
59 def deploy():
60     """
61     Deploy the latest version of the site to the servers,
62     install any required third party modules,
63     install the virtual host and then restart the webserver
64     """
65     require('hosts', 'app_path')
66
67     import time
68     env.release = '%s_%s' % (time.strftime('%Y-%m-%dT%H%M'), check_output(['git', 'rev-parse', 'HEAD']).strip())
69
70     setup()
71     if not check_localsettings():
72         abort('Setup is complete, but\n    %(app_path)s/localsettings.py\n'
73               'is needed for actual deployment.' % env)
74     upload_tar_from_git()
75     copy_localsettings()
76     install_requirements()
77     symlink_current_release()
78     migrate()
79     pre_collectstatic()
80     collectstatic()
81     restart()
82
83 @task
84 def rollback():
85     """
86     Limited rollback capability. Simple loads the previously current
87     version of the code. Rolling back again will swap between the two.
88     Warning: this will almost certainly go wrong, it there were any migrations
89     in the meantime!
90     """
91     require('hosts', 'app_path')
92     with cd(env.path):
93         run('mv releases/current releases/_previous;', pty=True)
94         run('mv releases/previous releases/current;', pty=True)
95         run('mv releases/_previous releases/previous;', pty=True)
96     collectstatic()
97     restart()
98
99 @task
100 def deploy_version(version):
101     """
102     Loads the specified version.
103     Warning: this will almost certainly go wrong, it there were any migrations
104     in the meantime!
105     """
106     "Specify a specific version to be made live"
107     require('hosts', 'app_path')
108     env.version = version
109     with cd(env.path):
110         run('rm releases/previous; mv releases/current releases/previous;', pty=True)
111         run('ln -s %(version)s releases/current' % env, pty=True)
112     collectstatic()
113     restart()
114
115 @task
116 def restart():
117     for service in env.services or ():
118         execute(service)
119
120
121 # =====================================================================
122 # = Helpers. These are called by other functions rather than directly =
123 # =====================================================================
124
125 class Service(Task):
126     def upload_sample(self):
127         pass
128
129 class DebianGunicorn(Service):
130     def __init__(self, name):
131         super(Task, self).__init__()
132         self.name = name
133
134     def run(self):
135         print '>>> restart webserver using gunicorn-debian'
136         sudo('gunicorn-debian restart %s' % self.name, shell=False)
137
138     def upload_sample(self):
139         upload_sample('gunicorn', additional_context = dict(django_root_path = get_django_root_path(env['release'])))
140
141 class Apache(Service):
142     def run(self):
143         print '>>> restart webserver by touching WSGI'
144         with path('/sbin'):
145             run('touch %(app_path)s/%(project_name)s/wsgi.py' % env)
146
147 class Supervisord(Service):
148     def __init__(self, name):
149         super(Task, self).__init__()
150         self.name = name
151
152     def run(self):
153         print '>>> supervisord: restart %s' % self.name
154         sudo('supervisorctl restart %s' % self.name, shell=False)
155
156 class Command(Task):
157     def __init__(self, commands, working_dir):
158         if not hasattr(commands, '__iter__'):
159             commands = [commands]
160         self.name = 'Command: %s @ %s' % (commands, working_dir)
161         self.commands = commands
162         self.working_dir = working_dir
163
164     def run(self):
165         require('app_path')
166         with cd(join('%(app_path)s/releases/current' % env, self.working_dir)):
167             for command in self.commands:
168                 run(command)
169
170 def upload_samples():
171     upload_localsettings_sample()
172     upload_nginx_sample()
173     for service in env.services:
174         service.upload_sample()
175
176 def upload_sample(name, where="samples/", additional_context=None):
177     require('app_path', 'project_name')
178     upload_path = '%s/%s%s.sample' % (env['app_path'], where, name)
179     if files.exists(upload_path):
180         return
181     print '>>> upload %s template' % name
182     template = '%(project_name)s/' % env + name + '.template'
183     if not exists(template):
184         template = join(dirname(abspath(__file__)), 'templates/' + name + '.template')
185     template_context = additional_context or dict()
186     template_context.update(env)
187     files.upload_template(template, upload_path, template_context)
188
189 def upload_localsettings_sample():
190     "Fill out localsettings template and upload as a sample."
191     env.secret_key = get_random_string(50)
192     upload_sample('localsettings.py', where="")
193
194 upload_nginx_sample = lambda: upload_sample('nginx')
195
196 def upload_tar_from_git():
197     "Create an archive from the current Git branch and upload it"
198     print '>>> upload tar from git'
199     require('release', provided_by=[deploy])
200     require('app_path')
201     local('git-archive-all.sh --format tar %(release)s.tar' % env)
202     local('gzip %(release)s.tar' % env)
203     run('mkdir -p %(app_path)s/releases/%(release)s' % env, pty=True)
204     run('mkdir -p %(app_path)s/packages' % env, pty=True)
205     put('%(release)s.tar.gz' % env, '%(app_path)s/packages/' % env)
206     run('cd %(app_path)s/releases/%(release)s && tar zxf ../../packages/%(release)s.tar.gz' % env, pty=True)
207     local('rm %(release)s.tar.gz' % env)
208
209 def install_requirements():
210     "Install the required packages from the requirements file using pip"
211     print '>>> install requirements'
212     require('release', provided_by=[deploy])
213     require('app_path')
214     if not files.exists('%(app_path)s/ve' % env):
215         require('virtualenv')
216         run('%(virtualenv)s %(app_path)s/ve' % env, pty=True)
217     with cd('%(app_path)s/releases/%(release)s' % env):
218         run('%(app_path)s/ve/bin/pip install -r requirements.txt' % env, pty=True)
219     with cd(get_django_root_path(env['release'])):
220         # Install DB requirement
221         database_reqs = {
222             'django.db.backends.postgresql_psycopg2': 'psycopg2',
223             'django.db.backends.mysql': 'MySQL-python',
224         }
225         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)
226         for database in databases.split():
227             if database in database_reqs:
228                 # TODO: set pip default pypi
229                 run('%(app_path)s/ve/bin/pip install ' % env + database_reqs[database])
230
231
232 def copy_localsettings():
233     "Copy localsettings.py from root directory to release directory (if this file exists)"
234     print ">>> copy localsettings"
235     require('release', provided_by=[deploy])
236     require('app_path', 'project_name')
237
238     with settings(warn_only=True):
239         copy_to = join(get_django_root_path(env['release']), env.get('localsettings_dst_path', env['project_name']))
240         run('cp %(app_path)s/localsettings.py ' % env + copy_to)
241
242 def symlink_current_release():
243     "Symlink our current release"
244     print '>>> symlink current release'
245     require('release', provided_by=[deploy])
246     require('app_path')
247     with cd(env.app_path):
248         run('rm releases/previous; mv releases/current releases/previous')
249         run('ln -s %(release)s releases/current' % env)
250
251 def migrate():
252     "Update the database"
253     print '>>> migrate'
254     require('app_path', 'project_name')
255     with cd(get_django_root_path('current')):
256         run('%(app_path)s/ve/bin/python manage.py syncdb --noinput' % env, pty=True)
257         run('%(app_path)s/ve/bin/python manage.py migrate' % env, pty=True)
258
259 def pre_collectstatic():
260     print '>>> pre_collectstatic'
261     for task in env.get('pre_collectstatic', []):
262         execute(task)
263
264 def collectstatic():
265     """Collect static files"""
266     print '>>> collectstatic'
267     if env.get('skip_collect_static', False):
268         print '... skipped'
269         return
270     require('app_path', 'project_name')
271     with cd(get_django_root_path('current')):
272         run('%(app_path)s/ve/bin/python manage.py collectstatic --noinput' % env, pty=True)
273
274
275 def get_django_root_path(release):
276     require('app_path')
277     path = '%(app_path)s/releases/%(release)s' % dict(app_path = env['app_path'], release = release)
278     if 'django_root_path' in env:
279         path = join(path, env['django_root_path'])
280     return path