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

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: eea2842e739a4c3d3314f31c7d8937f7cc2542e2
Merged at revision: cf713d479b3fd067cb2a837428b276565b33f0a4
Proposed branch: ~cjwatson/turnip:set-symbolic-ref
Merge into: turnip:master
Diff against target: 497 lines (+297/-26)
8 files modified
README (+16/-0)
turnip/pack/git.py (+83/-9)
turnip/pack/hookrpc.py (+6/-2)
turnip/pack/http.py (+5/-2)
turnip/pack/ssh.py (+2/-1)
turnip/pack/tests/test_functional.py (+65/-11)
turnip/pack/tests/test_git.py (+118/-0)
turnip/pack/tests/test_http.py (+2/-1)
Reviewer Review Type Date Requested Status
Otto Co-Pilot Needs Fixing
William Grant code Approve
Review via email: mp+309702@code.launchpad.net

Commit message

Add turnip-set-symbolic-ref extension service

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

LP: #1469459

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

All fixed, thanks.

Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :

I tried to merge it but there are some problems. Typically you want to merge or rebase and try again.

review: Needs Fixing

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/README b/README
2index 7b26510..4ebc443 100644
3--- a/README
4+++ b/README
5@@ -68,6 +68,22 @@ The only additional parameters implemented today are
6 'turnip-stateless-rpc' and 'turnip-advertise-refs', which are used by
7 the smart HTTP server to proxy to the standard pack protocol.
8
9+turnip implements one externally-visible extension: a
10+'turnip-set-symbolic-ref' service that sets a symbolic ref (currently only
11+'HEAD' is permitted) to a given target. This may be used over the various
12+protocols (git, SSH, smart HTTP), requesting the service in the same way as
13+the existing 'git-upload-pack' and 'git-receive-pack' services.
14+
15+ turnip-set-symbolic-ref-request = set-symbolic-ref-line
16+ flush-pkt
17+ set-symbolic-ref-line = PKT-LINE(refname SP refname)
18+
19+The server replies with an ACK indicating the symbolic ref name that was
20+changed, or an error message.
21+
22+ turnip-set-symbolic-ref-response = set-symbolic-ref-ack / error-line
23+ set-symbolic-ref-ack = PKT-LINE("ACK" SP refname)
24+
25
26 Development
27 ===========
28diff --git a/turnip/pack/git.py b/turnip/pack/git.py
29index 0e5d928..93bc91b 100644
30--- a/turnip/pack/git.py
31+++ b/turnip/pack/git.py
32@@ -278,7 +278,7 @@ class GitProcessProtocol(protocol.ProcessProtocol):
33 'git exited {code} with no output; synthesising an error',
34 code=code)
35 self.peer.sendPacket(ERROR_PREFIX + 'backend exited %d' % code)
36- self.peer.transport.loseConnection()
37+ self.peer.processEnded(reason)
38
39 def pauseProducing(self):
40 self.transport.pauseProducing()
41@@ -392,43 +392,117 @@ class PackBackendProtocol(PackServerProtocol):
42 """
43
44 hookrpc_key = None
45+ expect_set_symbolic_ref = False
46
47 def requestReceived(self, command, raw_pathname, params):
48 self.extractRequestMeta(command, raw_pathname, params)
49+ self.command = command
50+ self.raw_pathname = raw_pathname
51+ self.path = compose_path(self.factory.root, self.raw_pathname)
52+
53+ if command == b'turnip-set-symbolic-ref':
54+ self.expect_set_symbolic_ref = True
55+ self.resumeProducing()
56+ return
57
58- path = compose_path(self.factory.root, raw_pathname)
59+ write_operation = False
60 if command == b'git-upload-pack':
61 subcmd = b'upload-pack'
62 elif command == b'git-receive-pack':
63 subcmd = b'receive-pack'
64+ write_operation = True
65 else:
66 self.die(b'Unsupported command in request')
67 return
68
69- cmd = b'git'
70- args = [b'git', subcmd]
71+ args = []
72 if params.pop(b'turnip-stateless-rpc', None):
73 args.append(b'--stateless-rpc')
74 if params.pop(b'turnip-advertise-refs', None):
75 args.append(b'--advertise-refs')
76- args.append(path)
77+ args.append(self.path)
78+ self.spawnGit(subcmd, args, write_operation=write_operation)
79+
80+ def spawnGit(self, subcmd, extra_args, write_operation=False,
81+ send_path_as_option=False):
82+ cmd = b'git'
83+ args = [b'git']
84+ if send_path_as_option:
85+ args.extend([b'-C', self.path])
86+ args.append(subcmd)
87+ args.extend(extra_args)
88
89 env = {}
90- if subcmd == b'receive-pack' and self.factory.hookrpc_handler:
91+ if write_operation and self.factory.hookrpc_handler:
92 # This is a write operation, so prepare config, hooks, the hook
93 # RPC server, and the environment variables that link them up.
94- ensure_config(path)
95+ ensure_config(self.path)
96 self.hookrpc_key = str(uuid.uuid4())
97 self.factory.hookrpc_handler.registerKey(
98- self.hookrpc_key, raw_pathname, [])
99- ensure_hooks(path)
100+ self.hookrpc_key, self.raw_pathname, [])
101+ ensure_hooks(self.path)
102 env[b'TURNIP_HOOK_RPC_SOCK'] = self.factory.hookrpc_sock
103 env[b'TURNIP_HOOK_RPC_KEY'] = self.hookrpc_key
104
105 self.log.info('Spawning {args}', args=args)
106 self.peer = GitProcessProtocol(self)
107+ self.spawnProcess(cmd, args, env=env)
108+
109+ def spawnProcess(self, cmd, args, env=None):
110 reactor.spawnProcess(self.peer, cmd, args, env=env)
111
112+ def packetReceived(self, data):
113+ if self.expect_set_symbolic_ref:
114+ if data is None:
115+ self.die(b'Bad request: flush-pkt instead')
116+ return
117+ self.pauseProducing()
118+ self.expect_set_symbolic_ref = False
119+ if b' ' not in data:
120+ self.die(b'Invalid set-symbolic-ref-line')
121+ return
122+ name, target = data.split(b' ', 1)
123+ # Be careful about extending this to anything other than HEAD.
124+ # We use "git symbolic-ref" because it gives us locking and
125+ # logging, but it doesn't prevent writing a ref to ../something.
126+ # Fortunately it does at least refuse to point HEAD outside of
127+ # refs/.
128+ if name != b'HEAD':
129+ self.die(b'Symbolic ref name must be "HEAD"')
130+ return
131+ if target.startswith(b'-'):
132+ self.die(b'Symbolic ref target may not start with "-"')
133+ return
134+ elif b' ' in target:
135+ self.die(b'Symbolic ref target may not contain " "')
136+ return
137+ self.symbolic_ref_name = name
138+ self.spawnGit(
139+ b'symbolic-ref', [name, target], write_operation=True,
140+ send_path_as_option=True)
141+ return
142+
143+ PackServerProtocol.packetReceived(self, data)
144+
145+ @defer.inlineCallbacks
146+ def processEnded(self, reason):
147+ message = None
148+ if self.command == b'turnip-set-symbolic-ref':
149+ if reason.check(error.ProcessDone):
150+ try:
151+ yield self.factory.hookrpc_handler.notify(self.path)
152+ self.sendPacket(b'ACK %s\n' % self.symbolic_ref_name)
153+ except Exception as e:
154+ message = str(e)
155+ else:
156+ message = (
157+ 'git symbolic-ref exited with status %d' %
158+ reason.value.exitCode)
159+ if message is None:
160+ self.transport.loseConnection()
161+ else:
162+ self.die(message)
163+
164 def readConnectionLost(self):
165 # Forward the closed stdin down the stack.
166 if self.peer is not None:
167diff --git a/turnip/pack/hookrpc.py b/turnip/pack/hookrpc.py
168index d273a9c..bd3bc0e 100644
169--- a/turnip/pack/hookrpc.py
170+++ b/turnip/pack/hookrpc.py
171@@ -126,11 +126,15 @@ class HookRPCHandler(object):
172 return [rule.decode('utf-8') for rule in self.ref_rules[args['key']]]
173
174 @defer.inlineCallbacks
175+ def notify(self, path):
176+ proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
177+ yield proxy.callRemote(b'notify', path)
178+
179+ @defer.inlineCallbacks
180 def notifyPush(self, proto, args):
181 """Notify the virtinfo service about a push."""
182 path = self.ref_paths[args['key']]
183- proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
184- yield proxy.callRemote(b'notify', path)
185+ yield self.notify(path)
186
187
188 class HookRPCServerFactory(RPCServerFactory):
189diff --git a/turnip/pack/http.py b/turnip/pack/http.py
190index 5e5f87e..42fed27 100644
191--- a/turnip/pack/http.py
192+++ b/turnip/pack/http.py
193@@ -680,7 +680,8 @@ class HTTPAuthResource(resource.Resource):
194 class SmartHTTPFrontendResource(resource.Resource):
195 """HTTP resource to translate Git smart HTTP requests to pack protocol."""
196
197- allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
198+ allowed_services = frozenset((
199+ b'git-upload-pack', b'git-receive-pack', b'turnip-set-symbolic-ref'))
200
201 def __init__(self, backend_host, config):
202 resource.Resource.__init__(self)
203@@ -737,7 +738,9 @@ class SmartHTTPFrontendResource(resource.Resource):
204 content_type = request.getHeader(b'Content-Type')
205 if content_type is None:
206 return False
207- return content_type.startswith(b'application/x-git-')
208+ return (
209+ content_type.startswith(b'application/x-git-') or
210+ content_type.startswith(b'application/x-turnip-'))
211
212 def getChild(self, path, request):
213 if self._isGitRequest(request):
214diff --git a/turnip/pack/ssh.py b/turnip/pack/ssh.py
215index 006331d..b753220 100644
216--- a/turnip/pack/ssh.py
217+++ b/turnip/pack/ssh.py
218@@ -120,7 +120,8 @@ class SSHPackClientFactory(protocol.ClientFactory):
219 class SmartSSHSession(DoNothingSession):
220 """SSH session allowing only Git smart SSH requests."""
221
222- allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
223+ allowed_services = frozenset((
224+ b'git-upload-pack', b'git-receive-pack', b'turnip-set-symbolic-ref'))
225
226 def __init__(self, *args, **kwargs):
227 super(SmartSSHSession, self).__init__(*args, **kwargs)
228diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
229index 61fd5c4..e6dbd20 100644
230--- a/turnip/pack/tests/test_functional.py
231+++ b/turnip/pack/tests/test_functional.py
232@@ -8,8 +8,10 @@ from __future__ import (
233 unicode_literals,
234 )
235
236+import base64
237 from collections import defaultdict
238 import hashlib
239+import io
240 import os
241 import random
242 import shutil
243@@ -33,10 +35,7 @@ from fixtures import (
244 from lazr.sshserver.auth import NoSuchPersonWithName
245 from testtools import TestCase
246 from testtools.content import text_content
247-from testtools.deferredruntest import (
248- assert_fails_with,
249- AsynchronousDeferredRunTest,
250- )
251+from testtools.deferredruntest import AsynchronousDeferredRunTest
252 from testtools.matchers import StartsWith
253 from twisted.internet import (
254 defer,
255@@ -45,11 +44,12 @@ from twisted.internet import (
256 )
257 from twisted.web import (
258 client,
259- error,
260+ http_headers,
261 server,
262 xmlrpc,
263 )
264
265+from turnip.pack import helpers
266 from turnip.pack.git import (
267 PackBackendFactory,
268 PackFrontendFactory,
269@@ -480,14 +480,68 @@ class TestSmartHTTPFrontendFunctional(FrontendFunctionalTestMixin, TestCase):
270
271 @defer.inlineCallbacks
272 def test_root_revision_header(self):
273- factory = client.HTTPClientFactory(
274- b'http://localhost:%d/' % self.port, method=b'HEAD',
275- followRedirect=False)
276- reactor.connectTCP(b'localhost', self.port, factory)
277- yield assert_fails_with(factory.deferred, error.PageRedirect)
278+ response = yield client.Agent(reactor).request(
279+ b'HEAD', b'http://localhost:%d/' % self.port)
280+ self.assertEqual(302, response.code)
281 self.assertEqual(
282 [version_info['revision_id']],
283- factory.response_headers[b'x-turnip-revision'])
284+ response.headers.getRawHeaders(b'X-Turnip-Revision'))
285+
286+ def make_set_symbolic_ref_request(self, line):
287+ parsed_url = urlsplit(self.url)
288+ url = urlunsplit([
289+ parsed_url.scheme,
290+ b'%s:%d' % (parsed_url.hostname, parsed_url.port),
291+ parsed_url.path + b'/turnip-set-symbolic-ref', b'', b''])
292+ headers = {
293+ b'Content-Type': [
294+ b'application/x-turnip-set-symbolic-ref-request',
295+ ],
296+ }
297+ if parsed_url.username:
298+ headers[b'Authorization'] = [
299+ b'Basic ' + base64.b64encode(
300+ b'%s:%s' % (parsed_url.username, parsed_url.password)),
301+ ]
302+ data = helpers.encode_packet(line) + helpers.encode_packet(None)
303+ return client.Agent(reactor).request(
304+ b'POST', url, headers=http_headers.Headers(headers),
305+ bodyProducer=client.FileBodyProducer(io.BytesIO(data)))
306+
307+ @defer.inlineCallbacks
308+ def get_symbolic_ref(self, path, name):
309+ out = yield utils.getProcessOutput(
310+ b'git', (b'symbolic-ref', name), env=os.environ, path=path)
311+ defer.returnValue(out.rstrip(b'\n'))
312+
313+ @defer.inlineCallbacks
314+ def test_turnip_set_symbolic_ref(self):
315+ repo = os.path.join(self.root, self.internal_name)
316+ head_target = yield self.get_symbolic_ref(repo, b'HEAD')
317+ self.assertEqual(b'refs/heads/master', head_target)
318+ response = yield self.make_set_symbolic_ref_request(
319+ b'HEAD refs/heads/new-head')
320+ self.assertEqual(200, response.code)
321+ body = yield client.readBody(response)
322+ self.assertEqual((b'ACK HEAD\n', ''), helpers.decode_packet(body))
323+ head_target = yield self.get_symbolic_ref(repo, b'HEAD')
324+ self.assertEqual(b'refs/heads/new-head', head_target)
325+
326+ @defer.inlineCallbacks
327+ def test_turnip_set_symbolic_ref_error(self):
328+ repo = os.path.join(self.root, self.internal_name)
329+ head_target = yield self.get_symbolic_ref(repo, b'HEAD')
330+ self.assertEqual(b'refs/heads/master', head_target)
331+ response = yield self.make_set_symbolic_ref_request(b'HEAD --evil')
332+ # This is a little weird since an error occurred, but it's
333+ # consistent with other HTTP pack protocol responses.
334+ self.assertEqual(200, response.code)
335+ body = yield client.readBody(response)
336+ self.assertEqual(
337+ (b'ERR Symbolic ref target may not start with "-"\n', ''),
338+ helpers.decode_packet(body))
339+ head_target = yield self.get_symbolic_ref(repo, b'HEAD')
340+ self.assertEqual(b'refs/heads/master', head_target)
341
342
343 class TestSmartHTTPFrontendWithAuthFunctional(TestSmartHTTPFrontendFunctional):
344diff --git a/turnip/pack/tests/test_git.py b/turnip/pack/tests/test_git.py
345index d19574d..4153234 100644
346--- a/turnip/pack/tests/test_git.py
347+++ b/turnip/pack/tests/test_git.py
348@@ -7,13 +7,23 @@ from __future__ import (
349 unicode_literals,
350 )
351
352+import os.path
353+
354+from fixtures import TempDir
355+from pygit2 import init_repository
356 from testtools import TestCase
357+from testtools.matchers import (
358+ ContainsDict,
359+ Equals,
360+ MatchesListwise,
361+ )
362 from twisted.test import proto_helpers
363
364 from turnip.pack import (
365 git,
366 helpers,
367 )
368+from turnip.pack.tests.test_hooks import MockHookRPCHandler
369
370
371 class DummyPackServerProtocol(git.PackServerProtocol):
372@@ -87,3 +97,111 @@ class TestPackServerProtocol(TestCase):
373 # dropped.
374 self.proto.dataReceived(b'0000')
375 self.assertKilledWith(b'Bad request: flush-pkt instead')
376+
377+
378+class DummyPackBackendProtocol(git.PackBackendProtocol):
379+
380+ test_process = None
381+
382+ def spawnProcess(self, cmd, args, env=None):
383+ if self.test_process is not None:
384+ raise AssertionError('Process already spawned.')
385+ self.test_process = (cmd, args, env)
386+
387+
388+class TestPackBackendProtocol(TestCase):
389+ """Test the Git pack backend protocol."""
390+
391+ def setUp(self):
392+ super(TestPackBackendProtocol, self).setUp()
393+ self.root = self.useFixture(TempDir()).path
394+ self.hookrpc_handler = MockHookRPCHandler()
395+ self.hookrpc_sock = os.path.join(self.root, 'hookrpc_sock')
396+ self.factory = git.PackBackendFactory(
397+ self.root, self.hookrpc_handler, self.hookrpc_sock)
398+ self.proto = DummyPackBackendProtocol()
399+ self.proto.factory = self.factory
400+ self.transport = proto_helpers.StringTransportWithDisconnection()
401+ self.transport.protocol = self.proto
402+ self.proto.makeConnection(self.transport)
403+
404+ def assertKilledWith(self, message):
405+ self.assertFalse(self.transport.connected)
406+ self.assertEqual(
407+ (b'ERR ' + message + b'\n', b''),
408+ helpers.decode_packet(self.transport.value()))
409+
410+ def test_git_upload_pack_calls_spawnProcess(self):
411+ # If the command is git-upload-pack, requestReceived calls
412+ # spawnProcess with appropriate arguments.
413+ self.proto.requestReceived(
414+ b'git-upload-pack', b'/foo.git', {b'host': b'example.com'})
415+ self.assertEqual(
416+ (b'git',
417+ [b'git', b'upload-pack', os.path.join(self.root, b'foo.git')],
418+ {}),
419+ self.proto.test_process)
420+
421+ def test_git_receive_pack_calls_spawnProcess(self):
422+ # If the command is git-receive-pack, requestReceived calls
423+ # spawnProcess with appropriate arguments.
424+ repo_dir = os.path.join(self.root, 'foo.git')
425+ init_repository(repo_dir, bare=True)
426+ self.proto.requestReceived(
427+ b'git-receive-pack', b'/foo.git', {b'host': b'example.com'})
428+ self.assertThat(
429+ self.proto.test_process, MatchesListwise([
430+ Equals(b'git'),
431+ Equals([b'git', b'receive-pack', repo_dir.encode('utf-8')]),
432+ ContainsDict(
433+ {b'TURNIP_HOOK_RPC_SOCK': Equals(self.hookrpc_sock)})]))
434+
435+ def test_turnip_set_symbolic_ref_calls_spawnProcess(self):
436+ # If the command is turnip-set-symbolic-ref, requestReceived does
437+ # not spawn a process, but packetReceived calls spawnProcess with
438+ # appropriate arguments.
439+ repo_dir = os.path.join(self.root, 'foo.git')
440+ init_repository(repo_dir, bare=True)
441+ self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
442+ self.assertIsNone(self.proto.test_process)
443+ self.proto.packetReceived(b'HEAD refs/heads/master')
444+ self.assertThat(
445+ self.proto.test_process, MatchesListwise([
446+ Equals(b'git'),
447+ Equals([
448+ b'git', b'-C', repo_dir.encode('utf-8'), b'symbolic-ref',
449+ b'HEAD', b'refs/heads/master']),
450+ ContainsDict(
451+ {b'TURNIP_HOOK_RPC_SOCK': Equals(self.hookrpc_sock)})]))
452+
453+ def test_turnip_set_symbolic_ref_requires_valid_line(self):
454+ # The turnip-set-symbolic-ref command requires a valid
455+ # set-symbolic-ref-line packet.
456+ self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
457+ self.assertIsNone(self.proto.test_process)
458+ self.proto.packetReceived(b'HEAD')
459+ self.assertKilledWith(b'Invalid set-symbolic-ref-line')
460+
461+ def test_turnip_set_symbolic_ref_name_must_be_HEAD(self):
462+ # The turnip-set-symbolic-ref command's "name" parameter must be
463+ # "HEAD".
464+ self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
465+ self.assertIsNone(self.proto.test_process)
466+ self.proto.packetReceived(b'another-symref refs/heads/master')
467+ self.assertKilledWith(b'Symbolic ref name must be "HEAD"')
468+
469+ def test_turnip_set_symbolic_ref_target_not_option(self):
470+ # The turnip-set-symbolic-ref command's "target" parameter may not
471+ # start with "-".
472+ self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
473+ self.assertIsNone(self.proto.test_process)
474+ self.proto.packetReceived(b'HEAD --evil')
475+ self.assertKilledWith(b'Symbolic ref target may not start with "-"')
476+
477+ def test_turnip_set_symbolic_ref_target_no_space(self):
478+ # The turnip-set-symbolic-ref command's "target" parameter may not
479+ # contain " ".
480+ self.proto.requestReceived(b'turnip-set-symbolic-ref', b'/foo.git', {})
481+ self.assertIsNone(self.proto.test_process)
482+ self.proto.packetReceived(b'HEAD evil lies')
483+ self.assertKilledWith(b'Symbolic ref target may not contain " "')
484diff --git a/turnip/pack/tests/test_http.py b/turnip/pack/tests/test_http.py
485index 45cafad..6e16b77 100644
486--- a/turnip/pack/tests/test_http.py
487+++ b/turnip/pack/tests/test_http.py
488@@ -64,7 +64,8 @@ def render_resource(resource, request):
489
490 class FakeRoot(object):
491
492- allowed_services = frozenset((b'git-upload-pack', b'git-receive-pack'))
493+ allowed_services = frozenset((
494+ b'git-upload-pack', b'git-receive-pack', b'turnip-set-symbolic-ref'))
495
496 def __init__(self):
497 self.backend_transport = None

Subscribers

People subscribed via source and target branches