├── .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 |
--------------------------------------------------------------------------------