Merge ~pappacena/turnip:http-v2-protocol into turnip:master

Proposed by Thiago F. Pappacena
Status: Work in progress
Proposed branch: ~pappacena/turnip:http-v2-protocol
Merge into: turnip:master
Diff against target: 996 lines (+497/-58)
16 files modified
config.yaml (+5/-0)
requirements.txt (+3/-2)
setup.py (+1/-0)
turnip/pack/extensions/__init__.py (+50/-0)
turnip/pack/extensions/agent.py (+26/-0)
turnip/pack/extensions/extension.py (+61/-0)
turnip/pack/extensions/fetch.py (+22/-0)
turnip/pack/extensions/lsrefs.py (+22/-0)
turnip/pack/extensions/tests/__init__.py (+0/-0)
turnip/pack/extensions/tests/test_extensions.py (+42/-0)
turnip/pack/git.py (+65/-20)
turnip/pack/helpers.py (+75/-19)
turnip/pack/http.py (+39/-4)
turnip/pack/tests/test_functional.py (+15/-2)
turnip/pack/tests/test_helpers.py (+38/-11)
turnip/pack/tests/test_http.py (+33/-0)
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+388269@code.launchpad.net

Commit message

Accepting git-protocol header for protocol version 2

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I'll keep this MP as "Work in progress" until I write some functional tests using protocol v2, to make sure it will be compatible with both protocol versions (even if no relevant capability is implemented for v2 right now).

~pappacena/turnip:http-v2-protocol updated
6e8f1a4... by Thiago F. Pappacena

Refactoring the way we implement extensions

5132bc1... by Thiago F. Pappacena

delegating commands to extension

60583ba... by Thiago F. Pappacena

Merge branch 'master' into http-v2-protocol

e126bd5... by Thiago F. Pappacena

Decoding v2 protocol extra parameters

5ba7fdf... by Thiago F. Pappacena

Merge branch 'decode-v2-protocol-commands' into http-v2-protocol

1b9bf8a... by Thiago F. Pappacena

Making the v2 protocol negotiation work, and cleaning up things

797a1ac... by Thiago F. Pappacena

ls-refs kind of working

7a76ade... by Thiago F. Pappacena

Checkpoint

caefc9d... by Thiago F. Pappacena

HTTP v2 working ok-ish

Unmerged commits

caefc9d... by Thiago F. Pappacena

HTTP v2 working ok-ish

7a76ade... by Thiago F. Pappacena

Checkpoint

797a1ac... by Thiago F. Pappacena

ls-refs kind of working

1b9bf8a... by Thiago F. Pappacena

Making the v2 protocol negotiation work, and cleaning up things

5ba7fdf... by Thiago F. Pappacena

Merge branch 'decode-v2-protocol-commands' into http-v2-protocol

e126bd5... by Thiago F. Pappacena

Decoding v2 protocol extra parameters

60583ba... by Thiago F. Pappacena

Merge branch 'master' into http-v2-protocol

5132bc1... by Thiago F. Pappacena

delegating commands to extension

6e8f1a4... by Thiago F. Pappacena

Refactoring the way we implement extensions

b37f9e2... by Thiago F. Pappacena

