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