├── .gitignore ├── .gitmodules ├── LICENSE.txt ├── README.md ├── bluegreen-example ├── app.py ├── fabfile.py └── requirements.txt ├── example └── fabfile.py ├── gitric ├── __init__.py └── api.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /MANIFEST 3 | *pyc 4 | .env 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "bluegreen-example/parrots"] 2 | path = bluegreen-example/parrots 3 | url = https://github.com/jmhobbs/cultofthepartyparrot.com 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dan Bravender 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitric # 2 | 3 | Very simple git-based deployment for fabric. 4 | 5 | Git pulling from a remote repository requires that you open your repository to the world and it means your deployment process relies on one more moving piece. Since git is distributed you can push from your local repository and rely on one fewer external resource and limit access to your private repositories to your internal network but still get lightning fast git deployments. 6 | 7 | ## Installation ## 8 | 9 | pip install gitric 10 | 11 | ## Features ## 12 | 13 | * Uses git push instead of git pull. 14 | * Pre-seeds your target's immutable git object store so you can stop and restart your server before and after the working copy is modified instead of waiting for network IO and the working copy to update. 15 | * Won't let you deploy from a dirty working copy (can be overridden for testing). 16 | * Won't let you lose history (can be overridden for rollbacks). 17 | 18 |
19 | cd example
20 | virtualenv .env --no-site-packages
21 | source .env/bin/activate
22 | pip install -r requirements.txt
23 | fab -l
24 | Available commands:
25 |     
26 |     allow_dirty  allow pushing even when the working copy is dirty
27 |     deploy       an example deploy action
28 |     force_push   allow pushing even when history will be lost
29 |     prod         an example production deployment
30 | 
31 | 32 | After creating a test-deploy user on my server: 33 | 34 | fab prod deploy 35 | ... 36 | [yourserverhere] out: HEAD is now at b2db04e Initial commit 37 | 38 | 39 | Done. 40 | Disconnecting from yourserverhere... done. 41 | 42 | You can't deploy when your working copy is dirty: 43 | 44 | touch dirty_working_copy 45 | fab prod deploy 46 | ... 47 | Fatal error: Working copy is dirty. This check can be overridden by 48 | importing gitric.api.allow_dirty and adding allow_dirty to your call. 49 | 50 | Aborting. 51 | 52 | And it (well git) won't let you deploy when you would lose history unless overridden with a force_push: 53 | 54 | # simulate a divergent history 55 | echo "#hello" >> requirements.txt 56 | git add requirements.txt 57 | git commit --amend 58 | fab prod deploy 59 | .... 60 | Fatal error: 11485c970d21ea2003c0be3be820905220d34631 is a non-fast-forward 61 | push. The seed will abort so you don't lose information. If you are doing this 62 | intentionally import gitric.api.force_push and add it to your call. 63 | -------------------------------------------------------------------------------- /bluegreen-example/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask, send_from_directory 4 | app = Flask(__name__) 5 | 6 | 7 | @app.route("/") 8 | def hello(): 9 | return "Hello 0-downtime %s World!" % os.environ.get('BLUEGREEN', 'bland') 10 | 11 | 12 | @app.route("/parrots/") 13 | def parrot(path): 14 | return send_from_directory(os.path.join('parrots', 'parrots'), path) 15 | -------------------------------------------------------------------------------- /bluegreen-example/fabfile.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from StringIO import StringIO 4 | 5 | from fabric.api import task, local, run 6 | from fabric.operations import put 7 | from fabric.state import env 8 | 9 | sys.path.append('../') 10 | from gitric.api import ( # noqa 11 | git_seed, git_reset, allow_dirty, force_push, 12 | init_bluegreen, swap_bluegreen 13 | ) 14 | 15 | 16 | @task 17 | def prod(): 18 | env.user = 'test-deployer' 19 | env.bluegreen_root = '/home/test-deployer/bluegreenmachine/' 20 | env.bluegreen_ports = {'blue': '8888', 21 | 'green': '8889'} 22 | init_bluegreen() 23 | 24 | 25 | @task 26 | def deploy(commit=None): 27 | if not commit: 28 | commit = local('git rev-parse HEAD', capture=True) 29 | env.repo_path = os.path.join(env.next_path, 'repo') 30 | git_seed(env.repo_path, commit, submodules=True) 31 | git_reset(env.repo_path, commit, submodules=True) 32 | run('kill $(cat %(pidfile)s) || true' % env) 33 | run('virtualenv %(virtualenv_path)s' % env) 34 | run('source %(virtualenv_path)s/bin/activate && ' 35 | 'pip install -r %(repo_path)s/bluegreen-example/requirements.txt' 36 | % env) 37 | put(StringIO('proxy_pass http://127.0.0.1:%(bluegreen_port)s/;' % env), 38 | env.nginx_conf) 39 | run('cd %(repo_path)s/bluegreen-example && PYTHONPATH=. ' 40 | 'BLUEGREEN=%(color)s %(virtualenv_path)s/bin/gunicorn -D ' 41 | '-b 0.0.0.0:%(bluegreen_port)s -p %(pidfile)s app:app' 42 | % env) 43 | 44 | 45 | @task 46 | def cutover(): 47 | swap_bluegreen() 48 | run('sudo /etc/init.d/nginx reload') 49 | -------------------------------------------------------------------------------- /bluegreen-example/requirements.txt: -------------------------------------------------------------------------------- 1 | Fabric==1.9.0 2 | Flask==0.10.1 3 | Jinja2==2.7.3 4 | MarkupSafe==0.23 5 | Werkzeug==0.9.6 6 | argparse==1.2.1 7 | ecdsa==0.11 8 | gunicorn==19.0.0 9 | itsdangerous==0.24 10 | paramiko==1.14.0 11 | pycrypto==2.6.1 12 | wsgiref==0.1.2 13 | -------------------------------------------------------------------------------- /example/fabfile.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.path.append('../') 3 | from fabric.api import task 4 | from fabric.state import env 5 | from gitric.api import git_seed, git_reset, allow_dirty, force_push # noqa 6 | 7 | 8 | @task 9 | def prod(): 10 | '''an example production deployment''' 11 | env.user = 'test-deployer' 12 | 13 | 14 | @task 15 | def deploy(commit=None): 16 | '''an example deploy action''' 17 | repo_path = '/home/test-deployer/test-repo' 18 | git_seed(repo_path, commit) 19 | # stop your service here 20 | git_reset(repo_path, commit) 21 | # restart your service here 22 | -------------------------------------------------------------------------------- /gitric/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbravender/gitric/5e27f82343ff1082137482d3c1a07944140ba411/gitric/__init__.py -------------------------------------------------------------------------------- /gitric/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import os 4 | from operator import itemgetter 5 | import re 6 | 7 | from fabric.api import cd, run, puts, sudo, task, abort, local, require 8 | from fabric.state import env 9 | from fabric.colors import green 10 | from fabric.contrib.files import exists 11 | from fabric.context_managers import settings 12 | import posixpath 13 | 14 | 15 | @task 16 | def allow_dirty(): 17 | """ allow pushing even when the working copy is dirty """ 18 | env.gitric_allow_dirty = True 19 | 20 | 21 | @task 22 | def force_push(): 23 | """ allow pushing even when history will be lost """ 24 | env.gitric_force_push = True 25 | 26 | 27 | def git_init(repo_path, use_sudo=False): 28 | """ create a git repository if necessary [remote] """ 29 | 30 | # check if it is a git repository yet 31 | if exists('%s/.git' % repo_path): 32 | return 33 | 34 | puts(green('Creating new git repository ') + repo_path) 35 | 36 | func = sudo if use_sudo else run 37 | 38 | # create repository folder if necessary 39 | func('mkdir -p %s' % repo_path, quiet=True) 40 | 41 | with cd(repo_path), settings(warn_only=True): 42 | # initialize the remote repository 43 | if func('git init').failed: 44 | func('git init-db') 45 | 46 | # silence git complaints about pushes coming in on the current branch 47 | # the pushes only seed the immutable object store and do not modify the 48 | # working copy 49 | func('git config receive.denyCurrentBranch ignore') 50 | 51 | 52 | def git_seed(repo_path, commit=None, ignore_untracked_files=False, 53 | use_sudo=False, submodules=False): 54 | """ seed a git repository (and create if necessary) [remote] """ 55 | 56 | # check if the local repository is dirty 57 | dirty_working_copy = git_is_dirty(ignore_untracked_files) 58 | if dirty_working_copy: 59 | abort( 60 | 'Working copy is dirty. This check can be overridden by\n' 61 | 'importing gitric.api.allow_dirty and adding allow_dirty to your ' 62 | 'call.') 63 | 64 | # check if the remote repository exists and create it if necessary 65 | git_init(repo_path, use_sudo=use_sudo) 66 | 67 | # use specified commit or HEAD 68 | commit = commit or git_head_rev() 69 | 70 | # push the commit to the remote repository 71 | # 72 | # (note that pushing to the master branch will not change the contents 73 | # of the working directory) 74 | 75 | puts(green('Pushing commit ') + commit) 76 | 77 | with settings(warn_only=True): 78 | force = ('gitric_force_push' in env) and '-f' or '' 79 | push = local( 80 | 'git push git+ssh://%s@%s:%s%s %s:refs/heads/master %s' % ( 81 | env.user, env.host, env.port, repo_path, commit, force)) 82 | 83 | if push.failed: 84 | abort( 85 | '%s is a non-fast-forward\n' 86 | 'push. The seed will abort so you don\'t lose information. ' 87 | 'If you are doing this\nintentionally import ' 88 | 'gitric.api.force_push and add it to your call.' % commit) 89 | 90 | if submodules: 91 | git_seed_submodules(repo_path, commit, ignore_untracked_files, use_sudo) 92 | 93 | 94 | def git_reset(repo_path, commit=None, use_sudo=False, submodules=False): 95 | """ reset the working directory to a specific commit [remote] """ 96 | 97 | # use specified commit or HEAD 98 | commit = commit or git_head_rev() 99 | 100 | puts(green('Resetting to commit ') + commit) 101 | 102 | # reset the repository and working directory 103 | with cd(repo_path): 104 | func = sudo if use_sudo else run 105 | func('git reset --hard %s' % commit) 106 | 107 | if submodules: 108 | git_reset_submodules(repo_path, commit, use_sudo) 109 | 110 | 111 | def git_local_submodules(commit=None): 112 | """ get all submodules in local repository """ 113 | modules_path = [itemgetter(0, 1)(x.split(' ')) for x in local( 114 | "git submodule --quiet foreach 'echo $name $path $sha1 $toplevel'", capture=True).split('\n')] 115 | 116 | # use specified commit or HEAD 117 | commit = commit or git_head_rev() 118 | submodules = {} # key is a tuple of (full_path, module path) value is module revision which is corresponding with parent commit. 119 | for full_path, module_path in modules_path: 120 | module_rev = re.compile(r'\s+?').split(local('git ls-tree %s %s' % (commit, module_path), capture=True))[2] 121 | submodules[(full_path, module_path)] = module_rev 122 | return submodules 123 | 124 | 125 | def git_seed_submodule(repo_path, submodule_path, commit, ignore_untracked_files=False, use_sudo=False): 126 | 127 | # check if the local repository is dirty 128 | dirty_working_copy = git_is_dirty(ignore_untracked_files) 129 | if dirty_working_copy: 130 | abort( 131 | 'Working copy is dirty. This check can be overridden by\n' 132 | 'importing gitric.api.allow_dirty and adding allow_dirty to your ' 133 | 'call.') 134 | 135 | # check if the remote repository exists and create it if necessary 136 | git_init(repo_path, use_sudo=use_sudo) 137 | 138 | # push the commit to the remote repository 139 | # 140 | # (note that pushing to the master branch will not change the contents 141 | # of the working directory) 142 | 143 | puts(green('Pushing commit ') + commit) 144 | 145 | with settings(warn_only=True): 146 | force = ('gitric_force_push' in env) and '-f' or '' 147 | push = local( 148 | 'git submodule --quiet foreach \'[ $path != "%s" ] || git push git+ssh://%s@%s:%s%s %s:refs/heads/master %s\'' % ( 149 | submodule_path, env.user, env.host, env.port, repo_path, commit, force)) 150 | 151 | if push.failed: 152 | abort( 153 | '%s is a non-fast-forward\n' 154 | 'push. The seed will abort so you don\'t lose information. ' 155 | 'If you are doing this\nintentionally import ' 156 | 'gitric.api.force_push and add it to your call.' % commit) 157 | 158 | 159 | def git_seed_submodules(repo_path, commit=None, ignore_untracked_files=False, use_sudo=False): 160 | """ seed submodules in a git repository (and create if necessary) [remote] """ 161 | submodules = git_local_submodules(commit) 162 | for full_path, path in submodules: 163 | commit = submodules[(full_path, path)] 164 | puts(green('Pushing submodule ') + path) 165 | git_seed_submodule(repo_path + os.path.sep + full_path, 166 | path, commit, 167 | ignore_untracked_files=ignore_untracked_files, 168 | use_sudo=use_sudo) 169 | 170 | 171 | def git_reset_submodules(repo_path, commit=None, use_sudo=False): 172 | """ reset submodules in the working directory to a specific commit [remote] """ 173 | 174 | submodules = git_local_submodules(commit) 175 | for full_path, path in submodules: 176 | rev = submodules[(full_path, path)] 177 | puts(green('Resetting submodule ' + full_path + ' to commit ') + rev) 178 | # reset the repository and working directory 179 | with cd(repo_path + os.path.sep + full_path): 180 | func = sudo if use_sudo else run 181 | func('git reset --hard %s' % rev) 182 | 183 | 184 | def git_head_rev(): 185 | """ find the commit that is currently checked out [local] """ 186 | return local('git rev-parse HEAD', capture=True) 187 | 188 | 189 | def git_is_dirty(ignore_untracked_files): 190 | """ check if there are modifications in the repository [local] """ 191 | 192 | if 'gitric_allow_dirty' in env: 193 | return False 194 | 195 | untracked_files = '--untracked-files=no' if ignore_untracked_files else '' 196 | return local('git status %s --porcelain' % untracked_files, 197 | capture=True) != '' 198 | 199 | 200 | def init_bluegreen(): 201 | require('bluegreen_root', 'bluegreen_ports') 202 | env.green_path = posixpath.join(env.bluegreen_root, 'green') 203 | env.blue_path = posixpath.join(env.bluegreen_root, 'blue') 204 | env.next_path_abs = posixpath.join(env.bluegreen_root, 'next') 205 | env.live_path_abs = posixpath.join(env.bluegreen_root, 'live') 206 | run('mkdir -p %(bluegreen_root)s %(blue_path)s %(green_path)s ' 207 | '%(blue_path)s/etc %(green_path)s/etc' % env) 208 | if not exists(env.live_path_abs): 209 | run('ln -s %(blue_path)s %(live_path_abs)s' % env) 210 | if not exists(env.next_path_abs): 211 | run('ln -s %(green_path)s %(next_path_abs)s' % env) 212 | env.next_path = run('readlink -f %(next_path_abs)s' % env) 213 | env.live_path = run('readlink -f %(live_path_abs)s' % env) 214 | env.virtualenv_path = posixpath.join(env.next_path, 'env') 215 | env.pidfile = posixpath.join(env.next_path, 'etc', 'app.pid') 216 | env.nginx_conf = posixpath.join(env.next_path, 'etc', 'nginx.conf') 217 | env.color = posixpath.basename(env.next_path) 218 | env.bluegreen_port = env.bluegreen_ports.get(env.color) 219 | 220 | 221 | def swap_bluegreen(): 222 | require('next_path', 'live_path', 'live_path_abs', 'next_path_abs') 223 | run('ln -nsf %(next_path)s %(live_path_abs)s' % env) 224 | run('ln -nsf %(live_path)s %(next_path_abs)s' % env) 225 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='gitric', 5 | version='0.4', 6 | description='simple git-based deployment for fabric', 7 | author='Dan Bravender', 8 | author_email='dan.bravender@gmail.com', 9 | url='http://dan.bravender.us', 10 | download_url='http://github.com/dbravender/gitric/tarball/0.4', 11 | packages=['gitric']) 12 | --------------------------------------------------------------------------------