diff --git a/docs/index.rst b/docs/index.rst --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,7 @@ Users Guide usage/general usage/git_support usage/performance + usage/locking usage/statistics usage/backup usage/debugging diff --git a/docs/usage/locking.rst b/docs/usage/locking.rst new file mode 100644 --- /dev/null +++ b/docs/usage/locking.rst @@ -0,0 +1,41 @@ +.. _locking: + +=================================== +RhodeCode repository locking system +=================================== + + +| Repos with **locking function=disabled** is the default, that's how repos work + today. +| Repos with **locking function=enabled** behaves like follows: + +Repos have a state called `locked` that can be true or false. +The hg/git commands `hg/git clone`, `hg/git pull`, and `hg/git push` +influence this state: + +- The command `hg/git pull ` will lock that repo (locked=true) + if the user has write/admin permissions on this repo + +- The command `hg/git clone ` will lock that repo (locked=true) if the + user has write/admin permissions on this repo + + +RhodeCode will remember the user id who locked the repo +only this specific user can unlock the repo (locked=false) by calling + +- `hg/git push ` + +every other command on that repo from this user and +every command from any other user will result in http return code 423 (locked) + + +additionally the http error includes the that locked the repo +(e.g. “repository locked by user ”) + + +So the scenario of use for repos with `locking function` enabled is that +every initial clone and every pull gives users (with write permission) +the exclusive right to do a push. + + +Each repo can be manually unlocked by admin from the repo settings menu. \ No newline at end of file diff --git a/rhodecode/config/pre_receive_tmpl.py b/rhodecode/config/pre_receive_tmpl.py new file mode 100644 --- /dev/null +++ b/rhodecode/config/pre_receive_tmpl.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +import os +import sys + +try: + import rhodecode + RC_HOOK_VER = '_TMPL_' + os.environ['RC_HOOK_VER'] = RC_HOOK_VER + from rhodecode.lib.hooks import handle_git_pre_receive +except ImportError: + rhodecode = None + + +def main(): + if rhodecode is None: + # exit with success if we cannot import rhodecode !! + # this allows simply push to this repo even without + # rhodecode + sys.exit(0) + + repo_path = os.path.abspath('.') + push_data = sys.stdin.readlines() + # os.environ is modified here by a subprocess call that + # runs git and later git executes this hook. + # Environ get's some additional info from rhodecode system + # like IP or username from basic-auth + handle_git_pre_receive(repo_path, push_data, os.environ) + sys.exit(0) + +if __name__ == '__main__': + main() diff --git a/rhodecode/config/routing.py b/rhodecode/config/routing.py --- a/rhodecode/config/routing.py +++ b/rhodecode/config/routing.py @@ -138,7 +138,9 @@ def make_map(config): m.connect('repo_as_fork', "/repo_as_fork/{repo_name:.*?}", action="repo_as_fork", conditions=dict(method=["PUT"], function=check_repo)) - + m.connect('repo_locking', "/repo_locking/{repo_name:.*?}", + action="repo_locking", conditions=dict(method=["PUT"], + function=check_repo)) with rmap.submapper(path_prefix=ADMIN_PREFIX, controller='admin/repos_groups') as m: m.connect("repos_groups", "/repos_groups", diff --git a/rhodecode/controllers/admin/repos.py b/rhodecode/controllers/admin/repos.py --- a/rhodecode/controllers/admin/repos.py +++ b/rhodecode/controllers/admin/repos.py @@ -381,6 +381,7 @@ class ReposController(BaseController): RepoModel().delete_stats(repo_name) Session().commit() except Exception, e: + log.error(traceback.format_exc()) h.flash(_('An error occurred during deletion of repository stats'), category='error') return redirect(url('edit_repo', repo_name=repo_name)) @@ -397,11 +398,32 @@ class ReposController(BaseController): ScmModel().mark_for_invalidation(repo_name) Session().commit() except Exception, e: + log.error(traceback.format_exc()) h.flash(_('An error occurred during cache invalidation'), category='error') return redirect(url('edit_repo', repo_name=repo_name)) @HasPermissionAllDecorator('hg.admin') + def repo_locking(self, repo_name): + """ + Unlock repository when it is locked ! + + :param repo_name: + """ + + try: + repo = Repository.get_by_repo_name(repo_name) + if request.POST.get('set_lock'): + Repository.lock(repo, c.rhodecode_user.user_id) + elif request.POST.get('set_unlock'): + Repository.unlock(repo) + except Exception, e: + log.error(traceback.format_exc()) + h.flash(_('An error occurred during unlocking'), + category='error') + return redirect(url('edit_repo', repo_name=repo_name)) + + @HasPermissionAllDecorator('hg.admin') def repo_public_journal(self, repo_name): """ Set's this repository to be visible in public journal, diff --git a/rhodecode/lib/auth.py b/rhodecode/lib/auth.py --- a/rhodecode/lib/auth.py +++ b/rhodecode/lib/auth.py @@ -807,7 +807,7 @@ class HasPermissionAnyMiddleware(object) return self.check_permissions() def check_permissions(self): - log.debug('checking mercurial protocol ' + log.debug('checking VCS protocol ' 'permissions %s for user:%s repository:%s', self.user_perms, self.username, self.repo_name) if self.required_perms.intersection(self.user_perms): diff --git a/rhodecode/lib/base.py b/rhodecode/lib/base.py --- a/rhodecode/lib/base.py +++ b/rhodecode/lib/base.py @@ -8,6 +8,7 @@ import traceback from paste.auth.basic import AuthBasicAuthenticator from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden +from webob.exc import HTTPClientError from paste.httpheaders import WWW_AUTHENTICATE from pylons import config, tmpl_context as c, request, session, url @@ -17,15 +18,17 @@ from pylons.templating import render_mak from rhodecode import __version__, BACKENDS -from rhodecode.lib.utils2 import str2bool, safe_unicode, AttributeDict +from rhodecode.lib.utils2 import str2bool, safe_unicode, AttributeDict,\ + safe_str from rhodecode.lib.auth import AuthUser, get_container_username, authfunc,\ HasPermissionAnyMiddleware, CookieStoreWrapper from rhodecode.lib.utils import get_repo_slug, invalidate_cache from rhodecode.model import meta -from rhodecode.model.db import Repository, RhodeCodeUi +from rhodecode.model.db import Repository, RhodeCodeUi, User from rhodecode.model.notification import NotificationModel from rhodecode.model.scm import ScmModel +from rhodecode.model.meta import Session log = logging.getLogger(__name__) @@ -159,6 +162,49 @@ class BaseVCSController(object): return False return True + def _check_locking_state(self, environ, action, repo, user_id): + """ + Checks locking on this repository, if locking is enabled and lock is + present returns a tuple of make_lock, locked, locked_by. + make_lock can have 3 states None (do nothing) True, make lock + False release lock, This value is later propagated to hooks, which + do the locking. Think about this as signals passed to hooks what to do. + + """ + locked = False + make_lock = None + repo = Repository.get_by_repo_name(repo) + user = User.get(user_id) + + # this is kind of hacky, but due to how mercurial handles client-server + # server see all operation on changeset; bookmarks, phases and + # obsolescence marker in different transaction, we don't want to check + # locking on those + obsolete_call = environ['QUERY_STRING'] in ['cmd=listkeys',] + locked_by = repo.locked + if repo and repo.enable_locking and not obsolete_call: + if action == 'push': + #check if it's already locked !, if it is compare users + user_id, _date = repo.locked + if user.user_id == user_id: + log.debug('Got push from user, now unlocking' % (user)) + # unlock if we have push from user who locked + make_lock = False + else: + # we're not the same user who locked, ban with 423 ! + locked = True + if action == 'pull': + if repo.locked[0] and repo.locked[1]: + locked = True + else: + log.debug('Setting lock on repo %s by %s' % (repo, user)) + make_lock = True + + else: + log.debug('Repository %s do not have locking enabled' % (repo)) + + return make_lock, locked, locked_by + def __call__(self, environ, start_response): start = time.time() try: diff --git a/rhodecode/lib/db_manage.py b/rhodecode/lib/db_manage.py --- a/rhodecode/lib/db_manage.py +++ b/rhodecode/lib/db_manage.py @@ -307,37 +307,47 @@ class DbManage(object): hooks1.ui_key = hooks1_key hooks1.ui_value = 'hg update >&2' hooks1.ui_active = False + self.sa.add(hooks1) hooks2_key = RhodeCodeUi.HOOK_REPO_SIZE hooks2_ = self.sa.query(RhodeCodeUi)\ .filter(RhodeCodeUi.ui_key == hooks2_key).scalar() - hooks2 = RhodeCodeUi() if hooks2_ is None else hooks2_ hooks2.ui_section = 'hooks' hooks2.ui_key = hooks2_key hooks2.ui_value = 'python:rhodecode.lib.hooks.repo_size' + self.sa.add(hooks2) hooks3 = RhodeCodeUi() hooks3.ui_section = 'hooks' hooks3.ui_key = RhodeCodeUi.HOOK_PUSH hooks3.ui_value = 'python:rhodecode.lib.hooks.log_push_action' + self.sa.add(hooks3) hooks4 = RhodeCodeUi() hooks4.ui_section = 'hooks' - hooks4.ui_key = RhodeCodeUi.HOOK_PULL - hooks4.ui_value = 'python:rhodecode.lib.hooks.log_pull_action' + hooks4.ui_key = RhodeCodeUi.HOOK_PRE_PUSH + hooks4.ui_value = 'python:rhodecode.lib.hooks.pre_push' + self.sa.add(hooks4) - # For mercurial 1.7 set backward comapatibility with format - dotencode_disable = RhodeCodeUi() - dotencode_disable.ui_section = 'format' - dotencode_disable.ui_key = 'dotencode' - dotencode_disable.ui_value = 'false' + hooks5 = RhodeCodeUi() + hooks5.ui_section = 'hooks' + hooks5.ui_key = RhodeCodeUi.HOOK_PULL + hooks5.ui_value = 'python:rhodecode.lib.hooks.log_pull_action' + self.sa.add(hooks5) + + hooks6 = RhodeCodeUi() + hooks6.ui_section = 'hooks' + hooks6.ui_key = RhodeCodeUi.HOOK_PRE_PULL + hooks6.ui_value = 'python:rhodecode.lib.hooks.pre_pull' + self.sa.add(hooks6) # enable largefiles largefiles = RhodeCodeUi() largefiles.ui_section = 'extensions' largefiles.ui_key = 'largefiles' largefiles.ui_value = '' + self.sa.add(largefiles) # enable hgsubversion disabled by default hgsubversion = RhodeCodeUi() @@ -345,6 +355,7 @@ class DbManage(object): hgsubversion.ui_key = 'hgsubversion' hgsubversion.ui_value = '' hgsubversion.ui_active = False + self.sa.add(hgsubversion) # enable hggit disabled by default hggit = RhodeCodeUi() @@ -352,13 +363,6 @@ class DbManage(object): hggit.ui_key = 'hggit' hggit.ui_value = '' hggit.ui_active = False - - self.sa.add(hooks1) - self.sa.add(hooks2) - self.sa.add(hooks3) - self.sa.add(hooks4) - self.sa.add(largefiles) - self.sa.add(hgsubversion) self.sa.add(hggit) def create_ldap_options(self, skip_existing=False): @@ -461,6 +465,11 @@ class DbManage(object): paths.ui_key = '/' paths.ui_value = path + phases = RhodeCodeUi() + phases.ui_section = 'phases' + phases.ui_key = 'publish' + phases.ui_value = False + sett1 = RhodeCodeSetting('realm', 'RhodeCode authentication') sett2 = RhodeCodeSetting('title', 'RhodeCode') sett3 = RhodeCodeSetting('ga_code', '') diff --git a/rhodecode/lib/exceptions.py b/rhodecode/lib/exceptions.py --- a/rhodecode/lib/exceptions.py +++ b/rhodecode/lib/exceptions.py @@ -23,6 +23,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from webob.exc import HTTPClientError + class LdapUsernameError(Exception): pass @@ -53,4 +55,17 @@ class UsersGroupsAssignedException(Excep class StatusChangeOnClosedPullRequestError(Exception): - pass \ No newline at end of file + pass + + +class HTTPLockedRC(HTTPClientError): + """ + Special Exception For locked Repos in RhodeCode + """ + code = 423 + title = explanation = 'Repository Locked' + + def __init__(self, reponame, username, *args, **kwargs): + self.title = self.explanation = ('Repository `%s` locked by ' + 'user `%s`' % (reponame, username)) + super(HTTPLockedRC, self).__init__(*args, **kwargs) diff --git a/rhodecode/lib/helpers.py b/rhodecode/lib/helpers.py --- a/rhodecode/lib/helpers.py +++ b/rhodecode/lib/helpers.py @@ -41,7 +41,7 @@ from webhelpers.html.tags import _set_in from rhodecode.lib.annotate import annotate_highlight from rhodecode.lib.utils import repo_name_slug from rhodecode.lib.utils2 import str2bool, safe_unicode, safe_str, \ - get_changeset_safe + get_changeset_safe, datetime_to_time, time_to_datetime from rhodecode.lib.markup_renderer import MarkupRenderer from rhodecode.lib.vcs.exceptions import ChangesetDoesNotExistError from rhodecode.lib.vcs.backends.base import BaseChangeset @@ -439,6 +439,19 @@ def person(author): return _author +def person_by_id(id_): + # attr to return from fetched user + person_getter = lambda usr: usr.username + + #maybe it's an ID ? + if str(id_).isdigit() or isinstance(id_, int): + id_ = int(id_) + user = User.get(id_) + if user is not None: + return person_getter(user) + return id_ + + def desc_stylize(value): """ converts tags from value into html equivalent diff --git a/rhodecode/lib/hooks.py b/rhodecode/lib/hooks.py --- a/rhodecode/lib/hooks.py +++ b/rhodecode/lib/hooks.py @@ -34,6 +34,9 @@ from rhodecode.lib import helpers as h from rhodecode.lib.utils import action_logger from rhodecode.lib.vcs.backends.base import EmptyChangeset from rhodecode.lib.compat import json +from rhodecode.model.db import Repository, User +from rhodecode.lib.utils2 import safe_str +from rhodecode.lib.exceptions import HTTPLockedRC def _get_scm_size(alias, root_path): @@ -84,6 +87,59 @@ def repo_size(ui, repo, hooktype=None, * sys.stdout.write(msg) +def pre_push(ui, repo, **kwargs): + # pre push function, currently used to ban pushing when + # repository is locked + try: + rc_extras = json.loads(os.environ.get('RC_SCM_DATA', "{}")) + except: + rc_extras = {} + extras = dict(repo.ui.configitems('rhodecode_extras')) + + if 'username' in extras: + username = extras['username'] + repository = extras['repository'] + scm = extras['scm'] + locked_by = extras['locked_by'] + elif 'username' in rc_extras: + username = rc_extras['username'] + repository = rc_extras['repository'] + scm = rc_extras['scm'] + locked_by = rc_extras['locked_by'] + else: + raise Exception('Missing data in repo.ui and os.environ') + + usr = User.get_by_username(username) + + if locked_by[0] and usr.user_id != int(locked_by[0]): + raise HTTPLockedRC(username, repository) + + +def pre_pull(ui, repo, **kwargs): + # pre push function, currently used to ban pushing when + # repository is locked + try: + rc_extras = json.loads(os.environ.get('RC_SCM_DATA', "{}")) + except: + rc_extras = {} + extras = dict(repo.ui.configitems('rhodecode_extras')) + if 'username' in extras: + username = extras['username'] + repository = extras['repository'] + scm = extras['scm'] + locked_by = extras['locked_by'] + elif 'username' in rc_extras: + username = rc_extras['username'] + repository = rc_extras['repository'] + scm = rc_extras['scm'] + locked_by = rc_extras['locked_by'] + else: + raise Exception('Missing data in repo.ui and os.environ') + + if locked_by[0]: + raise HTTPLockedRC(username, repository) + + def log_pull_action(ui, repo, **kwargs): """ Logs user last pull action @@ -100,15 +156,17 @@ def log_pull_action(ui, repo, **kwargs): username = extras['username'] repository = extras['repository'] scm = extras['scm'] + make_lock = extras['make_lock'] elif 'username' in rc_extras: username = rc_extras['username'] repository = rc_extras['repository'] scm = rc_extras['scm'] + make_lock = rc_extras['make_lock'] else: raise Exception('Missing data in repo.ui and os.environ') - + user = User.get_by_username(username) action = 'pull' - action_logger(username, action, repository, extras['ip'], commit=True) + action_logger(user, action, repository, extras['ip'], commit=True) # extension hook call from rhodecode import EXTENSIONS callback = getattr(EXTENSIONS, 'PULL_HOOK', None) @@ -117,6 +175,12 @@ def log_pull_action(ui, repo, **kwargs): kw = {} kw.update(extras) callback(**kw) + + if make_lock is True: + Repository.lock(Repository.get_by_repo_name(repository), user.user_id) + #msg = 'Made lock on repo `%s`' % repository + #sys.stdout.write(msg) + return 0 @@ -138,10 +202,12 @@ def log_push_action(ui, repo, **kwargs): username = extras['username'] repository = extras['repository'] scm = extras['scm'] + make_lock = extras['make_lock'] elif 'username' in rc_extras: username = rc_extras['username'] repository = rc_extras['repository'] scm = rc_extras['scm'] + make_lock = rc_extras['make_lock'] else: raise Exception('Missing data in repo.ui and os.environ') @@ -179,6 +245,12 @@ def log_push_action(ui, repo, **kwargs): kw = {'pushed_revs': revs} kw.update(extras) callback(**kw) + + if make_lock is False: + Repository.unlock(Repository.get_by_repo_name(repository)) + msg = 'Released lock on repo `%s`\n' % repository + sys.stdout.write(msg) + return 0 @@ -219,8 +291,13 @@ def log_create_repository(repository_dic return 0 +handle_git_pre_receive = (lambda repo_path, revs, env: + handle_git_receive(repo_path, revs, env, hook_type='pre')) +handle_git_post_receive = (lambda repo_path, revs, env: + handle_git_receive(repo_path, revs, env, hook_type='post')) -def handle_git_post_receive(repo_path, revs, env): + +def handle_git_receive(repo_path, revs, env, hook_type='post'): """ A really hacky method that is runned by git post-receive hook and logs an push action together with pushed revisions. It's executed by subprocess @@ -240,7 +317,6 @@ def handle_git_post_receive(repo_path, r from rhodecode.model import init_model from rhodecode.model.db import RhodeCodeUi from rhodecode.lib.utils import make_ui - from rhodecode.model.db import Repository path, ini_name = os.path.split(env['RHODECODE_CONFIG_FILE']) conf = appconfig('config:%s' % ini_name, relative_to=path) @@ -255,20 +331,18 @@ def handle_git_post_receive(repo_path, r repo_path = repo_path[:-4] repo = Repository.get_by_full_path(repo_path) _hooks = dict(baseui.configitems('hooks')) or {} - # if push hook is enabled via web interface - if repo and _hooks.get(RhodeCodeUi.HOOK_PUSH): - extras = { - 'username': env['RHODECODE_USER'], - 'repository': repo.repo_name, - 'scm': 'git', - 'action': 'push', - 'ip': env['RHODECODE_CONFIG_IP'], - } - for k, v in extras.items(): - baseui.setconfig('rhodecode_extras', k, v) - repo = repo.scm_instance - repo.ui = baseui + extras = json.loads(env['RHODECODE_EXTRAS']) + for k, v in extras.items(): + baseui.setconfig('rhodecode_extras', k, v) + repo = repo.scm_instance + repo.ui = baseui + + if hook_type == 'pre': + pre_push(baseui, repo) + + # if push hook is enabled via web interface + elif hook_type == 'post' and _hooks.get(RhodeCodeUi.HOOK_PUSH): rev_data = [] for l in revs: diff --git a/rhodecode/lib/middleware/pygrack.py b/rhodecode/lib/middleware/pygrack.py --- a/rhodecode/lib/middleware/pygrack.py +++ b/rhodecode/lib/middleware/pygrack.py @@ -41,7 +41,7 @@ class GitRepository(object): git_folder_signature = set(['config', 'head', 'info', 'objects', 'refs']) commands = ['git-upload-pack', 'git-receive-pack'] - def __init__(self, repo_name, content_path, username): + def __init__(self, repo_name, content_path, extras): files = set([f.lower() for f in os.listdir(content_path)]) if not (self.git_folder_signature.intersection(files) == self.git_folder_signature): @@ -50,7 +50,7 @@ class GitRepository(object): self.valid_accepts = ['application/x-%s-result' % c for c in self.commands] self.repo_name = repo_name - self.username = username + self.extras = extras def _get_fixedpath(self, path): """ @@ -67,7 +67,7 @@ class GitRepository(object): HTTP /info/refs request. """ - git_command = request.GET['service'] + git_command = request.GET.get('service') if git_command not in self.commands: log.debug('command %s not allowed' % git_command) return exc.HTTPMethodNotAllowed() @@ -119,9 +119,8 @@ class GitRepository(object): try: gitenv = os.environ from rhodecode import CONFIG - from rhodecode.lib.base import _get_ip_addr - gitenv['RHODECODE_USER'] = self.username - gitenv['RHODECODE_CONFIG_IP'] = _get_ip_addr(environ) + from rhodecode.lib.compat import json + gitenv['RHODECODE_EXTRAS'] = json.dumps(self.extras) # forget all configs gitenv['GIT_CONFIG_NOGLOBAL'] = '1' # we need current .ini file used to later initialize rhodecode @@ -174,7 +173,7 @@ class GitRepository(object): class GitDirectory(object): - def __init__(self, repo_root, repo_name, username): + def __init__(self, repo_root, repo_name, extras): repo_location = os.path.join(repo_root, repo_name) if not os.path.isdir(repo_location): raise OSError(repo_location) @@ -182,12 +181,12 @@ class GitDirectory(object): self.content_path = repo_location self.repo_name = repo_name self.repo_location = repo_location - self.username = username + self.extras = extras def __call__(self, environ, start_response): content_path = self.content_path try: - app = GitRepository(self.repo_name, content_path, self.username) + app = GitRepository(self.repo_name, content_path, self.extras) except (AssertionError, OSError): if os.path.isdir(os.path.join(content_path, '.git')): app = GitRepository(self.repo_name, @@ -198,5 +197,5 @@ class GitDirectory(object): return app(environ, start_response) -def make_wsgi_app(repo_name, repo_root, username): - return GitDirectory(repo_root, repo_name, username) +def make_wsgi_app(repo_name, repo_root, extras): + return GitDirectory(repo_root, repo_name, extras) diff --git a/rhodecode/lib/middleware/simplegit.py b/rhodecode/lib/middleware/simplegit.py --- a/rhodecode/lib/middleware/simplegit.py +++ b/rhodecode/lib/middleware/simplegit.py @@ -31,6 +31,8 @@ import traceback from dulwich import server as dulserver from dulwich.web import LimitedInputFilter, GunzipFilter +from rhodecode.lib.exceptions import HTTPLockedRC +from rhodecode.lib.hooks import pre_pull class SimpleGitUploadPackHandler(dulserver.UploadPackHandler): @@ -102,11 +104,11 @@ def is_git(environ): class SimpleGit(BaseVCSController): def _handle_request(self, environ, start_response): - if not is_git(environ): return self.application(environ, start_response) if not self._check_ssl(environ, start_response): return HTTPNotAcceptable('SSL REQUIRED !')(environ, start_response) + ipaddr = self._get_ip_addr(environ) username = None self._git_first_op = False @@ -184,21 +186,39 @@ class SimpleGit(BaseVCSController): if perm is not True: return HTTPForbidden()(environ, start_response) + # extras are injected into UI object and later available + # in hooks executed by rhodecode extras = { 'ip': ipaddr, 'username': username, 'action': action, 'repository': repo_name, 'scm': 'git', + 'make_lock': None, + 'locked_by': [None, None] } - # set the environ variables for this request - os.environ['RC_SCM_DATA'] = json.dumps(extras) + #=================================================================== # GIT REQUEST HANDLING #=================================================================== repo_path = os.path.join(safe_str(self.basepath), safe_str(repo_name)) log.debug('Repository path is %s' % repo_path) + # CHECK LOCKING only if it's not ANONYMOUS USER + if username != User.DEFAULT_USER: + log.debug('Checking locking on repository') + (make_lock, + locked, + locked_by) = self._check_locking_state( + environ=environ, action=action, + repo=repo_name, user_id=user.user_id + ) + # store the make_lock for later evaluation in hooks + extras.update({'make_lock': make_lock, + 'locked_by': locked_by}) + # set the environ variables for this request + os.environ['RC_SCM_DATA'] = json.dumps(extras) + log.debug('HOOKS extras is %s' % extras) baseui = make_ui('db') self.__inject_extras(repo_path, baseui, extras) @@ -209,13 +229,16 @@ class SimpleGit(BaseVCSController): self._handle_githooks(repo_name, action, baseui, environ) log.info('%s action on GIT repo "%s"' % (action, repo_name)) - app = self.__make_app(repo_name, repo_path, username) + app = self.__make_app(repo_name, repo_path, extras) return app(environ, start_response) + except HTTPLockedRC, e: + log.debug('Repositry LOCKED ret code 423!') + return e(environ, start_response) except Exception: log.error(traceback.format_exc()) return HTTPInternalServerError()(environ, start_response) - def __make_app(self, repo_name, repo_path, username): + def __make_app(self, repo_name, repo_path, extras): """ Make an wsgi application using dulserver @@ -227,7 +250,7 @@ class SimpleGit(BaseVCSController): app = make_wsgi_app( repo_root=safe_str(self.basepath), repo_name=repo_name, - username=username, + extras=extras, ) app = GunzipFilter(LimitedInputFilter(app)) return app @@ -279,6 +302,7 @@ class SimpleGit(BaseVCSController): """ from rhodecode.lib.hooks import log_pull_action service = environ['QUERY_STRING'].split('=') + if len(service) < 2: return @@ -288,6 +312,9 @@ class SimpleGit(BaseVCSController): _repo._repo.ui = baseui _hooks = dict(baseui.configitems('hooks')) or {} + if action == 'pull': + # stupid git, emulate pre-pull hook ! + pre_pull(ui=baseui, repo=_repo._repo) if action == 'pull' and _hooks.get(RhodeCodeUi.HOOK_PULL): log_pull_action(ui=baseui, repo=_repo._repo) diff --git a/rhodecode/lib/middleware/simplehg.py b/rhodecode/lib/middleware/simplehg.py --- a/rhodecode/lib/middleware/simplehg.py +++ b/rhodecode/lib/middleware/simplehg.py @@ -42,6 +42,7 @@ from rhodecode.lib.auth import get_conta from rhodecode.lib.utils import make_ui, is_valid_repo, ui_sections from rhodecode.lib.compat import json from rhodecode.model.db import User +from rhodecode.lib.exceptions import HTTPLockedRC log = logging.getLogger(__name__) @@ -157,15 +158,31 @@ class SimpleHg(BaseVCSController): 'action': action, 'repository': repo_name, 'scm': 'hg', + 'make_lock': None, + 'locked_by': [None, None] } - # set the environ variables for this request - os.environ['RC_SCM_DATA'] = json.dumps(extras) #====================================================================== # MERCURIAL REQUEST HANDLING #====================================================================== repo_path = os.path.join(safe_str(self.basepath), safe_str(repo_name)) log.debug('Repository path is %s' % repo_path) + # CHECK LOCKING only if it's not ANONYMOUS USER + if username != User.DEFAULT_USER: + log.debug('Checking locking on repository') + (make_lock, + locked, + locked_by) = self._check_locking_state( + environ=environ, action=action, + repo=repo_name, user_id=user.user_id + ) + # store the make_lock for later evaluation in hooks + extras.update({'make_lock': make_lock, + 'locked_by': locked_by}) + + # set the environ variables for this request + os.environ['RC_SCM_DATA'] = json.dumps(extras) + log.debug('HOOKS extras is %s' % extras) baseui = make_ui('db') self.__inject_extras(repo_path, baseui, extras) @@ -179,6 +196,9 @@ class SimpleHg(BaseVCSController): except RepoError, e: if str(e).find('not found') != -1: return HTTPNotFound()(environ, start_response) + except HTTPLockedRC, e: + log.debug('Repositry LOCKED ret code 423!') + return e(environ, start_response) except Exception: log.error(traceback.format_exc()) return HTTPInternalServerError()(environ, start_response) diff --git a/rhodecode/lib/utils2.py b/rhodecode/lib/utils2.py --- a/rhodecode/lib/utils2.py +++ b/rhodecode/lib/utils2.py @@ -25,7 +25,7 @@ import re import time -from datetime import datetime +import datetime from pylons.i18n.translation import _, ungettext from rhodecode.lib.vcs.utils.lazy import LazyProperty @@ -300,7 +300,7 @@ def age(prevdate): deltas = {} # Get date parts deltas - now = datetime.now() + now = datetime.datetime.now() for part in order: deltas[part] = getattr(now, part) - getattr(prevdate, part) @@ -435,6 +435,15 @@ def datetime_to_time(dt): return time.mktime(dt.timetuple()) +def time_to_datetime(tm): + if tm: + if isinstance(tm, basestring): + try: + tm = float(tm) + except ValueError: + return + return datetime.datetime.fromtimestamp(tm) + MENTIONS_REGEX = r'(?:^@|\s@)([a-zA-Z0-9]{1}[a-zA-Z0-9\-\_\.]+)(?:\s{1})' diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -28,6 +28,7 @@ import logging import datetime import traceback import hashlib +import time from collections import defaultdict from sqlalchemy import * @@ -232,7 +233,9 @@ class RhodeCodeUi(Base, BaseModel): HOOK_UPDATE = 'changegroup.update' HOOK_REPO_SIZE = 'changegroup.repo_size' HOOK_PUSH = 'changegroup.push_logger' - HOOK_PULL = 'preoutgoing.pull_logger' + HOOK_PRE_PUSH = 'prechangegroup.pre_push' + HOOK_PULL = 'outgoing.pull_logger' + HOOK_PRE_PULL = 'preoutgoing.pre_pull' ui_id = Column("ui_id", Integer(), nullable=False, unique=True, default=None, primary_key=True) ui_section = Column("ui_section", String(255, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) @@ -247,17 +250,17 @@ class RhodeCodeUi(Base, BaseModel): @classmethod def get_builtin_hooks(cls): q = cls.query() - q = q.filter(cls.ui_key.in_([cls.HOOK_UPDATE, - cls.HOOK_REPO_SIZE, - cls.HOOK_PUSH, cls.HOOK_PULL])) + q = q.filter(cls.ui_key.in_([cls.HOOK_UPDATE, cls.HOOK_REPO_SIZE, + cls.HOOK_PUSH, cls.HOOK_PRE_PUSH, + cls.HOOK_PULL, cls.HOOK_PRE_PULL])) return q.all() @classmethod def get_custom_hooks(cls): q = cls.query() - q = q.filter(~cls.ui_key.in_([cls.HOOK_UPDATE, - cls.HOOK_REPO_SIZE, - cls.HOOK_PUSH, cls.HOOK_PULL])) + q = q.filter(~cls.ui_key.in_([cls.HOOK_UPDATE, cls.HOOK_REPO_SIZE, + cls.HOOK_PUSH, cls.HOOK_PRE_PUSH, + cls.HOOK_PULL, cls.HOOK_PRE_PULL])) q = q.filter(cls.ui_section == 'hooks') return q.all() @@ -280,9 +283,13 @@ class User(Base, BaseModel): __tablename__ = 'users' __table_args__ = ( UniqueConstraint('username'), UniqueConstraint('email'), + Index('u_username_idx', 'username'), + Index('u_email_idx', 'email'), {'extend_existing': True, 'mysql_engine': 'InnoDB', 'mysql_charset': 'utf8'} ) + DEFAULT_USER = 'default' + user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True) username = Column("username", String(255, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) password = Column("password", String(255, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) @@ -572,6 +579,7 @@ class Repository(Base, BaseModel): __tablename__ = 'repositories' __table_args__ = ( UniqueConstraint('repo_name'), + Index('r_repo_name_idx', 'repo_name'), {'extend_existing': True, 'mysql_engine': 'InnoDB', 'mysql_charset': 'utf8'}, ) @@ -587,6 +595,8 @@ class Repository(Base, BaseModel): description = Column("description", String(10000, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) created_on = Column('created_on', DateTime(timezone=False), nullable=True, unique=None, default=datetime.datetime.now) landing_rev = Column("landing_revision", String(255, convert_unicode=False, assert_unicode=None), nullable=False, unique=False, default=None) + enable_locking = Column("enable_locking", Boolean(), nullable=False, unique=None, default=False) + _locked = Column("locked", String(255, convert_unicode=False, assert_unicode=None), nullable=True, unique=False, default=None) fork_id = Column("fork_id", Integer(), ForeignKey('repositories.repo_id'), nullable=True, unique=False, default=None) group_id = Column("group_id", Integer(), ForeignKey('groups.group_id'), nullable=True, unique=False, default=None) @@ -617,6 +627,21 @@ class Repository(Base, BaseModel): return u"<%s('%s:%s')>" % (self.__class__.__name__, self.repo_id, self.repo_name) + @hybrid_property + def locked(self): + # always should return [user_id, timelocked] + if self._locked: + _lock_info = self._locked.split(':') + return int(_lock_info[0]), _lock_info[1] + return [None, None] + + @locked.setter + def locked(self, val): + if val and isinstance(val, (list, tuple)): + self._locked = ':'.join(map(str, val)) + else: + self._locked = None + @classmethod def url_sep(cls): return URL_SEP @@ -744,7 +769,7 @@ class Repository(Base, BaseModel): if ui_.ui_key == 'push_ssl': # force set push_ssl requirement to False, rhodecode # handles that - baseui.setconfig(ui_.ui_section, ui_.ui_key, False) + baseui.setconfig(ui_.ui_section, ui_.ui_key, False) return baseui @@ -793,6 +818,18 @@ class Repository(Base, BaseModel): return data + @classmethod + def lock(cls, repo, user_id): + repo.locked = [user_id, time.time()] + Session().add(repo) + Session().commit() + + @classmethod + def unlock(cls, repo): + repo.locked = None + Session().add(repo) + Session().commit() + #========================================================================== # SCM PROPERTIES #========================================================================== diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -182,6 +182,7 @@ def RepoForm(edit=False, old_data={}, su private = v.StringBoolean(if_missing=False) enable_statistics = v.StringBoolean(if_missing=False) enable_downloads = v.StringBoolean(if_missing=False) + enable_locking = v.StringBoolean(if_missing=False) landing_rev = v.OneOf(landing_revs, hideList=True) if edit: @@ -265,7 +266,7 @@ def ApplicationUiSettingsForm(): hooks_changegroup_update = v.StringBoolean(if_missing=False) hooks_changegroup_repo_size = v.StringBoolean(if_missing=False) hooks_changegroup_push_logger = v.StringBoolean(if_missing=False) - hooks_preoutgoing_pull_logger = v.StringBoolean(if_missing=False) + hooks_outgoing_pull_logger = v.StringBoolean(if_missing=False) extensions_largefiles = v.StringBoolean(if_missing=False) extensions_hgsubversion = v.StringBoolean(if_missing=False) diff --git a/rhodecode/model/scm.py b/rhodecode/model/scm.py --- a/rhodecode/model/scm.py +++ b/rhodecode/model/scm.py @@ -571,34 +571,41 @@ class ScmModel(BaseModel): if not os.path.isdir(loc): os.makedirs(loc) - tmpl = pkg_resources.resource_string( + tmpl_post = pkg_resources.resource_string( 'rhodecode', jn('config', 'post_receive_tmpl.py') ) + tmpl_pre = pkg_resources.resource_string( + 'rhodecode', jn('config', 'pre_receive_tmpl.py') + ) - _hook_file = jn(loc, 'post-receive') - _rhodecode_hook = False - log.debug('Installing git hook in repo %s' % repo) - if os.path.exists(_hook_file): - # let's take a look at this hook, maybe it's rhodecode ? - log.debug('hook exists, checking if it is from rhodecode') - _HOOK_VER_PAT = re.compile(r'^RC_HOOK_VER') - with open(_hook_file, 'rb') as f: - data = f.read() - matches = re.compile(r'(?:%s)\s*=\s*(.*)' - % 'RC_HOOK_VER').search(data) - if matches: - try: - ver = matches.groups()[0] - log.debug('got %s it is rhodecode' % (ver)) - _rhodecode_hook = True - except: - log.error(traceback.format_exc()) + for h_type, tmpl in [('pre', tmpl_pre), ('post', tmpl_post)]: + _hook_file = jn(loc, '%s-receive' % h_type) + _rhodecode_hook = False + log.debug('Installing git hook in repo %s' % repo) + if os.path.exists(_hook_file): + # let's take a look at this hook, maybe it's rhodecode ? + log.debug('hook exists, checking if it is from rhodecode') + _HOOK_VER_PAT = re.compile(r'^RC_HOOK_VER') + with open(_hook_file, 'rb') as f: + data = f.read() + matches = re.compile(r'(?:%s)\s*=\s*(.*)' + % 'RC_HOOK_VER').search(data) + if matches: + try: + ver = matches.groups()[0] + log.debug('got %s it is rhodecode' % (ver)) + _rhodecode_hook = True + except: + log.error(traceback.format_exc()) + else: + # there is no hook in this dir, so we want to create one + _rhodecode_hook = True - if _rhodecode_hook or force_create: - log.debug('writing hook file !') - with open(_hook_file, 'wb') as f: - tmpl = tmpl.replace('_TMPL_', rhodecode.__version__) - f.write(tmpl) - os.chmod(_hook_file, 0755) - else: - log.debug('skipping writing hook file') + if _rhodecode_hook or force_create: + log.debug('writing %s hook file !' % h_type) + with open(_hook_file, 'wb') as f: + tmpl = tmpl.replace('_TMPL_', rhodecode.__version__) + f.write(tmpl) + os.chmod(_hook_file, 0755) + else: + log.debug('skipping writing hook file') diff --git a/rhodecode/templates/admin/repos/repo_edit.html b/rhodecode/templates/admin/repos/repo_edit.html --- a/rhodecode/templates/admin/repos/repo_edit.html +++ b/rhodecode/templates/admin/repos/repo_edit.html @@ -108,6 +108,15 @@
+
+ +
+
+ ${h.checkbox('enable_locking',value="True")} + ${_('Enable lock-by-pulling on repository.')} +
+
+
@@ -196,26 +205,31 @@
    -
  • ${_('''All actions made on this repository will be accessible to everyone in public journal''')} +
  • ${_('All actions made on this repository will be accessible to everyone in public journal')}
