← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad-buildd/local-snap-proxy into lp:launchpad-buildd.

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.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad-buildd/local-snap-proxy/+merge/322545
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad-buildd/local-snap-proxy into lp:launchpad-buildd.
=== modified file 'buildd-genconfig'
--- buildd-genconfig	2016-12-09 18:04:00 +0000
+++ buildd-genconfig	2017-04-13 17:26:26 +0000
@@ -37,6 +37,11 @@
                   metavar="FILE",
                   default="/usr/share/launchpad-buildd/template-buildd-slave.conf")
 
+parser.add_option("--snap-proxy-port", dest="SNAPPROXYPORT",
+                  help="the port the local snap proxy binds to",
+                  metavar="PORT",
+                  default="8222")
+
 (options, args) = parser.parse_args()
 
 template = open(options.TEMPLATE, "r").read()
@@ -46,6 +51,7 @@
     "@BINDHOST@": options.BINDHOST,
     "@ARCHTAG@": options.ARCHTAG,
     "@BINDPORT@": options.BINDPORT,
+    "@SNAPPROXYPORT@": options.SNAPPROXYPORT,
     }
 
 for replacement_key in replacements:
@@ -53,4 +59,3 @@
                                 replacements[replacement_key])
 
 print(template.strip())
-

=== modified file 'buildd-slave.tac'
--- buildd-slave.tac	2015-07-31 11:54:07 +0000
+++ buildd-slave.tac	2017-04-13 17:26:26 +0000
@@ -48,6 +48,7 @@
 application.addComponent(
     RotatableFileLogObserver(options.get('logfile')), ignoreClass=1)
 builddslaveService = service.IServiceCollection(application)
+slave.slave.service = builddslaveService
 
 root = resource.Resource()
 root.putChild('rpc', slave)

=== modified file 'buildsnap'
--- buildsnap	2017-02-10 14:53:12 +0000
+++ buildsnap	2017-04-13 17:26:26 +0000
@@ -8,14 +8,11 @@
 
 __metaclass__ = type
 
-import base64
 from optparse import OptionParser
 import os
 import subprocess
 import sys
 import traceback