Adding git v2 protocol header for HTTP frontend

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/config.yaml b/config.yaml
2index 99b0e7b..3a75467 100644
3--- a/config.yaml
4+++ b/config.yaml
5@@ -21,3 +21,8 @@ openid_provider_root: https://testopenid.test/
6 site_name: git.launchpad.test
7 main_site_root: https://launchpad.test/
8 celery_broker: pyamqp://guest@localhost//
9+
10+# Enabled pack extension classes, relative to turnip.pack.extensions. This
11+# is only available for v2 protocol. If no extension is enabled, v2
12+# compatibility will not be advertised.
13+pack_extensions: .agent.Agent .lsrefs.LSRefs .fetch.Fetch
14diff --git a/requirements.txt b/requirements.txt
15index 89895ea..4c5f050 100644
16--- a/requirements.txt
17+++ b/requirements.txt
18@@ -39,7 +39,7 @@ mock==3.0.5; python_version < "3"
19 pathlib2==2.3.5
20 Paste==2.0.2
21 PasteDeploy==2.1.0
22-pbr==5.4.4
23+pbr==5.4.5
24 pep8==1.5.7
25 psutil==5.7.0
26 pyasn1==0.4.8
27@@ -60,7 +60,8 @@ scandir==1.10.0
28 setuptools-scm==1.17.0
29 simplejson==3.6.5
30 six==1.14.0
31-testtools==2.3.0
32+testscenarios-0.5.0
33+testtools==2.4.0
34 traceback2==1.4.0
35 translationstring==1.3
36 Twisted[conch]==20.3.0
37diff --git a/setup.py b/setup.py
38index 76bf6c2..44507da 100755
39--- a/setup.py
40+++ b/setup.py
41@@ -37,6 +37,7 @@ test_requires = [
42 'fixtures',
43 'flake8',
44 'mock; python_version < "3"',
45+ 'testscenarios',
46 'testtools',
47 'webtest',
48 ]
49diff --git a/turnip/pack/extensions/__init__.py b/turnip/pack/extensions/__init__.py
50new file mode 100644
51index 0000000..08b685e
52--- /dev/null
53+++ b/turnip/pack/extensions/__init__.py
54@@ -0,0 +1,50 @@
55+# Copyright 2015 Canonical Ltd. This software is licensed under the
56+# GNU Affero General Public License version 3 (see the file LICENSE).
57+
58+from __future__ import (
59+ absolute_import,
60+ print_function,
61+ unicode_literals,
62+ )
63+
64+from importlib import import_module
65+
66+import six
67+
68+from turnip.config import config
69+from turnip.pack.helpers import encode_packet, FLUSH_PKT
70+from turnip.version_info import version_info
71+
72+
73+def get_available_extensions(version='2'):
74+ """Reeturns the list of available PacketExtension classes."""
75+ # For now, we only have 1 and 2, but this might change in the future
76+ # (2.1? 2.5?), so delay this "int" conversion as much as possible.
77+ version = int(version)
78+ if version == 1:
79+ return []
80+ available = []
81+ for class_path in config.get('pack_extensions').split():
82+ module_path, class_name = class_path.rsplit('.', 1)
83+ module = import_module(module_path, 'turnip.pack.extensions')
84+ extension_cls = getattr(module, class_name)
85+ if extension_cls.min_version > version:
86+ continue
87+ available.append(extension_cls)
88+ return available
89+
90+
91+def get_capabilities_description(version='1'):
92+ """Returns the capability advertisement binary string for the given
93+ protocol version."""
94+ if version != '2':
95+ return b""
96+ turnip_version = six.ensure_binary(version_info.get("revision_id", '-1'))
97+ return (
98+ encode_packet(b"version 2\n") +
99+ encode_packet(b"agent=turnip/%s\n" % turnip_version) +
100+ encode_packet(b"ls-refs\n") +
101+ encode_packet(b"fetch=shallow\n") +
102+ encode_packet(b"server-option\n") +
103+ FLUSH_PKT
104+ )
105diff --git a/turnip/pack/extensions/agent.py b/turnip/pack/extensions/agent.py
106new file mode 100644
107index 0000000..3183c01
108--- /dev/null
109+++ b/turnip/pack/extensions/agent.py
110@@ -0,0 +1,26 @@
111+# Copyright 2015 Canonical Ltd. This software is licensed under the
112+# GNU Affero General Public License version 3 (see the file LICENSE).
113+
114+from __future__ import (
115+ absolute_import,
116+ print_function,
117+ unicode_literals,
118+ )
119+
120+import six
121+
122+from turnip.pack.extensions.extension import PackExtension
123+from turnip.pack.helpers import (
124+ encode_packet,
125+ )
126+from turnip.version_info import version_info
127+
128+
129+class Agent(PackExtension):
130+ min_version = 2
131+
132+ @classmethod
133+ def getCapabilityAdvertisement(cls):
134+ version = version_info.get('revision_id', '-1')
135+ agent = b"agent=turnip/%s\n" % six.ensure_binary(version)
136+ return encode_packet(agent)
137diff --git a/turnip/pack/extensions/extension.py b/turnip/pack/extensions/extension.py
138new file mode 100644
139index 0000000..58843c8
140--- /dev/null
141+++ b/turnip/pack/extensions/extension.py
142@@ -0,0 +1,61 @@
143+# Copyright 2015 Canonical Ltd. This software is licensed under the
144+# GNU Affero General Public License version 3 (see the file LICENSE).
145+
146+from __future__ import (
147+ absolute_import,
148+ print_function,
149+ unicode_literals,
150+ )
151+
152+__metaclass__ = type
153+
154+import os
155+
156+from turnip.api.store import open_repo
157+
158+
159+class PackExtension:
160+ # Minimum protocol version where this extension is available.
161+ min_version = 2
162+
163+ # Which command this extension represents (for example, 'ls-refs',
164+ # 'fetch', ...)
165+ command = None
166+
167+ # If False, the connection with the client will be closed immediately
168+ # after execute() is called.
169+ is_async = False
170+
171+ def __init__(self, backend_protocol=None, path=None, args=None):
172+ """Initialize the extension.
173+
174+ :param backend_protocol: The PackBackendProtocol instance requesting
175+ this extension to do its job.
176+ """
177+ self.backend_protocol = backend_protocol
178+ self.path = path
179+ self.args = args
180+ self.data_buffer = None
181+ self.done = False
182+
183+ def open_repo(self):
184+ repo_store = os.path.dirname(self.path)
185+ repo_name = os.path.basename(self.path)
186+ return open_repo(repo_store, repo_name)
187+
188+ @property
189+ def log(self):
190+ return self.backend_protocol.log
191+
192+ def sendRawData(self, data):
193+ self.backend_protocol.sendRawData(data)
194+
195+ def sendPacket(self, data):
196+ self.backend_protocol.sendPacket(data)
197+
198+ @classmethod
199+ def getCapabilityAdvertisement(self):
200+ raise NotImplementedError(
201+ "This must be implemented by subclasses, returning its binary "
202+ "representation")
203+
204diff --git a/turnip/pack/extensions/fetch.py b/turnip/pack/extensions/fetch.py
205new file mode 100644
206index 0000000..8a64ae8
207--- /dev/null
208+++ b/turnip/pack/extensions/fetch.py
209@@ -0,0 +1,22 @@
210+# Copyright 2015 Canonical Ltd. This software is licensed under the
211+# GNU Affero General Public License version 3 (see the file LICENSE).
212+
213+from __future__ import (
214+ absolute_import,
215+ print_function,
216+ unicode_literals,
217+ )
218+
219+from turnip.pack.extensions.extension import PackExtension
220+from turnip.pack.helpers import encode_packet, FLUSH_PKT
221+
222+
223+class Fetch(PackExtension):
224+ min_version = 2
225+ command = b'fetch'
226+ is_write_operation = False
227+ is_async = False
228+
229+ @classmethod
230+ def getCapabilityAdvertisement(cls):
231+ return encode_packet(b"fetch=shallow\n")
232diff --git a/turnip/pack/extensions/lsrefs.py b/turnip/pack/extensions/lsrefs.py
233new file mode 100644
234index 0000000..9a23f03
235--- /dev/null
236+++ b/turnip/pack/extensions/lsrefs.py
237@@ -0,0 +1,22 @@
238+# Copyright 2015 Canonical Ltd. This software is licensed under the
239+# GNU Affero General Public License version 3 (see the file LICENSE).
240+
241+from __future__ import (
242+ absolute_import,
243+ print_function,
244+ unicode_literals,
245+ )
246+
247+from turnip.pack.extensions.extension import PackExtension
248+from turnip.pack.helpers import encode_packet, FLUSH_PKT
249+
250+
251+class LSRefs(PackExtension):
252+ min_version = 2
253+ command = b'ls-refs'
254+ is_write_operation = False
255+ is_async = False
256+
257+ @classmethod
258+ def getCapabilityAdvertisement(cls):
259+ return encode_packet(b"ls-refs\n")
260diff --git a/turnip/pack/extensions/tests/__init__.py b/turnip/pack/extensions/tests/__init__.py
261new file mode 100644
262index 0000000..e69de29
263--- /dev/null
264+++ b/turnip/pack/extensions/tests/__init__.py
265diff --git a/turnip/pack/extensions/tests/test_extensions.py b/turnip/pack/extensions/tests/test_extensions.py
266new file mode 100644
267index 0000000..299e786
268--- /dev/null
269+++ b/turnip/pack/extensions/tests/test_extensions.py
270@@ -0,0 +1,42 @@
271+# Copyright 2015 Canonical Ltd. This software is licensed under the
272+# GNU Affero General Public License version 3 (see the file LICENSE).
273+
274+from __future__ import (
275+ absolute_import,
276+ print_function,
277+ unicode_literals,
278+ )
279+
280+from fixtures import EnvironmentVariable
281+from testtools import TestCase
282+
283+from turnip.pack.extensions import (
284+ get_capabilities_description,
285+ get_available_extensions,
286+ )
287+from turnip.pack.extensions.agent import Agent
288+from turnip.pack.extensions.lsrefs import LSRefs
289+from turnip.version_info import version_info
290+
291+
292+class TestCapabilities(TestCase):
293+ def test_get_extensions_from_config(self):
294+ self.useFixture(EnvironmentVariable(
295+ "PACK_EXTENSIONS", ".agent.Agent .lsrefs.LSRefs"))
296+ available = get_available_extensions('2')
297+ self.assertEqual(2, len(available))
298+ self.assertEqual(available[0], Agent)
299+ self.assertEqual(available[1], LSRefs)
300+
301+ def test_get_capabilities_description_v1_is_empty(self):
302+ self.assertEqual(b"", get_capabilities_description(1))
303+
304+ def test_get_capabilities_description_v2_with_agent_only(self):
305+ self.useFixture(EnvironmentVariable(
306+ "PACK_EXTENSIONS", ".agent.Agent .lsrefs.LSRefs"))
307+ self.assertEqual(
308+ b"000eversion 2\n"
309+ b"003aagent=turnip/%s\n"
310+ b"000cls-refs\n"
311+ b"0000" % version_info['revision_id'],
312+ get_capabilities_description('2'))
313diff --git a/turnip/pack/git.py b/turnip/pack/git.py
314index 763d5d6..43cbb32 100644
315--- a/turnip/pack/git.py
316+++ b/turnip/pack/git.py
317@@ -29,6 +29,7 @@ from turnip.api import store
318 from turnip.api.store import AlreadyExistsError
319 from turnip.config import config
320 from turnip.helpers import compose_path
321+from turnip.pack.extensions import get_available_extensions
322 from turnip.pack.helpers import (
323 decode_packet,
324 decode_request,
325@@ -37,8 +38,8 @@ from turnip.pack.helpers import (
326 ensure_config,
327 ensure_hooks,
328 INCOMPLETE_PKT,
329- translate_xmlrpc_fault,
330- )
331+ translate_xmlrpc_fault, FLUSH_PKT, DELIM_PKT,
332+)
333
334
335 ERROR_PREFIX = b'ERR '
336@@ -77,13 +78,17 @@ class UnstoppableProducerWrapper(object):
337 pass
338
339
340-class PackProtocol(protocol.Protocol):
341+class PackProtocol(protocol.Protocol, object):
342
343 paused = False
344 raw = False
345+ extension = None
346
347 __buffer = b''
348
349+ def getProtocolVersion(self):
350+ raise NotImplementedError()
351+
352 def packetReceived(self, payload):
353 raise NotImplementedError()
354
355@@ -187,11 +192,11 @@ class PackServerProtocol(PackProxyProtocol):
356 command, pathname, params = decode_request(data)
357 except ValueError as e:
358 self.die(str(e).encode('utf-8'))
359- return
360+ return None
361 self.pauseProducing()
362 self.got_request = True
363 self.requestReceived(command, pathname, params)
364- return
365+ return None
366 if data is None:
367 self.raw = True
368 self.peer.sendPacket(data)
369@@ -239,15 +244,20 @@ class GitProcessProtocol(protocol.ProcessProtocol):
370
371 _err_buffer = b''
372
373- def __init__(self, peer):
374+ def __init__(self, peer, cmd_input=None):
375 self.peer = peer
376+ self.cmd_input = cmd_input
377 self.out_started = False
378
379 def connectionMade(self):
380 self.peer.setPeer(self)
381 self.peer.transport.registerProducer(self, True)
382- self.transport.registerProducer(
383- UnstoppableProducerWrapper(self.peer.transport), True)
384+ if not self.cmd_input:
385+ self.transport.registerProducer(
386+ UnstoppableProducerWrapper(self.peer.transport), True)
387+ else:
388+ self.transport.write(self.cmd_input)
389+ self.loseWriteConnection()
390 self.peer.resumeProducing()
391
392 def outReceived(self, data):
393@@ -415,7 +425,7 @@ class PackProxyServerProtocol(PackServerProtocol):
394 def resumeProducing(self):
395 # Send our translated request and then open the gate to the client.
396 self.sendNextCommand()
397- PackServerProtocol.resumeProducing(self)
398+ super(PackProxyServerProtocol, self).resumeProducing()
399
400 def readConnectionLost(self):
401 # Forward the closed stdin down the stack.
402@@ -431,12 +441,14 @@ class PackBackendProtocol(PackServerProtocol):
403
404 hookrpc_key = None
405 expect_set_symbolic_ref = False
406+ extension = None
407
408 @defer.inlineCallbacks
409 def requestReceived(self, command, raw_pathname, params):
410 self.extractRequestMeta(command, raw_pathname, params)
411 self.command = command
412 self.raw_pathname = raw_pathname
413+ self.params = params
414 self.path = compose_path(self.factory.root, self.raw_pathname)
415 auth_params = self.createAuthParams(params)
416
417@@ -457,14 +469,42 @@ class PackBackendProtocol(PackServerProtocol):
418 return
419
420 write_operation = False
421- if command == b'git-upload-pack':
422- subcmd = b'upload-pack'
423- elif command == b'git-receive-pack':
424- subcmd = b'receive-pack'
425- write_operation = True
426+ cmd_input = None
427+ send_path_as_option = False
428+ cmd_env = {}
429+ if params.get('version') != '2':
430+ if command == b'git-upload-pack':
431+ subcmd = b'upload-pack'
432+ elif command == b'git-receive-pack':
433+ subcmd = b'receive-pack'
434+ write_operation = True
435+ else:
436+ self.die(b"Invalid command: %s" % command)
437 else:
438- self.die(b'Unsupported command in request')
439- return
440+ # v2 protocol
441+ if command == b'git-upload-pack':
442+ self.expectNextCommand()
443+ self.transport.loseConnection()
444+ return
445+ else:
446+ subcmd = b'upload-pack'
447+ cmd_env["GIT_PROTOCOL"] = 'version=2'
448+ send_path_as_option = True
449+ params.pop(b'turnip-advertise-refs', None)
450+ cmd_input = encode_packet(b"command=%s\n" % command)
451+ for capability in params["capabilities"].split(b"\n"):
452+ cmd_input += encode_packet(b"%s\n" % capability)
453+ cmd_input += DELIM_PKT
454+ ignore_keys = ('capabilities', 'version', 'extra_flags')
455+ for k, v in params.items():
456+ k = six.ensure_binary(k)
457+ if k.startswith(b"turnip-") or k in ignore_keys:
458+ continue
459+ for param_value in v.split(b'\n'):
460+ value = (b"" if not param_value
461+ else b" %s" % param_value)
462+ cmd_input += encode_packet(b"%s%s\n" % (k, value))
463+ cmd_input += FLUSH_PKT
464
465 args = []
466 if params.pop(b'turnip-stateless-rpc', None):
467@@ -475,10 +515,14 @@ class PackBackendProtocol(PackServerProtocol):
468 self.spawnGit(subcmd,
469 args,
470 write_operation=write_operation,
471- auth_params=auth_params)
472+ auth_params=auth_params,
473+ send_path_as_option=send_path_as_option,
474+ cmd_env=cmd_env,
475+ cmd_input=cmd_input)
476
477 def spawnGit(self, subcmd, extra_args, write_operation=False,
478- send_path_as_option=False, auth_params=None):
479+ send_path_as_option=False, auth_params=None,
480+ cmd_env=None, cmd_input=None):
481 cmd = b'git'
482 args = [b'git']
483 if send_path_as_option:
484@@ -487,6 +531,7 @@ class PackBackendProtocol(PackServerProtocol):
485 args.extend(extra_args)
486
487 env = {}
488+ env.update((cmd_env or {}))
489 if write_operation and self.factory.hookrpc_handler:
490 # This is a write operation, so prepare config, hooks, the hook
491 # RPC server, and the environment variables that link them up.
492@@ -499,7 +544,7 @@ class PackBackendProtocol(PackServerProtocol):
493 env[b'TURNIP_HOOK_RPC_KEY'] = self.hookrpc_key
494
495 self.log.info('Spawning {args}', args=args)
496- self.peer = GitProcessProtocol(self)
497+ self.peer = GitProcessProtocol(self, cmd_input)
498 self.spawnProcess(cmd, args, env=env)
499
500 def spawnProcess(self, cmd, args, env=None):
501@@ -628,7 +673,7 @@ class PackVirtServerProtocol(PackProxyServerProtocol):
502 @defer.inlineCallbacks
503 def requestReceived(self, command, pathname, params):
504 self.extractRequestMeta(command, pathname, params)
505- permission = 'read' if command == b'git-upload-pack' else 'write'
506+ permission = 'read' if command != b'git-receive-pack' else 'write'
507 proxy = xmlrpc.Proxy(self.factory.virtinfo_endpoint, allowNone=True)
508 try:
509 auth_params = self.createAuthParams(params)
510diff --git a/turnip/pack/helpers.py b/turnip/pack/helpers.py
511index 9cf411d..52649a9 100644
512--- a/turnip/pack/helpers.py
513+++ b/turnip/pack/helpers.py
514@@ -7,6 +7,8 @@ from __future__ import (
515 unicode_literals,
516 )
517
518+from collections import OrderedDict
519+
520 import enum
521 import hashlib
522 import os.path
523@@ -25,6 +27,8 @@ import yaml
524 import turnip.pack.hooks
525
526
527+FLUSH_PKT = b'0000'
528+DELIM_PKT = b'0001'
529 PKT_LEN_SIZE = 4
530 PKT_PAYLOAD_MAX = 65520
531 INCOMPLETE_PKT = object()
532@@ -33,7 +37,7 @@ INCOMPLETE_PKT = object()
533 def encode_packet(payload):
534 if payload is None:
535 # flush-pkt.
536- return b'0000'
537+ return FLUSH_PKT
538 else:
539 # data-pkt
540 if len(payload) > PKT_PAYLOAD_MAX:
541@@ -47,21 +51,37 @@ def decode_packet(input):
542 """Consume a packet, returning the payload and any unconsumed tail."""
543 if len(input) < PKT_LEN_SIZE:
544 return (INCOMPLETE_PKT, input)
545- if input.startswith(b'0000'):
546+ if input.startswith(FLUSH_PKT):
547 # flush-pkt
548 return (None, input[PKT_LEN_SIZE:])
549- else:
550- # data-pkt
551- try:
552- pkt_len = int(input[:PKT_LEN_SIZE], 16)
553- except ValueError:
554- pkt_len = 0
555- if not (PKT_LEN_SIZE <= pkt_len <= (PKT_LEN_SIZE + PKT_PAYLOAD_MAX)):
556- raise ValueError("Invalid pkt-len")
557- if len(input) < pkt_len:
558- # Some of the packet is yet to be received.
559- return (INCOMPLETE_PKT, input)
560- return (input[PKT_LEN_SIZE:pkt_len], input[pkt_len:])
561+ # data-pkt
562+ try:
563+ pkt_len = int(input[:PKT_LEN_SIZE], 16)
564+ except ValueError:
565+ pkt_len = 0
566+ if not (PKT_LEN_SIZE <= pkt_len <= (PKT_LEN_SIZE + PKT_PAYLOAD_MAX)):
567+ raise ValueError("Invalid pkt-len")
568+ if len(input) < pkt_len:
569+ # Some of the packet is yet to be received.
570+ return (INCOMPLETE_PKT, input)
571+
572+ # v2 protocol "hides" extra parameters after the end of the packet.
573+ if len(input) > pkt_len and b'version=2\x00' in input:
574+ if FLUSH_PKT not in input:
575+ return INCOMPLETE_PKT, input
576+ end = input.index(FLUSH_PKT)
577+ return input[PKT_LEN_SIZE:end], input[end + len(FLUSH_PKT):]
578+
579+ return (input[PKT_LEN_SIZE:pkt_len], input[pkt_len:])
580+
581+
582+def decode_packet_list(data):
583+ remaining = data
584+ retval = []
585+ while remaining:
586+ pkt, remaining = decode_packet(remaining)
587+ retval.append(pkt)
588+ return retval
589
590
591 def decode_request(data):
592@@ -77,10 +97,11 @@ def decode_request(data):
593 bits = rest.split(b'\0')
594 # Following the command is a pathname, then any number of named
595 # parameters. Each of these is NUL-terminated.
596- if len(bits) < 2 or bits[-1] != b'':
597+ # After that, v1 should end (v2 might have extra commands).
598+ if len(bits) < 2 or (b'version=2' not in bits and bits[-1] != b''):
599 raise ValueError('Invalid git-proto-request')
600 pathname = bits[0]
601- params = {}
602+ params = OrderedDict()
603 for index, param in enumerate(bits[1:-1]):
604 if param == b'':
605 if (index < len(bits) - 1):
606@@ -94,7 +115,42 @@ def decode_request(data):
607 if name in params:
608 raise ValueError('Parameters must not be repeated')
609 params[name] = value
610- return (command, pathname, params)
611+
612+ if not bits[-1]:
613+ return command, pathname, params
614+ else:
615+ # Parse v2 extra commands.
616+ cmd, remaining = decode_packet(bits[-1])
617+ cmd = cmd.split(b'=', 1)[-1].strip()
618+ capabilities, args = remaining.split(DELIM_PKT)
619+ cmd_params = OrderedDict({
620+ b"capabilities": decode_packet_list(capabilities)})
621+ cmd_params.update(params)
622+ for arg in decode_packet_list(args):
623+ if arg is None:
624+ continue
625+ arg = arg.strip('\n')
626+ if b' ' in arg:
627+ k, v = arg.split(b' ', 1)
628+ if k not in cmd_params:
629+ cmd_params[k] = []
630+ cmd_params[k].append(v)
631+ else:
632+ cmd_params[arg] = b""
633+ return (cmd, pathname, cmd_params)
634+
635+
636+def get_encoded_value(value):
637+ """Encode a value for serialization on encode_request"""
638+ if value is None:
639+ return b''
640+ if isinstance(value, list):
641+ if any(b'\n' in i for i in value):
642+ raise ValueError('Metacharacter in list argument')
643+ return b"\n".join(get_encoded_value(i) for i in value)
644+ if isinstance(value, bool):
645+ return b'1' if value else b'0'
646+ return six.ensure_binary(value)
647
648
649 def encode_request(command, pathname, params):
650@@ -105,9 +161,9 @@ def encode_request(command, pathname, params):
651 if b' ' in command or b'\0' in pathname:
652 raise ValueError('Metacharacter in arguments')
653 bits = [pathname]
654- for name in sorted(params):
655+ for name in params:
656 value = params[name]
657- value = six.ensure_binary(value) if value is not None else b''
658+ value = get_encoded_value(value)
659 name = six.ensure_binary(name)
660 if b'=' in name or b'\0' in name + value:
661 raise ValueError('Metacharacter in arguments')
662diff --git a/turnip/pack/http.py b/turnip/pack/http.py
663index c0becf0..7ae8765 100644
664--- a/turnip/pack/http.py
665+++ b/turnip/pack/http.py
666@@ -12,6 +12,7 @@ import json
667 import os.path
668 import tempfile
669 import textwrap
670+
671 try:
672 from urllib.parse import urlencode
673 except ImportError:
674@@ -29,6 +30,7 @@ from paste.auth.cookie import (
675 decode as decode_cookie,
676 encode as encode_cookie,
677 )
678+import six
679 from twisted.internet import (
680 defer,
681 error,
682@@ -49,6 +51,7 @@ from twisted.web import (
683 )
684
685 from turnip.helpers import compose_path
686+from turnip.pack.extensions import get_capabilities_description
687 from turnip.pack.git import (
688 ERROR_PREFIX,
689 PackProtocol,
690@@ -80,6 +83,16 @@ def fail_request(request, message, code=http.INTERNAL_SERVER_ERROR):
691 return b''
692
693
694+def get_protocol_version_from_request(request):
695+ version_header = request.requestHeaders.getRawHeaders(
696+ 'git-protocol', ['version=1'])[0]
697+ try:
698+ return version_header.split('version=', 1)[1]
699+ except Exception:
700+ pass
701+ return 1
702+
703+
704 class HTTPPackClientProtocol(PackProtocol):
705 """Abstract bridge between a Git pack connection and a smart HTTP request.
706
707@@ -99,7 +112,11 @@ class HTTPPackClientProtocol(PackProtocol):
708
709 def startGoodResponse(self):
710 """Prepare the HTTP response for forwarding from the backend."""
711- raise NotImplementedError()
712+ self.factory.http_request.write(
713+ get_capabilities_description(self.getProtocolVersion()))
714+
715+ def getProtocolVersion(self):
716+ return get_protocol_version_from_request(self.factory.http_request)
717
718 def backendConnected(self):
719 """Called when the backend is connected and has sent a good packet."""
720@@ -209,12 +226,14 @@ class HTTPPackClientRefsProtocol(HTTPPackClientProtocol):
721 self.factory.http_request.setHeader(
722 b'Content-Type',
723 b'application/x-%s-advertisement' % self.factory.command)
724+ super(HTTPPackClientRefsProtocol, self).startGoodResponse()
725
726 def backendConnected(self):
727 HTTPPackClientProtocol.backendConnected(self)
728- self.rawDataReceived(
729- encode_packet(b'# service=%s\n' % self.factory.command))
730- self.rawDataReceived(encode_packet(None))
731+ if self.getProtocolVersion() != b'2':
732+ self.rawDataReceived(
733+ encode_packet(b'# service=%s\n' % self.factory.command))
734+ self.rawDataReceived(encode_packet(None))
735
736
737 class HTTPPackClientCommandProtocol(HTTPPackClientProtocol):
738@@ -225,6 +244,7 @@ class HTTPPackClientCommandProtocol(HTTPPackClientProtocol):
739 self.factory.http_request.setHeader(
740 b'Content-Type',
741 b'application/x-%s-result' % self.factory.command)
742+ # super(HTTPPackClientCommandProtocol, self).startGoodResponse()
743
744
745 class HTTPPackClientFactory(protocol.ClientFactory):
746@@ -279,9 +299,24 @@ class BaseSmartHTTPResource(resource.Resource):
747 The turnip-authenticated-* parameters are set to the values returned
748 by the virt service, if any.
749 """
750+ # where = content.tell()
751+ # content.seek(0)
752+ # data = content.read()
753+ # content.seek(where)
754+ # print(b"req#%s will connect to backend" % (id(request), ))
755+ # print("--- body ---\n", data, "\n--- endbody ---")
756+ # # import ipdb; ipdb.set_trace()
757+ # orig = request.write
758+ # def x(data):
759+ # print(b"\n\nReq#%s %s %s -> user\n---%s----" % (
760+ # id(request), request.method, request.path, data))
761+ # orig(data)
762+ # request.write = x
763 params = {
764 b'turnip-can-authenticate': b'yes',
765 b'turnip-request-id': str(uuid.uuid4()),
766+ b'version': six.ensure_binary(
767+ get_protocol_version_from_request(request))
768 }
769 authenticated_params = yield self.authenticateUser(request)
770 for key, value in authenticated_params.items():
771diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
772index d6aaa7f..2809593 100644
773--- a/turnip/pack/tests/test_functional.py
774+++ b/turnip/pack/tests/test_functional.py
775@@ -35,6 +35,7 @@ from fixtures import (
776 TempDir,
777 )
778 from pygit2 import GIT_OID_HEX_ZERO
779+from testscenarios.testcase import WithScenarios
780 from testtools import TestCase
781 from testtools.content import text_content
782 from testtools.deferredruntest import AsynchronousDeferredRunTest
783@@ -77,10 +78,14 @@ from turnip.pack.tests.fake_servers import (
784 from turnip.version_info import version_info
785
786
787-class FunctionalTestMixin(object):
788+class FunctionalTestMixin(WithScenarios):
789
790 run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=30)
791
792+ scenarios = [
793+ ('v1 protocol', {"protocol_version": "1"}),
794+ ('v2 protocol', {"protocol_version": "2"})]
795+
796 def startVirtInfo(self):
797 # Set up a fake virt information XML-RPC server which just
798 # maps paths to their SHA-256 hash.
799@@ -120,8 +125,13 @@ class FunctionalTestMixin(object):
800
801 @defer.inlineCallbacks
802 def assertCommandSuccess(self, command, path='.'):
803+ if command[0] == b'git' and self.protocol_version == '2':
804+ args = list(command[1:])
805+ command = [b'git', b'-c', b'protocol.version=2'] + args
806+ env = {"GIT_TRACE_PACKET": '1'}
807+ env.update(os.environ)
808 out, err, code = yield utils.getProcessOutputAndValue(
809- command[0], command[1:], env=os.environ, path=path)
810+ command[0], command[1:], env=env, path=path)
811 if code != 0:
812 self.addDetail('stdout', text_content(out))
813 self.addDetail('stderr', text_content(err))
814@@ -130,6 +140,9 @@ class FunctionalTestMixin(object):
815
816 @defer.inlineCallbacks
817 def assertCommandFailure(self, command, path='.'):
818+ if self.protocol_version == '2':
819+ args = list(command[1:])
820+ command = [b'git', b'-c', b'protocol.version=2'] + args
821 out, err, code = yield utils.getProcessOutputAndValue(
822 command[0], command[1:], env=os.environ, path=path)
823 if code == 0:
824diff --git a/turnip/pack/tests/test_helpers.py b/turnip/pack/tests/test_helpers.py
825index a7a3870..8d09c98 100644
826--- a/turnip/pack/tests/test_helpers.py
827+++ b/turnip/pack/tests/test_helpers.py
828@@ -23,7 +23,7 @@ from testtools import TestCase
829
830 from turnip.pack import helpers
831 import turnip.pack.hooks
832-
833+from turnip.pack.helpers import FLUSH_PKT
834
835 TEST_DATA = b'0123456789abcdef'
836 TEST_PKT = b'00140123456789abcdef'
837@@ -91,8 +91,8 @@ class TestDecodeRequest(TestCase):
838 # We parse extra params behind 2 NUL bytes
839 req = b'git-upload-pack /test_repo\0host=git.launchpad.test\0\0ver=2\0'
840 self.assertEqual(
841- (b'git-upload-pack', b'/test_repo',
842- {b'host': b'git.launchpad.test', b'ver': b'2'}),
843+ [(b'git-upload-pack', b'/test_repo',
844+ {b'host': b'git.launchpad.test', b'ver': b'2'})],
845 helpers.decode_request(req))
846
847 def test_parse_multiple_extra_params_after_2_null_bytes(self):
848@@ -102,9 +102,9 @@ class TestDecodeRequest(TestCase):
849 b'ver=2\0\0param2=value2\0\0param3=value3\0'
850 )
851 self.assertEqual(
852- (b'git-upload-pack', b'/test_repo',
853+ [(b'git-upload-pack', b'/test_repo',
854 {b'host': b'git.launchpad.test', b'ver': b'2',
855- b'param2': b'value2', b'param3': b'value3'}),
856+ b'param2': b'value2', b'param3': b'value3'})],
857 helpers.decode_request(req))
858
859 def test_rejects_extra_param_without_end_null_bytes(self):
860@@ -124,25 +124,25 @@ class TestDecodeRequest(TestCase):
861 def test_allow_2_end_nul_bytes(self):
862 req = b'git-upload-pack /test_repo\0host=git.launchpad.test\0\0'
863 self.assertEqual(
864- (b'git-upload-pack', b'/test_repo',
865- {b'host': b'git.launchpad.test'}),
866+ [(b'git-upload-pack', b'/test_repo',
867+ {b'host': b'git.launchpad.test'})],
868 helpers.decode_request(req))
869
870 def test_without_parameters(self):
871 self.assertEqual(
872- (b'git-do-stuff', b'/some/path', {}),
873+ [(b'git-do-stuff', b'/some/path', {})],
874 helpers.decode_request(b'git-do-stuff /some/path\0'))
875
876 def test_with_host_parameter(self):
877 self.assertEqual(
878- (b'git-do-stuff', b'/some/path', {b'host': b'example.com'}),
879+ [(b'git-do-stuff', b'/some/path', {b'host': b'example.com'})],
880 helpers.decode_request(
881 b'git-do-stuff /some/path\0host=example.com\0'))
882
883 def test_with_host_and_user_parameters(self):
884 self.assertEqual(
885- (b'git-do-stuff', b'/some/path',
886- {b'host': b'example.com', b'user': b'foo=bar'}),
887+ [(b'git-do-stuff', b'/some/path',
888+ {b'host': b'example.com', b'user': b'foo=bar'})],
889 helpers.decode_request(
890 b'git-do-stuff /some/path\0host=example.com\0user=foo=bar\0'))
891
892@@ -173,6 +173,33 @@ class TestDecodeRequest(TestCase):
893 b'git-do-stuff /foo\0host=foo\0host=bar\0',
894 b'Parameters must not be repeated')
895
896+ def test_v2_extra_commands(self):
897+ data = (
898+ b"git-upload-pack b306d" +
899+ b"\x00turnip-x=yes\x00turnip-request-id=123\x00" +
900+ b"version=2\x000014command=ls-refs\n0014agent=git/2.25.1" +
901+ b'00010009peel\n000csymrefs\n0014ref-prefix HEAD\n' +
902+ b'001bref-prefix refs/heads/\n'
903+ b'001aref-prefix refs/tags/\n0000' +
904+ FLUSH_PKT)
905+
906+ decoded = helpers.decode_request(data)
907+ self.assertEqual(2, len(decoded))
908+ first, second = decoded
909+ self.assertEqual((b'ls-refs', b'b306d', {
910+ b'turnip-x': b'yes',
911+ b'turnip-request-id': b'123',
912+ b'version': b'2',
913+ b'capabilities': [b'agent=git/2.25.1'],
914+ b'symrefs': True,
915+ b'peel': True,
916+ b'ref-prefix': [b'HEAD', b'refs/heads/', b'refs/tags/']
917+ }), first)
918+ self.assertEqual((b'git-upload-pack', b'b306d', {
919+ b'turnip-x': b'yes',
920+ b'turnip-request-id': b'123',
921+ b'version': b'2'}), second)
922+
923
924 class TestEncodeRequest(TestCase):
925 """Test git-proto-request encoding."""
926diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py
927index 26e2754..0ce06f5 100644
928--- a/turnip/pack/tests/test_http.py
929+++ b/turnip/pack/tests/test_http.py
930@@ -9,6 +9,7 @@ from __future__ import (
931
932 from io import BytesIO
933
934+from fixtures import EnvironmentVariable
935 from testtools import TestCase
936 from testtools.deferredruntest import AsynchronousDeferredRunTest
937 from twisted.internet import (
938@@ -24,7 +25,10 @@ from turnip.pack import (
939 helpers,
940 http,
941 )
942+from turnip.pack.helpers import encode_packet
943 from turnip.pack.tests.fake_servers import FakeVirtInfoService
944+from turnip.tests.compat import mock
945+from turnip.version_info import version_info
946
947
948 class LessDummyRequest(requesthelper.DummyRequest):
949@@ -74,12 +78,14 @@ class FakeRoot(object):
950
951 def __init__(self):
952 self.backend_transport = None
953+ self.client_factory = None
954 self.backend_connected = defer.Deferred()
955
956 def authenticateWithPassword(self, user, password):
957 return {}
958
959 def connectToBackend(self, client_factory):
960+ self.client_factory = client_factory
961 self.backend_transport = testing.StringTransportWithDisconnection()
962 p = client_factory.buildProtocol(None)
963 self.backend_transport.protocol = p
964@@ -186,6 +192,33 @@ class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
965 'And I am raw, since we got a good packet to start with.',
966 self.request.value)
967
968+ @defer.inlineCallbacks
969+ def test_good_v2_included_version_and_capabilities(self):
970+ self.useFixture(EnvironmentVariable("PACK_EXTENSIONS", ".agent.Agent"))
971+ self.request.requestHeaders.addRawHeader("Git-Protocol", "version=2")
972+ yield self.performRequest(
973+ helpers.encode_packet(b'I am git protocol data.') +
974+ b'And I am raw, since we got a good packet to start with.')
975+ self.assertEqual(200, self.request.responseCode)
976+ self.assertEqual(self.root.client_factory.params, {
977+ 'version': '2',
978+ 'turnip-advertise-refs': 'yes',
979+ 'turnip-can-authenticate': 'yes',
980+ 'turnip-request-id': mock.ANY,
981+ 'turnip-stateless-rpc': 'yes'})
982+
983+ capabilities = (
984+ '000eversion 2\n' +
985+ encode_packet(
986+ 'agent=turnip/%s\n' % version_info["revision_id"]) +
987+ '0000\n')
988+ self.assertEqual(
989+ capabilities +
990+ '001e# service=git-upload-pack\n'
991+ '0000001bI am git protocol data.'
992+ 'And I am raw, since we got a good packet to start with.',
993+ self.request.value)
994+
995
996 class TestSmartHTTPCommandResource(ErrorTestMixin, TestCase):
997

Subscribers

People subscribed via source and target branches