${h.end_form()} -

${_('Delete')}

- ${h.form(url('repo', repo_name=c.repo_info.repo_name),method='delete')} +

${_('Locking')}

+ ${h.form(url('repo_locking', repo_name=c.repo_info.repo_name),method='put')}
- ${h.submit('remove_%s' % c.repo_info.repo_name,_('Remove this repository'),class_="ui-btn red",onclick="return confirm('"+_('Confirm to delete this repository')+"');")} + %if c.repo_info.locked[0]: + ${h.submit('set_unlock' ,_('Unlock locked repo'),class_="ui-btn",onclick="return confirm('"+_('Confirm to unlock repository')+"');")} + ${'Locked by %s on %s' % (h.person_by_id(c.repo_info.locked[0]),h.fmt_date(h.time_to_datetime(c.repo_info.locked[1])))} + %else: + ${h.submit('set_lock',_('lock repo'),class_="ui-btn",onclick="return confirm('"+_('Confirm to lock repository')+"');")} + ${_('Repository is not locked')} + %endif
    -
  • ${_('''This repository will be renamed in a special way in order to be unaccesible for RhodeCode and VCS systems. - If you need fully delete it from filesystem please do it manually''')} +
  • ${_('Force locking on repository. Works only when anonymous access is disabled')}
-
+
${h.end_form()} @@ -231,10 +245,24 @@
  • ${_('''Manually set this repository as a fork of another from the list''')}
  • - + ${h.end_form()} - + +

    ${_('Delete')}

    + ${h.form(url('repo', repo_name=c.repo_info.repo_name),method='delete')} +
    +
    + ${h.submit('remove_%s' % c.repo_info.repo_name,_('Remove this repository'),class_="ui-btn red",onclick="return confirm('"+_('Confirm to delete this repository')+"');")} +
    +
    +
      +
    • ${_('''This repository will be renamed in a special way in order to be unaccesible for RhodeCode and VCS systems. + If you need fully delete it from filesystem please do it manually''')} +
    • +
    +
    +
    + ${h.end_form()} - diff --git a/rhodecode/templates/admin/settings/settings.html b/rhodecode/templates/admin/settings/settings.html --- a/rhodecode/templates/admin/settings/settings.html +++ b/rhodecode/templates/admin/settings/settings.html @@ -211,8 +211,8 @@
    - ${h.checkbox('hooks_preoutgoing_pull_logger','True')} - + ${h.checkbox('hooks_outgoing_pull_logger','True')} +