← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pelpsi/launchpad:fetch-service-session-API into launchpad:master

 

Simone Pelosi has proposed merging ~pelpsi/launchpad:fetch-service-session-API into launchpad:master with ~ines-almeida/launchpad:fetch-service-option-model-update as a prerequisite.

Commit message:
Add fetch service session API
    
Update BuilderProxyMixin class to switch between
Fetch Service and classic Builder Proxy depending on 
use_fetch_service flag.
Add test suite for Fetch Service and fetchservice.py mock class
for API.
Add Fetch Service API information on schema-lazr.conf.


Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pelpsi/launchpad/+git/launchpad/+merge/461721
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pelpsi/launchpad:fetch-service-session-API into launchpad:master.
diff --git a/lib/lp/buildmaster/builderproxy.py b/lib/lp/buildmaster/builderproxy.py
index be65e29..4f249a7 100644
--- a/lib/lp/buildmaster/builderproxy.py
+++ b/lib/lp/buildmaster/builderproxy.py
@@ -20,7 +20,10 @@ from typing import Dict, Generator
 
 from twisted.internet import defer
 
-from lp.buildmaster.downloader import RequestProxyTokenCommand
+from lp.buildmaster.downloader import (
+    RequestFetchServiceSessionCommand,
+    RequestProxyTokenCommand,
+)
 from lp.buildmaster.interfaces.builder import CannotBuild
 from lp.buildmaster.interfaces.buildfarmjobbehaviour import BuildArgs
 from lp.services.config import config
@@ -36,9 +39,16 @@ class BuilderProxyMixin:
 
     @defer.inlineCallbacks
     def addProxyArgs(
-        self, args: BuildArgs, allow_internet: bool = True
+        self,
+        args: BuildArgs,
+        allow_internet: bool = True,
+        fetch_service: bool = False,
     ) -> Generator[None, Dict[str, str], None]:
-        if _get_proxy_config("builder_proxy_host") and allow_internet:
+        if (
+            not fetch_service
+            and _get_proxy_config("builder_proxy_host")
+            and allow_internet
+        ):
             token = yield self._requestProxyToken()
             args["proxy_url"] = (
                 "http://{username}:{password}@{host}:{port}".format(
@@ -52,6 +62,25 @@ class BuilderProxyMixin:
                 endpoint=_get_proxy_config("builder_proxy_auth_api_endpoint"),
                 token=token["username"],
             )
+        if (
+            fetch_service
+            and _get_proxy_config("fetch_service_host")
+            and allow_internet
+        ):
+            # http://<session-id>:<secret>@<addr>:9988/
+            token = yield self._requestFetchServiceSession()
+            args["proxy_url"] = (
+                "http://{session_id}:{token}@{host}:{port}".format(
+                    session_id=token["id"],
+                    token=token["token"],
+                    host=_get_proxy_config("fetch_service_host"),
+                    port=_get_proxy_config("fetch_service_port"),
+                )
+            )
+            args["revocation_endpoint"] = "{endpoint}/session/{id}".format(
+                endpoint=_get_proxy_config("fetch_service_auth_api_endpoint"),
+                id=token["id"],
+            )
 
     @defer.inlineCallbacks
     def _requestProxyToken(self):
@@ -86,3 +115,39 @@ class BuilderProxyMixin:
             proxy_username=proxy_username,
         )
         return token
+
+    @defer.inlineCallbacks
+    def _requestFetchServiceSession(self):
+        # This is a different function if we have the needs to
+        # differentiate more the behavior.
+        admin_username = _get_proxy_config(
+            "fetch_service_auth_api_admin_username"
+        )
+        if not admin_username:
+            raise CannotBuild(
+                "fetch_service_auth_api_admin_username is not configured."
+            )
+        secret = _get_proxy_config("fetch_service_auth_api_admin_secret")
+        if not secret:
+            raise CannotBuild(
+                "fetch_service_auth_api_admin_secret is not configured."
+            )
+        url = _get_proxy_config("fetch_service_auth_api_endpoint")
+        if not secret:
+            raise CannotBuild(
+                "fetch_service_auth_api_endpoint is not configured."
+            )
+        timestamp = int(time.time())
+        proxy_username = "{build_id}-{timestamp}".format(
+            build_id=self.build.build_cookie, timestamp=timestamp
+        )
+        auth_string = f"{admin_username}:{secret}".strip()
+        auth_header = b"Basic " + base64.b64encode(auth_string.encode("ASCII"))
+
+        token = yield self._worker.process_pool.doWork(
+            RequestFetchServiceSessionCommand,
+            url=url,
+            auth_header=auth_header,
+            proxy_username=proxy_username,
+        )
+        return token
diff --git a/lib/lp/buildmaster/downloader.py b/lib/lp/buildmaster/downloader.py
index fc16852..cee308a 100644
--- a/lib/lp/buildmaster/downloader.py
+++ b/lib/lp/buildmaster/downloader.py
@@ -9,6 +9,7 @@ anything from the rest of Launchpad.
 
 __all__ = [
     "DownloadCommand",
+    "RequestFetchServiceSessionCommand",
     "RequestProcess",
     "RequestProxyTokenCommand",
 ]
