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

Proposed by Simone Pelosi
Status: Merged
Approved by: Simone Pelosi
Approved revision: 8862e2cafd4efe19fea09311993f419bd1994594
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pelpsi/launchpad:fetch-service-session-API
Merge into: launchpad:master
Prerequisite: ~ines-almeida/launchpad:fetch-service-option-model-update
Diff against target: 544 lines (+422/-5)
6 files modified
lib/lp/buildmaster/builderproxy.py (+54/-3)
lib/lp/buildmaster/downloader.py (+44/-0)
lib/lp/buildmaster/tests/fetchservice.py (+124/-0)
lib/lp/services/config/schema-lazr.conf (+36/-0)
lib/lp/snappy/model/snapbuildbehaviour.py (+3/-1)
lib/lp/snappy/tests/test_snapbuildbehaviour.py (+161/-1)
Reviewer Review Type Date Requested Status
Jürgen Gmach Approve
Review via email: mp+461721@code.launchpad.net

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.

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Good work - left a few comments!
I'll leave the approval to Jürgen who is more into the details of the whole project.

Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Simone Pelosi (pelpsi) wrote :

Thank you for the review Ines, I'm so sorry I forgot to push the commit in which I removed all the debugging stuff or useless comments :P

Revision history for this message
Ines Almeida (ines-almeida) :
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks a lot for the MP! We are on the right track!

As we went with the approach to handle both cases inside a single mixin, we really, really want to have a good class docstring to explain that there are two proxies involved.

Also we should add a comment or a paragraph in the docstring which describes the way forward, that at one time hopefully the fetch service will replace the builder proxy.

Also, we need to be careful with naming - do not copy paste naming from the builder proxy. We should align this with what the starcraft folks are using.

Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks! Looks good now!

I would ask you to revert the refactoring when reading the configuration, or at very least put the refactoring and the new features in two separate commits.

Also, I left again the question where "FetchServiceURLMatcher" is used?

Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Looks good now - please at least separate the MP into a refactoring commit and one implementing the new feature.

