← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad-buildd:snap-pull-build-internet into launchpad-buildd:master

 

Colin Watson has proposed merging ~cjwatson/launchpad-buildd:snap-pull-build-internet into launchpad-buildd:master.

Commit message:
snap: Add option to disable proxy after pull phase

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad-buildd/+git/launchpad-buildd/+merge/437027

We're still working on all the details, but this is a relatively simple way to let us lock down certain snap recipe builds a bit more.

I converted the existing revocation code to use `requests`, since that's been on my to-do list for a long time.  There's a minor testing gap because the version of `responses` in Ubuntu 20.04 doesn't allow us a way to inspect the `timeout` argument passed when making the request, but we'll be able to fix that once we upgrade builders to Ubuntu 22.04, which is in the works anyway.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad-buildd:snap-pull-build-internet into launchpad-buildd:master.
diff --git a/debian/changelog b/debian/changelog
index af71f9c..a5371dc 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,6 +1,8 @@
 launchpad-buildd (230) UNRELEASED; urgency=medium
 
   * Apply black and isort.
+  * Add an option to disable the proxy after the "pull" phase of a snap
+    recipe build.
 
  -- Colin Watson <cjwatson@xxxxxxxxxx>  Tue, 07 Feb 2023 20:02:04 +0000
 
diff --git a/lpbuildd/proxy.py b/lpbuildd/proxy.py
index 6b4ade2..ea33dda 100644
--- a/lpbuildd/proxy.py
+++ b/lpbuildd/proxy.py
@@ -3,9 +3,7 @@
 
 import base64
 import io
-from urllib.error import HTTPError, URLError
 from urllib.parse import urlparse
-from urllib.request import Request, urlopen
 
 from twisted.application import strports
 from twisted.internet import reactor
@@ -14,6 +12,8 @@ from twisted.python.compat import intToBytes
 from twisted.web import http, proxy
 from zope.interface import implementer
 
+from lpbuildd.util import RevokeProxyTokenError, revoke_proxy_token
+
 
 class BuilderProxyClient(proxy.ProxyClient):
     def __init__(self, command, rest, version, headers, data, father):
@@ -224,7 +224,7 @@ class BuildManagerProxyMixin:
             "tcp:%s" % proxy_port, proxy_factory
         )
         self.proxy_service.setServiceParent(self._builder.service)
-        if self.backend_name == "lxd":
+        if hasattr(self.backend, "ipv4_network"):
             proxy_host = self.backend.ipv4_network.ip
         else:
             proxy_host = "localhost"
@@ -242,15 +242,7 @@ class BuildManagerProxyMixin:
         if not self.revocation_endpoint:
             return
         self._builder.log("Revoking proxy token...\n")
-        url = urlparse(self.proxy_url)
-        auth = f"{url.username}:{url.password}"
-        encoded_auth = base64.b64encode(auth.encode()).decode()
-        headers = {"Authorization": f"Basic {encoded_auth}"}
-        req = Request(self.revocation_endpoint, None, headers)
-        req.get_method = lambda: "DELETE"
         try:
-            urlopen(req, timeout=15)
-        except (HTTPError, URLError) as e:
-            self._builder.log(
-                f"Unable to revoke token for {url.username}: {e}"
-            )
+            revoke_proxy_token(self.proxy_url, self.revocation_endpoint)
+        except RevokeProxyTokenError as e:
+            self._builder.log(f"{e}\n")
diff --git a/lpbuildd/snap.py b/lpbuildd/snap.py
index bc35997..d922242 100644
--- a/lpbuildd/snap.py
+++ b/lpbuildd/snap.py
@@ -46,6 +46,9 @@ class SnapBuildManager(BuildManagerProxyMixin, DebianBuildManager):
         self.private = extra_args.get("private", False)
         self.proxy_service = None
         self.target_architectures = extra_args.get("target_architectures")
+        self.disable_proxy_after_pull = extra_args.get(
+            "disable_proxy_after_pull"
+        )
 
         super().initiate(files, chroot, extra_args)
 
@@ -65,6 +68,18 @@ class SnapBuildManager(BuildManagerProxyMixin, DebianBuildManager):
         args.extend(self.startProxy())
         if self.revocation_endpoint:
             args.extend(["--revocation-endpoint", self.revocation_endpoint])
