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