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
diff --git a/config.yaml b/config.yaml
index 99b0e7b..3a75467 100644
--- a/config.yaml
+++ b/config.yaml
@@ -21,3 +21,8 @@ openid_provider_root: https://testopenid.test/
21site_name: git.launchpad.test21site_name: git.launchpad.test
22main_site_root: https://launchpad.test/22main_site_root: https://launchpad.test/
23celery_broker: pyamqp://guest@localhost//23celery_broker: pyamqp://guest@localhost//
24
25# Enabled pack extension classes, relative to turnip.pack.extensions. This
26# is only available for v2 protocol. If no extension is enabled, v2
27# compatibility will not be advertised.
28pack_extensions: .agent.Agent .lsrefs.LSRefs .fetch.Fetch
diff --git a/requirements.txt b/requirements.txt
index 89895ea..4c5f050 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -39,7 +39,7 @@ mock==3.0.5; python_version < "3"
39pathlib2==2.3.539pathlib2==2.3.5
40Paste==2.0.240Paste==2.0.2
41PasteDeploy==2.1.041PasteDeploy==2.1.0
42pbr==5.4.442pbr==5.4.5
43pep8==1.5.743pep8==1.5.7
44psutil==5.7.044psutil==5.7.0
45pyasn1==0.4.845pyasn1==0.4.8
@@ -60,7 +60,8 @@ scandir==1.10.0
60setuptools-scm==1.17.060setuptools-scm==1.17.0
61simplejson==3.6.561simplejson==3.6.5
62six==1.14.062six==1.14.0
63testtools==2.3.063testscenarios-0.5.0
64testtools==2.4.0
64traceback2==1.4.065traceback2==1.4.0
65translationstring==1.366translationstring==1.3
66Twisted[conch]==20.3.067Twisted[conch]==20.3.0
diff --git a/setup.py b/setup.py
index 76bf6c2..44507da 100755
--- a/setup.py
+++ b/setup.py
@@ -37,6 +37,7 @@ test_requires = [
37 'fixtures',37 'fixtures',
38 'flake8',38 'flake8',
39 'mock; python_version < "3"',39 'mock; python_version < "3"',
40 'testscenarios',
40 'testtools',41 'testtools',
41 'webtest',42 'webtest',
42 ]43 ]
diff --git a/turnip/pack/extensions/__init__.py b/turnip/pack/extensions/__init__.py
43new file mode 10064444new file mode 100644
index 0000000..08b685e
--- /dev/null
+++ b/turnip/pack/extensions/__init__.py
@@ -0,0 +1,50 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import (
5 absolute_import,
6 print_function,
7 unicode_literals,
8 )
9
10from importlib import import_module
11
12import six
13
14from turnip.config import config
15from turnip.pack.helpers import encode_packet, FLUSH_PKT
16from turnip.version_info import version_info
17
18
19def get_available_extensions(version='2'):
20 """Reeturns the list of available PacketExtension classes."""
21 # For now, we only have 1 and 2, but this might change in the future
22 # (2.1? 2.5?), so delay this "int" conversion as much as possible.
23 version = int(version)
24 if version == 1:
25 return []
26 available = []
27 for class_path in config.get('pack_extensions').split():
28 module_path, class_name = class_path.rsplit('.', 1)
29 module = import_module(module_path, 'turnip.pack.extensions')
30 extension_cls = getattr(module, class_name)
31 if extension_cls.min_version > version:
32 continue
33 available.append(extension_cls)
34 return available
35
36
37def get_capabilities_description(version='1'):
38 """Returns the capability advertisement binary string for the given
39 protocol version."""
40 if version != '2':
41 return b""
42 turnip_version = six.ensure_binary(version_info.get("revision_id", '-1'))
43 return (
44 encode_packet(b"version 2\n") +
45 encode_packet(b"agent=turnip/%s\n" % turnip_version) +
46 encode_packet(b"ls-refs\n") +
47 encode_packet(b"fetch=shallow\n") +
48 encode_packet(b"server-option\n") +
49 FLUSH_PKT
50 )
diff --git a/turnip/pack/extensions/agent.py b/turnip/pack/extensions/agent.py
0new file mode 10064451new file mode 100644
index 0000000..3183c01
--- /dev/null
+++ b/turnip/pack/extensions/agent.py
@@ -0,0 +1,26 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import (
5 absolute_import,
6 print_function,
7 unicode_literals,
8 )
9
10import six
11
12from turnip.pack.extensions.extension import PackExtension
13from turnip.pack.helpers import (
14 encode_packet,
15 )
16from turnip.version_info import version_info
17
18
19class Agent(PackExtension):
20 min_version = 2
21
22 @classmethod
23 def getCapabilityAdvertisement(cls):
24 version = version_info.get('revision_id', '-1')
25 agent = b"agent=turnip/%s\n" % six.ensure_binary(version)
26 return encode_packet(agent)
diff --git a/turnip/pack/extensions/extension.py b/turnip/pack/extensions/extension.py
0new file mode 10064427new file mode 100644
index 0000000..58843c8
--- /dev/null
+++ b/turnip/pack/extensions/extension.py
@@ -0,0 +1,61 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import (
5 absolute_import,
6 print_function,
7 unicode_literals,
8 )
9
10__metaclass__ = type
11
12import os
13
14from turnip.api.store import open_repo
15
16
17class PackExtension:
18 # Minimum protocol version where this extension is available.
19 min_version = 2
20
21 # Which command this extension represents (for example, 'ls-refs',
22 # 'fetch', ...)
23 command = None
24
25 # If False, the connection with the client will be closed immediately
26 # after execute() is called.
27 is_async = False
28
29 def __init__(self, backend_protocol=None, path=None, args=None):
30 """Initialize the extension.
31
32 :param backend_protocol: The PackBackendProtocol instance requesting
33 this extension to do its job.
34 """
35 self.backend_protocol = backend_protocol
36 self.path = path
37 self.args = args
38 self.data_buffer = None
39 self.done = False
40
41 def open_repo(self):
42 repo_store = os.path.dirname(self.path)
43 repo_name = os.path.basename(self.path)
44 return open_repo(repo_store, repo_name)
45
46 @property
47 def log(self):
48 return self.backend_protocol.log
49
50 def sendRawData(self, data):
51 self.backend_protocol.sendRawData(data)
52
53 def sendPacket(self, data):
54 self.backend_protocol.sendPacket(data)
55
56 @classmethod
57 def getCapabilityAdvertisement(self):
58 raise NotImplementedError(
59 "This must be implemented by subclasses, returning its binary "
60 "representation")
61
diff --git a/turnip/pack/extensions/fetch.py b/turnip/pack/extensions/fetch.py
0new file mode 10064462new file mode 100644
index 0000000..8a64ae8
--- /dev/null
+++ b/turnip/pack/extensions/fetch.py
@@ -0,0 +1,22 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import (
5 absolute_import,
6 print_function,
7 unicode_literals,
8 )
9
10from turnip.pack.extensions.extension import PackExtension
11from turnip.pack.helpers import encode_packet, FLUSH_PKT
12
13
14class Fetch(PackExtension):
15 min_version = 2
16 command = b'fetch'
17 is_write_operation = False
18 is_async = False
19
20 @classmethod
21 def getCapabilityAdvertisement(cls):
22 return encode_packet(b"fetch=shallow\n")
diff --git a/turnip/pack/extensions/lsrefs.py b/turnip/pack/extensions/lsrefs.py
0new file mode 10064423new file mode 100644
index 0000000..9a23f03
--- /dev/null
+++ b/turnip/pack/extensions/lsrefs.py
@@ -0,0 +1,22 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import (
5 absolute_import,
6 print_function,
7 unicode_literals,
8 )
9
10from turnip.pack.extensions.extension import PackExtension
11from turnip.pack.helpers import encode_packet, FLUSH_PKT
12
13
14class LSRefs(PackExtension):
15 min_version = 2
16 command = b'ls-refs'
17 is_write_operation = False
18 is_async = False
19
20 @classmethod
21 def getCapabilityAdvertisement(cls):
22 return encode_packet(b"ls-refs\n")
diff --git a/turnip/pack/extensions/tests/__init__.py b/turnip/pack/extensions/tests/__init__.py
0new file mode 10064423new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/turnip/pack/extensions/tests/__init__.py
diff --git a/turnip/pack/extensions/tests/test_extensions.py b/turnip/pack/extensions/tests/test_extensions.py
1new file mode 10064424new file mode 100644
index 0000000..299e786
--- /dev/null
+++ b/turnip/pack/extensions/tests/test_extensions.py
@@ -0,0 +1,42 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from __future__ import (
5 absolute_import,
6 print_function,
7 unicode_literals,
8 )
9
10from fixtures import EnvironmentVariable
11from testtools import TestCase
12
13from turnip.pack.extensions import (
14 get_capabilities_description,
15 get_available_extensions,
16 )
17from turnip.pack.extensions.agent import Agent
18from turnip.pack.extensions.lsrefs import LSRefs
19from turnip.version_info import version_info
20
21
22class TestCapabilities(TestCase):
23 def test_get_extensions_from_config(self):
24 self.useFixture(EnvironmentVariable(
25 "PACK_EXTENSIONS", ".agent.Agent .lsrefs.LSRefs"))
26 available = get_available_extensions('2')
27 self.assertEqual(2, len(available))
28 self.assertEqual(available[0], Agent)
29 self.assertEqual(available[1], LSRefs)
30
31 def test_get_capabilities_description_v1_is_empty(self):
32 self.assertEqual(b"", get_capabilities_description(1))
33
34 def test_get_capabilities_description_v2_with_agent_only(self):
35 self.useFixture(EnvironmentVariable(
36 "PACK_EXTENSIONS", ".agent.Agent .lsrefs.LSRefs"))
37 self.assertEqual(
38 b"000eversion 2\n"
39 b"003aagent=turnip/%s\n"
40 b"000cls-refs\n"
41 b"0000" % version_info['revision_id'],
42 get_capabilities_description('2'))
diff --git a/turnip/pack/git.py b/turnip/pack/git.py
index 763d5d6..43cbb32 100644
--- a/turnip/pack/git.py
+++ b/turnip/pack/git.py
@@ -29,6 +29,7 @@ from turnip.api import store
29from turnip.api.store import AlreadyExistsError29from turnip.api.store import AlreadyExistsError
30from turnip.config import config30from turnip.config import config
31from turnip.helpers import compose_path31from turnip.helpers import compose_path
32from turnip.pack.extensions import get_available_extensions
32from turnip.pack.helpers import (33from turnip.pack.helpers import (
33 decode_packet,34 decode_packet,
34 decode_request,35 decode_request,
@@ -37,8 +38,8 @@ from turnip.pack.helpers import (
37 ensure_config,38 ensure_config,
38 ensure_hooks,39 ensure_hooks,
39 INCOMPLETE_PKT,40 INCOMPLETE_PKT,
40 translate_xmlrpc_fault,41 translate_xmlrpc_fault, FLUSH_PKT, DELIM_PKT,
41 )42)
4243
4344
44ERROR_PREFIX = b'ERR '45ERROR_PREFIX = b'ERR '
@@ -77,13 +78,17 @@ class UnstoppableProducerWrapper(object):
77 pass78 pass
7879
7980
80class PackProtocol(protocol.Protocol):81class PackProtocol(protocol.Protocol, object):
8182
82 paused = False83 paused = False
83 raw = False84 raw = False
85 extension = None
8486
85 __buffer = b''87 __buffer = b''
8688
89 def getProtocolVersion(self):
90 raise NotImplementedError()
91
87 def packetReceived(self, payload):92 def packetReceived(self, payload):
88 raise NotImplementedError()93 raise NotImplementedError()
8994
@@ -187,11 +192,11 @@ class PackServerProtocol(PackProxyProtocol):
187 command, pathname, params = decode_request(data)192 command, pathname, params = decode_request(data)
188 except ValueError as e:193 except ValueError as e:
189 self.die(str(e).encode('utf-8'))194 self.die(str(e).encode('utf-8'))
190 return195 return None
191 self.pauseProducing()196 self.pauseProducing()
192 self.got_request = True197 self.got_request = True
193 self.requestReceived(command, pathname, params)198 self.requestReceived(command, pathname, params)
194 return199 return None
195 if data is None:200 if data is None:
196 self.raw = True201 self.raw = True
197 self.peer.sendPacket(data)202 self.peer.sendPacket(data)
@@ -239,15 +244,20 @@ class GitProcessProtocol(protocol.ProcessProtocol):
239244
240 _err_buffer = b''245 _err_buffer = b''
241246
242 def __init__(self, peer):247 def __init__(self, peer, cmd_input=None):
243 self.peer = peer248 self.peer = peer
249 self.cmd_input = cmd_input
244 self.out_started = False250 self.out_started = False
245251
246 def connectionMade(self):252 def connectionMade(self):
247 self.peer.setPeer(self)253 self.peer.setPeer(self)
248 self.peer.transport.registerProducer(self, True)254 self.peer.transport.registerProducer(self, True)
249 self.transport.registerProducer(255 if not self.cmd_input:
250 UnstoppableProducerWrapper(self.peer.transport), True)256 self.transport.registerProducer(
257 UnstoppableProducerWrapper(self.peer.transport), True)
258 else:
259 self.transport.write(self.cmd_input)
260 self.loseWriteConnection()
251 self.peer.resumeProducing()261 self.peer.resumeProducing()
252262
253 def outReceived(self, data):263 def outReceived(self, data):
@@ -415,7 +425,7 @@ class PackProxyServerProtocol(PackServerProtocol):
415 def resumeProducing(self):425 def resumeProducing(self):
416 # Send our translated request and then open the gate to the client.426 # Send our translated request and then open the gate to the client.
417 self.sendNextCommand()427 self.sendNextCommand()
418 PackServerProtocol.resumeProducing(self)428 super(PackProxyServerProtocol, self).resumeProducing()
419429
420 def readConnectionLost(self):430 def readConnectionLost(self):
421 # Forward the closed stdin down the stack.431 # Forward the closed stdin down the stack.
@@ -431,12 +441,14 @@ class PackBackendProtocol(PackServerProtocol):
431441
432 hookrpc_key = None442 hookrpc_key = None
433 expect_set_symbolic_ref = False443 expect_set_symbolic_ref = False
444 extension = None
434445
435 @defer.inlineCallbacks446 @defer.inlineCallbacks
436 def requestReceived(self, command, raw_pathname, params):447 def requestReceived(self, command, raw_pathname, params):
437 self.extractRequestMeta(command, raw_pathname, params)448 self.extractRequestMeta(command, raw_pathname, params)
438 self.command = command449 self.command = command
439 self.raw_pathname = raw_pathname450 self.raw_pathname = raw_pathname
451 self.params = params
440 self.path = compose_path(self.factory.root, self.raw_pathname)452 self.path = compose_path(self.factory.root, self.raw_pathname)
441 auth_params = self.createAuthParams(params)453 auth_params = self.createAuthParams(params)
442454
@@ -457,14 +469,42 @@ class PackBackendProtocol(PackServerProtocol):
457 return469 return
458470
459 write_operation = False471 write_operation = False
460 if command == b'git-upload-pack':472 cmd_input = None
461 subcmd = b'upload-pack'473 send_path_as_option = False
462 elif command == b'git-receive-pack':474 cmd_env = {}
463 subcmd = b'receive-pack'475 if params.get('version') != '2':
464 write_operation = True476 if command == b'git-upload-pack':
477 subcmd = b'upload-pack'
478 elif command == b'git-receive-pack':
479 subcmd = b'receive-pack'
480 write_operation = True
481 else:
482 self.die(b"Invalid command: %s" % command)
465 else:483 else:
466 self.die(b'Unsupported command in request')484 # v2 protocol
467 return485 if command == b'git-upload-pack':
486 self.expectNextCommand()
487 self.transport.loseConnection()
488 return
489 else:
490 subcmd = b'upload-pack'
491 cmd_env["GIT_PROTOCOL"] = 'version=2'
492 send_path_as_option = True
493 params.pop(b'turnip-advertise-refs', None)
494 cmd_input = encode_packet(b"command=%s\n" % command)
495 for capability in params["capabilities"].split(b"\n"):
496 cmd_input += encode_packet(b"%s\n" % capability)
497 cmd_input += DELIM_PKT
498 ignore_keys = ('capabilities', 'version', 'extra_flags')
499 for k, v in params.items():
500 k = six.ensure_binary(k)
501 if k.startswith(b"turnip-") or k in ignore_keys:
502 continue
503 for param_value in v.split(b'\n'):
504 value = (b"" if not param_value
505 else b" %s" % param_value)
506 cmd_input += encode_packet(b"%s%s\n" % (k, value))
507 cmd_input += FLUSH_PKT
468508
469 args = []509 args = []
470 if params.pop(b'turnip-stateless-rpc', None):510 if params.pop(b'turnip-stateless-rpc', None):
@@ -475,10 +515,14 @@ class PackBackendProtocol(PackServerProtocol):
475 self.spawnGit(subcmd,515 self.spawnGit(subcmd,
476 args,516 args,
477 write_operation=write_operation,517 write_operation=write_operation,
478 auth_params=auth_params)518 auth_params=auth_params,
519 send_path_as_option=send_path_as_option,
520 cmd_env=cmd_env,
521 cmd_input=cmd_input)
479522
480 def spawnGit(self, subcmd, extra_args, write_operation=False,523 def spawnGit(self, subcmd, extra_args, write_operation=False,
481 send_path_as_option=False, auth_params=None):524 send_path_as_option=False, auth_params=None,
525 cmd_env=None, cmd_input=None):
482 cmd = b'git'526 cmd = b'git'
483 args = [b'git']527 args = [b'git']
484 if send_path_as_option:528 if send_path_as_option:
@@ -487,6 +531,7 @@ class PackBackendProtocol(PackServerProtocol):
487 args.extend(extra_args)531 args.extend(extra_args)
488532
489 env = {}533 env = {}
534 env.update((cmd_env or {}))
490 if write_operation and self.factory.hookrpc_handler:535 if write_operation and self.factory.hookrpc_handler:
491 # This is a write operation, so prepare config, hooks, the hook536 # This is a write operation, so prepare config, hooks, the hook
492 # RPC server, and the environment variables that link them up.537 # RPC server, and the environment variables that link them up.
@@ -499,7 +544,7 @@ class PackBackendProtocol(PackServerProtocol):
499 env[b'TURNIP_HOOK_RPC_KEY'] = self.hookrpc_key544 env[b'TURNIP_HOOK_RPC_KEY'] = self.hookrpc_key
500545
501 self.log.info('Spawning {args}', args=args)546 self.log.info('Spawning {args}', args=args)
502 self.peer = GitProcessProtocol(self)547 self.peer = GitProcessProtocol(self, cmd_input)
503 self.spawnProcess(cmd, args, env=env)548 self.spawnProcess(cmd, args, env=env)
504549
505 def spawnProcess(self, cmd, args, env=None):550 def spawnProcess(self, cmd, args, env=None):
@@ -628,7 +673,7 @@ class PackVirtServerProtocol(PackProxyServerProtocol):
628 @defer.inlineCallbacks673 @defer.inlineCallbacks
629 def requestReceived(self, command, pathname, params):674 def requestReceived(self, command, pathname, params):
630 self.extractRequestMeta(command, pathname, params)675 self.extractRequestMeta(command, pathname, params)
631 permission = 'read' if command == b'git-upload-pack' else 'write'676 permission = 'read' if command != b'git-receive-pack' else 'write'
632 proxy = xmlrpc.Proxy(self.factory.virtinfo_endpoint, allowNone=True)677 proxy = xmlrpc.Proxy(self.factory.virtinfo_endpoint, allowNone=True)
633 try:678 try:
634 auth_params = self.createAuthParams(params)679 auth_params = self.createAuthParams(params)
diff --git a/turnip/pack/helpers.py b/turnip/pack/helpers.py
index 9cf411d..52649a9 100644
--- a/turnip/pack/helpers.py
+++ b/turnip/pack/helpers.py
@@ -7,6 +7,8 @@ from __future__ import (
7 unicode_literals,7 unicode_literals,
8 )8 )
99
10from collections import OrderedDict
11
10import enum12import enum
11import hashlib13import hashlib
12import os.path14import os.path
@@ -25,6 +27,8 @@ import yaml
25import turnip.pack.hooks27import turnip.pack.hooks
2628
2729
30FLUSH_PKT = b'0000'
31DELIM_PKT = b'0001'
28PKT_LEN_SIZE = 432PKT_LEN_SIZE = 4
29PKT_PAYLOAD_MAX = 6552033PKT_PAYLOAD_MAX = 65520
30INCOMPLETE_PKT = object()34INCOMPLETE_PKT = object()
@@ -33,7 +37,7 @@ INCOMPLETE_PKT = object()
33def encode_packet(payload):37def encode_packet(payload):
34 if payload is None:38 if payload is None:
35 # flush-pkt.39 # flush-pkt.
36 return b'0000'40 return FLUSH_PKT
37 else:41 else:
38 # data-pkt42 # data-pkt
39 if len(payload) > PKT_PAYLOAD_MAX:43 if len(payload) > PKT_PAYLOAD_MAX:
@@ -47,21 +51,37 @@ def decode_packet(input):
47 """Consume a packet, returning the payload and any unconsumed tail."""51 """Consume a packet, returning the payload and any unconsumed tail."""
48 if len(input) < PKT_LEN_SIZE:52 if len(input) < PKT_LEN_SIZE:
49 return (INCOMPLETE_PKT, input)53 return (INCOMPLETE_PKT, input)
50 if input.startswith(b'0000'):54 if input.startswith(FLUSH_PKT):
51 # flush-pkt55 # flush-pkt
52 return (None, input[PKT_LEN_SIZE:])56 return (None, input[PKT_LEN_SIZE:])
53 else:57 # data-pkt
54 # data-pkt58 try:
55 try:59 pkt_len = int(input[:PKT_LEN_SIZE], 16)
56 pkt_len = int(input[:PKT_LEN_SIZE], 16)60 except ValueError:
57 except ValueError:61 pkt_len = 0
58 pkt_len = 062 if not (PKT_LEN_SIZE <= pkt_len <= (PKT_LEN_SIZE + PKT_PAYLOAD_MAX)):
59 if not (PKT_LEN_SIZE <= pkt_len <= (PKT_LEN_SIZE + PKT_PAYLOAD_MAX)):63 raise ValueError("Invalid pkt-len")
60 raise ValueError("Invalid pkt-len")64 if len(input) < pkt_len:
61 if len(input) < pkt_len:65 # Some of the packet is yet to be received.
62 # Some of the packet is yet to be received.66 return (INCOMPLETE_PKT, input)
63 return (INCOMPLETE_PKT, input)67
64 return (input[PKT_LEN_SIZE:pkt_len], input[pkt_len:])68 # v2 protocol "hides" extra parameters after the end of the packet.
69 if len(input) > pkt_len and b'version=2\x00' in input:
70 if FLUSH_PKT not in input:
71 return INCOMPLETE_PKT, input
72 end = input.index(FLUSH_PKT)
73 return input[PKT_LEN_SIZE:end], input[end + len(FLUSH_PKT):]
74
75 return (input[PKT_LEN_SIZE:pkt_len], input[pkt_len:])
76
77
78def decode_packet_list(data):
79 remaining = data
80 retval = []
81 while remaining:
82 pkt, remaining = decode_packet(remaining)
83 retval.append(pkt)
84 return retval
6585
6686
67def decode_request(data):87def decode_request(data):
@@ -77,10 +97,11 @@ def decode_request(data):
77 bits = rest.split(b'\0')97 bits = rest.split(b'\0')
78 # Following the command is a pathname, then any number of named98 # Following the command is a pathname, then any number of named
79 # parameters. Each of these is NUL-terminated.99 # parameters. Each of these is NUL-terminated.
80 if len(bits) < 2 or bits[-1] != b'':100 # After that, v1 should end (v2 might have extra commands).
101 if len(bits) < 2 or (b'version=2' not in bits and bits[-1] != b''):
81 raise ValueError('Invalid git-proto-request')102 raise ValueError('Invalid git-proto-request')
82 pathname = bits[0]103 pathname = bits[0]
83 params = {}104 params = OrderedDict()
84 for index, param in enumerate(bits[1:-1]):105 for index, param in enumerate(bits[1:-1]):
85 if param == b'':106 if param == b'':
86 if (index < len(bits) - 1):107 if (index < len(bits) - 1):
@@ -94,7 +115,42 @@ def decode_request(data):
94 if name in params:115 if name in params:
95 raise ValueError('Parameters must not be repeated')116 raise ValueError('Parameters must not be repeated')
96 params[name] = value117 params[name] = value
97 return (command, pathname, params)118
119 if not bits[-1]:
120 return command, pathname, params
121 else:
122 # Parse v2 extra commands.
123 cmd, remaining = decode_packet(bits[-1])
124 cmd = cmd.split(b'=', 1)[-1].strip()
125 capabilities, args = remaining.split(DELIM_PKT)
126 cmd_params = OrderedDict({
127 b"capabilities": decode_packet_list(capabilities)})
128 cmd_params.update(params)
129 for arg in decode_packet_list(args):
130 if arg is None:
131 continue
132 arg = arg.strip('\n')
133 if b' ' in arg:
134 k, v = arg.split(b' ', 1)
135 if k not in cmd_params:
136 cmd_params[k] = []
137 cmd_params[k].append(v)
138 else:
139 cmd_params[arg] = b""
140 return (cmd, pathname, cmd_params)
141
142
143def get_encoded_value(value):
144 """Encode a value for serialization on encode_request"""
145 if value is None:
146 return b''
147 if isinstance(value, list):
148 if any(b'\n' in i for i in value):
149 raise ValueError('Metacharacter in list argument')
150 return b"\n".join(get_encoded_value(i) for i in value)
151 if isinstance(value, bool):
152 return b'1' if value else b'0'
153 return six.ensure_binary(value)
98154
99155
100def encode_request(command, pathname, params):156def encode_request(command, pathname, params):
@@ -105,9 +161,9 @@ def encode_request(command, pathname, params):
105 if b' ' in command or b'\0' in pathname:161 if b' ' in command or b'\0' in pathname:
106 raise ValueError('Metacharacter in arguments')162 raise ValueError('Metacharacter in arguments')
107 bits = [pathname]163 bits = [pathname]
108 for name in sorted(params):164 for name in params:
109 value = params[name]165 value = params[name]
110 value = six.ensure_binary(value) if value is not None else b''166 value = get_encoded_value(value)
111 name = six.ensure_binary(name)167 name = six.ensure_binary(name)
112 if b'=' in name or b'\0' in name + value:168 if b'=' in name or b'\0' in name + value:
113 raise ValueError('Metacharacter in arguments')169 raise ValueError('Metacharacter in arguments')
diff --git a/turnip/pack/http.py b/turnip/pack/http.py
index c0becf0..7ae8765 100644
--- a/turnip/pack/http.py
+++ b/turnip/pack/http.py
@@ -12,6 +12,7 @@ import json
12import os.path12import os.path
13import tempfile13import tempfile
14import textwrap14import textwrap
15
15try:16try:
16 from urllib.parse import urlencode17 from urllib.parse import urlencode
17except ImportError:18except ImportError:
@@ -29,6 +30,7 @@ from paste.auth.cookie import (
29 decode as decode_cookie,30 decode as decode_cookie,
30 encode as encode_cookie,31 encode as encode_cookie,
31 )32 )
33import six
32from twisted.internet import (34from twisted.internet import (
33 defer,35 defer,
34 error,36 error,
@@ -49,6 +51,7 @@ from twisted.web import (
49 )51 )
5052
51from turnip.helpers import compose_path53from turnip.helpers import compose_path
54from turnip.pack.extensions import get_capabilities_description
52from turnip.pack.git import (55from turnip.pack.git import (
53 ERROR_PREFIX,56 ERROR_PREFIX,
54 PackProtocol,57 PackProtocol,
@@ -80,6 +83,16 @@ def fail_request(request, message, code=http.INTERNAL_SERVER_ERROR):
80 return b''83 return b''
8184
8285
86def get_protocol_version_from_request(request):
87 version_header = request.requestHeaders.getRawHeaders(
88 'git-protocol', ['version=1'])[0]
89 try:
90 return version_header.split('version=', 1)[1]
91 except Exception:
92 pass
93 return 1
94
95
83class HTTPPackClientProtocol(PackProtocol):96class HTTPPackClientProtocol(PackProtocol):
84 """Abstract bridge between a Git pack connection and a smart HTTP request.97 """Abstract bridge between a Git pack connection and a smart HTTP request.
8598
@@ -99,7 +112,11 @@ class HTTPPackClientProtocol(PackProtocol):
99112
100 def startGoodResponse(self):113 def startGoodResponse(self):
101 """Prepare the HTTP response for forwarding from the backend."""114 """Prepare the HTTP response for forwarding from the backend."""
102 raise NotImplementedError()115 self.factory.http_request.write(
116 get_capabilities_description(self.getProtocolVersion()))
117
118 def getProtocolVersion(self):
119 return get_protocol_version_from_request(self.factory.http_request)
103120
104 def backendConnected(self):121 def backendConnected(self):
105 """Called when the backend is connected and has sent a good packet."""122 """Called when the backend is connected and has sent a good packet."""
@@ -209,12 +226,14 @@ class HTTPPackClientRefsProtocol(HTTPPackClientProtocol):
209 self.factory.http_request.setHeader(226 self.factory.http_request.setHeader(
210 b'Content-Type',227 b'Content-Type',
211 b'application/x-%s-advertisement' % self.factory.command)228 b'application/x-%s-advertisement' % self.factory.command)
229 super(HTTPPackClientRefsProtocol, self).startGoodResponse()
212230
213 def backendConnected(self):231 def backendConnected(self):
214 HTTPPackClientProtocol.backendConnected(self)232 HTTPPackClientProtocol.backendConnected(self)
215 self.rawDataReceived(233 if self.getProtocolVersion() != b'2':
216 encode_packet(b'# service=%s\n' % self.factory.command))234 self.rawDataReceived(
217 self.rawDataReceived(encode_packet(None))235 encode_packet(b'# service=%s\n' % self.factory.command))
236 self.rawDataReceived(encode_packet(None))
218237
219238
220class HTTPPackClientCommandProtocol(HTTPPackClientProtocol):239class HTTPPackClientCommandProtocol(HTTPPackClientProtocol):
@@ -225,6 +244,7 @@ class HTTPPackClientCommandProtocol(HTTPPackClientProtocol):
225 self.factory.http_request.setHeader(244 self.factory.http_request.setHeader(
226 b'Content-Type',245 b'Content-Type',
227 b'application/x-%s-result' % self.factory.command)246 b'application/x-%s-result' % self.factory.command)
247 # super(HTTPPackClientCommandProtocol, self).startGoodResponse()
228248
229249
230class HTTPPackClientFactory(protocol.ClientFactory):250class HTTPPackClientFactory(protocol.ClientFactory):
@@ -279,9 +299,24 @@ class BaseSmartHTTPResource(resource.Resource):
279 The turnip-authenticated-* parameters are set to the values returned299 The turnip-authenticated-* parameters are set to the values returned
280 by the virt service, if any.300 by the virt service, if any.
281 """301 """
302 # where = content.tell()
303 # content.seek(0)
304 # data = content.read()
305 # content.seek(where)
306 # print(b"req#%s will connect to backend" % (id(request), ))
307 # print("--- body ---\n", data, "\n--- endbody ---")
308 # # import ipdb; ipdb.set_trace()
309 # orig = request.write
310 # def x(data):
311 # print(b"\n\nReq#%s %s %s -> user\n---%s----" % (
312 # id(request), request.method, request.path, data))
313 # orig(data)
314 # request.write = x
282 params = {315 params = {
283 b'turnip-can-authenticate': b'yes',316 b'turnip-can-authenticate': b'yes',
284 b'turnip-request-id': str(uuid.uuid4()),317 b'turnip-request-id': str(uuid.uuid4()),
318 b'version': six.ensure_binary(
319 get_protocol_version_from_request(request))
285 }320 }
286 authenticated_params = yield self.authenticateUser(request)321 authenticated_params = yield self.authenticateUser(request)
287 for key, value in authenticated_params.items():322 for key, value in authenticated_params.items():
diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
index d6aaa7f..2809593 100644
--- a/turnip/pack/tests/test_functional.py
+++ b/turnip/pack/tests/test_functional.py
@@ -35,6 +35,7 @@ from fixtures import (
35 TempDir,35 TempDir,
36 )36 )
37from pygit2 import GIT_OID_HEX_ZERO37from pygit2 import GIT_OID_HEX_ZERO
38from testscenarios.testcase import WithScenarios
38from testtools import TestCase39from testtools import TestCase
39from testtools.content import text_content40from testtools.content import text_content
40from testtools.deferredruntest import AsynchronousDeferredRunTest41from testtools.deferredruntest import AsynchronousDeferredRunTest
@@ -77,10 +78,14 @@ from turnip.pack.tests.fake_servers import (
77from turnip.version_info import version_info78from turnip.version_info import version_info
7879
7980
80class FunctionalTestMixin(object):81class FunctionalTestMixin(WithScenarios):
8182
82 run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=30)83 run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=30)
8384
85 scenarios = [
86 ('v1 protocol', {"protocol_version": "1"}),
87 ('v2 protocol', {"protocol_version": "2"})]
88
84 def startVirtInfo(self):89 def startVirtInfo(self):
85 # Set up a fake virt information XML-RPC server which just90 # Set up a fake virt information XML-RPC server which just
86 # maps paths to their SHA-256 hash.91 # maps paths to their SHA-256 hash.
@@ -120,8 +125,13 @@ class FunctionalTestMixin(object):
120125
121 @defer.inlineCallbacks126 @defer.inlineCallbacks
122 def assertCommandSuccess(self, command, path='.'):127 def assertCommandSuccess(self, command, path='.'):
128 if command[0] == b'git' and self.protocol_version == '2':
129 args = list(command[1:])
130 command = [b'git', b'-c', b'protocol.version=2'] + args
131 env = {"GIT_TRACE_PACKET": '1'}
132 env.update(os.environ)
123 out, err, code = yield utils.getProcessOutputAndValue(133 out, err, code = yield utils.getProcessOutputAndValue(
124 command[0], command[1:], env=os.environ, path=path)134 command[0], command[1:], env=env, path=path)
125 if code != 0:135 if code != 0:
126 self.addDetail('stdout', text_content(out))136 self.addDetail('stdout', text_content(out))
127 self.addDetail('stderr', text_content(err))137 self.addDetail('stderr', text_content(err))
@@ -130,6 +140,9 @@ class FunctionalTestMixin(object):
130140
131 @defer.inlineCallbacks141 @defer.inlineCallbacks
132 def assertCommandFailure(self, command, path='.'):142 def assertCommandFailure(self, command, path='.'):
143 if self.protocol_version == '2':
144 args = list(command[1:])
145 command = [b'git', b'-c', b'protocol.version=2'] + args
133 out, err, code = yield utils.getProcessOutputAndValue(146 out, err, code = yield utils.getProcessOutputAndValue(
134 command[0], command[1:], env=os.environ, path=path)147 command[0], command[1:], env=os.environ, path=path)
135 if code == 0:148 if code == 0:
diff --git a/turnip/pack/tests/test_helpers.py b/turnip/pack/tests/test_helpers.py
index a7a3870..8d09c98 100644
--- a/turnip/pack/tests/test_helpers.py
+++ b/turnip/pack/tests/test_helpers.py
@@ -23,7 +23,7 @@ from testtools import TestCase
2323
24from turnip.pack import helpers24from turnip.pack import helpers
25import turnip.pack.hooks25import turnip.pack.hooks
2626from turnip.pack.helpers import FLUSH_PKT
2727
28TEST_DATA = b'0123456789abcdef'28TEST_DATA = b'0123456789abcdef'
29TEST_PKT = b'00140123456789abcdef'29TEST_PKT = b'00140123456789abcdef'
@@ -91,8 +91,8 @@ class TestDecodeRequest(TestCase):
91 # We parse extra params behind 2 NUL bytes91 # We parse extra params behind 2 NUL bytes
92 req = b'git-upload-pack /test_repo\0host=git.launchpad.test\0\0ver=2\0'92 req = b'git-upload-pack /test_repo\0host=git.launchpad.test\0\0ver=2\0'
93 self.assertEqual(93 self.assertEqual(
94 (b'git-upload-pack', b'/test_repo',94 [(b'git-upload-pack', b'/test_repo',
95 {b'host': b'git.launchpad.test', b'ver': b'2'}),95 {b'host': b'git.launchpad.test', b'ver': b'2'})],
96 helpers.decode_request(req))96 helpers.decode_request(req))
9797
98 def test_parse_multiple_extra_params_after_2_null_bytes(self):98 def test_parse_multiple_extra_params_after_2_null_bytes(self):
@@ -102,9 +102,9 @@ class TestDecodeRequest(TestCase):
102 b'ver=2\0\0param2=value2\0\0param3=value3\0'102 b'ver=2\0\0param2=value2\0\0param3=value3\0'
103 )103 )
104 self.assertEqual(104 self.assertEqual(
105 (b'git-upload-pack', b'/test_repo',105 [(b'git-upload-pack', b'/test_repo',
106 {b'host': b'git.launchpad.test', b'ver': b'2',106 {b'host': b'git.launchpad.test', b'ver': b'2',
107 b'param2': b'value2', b'param3': b'value3'}),107 b'param2': b'value2', b'param3': b'value3'})],
108 helpers.decode_request(req))108 helpers.decode_request(req))
109109
110 def test_rejects_extra_param_without_end_null_bytes(self):110 def test_rejects_extra_param_without_end_null_bytes(self):
@@ -124,25 +124,25 @@ class TestDecodeRequest(TestCase):
124 def test_allow_2_end_nul_bytes(self):124 def test_allow_2_end_nul_bytes(self):
125 req = b'git-upload-pack /test_repo\0host=git.launchpad.test\0\0'125 req = b'git-upload-pack /test_repo\0host=git.launchpad.test\0\0'
126 self.assertEqual(126 self.assertEqual(
127 (b'git-upload-pack', b'/test_repo',127 [(b'git-upload-pack', b'/test_repo',
128 {b'host': b'git.launchpad.test'}),128 {b'host': b'git.launchpad.test'})],
129 helpers.decode_request(req))129 helpers.decode_request(req))
130130
131 def test_without_parameters(self):131 def test_without_parameters(self):
132 self.assertEqual(132 self.assertEqual(
133 (b'git-do-stuff', b'/some/path', {}),133 [(b'git-do-stuff', b'/some/path', {})],
134 helpers.decode_request(b'git-do-stuff /some/path\0'))134 helpers.decode_request(b'git-do-stuff /some/path\0'))
135135
136 def test_with_host_parameter(self):136 def test_with_host_parameter(self):
137 self.assertEqual(137 self.assertEqual(
138 (b'git-do-stuff', b'/some/path', {b'host': b'example.com'}),138 [(b'git-do-stuff', b'/some/path', {b'host': b'example.com'})],
139 helpers.decode_request(139 helpers.decode_request(
140 b'git-do-stuff /some/path\0host=example.com\0'))140 b'git-do-stuff /some/path\0host=example.com\0'))
141141
142 def test_with_host_and_user_parameters(self):142 def test_with_host_and_user_parameters(self):
143 self.assertEqual(143 self.assertEqual(
144 (b'git-do-stuff', b'/some/path',144 [(b'git-do-stuff', b'/some/path',
145 {b'host': b'example.com', b'user': b'foo=bar'}),145 {b'host': b'example.com', b'user': b'foo=bar'})],
146 helpers.decode_request(146 helpers.decode_request(
147 b'git-do-stuff /some/path\0host=example.com\0user=foo=bar\0'))147 b'git-do-stuff /some/path\0host=example.com\0user=foo=bar\0'))
148148
@@ -173,6 +173,33 @@ class TestDecodeRequest(TestCase):
173 b'git-do-stuff /foo\0host=foo\0host=bar\0',173 b'git-do-stuff /foo\0host=foo\0host=bar\0',
174 b'Parameters must not be repeated')174 b'Parameters must not be repeated')
175175
176 def test_v2_extra_commands(self):
177 data = (
178 b"git-upload-pack b306d" +
179 b"\x00turnip-x=yes\x00turnip-request-id=123\x00" +
180 b"version=2\x000014command=ls-refs\n0014agent=git/2.25.1" +
181 b'00010009peel\n000csymrefs\n0014ref-prefix HEAD\n' +
182 b'001bref-prefix refs/heads/\n'
183 b'001aref-prefix refs/tags/\n0000' +
184 FLUSH_PKT)
185
186 decoded = helpers.decode_request(data)
187 self.assertEqual(2, len(decoded))
188 first, second = decoded
189 self.assertEqual((b'ls-refs', b'b306d', {
190 b'turnip-x': b'yes',
191 b'turnip-request-id': b'123',
192 b'version': b'2',
193 b'capabilities': [b'agent=git/2.25.1'],
194 b'symrefs': True,
195 b'peel': True,
196 b'ref-prefix': [b'HEAD', b'refs/heads/', b'refs/tags/']
197 }), first)
198 self.assertEqual((b'git-upload-pack', b'b306d', {
199 b'turnip-x': b'yes',
200 b'turnip-request-id': b'123',
201 b'version': b'2'}), second)
202
176203
177class TestEncodeRequest(TestCase):204class TestEncodeRequest(TestCase):
178 """Test git-proto-request encoding."""205 """Test git-proto-request encoding."""
diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py
index 26e2754..0ce06f5 100644
--- a/turnip/pack/tests/test_http.py
+++ b/turnip/pack/tests/test_http.py
@@ -9,6 +9,7 @@ from __future__ import (
99
10from io import BytesIO10from io import BytesIO
1111
12from fixtures import EnvironmentVariable
12from testtools import TestCase13from testtools import TestCase
13from testtools.deferredruntest import AsynchronousDeferredRunTest14from testtools.deferredruntest import AsynchronousDeferredRunTest
14from twisted.internet import (15from twisted.internet import (
@@ -24,7 +25,10 @@ from turnip.pack import (
24 helpers,25 helpers,
25 http,26 http,
26 )27 )
28from turnip.pack.helpers import encode_packet
27from turnip.pack.tests.fake_servers import FakeVirtInfoService29from turnip.pack.tests.fake_servers import FakeVirtInfoService
30from turnip.tests.compat import mock
31from turnip.version_info import version_info
2832
2933
30class LessDummyRequest(requesthelper.DummyRequest):34class LessDummyRequest(requesthelper.DummyRequest):
@@ -74,12 +78,14 @@ class FakeRoot(object):
7478
75 def __init__(self):79 def __init__(self):
76 self.backend_transport = None80 self.backend_transport = None
81 self.client_factory = None
77 self.backend_connected = defer.Deferred()82 self.backend_connected = defer.Deferred()
7883
79 def authenticateWithPassword(self, user, password):84 def authenticateWithPassword(self, user, password):
80 return {}85 return {}
8186
82 def connectToBackend(self, client_factory):87 def connectToBackend(self, client_factory):
88 self.client_factory = client_factory
83 self.backend_transport = testing.StringTransportWithDisconnection()89 self.backend_transport = testing.StringTransportWithDisconnection()
84 p = client_factory.buildProtocol(None)90 p = client_factory.buildProtocol(None)
85 self.backend_transport.protocol = p91 self.backend_transport.protocol = p
@@ -186,6 +192,33 @@ class TestSmartHTTPRefsResource(ErrorTestMixin, TestCase):
186 'And I am raw, since we got a good packet to start with.',192 'And I am raw, since we got a good packet to start with.',
187 self.request.value)193 self.request.value)
188194
195 @defer.inlineCallbacks
196 def test_good_v2_included_version_and_capabilities(self):
197 self.useFixture(EnvironmentVariable("PACK_EXTENSIONS", ".agent.Agent"))
198 self.request.requestHeaders.addRawHeader("Git-Protocol", "version=2")
199 yield self.performRequest(
200 helpers.encode_packet(b'I am git protocol data.') +
201 b'And I am raw, since we got a good packet to start with.')
202 self.assertEqual(200, self.request.responseCode)
203 self.assertEqual(self.root.client_factory.params, {
204 'version': '2',
205 'turnip-advertise-refs': 'yes',
206 'turnip-can-authenticate': 'yes',
207 'turnip-request-id': mock.ANY,
208 'turnip-stateless-rpc': 'yes'})
209
210 capabilities = (
211 '000eversion 2\n' +
212 encode_packet(
213 'agent=turnip/%s\n' % version_info["revision_id"]) +
214 '0000\n')
215 self.assertEqual(
216 capabilities +
217 '001e# service=git-upload-pack\n'
218 '0000001bI am git protocol data.'
219 'And I am raw, since we got a good packet to start with.',
220 self.request.value)
221
189222
190class TestSmartHTTPCommandResource(ErrorTestMixin, TestCase):223class TestSmartHTTPCommandResource(ErrorTestMixin, TestCase):
191224

Subscribers

People subscribed via source and target branches