← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/turnip:set-symbolic-ref into turnip:master

 

Colin Watson has proposed merging ~cjwatson/turnip:set-symbolic-ref into turnip:master.

Commit message:
Add turnip-set-symbolic-ref extension service

This makes it possible to set a repository's HEAD by talking directly to
turnip over HTTPS, which is needed for git-to-git code imports.

LP: #1469459

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1469459 in turnip: "import external code into a LP git repo (natively)"
  https://bugs.launchpad.net/turnip/+bug/1469459

For more details, see:
https://code.launchpad.net/~cjwatson/turnip/+git/turnip/+merge/309702
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/turnip:set-symbolic-ref into turnip:master.
diff --git a/README b/README
index 7b26510..e274c12 100644
--- a/README
+++ b/README
@@ -68,6 +68,21 @@ The only additional parameters implemented today are
 'turnip-stateless-rpc' and 'turnip-advertise-refs', which are used by
 the smart HTTP server to proxy to the standard pack protocol.
 
+turnip implements one externally-visible extension: a
+'turnip-set-symbolic-ref' service that sets a symbolic ref (currently only
+'HEAD' is permitted) to a given target. This may be used over the various
+protocols (git, SSH, smart HTTP), requesting the service in the same way as
+the existing 'git-upload-pack' and 'git-receive-pack' services.
+
+   turnip-set-symbolic-ref-request = set-symbolic-ref-line
+                                     flush-pkt
+   set-symbolic-ref-line           = PKT-LINE(refname SP refname)
+
+The server replies with an ACK indicating the symbolic ref name that was
+changed, or an error message.
+
+   turnip-set-symbolic-ref-response = set-symbolic-ref-ack / error-line
+   set-symbolic-ref-ack             = PKT-LINE("ACK" SP refname)
 
 Development
 ===========
diff --git a/turnip/pack/git.py b/turnip/pack/git.py
index 04d6770..fc01527 100644
--- a/turnip/pack/git.py
+++ b/turnip/pack/git.py
@@ -272,7 +272,7 @@ class GitProcessProtocol(protocol.ProcessProtocol):
         self.transport.closeChildFD(0)
 
     def processEnded(self, status):
-        self.peer.transport.loseConnection()
+        self.peer.processEnded(status)
 
     def pauseProducing(self):
         self.transport.pauseProducing()
