5b61df10d86eecc1de20cdefc4dc0d2baa16112b
[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 os.path import abspath, dirname, exists, join
22 from django.utils.crypto import get_random_string
23 from fabric.api import *
24 from fabric.contrib import files
25 from fabric.tasks import Task, execute
26
27 env.virtualenv = '/usr/bin/virtualenv'
28 env.services = None
29
30
31 @task
32 def setup():
33     """
34     Setup all needed directories.
35     """
36     require('hosts', 'app_path')
37
38     if not files.exists(env.app_path):
39         run('mkdir -p %(app_path)s' % env, pty=True)
40     with cd(env.app_path):
41         for subdir in 'releases', 'packages', 'log', 'samples':
42             if not files.exists(subdir):
43                 run('mkdir -p %s' % subdir, pty=True)
44     with cd('%(app_path)s/releases' % env):
45         if not files.exists('current'):
46             run('ln -sfT . current', pty=True)
47         if not files.exists('previous'):
48             run('ln -sfT . previous', pty=True)
49     
50     upload_samples()
51
52
53 def check_localsettings():
54     return files.exists('%(app_path)s/localsettings.py' % env)
55
56
57 @task(default=True)
58 def deploy():
59     """
60     Deploy the latest version of the site to the servers,
61     install any required third party modules,
62     install the virtual host and then restart the webserver
63     """
64     require('hosts', 'app_path')
65
66     import time
67     env.release = time.strftime('%Y-%m-%dT%H%M')
68
69     setup()
70     if not check_localsettings():
71         abort('Setup is complete, but\n    %(app_path)s/localsettings.py\n'
72               'is needed for actual deployment.' % env)
73     upload_tar_from_git()
74     copy_localsettings()
75     install_requirements()
76     symlink_current_release()
77     migrate()
78     pre_collectstatic()
79     collectstatic()
80     restart()
81
82 @task
83 def rollback():
84     """
85     Limited rollback capability. Simple loads the previously current
86     version of the code. Rolling back again will swap between the two.
87     Warning: this will almost certainly go wrong, it there were any migrations
88     in the meantime!
89     """
90     require('hosts', 'app_path')
91     with cd(env.path):
92         run('mv releases/current releases/_previous;', pty=True)
93         run('mv releases/previous releases/current;', pty=True)
94         run('mv releases/_previous releases/previous;', pty=True)
95     collectstatic()
96     restart()
97
98 @task
99 def deploy_version(version):
100     """
101     Loads the specified version.
102     Warning: this will almost certainly go wrong, it there were any migrations
103     in the meantime!
104     """
105     "Specify a specific version to be made live"
106     require('hosts', 'app_path')
107     env.version = version
108     with cd(env.path):
109         run('rm releases/previous; mv releases/current releases/previous;', pty=True)
110         run('ln -s %(version)s releases/current' % env, pty=True)
111     collectstatic()
112     restart()
113
114 @task
115 def restart():
116     require('services')
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')
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/"):
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     files.upload_template(template, upload_path, env)
186
187 def upload_localsettings_sample():
188     "Fill out localsettings template and upload as a sample."
189     env.secret_key = get_random_string(50)
190     upload_sample('localsettings.py', where="")
191
192 upload_nginx_sample = lambda: upload_sample('nginx')
193
194 def upload_tar_from_git():
195     "Create an archive from the current Git branch and upload it"
196     print '>>> upload tar from git'
197     require('release', provided_by=[deploy])
198     require('app_path')
199     local('git-archive-all.sh --format tar %(release)s.tar' % env)
200     local('gzip %(release)s.tar' % env)
201     run('mkdir -p %(app_path)s/releases/%(release)s' % env, pty=True)
202     run('mkdir -p %(app_path)s/packages' % env, pty=True)
203     put('%(release)s.tar.gz' % env, '%(app_path)s/packages/' % env)
204     run('cd %(app_path)s/releases/%(release)s && tar zxf ../../packages/%(release)s.tar.gz' % env, pty=True)
205     local('rm %(release)s.tar.gz' % env)
206
207 def install_requirements():
208     "Install the required packages from the requirements file using pip"
209     print '>>> install requirements'
210     require('release', provided_by=[deploy])
211     require('app_path')
212     if not files.exists('%(app_path)s/ve' % env):
213         require('virtualenv')
214         run('%(virtualenv)s %(app_path)s/ve' % env, pty=True)
215     with cd('%(app_path)s/releases/%(release)s' % env):
216         run('%(app_path)s/ve/bin/pip install -r requirements.txt' % env, pty=True)
217     with cd(get_django_root_path(env['release'])):
218         # Install DB requirement
219         database_reqs = {
220             'django.db.backends.postgresql_psycopg2': 'psycopg2',
221             'django.db.backends.mysql': 'MySQL-python',
222         }
223         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)
224         for database in databases.split():
225             if database in database_reqs:
226                 # TODO: set pip default pypi
227                 run('%(app_path)s/ve/bin/pip install ' % env + database_reqs[database])
228
229
230 def copy_localsettings():
231     "Copy localsettings.py from root directory to release directory (if this file exists)"
232     print ">>> copy localsettings"
233     require('release', provided_by=[deploy])
234     require('app_path', 'project_name')
235
236     with settings(warn_only=True):
237         copy_to = join(get_django_root_path(env['release']), env.get('localsettings_dst_path', env['project_name']))
238         run('cp %(app_path)s/localsettings.py ' % env + copy_to)
239
240 def symlink_current_release():
241     "Symlink our current release"
242     print '>>> symlink current release'
243     require('release', provided_by=[deploy])
244     require('app_path')
245     with cd(env.app_path):
246         run('rm releases/previous; mv releases/current releases/previous')
247         run('ln -s %(release)s releases/current' % env)
248
249 def migrate():
250     "Update the database"
251     print '>>> migrate'
252     require('app_path', 'project_name')
253     with cd(get_django_root_path('current')):
254         run('%(app_path)s/ve/bin/python manage.py syncdb --noinput' % env, pty=True)
255         run('%(app_path)s/ve/bin/python manage.py migrate' % env, pty=True)
256
257 def pre_collectstatic():
258     print '>>> pre_collectstatic'
259     for task in env.get('pre_collectstatic', []):
260         execute(task)
261
262 def collectstatic():
263     """Collect static files"""
264     print '>>> collectstatic'
265     if env.get('skip_collect_static', False):
266         print '... skipped'
267         return
268     require('app_path', 'project_name')
269     with cd(get_django_root_path('current')):
270         run('%(app_path)s/ve/bin/python manage.py collectstatic --noinput' % env, pty=True)
271
272
273 def get_django_root_path(release):
274     require('app_path')
275     path = '%(app_path)s/releases/%(release)s' % dict(app_path = env['app_path'], release = release)
276     if 'django_root_path' in env:
277         path = join(path, env['django_root_path'])
278     return path