Merge ~pappacena/turnip:http-v2-protocol into turnip:master
- Git
- lp:~pappacena/turnip
- http-v2-protocol
- Merge into master
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) |
Related bugs: |
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
Description of the change
- 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
1 | diff --git a/config.yaml b/config.yaml |
2 | index 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 |
14 | diff --git a/requirements.txt b/requirements.txt |
15 | index 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 |
37 | diff --git a/setup.py b/setup.py |
38 | index 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 | ] |
49 | diff --git a/turnip/pack/extensions/__init__.py b/turnip/pack/extensions/__init__.py |
50 | new file mode 100644 |
51 | index 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 | + ) |
105 | diff --git a/turnip/pack/extensions/agent.py b/turnip/pack/extensions/agent.py |
106 | new file mode 100644 |
107 | index 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) |
137 | diff --git a/turnip/pack/extensions/extension.py b/turnip/pack/extensions/extension.py |
138 | new file mode 100644 |
139 | index 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 | + |
204 | diff --git a/turnip/pack/extensions/fetch.py b/turnip/pack/extensions/fetch.py |
205 | new file mode 100644 |
206 | index 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") |
232 | diff --git a/turnip/pack/extensions/lsrefs.py b/turnip/pack/extensions/lsrefs.py |
233 | new file mode 100644 |
234 | index 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") |
260 | diff --git a/turnip/pack/extensions/tests/__init__.py b/turnip/pack/extensions/tests/__init__.py |
261 | new file mode 100644 |
262 | index 0000000..e69de29 |
263 | --- /dev/null |
264 | +++ b/turnip/pack/extensions/tests/__init__.py |
265 | diff --git a/turnip/pack/extensions/tests/test_extensions.py b/turnip/pack/extensions/tests/test_extensions.py |
266 | new file mode 100644 |
267 | index 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')) |
313 | diff --git a/turnip/pack/git.py b/turnip/pack/git.py |
314 | index 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) |
510 | diff --git a/turnip/pack/helpers.py b/turnip/pack/helpers.py |
511 | index 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') |
662 | diff --git a/turnip/pack/http.py b/turnip/pack/http.py |
663 | index 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(): |
771 | diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py |
772 | index 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: |
824 | diff --git a/turnip/pack/tests/test_helpers.py b/turnip/pack/tests/test_helpers.py |
825 | index 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.""" |
926 | diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py |
927 | index 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 |
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).