+        if (
+            self.disable_proxy_after_pull
+            and self.proxy_url
+            and self.revocation_endpoint
+        ):
+            args.extend(
+                [
+                    "--upstream-proxy-url",
+                    self.proxy_url,
+                    "--disable-proxy-after-pull",
+                ]
+            )
         if self.branch is not None:
             args.extend(["--branch", self.branch])
         if self.git_repository is not None:
diff --git a/lpbuildd/target/build_snap.py b/lpbuildd/target/build_snap.py
index 6e4484b..44dcc1c 100644
--- a/lpbuildd/target/build_snap.py
+++ b/lpbuildd/target/build_snap.py
@@ -12,6 +12,7 @@ from lpbuildd.target.operation import Operation
 from lpbuildd.target.proxy import BuilderProxyOperationMixin
 from lpbuildd.target.snapstore import SnapStoreOperationMixin
 from lpbuildd.target.vcs import VCSOperationMixin
+from lpbuildd.util import RevokeProxyTokenError, revoke_proxy_token
 
 RETCODE_FAILURE_INSTALL = 200
 RETCODE_FAILURE_BUILD = 201
@@ -93,6 +94,19 @@ class BuildSnap(
             action="append",
             help="build for the specified architectures",
         )
+        parser.add_argument(
+            "--upstream-proxy-url",
+            help=(
+                "URL of the builder proxy upstream of the one run internally "
+                "by launchpad-buildd"
+            ),
+        )
+        parser.add_argument(
+            "--disable-proxy-after-pull",
+            default=False,
+            action="store_true",
+            help="disable proxy access after the pull phase has finished",
+        )
         parser.add_argument("name", help="name of snap to build")
 
     def install_svn_servers(self):
@@ -212,6 +226,18 @@ class BuildSnap(
                 ],
                 cwd="/build",
             )
+        if (
+            self.args.disable_proxy_after_pull
+            and self.args.upstream_proxy_url
+            and self.args.revocation_endpoint
+        ):
+            logger.info("Revoking proxy token...")
+            try:
+                revoke_proxy_token(
+                    self.args.upstream_proxy_url, self.args.revocation_endpoint
+                )
+            except RevokeProxyTokenError as e:
+                logger.info(str(e))
 
     def build(self):
         """Run all build, stage and snap phases."""
diff --git a/lpbuildd/target/tests/test_build_snap.py b/lpbuildd/target/tests/test_build_snap.py
index 1eb4f05..95bf948 100644
--- a/lpbuildd/target/tests/test_build_snap.py
+++ b/lpbuildd/target/tests/test_build_snap.py
@@ -1,6 +1,7 @@
 # Copyright 2017-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+import base64
 import json
 import os.path
 import stat
@@ -545,6 +546,63 @@ class TestBuildSnap(TestCase):
             ),
         )
 