review: Approve
Revision history for this message
Simone Pelosi (pelpsi) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/buildmaster/builderproxy.py b/lib/lp/buildmaster/builderproxy.py
2index be65e29..1ac121f 100644
3--- a/lib/lp/buildmaster/builderproxy.py
4+++ b/lib/lp/buildmaster/builderproxy.py
5@@ -20,7 +20,10 @@ from typing import Dict, Generator
6
7 from twisted.internet import defer
8
9-from lp.buildmaster.downloader import RequestProxyTokenCommand
10+from lp.buildmaster.downloader import (
11+ RequestFetchServiceSessionCommand,
12+ RequestProxyTokenCommand,
13+)
14 from lp.buildmaster.interfaces.builder import CannotBuild
15 from lp.buildmaster.interfaces.buildfarmjobbehaviour import BuildArgs
16 from lp.services.config import config
17@@ -31,14 +34,26 @@ def _get_proxy_config(name):
18 return getattr(config.builddmaster, name) or getattr(config.snappy, name)
19
20
21+def _get_value_from_config(key: str):
22+ value = _get_proxy_config(key)
23+ if not value:
24+ raise CannotBuild(f"{key} is not configured.")
25+ return value
26+
27+
28 class BuilderProxyMixin:
29 """Methods for handling builds with the Snap Build Proxy enabled."""
30
31 @defer.inlineCallbacks
32 def addProxyArgs(
33- self, args: BuildArgs, allow_internet: bool = True
34+ self,
35+ args: BuildArgs,
36+ allow_internet: bool = True,
37+ fetch_service: bool = False,
38 ) -> Generator[None, Dict[str, str], None]:
39- if _get_proxy_config("builder_proxy_host") and allow_internet:
40+ if not allow_internet:
41+ return
42+ if not fetch_service and _get_proxy_config("builder_proxy_host"):
43 token = yield self._requestProxyToken()
44 args["proxy_url"] = (
45 "http://{username}:{password}@{host}:{port}".format(
46@@ -52,6 +67,20 @@ class BuilderProxyMixin:
47 endpoint=_get_proxy_config("builder_proxy_auth_api_endpoint"),
48 token=token["username"],
49 )
50+ elif fetch_service and _get_proxy_config("fetch_service_host"):
51+ session = yield self._requestFetchServiceSession()
52+ args["proxy_url"] = (
53+ "http://{session_id}:{token}@{host}:{port}".format(
54+ session_id=session["id"],
55+ token=session["token"],
56+ host=_get_proxy_config("fetch_service_host"),
57+ port=_get_proxy_config("fetch_service_port"),
58+ )
59+ )
60+ args["revocation_endpoint"] = "{endpoint}/session/{id}".format(
61+ endpoint=_get_proxy_config("fetch_service_control_endpoint"),
62+ id=session["id"],
63+ )
64
65 @defer.inlineCallbacks
66 def _requestProxyToken(self):
67@@ -86,3 +115,25 @@ class BuilderProxyMixin:
68 proxy_username=proxy_username,
69 )
70 return token
71+
72+ @defer.inlineCallbacks
73+ def _requestFetchServiceSession(self):
74+ admin_username = _get_value_from_config(
75+ "fetch_service_control_admin_username"
76+ )
77+ secret = _get_value_from_config("fetch_service_control_admin_secret")
78+ url = _get_value_from_config("fetch_service_control_endpoint")
79+ timestamp = int(time.time())
80+ proxy_username = "{build_id}-{timestamp}".format(
81+ build_id=self.build.build_cookie, timestamp=timestamp
82+ )
83+ auth_string = f"{admin_username}:{secret}".strip()
84+ auth_header = b"Basic " + base64.b64encode(auth_string.encode("ASCII"))
85+
86+ session = yield self._worker.process_pool.doWork(
87+ RequestFetchServiceSessionCommand,
88+ url=url,
89+ auth_header=auth_header,
90+ proxy_username=proxy_username,
91+ )
92+ return session
93diff --git a/lib/lp/buildmaster/downloader.py b/lib/lp/buildmaster/downloader.py
94index fc16852..7cf1c9b 100644
95--- a/lib/lp/buildmaster/downloader.py
96+++ b/lib/lp/buildmaster/downloader.py
97@@ -9,6 +9,7 @@ anything from the rest of Launchpad.
98
99 __all__ = [
100 "DownloadCommand",
101+ "RequestFetchServiceSessionCommand",
102 "RequestProcess",
103 "RequestProxyTokenCommand",
104 ]
105@@ -37,6 +38,28 @@ class DownloadCommand(amp.Command):
106 }
107
108
109+class RequestFetchServiceSessionCommand(amp.Command):
110+ """Fetch service API Command subclass
111+
112+ It defines arguments, response values, and error conditions.
113+ For reference:
114+ https://docs.twisted.org/en/twisted-18.7.0/core/howto/amp.html
115+ """
116+
117+ arguments = [
118+ (b"url", amp.Unicode()),
119+ (b"auth_header", amp.String()),
120+ (b"proxy_username", amp.Unicode()),
121+ ]
122+ response = [
123+ (b"id", amp.Unicode()),
124+ (b"token", amp.Unicode()),
125+ ]
126+ errors = {
127+ RequestException: b"REQUEST_ERROR",
128+ }
129+
130+
131 class RequestProxyTokenCommand(amp.Command):
132 arguments = [
133 (b"url", amp.Unicode()),
134@@ -94,3 +117,24 @@ class RequestProcess(AMPChild):
135 )
136 response.raise_for_status()
137 return response.json()
138+
139+ @RequestFetchServiceSessionCommand.responder
140+ def requestFetchServiceSessionCommand(
141+ self, url, auth_header, proxy_username
142+ ):
143+ with Session() as session:
144+ session.trust_env = False
145+ # XXX pelpsi: from ST108 and from what Claudio
146+ # said `timeout` and `policy` are not mandatory now:
147+ # `timeout` will never be mandatory and we don't pass
148+ # it as parameter to the call.
149+ # `policy` could be mandatory or optional in future
150+ # (assuming `strict` as default), so for now it's better
151+ # to pass it explicitly and set it as `permissive`.
152+ response = session.post(
153+ url,
154+ headers={"Authorization": auth_header},
155+ json={"username": proxy_username, "policy": "permissive"},
156+ )
157+ response.raise_for_status()
158+ return response.json()
159diff --git a/lib/lp/buildmaster/tests/fetchservice.py b/lib/lp/buildmaster/tests/fetchservice.py
160new file mode 100644
161index 0000000..ea70651
162--- /dev/null
163+++ b/lib/lp/buildmaster/tests/fetchservice.py
164@@ -0,0 +1,124 @@
165+# Copyright 2015-2024 Canonical Ltd. This software is licensed under the
166+# GNU Affero General Public License version 3 (see the file LICENSE).
167+
168+"""Fixtures for dealing with the build time Fetch Service http proxy."""
169+
170+import json
171+import uuid
172+from textwrap import dedent
173+from urllib.parse import urlsplit
174+
175+import fixtures
176+from testtools.matchers import Equals, HasLength, MatchesStructure
177+from twisted.internet import defer, endpoints, reactor
178+from twisted.python.compat import nativeString
179+from twisted.web import resource, server
180+
181+from lp.services.config import config
182+
183+
184+class FetchServiceAuthAPITokensResource(resource.Resource):
185+ """A test session resource for the fetch service authentication API."""
186+
187+ isLeaf = True
188+
189+ def __init__(self):
190+ resource.Resource.__init__(self)
191+ self.requests = []
192+
193+ def render_POST(self, request):
194+ content = json.loads(request.content.read().decode("UTF-8"))
195+ self.requests.append(
196+ {
197+ "method": request.method,
198+ "uri": request.uri,
199+ "headers": dict(request.requestHeaders.getAllRawHeaders()),
200+ "json": content,
201+ }
202+ )
203+ return json.dumps(
204+ {
205+ "id": "1",
206+ "token": uuid.uuid4().hex,
207+ }
208+ ).encode("UTF-8")
209+
210+
211+class InProcessFetchServiceAuthAPIFixture(fixtures.Fixture):
212+ """A fixture that mocks the Fetch Service authentication API.
213+
214+ Users of this fixture must call the `start` method, which returns a
215+ `Deferred`, and arrange for that to get back to the reactor. This is
216+ necessary because the basic fixture API does not allow `setUp` to return
217+ anything. For example:
218+
219+ class TestSomething(TestCase):
220+
221+ run_tests_with = AsynchronousDeferredRunTest.make_factory(
222+ timeout=30)
223+
224+ @defer.inlineCallbacks
225+ def setUp(self):
226+ super().setUp()
227+ yield self.useFixture(
228+ InProcessFetchServiceAuthAPIFixture()
229+ ).start()
230+ """
231+
232+ @defer.inlineCallbacks
233+ def start(self):
234+ root = resource.Resource()
235+ self.sessions = FetchServiceAuthAPITokensResource()
236+ root.putChild(b"sessions", self.sessions)
237+ endpoint = endpoints.serverFromString(reactor, nativeString("tcp:0"))
238+ site = server.Site(self.sessions)
239+ self.addCleanup(site.stopFactory)
240+ port = yield endpoint.listen(site)
241+ self.addCleanup(port.stopListening)
242+ config.push(
243+ "in-process-fetch-service-api-fixture",
244+ dedent(
245+ """
246+ [builddmaster]
247+ fetch_service_control_admin_secret: admin-secret
248+ fetch_service_control_admin_username: admin-launchpad.test
249+ fetch_service_control_endpoint: http://{host}:{port}/session
250+ fetch_service_host: {host}
251+ fetch_service_port: {port}
252+ """
253+ ).format(host=port.getHost().host, port=port.getHost().port),
254+ )
255+ self.addCleanup(config.pop, "in-process-fetch-service-api-fixture")
256+
257+
258+# This class is not used yet but it could be
259+# useful for future tests
260+class FetchServiceURLMatcher(MatchesStructure):
261+ """Check that a string is a valid url for fetch service."""
262+
263+ def __init__(self):
264+ super().__init__(
265+ scheme=Equals("http"),
266+ id=Equals(1),
267+ token=HasLength(32),
268+ hostname=Equals(config.builddmaster.fetch_service_host),
269+ port=Equals(config.builddmaster.fetch_service_port),
270+ path=Equals(""),
271+ )
272+
273+ def match(self, matchee):
274+ super().match(urlsplit(matchee))
275+
276+
277+class RevocationEndpointMatcher(Equals):
278+ """Check that a string is a valid endpoint for fetch
279+ service session revocation.
280+ """
281+
282+ def __init__(self, session_id):
283+ super().__init__(
284+ "{endpoint}/session/{session_id}".format(
285+ endpoint=config.builddmaster.fetch_service_control_endpoint,
286+ session_id=session_id,
287+ )
288+ )
289diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
290index ac68bd4..bbfe8b8 100644
291--- a/lib/lp/services/config/schema-lazr.conf
292+++ b/lib/lp/services/config/schema-lazr.conf
293@@ -167,6 +167,24 @@ builder_proxy_host: none
294 # Builder http proxy port
295 builder_proxy_port: none
296
297+# Admin secret for requesting sessions from the fetch service.
298+# For a mojo deployed proxy service, the secret is generated and will reside in
299+# /srv/mojo/${MOJO_PROJECT}/${SERIES}/${MOJO_WORKSPACE}/local/admin-api-secret
300+fetch_service_control_admin_secret: none
301+
302+# Admin username for fetch service.
303+fetch_service_control_admin_username: none
304+
305+# Endpoint for fetch service authentication service
306+fetch_service_control_endpoint: none
307+
308+# Fetch service host, it could be either a single instance
309+# or a load balancer in front
310+fetch_service_host: none
311+
312+# Fetch service port
313+fetch_service_port: none
314+
315 [canonical]
316 # datatype: boolean
317 show_tracebacks: False
318@@ -1854,6 +1872,24 @@ builder_proxy_host: none
319 # Deprecated in favour of the same key in the builddmaster section.
320 builder_proxy_port: none
321
322+# Admin secret for requesting sessions from the fetch service.
323+# For a mojo deployed proxy service, the secret is generated and will reside in
324+# /srv/mojo/${MOJO_PROJECT}/${SERIES}/${MOJO_WORKSPACE}/local/admin-api-secret
325+fetch_service_control_admin_secret: none
326+
327+# Admin username for fetch service.
328+fetch_service_control_admin_username: none
329+
330+# Endpoint for fetch service authentication service
331+fetch_service_control_endpoint: none
332+
333+# Fetch service host, it could be either a single instance
334+# or a load balancer in front
335+fetch_service_host: none
336+
337+# Fetch service port
338+fetch_service_port: none
339+
340 # Optional sources.list entry to send to build workers when doing snap
341 # package builds. Use this form so that the series is set:
342 # deb http://foo %(series)s main
343diff --git a/lib/lp/snappy/model/snapbuildbehaviour.py b/lib/lp/snappy/model/snapbuildbehaviour.py
344index 66ae7cc..58fdd5f 100644
345--- a/lib/lp/snappy/model/snapbuildbehaviour.py
346+++ b/lib/lp/snappy/model/snapbuildbehaviour.py
347@@ -116,7 +116,9 @@ class SnapBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
348 """
349 build: ISnapBuild = self.build
350 args: BuildArgs = yield super().extraBuildArgs(logger=logger)
351- yield self.addProxyArgs(args, build.snap.allow_internet)
352+ yield self.addProxyArgs(
353+ args, build.snap.allow_internet, build.snap.use_fetch_service
354+ )
355 args["name"] = build.snap.store_name or build.snap.name
356 channels = build.channels or {}
357 if "snapcraft" not in channels:
358diff --git a/lib/lp/snappy/tests/test_snapbuildbehaviour.py b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
359index 5ec5804..2ccc18d 100644
360--- a/lib/lp/snappy/tests/test_snapbuildbehaviour.py
361+++ b/lib/lp/snappy/tests/test_snapbuildbehaviour.py
362@@ -51,6 +51,9 @@ from lp.buildmaster.tests.builderproxy import (
363 ProxyURLMatcher,
364 RevocationEndpointMatcher,
365 )
366+from lp.buildmaster.tests.fetchservice import (
367+ InProcessFetchServiceAuthAPIFixture,
368+)
369 from lp.buildmaster.tests.mock_workers import (
370 MockBuilder,
371 OkWorker,
372@@ -72,6 +75,7 @@ from lp.services.statsd.tests import StatsMixin
373 from lp.services.webapp import canonical_url
374 from lp.snappy.interfaces.snap import (
375 SNAP_SNAPCRAFT_CHANNEL_FEATURE_FLAG,
376+ SNAP_USE_FETCH_SERVICE_FEATURE_FLAG,
377 SnapBuildArchiveOwnerMismatch,
378 )
379 from lp.snappy.model.snapbuildbehaviour import (
380@@ -235,7 +239,163 @@ class TestSnapBuildBehaviour(TestSnapBuildBehaviourBase):
381 self.assertIn("Missing chroot", str(e))
382
383
384-class TestAsyncSnapBuildBehaviour(StatsMixin, TestSnapBuildBehaviourBase):
385+class TestAsyncSnapBuildBehaviourFetchService(
386+ StatsMixin, TestSnapBuildBehaviourBase
387+):
388+ run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
389+ timeout=30
390+ )
391+
392+ @defer.inlineCallbacks
393+ def setUp(self):
394+ super().setUp()
395+ self.session = {
396+ "id": "1",
397+ "token": uuid.uuid4().hex,
398+ }
399+ self.fetch_service_url = (
400+ "http://{session_id}:{token}@{host}:{port}".format(
401+ session_id=self.session["id"],
402+ token=self.session["token"],
403+ host=config.builddmaster.fetch_service_host,
404+ port=config.builddmaster.fetch_service_port,
405+ )
406+ )
407+ self.fetch_service_api = self.useFixture(
408+ InProcessFetchServiceAuthAPIFixture()
409+ )
410+ yield self.fetch_service_api.start()
411+ self.now = time.time()
412+ self.useFixture(fixtures.MockPatch("time.time", return_value=self.now))
413+ self.addCleanup(shut_down_default_process_pool)
414+ self.setUpStats()
415+
416+ def makeJob(self, **kwargs):
417+ # We need a builder worker in these tests, in order that requesting
418+ # a proxy token can piggyback on its reactor and pool.
419+ job = super().makeJob(**kwargs)
420+ builder = MockBuilder()
421+ builder.processor = job.build.processor
422+ worker = self.useFixture(WorkerTestHelpers()).getClientWorker()
423+ job.setBuilder(builder, worker)
424+ self.addCleanup(worker.pool.closeCachedConnections)
425+ return job
426+
427+ @defer.inlineCallbacks
428+ def test_requestFetchServiceSession_unconfigured(self):
429+ """Create a snap build request with an incomplete fetch service
430+ configuration.
431+
432+ If `fetch_service_host` is not provided the function will return
433+ without populating `proxy_url` and `revocation_endpoint`.
434+ """
435+ self.pushConfig("builddmaster", fetch_service_host=None)
436+ self.useFixture(
437+ FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: "on"})
438+ )
439+ snap = self.factory.makeSnap(use_fetch_service=True)
440+ request = self.factory.makeSnapBuildRequest(snap=snap)
441+ job = self.makeJob(snap=snap, build_request=request)
442+ with dbuser(config.builddmaster.dbuser):
443+ args = yield job.extraBuildArgs()
444+ self.assertEqual([], self.fetch_service_api.sessions.requests)
445+ self.assertNotIn("proxy_url", args)
446+ self.assertNotIn("revocation_endpoint", args)
447+
448+ @defer.inlineCallbacks
449+ def test_requestFetchServiceSession_no_secret(self):
450+ """Create a snap build request with an incomplete fetch service
451+ configuration.
452+
453+ If `fetch_service_control_admin_secret` is not provided
454+ the function raises a `CannotBuild` error.
455+ """
456+ self.pushConfig(
457+ "builddmaster", fetch_service_control_admin_secret=None
458+ )
459+ self.useFixture(
460+ FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: "on"})
461+ )
462+ snap = self.factory.makeSnap(use_fetch_service=True)
463+ request = self.factory.makeSnapBuildRequest(snap=snap)
464+ job = self.makeJob(snap=snap, build_request=request)
465+ expected_exception_msg = (
466+ "fetch_service_control_admin_secret is not configured."
467+ )
468+ with ExpectedException(CannotBuild, expected_exception_msg):
469+ yield job.extraBuildArgs()
470+
471+ @defer.inlineCallbacks
472+ def test_requestFetchServiceSession(self):
473+ """Create a snap build request with a successful fetch service
474+ configuration.
475+
476+ `proxy_url` and `revocation_endpoint` are correctly populated.
477+ """
478+ self.useFixture(
479+ FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: "on"})
480+ )
481+ snap = self.factory.makeSnap(use_fetch_service=True)
482+ request = self.factory.makeSnapBuildRequest(snap=snap)
483+ job = self.makeJob(snap=snap, build_request=request)
484+ args = yield job.extraBuildArgs()
485+ expected_uri = urlsplit(
486+ config.builddmaster.fetch_service_control_endpoint
487+ ).path.encode("UTF-8")
488+ request_matcher = MatchesDict(
489+ {
490+ "method": Equals(b"POST"),
491+ "uri": Equals(expected_uri),
492+ "headers": ContainsDict(
493+ {
494+ b"Authorization": MatchesListwise(
495+ [
496+ Equals(
497+ b"Basic "
498+ + base64.b64encode(
499+ b"admin-launchpad.test:admin-secret"
500+ )
501+ )
502+ ]
503+ ),
504+ b"Content-Type": MatchesListwise(
505+ [
506+ Equals(b"application/json"),
507+ ]
508+ ),
509+ }
510+ ),
511+ "json": MatchesDict(
512+ {
513+ "username": StartsWith(job.build.build_cookie + "-"),
514+ "policy": Equals("permissive"),
515+ }
516+ ),
517+ }
518+ )
519+ self.assertThat(
520+ self.fetch_service_api.sessions.requests,
521+ MatchesListwise([request_matcher]),
522+ )
523+ self.assertIn("proxy_url", args)
524+ self.assertIn("revocation_endpoint", args)
525+
526+ @defer.inlineCallbacks
527+ def test_requestFetchServiceSession_flag_off(self):
528+ """Create a snap build request turning off the feature flag."""
529+ self.useFixture(
530+ FeatureFixture({SNAP_USE_FETCH_SERVICE_FEATURE_FLAG: None})
531+ )
532+ snap = self.factory.makeSnap(use_fetch_service=True)
533+ request = self.factory.makeSnapBuildRequest(snap=snap)
534+ job = self.makeJob(snap=snap, build_request=request)
535+ yield job.extraBuildArgs()
536+ self.assertEqual([], self.fetch_service_api.sessions.requests)
537+
538+
539+class TestAsyncSnapBuildBehaviourBuilderProxy(
540+ StatsMixin, TestSnapBuildBehaviourBase
541+):
542 run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
543 timeout=30
544 )

Subscribers

People subscribed via source and target branches

to status/vote changes: