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