+    @responses.activate
+    def test_pull_disable_proxy_after_pull(self):
+        self.useFixture(FakeLogger())
+        responses.add("DELETE", "http://proxy-auth.example/tokens/1";)
+        args = [
+            "buildsnap",
+            "--backend=fake",
+            "--series=xenial",
+            "--arch=amd64",
+            "1",
+            "--build-url",
+            "https://launchpad.example/build";,
+            "--branch",
+            "lp:foo",
+            "--proxy-url",
+            "http://localhost:8222/";,
+            "--upstream-proxy-url",
+            "http://username:password@proxy.example:3128/";,
+            "--revocation-endpoint",
+            "http://proxy-auth.example/tokens/1";,
+            "--disable-proxy-after-pull",
+            "test-snap",
+        ]
+        build_snap = parse_args(args=args).operation
+        build_snap.pull()
+        env = {
+            "SNAPCRAFT_LOCAL_SOURCES": "1",
+            "SNAPCRAFT_SETUP_CORE": "1",
+            "SNAPCRAFT_BUILD_INFO": "1",
+            "SNAPCRAFT_IMAGE_INFO": (
+                '{"build_url": "https://launchpad.example/build"}'
+            ),
+            "SNAPCRAFT_BUILD_ENVIRONMENT": "host",
+            "http_proxy": "http://localhost:8222/";,
+            "https_proxy": "http://localhost:8222/";,
+            "GIT_PROXY_COMMAND": "/usr/local/bin/lpbuildd-git-proxy",
+            "SNAPPY_STORE_NO_CDN": "1",
+        }
+        self.assertThat(
+            build_snap.backend.run.calls,
+            MatchesListwise(
+                [
+                    RanBuildCommand(
+                        ["snapcraft", "pull"], cwd="/build/test-snap", **env
+                    ),
+                ]
+            ),
+        )
+        self.assertEqual(1, len(responses.calls))
+        request = responses.calls[0].request
+        auth = base64.b64encode(b"username:password").decode()
+        self.assertEqual(f"Basic {auth}", request.headers["Authorization"])
+        self.assertEqual("http://proxy-auth.example/tokens/1";, request.url)
+        # XXX cjwatson 2023-02-07: Ideally we'd check the timeout as well,
+        # but the version of responses in Ubuntu 20.04 doesn't store it
+        # anywhere we can get at it.
+
     def test_pull_build_source_tarball(self):
         args = [
             "buildsnap",
diff --git a/lpbuildd/tests/fakebuilder.py b/lpbuildd/tests/fakebuilder.py
index 9d70df6..15a820a 100644
--- a/lpbuildd/tests/fakebuilder.py
+++ b/lpbuildd/tests/fakebuilder.py
@@ -16,6 +16,8 @@ import subprocess
 from collections import defaultdict
 from configparser import NoOptionError, NoSectionError
 
+from twisted.application import service
+
 from lpbuildd.target.backend import Backend
 from lpbuildd.util import set_personality, shell_escape
 
@@ -99,6 +101,9 @@ class FakeBuilder:
         self._cachepath = tempdir
         self._config = FakeConfig()
         self.waitingfiles = {}
+        self.service = service.IServiceCollection(
+            service.Application("FakeBuilder")
+        )
         for fake_method in (
             "emptyLog",
             "log",
diff --git a/lpbuildd/tests/test_charm.py b/lpbuildd/tests/test_charm.py
index d90049c..18736dd 100644
--- a/lpbuildd/tests/test_charm.py
+++ b/lpbuildd/tests/test_charm.py
@@ -1,9 +1,10 @@
 # Copyright 2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+import base64
 import os
-from unittest import mock
 
+import responses
 from fixtures import EnvironmentVariable, TempDir
 from testtools import TestCase
 from testtools.deferredruntest import AsynchronousDeferredRunTest
@@ -243,17 +244,23 @@ class TestCharmBuildManagerIteration(TestCase):
         )
         self.assertFalse(self.builder.wasCalled("buildFail"))
 
-    @mock.patch("lpbuildd.proxy.urlopen")
-    def test_revokeProxyToken(self, urlopen_mock):
-        self.buildmanager.revocation_endpoint = "http://revoke_endpoint";
-        self.buildmanager.proxy_url = "http://username:password@proxy_url";
+    @responses.activate
+    def test_revokeProxyToken(self):
+        responses.add(
+            "DELETE", f"http://proxy-auth.example/tokens/{self.buildid}";
+        )
+        self.buildmanager.revocation_endpoint = (
+            f"http://proxy-auth.example/tokens/{self.buildid}";
+        )
+        self.buildmanager.proxy_url = "http://username:password@proxy.example";
         self.buildmanager.revokeProxyToken()
-        self.assertEqual(1, urlopen_mock.call_count)
-        args, kwargs = urlopen_mock.call_args
-        request = args[0]
+        self.assertEqual(1, len(responses.calls))
+        request = responses.calls[0].request
+        auth = base64.b64encode(b"username:password").decode()
+        self.assertEqual(f"Basic {auth}", request.headers["Authorization"])
         self.assertEqual(
-            {"Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ="},
-            request.headers,
+            f"http://proxy-auth.example/tokens/{self.buildid}";, request.url
         )
-        self.assertEqual("http://revoke_endpoint";, request.get_full_url())
-        self.assertEqual({"timeout": 15}, kwargs)
+        # XXX cjwatson 2023-02-07: Ideally we'd check the timeout as well,
+        # but the version of responses in Ubuntu 20.04 doesn't store it
+        # anywhere we can get at it.
diff --git a/lpbuildd/tests/test_snap.py b/lpbuildd/tests/test_snap.py
index 3a5de6b..0e44093 100644
--- a/lpbuildd/tests/test_snap.py
+++ b/lpbuildd/tests/test_snap.py
@@ -1,9 +1,10 @@
 # Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+import base64
 import os
-from unittest import mock
 
+import responses
 from fixtures import EnvironmentVariable, TempDir
 from testtools import TestCase
 from testtools.content import text_content
@@ -675,6 +676,36 @@ class TestSnapBuildManagerIteration(TestCase):
         ]
         yield self.startBuild(args, expected_options)
 
