Support for remote Py3
[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         # HERE: maybe venv?
228         require('virtualenv')
229         run('%(virtualenv)s %(app_path)s/ve' % env, pty=True)
230     with cd('%(app_path)s/releases/%(release)s' % env):
231         run('%(app_path)s/ve/bin/pip install -r requirements.txt' % env, pty=True)
232     with cd(get_django_root_path(env['release'])):
233         # Install DB requirement
234         database_reqs = {
235             'django.db.backends.postgresql_psycopg2': 'psycopg2',
236             'django.db.backends.mysql': 'MySQL-python',
237         }
238         databases = run(
239             'DJANGO_SETTINGS_MODULE=%(project_name)s.settings '
240             '%(app_path)s/ve/bin/python -c \''
241             'from django.conf import settings;'
242             'print(" ".join(set([d["ENGINE"] for d in settings.DATABASES.values()])))\'' % env)
243         for database in databases.split():
244             if database in database_reqs:
245                 # TODO: set pip default pypi
246                 run('%(app_path)s/ve/bin/pip install ' % env + database_reqs[database])
247
248
249 def copy_localsettings():
250     "Copy localsettings.py from root directory to release directory (if this file exists)"
251     print ">>> copy localsettings"
252     require('release', provided_by=[deploy])
253     require('app_path', 'project_name')
254
255     with settings(warn_only=True):
256         copy_to = join(get_django_root_path(env['release']), env.get('localsettings_dst_path', env['project_name']))
257         run('cp %(app_path)s/localsettings.py ' % env + copy_to)
258
259 def symlink_current_release():
260     "Symlink our current release"
261     print '>>> symlink current release'
262     require('release', provided_by=[deploy])
263     require('app_path')
264     with cd(env.app_path):
265         run('rm releases/previous; mv releases/current releases/previous')
266         run('ln -s %(release)s releases/current' % env)
267
268 def migrate():
269     "Update the database"
270     print '>>> migrate'
271     require('app_path', 'project_name')
272     with cd(get_django_root_path('current')):
273         run('%(app_path)s/ve/bin/python manage.py syncdb --noinput' % env, pty=True)
274         for app, migration in env.get('migrate_fake', ()):
275             run('%s/ve/bin/python manage.py migrate %s --fake %s' % (env.app_path, app, migration), pty=True)
276         run('%(app_path)s/ve/bin/python manage.py migrate' % env, pty=True)
277
278 def pre_collectstatic():
279     print '>>> pre_collectstatic'
280     for task in env.get('pre_collectstatic', []):
281         execute(task)
282
283 def collectstatic():
284     """Collect static files"""
285     print '>>> collectstatic'
286     if env.get('skip_collect_static', False):
287         print '... skipped'
288         return
289     require('app_path', 'project_name')
290     with cd(get_django_root_path('current')):
291         run('%(app_path)s/ve/bin/python manage.py collectstatic --noinput' % env, pty=True)
292
293
294 def get_django_root_path(release):
295     require('app_path')
296     path = '%(app_path)s/releases/%(release)s' % dict(app_path = env['app_path'], release = release)
297     if 'django_root_path' in env:
298         path = join(path, env['django_root_path'])
299     return path