-import urllib2
-from urlparse import urlparse
 
 from lpbuildd.util import (
     set_personality,
@@ -148,21 +145,6 @@
         self.run_build_command(
             ["snapcraft"], path=os.path.join("/build", self.name), env=env)
 
-    def revoke_token(self):
-        """Revoke builder proxy token."""
-        print("Revoking proxy token...")
-        url = urlparse(self.options.proxy_url)
-        auth = '{}:{}'.format(url.username, url.password)
-        headers = {
-            'Authorization': 'Basic {}'.format(base64.b64encode(auth))
-            }
-        req = urllib2.Request(self.options.revocation_endpoint, None, headers)
-        req.get_method = lambda: 'DELETE'
-        try:
-            urllib2.urlopen(req)
-        except (urllib2.HTTPError, urllib2.URLError) as e:
-            print('Unable to revoke token for %s: %s' % (url.username, e))
-
 
 def main():
     parser = OptionParser("%prog [options] NAME")
@@ -204,9 +186,6 @@
     except Exception:
         traceback.print_exc()
         return RETCODE_FAILURE_BUILD
-    finally:
-        if options.revocation_endpoint is not None:
-            builder.revoke_token()
     return RETCODE_SUCCESS
 
 

=== modified file 'debian/changelog'
--- debian/changelog	2017-02-10 14:55:42 +0000
+++ debian/changelog	2017-04-13 17:26:26 +0000
@@ -1,3 +1,12 @@
+launchpad-buildd (143) UNRELEASED; urgency=medium
+
+  * 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.
+
+ -- Colin Watson <cjwatson@xxxxxxxxxx>  Thu, 13 Apr 2017 18:02:20 +0100
+
 launchpad-buildd (142) trusty; urgency=medium
 
   * lpbuildd.binarypackage: Pass DEB_BUILD_OPTIONS=noautodbgsym if we have

=== modified file 'debian/postinst'
--- debian/postinst	2015-11-25 15:00:43 +0000
+++ debian/postinst	2017-04-13 17:26:26 +0000
@@ -14,7 +14,7 @@
 
 make_buildd()
 {
- /usr/share/launchpad-buildd/buildd-genconfig --name=default --host=0.0.0.0 --port=8221 > \
+ /usr/share/launchpad-buildd/buildd-genconfig --name=default --host=0.0.0.0 --port=8221 --snap-proxy-port=8222 > \
   /etc/launchpad-buildd/default
  echo Default buildd created.
 }

=== modified file 'debian/upgrade-config'
--- debian/upgrade-config	2016-12-09 18:05:21 +0000
+++ debian/upgrade-config	2017-04-13 17:26:26 +0000
@@ -197,6 +197,17 @@
     in_file.close()
     out_file.close()
 
+def upgrade_to_143():
+    print("Upgrading %s to version 143" % conf_file)
+    os.rename(conf_file, conf_file + "-prev143~")
+
+    with open(conf_file + "-prev143~", "r") as in_file:
+        with open(conf_file, "w") as out_file:
+            out_file.write(in_file.read())
+            out_file.write(
+                "\n[snapmanager]\n"
+                "proxyport = 8222\n")
+
 if __name__ == "__main__":
     old_version = re.sub(r'[~-].*', '', old_version)
     if apt_pkg.version_compare(old_version, "12") < 0:
@@ -223,3 +234,5 @@
         upgrade_to_126()
     if apt_pkg.version_compare(old_version, "127") < 0:
         upgrade_to_127()
+    if apt_pkg.version_compare(old_version, "143") < 0:
+        upgrade_to_143()

=== modified file 'lpbuildd/snap.py'
--- lpbuildd/snap.py	2016-08-30 12:47:03 +0000
+++ lpbuildd/snap.py	2017-04-13 17:26:26 +0000
@@ -3,8 +3,38 @@
 
 __metaclass__ = type
 
+import base64
+import io
 import os
 import shutil
+try:
+    from urllib.error import (
+        HTTPError,
+        URLError,
+        )
+    from urllib.parse import urlparse
+    from urllib.request import (
+        Request,
+        urlopen,
+        )
+except ImportError:
+    from urllib2 import (
+        HTTPError,
+        Request,
+        URLError,
+        urlopen,
+        )
+    from urlparse import urlparse
+
+from twisted.application import strports
+from twisted.internet import reactor
+from twisted.internet.interfaces import IHalfCloseableProtocol
+from twisted.python.compat import intToBytes
+from twisted.web import (
+    http,
+    proxy,
+    )
+from zope.interface import implementer
 
 from lpbuildd.debian import (
     DebianBuildManager,
@@ -18,6 +48,208 @@
 RETCODE_FAILURE_BUILD = 201
 
 
+class SnapProxyClient(proxy.ProxyClient):
+
+    def __init__(self, command, rest, version, headers, data, father):
+        proxy.ProxyClient.__init__(
+            self, command, rest, version, headers, data, father)
+        # Why doesn't ProxyClient at least store this?
+        self.version = version
+        # We must avoid calling self.father.finish in the event that its
+        # connection was already lost, i.e. if the original client
+        # disconnects first (which is particularly likely in the case of
+        # CONNECT).
+        d = self.father.notifyFinish()
+        d.addBoth(self.requestFinished)
+
+    def connectionMade(self):
+        proxy.ProxyClient.connectionMade(self)
+        self.father.setChildClient(self)
+
+    def sendCommand(self, command, path):
+        # For some reason, HTTPClient.sendCommand doesn't preserve the
+        # protocol version.
+        self.transport.writeSequence(
+            [command, b' ', path, b' ', self.version, b'\r\n'])
+
+    def handleEndHeaders(self):
+        self.father.handleEndHeaders()
+
+    def sendData(self, data):
+        self.transport.write(data)
+
+    def endData(self):
+        if self.transport is not None:
+            self.transport.loseWriteConnection()
+
+    def requestFinished(self, result):
+        self._finished = True
+        self.transport.loseConnection()
+
+
+class SnapProxyClientFactory(proxy.ProxyClientFactory):
+
+    protocol = SnapProxyClient
+
+
+class SnapProxyRequest(http.Request):
+
+    child_client = None
+    _request_buffer = None
+    _request_data_done = False
+
+    def setChildClient(self, child_client):
+        self.child_client = child_client
+        if self._request_buffer is not None:
+            self.child_client.sendData(self._request_buffer.getvalue())
+            self._request_buffer = None
+        if self._request_data_done:
+            self.child_client.endData()
+
+    def allHeadersReceived(self, command, path, version):
+        # Normally done in `requestReceived`, but we disable that since it
+        # does other things we don't want.
+        self.method, self.uri, self.clientproto = command, path, version
+        self.client = self.channel.transport.getPeer()
+        self.host = self.channel.transport.getHost()
+
+        remote_parsed = urlparse(self.channel.factory.remote_url)
+        request_parsed = urlparse(path)
+        headers = self.getAllHeaders().copy()
+        if b"host" not in headers and request_parsed.netloc:
+            headers[b"host"] = request_parsed.netloc
+        if remote_parsed.username:
+            auth = (remote_parsed.username + ":" +
+                    remote_parsed.password).encode("ASCII")
+            authHeader = b"Basic " + base64.b64encode(auth)
+            headers[b"proxy-authorization"] = authHeader
+        self.client_factory = SnapProxyClientFactory(
+            command, path, version, headers, b"", self)
+        reactor.connectTCP(
+            remote_parsed.hostname, remote_parsed.port, self.client_factory)
+
+    def requestReceived(self, command, path, version):
+        # We do most of our work in `allHeadersReceived` instead.
+        pass
+
+    def rawDataReceived(self, data):
+        if self.child_client is not None:
+            if not self._request_data_done:
+                self.child_client.sendData(data)
+        else:
+            if self._request_buffer is None:
+                self._request_buffer = io.BytesIO()
+            self._request_buffer.write(data)
+
+    def handleEndHeaders(self):
+        # Cut-down version of Request.write.  We must avoid switching to
+        # chunked encoding for the sake of CONNECT; since our actual
+        # response data comes from another proxy, we can cut some corners.
+        if self.startedWriting:
+            return
+        self.startedWriting = 1
+        l = []
+        l.append(
+            self.clientproto + b" " + intToBytes(self.code) + b" " +
+            self.code_message + b"\r\n")
+        for name, values in self.responseHeaders.getAllRawHeaders():
+            for value in values:
+                l.extend([name, b": ", value, b"\r\n"])
+        l.append(b"\r\n")
+        self.transport.writeSequence(l)
+
+    def write(self, data):
+        if self.channel is not None:
+            self.channel.resetTimeout()
+        http.Request.write(self, data)
+
+    def endData(self):
+        if self.child_client is not None:
+            self.child_client.endData()
+        self._request_data_done = True
+
+
+@implementer(IHalfCloseableProtocol)
+class SnapProxy(http.HTTPChannel):
+    """A channel that streams request data.
+
+    The stock HTTPChannel isn't quite suitable for our needs, because it
+    expects to read the entire request data before passing control to the
+    request.  This doesn't work well for CONNECT.
+    """
+
+    requestFactory = SnapProxyRequest
+
+    def checkPersistence(self, request, version):
+        # ProxyClient.__init__ forces "Connection: close".
+        return False
+        if self._command == b"CONNECT":
+            return False
+        else:
+            return http.HTTPChannel.checkPersistence(self, request, version)
+
+    def allHeadersReceived(self):
+        http.HTTPChannel.allHeadersReceived(self)
+        self.requests[-1].allHeadersReceived(
+            self._command, self._path, self._version)
+        if self._command == b"CONNECT":
+            # This is a lie, but we don't want HTTPChannel to decide that
+            # the request is finished just because a CONNECT request
+            # (naturally) has no Content-Length.
+            self.length = -1
+
+    def rawDataReceived(self, data):
+        self.resetTimeout()
+        if self.requests:
+            self.requests[-1].rawDataReceived(data)
+
+    def readConnectionLost(self):
+        for request in self.requests:
+            request.endData()
+
+    def writeConnectionLost(self):
+        pass
+
+
+class SnapProxyFactory(http.HTTPFactory):
+
+    protocol = SnapProxy
+
+    def __init__(self, manager, remote_url, *args, **kwargs):
+        http.HTTPFactory.__init__(self, *args, **kwargs)
+        self.manager = manager
+        self.remote_url = remote_url
+        # Hack for compatibility with the old version of Twisted that
+        # Launchpad currently uses, for the benefit of tests.
+        try:
+            from twisted.web.http import _escape
+            self._log_escape = _escape
+        except ImportError:
+            self._log_escape = self._escape
+
+    def log(self, request):
+        # Log requests to the build log rather than to Twisted.
+        # Reimplement log formatting partly to make it easier to stay
+        # compatible with the old version of Twisted that Launchpad
+        # currently uses, and partly because there's no point logging the IP
+        # here.
+        referrer = self._log_escape(request.getHeader(b"referer") or b"-")
+        agent = self._log_escape(request.getHeader(b"user-agent") or b"-")
+        line = (
+            u'%(timestamp)s "%(method)s %(uri)s %(protocol)s" '
+            u'%(code)d %(length)s "%(referrer)s" "%(agent)s"\n' % {
+                'timestamp': self._logDateTime,
+                'method': self._log_escape(request.method),
+                'uri': self._log_escape(request.uri),
+                'protocol': self._log_escape(request.clientproto),
+                'code': request.code,
+                'length': request.sentLength or "-",
+                'referrer': referrer,
+                'agent': agent,
+                })
+        self.manager._slave.log(line.encode("UTF-8"))
+
+
 class SnapBuildState(DebianBuildState):
     BUILD_SNAP = "BUILD_SNAP"
 
@@ -48,9 +280,45 @@
         self.git_path = extra_args.get("git_path")
         self.proxy_url = extra_args.get("proxy_url")
         self.revocation_endpoint = extra_args.get("revocation_endpoint")
+        self.proxy_service = None
 
         super(SnapBuildManager, self).initiate(files, chroot, extra_args)
 
+    def startProxy(self):
+        """Start the local snap proxy, if necessary."""
+        if not self.proxy_url:
+            return []
+        proxy_port = self._slave._config.get("snapmanager", "proxyport")
+        proxy_factory = SnapProxyFactory(self, self.proxy_url, timeout=60)
+        self.proxy_service = strports.service(proxy_port, proxy_factory)
+        self.proxy_service.setServiceParent(self._slave.service)
+        return ["--proxy-url", "http://localhost:{}/".format(proxy_port)]
+
+    def stopProxy(self):
+        """Stop the local snap proxy, if necessary."""
+        if self.proxy_service is None:
+            return
+        self.proxy_service.disownServiceParent()
+        self.proxy_service = None
+
+    def revokeProxyToken(self):
+        """Revoke builder proxy token."""
+        if not self.revocation_endpoint:
+            return
+        self._slave.log("Revoking proxy token...\n")
+        url = urlparse(self.proxy_url)
+        auth = "{}:{}".format(url.username, url.password)
+        headers = {
+            "Authorization": "Basic {}".format(base64.b64encode(auth))
+            }
+        req = Request(self.revocation_endpoint, None, headers)
+        req.get_method = lambda: "DELETE"
+        try:
+            urlopen(req)
+        except (HTTPError, URLError) as e:
+            self._slave.log(
+                "Unable to revoke token for %s: %s" % (url.username, e))
+
     def doRunBuild(self):
         """Run the process to build the snap."""
         args = [
@@ -58,8 +326,7 @@
             "--build-id", self._buildid,
             "--arch", self.arch_tag,
             ]
-        if self.proxy_url:
-            args.extend(["--proxy-url", self.proxy_url])
+        args.extend(self.startProxy())
         if self.revocation_endpoint:
             args.extend(["--revocation-endpoint", self.revocation_endpoint])
         if self.branch is not None:
@@ -73,6 +340,8 @@
 
     def iterate_BUILD_SNAP(self, retcode):
         """Finished building the snap."""
+        self.stopProxy()
+        self.revokeProxyToken()
         if retcode == RETCODE_SUCCESS:
             self.gatherResults()
             print("Returning build status: OK")

=== modified file 'lpbuildd/tests/test_snap.py'
--- lpbuildd/tests/test_snap.py	2016-08-30 12:47:03 +0000
+++ lpbuildd/tests/test_snap.py	2017-04-13 17:26:26 +0000
@@ -8,10 +8,25 @@
 import tempfile
 
 from testtools import TestCase
+from testtools.content import text_content
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet import (
+    defer,
+    reactor,
+    utils,
+    )
+from twisted.web import (
+    http,
+    proxy,
+    resource,
+    server,
+    static,
+    )
 
 from lpbuildd.snap import (
     SnapBuildManager,
     SnapBuildState,
+    SnapProxyFactory,
     )
 from lpbuildd.tests.fakeslave import FakeSlave
 
@@ -32,6 +47,9 @@
 
 class TestSnapBuildManagerIteration(TestCase):
     """Run SnapBuildManager through its iteration steps."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
+
     def setUp(self):
         super(TestSnapBuildManagerIteration, self).setUp()
         self.working_dir = tempfile.mkdtemp()
@@ -163,3 +181,64 @@
         self.assertEqual(
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled("buildFail"))
+
+    def getListenerURL(self, listener):
+        port = listener.getHost().port
+        return b"http://localhost:%d/"; % port
+
+    def startFakeRemoteEndpoint(self):
+        remote_endpoint = resource.Resource()
+        remote_endpoint.putChild("a", static.Data("a" * 1024, "text/plain"))
+        remote_endpoint.putChild("b", static.Data("b" * 65536, "text/plain"))
+        remote_endpoint_listener = reactor.listenTCP(
+            0, server.Site(remote_endpoint))
+        self.addCleanup(remote_endpoint_listener.stopListening)
+        return remote_endpoint_listener
+
+    def startFakeRemoteProxy(self):
+        remote_proxy_factory = http.HTTPFactory()
+        remote_proxy_factory.protocol = proxy.Proxy
+        remote_proxy_listener = reactor.listenTCP(0, remote_proxy_factory)
+        self.addCleanup(remote_proxy_listener.stopListening)
+        return remote_proxy_listener
+
+    def startLocalProxy(self, remote_url):
+        proxy_factory = SnapProxyFactory(
+            self.buildmanager, remote_url, timeout=60)
+        proxy_listener = reactor.listenTCP(0, proxy_factory)
+        self.addCleanup(proxy_listener.stopListening)
+        return proxy_listener
+
+    @defer.inlineCallbacks
+    def assertCommandSuccess(self, command, extra_env=None):
+        env = os.environ
+        if extra_env is not None:
+            env.update(extra_env)
+        out, err, code = yield utils.getProcessOutputAndValue(
+            command[0], command[1:], env=env, path=".")
+        if code != 0:
+            self.addDetail("stdout", text_content(out))
+            self.addDetail("stderr", text_content(err))
+            self.assertEqual(0, code)
+        defer.returnValue(out)
+
+    @defer.inlineCallbacks
+    def test_fetch_via_proxy(self):
+        remote_endpoint_listener = self.startFakeRemoteEndpoint()
+        remote_endpoint_url = self.getListenerURL(remote_endpoint_listener)
+        remote_proxy_listener = self.startFakeRemoteProxy()
+        proxy_listener = self.startLocalProxy(
+            self.getListenerURL(remote_proxy_listener))
+        out = yield self.assertCommandSuccess(
+            [b"curl", remote_endpoint_url + b"a"],
+            extra_env={b"http_proxy": self.getListenerURL(proxy_listener)})
+        self.assertEqual("a" * 1024, out)
+        out = yield self.assertCommandSuccess(
+            [b"curl", remote_endpoint_url + b"b"],
+            extra_env={b"http_proxy": self.getListenerURL(proxy_listener)})
+        self.assertEqual("b" * 65536, out)
+
+    # XXX cjwatson 2017-04-13: We should really test the HTTPS case as well,
+    # but it's hard to see how to test that in a way that's independent of
+    # the code under test since the stock twisted.web.proxy doesn't support
+    # CONNECT.

=== modified file 'template-buildd-slave.conf'
--- template-buildd-slave.conf	2015-05-11 06:09:19 +0000
+++ template-buildd-slave.conf	2017-04-13 17:26:26 +0000
@@ -12,3 +12,6 @@
 
 [translationtemplatesmanager]
 resultarchive = translation-templates.tar.gz
+
+[snapmanager]
+proxyport = @SNAPPROXYPORT@


Follow ups