From 97c093470e2cd5f968422be938b2086d07e68757 Mon Sep 17 00:00:00 2001 From: Tommi Virtanen Date: Sat, 1 Sep 2007 18:27:25 -0700 Subject: Add gitosis-init, for bootstrapping a new installation. --- gitosis/init.py | 178 ++++++++++++++++++++++++++++++ gitosis/repository.py | 26 +++++ gitosis/templates/__init__.py | 3 + gitosis/templates/admin/hooks/post-update | 4 + gitosis/test/test_init.py | 83 ++++++++++++++ gitosis/test/test_repository.py | 28 +++++ gitosis/test/util.py | 13 +++ setup.py | 1 + 8 files changed, 336 insertions(+) create mode 100644 gitosis/init.py create mode 100644 gitosis/templates/__init__.py create mode 100755 gitosis/templates/admin/hooks/post-update create mode 100644 gitosis/test/test_init.py diff --git a/gitosis/init.py b/gitosis/init.py new file mode 100644 index 0000000..bf0eb9b --- /dev/null +++ b/gitosis/init.py @@ -0,0 +1,178 @@ +""" +Initialize a user account for use with gitosis. +""" + +import errno +import logging +import optparse +import os +import re +import subprocess +import sys + +from pkg_resources import resource_filename +from cStringIO import StringIO +from ConfigParser import RawConfigParser + +from gitosis import repository +from gitosis import util + +log = logging.getLogger('gitosis.init') + +def die(msg): + log.error(msg) + sys.exit(1) + +def read_ssh_pubkey(fp=None): + if fp is None: + fp = sys.stdin + line = fp.readline() + return line + +_ACCEPTABLE_USER_RE = re.compile(r'^[a-z][a-z0-9]*@[a-z][a-z0-9]*$') + +class InsecureSSHKeyUsername(Exception): + """Username contains not allowed characters""" + + def __str__(self): + return '%s: %s' % (self.__doc__, ': '.join(self.args)) + +def ssh_extract_user(pubkey): + _, user = pubkey.rsplit(None, 1) + if _ACCEPTABLE_USER_RE.match(user): + return user + else: + raise InsecureSSHKeyUsername(repr(user)) + +def initial_commit(git_dir, cfg, pubkey, user): + repository.fast_import( + git_dir=git_dir, + commit_msg='Automatic creation of gitosis repository.', + committer='Gitosis Admin <%s>' % user, + files=[ + ('keydir/%s.pub' % user, pubkey), + ('gitosis.conf', cfg), + ], + ) + +def run_post_update(git_dir): + args = [os.path.join(git_dir, 'hooks', 'post-update')] + returncode = subprocess.call( + args=args, + cwd=git_dir, + close_fds=True, + env=dict(GIT_DIR='.'), + ) + if returncode != 0: + die( + ("post-update returned non-zero exit status %d" + % returncode), + ) + +def symlink_config(git_dir): + dst = os.path.expanduser('~/.gitosis.conf') + tmp = '%s.%d.tmp' % (dst, os.getpid()) + try: + os.unlink(tmp) + except OSError, e: + if e.errno == errno.ENOENT: + pass + else: + raise + os.symlink( + os.path.join(git_dir, 'gitosis.conf'), + tmp, + ) + os.rename(tmp, dst) + +def getParser(): + parser = optparse.OptionParser( + usage='%prog', + description='Initialize a user account for use with gitosis', + ) + parser.set_defaults( + config=os.path.expanduser('~/.gitosis.conf'), + ) + parser.add_option('--config', + metavar='FILE', + help='read config from FILE', + ) + return parser + +def init_admin_repository( + git_dir, + pubkey, + user, + ): + repository.init( + path=git_dir, + template=resource_filename('gitosis.templates', 'admin') + ) + repository.init( + path=git_dir, + ) + if not repository.has_initial_commit(git_dir): + log.info('Making initial commit...') + # ConfigParser does not guarantee order, so jump through hoops + # to make sure [gitosis] is first + cfg_file = StringIO() + print >>cfg_file, '[gitosis]' + print >>cfg_file + cfg = RawConfigParser() + cfg.add_section('group gitosis-admin') + cfg.set('group gitosis-admin', 'members', user) + cfg.set('group gitosis-admin', 'writable', 'gitosis-admin') + cfg.write(cfg_file) + initial_commit( + git_dir=git_dir, + cfg=cfg_file.getvalue(), + pubkey=pubkey, + user=user, + ) + +def main(): + logging.basicConfig(level=logging.INFO) + os.umask(0022) + + parser = getParser() + (options, args) = parser.parse_args() + if args: + parser.error('Did not expect arguments.') + + cfg = RawConfigParser() + try: + conffile = file(options.config) + except (IOError, OSError), e: + if e.errno == errno.ENOENT: + # not existing is ok + pass + else: + # I trust the exception has the path. + die("Unable to read config file: %s." % e) + else: + try: + cfg.readfp(conffile) + finally: + conffile.close() + + + log.info('Reading SSH public key...') + pubkey = read_ssh_pubkey() + user = ssh_extract_user(pubkey) + if user is None: + die('Cannot parse user from SSH public key.') + log.info('Admin user is %r', user) + log.info('Creating repository structure...') + repositories = util.getRepositoryDir(cfg) + util.mkdir(repositories) + admin_repository = os.path.join(repositories, 'gitosis-admin.git') + init_admin_repository( + git_dir=admin_repository, + pubkey=pubkey, + user=user, + ) + log.info('Running post-update hook...') + run_post_update(git_dir=admin_repository) + log.info('Symlinking ~/.gitosis.conf to repository...') + symlink_config(git_dir=admin_repository) + log.info('Done.') diff --git a/gitosis/repository.py b/gitosis/repository.py index 764c980..9558494 100644 --- a/gitosis/repository.py +++ b/gitosis/repository.py @@ -1,4 +1,5 @@ import os +import re import subprocess from gitosis import util @@ -119,3 +120,28 @@ def export(git_dir, path): ) if returncode != 0: raise GitCheckoutIndexError('exit status %d' % returncode) + +class GitHasInitialCommitError(GitError): + """Check for initial commit failed""" + +class GitRevParseError(GitError): + """rev-parse failed""" + +def has_initial_commit(git_dir): + child = subprocess.Popen( + args=['git', 'rev-parse', 'HEAD'], + cwd=git_dir, + stdout=subprocess.PIPE, + close_fds=True, + env=dict(GIT_DIR='.'), + ) + got = child.stdout.read() + returncode = child.wait() + if returncode != 0: + raise GitRevParseError('exit status %d' % returncode) + if got == 'HEAD\n': + return False + elif re.match('^[0-9a-f]{40}\n$', got): + return True + else: + raise GitHasInitialCommitError('Unknown git HEAD: %r' % got) diff --git a/gitosis/templates/__init__.py b/gitosis/templates/__init__.py new file mode 100644 index 0000000..b697fb8 --- /dev/null +++ b/gitosis/templates/__init__.py @@ -0,0 +1,3 @@ +""" +Git templates for use by gitosis-init. +""" diff --git a/gitosis/templates/admin/hooks/post-update b/gitosis/templates/admin/hooks/post-update new file mode 100755 index 0000000..704ecb2 --- /dev/null +++ b/gitosis/templates/admin/hooks/post-update @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +gitosis-run-hook post-update +git-update-server-info diff --git a/gitosis/test/test_init.py b/gitosis/test/test_init.py new file mode 100644 index 0000000..8f72b2d --- /dev/null +++ b/gitosis/test/test_init.py @@ -0,0 +1,83 @@ +from nose.tools import eq_ as eq +from gitosis.test.util import assert_raises, maketemp + +import os +from ConfigParser import RawConfigParser + +from gitosis import init +from gitosis import repository + +from gitosis.test import util + +def test_ssh_extract_user_simple(): + got = init.ssh_extract_user( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') + eq(got, 'fakeuser@fakehost') + +def test_ssh_extract_user_bad(): + e = assert_raises( + init.InsecureSSHKeyUsername, + init.ssh_extract_user, + 'ssh-somealgo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ER3%#@e%') + eq(str(e), "Username contains not allowed characters: 'ER3%#@e%'") + +def test_init_admin_repository(): + tmp = maketemp() + admin_repository = os.path.join(tmp, 'admin.git') + pubkey = ( + 'ssh-somealgo ' + +'0123456789ABCDEFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= fakeuser@fakehost') + user = 'jdoe' + init.init_admin_repository( + git_dir=admin_repository, + pubkey=pubkey, + user=user, + ) + eq(os.listdir(tmp), ['admin.git']) + hook = os.path.join( + tmp, + 'admin.git', + 'hooks', + 'post-update', + ) + util.check_mode(hook, 0755, is_file=True) + got = util.readFile(hook).splitlines() + assert 'gitosis-run-hook post-update' in got + export_dir = os.path.join(tmp, 'export') + repository.export(git_dir=admin_repository, + path=export_dir) + eq(sorted(os.listdir(export_dir)), + sorted(['gitosis.conf', 'keydir'])) + eq(os.listdir(os.path.join(export_dir, 'keydir')), + ['jdoe.pub']) + got = util.readFile( + os.path.join(export_dir, 'keydir', 'jdoe.pub')) + eq(got, pubkey) + # the only thing guaranteed of initial config file ordering is + # that [gitosis] is first + got = util.readFile(os.path.join(export_dir, 'gitosis.conf')) + got = got.splitlines()[0] + eq(got, '[gitosis]') + cfg = RawConfigParser() + cfg.read(os.path.join(export_dir, 'gitosis.conf')) + eq(sorted(cfg.sections()), + sorted([ + 'gitosis', + 'group gitosis-admin', + ])) + eq(cfg.items('gitosis'), []) + eq(sorted(cfg.items('group gitosis-admin')), + sorted([ + ('writable', 'gitosis-admin'), + ('members', 'jdoe'), + ])) diff --git a/gitosis/test/test_repository.py b/gitosis/test/test_repository.py index 79d1561..9d22d0a 100644 --- a/gitosis/test/test_repository.py +++ b/gitosis/test/test_repository.py @@ -6,6 +6,7 @@ import subprocess from gitosis import repository from gitosis.test.util import mkdir, maketemp, readFile, check_mode +from gitosis.test.util import assert_raises def check_bare(path): # we want it to be a bare repository @@ -102,3 +103,30 @@ Frobitz the quux and eschew obfuscation. eq(got[5], '') eq(got[6], 'Frobitz the quux and eschew obfuscation.') eq(got[7:], []) + +def test_has_initial_commit_fail_notAGitDir(): + tmp = maketemp() + e = assert_raises( + repository.GitRevParseError, + repository.has_initial_commit, + git_dir=tmp) + eq(str(e), 'rev-parse failed: exit status 128') + +def test_has_initial_commit_no(): + tmp = maketemp() + repository.init(path=tmp) + got = repository.has_initial_commit(git_dir=tmp) + eq(got, False) + +def test_has_initial_commit_yes(): + tmp = maketemp() + repository.init(path=tmp) + repository.fast_import( + git_dir=tmp, + commit_msg='fakecommit', + committer='John Doe ', + files=[], + ) + got = repository.has_initial_commit(git_dir=tmp) + eq(got, True) + diff --git a/gitosis/test/util.py b/gitosis/test/util.py index aa5a4a2..592b766 100644 --- a/gitosis/test/util.py +++ b/gitosis/test/util.py @@ -52,6 +52,19 @@ def readFile(path): f.close() return data +def assert_raises(excClass, callableObj, *args, **kwargs): + """ + Like unittest.TestCase.assertRaises, but returns the exception. + """ + try: + callableObj(*args, **kwargs) + except excClass, e: + return e + else: + if hasattr(excClass,'__name__'): excName = excClass.__name__ + else: excName = str(excClass) + raise AssertionError("%s not raised" % excName) + def check_mode(path, mode, is_file=None, is_dir=None): st = os.stat(path) if is_dir: diff --git a/setup.py b/setup.py index 697d952..3b2dd97 100755 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ setup( 'gitosis-serve = gitosis.serve:main', 'gitosis-gitweb = gitosis.gitweb:main', 'gitosis-run-hook = gitosis.run_hook:main', + 'gitosis-init = gitosis.init:main', ], }, ) -- cgit v1.2.3