@@ -37,6 +38,21 @@ class DownloadCommand(amp.Command):
     }
 
 
+class RequestFetchServiceSessionCommand(amp.Command):
+    arguments = [
+        (b"url", amp.Unicode()),
+        (b"auth_header", amp.String()),
+        (b"proxy_username", amp.Unicode()),
+    ]
+    response = [
+        (b"id", amp.Unicode()),
+        (b"token", amp.Unicode()),
+    ]
+    errors = {
+        RequestException: b"REQUEST_ERROR",
+    }
+
+
 class RequestProxyTokenCommand(amp.Command):
     arguments = [
         (b"url", amp.Unicode()),
@@ -94,3 +110,24 @@ class RequestProcess(AMPChild):
             )
             response.raise_for_status()
             return response.json()
+
+    @RequestFetchServiceSessionCommand.responder
+    def requestFetchServiceSessionCommand(
+        self, url, auth_header, proxy_username
+    ):
+        with Session() as session:
+            session.trust_env = False
+            # XXX pelpsi 2024-02-28: should take the same
+            # json body? From ST108 we should provide
+            # {
+            #   "timeout": <int>,			// session timeout in seconds
+            #   "policy": <string>			// "strict" or "permissive"
+            # }
+            response = session.post(
+                url,
+                headers={"Authorization": auth_header},
+                json={"username": proxy_username},
+            )
+            print(" CI SIAMO???? ")
+            response.raise_for_status()
+            return response.json()
diff --git a/lib/lp/buildmaster/tests/fetchservice.py b/lib/lp/buildmaster/tests/fetchservice.py
new file mode 100644
index 0000000..9a12933
--- /dev/null
+++ b/lib/lp/buildmaster/tests/fetchservice.py
@@ -0,0 +1,119 @@
+# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Fixtures for dealing with the build time HTTP proxy."""
+
+import json
+import uuid
+from textwrap import dedent
+from urllib.parse import urlsplit
+
+import fixtures
+from testtools.matchers import Equals, HasLength, MatchesStructure
+from twisted.internet import defer, endpoints, reactor
+from twisted.python.compat import nativeString
+from twisted.web import resource, server
+
+from lp.services.config import config
+
+
+class FetchServiceAuthAPITokensResource(resource.Resource):
+    """A test tokens resource for the proxy authentication API."""
+
+    isLeaf = True
+
+    def __init__(self):
+        resource.Resource.__init__(self)
+        self.requests = []
+
+    def render_POST(self, request):
+        content = json.loads(request.content.read().decode("UTF-8"))
+        self.requests.append(
+            {
+                "method": request.method,
+                "uri": request.uri,
+                "headers": dict(request.requestHeaders.getAllRawHeaders()),
+                "json": content,
+            }
+        )
+        return json.dumps(
+            {
+                "id": "1",
+                "token": uuid.uuid4().hex,
+            }
+        ).encode("UTF-8")
+
+
+class InProcessFetchServiceAuthAPIFixture(fixtures.Fixture):
+    """A fixture that pretends to be the proxy authentication API.
+
+    Users of this fixture must call the `start` method, which returns a
+    `Deferred`, and arrange for that to get back to the reactor.  This is
+    necessary because the basic fixture API does not allow `setUp` to return
+    anything.  For example:
+
+        class TestSomething(TestCase):
+
+            run_tests_with = AsynchronousDeferredRunTest.make_factory(
+                timeout=30)
+
+            @defer.inlineCallbacks
+            def setUp(self):
+                super().setUp()
+                yield self.useFixture(
+                    InProcessFetchServiceAuthAPIFixture()
+                ).start()
+    """
+
+    @defer.inlineCallbacks
+    def start(self):
+        root = resource.Resource()
+        self.tokens = FetchServiceAuthAPITokensResource()
+        root.putChild(b"tokens", self.tokens)
+        endpoint = endpoints.serverFromString(reactor, nativeString("tcp:0"))
+        site = server.Site(self.tokens)
+        self.addCleanup(site.stopFactory)
+        port = yield endpoint.listen(site)
+        self.addCleanup(port.stopListening)
+        config.push(
+            "in-process-fetch-service-api-fixture",
+            dedent(
+                """
+                [builddmaster]
+                fetch_service_auth_api_admin_secret: admin-secret
+                fetch_service_auth_api_admin_username: admin-launchpad.test
+                fetch_service_auth_api_endpoint: http://{host}:{port}/tokens
+                fetch_service_host: {host}
+                fetch_service_port: {port}
+                """
+            ).format(host=port.getHost().host, port=port.getHost().port),
+        )
+        self.addCleanup(config.pop, "in-process-fetch-service-api-fixture")
+
+
+class FetchServiceURLMatcher(MatchesStructure):
+    """Check that a string is a valid url for a builder proxy."""
+
+    def __init__(self):
+        super().__init__(
+            scheme=Equals("http"),
+            id=Equals(1),
+            token=HasLength(32),
+            hostname=Equals(config.builddmaster.fetch_service_host),
+            port=Equals(config.builddmaster.fetch_service_port),
+            path=Equals(""),
+        )
+
+    def match(self, matchee):
+        super().match(urlsplit(matchee))
+
+
+class RevocationEndpointMatcher(Equals):
+    """Check that a string is a valid endpoint for proxy token revocation."""
+
+    def __init__(self, session_id):
+        super().__init__(
+            "{}/session/{}".format(
+                config.builddmaster.fetch_service_auth_api_endpoint, session_id
+            )
+        )
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index ac68bd4..9e2a463 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -167,6 +167,23 @@ builder_proxy_host: none
 # Builder http proxy port
 builder_proxy_port: none
 
