← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/turnip:enable-protocol-v2-on-http into turnip:master

 

Thiago F. Pappacena has proposed merging ~pappacena/turnip:enable-protocol-v2-on-http into turnip:master.

Commit message:
Enabling protocol v2 compatibility on HTTP frontend



Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/turnip/+git/turnip/+merge/389309
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/turnip:enable-protocol-v2-on-http into turnip:master.
diff --git a/turnip/pack/git.py b/turnip/pack/git.py
index 763d5d6..9be4e90 100644
--- a/turnip/pack/git.py
+++ b/turnip/pack/git.py
@@ -77,7 +77,7 @@ class UnstoppableProducerWrapper(object):
         pass
 
 
-class PackProtocol(protocol.Protocol):
+class PackProtocol(protocol.Protocol, object):
 
     paused = False
     raw = False
@@ -415,7 +415,7 @@ class PackProxyServerProtocol(PackServerProtocol):
     def resumeProducing(self):
         # Send our translated request and then open the gate to the client.
         self.sendNextCommand()
-        PackServerProtocol.resumeProducing(self)
+        super(PackProxyServerProtocol, self).resumeProducing()
 
     def readConnectionLost(self):
         # Forward the closed stdin down the stack.
@@ -456,7 +456,12 @@ class PackBackendProtocol(PackServerProtocol):
             self.resumeProducing()
             return
 
+        cmd_env = {}
         write_operation = False
+        version = params.get(b'version', 0)
+        cmd_env["GIT_PROTOCOL"] = 'version=%s' % version
+        if version == b'2':
+            params.pop(b'turnip-advertise-refs', None)
         if command == b'git-upload-pack':
             subcmd = b'upload-pack'
         elif command == b'git-receive-pack':
@@ -472,13 +477,14 @@ class PackBackendProtocol(PackServerProtocol):
         if params.pop(b'turnip-advertise-refs', None):
             args.append(b'--advertise-refs')
         args.append(self.path)
-        self.spawnGit(subcmd,
-                      args,
-                      write_operation=write_operation,
-                      auth_params=auth_params)
+        self.spawnGit(
+            subcmd, args,
+            write_operation=write_operation, auth_params=auth_params,
+            cmd_env=cmd_env)
 
     def spawnGit(self, subcmd, extra_args, write_operation=False,
-                 send_path_as_option=False, auth_params=None):
+                 send_path_as_option=False, auth_params=None,
+                 cmd_env=None):
         cmd = b'git'
         args = [b'git']
         if send_path_as_option:
@@ -487,6 +493,7 @@ class PackBackendProtocol(PackServerProtocol):
         args.extend(extra_args)
 
         env = {}
+        env.update((cmd_env or {}))
         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.
diff --git a/turnip/pack/helpers.py b/turnip/pack/helpers.py
index 9cf411d..4a3758c 100644
--- a/turnip/pack/helpers.py
+++ b/turnip/pack/helpers.py
@@ -7,6 +7,7 @@ from __future__ import (
     unicode_literals,
     )
 
+from collections import OrderedDict
 import enum
 import hashlib
 import os.path
@@ -23,8 +24,9 @@ import six
 import yaml
 
 import turnip.pack.hooks
+from turnip.version_info import version_info
 
-
+DELIM_PKT = object()
 PKT_LEN_SIZE = 4
 PKT_PAYLOAD_MAX = 65520
 INCOMPLETE_PKT = object()
@@ -34,6 +36,8 @@ def encode_packet(payload):
     if payload is None:
         # flush-pkt.
         return b'0000'
+    if payload is DELIM_PKT:
+        return b'0001'
     else:
         # data-pkt
         if len(payload) > PKT_PAYLOAD_MAX:
@@ -45,6 +49,8 @@ def encode_packet(payload):
 
 def decode_packet(input):
     """Consume a packet, returning the payload and any unconsumed tail."""
+    if input.startswith(b'0001'):
+        return (DELIM_PKT, input[PKT_LEN_SIZE:])
     if len(input) < PKT_LEN_SIZE:
         return (INCOMPLETE_PKT, input)
     if input.startswith(b'0000'):
@@ -80,7 +86,7 @@ def decode_request(data):
     if len(bits) < 2 or bits[-1] != b'':
         raise ValueError('Invalid git-proto-request')
     pathname = bits[0]
