├── opsfab ├── __init__.py ├── defaults.py ├── environments.py └── types.py ├── buedafab ├── deploy │ ├── __init__.py │ ├── cron.py │ ├── utils.py │ ├── packages.py │ ├── types.py │ └── release.py ├── django │ ├── __init__.py │ └── management.py ├── __init__.py ├── files │ └── ssh_config ├── celery.py ├── testing.py ├── utils.py ├── db.py ├── defaults.py ├── notify.py ├── aws.py ├── environments.py ├── tasks.py └── operations.py ├── pip-requirements.txt ├── fab_shared.py ├── LICENSE ├── fabfile.py ├── example ├── fabfile.py └── README.md └── README.md /opsfab/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /buedafab/deploy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /buedafab/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /buedafab/__init__.py: -------------------------------------------------------------------------------- 1 | import buedafab.defaults 2 | -------------------------------------------------------------------------------- /buedafab/files/ssh_config: -------------------------------------------------------------------------------- 1 | Host github.com 2 | StrictHostKeyChecking no 3 | -------------------------------------------------------------------------------- /pip-requirements.txt: -------------------------------------------------------------------------------- 1 | fabric==1.0.1 2 | boto==2.0b4 3 | prettyprint==0.1.3 4 | hoppy==0.1.2 5 | -e git://github.com/rhymes/pinder.git@0a34823#egg=pinder 6 | -------------------------------------------------------------------------------- /opsfab/defaults.py: -------------------------------------------------------------------------------- 1 | """ 2 | Environment defaults for ops deployment fabfile. 3 | """ 4 | #!/usr/bin/env python 5 | from fabric.api import env 6 | 7 | env.unit = "chef" 8 | env.scm = "git@github.com:bueda/chef" 9 | 10 | env.security_groups = ["temporary", "ssh"] 11 | env.key_name = "temporary" 12 | env.region = 'us-east-1b' 13 | env.chef_roles = ["base"] 14 | -------------------------------------------------------------------------------- /buedafab/django/management.py: -------------------------------------------------------------------------------- 1 | from fabric.api import require, prefix, env 2 | from fabric.decorators import runs_once 3 | 4 | from buedafab.operations import virtualenv_run 5 | from buedafab.utils import absolute_release_path 6 | 7 | def django_manage_run(cmd): 8 | require('deployment_type') 9 | with prefix("export DEPLOYMENT_TYPE='%(deployment_type)s'" % env): 10 | virtualenv_run("./manage.py %s" % cmd, env.release_path) 11 | 12 | @runs_once 13 | def shell(): 14 | env.release_path = absolute_release_path() 15 | django_manage_run('shell') 16 | -------------------------------------------------------------------------------- /buedafab/deploy/cron.py: -------------------------------------------------------------------------------- 1 | """Utilities to manage crontabs on a remote server. 2 | 3 | These aren't used at Bueda anymore, since migrating to celery's scheduled tasks. 4 | """ 5 | from fabric.operations import sudo 6 | import os 7 | 8 | from buedafab.operations import exists 9 | 10 | def conditional_install_crontab(base_path, crontab, user): 11 | """If the project specifies a crontab, install it for the specified user on 12 | the remote server. 13 | """ 14 | if crontab: 15 | crontab_path = os.path.join(base_path, crontab) 16 | if crontab and exists(crontab_path): 17 | sudo('crontab -u %s %s' % (user, crontab_path)) 18 | 19 | -------------------------------------------------------------------------------- /fab_shared.py: -------------------------------------------------------------------------------- 1 | """ 2 | Included for legacy support of fabfiles depending on a one-file fab_shared. 3 | """ 4 | import buedafab 5 | from buedafab.aws import * 6 | from buedafab.celery import * 7 | from buedafab.tasks import * 8 | from buedafab.db import * 9 | from buedafab.environments import * 10 | from buedafab.notify import * 11 | from buedafab.operations import * 12 | from buedafab.testing import * 13 | from buedafab.utils import * 14 | 15 | import buedafab.deploy 16 | from buedafab.deploy.cron import * 17 | from buedafab.deploy.packages import * 18 | from buedafab.deploy.release import * 19 | from buedafab.deploy.types import * 20 | from buedafab.deploy.utils import * 21 | -------------------------------------------------------------------------------- /opsfab/environments.py: -------------------------------------------------------------------------------- 1 | """ 2 | Definitions of available server environments. 3 | """ 4 | #!/usr/bin/env python 5 | from fabric.api import env 6 | 7 | from fab_shared import (development as shared_development, 8 | production as shared_production) 9 | 10 | def development(): 11 | """ Sets roles for development server. """ 12 | shared_development() 13 | env.security_groups = ["development", "ssh"] 14 | env.key_name = "development" 15 | env.chef_roles = ["dev"] 16 | 17 | def production(): 18 | """ Sets roles for production servers behind load balancer. """ 19 | shared_production() 20 | env.security_groups = ["ssh", "database-client"] 21 | env.key_name = "production" 22 | env.chef_roles = ["production"] 23 | 24 | def web(): 25 | production() 26 | env.chef_roles.append("app_server") 27 | env.security_groups.extend(["web"]) 28 | 29 | def support(): 30 | production() 31 | env.chef_roles.append("support_server") 32 | env.security_groups.extend(["support"]) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Christopher Peplin and Bueda, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /buedafab/deploy/utils.py: -------------------------------------------------------------------------------- 1 | """General deployment utilities (not Fabric commands).""" 2 | from fabric.api import cd, require, local, env 3 | 4 | from buedafab import deploy 5 | 6 | def make_archive(): 7 | """Create a compressed archive of the project's repository, complete with 8 | submodules. 9 | 10 | TODO We used to used git-archive-all to archive the submodules as well, 11 | since 'git archive' doesn't touch them. We reverted back at some point and 12 | stopped using archives in our deployment strategy, so this may not work with 13 | submodules. 14 | """ 15 | require('release') 16 | require('scratch_path') 17 | with cd(env.scratch_path): 18 | deploy.release.make_pretty_release() 19 | local('git checkout %(release)s' % env, capture=True) 20 | local('git submodule update --init', capture=True) 21 | local('git archive --prefix=%(unit)s/ --format tar ' 22 | '%(release)s | gzip > %(scratch_path)s/%(archive)s' % env, 23 | capture=True) 24 | 25 | def run_extra_deploy_tasks(deployed=False): 26 | """Run arbitrary functions listed in env.package_installation_scripts. 27 | 28 | Each function must accept a single parameter (or just kwargs) that will 29 | indicates if the app was deployed or already existed. 30 | 31 | """ 32 | require('release_path') 33 | if not env.extra_deploy_tasks: 34 | return 35 | 36 | with cd(env.release_path): 37 | for task in env.extra_deploy_tasks: 38 | task(deployed=deployed) 39 | -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fabfile for deploying instances with Chef to EC2. 3 | """ 4 | #!/usr/bin/env python 5 | import os 6 | import time 7 | from fabric.api import env, require, runs_once 8 | 9 | import opsfab.defaults 10 | from opsfab.types import * 11 | from opsfab.environments import * 12 | from fab_shared import local, put, sudo, rechef, setup 13 | 14 | env.root_dir = os.path.abspath(os.path.dirname(__file__)) 15 | env.pip_requirements = ["pip-requirements.txt",] 16 | 17 | @runs_once 18 | def spawn(ami=None, region=None, chef_roles=None): 19 | """ Create a new server instance, which will bootstrap itself with Chef. """ 20 | require('ami', provided_by=[small, large, extra_large, extra_large_mem, 21 | double_extra_large_mem, quadruple_extra_large_mem, medium_cpu, 22 | extra_large_cpu]) 23 | require('instance_type') 24 | require('region') 25 | require('security_groups') 26 | require('key_name') 27 | require('ec2_connection') 28 | 29 | env.ami = ami or env.ami 30 | env.region = region or env.region 31 | 32 | role_string = "" 33 | if chef_roles: 34 | env.chef_roles.extend(chef_roles.split('-')) 35 | for role in env.chef_roles: 36 | role_string += "role[%s] " % role 37 | 38 | local('ssh-add ~/.ssh/%(key_name)s.pem' % env) 39 | 40 | command = 'knife ec2 server create %s ' % role_string 41 | command += '-Z %(region)s ' % env 42 | command += '-f %(instance_type)s -i %(ami)s ' % env 43 | command += '-G %s ' % ','.join(env.security_groups) 44 | command += '-S %(key_name)s ' % env 45 | command += '-x ubuntu ' 46 | 47 | print "Run this command to spawn the server:\n" 48 | print command 49 | -------------------------------------------------------------------------------- /opsfab/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Definitions of available EC2 server types. 3 | """ 4 | #!/usr/bin/env python 5 | from fabric.api import env 6 | 7 | def _32bit(): 8 | """ Ubuntu Maverick 10.10 32-bit """ 9 | env.ami = "ami-a6f504cf" 10 | 11 | def _32bit_ebs(): 12 | """ Ubuntu Maverick 10.10 32-bit """ 13 | env.ami = "ami-ccf405a5" 14 | 15 | def _64bit_ebs(): 16 | """ Ubuntu Maverick 10.10 64-bit """ 17 | env.ami = "ami-cef405a7" 18 | 19 | def _64bit(): 20 | """ Ubuntu Maverick 10.10 64-bit """ 21 | env.ami = "ami-08f40561" 22 | 23 | def micro(): 24 | """ Micro instance, 613MB, up to 2 CPU (64-bit) """ 25 | _64bit_ebs() 26 | env.instance_type = 't1.micro' 27 | 28 | def small(): 29 | """ Small Instance, 1.7GB, 1 CPU (32-bit) """ 30 | _32bit() 31 | env.instance_type = 'm1.small' 32 | 33 | def large(): 34 | """ Large Instance, 7.5GB, 4 CPU (64-bit) """ 35 | _64bit() 36 | env.instance_type = 'm1.large' 37 | 38 | def extra_large(): 39 | """ Extra Large Instance, 16GB, 8 CPU (64-bit) """ 40 | _64bit() 41 | env.instance_type = 'm1.xlarge' 42 | 43 | def extra_large_mem(): 44 | """ High-Memory Extra Large Instance, 17.1GB, 6.5 CPU (64-bit) """ 45 | _64bit() 46 | env.instance_type = 'm2.xlarge' 47 | 48 | def double_extra_large_mem(): 49 | """ High-Memory Double Extra Large Instance, 34.2GB, 13 CPU (64-bit) """ 50 | _64bit() 51 | env.instance_type = 'm2.2xlarge' 52 | 53 | def quadruple_extra_large_mem(): 54 | """ High-Memory Quadruple Extra Large Instance, 68.4GB, 26 CPU (64-bit) """ 55 | _64bit() 56 | env.instance_type = 'm2.4xlarge' 57 | 58 | def medium_cpu(): 59 | """ High-CPU Medium Instance, 1.7GB, 5 CPU (32-bit) """ 60 | _32bit() 61 | env.instance_type = 'c1.medium' 62 | 63 | def extra_large_cpu(): 64 | """ High-CPU Extra Large Instance, 7GB, 20 CPU (64-bit) """ 65 | _64bit() 66 | env.instance_type = 'c1.xlarge' 67 | 68 | -------------------------------------------------------------------------------- /buedafab/celery.py: -------------------------------------------------------------------------------- 1 | """Utilities for configuring and managing celeryd processes on a remote 2 | server. 3 | """ 4 | from fabric.api import require, env 5 | from fabric.contrib.files import upload_template 6 | import os 7 | 8 | from buedafab.operations import chmod, sudo 9 | 10 | def update_and_restart_celery(): 11 | """Render a celeryd init.d script template and upload it to the remote 12 | server, then restart the celeryd process to reload the configuration. 13 | 14 | In addition to any env keys required by the celeryd template, requires: 15 | 16 | celeryd -- relateive path to the celeryd init.d script template from the 17 | project root 18 | unit -- project's brief name, used to give each celeryd script and 19 | process a unique name, if more than one are running on the same 20 | host 21 | deployment_type -- app environment, to differentiate between celeryd 22 | processes for the same app in different environments on the 23 | same host (e.g. if staging and development run on the same 24 | physical server) 25 | 26 | The template is uploaded to: 27 | 28 | /etc/init.d/celeryd-%(unit)s_%(deployment_type)s 29 | 30 | which in final form might look like: 31 | 32 | /etc/init.d/celeryd-five_DEV 33 | """ 34 | 35 | require('path') 36 | require('celeryd') 37 | require('unit') 38 | require('deployment_type') 39 | if env.celeryd: 40 | celeryd_path = os.path.join(env.root_dir, env.celeryd) 41 | celeryd_remote_path = ( 42 | '/etc/init.d/celeryd-%(unit)s_%(deployment_type)s' % env) 43 | upload_template(celeryd_path, celeryd_remote_path, env, use_sudo=True) 44 | 45 | # Wipe the -B option so it only happens once 46 | env.celeryd_beat_option = "" 47 | 48 | chmod(celeryd_remote_path, 'u+x') 49 | sudo(celeryd_remote_path + ' restart') 50 | -------------------------------------------------------------------------------- /buedafab/testing.py: -------------------------------------------------------------------------------- 1 | """Code style and unit testing utilities.""" 2 | from fabric.api import env, require, cd, runs_once, local, settings 3 | import os 4 | 5 | @runs_once 6 | def lint(): 7 | """Run pylint on the project, including the packages in `apps/`, `lib/` and 8 | `vendor/`, and using the `.pylintrc` file in the project's root. 9 | 10 | Requires the env keys: 11 | root_dir - root of the project, where the fabfile resides 12 | """ 13 | require('root_dir') 14 | env.python_path_extensions = '%(root_dir)s/lib:%(root_dir)s/apps' % env 15 | for directory in os.listdir(os.path.join(env.root_dir, 'vendor')): 16 | full_path = os.path.join(env.root_dir, 'vendor', directory) 17 | if os.path.isdir(full_path): 18 | env.python_path_extensions += ':' + full_path 19 | with cd(env.root_dir): 20 | local('PYTHONPATH=$PYTHONPATH:%(python_path_extensions)s ' 21 | 'pylint %(root_dir)s --rcfile=.pylintrc 2>/dev/null' % env) 22 | 23 | @runs_once 24 | def test(dir=None, deployment_type=None): 25 | """Run the test suite for this project. There are current test runners defined for 26 | Django, Tornado, and general nosetests suites. Just set `env.test_runner` to the 27 | appropriate method (or write your own). 28 | 29 | Requires the env keys: 30 | root_dir - root of the project, where the fabfile resides 31 | test_runner - a function expecting the deployment_type as a parameter 32 | that runs the test suite for this project 33 | """ 34 | require('root_dir') 35 | require('test_runner') 36 | with settings(root_dir=(dir or env.root_dir), warn_only=True): 37 | return env.test_runner(deployment_type) 38 | 39 | @runs_once 40 | def nose_test_runner(deployment_type=None): 41 | """Basic nosetests suite runner.""" 42 | return local('nosetests').return_code 43 | 44 | @runs_once 45 | def webpy_test_runner(deployment_type=None): 46 | # TODO 47 | #import manage 48 | #import nose 49 | #return nose.run() 50 | pass 51 | 52 | @runs_once 53 | def tornado_test_runner(deployment_type=None): 54 | """Tornado test suite runner - depends on using Bueda's tornado-boilerplate 55 | app layout.""" 56 | return local('tests/run_tests.py').return_code 57 | 58 | @runs_once 59 | def django_test_runner(deployment_type=None): 60 | """Django test suite runer.""" 61 | command = './manage.py test' 62 | if deployment_type: 63 | command = 'DEPLOYMENT_TYPE=%s ' % deployment_type + command 64 | return local(command).return_code 65 | -------------------------------------------------------------------------------- /buedafab/utils.py: -------------------------------------------------------------------------------- 1 | """Lower-level utilities, including some git helpers.""" 2 | from fabric.api import env, local, require, settings 3 | from fabric.colors import green 4 | import os 5 | 6 | def compare_versions(x, y): 7 | """ 8 | Expects 2 strings in the format of 'X.Y.Z' where X, Y and Z are 9 | integers. It will compare the items which will organize things 10 | properly by their major, minor and bugfix version. 11 | :: 12 | 13 | >>> my_list = ['v1.13', 'v1.14.2', 'v1.14.1', 'v1.9', 'v1.1'] 14 | >>> sorted(my_list, cmp=compare_versions) 15 | ['v1.1', 'v1.9', 'v1.13', 'v1.14.1', 'v1.14.2'] 16 | 17 | """ 18 | def version_to_tuple(version): 19 | # Trim off the leading v 20 | version_list = version[1:].split('.', 2) 21 | if len(version_list) <= 3: 22 | [version_list.append(0) for _ in range(3 - len(version_list))] 23 | try: 24 | return tuple((int(version) for version in version_list)) 25 | except ValueError: # not an integer, so it goes to the bottom 26 | return (0, 0, 0) 27 | 28 | x_major, x_minor, x_bugfix = version_to_tuple(x) 29 | y_major, y_minor, y_bugfix = version_to_tuple(y) 30 | return (cmp(x_major, y_major) or cmp(x_minor, y_minor) 31 | or cmp(x_bugfix, y_bugfix)) 32 | 33 | def store_deployed_version(): 34 | if env.sha_url_template: 35 | env.deployed_version = None 36 | with settings(warn_only=True): 37 | env.deployed_version = local('curl -s %s' % sha_url(), capture=True 38 | ).strip('"') 39 | if env.deployed_version and len(env.deployment_type) > 10: 40 | env.deployed_version = None 41 | else: 42 | print(green("The currently deployed version is %(deployed_version)s" 43 | % env)) 44 | 45 | def sha_url(): 46 | require('sha_url_template') 47 | if env.deployment_type == 'PRODUCTION': 48 | subdomain = 'www.' 49 | else: 50 | subdomain = env.deployment_type.lower() + '.' 51 | return env.sha_url_template % subdomain 52 | 53 | def absolute_release_path(): 54 | require('path') 55 | require('current_release_path') 56 | return os.path.join(env.path, env.current_release_path) 57 | 58 | def branch(ref=None): 59 | """Return the name of the current git branch.""" 60 | ref = ref or "HEAD" 61 | return local("git symbolic-ref %s 2>/dev/null | awk -F/ {'print $NF'}" 62 | % ref, capture=True) 63 | 64 | def sha_for_file(input_file, block_size=2**20): 65 | import hashlib 66 | sha = hashlib.sha256() 67 | with open(input_file, 'rb') as f: 68 | for chunk in iter(lambda: f.read(block_size), ''): 69 | sha.update(chunk) 70 | return sha.hexdigest() 71 | -------------------------------------------------------------------------------- /buedafab/db.py: -------------------------------------------------------------------------------- 1 | """Utilities for updating schema and loading data into a database (all Django 2 | specific at the moment. 3 | """ 4 | from fabric.api import require, env 5 | from fabric.contrib.console import confirm 6 | from fabric.decorators import runs_once 7 | from fabric.colors import yellow 8 | 9 | from buedafab.django.management import django_manage_run 10 | 11 | @runs_once 12 | def load_data(): 13 | """Load extra fixtures into the database. 14 | 15 | Requires the env keys: 16 | 17 | release_path -- remote path of the deployed app 18 | deployment_type -- app environment to set before loading the data (i.e. 19 | which database should it be loaded into) 20 | virtualenv -- path to this app's virtualenv (required to grab the 21 | correct Python executable) 22 | extra_fixtures -- a list of names of fixtures to load (empty by default) 23 | """ 24 | require('release_path') 25 | require('deployment_type') 26 | require('virtualenv') 27 | if env.migrated or env.updated_db: 28 | for fixture in env.extra_fixtures: 29 | django_manage_run("loaddata %s" % fixture) 30 | 31 | @runs_once 32 | def migrate(deployed=False): 33 | """Migrate the database to the currently deployed version using South. If 34 | the app wasn't deployed (e.g. we are redeploying the same version for some 35 | reason, this command will prompt the user to confirm that they want to 36 | migrate. 37 | 38 | Requires the env keys: 39 | 40 | release_path -- remote path of the deployed app 41 | deployment_type -- app environment to set before loading the data (i.e. 42 | which database should it be loaded into) 43 | virtualenv -- path to this app's virtualenv (required to grab the 44 | correct Python executable) 45 | """ 46 | require('release_path') 47 | require('deployment_type') 48 | require('virtualenv') 49 | if (env.migrate and 50 | (deployed or confirm(yellow("Migrate database?"), default=True))): 51 | django_manage_run("migrate") 52 | env.migrated = True 53 | 54 | @runs_once 55 | def update_db(deployed=False): 56 | """Update the database to the currently deployed version using syncdb. If 57 | the app wasn't deployed (e.g. we are redeploying the same version for some 58 | reason, this command will prompt the user to confirm that they want to 59 | update. 60 | 61 | Requires the env keys: 62 | 63 | release_path -- remote path of the deployed app 64 | deployment_type -- app environment to set before loading the data (i.e. 65 | which database should it be loaded into) 66 | virtualenv -- path to this app's virtualenv (required to grab the 67 | correct Python executable) 68 | """ 69 | require('deployment_type') 70 | require('virtualenv') 71 | require('release_path') 72 | if deployed or confirm(yellow("Update database?"), default=True): 73 | django_manage_run("syncdb --noinput") 74 | env.updated_db = True 75 | -------------------------------------------------------------------------------- /example/fabfile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from fabric.api import * 4 | 5 | from buedafab.test import test, django_test_runner as _django_test_runner, lint 6 | from buedafab.deploy.types import django_deploy as deploy 7 | from buedafab.environments import (django_development as development, 8 | django_production as production, django_localhost as localhost, 9 | django_staging as staging) 10 | from buedafab.tasks import (setup, restart_webserver, rollback, enable, 11 | disable, maintenancemode, rechef) 12 | 13 | # A short name for the app, used in folder names 14 | env.unit = "five" 15 | 16 | # Deploy target on remote server 17 | env.path = "/var/webapps/%(unit)s" % env 18 | 19 | # git-compatible path to remote repository 20 | env.scm = "git@github.com:bueda/%(unit)s.git" % env 21 | 22 | # HTTP-compatible path to the remote repository 23 | # This is optional, and is used only to link Hoptoad deploys to source code 24 | env.scm_http_url = "http://github.com/bueda/%(unit)s" % env 25 | 26 | # The root directory of the project (where this fabfile.py resides) 27 | env.root_dir = os.path.abspath(os.path.dirname(__file__)) 28 | 29 | # Paths to Python package requirements files for pip 30 | # pip_requirements are installed in all environments 31 | env.pip_requirements = ["requirements/common.txt",] 32 | # pip_requirements_dev are installed only in the development environment 33 | env.pip_requirements_dev = ["requirements/dev.txt",] 34 | # pip_requirements_production are installed only in the production environment 35 | env.pip_requirements_production = ["requirements/production.txt",] 36 | 37 | # A Django-specific for projects using the South database migration app 38 | env.migrate = True 39 | 40 | # For projects using celery, the path to the system service script for celeryd 41 | env.celeryd = 'scripts/init.d/celeryd' 42 | 43 | # Name of the Amazon Elastic Load Balancer instance that sits in front of the 44 | # app servers for this project - this is used to find all of the production 45 | # servers for the app when re-deploying. 46 | env.load_balancer = 'web' 47 | 48 | # The test runner to use before deploying, and also when running 'fab test' 49 | # Test runners are defined for Django, Tornado, web.py and general nosetests 50 | # test suites. To define a custome test runner, just write a method and assign 51 | # it to env.test_runner. 52 | env.test_runner = _django_test_runner 53 | 54 | # URL that returns the current git commit SHA this app is running 55 | # This current must have a single string format parameter that is replaced by 56 | # "dev." or "staging." or "www." depending on the environment - kind of a weird, 57 | # strict requirement that should be re-worked. 58 | env.sha_url_template = 'http://%sfivebybueda.com/version/' 59 | 60 | # API key for the Hoptoad account associated with this project. Will report a 61 | # deployment to Hoptoad to help keep track of resolved errors. 62 | env.hoptoad_api_key = 'your-hoptoad-api-key' 63 | 64 | # Campfire chat room information - will notify whenever someone deploys the app 65 | env.campfire_subdomain = 'bueda' 66 | env.campfire_room = 'YourRoom' 67 | env.campfire_token = 'your-api-key' 68 | -------------------------------------------------------------------------------- /buedafab/defaults.py: -------------------------------------------------------------------------------- 1 | """Set sane default values for many of the keys required by buedafab's commands 2 | and utilities. Any of these can be overridden by setting a custom value in a 3 | project's fabfile that uses buedafab. 4 | """ 5 | from fabric.api import env, warn 6 | import datetime 7 | import os 8 | 9 | env.time_now = datetime.datetime.now().strftime("%H%M%S-%d%m%Y") 10 | env.version_pattern = r'^v\d+(\.\d+)+?$' 11 | env.pip_install_command = 'pip install -i http://d.pypi.python.org/simple' 12 | 13 | # Within the target directory on the remote server, subdirectory for the a/b 14 | # releases directory. 15 | env.releases_root = 'releases' 16 | 17 | # Name of the symlink to the current release 18 | env.current_release_symlink = 'current' 19 | env.current_release_path = os.path.join(env.releases_root, 20 | env.current_release_symlink) 21 | 22 | # Names of the directories to alternate between in the releases directory 23 | env.release_paths = ('a', 'b',) 24 | 25 | # Name of the virtualenv to create within each release directory 26 | env.virtualenv = 'env' 27 | 28 | # Default SSH port for all servers 29 | env.ssh_port = 1222 30 | 31 | # Default commit ID to deploy if none is specificed, e.g. fab development deploy 32 | env.default_revision = 'HEAD' 33 | 34 | # User and group that owns the deployed files - you probably want to change this 35 | env.deploy_user = 'deploy' 36 | env.deploy_group = 'bueda' 37 | 38 | env.master_remote = 'origin' 39 | env.settings = "settings.py" 40 | env.extra_fixtures = ["permissions"] 41 | 42 | # To avoid using hasattr(env, 'the_attr') everywhere, set some blank defaults 43 | env.private_requirements = [] 44 | env.package_installation_scripts = [] 45 | env.crontab = None 46 | env.updated_db = False 47 | env.migrated = False 48 | env.celeryd = None 49 | env.celeryd_beat_option = "-B" 50 | env.celeryd_options = "-E" 51 | env.hoptoad_api_key = None 52 | env.campfire_token = None 53 | env.sha_url_template = None 54 | env.deployed_version = None 55 | env.scm_url_template = None 56 | env.extra_deploy_tasks = [] 57 | env.extra_setup_tasks = [] 58 | 59 | # TODO open source the now deleted upload_to_s3 utils 60 | if 'AWS_ACCESS_KEY_ID' in os.environ and 'AWS_SECRET_ACCESS_KEY' in os.environ: 61 | try: 62 | import boto.ec2 63 | import boto.ec2.elb 64 | import boto.s3 65 | import boto.s3.connection 66 | import boto.s3.key 67 | except ImportError: 68 | warn('boto not installed -- required to use S3 or EC2. ' 69 | 'Try running "fab setup" from the root of the ops repo') 70 | else: 71 | env.aws_access_key = os.environ['AWS_ACCESS_KEY_ID'] 72 | env.aws_secret_key = os.environ['AWS_SECRET_ACCESS_KEY'] 73 | env.elb_connection = boto.ec2.elb.ELBConnection( 74 | env.aws_access_key, env.aws_secret_key) 75 | env.ec2_connection = boto.ec2.EC2Connection( 76 | env.aws_access_key, env.aws_secret_key) 77 | # TODO this recently became required as a workaround? 78 | env.ec2_connection.SignatureVersion = '1' 79 | _s3_connection = boto.s3.connection.S3Connection(env.aws_access_key, 80 | env.aws_secret_key) 81 | 82 | env.s3_bucket_name = 'bueda.deploy' 83 | _bucket = _s3_connection.get_bucket(env.s3_bucket_name) 84 | env.s3_key = boto.s3.connection.Key(_bucket) 85 | else: 86 | warn('No S3 key set. To use S3 or EC2 for deployment, ' 87 | 'you will need to set one -- ' 88 | 'see https://github.com/bueda/buedaweb/wikis/deployment-with-fabric') 89 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | buedafab Usage Example 2 | ============================ 3 | 4 | The example `fabfile.py` in this directory assumes that you are using the deploy 5 | directories & methodology laid out in buedafab's README. 6 | 7 | ## Usage 8 | 9 | example/$ fab -l 10 | Available commands: 11 | 12 | deploy 13 | development 14 | disable 15 | enable 16 | lint 17 | localhost 18 | production 19 | reset 20 | restart_webserver Restart the Gunicorn application webserver 21 | rollback Swaps the current and previous release that was depl... 22 | setup 23 | staging 24 | test 25 | 26 | $ fab deploy[:release=] 27 | 28 | A Fabric run must have one of `production`, `development` or `localhost` as the 29 | first parameter to specify which environment you would like the following 30 | commands to operate. For example, the `production` environment updates all 31 | servers behind the load balancer, while the `development` environment only 32 | updates dev.bueda.com. 33 | 34 | Tag a specific commit of djangoapp as a new release, then deploy to all 35 | production machines: 36 | 37 | ~/web$ fab production deploy:commit=6b4943c 38 | 39 | Deploy HEAD to the development machine without making a tagged release: 40 | 41 | ~/web$ fab development deploy 42 | 43 | Deploy a specific commit to the development machine without making a tagged 44 | release: 45 | 46 | ~/web$ fab development deploy:commit=6b4943c 47 | 48 | Deploy a tag that already exists to all production machines: 49 | 50 | ~/web$ fab production deploy:release=v0.1.1 51 | 52 | ## Server Provisioning 53 | 54 | The Fabfile in the [ops](http://github.com/bueda/ops) repository supports 55 | provisioning new servers in EC2 using Chef. You can import the methods it uses 56 | into your own `fabfile.py` as well. There are quite a few prerequisites not 57 | mentioned here, namely a working Chef setup, but this should be a good start. 58 | 59 | This requires gems, installed with rubygems. 60 | 61 | $ gem install chef fog net-ssh-multi 62 | 63 | ~/ops$ fab -l 64 | Available commands: 65 | deploy Deploy the shared fabfile. 66 | development Sets roles for development server. 67 | double_extra_large_mem High-Memory Double Extra Large Instance, 34.2... 68 | extra_large Extra Large Instance, 16GB, 8 CPU (64-bit) 69 | extra_large_cpu High-CPU Extra Large Instance, 7GB, 20 CPU (6... 70 | extra_large_mem High-Memory Extra Large Instance, 17.1GB, 6.5... 71 | large Large Instance, 7.5GB, 4 CPU (64-bit) 72 | medium_cpu High-CPU Medium Instance, 1.7GB, 5 CPU (32-bi... 73 | production Sets roles for production servers behind load... 74 | put 75 | quadruple_extra_large_mem High-Memory Quadruple Extra Large Instance, 6... 76 | rechef Run the latest Chef cookbooks on all servers. 77 | small Small Instance, 1.7GB, 1 CPU (32-bit) 78 | spawn Create a new server instance, which will boot... 79 | 80 | To create a test machine with your base config (so you can SSH in with your 81 | regular user credentials): 82 | 83 | $ fab spawn 84 | 85 | To create a production/development machine: 86 | 87 | $ fab spawn 88 | 89 | To force a re-run of Chef *immediately*: 90 | 91 | $ fab production rechef 92 | -------------------------------------------------------------------------------- /buedafab/notify.py: -------------------------------------------------------------------------------- 1 | """Deploy notification hooks for third party services like Campfire and Hoptoad. 2 | """ 3 | from fabric.api import env, require, local 4 | from fabric.decorators import runs_once 5 | import os 6 | 7 | from buedafab import utils 8 | 9 | @runs_once 10 | def hoptoad_deploy(deployed=False): 11 | """Notify Hoptoad of the time and commit SHA of an app deploy. 12 | 13 | Requires the hoppy Python package and the env keys: 14 | 15 | hoptoad_api_key - as it sounds. 16 | deployment_type - app environment 17 | release - the commit SHA or git tag of the deployed version 18 | scm - path to the remote git repository 19 | """ 20 | require('hoptoad_api_key') 21 | require('deployment_type') 22 | require('release') 23 | require('scm') 24 | if deployed and env.hoptoad_api_key: 25 | commit = local('git rev-parse --short %(release)s' % env, 26 | capture=True) 27 | import hoppy.deploy 28 | hoppy.api_key = env.hoptoad_api_key 29 | try: 30 | hoppy.deploy.Deploy().deploy( 31 | env=env.deployment_type, 32 | scm_revision=commit, 33 | scm_repository=env.scm, 34 | local_username=os.getlogin()) 35 | except Exception, e: 36 | print ("Couldn't notify Hoptoad of the deploy, but continuing " 37 | "anyway: %s" % e) 38 | else: 39 | print ('Hoptoad notified of deploy of %s@%s to %s environment by %s' 40 | % (env.scm, commit, env.deployment_type, os.getlogin())) 41 | 42 | @runs_once 43 | def campfire_notify(deployed=False): 44 | """Hop in Campfire and notify your developers of the time and commit SHA of 45 | an app deploy. 46 | 47 | Requires the pinder Python package and the env keys: 48 | 49 | deployment_type - app environment 50 | release - the commit SHA or git tag of the deployed version 51 | scm_http_url - path to an HTTP view of the remote git repository 52 | campfire_subdomain - subdomain of your Campfire account 53 | campfire_token - API token for Campfire 54 | campfire_room - the room to join and notify (the string name, e.g. 55 | "Developers") 56 | """ 57 | require('deployment_type') 58 | require('release') 59 | 60 | if (deployed and env.campfire_subdomain and env.campfire_token 61 | and env.campfire_room): 62 | from pinder import Campfire 63 | deploying = local('git rev-list --abbrev-commit %s | head -n 1' % 64 | env.release, capture=True) 65 | branch = utils.branch(env.release) 66 | 67 | if env.tagged: 68 | require('release') 69 | branch = env.release 70 | 71 | name = env.unit 72 | deployer = os.getlogin() 73 | deployed = env.deployed_version 74 | target = env.deployment_type.lower() 75 | source_repo_url = env.scm_http_url 76 | compare_url = ('%s/compare/%s...%s' % (source_repo_url, deployed, 77 | deploying)) 78 | 79 | campfire = Campfire(env.campfire_subdomain, env.campfire_token, 80 | ssl=True) 81 | room = campfire.find_room_by_name(env.campfire_room) 82 | room.join() 83 | if deployed: 84 | message = ('%s is deploying %s %s (%s..%s) to %s %s' 85 | % (deployer, name, branch, deployed, deploying, target, 86 | compare_url)) 87 | else: 88 | message = ('%s is deploying %s %s to %s' % (deployer, name, 89 | branch, target)) 90 | room.speak(message) 91 | print 'Campfire notified that %s' % message 92 | 93 | -------------------------------------------------------------------------------- /buedafab/aws.py: -------------------------------------------------------------------------------- 1 | """Methods for interacting with Amazon's cloud servers. Uses the boto library to 2 | connect to their API. 3 | """ 4 | from fabric.api import require, env 5 | from fabric.decorators import runs_once 6 | 7 | from buedafab.operations import exists 8 | from buedafab.utils import sha_for_file 9 | 10 | def collect_load_balanced_instances(): 11 | """Return the fully-qualified domain names of the servers attached to an 12 | Elastic Load Balancer. 13 | 14 | Requires the env keys: 15 | 16 | load_balancer -- the ID of the load balancer, typically a chosen name 17 | ec2_connection -- an instance of boto.ec2.EC2Connection (set by default 18 | if your shell environment has an AWS_ACCESS_KEY_ID 19 | and AWS_SECRET_ACCESS_KEY defined 20 | elb_connection -- an instance of boto.ec2.elb.ELBConnection (again, set 21 | by default if you have the right shell variables) 22 | env.ssh_port -- the SSH port used by the servers (has a default) 23 | """ 24 | 25 | require('load_balancer') 26 | require('ec2_connection') 27 | require('elb_connection') 28 | instance_states = env.elb_connection.describe_instance_health( 29 | env.load_balancer) 30 | ids = [] 31 | for instance in instance_states: 32 | print("Adding instance %s" % instance.instance_id) 33 | ids.append(instance.instance_id) 34 | instances = None 35 | instance_fqdns = [] 36 | if ids: 37 | instances = env.ec2_connection.get_all_instances(instance_ids=ids) 38 | for instance in instances: 39 | if (instance.instances[0].update() == 'running' 40 | and instance.instances[0].dns_name): 41 | instance_fqdns.append( 42 | '%s:%d' % (instance.instances[0].dns_name, env.ssh_port)) 43 | print("Found instances %s behind load balancer" % instance_fqdns) 44 | return instance_fqdns 45 | 46 | @runs_once 47 | def elb_add(instance=None): 48 | """Attach the instance defined by the provided instance ID (e.g. i-34927a9) 49 | to the application's Elastic Load Balancer. 50 | 51 | Requires the env keys: 52 | 53 | load_balancer -- the ID of the load balancer, typically a chosen name 54 | elb_connection -- an instance of boto.ec2.elb.ELBConnection (set 55 | by default if you have the AWS shell variables) 56 | """ 57 | require('load_balancer') 58 | require('elb_connection') 59 | status = env.elb_connection.register_instances( 60 | env.load_balancer, [instance]) 61 | print("Status of attaching %s to load balancer %s was %s" 62 | % (instance, env.load_balancer, status)) 63 | 64 | @runs_once 65 | def elb_remove(instance=None): 66 | """Detach the instance defined by the provided instance ID (e.g. i-34927a9) 67 | to the application's Elastic Load Balancer. 68 | 69 | Requires the env keys: 70 | 71 | load_balancer -- the ID of the load balancer, typically a chosen name 72 | elb_connection -- an instance of boto.ec2.elb.ELBConnection (set 73 | by default if you have the AWS shell variables) 74 | """ 75 | require('load_balancer') 76 | require('elb_connection') 77 | status = env.elb_connection.deregister_instances( 78 | env.load_balancer, [instance]) 79 | print("Status of detaching %s from load balancer %s was %s" 80 | % (instance, env.load_balancer, status)) 81 | 82 | @runs_once 83 | def conditional_s3_get(key, filename, sha=None): 84 | """Download a file from S3 to the local machine. Don't re-download if the 85 | sha matches (uses sha256). 86 | """ 87 | sha_matches = False 88 | if exists(filename) and sha: 89 | sha_matches = sha_for_file(filename).startswith(sha) 90 | 91 | if not exists(filename) or not sha_matches: 92 | env.s3_key.key = key 93 | env.s3_key.get_contents_to_filename(filename) 94 | -------------------------------------------------------------------------------- /buedafab/deploy/packages.py: -------------------------------------------------------------------------------- 1 | """Utilities to install Python package dependencies.""" 2 | from fabric.api import warn, cd, require, local, env, settings 3 | from fabric.contrib.console import confirm 4 | from fabric.colors import yellow 5 | import os 6 | 7 | from buedafab.operations import run, exists, put 8 | from buedafab import deploy 9 | 10 | def _read_private_requirements(): 11 | for private_requirements in env.private_requirements: 12 | with open(os.path.join(env.root_dir, private_requirements), 'r') as f: 13 | for requirement in f: 14 | yield requirement.strip().split('==') 15 | 16 | def _install_private_package(package, scm=None, release=None): 17 | env.scratch_path = os.path.join('/tmp', '%s-%s' % (package, env.time_now)) 18 | archive_path = '%s.tar.gz' % env.scratch_path 19 | 20 | if not scm: 21 | require('s3_key') 22 | env.s3_key.key = '%s.tar.gz' % package 23 | env.s3_key.get_contents_to_filename(archive_path) 24 | else: 25 | if 'release' not in env: 26 | env.release = release 27 | release = release or 'HEAD' 28 | if 'pretty_release' in env: 29 | original_pretty_release = env.pretty_release 30 | else: 31 | original_pretty_release = None 32 | if 'archive' in env: 33 | original_archive = env.archive 34 | else: 35 | original_archive = None 36 | with settings(unit=package, scm=scm, release=release): 37 | if not os.path.exists(env.scratch_path): 38 | local('git clone %(scm)s %(scratch_path)s' % env) 39 | deploy.utils.make_archive() 40 | local('mv %s %s' % (os.path.join(env.scratch_path, env.archive), 41 | archive_path)) 42 | if original_pretty_release: 43 | env.pretty_release = original_pretty_release 44 | if original_archive: 45 | env.archive = original_archive 46 | put(archive_path, '/tmp') 47 | if env.virtualenv is not None: 48 | require('release_path') 49 | require('path') 50 | with cd(env.release_path): 51 | run('%s -E %s -s %s' 52 | % (env.pip_install_command, env.virtualenv, archive_path)) 53 | else: 54 | run('%s -s %s' % (env.pip_install_command, archive_path)) 55 | 56 | def _install_manual_packages(path=None): 57 | require('virtualenv') 58 | if not env.package_installation_scripts: 59 | return 60 | 61 | if not path: 62 | require('release_path') 63 | path = env.release_path 64 | with cd(path): 65 | for script in env.package_installation_scripts: 66 | run('./%s %s' % (script, local("echo $VIRTUAL_ENV") 67 | or env.virtualenv)) 68 | 69 | def _install_pip_requirements(path=None): 70 | require('virtualenv') 71 | require('pip_requirements') 72 | if not path: 73 | require('release_path') 74 | path = env.release_path 75 | if not env.pip_requirements: 76 | warn("No pip requirements files found -- %(pip_requirements)s" 77 | % env) 78 | return 79 | with cd(path): 80 | for requirements_file in env.pip_requirements: 81 | run('%s -E %s -s -r %s' % (env.pip_install_command, 82 | env.virtualenv, requirements_file)) 83 | 84 | def install_requirements(deployed=False): 85 | """Install the pip packages listed in the project's requirements files, 86 | private packages, as well as manual installation scripts. 87 | 88 | Installation scripts defined by env.package_installation_scripts will be 89 | provided the path to the virtualenv if one exists as the first argument. 90 | 91 | Requires the env keys: 92 | 93 | release_path -- remote path of the deployed app 94 | virtualenv -- path to this app's virtualenv (required to grab the 95 | correct Python executable) 96 | """ 97 | require('release_path') 98 | require('virtualenv') 99 | 100 | with settings(cd(env.release_path), warn_only=True): 101 | virtualenv_exists = exists('%(virtualenv)s' % env) 102 | if (deployed or not virtualenv_exists or 103 | confirm(yellow("Reinstall Python dependencies?"), default=True)): 104 | _install_pip_requirements() 105 | for package in _read_private_requirements(): 106 | _install_private_package(*package) 107 | _install_manual_packages() 108 | return True 109 | return False 110 | -------------------------------------------------------------------------------- /buedafab/deploy/types.py: -------------------------------------------------------------------------------- 1 | """Deploy commands for applications following Bueda's boilerplate layouts.""" 2 | from fabric.api import warn, cd, require, local, env, settings, abort 3 | from fabric.colors import green, red 4 | import os 5 | 6 | from buedafab.operations import run, put, chmod 7 | from buedafab import celery, db, tasks, notify, testing, utils 8 | from buedafab import deploy 9 | 10 | def _git_deploy(release, skip_tests): 11 | starting_branch = utils.branch() 12 | print(green("Deploying from git branch '%s'" % starting_branch)) 13 | # Ideally, tests would run on the version you are deploying exactly. 14 | # There is no easy way to require that without allowing users to go 15 | # through the entire tagging process before failing tests. 16 | if not skip_tests and testing.test(): 17 | abort(red("Unit tests did not pass -- must fix before deploying")) 18 | 19 | local('git push %(master_remote)s' % env, capture=True) 20 | deploy.release.make_release(release) 21 | 22 | require('pretty_release') 23 | require('path') 24 | require('hosts') 25 | 26 | print(green("Deploying version %s" % env.pretty_release)) 27 | put(os.path.join(os.path.abspath(os.path.dirname(__file__)), 28 | '..', 'files', 'ssh_config'), '.ssh/config') 29 | 30 | deployed = False 31 | hard_reset = False 32 | deployed_versions = {} 33 | deploy.release.bootstrap_release_folders() 34 | for release_path in env.release_paths: 35 | with cd(os.path.join(env.path, env.releases_root, release_path)): 36 | deployed_versions[run('git describe')] = release_path 37 | print(green("The host '%s' currently has the revisions: %s" 38 | % (env.host, deployed_versions))) 39 | if env.pretty_release not in deployed_versions: 40 | env.release_path = os.path.join(env.path, env.releases_root, 41 | deploy.release.alternative_release_path()) 42 | with cd(env.release_path): 43 | run('git fetch %(master_remote)s' % env, forward_agent=True) 44 | run('git reset --hard %(release)s' % env) 45 | deploy.cron.conditional_install_crontab(env.release_path, env.crontab, 46 | env.deploy_user) 47 | deployed = True 48 | else: 49 | warn(red("%(pretty_release)s is already deployed" % env)) 50 | env.release_path = os.path.join(env.path, env.releases_root, 51 | deployed_versions[env.pretty_release]) 52 | with cd(env.release_path): 53 | run('git submodule update --init --recursive', forward_agent=True) 54 | hard_reset = deploy.packages.install_requirements(deployed) 55 | deploy.utils.run_extra_deploy_tasks(deployed) 56 | local('git checkout %s' % starting_branch, capture=True) 57 | chmod(os.path.join(env.path, env.releases_root), 'g+w', use_sudo=True) 58 | return deployed, hard_reset 59 | 60 | def default_deploy(release=None, skip_tests=None): 61 | """Deploy a project according to the methodology defined in the README.""" 62 | require('hosts') 63 | require('path') 64 | require('unit') 65 | 66 | env.test_runner = testing.webpy_test_runner 67 | 68 | utils.store_deployed_version() 69 | deployed, hard_reset = _git_deploy(release, skip_tests) 70 | deploy.release.conditional_symlink_current_release(deployed) 71 | tasks.restart_webserver(hard_reset) 72 | with settings(warn_only=True): 73 | notify.hoptoad_deploy(deployed) 74 | notify.campfire_notify(deployed) 75 | 76 | webpy_deploy = default_deploy 77 | tornado_deploy = default_deploy 78 | 79 | def django_deploy(release=None, skip_tests=None): 80 | """Deploy a Django project according to the methodology defined in the 81 | README. 82 | 83 | Beyond the default_deploy(), this also updates and migrates the database, 84 | loads extra database fixtures, installs an optional crontab as well as 85 | celeryd. 86 | """ 87 | require('hosts') 88 | require('path') 89 | require('unit') 90 | require('migrate') 91 | require('root_dir') 92 | 93 | env.test_runner = testing.django_test_runner 94 | 95 | utils.store_deployed_version() 96 | deployed, hard_reset = _git_deploy(release, skip_tests) 97 | db.update_db(deployed) 98 | db.migrate(deployed) 99 | db.load_data() 100 | deploy.release.conditional_symlink_current_release(deployed) 101 | celery.update_and_restart_celery() 102 | tasks.restart_webserver(hard_reset) 103 | notify.hoptoad_deploy(deployed) 104 | notify.campfire_notify(deployed) 105 | print(green("%(pretty_release)s is now deployed to %(deployment_type)s" 106 | % env)) 107 | -------------------------------------------------------------------------------- /buedafab/environments.py: -------------------------------------------------------------------------------- 1 | """Application environments, which determine the servers, database and other 2 | conditions for deployment. 3 | """ 4 | from fabric.api import require, env 5 | import os 6 | 7 | from buedafab import aws 8 | 9 | def _not_localhost(): 10 | """All non-localhost environments need to install the "production" pip 11 | requirements, which typically includes the Python database bindings. 12 | """ 13 | if (hasattr(env, 'pip_requirements') 14 | and hasattr(env, 'pip_requirements_production')): 15 | env.pip_requirements += env.pip_requirements_production 16 | 17 | def development(): 18 | """[Env] Development server environment 19 | 20 | - Sets the hostname of the development server (using the default ssh port) 21 | - Sets the app environment to "DEV" 22 | - Permits developers to deploy without creating a tag in git 23 | """ 24 | _not_localhost() 25 | if len(env.hosts) == 0: 26 | env.hosts = ['dev.bueda.com:%(ssh_port)d' % env] 27 | env.allow_no_tag = True 28 | env.deployment_type = "DEV" 29 | if (hasattr(env, 'pip_requirements') 30 | and hasattr(env, 'pip_requirements_dev')): 31 | env.pip_requirements += env.pip_requirements_dev 32 | 33 | def staging(): 34 | """[Env] Staging server environment 35 | 36 | - Sets the hostname of the staging server (using the default ssh port) 37 | - Sets the app environment to "STAGING" 38 | - Permits developers to deploy without creating a tag in git 39 | - Appends "-staging" to the target directory to allow development and 40 | staging servers to be the same machine 41 | """ 42 | _not_localhost() 43 | if len(env.hosts) == 0: 44 | env.hosts = ['dev.bueda.com:%(ssh_port)d' % env] 45 | env.allow_no_tag = True 46 | env.deployment_type = "STAGING" 47 | env.path += '-staging' 48 | 49 | def production(): 50 | """[Env] Production servers. Stricter requirements. 51 | 52 | - Collects production servers from the Elastic Load Balancer specified by 53 | the load_balancer env attribute 54 | - Sets the app environment to "PRODUCTION" 55 | - Requires that developers deploy from the 'master' branch in git 56 | - Requires that developers tag the commit in git before deploying 57 | """ 58 | _not_localhost() 59 | env.allow_no_tag = False 60 | env.deployment_type = "PRODUCTION" 61 | if hasattr(env, 'load_balancer'): 62 | if len(env.hosts) == 0: 63 | env.hosts = aws.collect_load_balanced_instances() 64 | env.default_revision = '%(master_remote)s/master' % env 65 | 66 | def localhost(deployment_type=None): 67 | """[Env] Bootstrap the localhost - can be either dev, production or staging. 68 | 69 | We don't really use this anymore except for 'fab setup', and even there it 70 | may not be neccessary. It was originally intended for deploying 71 | automatically with Chef, but we moved away from that approach. 72 | """ 73 | require('root_dir') 74 | if len(env.hosts) == 0: 75 | env.hosts = ['localhost'] 76 | env.allow_no_tag = True 77 | env.deployment_type = deployment_type 78 | env.virtualenv = os.environ.get('VIRTUAL_ENV', 'env') 79 | if deployment_type is None: 80 | deployment_type = "SOLO" 81 | env.deployment_type = deployment_type 82 | if env.deployment_type == "STAGING": 83 | env.path += '-staging' 84 | if (hasattr(env, 'pip_requirements') 85 | and hasattr(env, 'pip_requirements_dev')): 86 | env.pip_requirements += env.pip_requirements_dev 87 | 88 | def django_development(): 89 | """[Env] Django development server environment 90 | 91 | In addition to everything from the development() task, also: 92 | 93 | - loads any database fixtures named "dev" 94 | - loads a crontab from the scripts directory (deprecated at Bueda) 95 | """ 96 | development() 97 | env.extra_fixtures += ["dev"] 98 | env.crontab = os.path.join('scripts', 'crontab', 'development') 99 | 100 | def django_staging(): 101 | """[Env] Django staging server environment 102 | 103 | In addition to everything from the staging() task, also: 104 | 105 | - loads a production crontab from the scripts directory (deprecated at 106 | Bueda) 107 | """ 108 | staging() 109 | env.crontab = os.path.join('scripts', 'crontab', 'production') 110 | 111 | def django_production(): 112 | """[Env] Django production server environment 113 | 114 | In addition to everything from the production() task, also: 115 | 116 | - loads a production crontab from the scripts directory (deprecated at 117 | Bueda) 118 | """ 119 | production() 120 | env.crontab = os.path.join('scripts', 'crontab', 'production') 121 | -------------------------------------------------------------------------------- /buedafab/tasks.py: -------------------------------------------------------------------------------- 1 | """Relatively self-contained, simple Fabric commands.""" 2 | from fabric.api import require, env, local, warn, settings, cd 3 | import os 4 | 5 | from buedafab.operations import run, exists, conditional_rm, sed, sudo 6 | from buedafab import environments, deploy, utils 7 | 8 | def setup(): 9 | """A shortcut to bootstrap or update a virtualenv with the dependencies for 10 | this project. Installs the `common.txt` and `dev.txt` pip requirements and 11 | initializes/updates any git submodules. 12 | 13 | setup() also supports the concept of "private packages" - i.e. Python 14 | packages that are not available on PyPi but require some local compilation 15 | and thus don't work well as git submodules. It can either download a tar 16 | file of the package from S3 or clone a git repository, build and install the 17 | package. 18 | 19 | Any arbitrary functions in env.extra_setup_tasks will also be run from 20 | env.root_dir. 21 | """ 22 | 23 | environments.localhost() 24 | with settings(virtualenv=None): 25 | for package in deploy.packages._read_private_requirements(): 26 | deploy.packages._install_private_package(*package) 27 | deploy.packages._install_manual_packages(env.root_dir) 28 | deploy.packages._install_pip_requirements(env.root_dir) 29 | 30 | with cd(env.root_dir): 31 | local('git submodule update --init --recursive') 32 | for task in env.extra_setup_tasks: 33 | task() 34 | 35 | 36 | def enable(): 37 | """Toggles a value True. Used in 'toggle' commands such as 38 | maintenancemode(). 39 | """ 40 | env.toggle = True 41 | 42 | def disable(): 43 | """Toggles a value False. Used in 'toggle' commands such as 44 | maintenancemode(). 45 | """ 46 | env.toggle = False 47 | 48 | def maintenancemode(): 49 | """If using the maintenancemode app 50 | (https://github.com/jezdez/django-maintenancemode), this command will toggle 51 | it on and off. It finds the `MAINTENANCE_MODE` variable in your 52 | `settings.py` on the remote server, toggles its value and restarts the web 53 | server. 54 | 55 | Requires the env keys: 56 | 57 | toggle - set by enable() or disable(), indicates whether we should turn 58 | maintenance mode on or off. 59 | settings - relative path from the project root to the settings.py file 60 | current_release_path - path to the current release on the remote server 61 | """ 62 | require('toggle', provided_by=[enable, disable]) 63 | require('settings') 64 | require('current_release_path') 65 | 66 | settings_file = os.path.join(utils.absolute_release_path(), env.settings) 67 | if exists(settings_file): 68 | sed(settings_file, '(MAINTENANCE_MODE = )(False|True)', 69 | '\\1%(toggle)s' % env) 70 | restart_webserver() 71 | else: 72 | warn('Settings file %s could not be found' % settings_file) 73 | 74 | def rollback(): 75 | """Swaps the deployed version of the app to the previous version. 76 | 77 | Requires the env keys: 78 | 79 | path - root deploy target for this app 80 | releases_root - subdirectory that stores the releases 81 | current_release_symlink - name of the symlink pointing to the currently 82 | deployed version 83 | Optional: 84 | 85 | crontab - relative path from the project root to a crontab to install 86 | deploy_user - user that should run the crontab 87 | """ 88 | require('path') 89 | require('releases_root') 90 | require('current_release_symlink') 91 | require('crontab') 92 | require('deploy_user') 93 | with cd(os.path.join(env.path, env.releases_root)): 94 | previous_link = deploy.release.alternative_release_path() 95 | conditional_rm(env.current_release_symlink) 96 | run('ln -fs %s %s' % (previous_link, env.current_release_symlink)) 97 | deploy.cron.conditional_install_crontab(utils.absolute_release_path(), 98 | env.crontab, env.deploy_user) 99 | restart_webserver() 100 | 101 | def restart_webserver(hard_reset=False): 102 | """Restart the Gunicorn application webserver. 103 | 104 | Requires the env keys: 105 | 106 | unit - short name of the app, assuming /etc/sv/%(unit)s is the 107 | runit config path 108 | """ 109 | require('unit') 110 | with settings(warn_only=True): 111 | sudo('/etc/init.d/%(unit)s restart' % env) 112 | 113 | def rechef(): 114 | """Run the latest Chef cookbooks on all servers.""" 115 | sudo('chef-client') 116 | 117 | def _package_installed(package): 118 | with settings(warn_only=True): 119 | virtualenv_exists = exists('%(virtualenv)s' % env) 120 | if virtualenv_exists: 121 | installed = run('%s/bin/python -c "import %s"' 122 | % (env.virtualenv, package)) 123 | else: 124 | installed = run('python -c "import %s"' % package) 125 | return installed.return_code == 0 126 | 127 | def install_jcc(**kwargs): 128 | if not _package_installed('jcc'): 129 | run('git clone git://gist.github.com/729451.git build-jcc') 130 | run('VIRTUAL_ENV=%s build-jcc/install_jcc.sh' 131 | % env.virtualenv) 132 | run('rm -rf build-jcc') 133 | 134 | def install_pylucene(**kwargs): 135 | if not _package_installed('lucene'): 136 | run('git clone git://gist.github.com/728598.git build-pylucene') 137 | run('VIRTUAL_ENV=%s build-pylucene/install_pylucene.sh' 138 | % env.virtualenv) 139 | run('rm -rf build-pylucene') 140 | -------------------------------------------------------------------------------- /buedafab/operations.py: -------------------------------------------------------------------------------- 1 | """Lower-level Fabric extensions for common tasks. None of these are ready-to-go 2 | Fabric commands. 3 | """ 4 | from fabric.api import (run as fabric_run, local, sudo as fabric_sudo, hide, 5 | put as fabric_put, settings, env, require, abort, cd) 6 | from fabric.contrib.files import (exists as fabric_exists, sed as fabric_sed) 7 | import os 8 | 9 | from buedafab.utils import absolute_release_path 10 | 11 | def chmod(path, mode, recursive=True, use_sudo=False): 12 | cmd = 'chmod %(mode)s %(path)s' % locals() 13 | if recursive: 14 | cmd += ' -R' 15 | _conditional_sudo(cmd, use_sudo) 16 | 17 | def chgrp(path, group, recursive=True, use_sudo=False): 18 | cmd = 'chgrp %(group)s %(path)s' % locals() 19 | if recursive: 20 | cmd += ' -R' 21 | _conditional_sudo(cmd, use_sudo) 22 | 23 | def chown(path, user, recursive=True, use_sudo=False): 24 | cmd = 'chown %(user)s %(path)s' % locals() 25 | if recursive: 26 | cmd += ' -R' 27 | _conditional_sudo(cmd, use_sudo) 28 | 29 | def _conditional_sudo(cmd, use_sudo): 30 | if use_sudo: 31 | sudo(cmd) 32 | else: 33 | run(cmd) 34 | 35 | def put(local_path, remote_path, mode=None, **kwargs): 36 | """If the host is localhost, puts the file without requiring SSH.""" 37 | require('hosts') 38 | if 'localhost' in env.hosts: 39 | if (os.path.isdir(remote_path) and 40 | (os.path.join(remote_path, os.path.basename(local_path))) 41 | == local_path): 42 | return 0 43 | result = local('cp -R %s %s' % (local_path, remote_path)) 44 | if mode: 45 | local('chmod -R %o %s' % (mode, remote_path)) 46 | return result 47 | else: 48 | return fabric_put(local_path, remote_path, mode, **kwargs) 49 | 50 | def run(command, forward_agent=False, use_sudo=False, **kwargs): 51 | require('hosts') 52 | if 'localhost' in env.hosts: 53 | return local(command) 54 | elif forward_agent: 55 | if not env.host: 56 | abort("At least one host is required") 57 | return sshagent_run(command, use_sudo=use_sudo) 58 | else: 59 | return fabric_run(command, **kwargs) 60 | 61 | def virtualenv_run(command, path=None): 62 | path = path or absolute_release_path() 63 | with cd(path): 64 | run("%s/bin/python %s" % (env.virtualenv, command)) 65 | 66 | def sshagent_run(command, use_sudo=False): 67 | """ 68 | Helper function. 69 | Runs a command with SSH agent forwarding enabled. 70 | 71 | Note:: Fabric (and paramiko) can't forward your SSH agent. 72 | This helper uses your system's ssh to do so. 73 | """ 74 | 75 | if use_sudo: 76 | command = 'sudo %s' % command 77 | 78 | cwd = env.get('cwd', '') 79 | if cwd: 80 | cwd = 'cd %s && ' % cwd 81 | real_command = cwd + command 82 | 83 | with settings(cwd=''): 84 | if env.port: 85 | port = env.port 86 | host = env.host 87 | else: 88 | try: 89 | # catch the port number to pass to ssh 90 | host, port = env.host.split(':') 91 | except ValueError: 92 | port = None 93 | host = env.host 94 | 95 | if port: 96 | local('ssh -p %s -A %s "%s"' % (port, host, real_command)) 97 | else: 98 | local('ssh -A %s "%s"' % (env.host, real_command)) 99 | 100 | def sudo(command, shell=True, user=None, pty=False): 101 | """If the host is localhost, runs without requiring SSH.""" 102 | require('hosts') 103 | if 'localhost' in env.hosts: 104 | command = 'sudo %s' % command 105 | return local(command, capture=False) 106 | else: 107 | return fabric_sudo(command, shell, user, pty) 108 | 109 | def exists(path, use_sudo=False, verbose=False): 110 | require('hosts') 111 | if 'localhost' in env.hosts: 112 | capture = not verbose 113 | command = 'test -e "%s"' % path 114 | func = use_sudo and sudo or run 115 | with settings(hide('everything'), warn_only=True): 116 | return not func(command, capture=capture).failed 117 | else: 118 | return fabric_exists(path, use_sudo, verbose) 119 | 120 | def sed(filename, before, after, limit='', use_sudo=False, backup='.bak'): 121 | require('hosts') 122 | if 'localhost' in env.hosts: 123 | # Code copied from Fabric - is there a better way to have Fabric's sed() 124 | # use our sudo and run functions? 125 | expr = r"sed -i%s -r -e '%ss/%s/%s/g' %s" 126 | # Characters to be escaped in both 127 | for char in "/'": 128 | before = before.replace(char, r'\%s' % char) 129 | after = after.replace(char, r'\%s' % char) 130 | # Characters to be escaped in replacement only (they're useful in 131 | # regexe in the 'before' part) 132 | for char in "()": 133 | after = after.replace(char, r'\%s' % char) 134 | if limit: 135 | limit = r'/%s/ ' % limit 136 | command = expr % (backup, limit, before, after, filename) 137 | func = use_sudo and sudo or run 138 | return func(command) 139 | else: 140 | return fabric_sed(filename, before, after, limit, use_sudo, backup) 141 | 142 | def conditional_mv(source, destination): 143 | if exists(source): 144 | run('mv %s %s' % (source, destination)) 145 | 146 | def conditional_rm(path, recursive=False): 147 | if exists(path): 148 | cmd = 'rm' 149 | if recursive: 150 | cmd += ' -rf' 151 | run('%s %s' % (cmd, path)) 152 | 153 | def conditional_mkdir(path, group=None, mode=None, user=None, use_local=False, 154 | use_sudo=False): 155 | cmd = 'mkdir -p %s' % path 156 | if not exists(path): 157 | if use_local: 158 | local(cmd) 159 | else: 160 | _conditional_sudo(cmd, use_sudo) 161 | if group: 162 | chgrp(path, group, use_sudo=True) 163 | if user: 164 | chown(path, user, use_sudo=True) 165 | if mode: 166 | chmod(path, mode, use_sudo=True) 167 | -------------------------------------------------------------------------------- /buedafab/deploy/release.py: -------------------------------------------------------------------------------- 1 | """Utilities to determine the proper identifier for a deploy.""" 2 | from fabric.api import cd, require, local, env, prompt, settings, abort 3 | from fabric.contrib.console import confirm 4 | from fabric.decorators import runs_once 5 | from fabric.colors import green, yellow 6 | import os 7 | import re 8 | 9 | from buedafab.operations import (run, exists, conditional_mkdir, 10 | conditional_rm, chmod) 11 | from buedafab import utils 12 | 13 | def bootstrap_release_folders(): 14 | """Create the target deploy directories if they don't exist and clone a 15 | fresh copy of the project's repository into each of the release directories. 16 | """ 17 | require('path') 18 | require('deploy_group') 19 | conditional_mkdir(os.path.join(env.path, env.releases_root), 20 | env.deploy_group, 'g+w', use_sudo=True) 21 | with cd(os.path.join(env.path, env.releases_root)): 22 | first_exists = exists(env.release_paths[0]) 23 | if not first_exists: 24 | run('git clone %s %s' % (env.scm, env.release_paths[0]), 25 | forward_agent=True) 26 | with cd(os.path.join(env.path, env.releases_root)): 27 | if not exists(env.release_paths[1]): 28 | run('cp -R %s %s' % (env.release_paths[0], env.release_paths[1])) 29 | chmod(os.path.join(env.path, env.releases_root), 'g+w', use_sudo=True) 30 | 31 | def make_pretty_release(): 32 | """Assigns env.pretty_release to the commit identifier returned by 'git 33 | describe'. 34 | 35 | Requires the env keys: 36 | release - 37 | unit - 38 | """ 39 | require('release') 40 | env.pretty_release = local('git describe %(release)s' % env, capture=True 41 | ).rstrip('\n') 42 | env.archive = '%(pretty_release)s-%(unit)s.tar' % env 43 | 44 | def make_head_commit(): 45 | """Assigns the commit SHA of the current git HEAD to env.head_commit. 46 | 47 | Requires the env keys: 48 | default_revision - the commit ref for HEAD 49 | """ 50 | revision = local('git rev-list %(default_revision)s ' 51 | '-n 1 --abbrev-commit --abbrev=7' % env, capture=True) 52 | env.head_commit = revision.rstrip('\n') 53 | 54 | @runs_once 55 | def make_release(release=None): 56 | """Based on the deployment type and any arguments from the command line, 57 | determine the proper identifier for the commit to deploy. 58 | 59 | If a tag is required (e.g. when in the production app environment), the 60 | deploy must be coming from the master branch, and cannot proceed without 61 | either creating a new tag or specifing and existing one. 62 | 63 | Requires the env keys: 64 | allow_no_tag - whether or not to require the release to be tagged in git 65 | default_revision - the commit ref for HEAD 66 | """ 67 | require('allow_no_tag') 68 | require('default_revision') 69 | 70 | env.release = release 71 | env.tagged = False 72 | if not env.release or env.release == 'latest_tag': 73 | if not env.allow_no_tag: 74 | branch = utils.branch() 75 | if branch != "master": 76 | abort("Make sure to checkout the master branch and merge in the" 77 | " development branch before deploying to production.") 78 | local('git checkout master', capture=True) 79 | description = local('git describe master' % env, capture=True 80 | ).rstrip('\n') 81 | if '-' in description: 82 | env.latest_tag = description[:description.find('-')] 83 | else: 84 | env.latest_tag = description 85 | if not re.match(env.version_pattern, env.latest_tag): 86 | env.latest_tag = None 87 | env.release = env.release or env.latest_tag 88 | env.commit = 'HEAD' 89 | if not env.allow_no_tag: 90 | if confirm(yellow("Tag this release?"), default=False): 91 | require('master_remote') 92 | from prettyprint import pp 93 | print(green("The last 5 tags were: ")) 94 | tags = local('git tag | tail -n 20', capture=True) 95 | pp(sorted(tags.split('\n'), utils.compare_versions, 96 | reverse=True)) 97 | prompt("New release tag in the format vX.Y[.Z]?", 98 | 'tag', 99 | validate=env.version_pattern) 100 | require('commit') 101 | local('git tag -s %(tag)s %(commit)s' % env) 102 | local('git push --tags %(master_remote)s' % env, capture=True) 103 | env.tagged = True 104 | env.release = env.tag 105 | local('git fetch --tags %(master_remote)s' % env, capture=True) 106 | else: 107 | print(green("Using latest tag %(latest_tag)s" % env)) 108 | env.release = env.latest_tag 109 | else: 110 | make_head_commit() 111 | env.release = env.head_commit 112 | print(green("Using the HEAD commit %s" % env.head_commit)) 113 | else: 114 | local('git checkout %s' % env.release, capture=True) 115 | env.tagged = re.match(env.version_pattern, env.release) 116 | make_pretty_release() 117 | 118 | def conditional_symlink_current_release(deployed=False): 119 | """Swap the 'current' symlink to point to the new release if it doesn't 120 | point there already. 121 | 122 | Requires the env keys: 123 | pretty_release - set by make_pretty_release(), a commit identifier 124 | release_path - root target directory on the remote server 125 | """ 126 | current_version = None 127 | if exists(utils.absolute_release_path()): 128 | with settings(cd(utils.absolute_release_path()), warn_only=True): 129 | current_version = run('git describe') 130 | if (not exists(utils.absolute_release_path()) 131 | or deployed or current_version != env.pretty_release): 132 | _symlink_current_release(env.release_path) 133 | 134 | def alternative_release_path(): 135 | """Determine the release directory that is not currently in use. 136 | 137 | For example if the 'current' symlink points to the 'a' release directory, 138 | this method returns 'b'. 139 | 140 | Requires the env keys: 141 | release_paths - a tuple of length 2 with the release directory names 142 | (defaults to 'a' and 'b') 143 | """ 144 | 145 | if exists(utils.absolute_release_path()): 146 | current_release_path = run('readlink %s' 147 | % utils.absolute_release_path()) 148 | if os.path.basename(current_release_path) == env.release_paths[0]: 149 | alternative = env.release_paths[1] 150 | else: 151 | alternative = env.release_paths[0] 152 | return alternative 153 | else: 154 | return env.release_paths[0] 155 | 156 | def _symlink_current_release(next_release_path): 157 | with cd(os.path.join(env.path, env.releases_root)): 158 | conditional_rm(env.current_release_symlink) 159 | run('ln -fs %s %s' % (next_release_path, env.current_release_symlink)) 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | buedafab -- a collection of Fabric utilities used at Bueda 2 | =============================================================================== 3 | 4 | ## Description 5 | 6 | buedafab is a collection of methods for deploying Python apps using Fabric. 7 | 8 | At Bueda, we need to deploy Django, web.py and Tornado applications to our 9 | servers in the Amazon EC2 cloud. A frequent topic of conversation in the Python 10 | web application community seems to be a lack of a "default" pattern for 11 | deploying applications to production servers. Our approach may not work for you, 12 | but we would love to compare methods and see if both can't be improved. 13 | 14 | Read the "Contributing" section of this README if you've got something to add. 15 | 16 | ### Related Projects 17 | 18 | [django-boilerplate](https://github.com/bueda/django-boilerplate) 19 | [tornado-boilerplate](https://github.com/bueda/tornado-boilerplate) 20 | [python-webapp-etc](https://github.com/bueda/python-webapp-etc) 21 | [comrade](https://github.com/bueda/django-comrade) 22 | 23 | ## Installation 24 | 25 | buedafab only need be in your PYTHONPATH - a typical installation method is to 26 | clone a copy of the repository somewhere: 27 | 28 | $ git clone git@github.com:bueda/ops ~/projects 29 | 30 | Then edit your .bashrc or .zshrc file so your shell adds this directory to your 31 | PYTHONPATH every time you open a new terminal: 32 | 33 | $ echo "export PYTHONPATH=$PYTHONPATH:$HOME/projects/ops" >> ~/.bashrc 34 | 35 | In the future, it could be packaged up and installed with pip. 36 | 37 | If you will be using the AWS features of buedafab, add your AWS access key ID 38 | and secret access key to your shell environment: 39 | 40 | $ echo "export AWS_ACCESS_KEY_ID=" >> ~/.bashrc 41 | $ echo "export AWS_SECRET_ACCESS_KEY=" >> ~/.bashrc 42 | 43 | ### Python Package Dependencies 44 | 45 | * fabric 46 | * prettyprint 47 | * boto (only if you are using the Amazon Web Services commands) 48 | * pinder (only if you are using Campfire notification) 49 | * hoppy (only if you are using Hoptoad notification) 50 | 51 | You can install all of these with pip using the `pip-requirements.txt` file: 52 | 53 | $ pip install -r pip-requirements.txt 54 | 55 | If you already have Fabric, the same can be accomplished with: 56 | 57 | $ fab setup 58 | 59 | The recommended method is to install buedafab's dependencies in each of your 60 | project's [virtualenv](http://pypi.python.org/pypi/virtualenv)s. 61 | 62 | In any project that you will be using buedafab, activate that project's 63 | virtualenv (using 64 | [virtualenvwrapper](http://www.doughellmann.com/projects/virtualenvwrapper/) 65 | here) and install the ops repo's pip requirements: 66 | 67 | web/$ workon web 68 | (web)web/$ pip install -r ~/projects/ops/pip-requirements.txt 69 | 70 | An alternative is to install them system-wide, but depending the way Fabric is 71 | installed you may encounter problems with Python imports. 72 | 73 | ## Usage 74 | 75 | To use any of the utilities, your project must have a `fabfile.py` and 76 | `buedafab` must be in your `PYTHONPATH`. Some of the utilities are ready-to-go 77 | Fabric commands - the only requirement is that you import them at the top of 78 | your `fabfile.py`. 79 | 80 | For example, the `rollback` method in `buedafab/tasks.py` is s a complete Fabric 81 | command. The method will roll back an app deployment to the previous version. 82 | To use it in your project, import it at the top of the `fabfile.py`: 83 | 84 | from buedafab.tasks import rollback 85 | 86 | and on the command line, run it like any other Fabric task: 87 | 88 | $ fab rollback 89 | 90 | Most of the utilities (`rollback` included) depend on certain keys being set in 91 | the Fabric `env`. Check the documentation of the method you'd like to use to 92 | see what keys it will be expecting and set them to the correct value at the top 93 | of your project's `fabfile.py`. For example, the `rollback` method expects the 94 | `path` key to point to the deploy target on the server: 95 | 96 | env.path = "/var/webapps/myapp" 97 | 98 | The default values for many keys are defined in `beudafab/defaults.py` and can 99 | be overridden as needed by re-defining them at the top of your `fabfile.py`. The 100 | defaults should be sufficient if you're using our deploy directory layout & 101 | methodology as defined in the next section. 102 | 103 | Here are the commands we use most often: 104 | 105 | ### fab test 106 | 107 | Run the test suite for this project. There are currently test runners defined 108 | for Django, Tornado, and general 109 | [nose](http://somethingaboutorange.com/mrl/projects/nose/0.11.2/) test suites. 110 | Just set `env.test_runner` to the appropriate method (or write your own). 111 | 112 | ### fab lint 113 | 114 | Run pylint on the project, including the packages in `apps/`, `lib/` and 115 | `vendor/`, and using the `.pylintrc` file in the project's root. 116 | 117 | ### fab ... 118 | 119 | Some of the commands require an app environment - `buedafab` defines four 120 | different environments: localhost, development, staging and production. These 121 | are defined in `buedafab/environments.py` and can be overridden or extended. 122 | 123 | These commands don't do anything on their own, but are meant to prefix other 124 | commands, such as... 125 | 126 | ### fab deploy 127 | 128 | Deploy this app to the environment using the git-backed deploy 129 | strategy defined at the end of this README. Some example use cases: 130 | 131 | Deploy a new tagged release from HEAD to all production machines (will prompt 132 | for the tag to use): 133 | 134 | ~/web$ fab production deploy 135 | 136 | Tag a specific commit as a new release, then deploy to all production machines: 137 | 138 | ~/web$ fab production deploy:commit=6b4943c 139 | 140 | Deploy HEAD to the development machine without making a tagged release: 141 | 142 | ~/web$ fab development deploy 143 | 144 | Deploy a specific commit to the development machine without making a tagged 145 | release: 146 | 147 | ~/web$ fab development deploy:commit=6b4943c 148 | 149 | Deploy a tag that already exists to all production machines: 150 | 151 | ~/web$ fab production deploy:release=v0.1.1 152 | 153 | ### fab setup 154 | 155 | A shortcut to bootstrap or update a virtualenv with the dependencies for this 156 | project. Installs the pip requirements files listed in `env.pip_requirements` 157 | and `env.pip_requirements_dev` and initializes/updates any git submodules. 158 | 159 | It also supports the concept of "private packages" - i.e. Python packages that 160 | are not available on PyPi but require some local compilation and thus don't work 161 | well as git submodules. It can either download a tar file of the package from 162 | S3 or clone a git repository, build and install the package. The private package 163 | methods could use a little work - it's probably a better idea to clone a git 164 | submodule and just run `python setup.py build`. 165 | 166 | Lastly, you can provide arbitrary Python functions that will run at the end of 167 | setup (`env.extra_setup_tasks`). We use this to download data files to developer 168 | machines (e.g. Lucene indices. 169 | 170 | ### fab restart_webserver 171 | 172 | Assuming your remote server has an init script for this application's process at 173 | `/etc/init.d/%(unit)s`, this command simple bounces the web server. 174 | 175 | ### fab rollback 176 | 177 | Roll back the deployed version to the previously deployed version - i.e. swap 178 | the `current` symlink from the `a` to the `b` directory (see below for the 179 | methodology this uses). 180 | 181 | ### fab maintenancemode 182 | 183 | If your Django project uses the 184 | [maintenancemode app](https://github.com/jezdez/django-maintenancemode), 185 | this command will toggle that on and off. It finds the `MAINTENANCE_MODE` 186 | variable in your `settings.py` on the remote server, toggles its value and 187 | restarts the web server. 188 | 189 | ## Deploy Directories & Methodology 190 | 191 | After a few iterations, Bueda settled on a certain method for organizing 192 | deployed code. Here's what we've come up with. 193 | 194 | ### Version Control 195 | 196 | All projects are versioned with `git` in a remote repository accessible to all 197 | developers. It need not be GitHub, and you need not have a special deploy key on 198 | the remote server. You will need `ssh-agent` installed and running if you are 199 | using git submodules in your app. 200 | 201 | #### Motive 202 | 203 | Developers should only be able to deploy from a remote repository to make sure 204 | that deployed code (no matter if it's to a development, staging or production 205 | environment) is always available to other developers. If a developer deploys 206 | from a local repository and forgets to push their commits, it can wreak havoc if 207 | that developer becomes unavailable. 208 | 209 | Additionally, deployment should always be a manually initiated push operation, 210 | not an automated pull one. A developer or operations person should always be at 211 | the helm of a deployment, no matter how automated the actual process is. This 212 | person should ideally be able to triage and resolve a bad deploy manually if the 213 | need arises. 214 | 215 | Finally, servers shouldn't require any special authentication to deploy. Since 216 | we've always got a live person leading the deploy, they can use their own 217 | personal SSH authentication keys to clone or update a remote repository. 218 | 219 | ### Application Environments 220 | 221 | We maintain four application environments across at least two servers at Bueda - 222 | solo, development, staging and production. We use GitHub, and our commit and 223 | deploy workflow goes something like this: 224 | 225 | #### As a developer... 226 | 227 | 1. Create your own fork of the master repo on GitHub 228 | 1. Clone your fork and make changes locally, test at localhost:8000 with 229 | built-in Django server 230 | 1. Commit changes into a feature branch and push to your fork 231 | 1. Send a pull request on GitHub to the repo's owner 232 | 233 | #### As the repository's integration master: 234 | 235 | 1. When someone sends a pull request, evaluate the difference and if it's good, 236 | merge it into the development branch of the main repo 237 | 1. Deploy to development server: `~/web$ fab development deploy` 238 | 1. Test again on live development server, make sure everything works (including 239 | database access, since that is the primary difference) 240 | 1. If all is good, merge the development branch into the master branch: 241 | `~/web$ git checkout master && git merge development master` 242 | 1. Deploy to to staging with Fabric to test the live production environment 243 | minus the public URL: `~/web$ fab staging deploy` 244 | 1. Again, if everyone looks good, tag and push to production with Fabric: 245 | `~/web$ fab production deploy` 246 | 1. If somehow after all of your diligent testing the update broke production, 247 | rollback to the last version: `~/web$ fab production rollback` 248 | 249 | #### Motive 250 | 251 | Beyond developing and testing locally, developers should have an easy way to get 252 | their code onto a remote server somewhat similar to the production environment. 253 | This is our `development` environment - it is backed by a completely isolated 254 | database with no live user data, on a separate physical server (well, as 255 | separate as a virtual machine can get). Any developer can deploy here anytime to 256 | make collaborating with a distributed team as easy as possible. The 257 | `development` environment is usually running the HEAD of the `development` 258 | branch from git. 259 | 260 | The `staging` environment is one big step closer to production. This environment 261 | is usually running the HEAD of the `master` branch. This environment is on a 262 | separate physical server from production, but uses the production database. 263 | 264 | Finally, the `production` environment exists on its own server (or cluster of 265 | load-balanced servers) and only ever runs a tagged commit from the `master` 266 | branch in git. This way anyone can tell which version is in production by 267 | looking in their git repository for the latest tag. 268 | 269 | ### Directories 270 | 271 | All apps are deployed to `/var/webapps/appname` - the actual path is arbitrary, 272 | this is just our convention. We bind this path to the EC2 instances ephemeral 273 | storage on /mnt, to avoid filling up the root drive. The path to an application 274 | named `five` would be `/var/webapps/five`. 275 | 276 | Within the deploy directory (continuing with our example of `/var/webapps/five`) 277 | there is a `releases` subdirectory. This in turn contains two more 278 | subdirectories, `a` and `b`. Each of these contains a clone of the project's git 279 | repository. Why not put `a` and `b` at right at `/var/webapps/five`? There are 280 | some cases where you need to store logs or data files along side an app, and 281 | it's good not to mix those with `releases`. 282 | 283 | There is also a `current` symlink that points to either `a` or `b`. Wherever 284 | `current` points, that's what's running in production. 285 | 286 | The final directory structure looks like: 287 | 288 | /var/ 289 | webapps/ 290 | app/ 291 | releases/ 292 | a/ 293 | b/ 294 | current -> a 295 | 296 | #### Motive 297 | 298 | Our Internet spelunking found four prevailing ways to organize deploy 299 | directories: 300 | 301 | ##### Separate deploy directories by timestamp 302 | 303 | Every time you deploy, a new directory is created on the remote server with the 304 | current timestamp. The repository is either re-cloned from the remote, pushed 305 | from the local repo, or archived and uploaded from the deploying 306 | machine. A `current` symlink points to the deployed release. To rollback, find 307 | the timestamp before `current`. 308 | 309 | ##### Separate deploy directories by commit SHA or tag 310 | 311 | Each commit SHA or tag (or output from `git describe`) gets a separate folder. 312 | The repository is either re-cloned from the remote, pushed from the local repo, 313 | or archived and uploaded from the deploying machine. A `current` symlink points 314 | to the deployed release, and `previous` to the last (for rollback purposes). 315 | 316 | In both this scenario and the timestamp strategy, if the deploys are archived 317 | and uploaded, it is a good idea to keep the original `app-VERSION.tar.gz` in a 318 | `packages/` directory alongside `releases`. 319 | 320 | ##### Single deploy directory, version selected with git 321 | 322 | Use one deploy target folder per application, and rely on git to update and 323 | checkout the version you wish to deploy. No need for symlinks - the one folder 324 | is always the deployed version. There is no specific way to 'rollback' since 325 | there is no record of the 'previous' version except the git log. 326 | 327 | GitHub [uses this approach](https://github.com/blog/470-deployment-script-spring-cleaning). 328 | 329 | ##### Two deploy directories, version selected with git 330 | 331 | This method is similar to the single deploy directory approach in that it uses 332 | git to update to the latest version, but it keeps two directories - an `a` and a 333 | `b` (the names are arbitrary) and uses symlinks to bounce back and forth between 334 | them on alternate deploys. 335 | 336 | This method doesn't require a `git reset --hard` and gives you a bit of a buffer 337 | between the currently deployed version and the one that's about to go live. 338 | Especially considering that this gets you separate virtualenvs (and a thus a 339 | buffer against funky egg installs or PyPi outages) it is the method we have 340 | settled on and what buedafab uses. 341 | 342 | ### Virtualenv 343 | 344 | Both the `a` and `b` directories have separate virtualenvs as a subdirectory 345 | named `env`. The web server must be careful to either add this directory to the 346 | `PYTHONPATH` or use the Python executable at `env/bin/python`. 347 | 348 | buedafab relies on pip's requirements file support to define package 349 | dependencies in a project - see `buedafab/deploy/packages.py` for more details. 350 | 351 | #### Motive 352 | 353 | At one point in time, we wiped and rebuilt a virtualenv for the application from 354 | scratch each deploy. This is clearly the safest solution, as it will always get 355 | the correct versions of each package. The time spent, however, was not worth the 356 | added safety to us. Our applications have dependencies that typically take 5-10 357 | minutes to install from scratch - by sharing the virtualenv from the previous 358 | deploy and using the `--update` flag to bump package versions as necessary, we 359 | save a lot of time. 360 | 361 | The only tricky point at the moment is if your pip requirements files have paths 362 | to SCM repositories. Perhaps due to operator error, but we have found pip to be 363 | unreliable when it comes to checking out specific tags or commit IDs from SCM. 364 | It can often get in a bad state (e.g. a detached HEAD in a git repository that 365 | pip can't seem to handle) that requires the `src/` directory inside the 366 | virtualenv to be wiped. 367 | 368 | ## Example 369 | 370 | The `fabfile-example.py` file in this repository has an example `fabfile.py` for 371 | a project that uses a wide range of the methods from buedfab, and sets a few 372 | extra `env` keys. 373 | 374 | ## TODO 375 | 376 | * Document crontab support, and add the scripts directory to the boilerplate 377 | repository. We stopped using this in favor of celery scheduled tasks, but 378 | someone else may still want it (and the code works fine). 379 | 380 | ## Contributing 381 | 382 | If you have improvements or bug fixes: 383 | 384 | * Fork the repository on GitHub 385 | * File an issue for the bug fix/feature request in GitHub 386 | * Create a topic branch 387 | * Push your modifications to that branch 388 | * Send a pull request 389 | 390 | ## Authors 391 | 392 | * [Bueda Inc.](http://www.bueda.com) 393 | * Christopher Peplin, peplin@bueda.com, @[peplin](http://twitter.com/peplin) 394 | --------------------------------------------------------------------------------