launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18219
[Merge] lp:~wgrant/turnip/pre-receive into lp:turnip
William Grant has proposed merging lp:~wgrant/turnip/pre-receive into lp:turnip.
Commit message:
Implement a post-receive hook for push callbacks to the virt service.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~wgrant/turnip/pre-receive/+merge/254542
Implement a post-receive hook for push callbacks to the virt service.
There's also a pre-receive hook to forbid ref patterns, but the production code is so far hardcoded to allow everything.
Both hooks are symlinks to a standalone Python script which communicates to the pack backend server using a netstring-framed JSON RPC protocol over a UNIX socket. The hook identifies the repo by a random UUID from an environment variable.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/turnip/pre-receive into lp:turnip.
=== 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
+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
+
+ 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))
=== 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'))
+
+
+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))
+
+
+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']
+ 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'
=== 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,
Follow ups