+    @defer.inlineCallbacks
+    def test_iterate_disable_proxy_after_pull(self):
+        self.builder._config.set("builder", "proxyport", "8222")
+        args = {
+            "disable_proxy_after_pull": True,
+            "git_repository": "https://git.launchpad.dev/~example/+git/snap";,
+            "git_path": "master",
+            "proxy_url": "http://username:password@proxy.example/";,
+            "revocation_endpoint": (
+                f"http://proxy-auth.example/tokens/{self.buildid}";
+            ),
+        }
+        expected_options = [
+            "--proxy-url",
+            "http://localhost:8222/";,
+            "--revocation-endpoint",
+            f"http://proxy-auth.example/tokens/{self.buildid}";,
+            "--upstream-proxy-url",
+            "http://username:password@proxy.example/";,
+            "--disable-proxy-after-pull",
+            "--git-repository",
+            "https://git.launchpad.dev/~example/+git/snap";,
+            "--git-path",
+            "master",
+        ]
+        try:
+            yield self.startBuild(args, expected_options)
+        finally:
+            self.buildmanager.stopProxy()
+
     def getListenerURL(self, listener):
         port = listener.getHost().port
         return "http://localhost:%d/"; % port
@@ -746,17 +777,23 @@ class TestSnapBuildManagerIteration(TestCase):
     # the code under test since the stock twisted.web.proxy doesn't support
     # CONNECT.
 
-    @mock.patch("lpbuildd.proxy.urlopen")
-    def test_revokeProxyToken(self, urlopen_mock):
-        self.buildmanager.revocation_endpoint = "http://revoke_endpoint";
-        self.buildmanager.proxy_url = "http://username:password@proxy_url";
+    @responses.activate
+    def test_revokeProxyToken(self):
+        responses.add(
+            "DELETE", f"http://proxy-auth.example/tokens/{self.buildid}";
+        )
+        self.buildmanager.revocation_endpoint = (
+            f"http://proxy-auth.example/tokens/{self.buildid}";
+        )
+        self.buildmanager.proxy_url = "http://username:password@proxy.example";
         self.buildmanager.revokeProxyToken()
-        self.assertEqual(1, urlopen_mock.call_count)
-        args, kwargs = urlopen_mock.call_args
-        request = args[0]
+        self.assertEqual(1, len(responses.calls))
+        request = responses.calls[0].request
+        auth = base64.b64encode(b"username:password").decode()
+        self.assertEqual(f"Basic {auth}", request.headers["Authorization"])
         self.assertEqual(
-            {"Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ="},
-            request.headers,
+            f"http://proxy-auth.example/tokens/{self.buildid}";, request.url
         )
-        self.assertEqual("http://revoke_endpoint";, request.get_full_url())
-        self.assertEqual({"timeout": 15}, kwargs)
+        # XXX cjwatson 2023-02-07: Ideally we'd check the timeout as well,
+        # but the version of responses in Ubuntu 20.04 doesn't store it
+        # anywhere we can get at it.
diff --git a/lpbuildd/util.py b/lpbuildd/util.py
index 3359e17..eb35f01 100644
--- a/lpbuildd/util.py
+++ b/lpbuildd/util.py
@@ -5,6 +5,9 @@ import os
 import subprocess
 import sys
 from shlex import quote
+from urllib.parse import urlparse
+
+import requests
 
 
 def shell_escape(s):
@@ -53,3 +56,27 @@ def set_personality(args, arch, series=None):
         setarch_cmd.append("--uname-2.6")
 
     return setarch_cmd + args
+
+
+class RevokeProxyTokenError(Exception):
+    def __init__(self, username, exception):
+        super().__init__(self)
+        self.username = username
+        self.exception = exception
+
+    def __str__(self):
+        return f"Unable to revoke token for {self.username}: {self.exception}"
+
+
+def revoke_proxy_token(proxy_url, revocation_endpoint):
+    """Revoke builder proxy token.
+
+    :raises RevokeProxyTokenError: if attempting to revoke the token failed.
+    """
+    url = urlparse(proxy_url)
+    try:
+        requests.delete(
+            revocation_endpoint, auth=(url.username, url.password), timeout=15
+        )
+    except requests.RequestException as e:
+        raise RevokeProxyTokenError(url.username, e)