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