-    params = {}
+    params = OrderedDict()
     for index, param in enumerate(bits[1:-1]):
         if param == b'':
             if (index < len(bits) - 1):
@@ -94,7 +100,7 @@ def decode_request(data):
         if name in params:
             raise ValueError('Parameters must not be repeated')
         params[name] = value
-    return (command, pathname, params)
+    return command, pathname, params
 
 
 def encode_request(command, pathname, params):
@@ -105,7 +111,7 @@ def encode_request(command, pathname, params):
     if b' ' in command or b'\0' in pathname:
         raise ValueError('Metacharacter in arguments')
     bits = [pathname]
-    for name in sorted(params):
+    for name in params:
         value = params[name]
         value = six.ensure_binary(value) if value is not None else b''
         name = six.ensure_binary(name)
@@ -226,3 +232,22 @@ def translate_xmlrpc_fault(code):
     else:
         result = TurnipFaultCode.INTERNAL_SERVER_ERROR
     return result
+
+
+def get_capabilities_advertisement(version='1'):
+    """Returns the capability advertisement binary string to be sent to
+    clients for a given protocol version requested.
+
+    If no binary data is sent, no advertisement is done and we declare to
+    not be compatible with that specific version."""
+    if version != '2':
+        return b""
+    turnip_version = six.ensure_binary(version_info.get("revision_id", '-1'))
+    return (
+        encode_packet(b"version 2\n") +
+        encode_packet(b"agent=turnip/%s\n" % turnip_version) +
+        encode_packet(b"ls-refs\n") +
+        encode_packet(b"fetch=shallow\n") +
+        encode_packet(b"server-option\n") +
+        b'0000'
+    )
diff --git a/turnip/pack/http.py b/turnip/pack/http.py
index c0becf0..50fd5b8 100644
--- a/turnip/pack/http.py
+++ b/turnip/pack/http.py
@@ -29,6 +29,7 @@ from paste.auth.cookie import (
     decode as decode_cookie,
     encode as encode_cookie,
     )