+# Admin secret for requesting sessions from the fetch service.
+# For a mojo deployed proxy service, the secret is generated and will reside in
+# /srv/mojo/${MOJO_PROJECT}/${SERIES}/${MOJO_WORKSPACE}/local/admin-api-secret
+fetch_service_auth_api_admin_secret: none
+
+# Admin username for fetch service.
+fetch_service_auth_api_admin_username: none
+
+# Endpoint for fetch service authentication service
+fetch_service_auth_api_endpoint: none
+
+# Fetch service http proxy host
+fetch_service_host: none
+
+# Fetch service http proxy port
+fetch_service_port: none
+
 [canonical]
 # datatype: boolean
 show_tracebacks: False
@@ -1854,6 +1871,23 @@ builder_proxy_host: none
 # Deprecated in favour of the same key in the builddmaster section.
 builder_proxy_port: none
 
+# Admin secret for requesting sessions from the fetch service.
+# For a mojo deployed proxy service, the secret is generated and will reside in
+# /srv/mojo/${MOJO_PROJECT}/${SERIES}/${MOJO_WORKSPACE}/local/admin-api-secret
+fetch_service_auth_api_admin_secret: none
+
+# Admin username for fetch service.
+fetch_service_auth_api_admin_username: none
+
+# Endpoint for fetch service authentication service
+fetch_service_auth_api_endpoint: none
+
+# Fetch service http proxy host
+fetch_service_host: none
+
+# Fetch service http proxy port
+fetch_service_port: none
+
 # Optional sources.list entry to send to build workers when doing snap
 # package builds.  Use this form so that the series is set:
 # deb http://foo %(series)s main