@@ -386,43 +386,114 @@ class PackBackendProtocol(PackServerProtocol):
     """
 
     hookrpc_key = None
+    expect_set_symbolic_ref = False
 
     def requestReceived(self, command, raw_pathname, params):
         self.extractRequestMeta(command, raw_pathname, params)
+        self.command = command
+        self.raw_pathname = raw_pathname
+        self.path = compose_path(self.factory.root, self.raw_pathname)
+
+        if command == b'turnip-set-symbolic-ref':
+            self.expect_set_symbolic_ref = True
+            self.resumeProducing()
+            return
 
-        path = compose_path(self.factory.root, raw_pathname)
+        write_operation = False
         if command == b'git-upload-pack':
             subcmd = b'upload-pack'
         elif command == b'git-receive-pack':
             subcmd = b'receive-pack'
+            write_operation = True
         else:
             self.die(b'Unsupported command in request')
             return
 
-        cmd = b'git'
-        args = [b'git', subcmd]
+        args = []
         if params.pop(b'turnip-stateless-rpc', None):
             args.append(b'--stateless-rpc')
         if params.pop(b'turnip-advertise-refs', None):
             args.append(b'--advertise-refs')
-        args.append(path)
+        args.append(self.path)
+        self.spawnGit(subcmd, args, write_operation=write_operation)
+
+    def spawnGit(self, subcmd, extra_args, write_operation=False,
+                 send_path_as_option=False):
+        cmd = b'git'
+        args = [b'git']
+        if send_path_as_option:
+            args.extend([b'-C', self.path])
+        args.append(subcmd)
+        args.extend(extra_args)
 
         env = {}
-        if subcmd == b'receive-pack' and self.factory.hookrpc_handler:
+        if write_operation and self.factory.hookrpc_handler:
             # This is a write operation, so prepare config, hooks, the hook
             # RPC server, and the environment variables that link them up.
-            ensure_config(path)
+            ensure_config(self.path)
             self.hookrpc_key = str(uuid.uuid4())
             self.factory.hookrpc_handler.registerKey(
-                self.hookrpc_key, raw_pathname, [])
-            ensure_hooks(path)
+                self.hookrpc_key, self.raw_pathname, [])
+            ensure_hooks(self.path)
             env[b'TURNIP_HOOK_RPC_SOCK'] = self.factory.hookrpc_sock
             env[b'TURNIP_HOOK_RPC_KEY'] = self.hookrpc_key
 
         self.log.info('Spawning {args}', args=args)
         self.peer = GitProcessProtocol(self)
+        self.spawnProcess(cmd, args, env=env)
+
+    def spawnProcess(self, cmd, args, env=None):
         reactor.spawnProcess(self.peer, cmd, args, env=env)
 
+    def packetReceived(self, data):
+        if self.expect_set_symbolic_ref:
+            if data is None:
+                self.die(b'Bad request: flush-pkt instead')
+                return
+            self.pauseProducing()
+            self.expect_set_symbolic_ref = False
+            if b' ' not in data:
+                self.die(b'Invalid set-symbolic-ref-line')
+                return
+            name, target = data.split(b' ', 1)
+            # Be careful about extending this to anything other than HEAD.
+            # We use "git symbolic-ref" because it gives us locking and
+            # logging, but it doesn't prevent writing a ref to ../something.
+            # Fortunately it does at least refuse to point HEAD outside of
+            # refs/.
+            if name != b'HEAD':
+                self.die(b'Symbolic ref name must be "HEAD"')
+                return
+            if target.startswith(b'-'):
+                self.die(b'Symbolic ref target may not start with "-"')
+                return
+            self.symbolic_ref_name = name
+            self.spawnGit(
+                b'symbolic-ref', [name, target], write_operation=True,
+                send_path_as_option=True)
+            return
+
+        PackServerProtocol.packetReceived(self, data)
+
+    @defer.inlineCallbacks
+    def processEnded(self, reason):
+        message = None
+        if self.command == b'turnip-set-symbolic-ref':
+            if reason.check(error.ProcessDone):
+                try:
+                    yield self.factory.hookrpc_handler.notify(self.path)
+                    self.sendPacket(b'ACK %s' % self.symbolic_ref_name)
+                except Exception as e:
+                    message = str(e)
+            else:
+                message = (
+                    'git symbolic-ref exited with status %d' %
+                    reason.value.exitCode)
+        if message is None:
+            self.transport.loseConnection()
+        else:
+            self.die(message)
+
     def readConnectionLost(self):
         # Forward the closed stdin down the stack.
         if self.peer is not None:
diff --git a/turnip/pack/hookrpc.py b/turnip/pack/hookrpc.py
index d273a9c..bd3bc0e 100644
--- a/turnip/pack/hookrpc.py
+++ b/turnip/pack/hookrpc.py
@@ -126,11 +126,15 @@ class HookRPCHandler(object):
         return [rule.decode('utf-8') for rule in self.ref_rules[args['key']]]
 
     @defer.inlineCallbacks
+    def notify(self, path):
+        proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
+        yield proxy.callRemote(b'notify', path)
+
+    @defer.inlineCallbacks
     def notifyPush(self, proto, args):
         """Notify the virtinfo service about a push."""
         path = self.ref_paths[args['key']]
-        proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
-        yield proxy.callRemote(b'notify', path)
+        yield self.notify(path)
 
 
 class HookRPCServerFactory(RPCServerFactory):
diff --git a/turnip/pack/http.py b/turnip/pack/http.py
index ada6b5f..9c209d2 100644
--- a/turnip/pack/http.py
+++ b/turnip/pack/http.py
@@ -664,7 +664,8 @@ class HTTPAuthResource(resource.Resource):
 class SmartHTTPFrontendResource(resource.Resource):
     """HTTP resource to translate Git smart HTTP requests to pack protocol."""
 
-    allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
+    allowed_services = frozenset((
+        b'git-upload-pack', b'git-receive-pack', b'turnip-set-symbolic-ref'))
 
     def __init__(self, backend_host, config):
         resource.Resource.__init__(self)
@@ -721,7 +722,9 @@ class SmartHTTPFrontendResource(resource.Resource):
         content_type = request.getHeader(b'Content-Type')
         if content_type is None:
             return False
-        return content_type.startswith(b'application/x-git-')
+        return (
+            content_type.startswith(b'application/x-git-') or
+            content_type.startswith(b'application/x-turnip-'))
 
     def getChild(self, path, request):
         if self._isGitRequest(request):
diff --git a/turnip/pack/ssh.py b/turnip/pack/ssh.py
index 006331d..b753220 100644
--- a/turnip/pack/ssh.py
+++ b/turnip/pack/ssh.py
@@ -120,7 +120,8 @@ class SSHPackClientFactory(protocol.ClientFactory):
 class SmartSSHSession(DoNothingSession):
     """SSH session allowing only Git smart SSH requests."""
 
-    allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
+    allowed_services = frozenset((
+        b'git-upload-pack', b'git-receive-pack', b'turnip-set-symbolic-ref'))
 
     def __init__(self, *args, **kwargs):
         super(SmartSSHSession, self).__init__(*args, **kwargs)
diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
index e30b59f..374213c 100644
--- a/turnip/pack/tests/test_functional.py
+++ b/turnip/pack/tests/test_functional.py
@@ -8,8 +8,10 @@ from __future__ import (
     unicode_literals,
     )
 
+import base64
 from collections import defaultdict
 import hashlib
+import io
 import os
 import shutil
 import stat
@@ -45,10 +47,12 @@ from twisted.internet import (
 from twisted.web import (
     client,
     error,
+    http_headers,
     server,
     xmlrpc,
     )
 
+from turnip.pack import helpers
 from turnip.pack.git import (
     PackBackendFactory,
     PackFrontendFactory,
@@ -441,14 +445,68 @@ class TestSmartHTTPFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
 
     @defer.inlineCallbacks
     def test_root_revision_header(self):
-        factory = client.HTTPClientFactory(
-            b'http://localhost:%d/' % self.port, method=b'HEAD',
-            followRedirect=False)
-        reactor.connectTCP(b'localhost', self.port, factory)
-        yield assert_fails_with(factory.deferred, error.PageRedirect)
+        response = yield client.Agent(reactor).request(
+            b'HEAD', b'http://localhost:%d/' % self.port)
+        self.assertEqual(302, response.code)
         self.assertEqual(
             [version_info['revision_id']],
-            factory.response_headers[b'x-turnip-revision'])
+            response.headers.getRawHeaders(b'X-Turnip-Revision'))
+
+    def make_set_symbolic_ref_request(self, line):
+        parsed_url = urlsplit(self.url)
+        url = urlunsplit([
+            parsed_url.scheme,
+            b'%s:%d' % (parsed_url.hostname, parsed_url.port),
+            parsed_url.path + b'/turnip-set-symbolic-ref', b'', b''])
+        headers = {
+            b'Content-Type': [
+                b'application/x-turnip-set-symbolic-ref-request',
+                ],
+            }
+        if parsed_url.username:
+            headers[b'Authorization'] = [
+                b'Basic ' + base64.b64encode(
+                    b'%s:%s' % (parsed_url.username, parsed_url.password)),
+                ]
+        data = helpers.encode_packet(line) + helpers.encode_packet(None)
+        return client.Agent(reactor).request(
+            b'POST', url, headers=http_headers.Headers(headers),
+            bodyProducer=client.FileBodyProducer(io.BytesIO(data)))
+
+    @defer.inlineCallbacks
+    def get_symbolic_ref(self, path, name):
+        out = yield utils.getProcessOutput(
+            b'git', (b'symbolic-ref', name), env=os.environ, path=path)
+        defer.returnValue(out.rstrip(b'\n'))
+
+    @defer.inlineCallbacks
+    def test_turnip_set_symbolic_ref(self):
+        repo = os.path.join(self.root, self.internal_name)
+        head_target = yield self.get_symbolic_ref(repo, b'HEAD')
+        self.assertEqual(b'refs/heads/master', head_target)
+        response = yield self.make_set_symbolic_ref_request(
+            b'HEAD refs/heads/new-head')
+        self.assertEqual(200, response.code)
+        body = yield client.readBody(response)
+        self.assertEqual((b'ACK HEAD', ''), helpers.decode_packet(body))
+        head_target = yield self.get_symbolic_ref(repo, b'HEAD')
+        self.assertEqual(b'refs/heads/new-head', head_target)
+
+    @defer.inlineCallbacks
+    def test_turnip_set_symbolic_ref_error(self):
+        repo = os.path.join(self.root, self.internal_name)
+        head_target = yield self.get_symbolic_ref(repo, b'HEAD')
+        self.assertEqual(b'refs/heads/master', head_target)
+        response = yield self.make_set_symbolic_ref_request(b'HEAD --evil')
+        # This is a little weird since an error occurred, but it's
+        # consistent with other HTTP pack protocol responses.
+        self.assertEqual(200, response.code)
+        body = yield client.readBody(response)
+        self.assertEqual(
+            (b'ERR Symbolic ref target may not start with "-"\n', ''),
+            helpers.decode_packet(body))
+        head_target = yield self.get_symbolic_ref(repo, b'HEAD')
+        self.assertEqual(b'refs/heads/master', head_target)
 
 
 class TestSmartHTTPFrontendWithAuthFunctional(TestSmartHTTPFrontendFunctional):
diff --git a/turnip/pack/tests/test_git.py b/turnip/pack/tests/test_git.py
index d19574d..8d0b1cf 100644
--- a/turnip/pack/tests/test_git.py
+++ b/turnip/pack/tests/test_git.py
@@ -7,13 +7,23 @@ from __future__ import (
     unicode_literals,
     )
 
+import os.path
+
+from fixtures import TempDir
+from pygit2 import init_repository
 from testtools import TestCase
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    MatchesListwise,
+    )
 from twisted.test import proto_helpers
 
 from turnip.pack import (
     git,
     helpers,
     )
+from turnip.pack.tests.test_hooks import MockHookRPCHandler
 
 
 class DummyPackServerProtocol(git.PackServerProtocol):
@@ -87,3 +97,103 @@ class TestPackServerProtocol(TestCase):
         # dropped.
         self.proto.dataReceived(b'0000')
         self.assertKilledWith(b'Bad request: flush-pkt instead')
+
+
+class DummyPackBackendProtocol(git.PackBackendProtocol):
+
+    test_process = None
+
+    def spawnProcess(self, cmd, args, env=None):
+        if self.test_process is not None:
+            raise AssertionError('Process already spawned.')
+        self.test_process = (cmd, args, env)
+
+
+class TestPackBackendProtocol(TestCase):
+    """Test the Git pack backend protocol."""
+
+    def setUp(self):
+        super(TestPackBackendProtocol, self).setUp()
+        self.root = self.useFixture(TempDir()).path
+        self.hookrpc_handler = MockHookRPCHandler()
+        self.hookrpc_sock = os.path.join(self.root, 'hookrpc_sock')
+        self.factory = git.PackBackendFactory(
+            self.root, self.hookrpc_handler, self.hookrpc_sock)
+        self.proto = DummyPackBackendProtocol()
+        self.proto.factory = self.factory
+        self.transport = proto_helpers.StringTransportWithDisconnection()
+        self.transport.protocol = self.proto
+        self.proto.makeConnection(self.transport)
+
+    def assertKilledWith(self, message):
+        self.assertFalse(self.transport.connected)
+        self.assertEqual(
+            (b'ERR ' + message + b'\n', b''),
+            helpers.decode_packet(self.transport.value()))
+
+    def test_git_upload_pack_calls_spawnProcess(self):
+        # If the command is git-upload-pack, requestReceived calls
+        # spawnProcess with appropriate arguments.
+        self.proto.requestReceived(
+            b'git-upload-pack', b'/foo.git', {b'host': b'example.com'})
+        self.assertEqual(
+            (b'git',
+             [b'git', b'upload-pack', os.path.join(self.root, b'foo.git')],
+             {}),
+            self.proto.test_process)
+
+    def test_git_receive_pack_calls_spawnProcess(self):
+        # If the command is git-receive-pack, requestReceived calls
+        # spawnProcess with appropriate arguments.
+        repo_dir = os.path.join(self.root, 'foo.git')
+        init_repository(repo_dir, bare=True)
+        self.proto.requestReceived(
+            b'git-receive-pack', b'/foo.git', {b'host': b'example.com'})
+        self.assertThat(
+            self.proto.test_process, MatchesListwise([
+                Equals(b'git'),
+                Equals([b'git', b'receive-pack', repo_dir.encode('utf-8')]),
+                ContainsDict(
+                    {b'TURNIP_HOOK_RPC_SOCK': Equals(self.hookrpc_sock)})]))
+
+    def test_turnip_set_symbolic_ref_calls_spawnProcess(self):
+        # If the command is turnip-set-symbolic-ref, requestReceived does
+        # not spawn a process, but packetReceived calls spawnProcess with
+        # appropriate arguments.
+        repo_dir = os.path.join(self.root, 'foo.git')
+        init_repository(repo_dir, bare=True)
+        self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
+        self.assertIsNone(self.proto.test_process)
+        self.proto.packetReceived(b'HEAD refs/heads/master')
+        self.assertThat(
+            self.proto.test_process, MatchesListwise([
+                Equals(b'git'),
+                Equals([
+                    b'git', b'-C', repo_dir.encode('utf-8'), b'symbolic-ref',
+                    b'HEAD', b'refs/heads/master']),
+                ContainsDict(
+                    {b'TURNIP_HOOK_RPC_SOCK': Equals(self.hookrpc_sock)})]))
+
+    def test_turnip_set_symbolic_ref_requires_valid_line(self):
+        # The turnip-set-symbolic-ref command requires a valid
+        # set-symbolic-ref-line packet.
+        self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
+        self.assertIsNone(self.proto.test_process)
+        self.proto.packetReceived(b'HEAD')
+        self.assertKilledWith(b'Invalid set-symbolic-ref-line')
+
+    def test_turnip_set_symbolic_ref_name_must_be_HEAD(self):
+        # The turnip-set-symbolic-ref command's "name" parameter must be
+        # "HEAD".
+        self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
+        self.assertIsNone(self.proto.test_process)
+        self.proto.packetReceived(b'another-symref refs/heads/master')
+        self.assertKilledWith(b'Symbolic ref name must be "HEAD"')
+
+    def test_turnip_set_symbolic_ref_target_not_option(self):
+        # The turnip-set-symbolic-ref command's "target" parameter may not
+        # start with "-".
+        self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
+        self.assertIsNone(self.proto.test_process)
+        self.proto.packetReceived(b'HEAD --evil')
+        self.assertKilledWith(b'Symbolic ref target may not start with "-"')
diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py
index 8e0418f..4eb6ada 100644
--- a/turnip/pack/tests/test_http.py
+++ b/turnip/pack/tests/test_http.py
@@ -64,7 +64,8 @@ def render_resource(resource, request):
 
 class FakeRoot(object):
 
-    allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
+    allowed_services = frozenset((
+        b'git-upload-pack', b'git-receive-pack', b'turnip-set-symbolic-ref'))
 
     def __init__(self):
         self.backend_transport = None