+import six
 from twisted.internet import (
     defer,
     error,
@@ -57,9 +58,10 @@ from turnip.pack.git import (
 from turnip.pack.helpers import (
     encode_packet,
     encode_request,
+    get_capabilities_advertisement,
     translate_xmlrpc_fault,
     TurnipFaultCode,
-    )
+)
 try:
     from turnip.version_info import version_info
 except ImportError:
@@ -80,6 +82,16 @@ def fail_request(request, message, code=http.INTERNAL_SERVER_ERROR):
     return b''
 
 
+def get_protocol_version_from_request(request):
+    version_header = request.requestHeaders.getRawHeaders(
+        'git-protocol', ['version=1'])[0]
+    try:
+        return version_header.split('version=', 1)[1]
+    except IndexError:
+        pass
+    return 1
+
+
 class HTTPPackClientProtocol(PackProtocol):
     """Abstract bridge between a Git pack connection and a smart HTTP request.
 
@@ -99,7 +111,11 @@ class HTTPPackClientProtocol(PackProtocol):
 
     def startGoodResponse(self):
         """Prepare the HTTP response for forwarding from the backend."""
-        raise NotImplementedError()
+        self.factory.http_request.write(
+            get_capabilities_advertisement(self.getProtocolVersion()))
+
+    def getProtocolVersion(self):
+        return get_protocol_version_from_request(self.factory.http_request)
 
     def backendConnected(self):
         """Called when the backend is connected and has sent a good packet."""
@@ -209,6 +225,7 @@ class HTTPPackClientRefsProtocol(HTTPPackClientProtocol):
         self.factory.http_request.setHeader(
             b'Content-Type',
             b'application/x-%s-advertisement' % self.factory.command)
+        super(HTTPPackClientRefsProtocol, self).startGoodResponse()
 
     def backendConnected(self):
         HTTPPackClientProtocol.backendConnected(self)
@@ -221,10 +238,11 @@ class HTTPPackClientCommandProtocol(HTTPPackClientProtocol):
 
     def startGoodResponse(self):
         """Prepare the HTTP response for forwarding from the backend."""
-        self.factory.http_request.setResponseCode(http.OK)
-        self.factory.http_request.setHeader(
-            b'Content-Type',
-            b'application/x-%s-result' % self.factory.command)
+        if self.getProtocolVersion() != b'2':
+            self.factory.http_request.setResponseCode(http.OK)
+            self.factory.http_request.setHeader(
+                b'Content-Type',
+                b'application/x-%s-result' % self.factory.command)
 
 
 class HTTPPackClientFactory(protocol.ClientFactory):
@@ -282,6 +300,8 @@ class BaseSmartHTTPResource(resource.Resource):
         params = {
             b'turnip-can-authenticate': b'yes',
             b'turnip-request-id': str(uuid.uuid4()),
+            b'version': six.ensure_binary(
+                get_protocol_version_from_request(request))
             }
         authenticated_params = yield self.authenticateUser(request)
         for key, value in authenticated_params.items():
diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
index 27bc26e..56a6c17 100644
--- a/turnip/pack/tests/test_functional.py
+++ b/turnip/pack/tests/test_functional.py
@@ -83,6 +83,7 @@ class FunctionalTestMixin(WithScenarios):
     run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=30)
 
     scenarios = [
+        ('v0 protocol', {"protocol_version": b"0"}),
         ('v1 protocol', {"protocol_version": b"1"}),
         ('v2 protocol', {"protocol_version": b"2"}),
         ]
@@ -815,16 +816,19 @@ class TestSmartHTTPFrontendWithAuthFunctional(TestSmartHTTPFrontendFunctional):
         test_root = self.useFixture(TempDir()).path
         clone = os.path.join(test_root, 'clone')
         yield self.assertCommandSuccess((b'git', b'clone', self.ro_url, clone))
+        expected_requests = 1 if self.protocol_version in ('0', '1') else 2
         self.assertEqual(
-            [(b'test-user', b'test-password')], self.virtinfo.authentications)
-        self.assertThat(self.virtinfo.translations, MatchesListwise([
-            MatchesListwise([
+            [(b'test-user', b'test-password')] * expected_requests,
+            self.virtinfo.authentications)
+        self.assertEqual(expected_requests, len(self.virtinfo.translations))
+        for translation in self.virtinfo.translations:
+            self.assertThat(translation, MatchesListwise([
                 Equals(b'/test'), Equals(b'read'),
                 MatchesDict({
                     b'can-authenticate': Is(True),
                     b'request-id': Not(Is(None)),
-                    b'user': Equals(b'test-user'),
-                    })])]))
+                    b'user': Equals(b'test-user')})
+                ]))
 
     @defer.inlineCallbacks
     def test_authenticated_push(self):
diff --git a/turnip/pack/tests/test_git.py b/turnip/pack/tests/test_git.py
index 8d4ed32..d1a8162 100644
--- a/turnip/pack/tests/test_git.py
+++ b/turnip/pack/tests/test_git.py
@@ -229,8 +229,9 @@ class TestPackBackendProtocol(TestCase):
             [('foo.git', )], self.virtinfo.confirm_repo_creation_call_args)
 
         self.assertEqual(
-            (b'git', [b'git', b'upload-pack', full_path], {}),
-            self.proto.test_process)
+            (b'git', [b'git', b'upload-pack', full_path], {
+                'GIT_PROTOCOL': 'version=0'
+            }), self.proto.test_process)
 
     @defer.inlineCallbacks
     def test_create_repo_fails_to_confirm(self):
@@ -267,7 +268,7 @@ class TestPackBackendProtocol(TestCase):
         self.assertEqual(
             (b'git',
              [b'git', b'upload-pack', full_path],
-             {}),
+             {'GIT_PROTOCOL': 'version=0'}),
             self.proto.test_process)
 
     def test_git_receive_pack_calls_spawnProcess(self):
diff --git a/turnip/pack/tests/test_helpers.py b/turnip/pack/tests/test_helpers.py
index a7a3870..ec89370 100644
--- a/turnip/pack/tests/test_helpers.py
+++ b/turnip/pack/tests/test_helpers.py
@@ -9,21 +9,29 @@ from __future__ import (
 
 import os.path
 import re
-import stat
-import sys
-from textwrap import dedent
-import time
+import shutil
+import subprocess
+import tempfile
 
 from fixtures import TempDir
 from pygit2 import (
     Config,
     init_repository,
     )
+import six
+import stat
+import sys
 from testtools import TestCase
+from textwrap import dedent
+import time
 
 from turnip.pack import helpers
+from turnip.pack.helpers import (
+    encode_packet,
+    get_capabilities_advertisement,
+    )
 import turnip.pack.hooks
-
+from turnip.version_info import version_info
 
 TEST_DATA = b'0123456789abcdef'
 TEST_PKT = b'00140123456789abcdef'
@@ -303,3 +311,30 @@ class TestEnsureHooks(TestCase):
                 self.assertEqual(expected_bytes, actual.read())
         # The hook is executable.
         self.assertTrue(os.stat(self.hook('hook.py')).st_mode & stat.S_IXUSR)
+
+
+class TestCapabilityAdvertisement(TestCase):
+    def test_returning_same_output_as_git_command(self):
+        """Make sure that our hard-coded feature advertisement matches what
+        our git command advertises."""
+        root = tempfile.mkdtemp(prefix=b'turnip-test-root-')
+        self.addCleanup(shutil.rmtree, root, ignore_errors=True)
+        # Create a dummy repository
+        subprocess.call(['git', 'init', root])
+
+        git_version = subprocess.check_output(['git', '--version'])
+        git_version_num = six.ensure_binary(git_version.split(' ')[-1].strip())
+        git_agent = encode_packet(b"agent=git/%s\n" % git_version_num)
+
+        proc = subprocess.Popen(
+            ['git', 'upload-pack', root], env={"GIT_PROTOCOL": "version=2"},
+            stdout=subprocess.PIPE, stdin=subprocess.PIPE)
+        git_advertised_capabilities, _ = proc.communicate()
+
+        turnip_capabilities = get_capabilities_advertisement(version=b'2')
+        turnip_agent = encode_packet(
+            b"agent=turnip/%s\n" % version_info["revision_id"])
+
+        self.assertEqual(
+            turnip_capabilities,
+            git_advertised_capabilities.replace(git_agent, turnip_agent))
diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py
index 26e2754..3471cd6 100644
--- a/turnip/pack/tests/test_http.py
+++ b/turnip/pack/tests/test_http.py
@@ -24,7 +24,10 @@ from turnip.pack import (
     helpers,
     http,
     )
+from turnip.pack.helpers import encode_packet
 from turnip.pack.tests.fake_servers import FakeVirtInfoService
+from turnip.tests.compat import mock
+from turnip.version_info import version_info
 
 
 class LessDummyRequest(requesthelper.DummyRequest):
@@ -74,12 +77,14 @@ class FakeRoot(object):
 
     def __init__(self):
         self.backend_transport = None
+        self.client_factory = None
         self.backend_connected = defer.Deferred()
 
     def authenticateWithPassword(self, user, password):
         return {}
 
     def connectToBackend(self, client_factory):
+        self.client_factory = client_factory
         self.backend_transport = testing.StringTransportWithDisconnection()
         p = client_factory.buildProtocol(None)
         self.backend_transport.protocol = p
@@ -186,6 +191,36 @@ class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
             'And I am raw, since we got a good packet to start with.',
             self.request.value)
 
+    @defer.inlineCallbacks
+    def test_good_v2_included_version_and_capabilities(self):
+        self.request.requestHeaders.addRawHeader("Git-Protocol", "version=2")
+        yield self.performRequest(
+            helpers.encode_packet(b'I am git protocol data.') +
+            b'And I am raw, since we got a good packet to start with.')
+        self.assertEqual(200, self.request.responseCode)
+        self.assertEqual(self.root.client_factory.params, {
+            'version': '2',
+            'turnip-advertise-refs': 'yes',
+            'turnip-can-authenticate': 'yes',
+            'turnip-request-id': mock.ANY,
+            'turnip-stateless-rpc': 'yes'})
+
+        ver = version_info["revision_id"]
+        capabilities = (
+            encode_packet(b'version 2\n') +
+            encode_packet(b'agent=turnip/%s\n' % ver) +
+            encode_packet(b'ls-refs\n') +
+            encode_packet(b'fetch=shallow\n') +
+            encode_packet(b'server-option\n') +
+            b'0000'
+            )
+        self.assertEqual(
+            capabilities +
+            '001e# service=git-upload-pack\n'
+            '0000001bI am git protocol data.'
+            'And I am raw, since we got a good packet to start with.',
+            self.request.value)
+
 
 class TestSmartHTTPCommandResource(ErrorTestMixin, TestCase):