Merge lp:~cjwatson/launchpad-buildd/local-snap-proxy into lp:launchpad-buildd

Proposed by Colin Watson on 2017-04-13
Status: Needs review
Proposed branch: lp:~cjwatson/launchpad-buildd/local-snap-proxy
Merge into: lp:launchpad-buildd
Diff against target: 610 lines (+394/-24) (has conflicts)
9 files modified
bin/buildsnap (+3/-20)
buildd-genconfig (+6/-1)
buildd-slave.tac (+1/-0)
debian/changelog (+12/-0)
debian/postinst (+1/-1)
debian/upgrade-config (+13/-0)
lpbuildd/snap.py (+276/-2)
lpbuildd/tests/test_snap.py (+79/-0)
template-buildd-slave.conf (+3/-0)
Text conflict in bin/buildsnap
Text conflict in debian/changelog
Text conflict in lpbuildd/snap.py
To merge this branch: bzr merge lp:~cjwatson/launchpad-buildd/local-snap-proxy
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2017-04-13 Pending
Review via email: mp+322545@code.launchpad.net

Commit Message

Add a local unauthenticated proxy on port 8222, which proxies through to
the remote authenticated proxy. This should allow running a wider range
of network clients, since some of them apparently don't support
authenticated proxies very well.

To post a comment you must log in.
Adam Collard (adam-collard) wrote :

drive by review

217. By Colin Watson on 2017-08-04

Remove dead code.

Kevin W Monroe (kwmonroe) wrote :

Aside from the merge conflicts, the spirit of this gets a big +1 from me. I'm running into all sorts of trouble with java projects (mvn, ant, ivy, gradle) and their poor support for auth'd proxies. I think an unauth'd alternative would be super useful.

Unmerged revisions

217. By Colin Watson on 2017-08-04

Remove dead code.

216. By Colin Watson on 2017-04-13