diff --git a/lib/lp/snappy/model/snapbuildbehaviour.py b/lib/lp/snappy/model/snapbuildbehaviour.py
index 66ae7cc..58fdd5f 100644
--- a/lib/lp/snappy/model/snapbuildbehaviour.py
+++ b/lib/lp/snappy/model/snapbuildbehaviour.py
@@ -116,7 +116,9 @@ class SnapBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
         """
         build: ISnapBuild = self.build
         args: BuildArgs = yield super().extraBuildArgs(logger=logger)
-        yield self.addProxyArgs(args, build.snap.allow_internet)
+        yield self.addProxyArgs(
+            args, build.snap.allow_internet, build.snap.use_fetch_service
+        )
         args["name"] = build.snap.store_name or build.snap.name
         channels = build.channels or {}
         if "snapcraft" not in channels:
diff --git a/lib/lp/snappy/tests/test_snapbuildbehaviour.py b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
index 5ec5804..45ee8c8 100644
--- a/lib/lp/snappy/tests/test_snapbuildbehaviour.py
+++ b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
@@ -51,6 +51,9 @@ from lp.buildmaster.tests.builderproxy import (
     ProxyURLMatcher,
     RevocationEndpointMatcher,
 )
+from lp.buildmaster.tests.fetchservice import (
+    InProcessFetchServiceAuthAPIFixture,
+)
 from lp.buildmaster.tests.mock_workers import (
     MockBuilder,
     OkWorker,
@@ -72,6 +75,7 @@ from lp.services.statsd.tests import StatsMixin
 from lp.services.webapp import canonical_url
 from lp.snappy.interfaces.snap import (
     SNAP_SNAPCRAFT_CHANNEL_FEATURE_FLAG,
+    SNAP_USE_FETCH_SERVICE_FEATURE_FLAG,
     SnapBuildArchiveOwnerMismatch,
 )
 from lp.snappy.model.snapbuildbehaviour import (
@@ -259,6 +263,23 @@ class TestAsyncSnapBuildBehaviour(StatsMixin, TestSnapBuildBehaviourBase):
             )
         )
         self.proxy_api = self.useFixture(InProcessProxyAuthAPIFixture())
+        self.session = {
+            "id": "1",
+            "token": uuid.uuid4().hex,
+        }
+        self.fetch_service_url = (
+            "http://{session_id}:{token}@{host}:{port}".format(
+                session_id=self.session["id"],
+                token=self.session["token"],
+                host=config.builddmaster.fetch_service_host,
+                port=config.builddmaster.fetch_service_port,
+            )
+        )
+        self.fetch_service_api = self.useFixture(
+            InProcessFetchServiceAuthAPIFixture()
+        )
+        yield self.fetch_service_api.start()
+        self.proxy_api = self.useFixture(InProcessProxyAuthAPIFixture())
         yield self.proxy_api.start()
         self.now = time.time()
         self.useFixture(fixtures.MockPatch("time.time", return_value=self.now))
@@ -296,6 +317,85 @@ class TestAsyncSnapBuildBehaviour(StatsMixin, TestSnapBuildBehaviourBase):
         )
 
     @defer.inlineCallbacks
+    def test_requestFetchServiceSession_unconfigured(self):
+        self.pushConfig("builddmaster", fetch_service_host=None)
+        self.useFixture(
+            FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: "on"})
+        )
+        snap = self.factory.makeSnap(use_fetch_service=True)
+        request = self.factory.makeSnapBuildRequest(snap=snap)
+        job = self.makeJob(snap=snap, build_request=request)
+        with dbuser(config.builddmaster.dbuser):
+            args = yield job.extraBuildArgs()
+        self.assertEqual([], self.fetch_service_api.tokens.requests)
+        self.assertNotIn("proxy_url", args)
+        self.assertNotIn("revocation_endpoint", args)
+
+    @defer.inlineCallbacks
+    def test_requestFetchServiceSession_no_secret(self):
+        self.pushConfig(
+            "builddmaster", fetch_service_auth_api_admin_secret=None
+        )
+        self.useFixture(
+            FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: "on"})
+        )
+        snap = self.factory.makeSnap(use_fetch_service=True)
+        request = self.factory.makeSnapBuildRequest(snap=snap)
+        job = self.makeJob(snap=snap, build_request=request)
+        expected_exception_msg = (
+            "fetch_service_auth_api_admin_secret is not configured."
+        )
+        with ExpectedException(CannotBuild, expected_exception_msg):
+            yield job.extraBuildArgs()
+
+    @defer.inlineCallbacks
+    def test_requestFetchServiceSession(self):
+        self.useFixture(
+            FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: "on"})
+        )
+        snap = self.factory.makeSnap(use_fetch_service=True)
+        request = self.factory.makeSnapBuildRequest(snap=snap)
+        job = self.makeJob(snap=snap, build_request=request)
+        yield job.extraBuildArgs()
+        expected_uri = urlsplit(
+            config.builddmaster.fetch_service_auth_api_endpoint
+        ).path.encode("UTF-8")
+        request_matcher = MatchesDict(
+            {
+                "method": Equals(b"POST"),
+                "uri": Equals(expected_uri),
+                "headers": ContainsDict(
+                    {
+                        b"Authorization": MatchesListwise(
+                            [
+                                Equals(
+                                    b"Basic "
+                                    + base64.b64encode(
+                                        b"admin-launchpad.test:admin-secret"
+                                    )
+                                )
+                            ]
+                        ),
+                        b"Content-Type": MatchesListwise(
+                            [
+                                Equals(b"application/json"),
+                            ]
+                        ),
+                    }
+                ),
+                "json": MatchesDict(
+                    {
+                        "username": StartsWith(job.build.build_cookie + "-"),
+                    }
+                ),
+            }
+        )
+        self.assertThat(
+            self.fetch_service_api.tokens.requests,
+            MatchesListwise([request_matcher]),
+        )
+
+    @defer.inlineCallbacks
     def test_requestProxyToken_unconfigured(self):
         self.pushConfig("builddmaster", builder_proxy_host=None)
         branch = self.factory.makeBranch()

Follow ups