launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18221
Re: [Merge] lp:~wgrant/turnip/pre-receive into lp:turnip
Review: Approve
Diff comments:
> === added directory 'turnip/pack/data'
> === modified file 'turnip/pack/git.py'
> --- turnip/pack/git.py 2015-03-09 11:53:40 +0000
> +++ turnip/pack/git.py 2015-03-30 09:20:00 +0000
> @@ -5,6 +5,7 @@
> )
>
> import sys
> +import uuid
>
> from twisted.internet import (
> defer,
> @@ -24,6 +25,7 @@
> decode_request,
> encode_packet,
> encode_request,
> + ensure_hooks,
> INCOMPLETE_PKT,
> )
>
> @@ -271,6 +273,8 @@
> Invokes the reference C Git implementation.
> """
>
> + hookrpc_key = None
> +
> def requestReceived(self, command, raw_pathname, params):
> path = compose_path(self.factory.root, raw_pathname)
> if command == b'git-upload-pack':
> @@ -289,8 +293,19 @@
> args.append(b'--advertise-refs')
> args.append(path)
>
> + env = {}
> + if subcmd == b'receive-pack' and self.factory.hookrpc_handler:
> + # This is a write operation, so prepare hooks, the hook RPC
> + # server, and the environment variables that link them up.
> + self.hookrpc_key = str(uuid.uuid4())
> + self.factory.hookrpc_handler.registerKey(
> + self.hookrpc_key, raw_pathname, [])
> + ensure_hooks(path)
> + env[b'TURNIP_HOOK_RPC_SOCK'] = self.factory.hookrpc_sock
> + env[b'TURNIP_HOOK_RPC_KEY'] = self.hookrpc_key
> +
> self.peer = GitProcessProtocol(self)
> - reactor.spawnProcess(self.peer, cmd, args)
> + reactor.spawnProcess(self.peer, cmd, args, env=env)
>
> def readConnectionLost(self):
> self.peer.loseWriteConnection()
> @@ -298,13 +313,20 @@
> def writeConnectionLost(self):
> self.peer.loseReadConnection()
>
> + def connectionLost(self, reason):
> + if self.hookrpc_key:
> + self.factory.hookrpc_handler.unregisterKey(self.hookrpc_key)
> + PackServerProtocol.connectionLost(self, reason)
> +
>
> class PackBackendFactory(protocol.Factory):
>
> protocol = PackBackendProtocol
>
> - def __init__(self, root):
> + def __init__(self, root, hookrpc_handler=None, hookrpc_sock=None):
> self.root = root
> + self.hookrpc_handler = hookrpc_handler
> + self.hookrpc_sock = hookrpc_sock
>
>
> class PackVirtServerProtocol(PackProxyServerProtocol):
>
> === modified file 'turnip/pack/helpers.py'
> --- turnip/pack/helpers.py 2015-01-22 06:14:32 +0000
> +++ turnip/pack/helpers.py 2015-03-30 09:20:00 +0000
> @@ -4,6 +4,16 @@
> unicode_literals,
> )
>
> +import hashlib
> +import os.path
Should logically be just os given that you're using os.rename etc. (Of course it works anyway.)
> +import shutil
> +from tempfile import (
> + mktemp,
> + NamedTemporaryFile,
> + )
> +
> +import turnip.pack.hooks.hook
> +
>
> PKT_LEN_SIZE = 4
> PKT_PAYLOAD_MAX = 65520
> @@ -83,3 +93,48 @@
> raise ValueError('Metacharacter in arguments')
> bits.append(name + b'=' + value)
> return command + b' ' + b'\0'.join(bits) + b'\0'
> +
> +
> +def ensure_hooks(repo_root):
> + """Put a repository's hooks into the desired state.
> +
> + Consistency is maintained even if there are multiple invocations
> + running concurrently. Files starting with tmp* are ignored, and any
> + directories will cause an exception.
> + """
> +
> + wanted_hooks = ('pre-receive', 'post-receive')
> + target_name = 'hook.py'
> +
> + def hook_path(name):
> + return os.path.join(repo_root, 'hooks', name)
> +
> + if not os.path.exists(hook_path(target_name)):
> + need_target = True
> + else:
> + with open(turnip.pack.hooks.hook.__file__, 'rb') as f:
> + wanted = hashlib.sha256(f.read()).hexdigest()
> + with open(hook_path(target_name), 'rb') as f:
> + have = hashlib.sha256(f.read()).hexdigest()
> + if wanted != have:
> + need_target = True
> + else:
> + need_target = False
Maybe we should sanity-check permissions here as well.
> +
> + if need_target:
> + with open(turnip.pack.hooks.hook.__file__, 'rb') as master:
> + with NamedTemporaryFile(dir=hook_path('.'), delete=False) as this:
> + shutil.copyfileobj(master, this)
> + os.chmod(this.name, 0o755)
> + os.rename(this.name, hook_path(target_name))
> +
> + for hook in wanted_hooks:
> + # Not actually insecure, since os.symlink fails if the file exists.
> + path = mktemp(dir=hook_path('.'))
> + os.symlink(target_name, path)
> + os.rename(path, hook_path(hook))
> +
> + for name in os.listdir(hook_path('.')):
> + if (name != target_name and name not in wanted_hooks
> + and not name.startswith('tmp')):
> + os.unlink(hook_path(name))
This can raise an exception if two invocations are running concurrently and race between os.listdir and os.unlink. I expect you could safely ignore errors from os.unlink.
>
> === added file 'turnip/pack/hookrpc.py'
> --- turnip/pack/hookrpc.py 1970-01-01 00:00:00 +0000
> +++ turnip/pack/hookrpc.py 2015-03-30 09:20:00 +0000
> @@ -0,0 +1,110 @@
> +from __future__ import (
> + absolute_import,
> + print_function,
> + unicode_literals,
> + )
> +
> +import json
> +import sys
> +
> +from twisted.internet import (
> + defer,
> + protocol,
> + )
> +from twisted.protocols import basic
> +# twisted.web.xmlrpc doesn't exist for Python 3 yet, but the non-XML-RPC
> +# bits of this module work.
> +if sys.version_info.major < 3:
> + from twisted.web import xmlrpc
> +
> +
> +class JSONNetstringProtocol(basic.NetstringReceiver):
> +
> + def stringReceived(self, string):
> + try:
> + val = json.loads(string.decode('utf-8'))
> + except (ValueError, UnicodeDecodeError):
> + self.invalidValueReceived(string)
> + else:
> + self.valueReceived(val)
> +
> + def valueReceived(self, value):
> + raise NotImplementedError()
> +
> + def invalidValueReceived(self, string):
> + raise NotImplementedError()
> +
> + def sendValue(self, value):
> + self.sendString(json.dumps(value).encode('utf-8'))
I don't know if this matters here, but json.dumps never returns unicode in Python 2 unless ensure_ascii=False. Did you mean to pass that?
> +
> +
> +class RPCServerProtocol(JSONNetstringProtocol):
> +
> + def __init__(self, methods):
> + self.result_log = []
> + self.methods = dict(methods)
> +
> + @defer.inlineCallbacks
> + def valueReceived(self, val):
> + if not isinstance(val, dict):
> + self.sendValue({"error": "Command must be a JSON object"})
> + return
> + val = dict(val)
> + op = val.pop('op', None)
> + if not op:
> + self.sendValue({"error": "No op specified"})
> + return
> + if op not in self.methods:
> + self.sendValue({"error": "Unknown op: %s" % op})
> + return
> + res = yield self.methods[op](self, val)
> + self.sendValue({"result": res})
> +
> + def invalidValueReceived(self, string):
> + self.sendValue({"error": "Command must be a JSON object"})
> +
> +
> +class RPCServerFactory(protocol.ServerFactory):
> +
> + protocol = RPCServerProtocol
> +
> + def __init__(self, methods):
> + self.methods = dict(methods)
> +
> + def buildProtocol(self, addr):
> + return self.protocol(self.methods)
> +
> +
> +class HookRPCHandler(object):
> +
> + def __init__(self, virtinfo_url):
> + self.ref_paths = {}
> + self.ref_rules = {}
> + self.virtinfo_url = virtinfo_url
> +
> + def registerKey(self, key, path, ref_rules):
> + self.ref_paths[key] = path
> + self.ref_rules[key] = ref_rules
> +
> + def unregisterKey(self, key):
> + del self.ref_rules[key]
> + del self.ref_paths[key]
> +
> + def listRefRules(self, proto, args):
> + return self.ref_rules[args['key']]
> +
> + @defer.inlineCallbacks
> + def notifyPush(self, proto, args):
> + path = self.ref_paths[args['key']]
> + proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
> + yield proxy.callRemote(b'notify', path)
> +
> +
> +class HookRPCServerFactory(RPCServerFactory):
> +
> + def __init__(self, hookrpc_handler):
> + self.hookrpc_handler = hookrpc_handler
> + self.methods = {
> + 'list_ref_rules': self.hookrpc_handler.listRefRules,
> + 'notify_push': self.hookrpc_handler.notifyPush,
> + }
>
> === added directory 'turnip/pack/hooks'
> === added file 'turnip/pack/hooks/__init__.py'
> === added file 'turnip/pack/hooks/hook.py'
> --- turnip/pack/hooks/hook.py 1970-01-01 00:00:00 +0000
> +++ turnip/pack/hooks/hook.py 2015-03-30 09:20:00 +0000
> @@ -0,0 +1,84 @@
> +#!/usr/bin/python
> +
> +from __future__ import (
> + absolute_import,
> + print_function,
> + unicode_literals,
> + )
> +
> +import json
> +import os
> +import re
> +import socket
> +import sys
> +
> +
> +def glob_to_re(s):
> + """Convert a glob to a regular expression.
> +
> + The only wildcard supported is "*", to match any path segment.
> + """
> + return b'^%s\Z' % (
> + b''.join(b'[^/]*' if c == b'*' else re.escape(c) for c in s))
It might be worth explaining here why fnmatch isn't good enough. (I infer that that is because we want to treat "/" specially.)
> +
> +
> +def match_rules(rule_lines, ref_lines):
> + rules = [re.compile(glob_to_re(l.rstrip(b'\n'))) for l in rule_lines]
> + # Match each ref against each rule.
> + errors = []
> + for ref_line in ref_lines:
> + old, new, ref = ref_line.rstrip(b'\n').split(b' ', 2)
> + if any(rule.match(ref) for rule in rules):
> + errors.append(b"You can't push to %s." % ref)
> + return errors
> +
> +
> +def netstring_send(sock, s):
> + sock.send(b'%d:%s,' % (len(s), s))
> +
> +
> +def netstring_recv(sock):
> + c = sock.recv(1)
> + lengthstr = ''
> + while c != b':':
> + assert c.isdigit()
> + lengthstr += c
> + c = sock.recv(1)
> + length = int(lengthstr)
> + s = sock.recv(length)
> + assert sock.recv(1) == b','
> + return s
> +
> +
> +def rpc_invoke(sock, method, args):
> + msg = dict(args)
> + assert 'op' not in msg
> + msg['op'] = method
> + netstring_send(sock, json.dumps(msg))
> + res = json.loads(netstring_recv(sock))
> + if 'error' in res:
> + raise Exception(res)
> + return res['result']
> +
> +
> +if __name__ == '__main__':
> + hook = os.path.basename(sys.argv[0])
> + if hook == 'pre-receive':
> + rpc_key = os.environ[b'TURNIP_HOOK_RPC_KEY']
os.environ's keys aren't byte strings, even in Python 3. os.environb is a separate thing.
> + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
> + sock.connect(os.environ[b'TURNIP_HOOK_RPC_SOCK'])
> + rule_lines = rpc_invoke(sock, b'list_ref_rules', {'key': rpc_key})
> +
> + errors = match_rules(rule_lines, sys.stdin.readlines())
> + for error in errors:
> + sys.stdout.write(error + b'\n')
> + sys.exit(1 if errors else 0)
> + elif hook == 'post-receive':
> + rpc_key = os.environ[b'TURNIP_HOOK_RPC_KEY']
> + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
> + sock.connect(os.environ[b'TURNIP_HOOK_RPC_SOCK'])
> + if sys.stdin.readlines():
> + rule_lines = rpc_invoke(sock, b'notify_push', {'key': rpc_key})
> + sys.exit(0)
> + else:
> + sys.stderr.write(b'Invalid hook name: %s' % hook)
>
> === added symlink 'turnip/pack/hooks/post-receive'
> === target is u'hook.py'
> === added symlink 'turnip/pack/hooks/pre-receive'
> === target is u'hook.py'
Why do we need these in the source tree?
> === modified file 'turnip/pack/tests/test_functional.py'
> --- turnip/pack/tests/test_functional.py 2015-03-04 08:02:09 +0000
> +++ turnip/pack/tests/test_functional.py 2015-03-30 09:20:00 +0000
> @@ -34,6 +34,10 @@
> PackFrontendFactory,
> PackVirtFactory,
> )
> +from turnip.pack.hookrpc import (
> + HookRPCHandler,
> + HookRPCServerFactory,
> + )
> from turnip.pack.http import SmartHTTPFrontendResource
> from turnip.pack.ssh import SmartSSHService
>
> @@ -74,6 +78,10 @@
> path is prefixed with '/+rw'
> """
>
> + def __init__(self, *args, **kwargs):
> + xmlrpc.XMLRPC.__init__(self, *args, **kwargs)
> + self.push_notifications = []
> +
> def xmlrpc_translatePath(self, pathname, permission, authenticated_uid,
> can_authenticate):
> writable = False
> @@ -88,11 +96,32 @@
> def xmlrpc_authenticateWithPassword(self, username, password):
> return {'user': username}
>
> + def xmlrpc_notify(self, path):
> + self.push_notifications.append(path)
> +
>
> class FunctionalTestMixin(object):
>
> run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
>
> + def startVirtInfo(self):
> + # Set up a fake virt information XML-RPC server which just
> + # maps paths to their SHA-256 hash.
> + self.virtinfo = FakeVirtInfoService(allowNone=True)
> + self.virtinfo_listener = reactor.listenTCP(
> + 0, server.Site(self.virtinfo))
> + self.virtinfo_port = self.virtinfo_listener.getHost().port
> + self.virtinfo_url = b'http://localhost:%d/' % self.virtinfo_port
> + self.addCleanup(self.virtinfo_listener.stopListening)
> +
> + def startHookRPC(self):
> + self.hookrpc_handler = HookRPCHandler(self.virtinfo_url)
> + dir = self.useFixture(TempDir()).path
> + self.hookrpc_path = os.path.join(dir, 'hookrpc_sock')
> + self.hookrpc_listener = reactor.listenUNIX(
> + self.hookrpc_path, HookRPCServerFactory(self.hookrpc_handler))
> + self.addCleanup(self.hookrpc_listener.stopListening)
> +
> @defer.inlineCallbacks
> def assertCommandSuccess(self, command, path='.'):
> out, err, code = yield utils.getProcessOutputAndValue(
> @@ -175,9 +204,15 @@
> @defer.inlineCallbacks
> def setUp(self):
> super(TestBackendFunctional, self).setUp()
> +
> # Set up a PackBackendFactory on a free port in a clean repo root.
> + self.startVirtInfo()
> + self.startHookRPC()
> self.root = self.useFixture(TempDir()).path
> - self.listener = reactor.listenTCP(0, PackBackendFactory(self.root))
> + self.listener = reactor.listenTCP(
> + 0,
> + PackBackendFactory(
> + self.root, self.hookrpc_handler, self.hookrpc_path))
> self.port = self.listener.getHost().port
>
> yield self.assertCommandSuccess(
> @@ -206,21 +241,18 @@
> self.authserver_port = self.authserver_listener.getHost().port
> self.authserver_url = b'http://localhost:%d/' % self.authserver_port
>
> - # Set up a fake virt information XML-RPC server which just
> - # maps paths to their SHA-256 hash.
> - self.virtinfo_listener = reactor.listenTCP(
> - 0, server.Site(FakeVirtInfoService(allowNone=True)))
> - self.virtinfo_port = self.virtinfo_listener.getHost().port
> - self.virtinfo_url = b'http://localhost:%d/' % self.virtinfo_port
> -
> # Run a backend server in a repo root containing an empty repo
> # for the path '/test'.
> + self.startVirtInfo()
> + self.startHookRPC()
> self.root = self.useFixture(TempDir()).path
> - internal_name = hashlib.sha256(b'/test').hexdigest()
> + self.internal_name = hashlib.sha256(b'/test').hexdigest()
> yield self.assertCommandSuccess(
> - (b'git', b'init', b'--bare', internal_name), path=self.root)
> + (b'git', b'init', b'--bare', self.internal_name), path=self.root)
> self.backend_listener = reactor.listenTCP(
> - 0, PackBackendFactory(self.root))
> + 0,
> + PackBackendFactory(
> + self.root, self.hookrpc_handler, self.hookrpc_path))
> self.backend_port = self.backend_listener.getHost().port
>
> self.virt_listener = reactor.listenTCP(
> @@ -234,7 +266,6 @@
> super(FrontendFunctionalTestMixin, self).tearDown()
> yield self.virt_listener.stopListening()
> yield self.backend_listener.stopListening()
> - yield self.virtinfo_listener.stopListening()
> yield self.authserver_listener.stopListening()
>
> @defer.inlineCallbacks
> @@ -259,6 +290,7 @@
> env=os.environ, path=clone1, errortoo=True)
> self.assertThat(
> out, StartsWith(self.early_error + b'Repository is read-only'))
> + self.assertEqual([], self.virtinfo.push_notifications)
>
> # The remote repository is still empty.
> out = yield utils.getProcessOutput(
> @@ -271,6 +303,8 @@
> (b'git', b'remote', b'set-url', b'origin', self.url), path=clone1)
> yield self.assertCommandSuccess(
> (b'git', b'push', b'origin', b'master'), path=clone1)
> + self.assertEqual(
> + [self.internal_name], self.virtinfo.push_notifications)
>
>
> class TestGitFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
>
> === modified file 'turnip/pack/tests/test_helpers.py'
> --- turnip/pack/tests/test_helpers.py 2015-01-22 06:14:32 +0000
> +++ turnip/pack/tests/test_helpers.py 2015-03-30 09:20:00 +0000
> @@ -4,9 +4,15 @@
> unicode_literals,
> )
>
> +import hashlib
> +import os.path
> +import stat
> +
> +from fixtures import TempDir
> from testtools import TestCase
>
> from turnip.pack import helpers
> +import turnip.pack.hooks.hook
>
>
> TEST_DATA = b'0123456789abcdef'
> @@ -153,3 +159,52 @@
> self.assertInvalid(
> b'git-do-stuff', b'/some/path', {b'host': b'exam\0le.com'},
> b'Metacharacter in arguments')
> +
> +
> +class TestEnsureHooks(TestCase):
> + """Test repository hook maintenance."""
> +
> + def setUp(self):
> + super(TestEnsureHooks, self).setUp()
> + self.repo_dir = self.useFixture(TempDir()).path
> + self.hooks_dir = os.path.join(self.repo_dir, 'hooks')
> + os.mkdir(self.hooks_dir)
> +
> + def hook(self, hook):
> + return os.path.join(self.hooks_dir, hook)
> +
> + def test_deletes_random(self):
> + # Unknown files are deleted.
> + os.symlink('foo', self.hook('bar'))
> + self.assertIn('bar', os.listdir(self.hooks_dir))
> + helpers.ensure_hooks(self.repo_dir)
> + self.assertNotIn('bar', os.listdir(self.hooks_dir))
> +
> + def test_fixes_symlink(self):
> + # A symlink with a bad path is fixed.
> + os.symlink('foo', self.hook('pre-receive'))
> + self.assertEqual('foo', os.readlink(self.hook('pre-receive')))
> + helpers.ensure_hooks(self.repo_dir)
> + self.assertEqual('hook.py', os.readlink(self.hook('pre-receive')))
> +
> + def test_replaces_regular_file(self):
> + # A regular file is replaced with a symlink.
> + with open(self.hook('pre-receive'), 'w') as f:
> + f.write('garbage')
> + self.assertRaises(OSError, os.readlink, self.hook('pre-receive'))
> + helpers.ensure_hooks(self.repo_dir)
> + self.assertEqual('hook.py', os.readlink(self.hook('pre-receive')))
> +
> + def test_replaces_hook_py(self):
> + # The hooks themselves are symlinks to hook.py, which is always
> + # kept up to date.
> + with open(self.hook('hook.py'), 'w') as f:
> + f.write('nothing to see here')
> + helpers.ensure_hooks(self.repo_dir)
> + with open(self.hook('hook.py'), 'rb') as actual:
> + with open(turnip.pack.hooks.hook.__file__, 'rb') as expected:
> + self.assertEqual(
> + hashlib.sha256(expected.read()).hexdigest(),
> + hashlib.sha256(actual.read()).hexdigest())
> + # The hook is executable.
> + self.assertTrue(os.stat(self.hook('hook.py')).st_mode & stat.S_IXUSR)
>
> === added file 'turnip/pack/tests/test_hookrpc.py'
> --- turnip/pack/tests/test_hookrpc.py 1970-01-01 00:00:00 +0000
> +++ turnip/pack/tests/test_hookrpc.py 2015-03-30 09:20:00 +0000
> @@ -0,0 +1,126 @@
> +from __future__ import (
> + absolute_import,
> + print_function,
> + unicode_literals,
> + )
> +
> +from testtools import TestCase
> +from twisted.internet import defer
> +from twisted.test import proto_helpers
> +
> +from turnip.pack import hookrpc
> +
> +
> +class DummyJSONNetstringProtocol(hookrpc.JSONNetstringProtocol):
> +
> + response_deferred = None
> +
> + def __init__(self):
> + self.test_value_log = []
> + self.test_invalid_log = []
> +
> + def valueReceived(self, val):
> + self.test_value_log.append(val)
> +
> + def invalidValueReceived(self, string):
> + self.test_invalid_log.append(string)
> +
> + def sendValue(self, value):
> + # Hack to allow tests to block until a response is sent, since
> + # dataReceived can't return a Deferred without breaking things.
> + hookrpc.JSONNetstringProtocol.sendValue(self, value)
> + if self.response_deferred is not None:
> + d = self.response_deferred
> + self.response_deferred = None
> + d.callback()
> +
> +
> +class TestJSONNetStringProtocol(TestCase):
> + """Test the JSON netstring protocol."""
> +
> + def setUp(self):
> + super(TestJSONNetStringProtocol, self).setUp()
> + self.proto = DummyJSONNetstringProtocol()
> + self.transport = proto_helpers.StringTransportWithDisconnection()
> + self.transport.protocol = self.proto
> + self.proto.makeConnection(self.transport)
> +
> + def test_calls_valueReceived(self):
> + # A valid netstring containing valid JSON is given to
> + # valueReceived.
> + self.proto.dataReceived(b'14:{"foo": "bar"},')
> + self.proto.dataReceived(b'19:[{"it": ["works"]}],')
> + self.assertEqual(
> + [{"foo": "bar"}, [{"it": ["works"]}]],
> + self.proto.test_value_log)
> +
> + def test_calls_invalidValueReceived(self):
> + # A valid nestring containing invalid JSON calls
> + # invalidValueReceived. Framing is preserved, so the connection
> + # need not be destroyed.
> + self.proto.dataReceived(b'12:{"foo": "bar,')
> + self.proto.dataReceived(b'3:"ga,')
> + self.assertEqual([], self.proto.test_value_log)
> + self.assertEqual(
> + [b'{"foo": "bar', b'"ga'], self.proto.test_invalid_log)
> +
> + def test_sendValue(self):
> + # sendValue serialises to JSON and encodes as a netstring.
> + self.proto.sendValue({"yay": "it works"})
> + self.assertEqual(b'19:{"yay": "it works"},', self.transport.value())
> +
> +
> +def async_rpc_method(proto, args):
> + d = defer.Deferred()
> + d.callback(list(args.items()))
> + return d
> +
> +
> +def sync_rpc_method(proto, args):
> + return list(args.items())
> +
> +
> +class TestRPCServerProtocol(TestCase):
> + """Test the socket server that handles git hook callbacks."""
> +
> + def setUp(self):
> + super(TestRPCServerProtocol, self).setUp()
> + self.proto = hookrpc.RPCServerProtocol({
> + 'sync': sync_rpc_method,
> + 'async': async_rpc_method,
> + })
> + self.transport = proto_helpers.StringTransportWithDisconnection()
> + self.transport.protocol = self.proto
> + self.proto.makeConnection(self.transport)
> +
> + def test_call_sync(self):
> + self.proto.dataReceived(b'28:{"op": "sync", "bar": "baz"},')
> + self.assertEqual(
> + b'28:{"result": [["bar", "baz"]]},', self.transport.value())
> +
> + def test_call_async(self):
> + self.proto.dataReceived(b'29:{"op": "async", "bar": "baz"},')
> + self.assertEqual(
> + b'28:{"result": [["bar", "baz"]]},', self.transport.value())
> +
> + def test_bad_op(self):
> + self.proto.dataReceived(b'27:{"op": "bar", "bar": "baz"},')
> + self.assertEqual(
> + b'28:{"error": "Unknown op: bar"},', self.transport.value())
> +
> + def test_no_op(self):
> + self.proto.dataReceived(b'28:{"nop": "bar", "bar": "baz"},')
> + self.assertEqual(
> + b'28:{"error": "No op specified"},', self.transport.value())
> +
> + def test_bad_value(self):
> + self.proto.dataReceived(b'14:["foo", "bar"],')
> + self.assertEqual(
> + b'42:{"error": "Command must be a JSON object"},',
> + self.transport.value())
> +
> + def test_bad_json(self):
> + self.proto.dataReceived(b'12:["nop", "bar,')
> + self.assertEqual(
> + b'42:{"error": "Command must be a JSON object"},',
> + self.transport.value())
>
> === added file 'turnip/pack/tests/test_hooks.py'
> --- turnip/pack/tests/test_hooks.py 1970-01-01 00:00:00 +0000
> +++ turnip/pack/tests/test_hooks.py 2015-03-30 09:20:00 +0000
> @@ -0,0 +1,172 @@
> +from __future__ import (
> + absolute_import,
> + print_function,
> + unicode_literals,
> + )
> +
> +import os.path
> +import uuid
> +
> +from fixtures import TempDir
> +from testtools import TestCase
> +from testtools.deferredruntest import AsynchronousDeferredRunTest
> +from twisted.internet import (
> + defer,
> + protocol,
> + reactor,
> + )
> +
> +from turnip.pack import hookrpc
> +import turnip.pack.hooks
> +
> +
> +class HookProcessProtocol(protocol.ProcessProtocol):
> +
> + def __init__(self, deferred, stdin):
> + self.deferred = deferred
> + self.stdin = stdin
> + self.stdout = self.stderr = ''
> +
> + def connectionMade(self):
> + self.transport.write(self.stdin)
> + self.transport.closeStdin()
> +
> + def outReceived(self, data):
> + self.stdout += data
> +
> + def errReceived(self, data):
> + self.stderr += data
> +
> + def processEnded(self, status):
> + self.deferred.callback(
> + (status.value.exitCode, self.stdout, self.stderr))
> +
> +
> +class MockHookRPCHandler(hookrpc.HookRPCHandler):
> +
> + def __init__(self):
> + super(MockHookRPCHandler, self).__init__(None)
> + self.notifications = []
> +
> + def notifyPush(self, proto, args):
> + self.notifications.append(self.ref_paths[args['key']])
> +
> +
> +class HookTestMixin(object):
> + run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=1)
> +
> + old_sha1 = b'a' * 40
> + new_sha1 = b'b' * 40
> +
> + @property
> + def hook_path(self):
> + return os.path.join(
> + os.path.dirname(turnip.pack.hooks.__file__), self.hook_name)
> +
> + def handlePushNotification(self, path):
> + self.notifications.append(path)
> +
> + def setUp(self):
> + super(HookTestMixin, self).setUp()
> + self.hookrpc_handler = MockHookRPCHandler()
> + self.hookrpc = hookrpc.HookRPCServerFactory(self.hookrpc_handler)
> + dir = self.useFixture(TempDir()).path
> + self.hookrpc_path = os.path.join(dir, 'hookrpc_sock')
> + self.hookrpc_port = reactor.listenUNIX(
> + self.hookrpc_path, self.hookrpc)
> + self.addCleanup(self.hookrpc_port.stopListening)
> +
> + def encodeRefs(self, updates):
> + return b'\n'.join(
> + b'%s %s %s' % (old, new, ref) for ref, old, new in updates)
> +
> + @defer.inlineCallbacks
> + def invokeHook(self, input, rules):
> + key = str(uuid.uuid4())
> + self.hookrpc_handler.registerKey(key, '/translated', list(rules))
> + try:
> + d = defer.Deferred()
> + reactor.spawnProcess(
> + HookProcessProtocol(d, input),
> + self.hook_path, [self.hook_path],
> + env={
> + b'TURNIP_HOOK_RPC_SOCK': self.hookrpc_path,
> + b'TURNIP_HOOK_RPC_KEY': key})
> + code, stdout, stderr = yield d
> + finally:
> + self.hookrpc_handler.unregisterKey(key)
> + defer.returnValue((code, stdout, stderr))
> +
> + @defer.inlineCallbacks
> + def assertAccepted(self, updates, rules):
> + code, out, err = yield self.invokeHook(self.encodeRefs(updates), rules)
> + self.assertEqual((0, b'', b''), (code, out, err))
> +
> + @defer.inlineCallbacks
> + def assertRejected(self, updates, rules, message):
> + code, out, err = yield self.invokeHook(self.encodeRefs(updates), rules)
> + self.assertEqual((1, message, b''), (code, out, err))
> +
> +
> +class TestPreReceiveHook(HookTestMixin, TestCase):
> + """Tests for the git pre-receive hook."""
> +
> + hook_name = 'pre-receive'
> +
> + @defer.inlineCallbacks
> + def test_accepted(self):
> + # A single valid ref is accepted.
> + yield self.assertAccepted(
> + [(b'refs/heads/master', self.old_sha1, self.new_sha1)],
> + [])
> +
> + @defer.inlineCallbacks
> + def test_rejected(self):
> + # An invalid ref is rejected.
> + yield self.assertRejected(
> + [(b'refs/heads/verboten', self.old_sha1, self.new_sha1)],
> + [b'refs/heads/verboten'],
> + b"You can't push to refs/heads/verboten.\n")
> +
> + @defer.inlineCallbacks
> + def test_wildcard(self):
> + # "*" in a rule matches any path segment.
> + yield self.assertRejected(
> + [(b'refs/heads/foo', self.old_sha1, self.new_sha1),
> + (b'refs/tags/bar', self.old_sha1, self.new_sha1),
> + (b'refs/tags/foo', self.old_sha1, self.new_sha1),
> + (b'refs/baz/quux', self.old_sha1, self.new_sha1)],
> + [b'refs/*/foo', b'refs/baz/*'],
> + b"You can't push to refs/heads/foo.\n"
> + b"You can't push to refs/tags/foo.\n"
> + b"You can't push to refs/baz/quux.\n")
> +
> + @defer.inlineCallbacks
> + def test_rejected_multiple(self):
> + # A combination of valid and invalid refs is still rejected.
> + yield self.assertRejected(
> + [(b'refs/heads/verboten', self.old_sha1, self.new_sha1),
> + (b'refs/heads/master', self.old_sha1, self.new_sha1),
> + (b'refs/heads/super-verboten', self.old_sha1, self.new_sha1)],
> + [b'refs/heads/verboten', b'refs/heads/super-verboten'],
> + b"You can't push to refs/heads/verboten.\n"
> + b"You can't push to refs/heads/super-verboten.\n")
> +
> +
> +class TestPostReceiveHook(HookTestMixin, TestCase):
> + """Tests for the git post-receive hook."""
> +
> + hook_name = 'post-receive'
> +
> + @defer.inlineCallbacks
> + def test_notifies(self):
> + # The notification callback is invoked with the storage path.
> + yield self.assertAccepted(
> + [(b'refs/heads/foo', self.old_sha1, self.new_sha1)], [])
> + self.assertEqual(['/translated'], self.hookrpc_handler.notifications)
> +
> + @defer.inlineCallbacks
> + def test_does_not_notify_on_empty_push(self):
> + # No notification is sent for an empty push.
> + yield self.assertAccepted([], [])
> + self.assertEqual([], self.hookrpc_handler.notifications)
>
> === modified file 'turnipserver.py'
> --- turnipserver.py 2015-03-29 09:50:58 +0000
> +++ turnipserver.py 2015-03-30 09:20:00 +0000
> @@ -15,6 +15,10 @@
> PackFrontendFactory,
> PackVirtFactory,
> )
> +from turnip.pack.hookrpc import (
> + HookRPCHandler,
> + HookRPCServerFactory,
> + )
> from turnip.pack.http import SmartHTTPFrontendResource
> from turnip.pack.ssh import SmartSSHService
>
> @@ -34,8 +38,14 @@
> # Start a pack storage service on 19418, pointed at by a pack frontend
> # on 9418 (the default git:// port), a smart HTTP frontend on 9419, and
> # a smart SSH frontend on 9422.
> -reactor.listenTCP(PACK_BACKEND_PORT,
> - PackBackendFactory(REPO_STORE))
> +
> +hookrpc_handler = HookRPCHandler(VIRTINFO_ENDPOINT)
> +hookrpc_path = os.path.join(REPO_STORE, 'hookrpc_sock')
> +reactor.listenUNIX(hookrpc_path, HookRPCServerFactory(hookrpc_handler))
> +
> +reactor.listenTCP(
> + PACK_BACKEND_PORT,
> + PackBackendFactory(REPO_STORE, hookrpc_handler, hookrpc_path))
> reactor.listenTCP(PACK_VIRT_PORT,
> PackVirtFactory('localhost',
> PACK_BACKEND_PORT,
>
--
https://code.launchpad.net/~wgrant/turnip/pre-receive/+merge/254542
Your team Launchpad code reviewers is subscribed to branch lp:turnip.
References