Add a local unauthenticated proxy on port 8222, which proxies through to
the remote authenticated proxy. This should allow running a wider range
of network clients, since some of them apparently don't support
authenticated proxies very well.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/buildsnap'
2--- bin/buildsnap 2017-07-28 11:15:51 +0000
3+++ bin/buildsnap 2017-08-04 11:03:23 +0000
4@@ -8,15 +8,16 @@
5
6 __metaclass__ = type
7
8+<<<<<<< TREE
9 import base64
10 import json
11+=======
12+>>>>>>> MERGE-SOURCE
13 from optparse import OptionParser
14 import os
15 import subprocess
16 import sys
17 import traceback
18-import urllib2
19-from urlparse import urlparse
20
21 from lpbuildd.util import (
22 set_personality,
23@@ -200,21 +201,6 @@
24 self.run_build_command(
25 ["snapcraft"], path=os.path.join("/build", self.name), env=env)
26
27- def revoke_token(self):
28- """Revoke builder proxy token."""
29- print("Revoking proxy token...")
30- url = urlparse(self.options.proxy_url)
31- auth = '{}:{}'.format(url.username, url.password)
32- headers = {
33- 'Authorization': 'Basic {}'.format(base64.b64encode(auth))
34- }
35- req = urllib2.Request(self.options.revocation_endpoint, None, headers)
36- req.get_method = lambda: 'DELETE'
37- try:
38- urllib2.urlopen(req)
39- except (urllib2.HTTPError, urllib2.URLError) as e:
40- print('Unable to revoke token for %s: %s' % (url.username, e))
41-
42
43 def main():
44 parser = OptionParser("%prog [options] NAME")
45@@ -255,9 +241,6 @@
46 except Exception:
47 traceback.print_exc()
48 return RETCODE_FAILURE_BUILD
49- finally:
50- if options.revocation_endpoint is not None:
51- builder.revoke_token()
52 return RETCODE_SUCCESS
53
54
55
56=== modified file 'buildd-genconfig'
57--- buildd-genconfig 2016-12-09 18:04:00 +0000
58+++ buildd-genconfig 2017-08-04 11:03:23 +0000
59@@ -37,6 +37,11 @@
60 metavar="FILE",
61 default="/usr/share/launchpad-buildd/template-buildd-slave.conf")
62
63+parser.add_option("--snap-proxy-port", dest="SNAPPROXYPORT",
64+ help="the port the local snap proxy binds to",
65+ metavar="PORT",
66+ default="8222")
67+
68 (options, args) = parser.parse_args()
69
70 template = open(options.TEMPLATE, "r").read()
71@@ -46,6 +51,7 @@
72 "@BINDHOST@": options.BINDHOST,
73 "@ARCHTAG@": options.ARCHTAG,
74 "@BINDPORT@": options.BINDPORT,
75+ "@SNAPPROXYPORT@": options.SNAPPROXYPORT,
76 }
77
78 for replacement_key in replacements:
79@@ -53,4 +59,3 @@
80 replacements[replacement_key])
81
82 print(template.strip())
83-
84
85=== modified file 'buildd-slave.tac'
86--- buildd-slave.tac 2015-07-31 11:54:07 +0000
87+++ buildd-slave.tac 2017-08-04 11:03:23 +0000
88@@ -48,6 +48,7 @@
89 application.addComponent(
90 RotatableFileLogObserver(options.get('logfile')), ignoreClass=1)
91 builddslaveService = service.IServiceCollection(application)
92+slave.slave.service = builddslaveService
93
94 root = resource.Resource()
95 root.putChild('rpc', slave)
96
97=== modified file 'debian/changelog'
98--- debian/changelog 2017-08-03 13:13:08 +0000
99+++ debian/changelog 2017-08-04 11:03:23 +0000
100@@ -1,3 +1,4 @@
101+<<<<<<< TREE
102 launchpad-buildd (148) UNRELEASED; urgency=medium
103
104 * Move the contents of /usr/share/launchpad-buildd/slavebin/ into bin/ in
105@@ -60,6 +61,17 @@
106
107 -- Colin Watson <cjwatson@ubuntu.com> Fri, 12 May 2017 16:47:57 +0100
108
109+=======
110+launchpad-buildd (143) UNRELEASED; urgency=medium
111+
112+ * Add a local unauthenticated proxy on port 8222, which proxies through to
113+ the remote authenticated proxy. This should allow running a wider range
114+ of network clients, since some of them apparently don't support
115+ authenticated proxies very well.
116+
117+ -- Colin Watson <cjwatson@ubuntu.com> Thu, 13 Apr 2017 18:02:20 +0100
118+
119+>>>>>>> MERGE-SOURCE
120 launchpad-buildd (142) trusty; urgency=medium
121
122 * lpbuildd.binarypackage: Pass DEB_BUILD_OPTIONS=noautodbgsym if we have
123
124=== modified file 'debian/postinst'
125--- debian/postinst 2015-11-25 15:00:43 +0000
126+++ debian/postinst 2017-08-04 11:03:23 +0000
127@@ -14,7 +14,7 @@
128
129 make_buildd()
130 {
131- /usr/share/launchpad-buildd/buildd-genconfig --name=default --host=0.0.0.0 --port=8221 > \
132+ /usr/share/launchpad-buildd/buildd-genconfig --name=default --host=0.0.0.0 --port=8221 --snap-proxy-port=8222 > \
133 /etc/launchpad-buildd/default
134 echo Default buildd created.
135 }
136
137=== modified file 'debian/upgrade-config'
138--- debian/upgrade-config 2016-12-09 18:05:21 +0000
139+++ debian/upgrade-config 2017-08-04 11:03:23 +0000
140@@ -197,6 +197,17 @@
141 in_file.close()
142 out_file.close()
143
144+def upgrade_to_143():
145+ print("Upgrading %s to version 143" % conf_file)
146+ os.rename(conf_file, conf_file + "-prev143~")
147+
148+ with open(conf_file + "-prev143~", "r") as in_file:
149+ with open(conf_file, "w") as out_file:
150+ out_file.write(in_file.read())
151+ out_file.write(
152+ "\n[snapmanager]\n"
153+ "proxyport = 8222\n")
154+
155 if __name__ == "__main__":
156 old_version = re.sub(r'[~-].*', '', old_version)
157 if apt_pkg.version_compare(old_version, "12") < 0:
158@@ -223,3 +234,5 @@
159 upgrade_to_126()
160 if apt_pkg.version_compare(old_version, "127") < 0:
161 upgrade_to_127()
162+ if apt_pkg.version_compare(old_version, "143") < 0:
163+ upgrade_to_143()
164
165=== modified file 'lpbuildd/snap.py'
166--- lpbuildd/snap.py 2017-04-03 12:30:15 +0000
167+++ lpbuildd/snap.py 2017-08-04 11:03:23 +0000
168@@ -5,10 +5,46 @@
169
170 __metaclass__ = type
171
172+<<<<<<< TREE
173 import json
174+=======
175+import base64
176+import io
177+>>>>>>> MERGE-SOURCE
178 import os
179 import shutil
180+<<<<<<< TREE
181 import sys
182+=======
183+try:
184+ from urllib.error import (
185+ HTTPError,
186+ URLError,
187+ )
188+ from urllib.parse import urlparse
189+ from urllib.request import (
190+ Request,
191+ urlopen,
192+ )
193+except ImportError:
194+ from urllib2 import (
195+ HTTPError,
196+ Request,
197+ URLError,
198+ urlopen,
199+ )
200+ from urlparse import urlparse
201+
202+from twisted.application import strports
203+from twisted.internet import reactor
204+from twisted.internet.interfaces import IHalfCloseableProtocol
205+from twisted.python.compat import intToBytes
206+from twisted.web import (
207+ http,
208+ proxy,
209+ )
210+from zope.interface import implementer
211+>>>>>>> MERGE-SOURCE
212
213 from lpbuildd.debian import (
214 DebianBuildManager,
215@@ -22,6 +58,204 @@
216 RETCODE_FAILURE_BUILD = 201
217
218
219+class SnapProxyClient(proxy.ProxyClient):
220+
221+ def __init__(self, command, rest, version, headers, data, father):
222+ proxy.ProxyClient.__init__(
223+ self, command, rest, version, headers, data, father)
224+ # Why doesn't ProxyClient at least store this?
225+ self.version = version
226+ # We must avoid calling self.father.finish in the event that its
227+ # connection was already lost, i.e. if the original client
228+ # disconnects first (which is particularly likely in the case of
229+ # CONNECT).
230+ d = self.father.notifyFinish()
231+ d.addBoth(self.requestFinished)
232+
233+ def connectionMade(self):
234+ proxy.ProxyClient.connectionMade(self)
235+ self.father.setChildClient(self)
236+
237+ def sendCommand(self, command, path):
238+ # For some reason, HTTPClient.sendCommand doesn't preserve the
239+ # protocol version.
240+ self.transport.writeSequence(
241+ [command, b' ', path, b' ', self.version, b'\r\n'])
242+
243+ def handleEndHeaders(self):
244+ self.father.handleEndHeaders()
245+
246+ def sendData(self, data):
247+ self.transport.write(data)
248+
249+ def endData(self):
250+ if self.transport is not None:
251+ self.transport.loseWriteConnection()
252+
253+ def requestFinished(self, result):
254+ self._finished = True
255+ self.transport.loseConnection()
256+
257+
258+class SnapProxyClientFactory(proxy.ProxyClientFactory):
259+
260+ protocol = SnapProxyClient
261+
262+
263+class SnapProxyRequest(http.Request):
264+
265+ child_client = None
266+ _request_buffer = None
267+ _request_data_done = False
268+
269+ def setChildClient(self, child_client):
270+ self.child_client = child_client
271+ if self._request_buffer is not None:
272+ self.child_client.sendData(self._request_buffer.getvalue())
273+ self._request_buffer = None
274+ if self._request_data_done:
275+ self.child_client.endData()
276+
277+ def allHeadersReceived(self, command, path, version):
278+ # Normally done in `requestReceived`, but we disable that since it
279+ # does other things we don't want.
280+ self.method, self.uri, self.clientproto = command, path, version
281+ self.client = self.channel.transport.getPeer()
282+ self.host = self.channel.transport.getHost()
283+
284+ remote_parsed = urlparse(self.channel.factory.remote_url)
285+ request_parsed = urlparse(path)
286+ headers = self.getAllHeaders().copy()
287+ if b"host" not in headers and request_parsed.netloc:
288+ headers[b"host"] = request_parsed.netloc
289+ if remote_parsed.username:
290+ auth = (remote_parsed.username + ":" +
291+ remote_parsed.password).encode("ASCII")
292+ authHeader = b"Basic " + base64.b64encode(auth)
293+ headers[b"proxy-authorization"] = authHeader
294+ self.client_factory = SnapProxyClientFactory(
295+ command, path, version, headers, b"", self)
296+ reactor.connectTCP(
297+ remote_parsed.hostname, remote_parsed.port, self.client_factory)
298+
299+ def requestReceived(self, command, path, version):
300+ # We do most of our work in `allHeadersReceived` instead.
301+ pass
302+
303+ def rawDataReceived(self, data):
304+ if self.child_client is not None:
305+ if not self._request_data_done:
306+ self.child_client.sendData(data)
307+ else:
308+ if self._request_buffer is None:
309+ self._request_buffer = io.BytesIO()
310+ self._request_buffer.write(data)
311+
312+ def handleEndHeaders(self):
313+ # Cut-down version of Request.write. We must avoid switching to
314+ # chunked encoding for the sake of CONNECT; since our actual
315+ # response data comes from another proxy, we can cut some corners.
316+ if self.startedWriting:
317+ return
318+ self.startedWriting = 1
319+ l = []
320+ l.append(
321+ self.clientproto + b" " + intToBytes(self.code) + b" " +
322+ self.code_message + b"\r\n")
323+ for name, values in self.responseHeaders.getAllRawHeaders():
324+ for value in values:
325+ l.extend([name, b": ", value, b"\r\n"])
326+ l.append(b"\r\n")
327+ self.transport.writeSequence(l)
328+
329+ def write(self, data):
330+ if self.channel is not None:
331+ self.channel.resetTimeout()
332+ http.Request.write(self, data)
333+
334+ def endData(self):
335+ if self.child_client is not None:
336+ self.child_client.endData()
337+ self._request_data_done = True
338+
339+
340+@implementer(IHalfCloseableProtocol)
341+class SnapProxy(http.HTTPChannel):
342+ """A channel that streams request data.
343+
344+ The stock HTTPChannel isn't quite suitable for our needs, because it
345+ expects to read the entire request data before passing control to the
346+ request. This doesn't work well for CONNECT.
347+ """
348+
349+ requestFactory = SnapProxyRequest
350+
351+ def checkPersistence(self, request, version):
352+ # ProxyClient.__init__ forces "Connection: close".
353+ return False
354+
355+ def allHeadersReceived(self):
356+ http.HTTPChannel.allHeadersReceived(self)
357+ self.requests[-1].allHeadersReceived(
358+ self._command, self._path, self._version)
359+ if self._command == b"CONNECT":
360+ # This is a lie, but we don't want HTTPChannel to decide that
361+ # the request is finished just because a CONNECT request
362+ # (naturally) has no Content-Length.
363+ self.length = -1
364+
365+ def rawDataReceived(self, data):
366+ self.resetTimeout()
367+ if self.requests:
368+ self.requests[-1].rawDataReceived(data)
369+
370+ def readConnectionLost(self):
371+ for request in self.requests:
372+ request.endData()
373+
374+ def writeConnectionLost(self):
375+ pass
376+
377+
378+class SnapProxyFactory(http.HTTPFactory):
379+
380+ protocol = SnapProxy
381+
382+ def __init__(self, manager, remote_url, *args, **kwargs):
383+ http.HTTPFactory.__init__(self, *args, **kwargs)
384+ self.manager = manager
385+ self.remote_url = remote_url
386+ # Hack for compatibility with the old version of Twisted that
387+ # Launchpad currently uses, for the benefit of tests.
388+ try:
389+ from twisted.web.http import _escape
390+ self._log_escape = _escape
391+ except ImportError:
392+ self._log_escape = self._escape
393+
394+ def log(self, request):
395+ # Log requests to the build log rather than to Twisted.
396+ # Reimplement log formatting partly to make it easier to stay
397+ # compatible with the old version of Twisted that Launchpad
398+ # currently uses, and partly because there's no point logging the IP
399+ # here.
400+ referrer = self._log_escape(request.getHeader(b"referer") or b"-")
401+ agent = self._log_escape(request.getHeader(b"user-agent") or b"-")
402+ line = (
403+ u'%(timestamp)s "%(method)s %(uri)s %(protocol)s" '
404+ u'%(code)d %(length)s "%(referrer)s" "%(agent)s"\n' % {
405+ 'timestamp': self._logDateTime,
406+ 'method': self._log_escape(request.method),
407+ 'uri': self._log_escape(request.uri),
408+ 'protocol': self._log_escape(request.clientproto),
409+ 'code': request.code,
410+ 'length': request.sentLength or "-",
411+ 'referrer': referrer,
412+ 'agent': agent,
413+ })
414+ self.manager._slave.log(line.encode("UTF-8"))
415+
416+
417 class SnapBuildState(DebianBuildState):
418 BUILD_SNAP = "BUILD_SNAP"
419
420@@ -52,9 +286,11 @@
421 self.git_path = extra_args.get("git_path")
422 self.proxy_url = extra_args.get("proxy_url")
423 self.revocation_endpoint = extra_args.get("revocation_endpoint")
424+ self.proxy_service = None
425
426 super(SnapBuildManager, self).initiate(files, chroot, extra_args)
427
428+<<<<<<< TREE
429 def status(self):
430 status_path = get_build_path(self.home, self._buildid, "status")
431 try:
432@@ -68,6 +304,43 @@
433 file=sys.stderr)
434 return {}
435
436+=======
437+ def startProxy(self):
438+ """Start the local snap proxy, if necessary."""
439+ if not self.proxy_url:
440+ return []
441+ proxy_port = self._slave._config.get("snapmanager", "proxyport")
442+ proxy_factory = SnapProxyFactory(self, self.proxy_url, timeout=60)
443+ self.proxy_service = strports.service(proxy_port, proxy_factory)
444+ self.proxy_service.setServiceParent(self._slave.service)
445+ return ["--proxy-url", "http://localhost:{}/".format(proxy_port)]
446+
447+ def stopProxy(self):
448+ """Stop the local snap proxy, if necessary."""
449+ if self.proxy_service is None:
450+ return
451+ self.proxy_service.disownServiceParent()
452+ self.proxy_service = None
453+
454+ def revokeProxyToken(self):
455+ """Revoke builder proxy token."""
456+ if not self.revocation_endpoint:
457+ return
458+ self._slave.log("Revoking proxy token...\n")
459+ url = urlparse(self.proxy_url)
460+ auth = "{}:{}".format(url.username, url.password)
461+ headers = {
462+ "Authorization": "Basic {}".format(base64.b64encode(auth))
463+ }
464+ req = Request(self.revocation_endpoint, None, headers)
465+ req.get_method = lambda: "DELETE"
466+ try:
467+ urlopen(req)
468+ except (HTTPError, URLError) as e:
469+ self._slave.log(
470+ "Unable to revoke token for %s: %s" % (url.username, e))
471+
472+>>>>>>> MERGE-SOURCE
473 def doRunBuild(self):
474 """Run the process to build the snap."""
475 args = [
476@@ -75,8 +348,7 @@
477 "--build-id", self._buildid,
478 "--arch", self.arch_tag,
479 ]
480- if self.proxy_url:
481- args.extend(["--proxy-url", self.proxy_url])
482+ args.extend(self.startProxy())
483 if self.revocation_endpoint:
484 args.extend(["--revocation-endpoint", self.revocation_endpoint])
485 if self.branch is not None:
486@@ -90,6 +362,8 @@
487
488 def iterate_BUILD_SNAP(self, retcode):
489 """Finished building the snap."""
490+ self.stopProxy()
491+ self.revokeProxyToken()
492 if retcode == RETCODE_SUCCESS:
493 self.gatherResults()
494 print("Returning build status: OK")
495
496=== modified file 'lpbuildd/tests/test_snap.py'
497--- lpbuildd/tests/test_snap.py 2017-07-28 13:57:47 +0000
498+++ lpbuildd/tests/test_snap.py 2017-08-04 11:03:23 +0000
499@@ -8,10 +8,25 @@
500 import tempfile
501
502 from testtools import TestCase
503+from testtools.content import text_content
504+from testtools.deferredruntest import AsynchronousDeferredRunTest
505+from twisted.internet import (
506+ defer,
507+ reactor,
508+ utils,
509+ )
510+from twisted.web import (
511+ http,
512+ proxy,
513+ resource,
514+ server,
515+ static,
516+ )
517
518 from lpbuildd.snap import (
519 SnapBuildManager,
520 SnapBuildState,
521+ SnapProxyFactory,
522 )
523 from lpbuildd.tests.fakeslave import FakeSlave
524
525@@ -32,6 +47,9 @@
526
527 class TestSnapBuildManagerIteration(TestCase):
528 """Run SnapBuildManager through its iteration steps."""
529+
530+ run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
531+
532 def setUp(self):
533 super(TestSnapBuildManagerIteration, self).setUp()
534 self.working_dir = tempfile.mkdtemp()
535@@ -174,3 +192,64 @@
536 self.assertEqual(
537 self.buildmanager.iterate, self.buildmanager.iterators[-1])
538 self.assertFalse(self.slave.wasCalled("buildFail"))
539+
540+ def getListenerURL(self, listener):
541+ port = listener.getHost().port
542+ return b"http://localhost:%d/" % port
543+
544+ def startFakeRemoteEndpoint(self):
545+ remote_endpoint = resource.Resource()
546+ remote_endpoint.putChild("a", static.Data("a" * 1024, "text/plain"))
547+ remote_endpoint.putChild("b", static.Data("b" * 65536, "text/plain"))
548+ remote_endpoint_listener = reactor.listenTCP(
549+ 0, server.Site(remote_endpoint))
550+ self.addCleanup(remote_endpoint_listener.stopListening)
551+ return remote_endpoint_listener
552+
553+ def startFakeRemoteProxy(self):
554+ remote_proxy_factory = http.HTTPFactory()
555+ remote_proxy_factory.protocol = proxy.Proxy
556+ remote_proxy_listener = reactor.listenTCP(0, remote_proxy_factory)
557+ self.addCleanup(remote_proxy_listener.stopListening)
558+ return remote_proxy_listener
559+
560+ def startLocalProxy(self, remote_url):
561+ proxy_factory = SnapProxyFactory(
562+ self.buildmanager, remote_url, timeout=60)
563+ proxy_listener = reactor.listenTCP(0, proxy_factory)
564+ self.addCleanup(proxy_listener.stopListening)
565+ return proxy_listener
566+
567+ @defer.inlineCallbacks
568+ def assertCommandSuccess(self, command, extra_env=None):
569+ env = os.environ
570+ if extra_env is not None:
571+ env.update(extra_env)
572+ out, err, code = yield utils.getProcessOutputAndValue(
573+ command[0], command[1:], env=env, path=".")
574+ if code != 0:
575+ self.addDetail("stdout", text_content(out))
576+ self.addDetail("stderr", text_content(err))
577+ self.assertEqual(0, code)
578+ defer.returnValue(out)
579+
580+ @defer.inlineCallbacks
581+ def test_fetch_via_proxy(self):
582+ remote_endpoint_listener = self.startFakeRemoteEndpoint()
583+ remote_endpoint_url = self.getListenerURL(remote_endpoint_listener)
584+ remote_proxy_listener = self.startFakeRemoteProxy()
585+ proxy_listener = self.startLocalProxy(
586+ self.getListenerURL(remote_proxy_listener))
587+ out = yield self.assertCommandSuccess(
588+ [b"curl", remote_endpoint_url + b"a"],
589+ extra_env={b"http_proxy": self.getListenerURL(proxy_listener)})
590+ self.assertEqual("a" * 1024, out)
591+ out = yield self.assertCommandSuccess(
592+ [b"curl", remote_endpoint_url + b"b"],
593+ extra_env={b"http_proxy": self.getListenerURL(proxy_listener)})
594+ self.assertEqual("b" * 65536, out)
595+
596+ # XXX cjwatson 2017-04-13: We should really test the HTTPS case as well,
597+ # but it's hard to see how to test that in a way that's independent of
598+ # the code under test since the stock twisted.web.proxy doesn't support
599+ # CONNECT.
600
601=== modified file 'template-buildd-slave.conf'
602--- template-buildd-slave.conf 2015-05-11 06:09:19 +0000
603+++ template-buildd-slave.conf 2017-08-04 11:03:23 +0000
604@@ -12,3 +12,6 @@
605
606 [translationtemplatesmanager]
607 resultarchive = translation-templates.tar.gz
608+
609+[snapmanager]
610+proxyport = @SNAPPROXYPORT@

Subscribers

People subscribed via source and target branches

to all changes: