Merge ~cjwatson/launchpad:charmhub-client-push-release into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: b03acb7fa583c96b54e8d4911a543ff3e93d7725
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charmhub-client-push-release
Merge into: launchpad:master
Diff against target: 952 lines (+625/-74)
10 files modified
lib/lp/charms/interfaces/charmhubclient.py (+63/-0)
lib/lp/charms/interfaces/charmrecipe.py (+6/-0)
lib/lp/charms/model/charmhubclient.py (+164/-2)
lib/lp/charms/model/charmrecipe.py (+8/-0)
lib/lp/charms/tests/test_charmhubclient.py (+352/-20)
lib/lp/oci/model/ociregistryclient.py (+2/-28)
lib/lp/services/config/schema-lazr.conf (+4/-0)
lib/lp/services/librarian/utils.py (+22/-1)
lib/lp/snappy/model/snapstoreclient.py (+3/-23)
setup.py (+1/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+407380@code.launchpad.net

Commit message

Extend Charmhub client to handle pushing and releasing charms

Description of the change

I've only checked this against the publishergw API by eye. A later branch will add an upload job, at which point we'll be able to do end-to-end testing.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py
2index 8beb251..4e9a528 100644
3--- a/lib/lp/charms/interfaces/charmhubclient.py
4+++ b/lib/lp/charms/interfaces/charmhubclient.py
5@@ -6,7 +6,13 @@
6 __all__ = [
7 "BadExchangeMacaroonsResponse",
8 "BadRequestPackageUploadResponse",
9+ "BadReviewStatusResponse",
10 "ICharmhubClient",
11+ "ReleaseFailedResponse",
12+ "ReviewFailedResponse",
13+ "UnauthorizedUploadResponse",
14+ "UploadFailedResponse",
15+ "UploadNotReviewedYetResponse",
16 ]
17
18 import http.client
19@@ -33,6 +39,30 @@ class BadExchangeMacaroonsResponse(CharmhubError):
20 pass
21
22
23+class UploadFailedResponse(CharmhubError):
24+ pass
25+
26+
27+class UnauthorizedUploadResponse(CharmhubError):
28+ pass
29+
30+
31+class BadReviewStatusResponse(CharmhubError):
32+ pass
33+
34+
35+class UploadNotReviewedYetResponse(CharmhubError):
36+ pass
37+
38+
39+class ReviewFailedResponse(CharmhubError):
40+ pass
41+
42+
43+class ReleaseFailedResponse(CharmhubError):
44+ pass
45+
46+
47 class ICharmhubClient(Interface):
48 """Interface for the API provided by Charmhub."""
49
50@@ -61,3 +91,36 @@ class ICharmhubClient(Interface):
51 :return: A serialized macaroon from Charmhub with no third-party
52 Candid caveat.
53 """
54+
55+ def upload(build):
56+ """Upload a charm recipe build to CharmHub.
57+
58+ :param build: The `ICharmRecipeBuild` to upload.
59+ :return: A URL to poll for upload processing status.
60+ :raises UnauthorizedUploadResponse: if the user who authorised this
61+ upload is not themselves authorised to upload the snap in
62+ question.
63+ :raises UploadFailedResponse: if uploading the build to Charmhub
64+ failed.
65+ """
66+
67+ def checkStatus(status_url):
68+ """Poll Charmhub once for upload scan status.
69+
70+ :param status_url: A URL as returned by `upload`.
71+ :raises UploadNotReviewedYetResponse: if the upload has not yet been
72+ reviewed.
73+ :raises BadReviewStatusResponse: if Charmhub failed to review the
74+ upload.
75+ :return: The Charmhub revision number for the upload.
76+ """
77+
78+ def release(build, revision):
79+ """Tell Charmhub to release a build to specified channels.
80+
81+ :param build: The `ICharmRecipeBuild` to release.
82+ :param revision: The revision returned by Charmhub when uploading
83+ the build.
84+ :raises ReleaseFailedResponse: if Charmhub failed to release the
85+ build.
86+ """
87diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
88index c6dac7d..46195b9 100644
89--- a/lib/lp/charms/interfaces/charmrecipe.py
90+++ b/lib/lp/charms/interfaces/charmrecipe.py
91@@ -274,6 +274,12 @@ class ICharmRecipeView(Interface):
92 title=_("Private"), required=False, readonly=False,
93 description=_("Whether this charm recipe is private."))
94
95+ can_upload_to_store = Bool(
96+ title=_("Can upload to Charmhub"), required=True, readonly=True,
97+ description=_(
98+ "Whether everything is set up to allow uploading builds of this "
99+ "charm recipe to Charmhub."))
100+
101 def getAllowedInformationTypes(user):
102 """Get a list of acceptable `InformationType`s for this charm recipe.
103
104diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py
105index be195d3..9b2627f 100644
106--- a/lib/lp/charms/model/charmhubclient.py
107+++ b/lib/lp/charms/model/charmhubclient.py
108@@ -8,24 +8,53 @@ __all__ = [
109 ]
110
111 from base64 import b64encode
112+from urllib.parse import quote
113
114 from lazr.restful.utils import get_current_browser_request
115 from pymacaroons import Macaroon
116 from pymacaroons.serializers import JsonSerializer
117 import requests
118+from requests_toolbelt import MultipartEncoder
119+from zope.component import getUtility
120 from zope.interface import implementer
121
122 from lp.charms.interfaces.charmhubclient import (
123 BadExchangeMacaroonsResponse,
124 BadRequestPackageUploadResponse,
125+ BadReviewStatusResponse,
126 ICharmhubClient,
127+ ReleaseFailedResponse,
128+ ReviewFailedResponse,
129+ UnauthorizedUploadResponse,
130+ UploadFailedResponse,
131+ UploadNotReviewedYetResponse,
132 )
133 from lp.services.config import config
134+from lp.services.crypto.interfaces import (
135+ CryptoError,
136+ IEncryptedContainer,
137+ )
138+from lp.services.librarian.utils import EncodableLibraryFileAlias
139 from lp.services.timeline.requesttimeline import get_request_timeline
140 from lp.services.timeout import urlfetch
141 from lp.services.webapp.url import urlappend
142
143
144+def _get_macaroon(recipe):
145+ """Get the Charmhub macaroon for a recipe."""
146+ store_secrets = recipe.store_secrets or {}
147+ macaroon_raw = store_secrets.get("exchanged_encrypted")
148+ if macaroon_raw is None:
149+ raise UnauthorizedUploadResponse(
150+ "{} is not authorized for upload to Charmhub".format(recipe))
151+ container = getUtility(IEncryptedContainer, "charmhub-secrets")
152+ try:
153+ return container.decrypt(macaroon_raw).decode()
154+ except CryptoError as e:
155+ raise UnauthorizedUploadResponse(
156+ "Failed to decrypt macaroon: {}".format(e))
157+
158+
159 @implementer(ICharmhubClient)
160 class CharmhubClient:
161 """A client for the API provided by Charmhub."""
162@@ -48,10 +77,10 @@ class CharmhubClient:
163 except ValueError:
164 pass
165 else:
166- if "error_list" in response_data:
167+ if "error-list" in response_data:
168 error_message = "\n".join(
169 error["message"]
170- for error in response_data["error_list"])
171+ for error in response_data["error-list"])
172 detail = requests_error.response.content.decode(errors="replace")
173 can_retry = requests_error.response.status_code in (502, 503)
174 return error_class(error_message, detail=detail, can_retry=can_retry)
175@@ -115,3 +144,136 @@ class CharmhubClient:
176 raise cls._makeCharmhubError(BadExchangeMacaroonsResponse, e)
177 finally:
178 timeline_action.finish()
179+
180+ @classmethod
181+ def _uploadToStorage(cls, lfa):
182+ """Upload a single file to Charmhub's storage."""
183+ assert config.charms.charmhub_storage_url is not None
184+ unscanned_upload_url = urlappend(
185+ config.charms.charmhub_storage_url, "unscanned-upload/")
186+ lfa.open()
187+ try:
188+ lfa_wrapper = EncodableLibraryFileAlias(lfa)
189+ encoder = MultipartEncoder(
190+ fields={
191+ "binary": (
192+ lfa.filename, lfa_wrapper, "application/octet-stream"),
193+ })
194+ request = get_current_browser_request()
195+ timeline_action = get_request_timeline(request).start(
196+ "charm-storage-push", lfa.filename, allow_nested=True)
197+ try:
198+ response = urlfetch(
199+ unscanned_upload_url, method="POST", data=encoder,
200+ headers={
201+ "Content-Type": encoder.content_type,
202+ "Accept": "application/json",
203+ })
204+ response_data = response.json()
205+ if not response_data.get("successful", False):
206+ raise UploadFailedResponse(response.text)
207+ return response_data["upload_id"]
208+ except requests.HTTPError as e:
209+ raise cls._makeCharmhubError(UploadFailedResponse, e)
210+ finally:
211+ timeline_action.finish()
212+ finally:
213+ lfa.close()
214+
215+ @classmethod
216+ def _push(cls, build, upload_id):
217+ """Push an already-uploaded charm to Charmhub."""
218+ recipe = build.recipe
219+ assert config.charms.charmhub_url is not None
220+ assert recipe.store_name is not None
221+ assert recipe.store_secrets is not None
222+ push_url = urlappend(
223+ config.charms.charmhub_url,
224+ "v1/charm/{}/revisions".format(quote(recipe.store_name)))
225+ macaroon_raw = _get_macaroon(recipe)
226+ data = {"upload-id": upload_id}
227+ request = get_current_browser_request()
228+ timeline_action = get_request_timeline(request).start(
229+ "charm-push", recipe.store_name, allow_nested=True)
230+ try:
231+ response = urlfetch(
232+ push_url, method="POST",
233+ headers={"Authorization": "Macaroon {}".format(macaroon_raw)},
234+ json=data)
235+ response_data = response.json()
236+ return response_data["status-url"]
237+ except requests.HTTPError as e:
238+ if e.response.status_code == 401:
239+ raise cls._makeCharmhubError(UnauthorizedUploadResponse, e)
240+ else:
241+ raise cls._makeCharmhubError(UploadFailedResponse, e)
242+ finally:
243+ timeline_action.finish()
244+
245+ @classmethod
246+ def upload(cls, build):
247+ """See `ICharmhubClient`."""
248+ assert build.recipe.can_upload_to_store
249+ for _, lfa, _ in build.getFiles():
250+ if not lfa.filename.endswith(".charm"):
251+ continue
252+ upload_id = cls._uploadToStorage(lfa)
253+ return cls._push(build, upload_id)
254+
255+ @classmethod
256+ def checkStatus(cls, build, status_url):
257+ """See `ICharmhubClient`."""
258+ macaroon_raw = _get_macaroon(build.recipe)
259+ request = get_current_browser_request()
260+ timeline_action = get_request_timeline(request).start(
261+ "charm-check-status", status_url, allow_nested=True)
262+ try:
263+ response = urlfetch(
264+ status_url,
265+ headers={"Authorization": "Macaroon {}".format(macaroon_raw)})
266+ response_data = response.json()
267+ # We're asking for a single upload ID, so the response should
268+ # only have one revision.
269+ if len(response_data.get("revisions", [])) != 1:
270+ raise BadReviewStatusResponse(response.text)
271+ [revision] = response_data["revisions"]
272+ if revision["status"] == "approved":
273+ return revision["revision"]
274+ elif revision["status"] == "rejected":
275+ error_message = "\n".join(
276+ error["message"] for error in revision["errors"])
277+ raise ReviewFailedResponse(error_message)
278+ else:
279+ raise UploadNotReviewedYetResponse()
280+ except requests.HTTPError as e:
281+ raise cls._makeCharmhubError(BadReviewStatusResponse, e)
282+ finally:
283+ timeline_action.finish()
284+
285+ @classmethod
286+ def release(cls, build, revision):
287+ """See `ICharmhubClient`."""
288+ assert config.charms.charmhub_url is not None
289+ recipe = build.recipe
290+ assert recipe.store_name is not None
291+ assert recipe.store_secrets is not None
292+ assert recipe.store_channels
293+ release_url = urlappend(
294+ config.charms.charmhub_url,
295+ "v1/charm/{}/releases".format(quote(recipe.store_name)))
296+ macaroon_raw = _get_macaroon(recipe)
297+ data = [
298+ {"channel": channel, "revision": revision}
299+ for channel in recipe.store_channels]
300+ request = get_current_browser_request()
301+ timeline_action = get_request_timeline(request).start(
302+ "charm-release", recipe.store_name, allow_nested=True)
303+ try:
304+ urlfetch(
305+ release_url, method="POST",
306+ headers={"Authorization": "Macaroon {}".format(macaroon_raw)},
307+ json=data)
308+ except requests.HTTPError as e:
309+ raise cls._makeCharmhubError(ReleaseFailedResponse, e)
310+ finally:
311+ timeline_action.finish()
312diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
313index a3ce3b3..1907347 100644
314--- a/lib/lp/charms/model/charmrecipe.py
315+++ b/lib/lp/charms/model/charmrecipe.py
316@@ -692,6 +692,14 @@ class CharmRecipe(StormBase):
317 container.encrypt(exchanged_macaroon_raw.encode()))
318 self.store_secrets.pop("root", None)
319
320+ @property
321+ def can_upload_to_store(self):
322+ return (
323+ config.charms.charmhub_url is not None and
324+ self.store_name is not None and
325+ self.store_secrets is not None and
326+ "exchanged_encrypted" in self.store_secrets)
327+
328 def destroySelf(self):
329 """See `ICharmRecipe`."""
330 store = IStore(self)
331diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
332index b041055..864c0d6 100644
333--- a/lib/lp/charms/tests/test_charmhubclient.py
334+++ b/lib/lp/charms/tests/test_charmhubclient.py
335@@ -4,68 +4,168 @@
336 """Tests for communication with Charmhub."""
337
338 import base64
339+import hashlib
340+import io
341 import json
342+from urllib.parse import quote
343
344 from lazr.restful.utils import get_current_browser_request
345-from pymacaroons import Macaroon
346+import multipart
347+from nacl.public import PrivateKey
348+from pymacaroons import (
349+ Macaroon,
350+ Verifier,
351+ )
352 from pymacaroons.serializers import JsonSerializer
353 import responses
354 from testtools.matchers import (
355 AfterPreprocessing,
356 ContainsDict,
357 Equals,
358+ Is,
359+ Matcher,
360 MatchesAll,
361+ MatchesDict,
362+ MatchesListwise,
363 MatchesStructure,
364+ Mismatch,
365 )
366+import transaction
367 from zope.component import getUtility
368+from zope.security.proxy import removeSecurityProxy
369
370+from lp.buildmaster.enums import BuildStatus
371 from lp.charms.interfaces.charmhubclient import (
372 BadExchangeMacaroonsResponse,
373 BadRequestPackageUploadResponse,
374+ BadReviewStatusResponse,
375 ICharmhubClient,
376+ ReleaseFailedResponse,
377+ ReviewFailedResponse,
378+ UnauthorizedUploadResponse,
379+ UploadFailedResponse,
380+ UploadNotReviewedYetResponse,
381 )
382 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
383+from lp.services.crypto.interfaces import IEncryptedContainer
384 from lp.services.features.testing import FeatureFixture
385 from lp.services.timeline.requesttimeline import get_request_timeline
386 from lp.testing import TestCaseWithFactory
387-from lp.testing.layers import ZopelessDatabaseLayer
388+from lp.testing.dbuser import dbuser
389+from lp.testing.layers import LaunchpadZopelessLayer
390+
391+
392+class MacaroonVerifies(Matcher):
393+ """Matches if a serialized macaroon passes verification."""
394+
395+ def __init__(self, key):
396+ self.key = key
397
398+ def match(self, macaroon_raw):
399+ macaroon = Macaroon.deserialize(macaroon_raw)
400+ try:
401+ Verifier().verify(macaroon, self.key)
402+ except Exception as e:
403+ return Mismatch("Macaroon does not verify: %s" % e)
404
405-class RequestMatches(MatchesStructure):
406+
407+class RequestMatches(MatchesAll):
408 """Matches a request with the specified attributes."""
409
410- def __init__(self, macaroons=None, json_data=None, **kwargs):
411+ def __init__(self, macaroons=None, auth=None, json_data=None,
412+ file_data=None, **kwargs):
413+ matchers = []
414 kwargs = dict(kwargs)
415 if macaroons is not None:
416- headers_matcher = ContainsDict({
417+ matchers.append(MatchesStructure(headers=ContainsDict({
418 "Macaroons": AfterPreprocessing(
419 lambda v: json.loads(
420 base64.b64decode(v.encode()).decode()),
421 Equals([json.loads(m) for m in macaroons])),
422- })
423- if kwargs.get("headers"):
424- headers_matcher = MatchesAll(
425- kwargs["headers"], headers_matcher)
426- kwargs["headers"] = headers_matcher
427+ })))
428+ if auth is not None:
429+ auth_scheme, auth_params_matcher = auth
430+ matchers.append(MatchesStructure(headers=ContainsDict({
431+ "Authorization": AfterPreprocessing(
432+ lambda v: v.split(" ", 1),
433+ MatchesListwise([
434+ Equals(auth_scheme),
435+ auth_params_matcher,
436+ ])),
437+ })))
438 if json_data is not None:
439- body_matcher = AfterPreprocessing(
440- lambda b: json.loads(b.decode()), Equals(json_data))
441- if kwargs.get("body"):
442- body_matcher = MatchesAll(kwargs["body"], body_matcher)
443- kwargs["body"] = body_matcher
444- super().__init__(**kwargs)
445+ matchers.append(MatchesStructure(body=AfterPreprocessing(
446+ lambda b: json.loads(b.decode()), Equals(json_data))))
447+ elif file_data is not None:
448+ matchers.append(AfterPreprocessing(
449+ lambda r: multipart.parse_form_data({
450+ "REQUEST_METHOD": r.method,
451+ "CONTENT_TYPE": r.headers["Content-Type"],
452+ "CONTENT_LENGTH": r.headers["Content-Length"],
453+ "wsgi.input": io.BytesIO(
454+ r.body.read() if hasattr(r.body, "read") else r.body),
455+ })[1],
456+ MatchesDict(file_data)))
457+ if kwargs:
458+ matchers.append(MatchesStructure(**kwargs))
459+ super().__init__(*matchers)
460
461
462 class TestCharmhubClient(TestCaseWithFactory):
463
464- layer = ZopelessDatabaseLayer
465+ layer = LaunchpadZopelessLayer
466
467 def setUp(self):
468 super().setUp()
469 self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
470- self.pushConfig("charms", charmhub_url="http://charmhub.example/")
471+ self.pushConfig(
472+ "charms",
473+ charmhub_url="http://charmhub.example/",
474+ charmhub_storage_url="http://storage.charmhub.example/")
475 self.client = getUtility(ICharmhubClient)
476
477+ def _setUpSecretStorage(self):
478+ self.private_key = PrivateKey.generate()
479+ self.pushConfig(
480+ "charms",
481+ charmhub_secrets_public_key=base64.b64encode(
482+ bytes(self.private_key.public_key)).decode(),
483+ charmhub_secrets_private_key=base64.b64encode(
484+ bytes(self.private_key)).decode())
485+
486+ def _makeStoreSecrets(self):
487+ self.exchanged_key = hashlib.sha256(
488+ self.factory.getUniqueBytes()).hexdigest()
489+ exchanged_macaroon = Macaroon(key=self.exchanged_key)
490+ container = getUtility(IEncryptedContainer, "charmhub-secrets")
491+ return {
492+ "exchanged_encrypted": removeSecurityProxy(container.encrypt(
493+ exchanged_macaroon.serialize().encode())),
494+ }
495+
496+ def _addUnscannedUploadResponse(self):
497+ responses.add(
498+ "POST", "http://storage.charmhub.example/unscanned-upload/",
499+ json={"successful": True, "upload_id": 1})
500+
501+ def _addCharmPushResponse(self, name):
502+ responses.add(
503+ "POST",
504+ "http://charmhub.example/v1/charm/{}/revisions".format(
505+ quote(name)),
506+ status=200,
507+ json={
508+ "status-url": (
509+ "http://charmhub.example/v1/charm/{}/revisions/review"
510+ "?upload-id=123".format(quote(name))),
511+ })
512+
513+ def _addCharmReleaseResponse(self, name):
514+ responses.add(
515+ "POST",
516+ "http://charmhub.example/v1/charm/{}/releases".format(quote(name)),
517+ json={})
518+
519 @responses.activate
520 def test_requestPackageUploadPermission(self):
521 responses.add(
522@@ -104,7 +204,7 @@ class TestCharmhubClient(TestCaseWithFactory):
523 def test_requestPackageUploadPermission_error(self):
524 responses.add(
525 "POST", "http://charmhub.example/v1/tokens",
526- status=503, json={"error_list": [{"message": "Failed"}]})
527+ status=503, json={"error-list": [{"message": "Failed"}]})
528 self.assertRaisesWithContent(
529 BadRequestPackageUploadResponse, "Failed",
530 self.client.requestPackageUploadPermission, "test-charm")
531@@ -161,7 +261,7 @@ class TestCharmhubClient(TestCaseWithFactory):
532 responses.add(
533 "POST", "http://charmhub.example/v1/tokens/exchange",
534 status=401,
535- json={"error_list": [{"message": "Exchange window expired"}]})
536+ json={"error-list": [{"message": "Exchange window expired"}]})
537 root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
538 discharge_macaroon_raw = Macaroon(version=2).serialize(
539 JsonSerializer())
540@@ -182,3 +282,235 @@ class TestCharmhubClient(TestCaseWithFactory):
541 "404 Client Error: Not Found",
542 self.client.exchangeMacaroons,
543 root_macaroon_raw, discharge_macaroon_raw)
544+
545+ def makeUploadableCharmRecipeBuild(self, store_secrets=None):
546+ if store_secrets is None:
547+ store_secrets = self._makeStoreSecrets()
548+ recipe = self.factory.makeCharmRecipe(
549+ store_upload=True,
550+ store_name="test-charm", store_secrets=store_secrets)
551+ build = self.factory.makeCharmRecipeBuild(recipe=recipe)
552+ charm_lfa = self.factory.makeLibraryFileAlias(
553+ filename="test-charm.charm", content="dummy charm content")
554+ self.factory.makeCharmFile(build=build, library_file=charm_lfa)
555+ manifest_lfa = self.factory.makeLibraryFileAlias(
556+ filename="test-charm.manifest", content="dummy manifest content")
557+ self.factory.makeCharmFile(build=build, library_file=manifest_lfa)
558+ build.updateStatus(BuildStatus.BUILDING)
559+ build.updateStatus(BuildStatus.FULLYBUILT)
560+ return build
561+
562+ @responses.activate
563+ def test_upload(self):
564+ self._setUpSecretStorage()
565+ build = self.makeUploadableCharmRecipeBuild()
566+ transaction.commit()
567+ self._addUnscannedUploadResponse()
568+ self._addCharmPushResponse("test-charm")
569+ # XXX cjwatson 2021-08-19: Use
570+ # config.ICharmhubUploadJobSource.dbuser once that job exists.
571+ with dbuser("charm-build-job"):
572+ self.assertEqual(
573+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
574+ "?upload-id=123",
575+ self.client.upload(build))
576+ requests = [call.request for call in responses.calls]
577+ self.assertThat(requests, MatchesListwise([
578+ RequestMatches(
579+ url=Equals(
580+ "http://storage.charmhub.example/unscanned-upload/"),
581+ method=Equals("POST"),
582+ file_data={
583+ "binary": MatchesStructure.byEquality(
584+ name="binary", filename="test-charm.charm",
585+ value="dummy charm content",
586+ content_type="application/octet-stream",
587+ )}),
588+ RequestMatches(
589+ url=Equals(
590+ "http://charmhub.example/v1/charm/test-charm/revisions"),
591+ method=Equals("POST"),
592+ headers=ContainsDict(
593+ {"Content-Type": Equals("application/json")}),
594+ auth=("Macaroon", MacaroonVerifies(self.exchanged_key)),
595+ json_data={"upload-id": 1}),
596+ ]))
597+
598+ @responses.activate
599+ def test_upload_unauthorized(self):
600+ self._setUpSecretStorage()
601+ build = self.makeUploadableCharmRecipeBuild()
602+ transaction.commit()
603+ self._addUnscannedUploadResponse()
604+ charm_push_error = {
605+ "code": "permission-required",
606+ "message": "Missing required permission: package-manage-revisions",
607+ }
608+ responses.add(
609+ "POST", "http://charmhub.example/v1/charm/test-charm/revisions",
610+ status=401,
611+ json={"error-list": [charm_push_error]})
612+ # XXX cjwatson 2021-08-19: Use
613+ # config.ICharmhubUploadJobSource.dbuser once that job exists.
614+ with dbuser("charm-build-job"):
615+ self.assertRaisesWithContent(
616+ UnauthorizedUploadResponse,
617+ "Missing required permission: package-manage-revisions",
618+ self.client.upload, build)
619+
620+ @responses.activate
621+ def test_upload_file_error(self):
622+ self._setUpSecretStorage()
623+ build = self.makeUploadableCharmRecipeBuild()
624+ transaction.commit()
625+ responses.add(
626+ "POST", "http://storage.charmhub.example/unscanned-upload/",
627+ status=502, body="The proxy exploded.\n")
628+ # XXX cjwatson 2021-08-19: Use
629+ # config.ICharmhubUploadJobSource.dbuser once that job exists.
630+ with dbuser("charm-build-job"):
631+ err = self.assertRaises(
632+ UploadFailedResponse, self.client.upload, build)
633+ self.assertEqual("502 Server Error: Bad Gateway", str(err))
634+ self.assertThat(err, MatchesStructure(
635+ detail=Equals("The proxy exploded.\n"),
636+ can_retry=Is(True)))
637+
638+ @responses.activate
639+ def test_checkStatus_pending(self):
640+ self._setUpSecretStorage()
641+ build = self.makeUploadableCharmRecipeBuild()
642+ status_url = (
643+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
644+ "?upload-id=123")
645+ responses.add(
646+ "GET", status_url,
647+ json={
648+ "revisions": [
649+ {
650+ "upload-id": "123",
651+ "status": "new",
652+ "revision": None,
653+ "errors": None,
654+ },
655+ ],
656+ })
657+ self.assertRaises(
658+ UploadNotReviewedYetResponse,
659+ self.client.checkStatus, build, status_url)
660+
661+ @responses.activate
662+ def test_checkStatus_error(self):
663+ self._setUpSecretStorage()
664+ build = self.makeUploadableCharmRecipeBuild()
665+ status_url = (
666+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
667+ "?upload-id=123")
668+ responses.add(
669+ "GET", status_url,
670+ json={
671+ "revisions": [
672+ {
673+ "upload-id": "123",
674+ "status": "rejected",
675+ "revision": None,
676+ "errors": [
677+ {"code": None, "message": "This charm is broken."},
678+ ],
679+ },
680+ ],
681+ })
682+ self.assertRaisesWithContent(
683+ ReviewFailedResponse, "This charm is broken.",
684+ self.client.checkStatus, build, status_url)
685+
686+ @responses.activate
687+ def test_checkStatus_approved(self):
688+ self._setUpSecretStorage()
689+ build = self.makeUploadableCharmRecipeBuild()
690+ status_url = (
691+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
692+ "?upload-id=123")
693+ responses.add(
694+ "GET", status_url,
695+ json={
696+ "revisions": [
697+ {
698+ "upload-id": "123",
699+ "status": "approved",
700+ "revision": 1,
701+ "errors": None,
702+ },
703+ ],
704+ })
705+ self.assertEqual(1, self.client.checkStatus(build, status_url))
706+ requests = [call.request for call in responses.calls]
707+ self.assertThat(requests, MatchesListwise([
708+ RequestMatches(
709+ url=Equals(status_url),
710+ method=Equals("GET"),
711+ auth=("Macaroon", MacaroonVerifies(self.exchanged_key))),
712+ ]))
713+
714+ @responses.activate
715+ def test_checkStatus_404(self):
716+ self._setUpSecretStorage()
717+ build = self.makeUploadableCharmRecipeBuild()
718+ status_url = (
719+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
720+ "?upload-id=123")
721+ responses.add("GET", status_url, status=404)
722+ self.assertRaisesWithContent(
723+ BadReviewStatusResponse, "404 Client Error: Not Found",
724+ self.client.checkStatus, build, status_url)
725+
726+ @responses.activate
727+ def test_release(self):
728+ self._setUpSecretStorage()
729+ recipe = self.factory.makeCharmRecipe(
730+ store_upload=True, store_name="test-charm",
731+ store_secrets=self._makeStoreSecrets(),
732+ store_channels=["stable", "edge"])
733+ build = self.factory.makeCharmRecipeBuild(recipe=recipe)
734+ self._addCharmReleaseResponse("test-charm")
735+ self.client.release(build, 1)
736+ self.assertThat(responses.calls[-1].request, RequestMatches(
737+ url=Equals("http://charmhub.example/v1/charm/test-charm/releases"),
738+ method=Equals("POST"),
739+ headers=ContainsDict({"Content-Type": Equals("application/json")}),
740+ auth=("Macaroon", MacaroonVerifies(self.exchanged_key)),
741+ json_data=[
742+ {"channel": "stable", "revision": 1},
743+ {"channel": "edge", "revision": 1},
744+ ]))
745+
746+ @responses.activate
747+ def test_release_error(self):
748+ self._setUpSecretStorage()
749+ recipe = self.factory.makeCharmRecipe(
750+ store_upload=True, store_name="test-charm",
751+ store_secrets=self._makeStoreSecrets(),
752+ store_channels=["stable", "edge"])
753+ build = self.factory.makeCharmRecipeBuild(recipe=recipe)
754+ responses.add(
755+ "POST", "http://charmhub.example/v1/charm/test-charm/releases",
756+ status=503,
757+ json={"error-list": [{"message": "Failed to publish"}]})
758+ self.assertRaisesWithContent(
759+ ReleaseFailedResponse, "Failed to publish",
760+ self.client.release, build, 1)
761+
762+ @responses.activate
763+ def test_release_404(self):
764+ self._setUpSecretStorage()
765+ recipe = self.factory.makeCharmRecipe(
766+ store_upload=True, store_name="test-charm",
767+ store_secrets=self._makeStoreSecrets(),
768+ store_channels=["stable", "edge"])
769+ build = self.factory.makeCharmRecipeBuild(recipe=recipe)
770+ responses.add(
771+ "POST", "http://charmhub.example/v1/charm/test-charm/releases",
772+ status=404)
773+ self.assertRaisesWithContent(
774+ ReleaseFailedResponse, "404 Client Error: Not Found",
775+ self.client.release, build, 1)
776diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py
777index 649b89a..4de58e8 100644
778--- a/lib/lp/oci/model/ociregistryclient.py
779+++ b/lib/lp/oci/model/ociregistryclient.py
780@@ -47,6 +47,7 @@ from lp.oci.interfaces.ociregistryclient import (
781 )
782 from lp.services.config import config
783 from lp.services.features import getFeatureFlag
784+from lp.services.librarian.utils import EncodableLibraryFileAlias
785 from lp.services.propertycache import cachedproperty
786 from lp.services.timeout import urlfetch
787
788@@ -71,33 +72,6 @@ def is_aws_bearer_token_domain(domain):
789 return any(domain.endswith(i) for i in domains.split())
790
791
792-class LibraryFileAliasWrapper:
793-
794- """A `LibraryFileAlias` wrapper used to read an LFA.
795-
796- The LFA is uploaded by Buildd to Librarian as tar.gz
797- after building the OCI image. Each LFA is essentially
798- a docker image layer and it is read in chunks when
799- uploading the layer to Dockerhub Registry."""
800-
801- def __init__(self, lfa):
802- self.lfa = lfa
803- self.position = 0
804-
805- def __len__(self):
806- return self.lfa.content.filesize - self.position
807-
808- """ Reads from the LFA in chunks. See ILibraryFileAlias."""
809- def read(self, length=-1):
810- chunksize = None if length == -1 else length
811- data = self.lfa.read(chunksize=chunksize)
812- if chunksize is None:
813- self.position = self.lfa.content.filesize
814- else:
815- self.position += length
816- return data
817-
818-
819 @implementer(IOCIRegistryClient)
820 class OCIRegistryClient:
821
822@@ -210,7 +184,7 @@ class OCIRegistryClient:
823 return tarinfo.size
824 else:
825 size = lfa.content.filesize
826- wrapper = LibraryFileAliasWrapper(lfa)
827+ wrapper = EncodableLibraryFileAlias(lfa)
828 cls._upload(
829 digest, push_rule, wrapper, size,
830 http_client)
831diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
832index e6242b9..413e38b 100644
833--- a/lib/lp/services/config/schema-lazr.conf
834+++ b/lib/lp/services/config/schema-lazr.conf
835@@ -155,6 +155,10 @@ cron_control_url: file:cronscripts.ini
836 # datatype: urlbase
837 charmhub_url: none
838
839+# Charmhub's storage URL endpoint.
840+# datatype: urlbase
841+charmhub_storage_url: none
842+
843 # Base64-encoded NaCl private key for decrypting Charmhub upload tokens.
844 # This should only be set in secret overlays on systems that need to perform
845 # Charmhub uploads on behalf of users.
846diff --git a/lib/lp/services/librarian/utils.py b/lib/lp/services/librarian/utils.py
847index 3d627e1..bc0895b 100644
848--- a/lib/lp/services/librarian/utils.py
849+++ b/lib/lp/services/librarian/utils.py
850@@ -1,9 +1,10 @@
851-# Copyright 2009 Canonical Ltd. This software is licensed under the
852+# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
853 # GNU Affero General Public License version 3 (see the file LICENSE).
854
855 __metaclass__ = type
856 __all__ = [
857 'copy_and_close',
858+ 'EncodableLibraryFileAlias',
859 'filechunks',
860 'guess_librarian_encoding',
861 'sha1_from_path',
862@@ -74,3 +75,23 @@ def guess_librarian_encoding(filename, mimetype):
863 encoding = None
864
865 return encoding, mimetype
866+
867+
868+class EncodableLibraryFileAlias:
869+ """A `LibraryFileAlias` wrapper usable with a `MultipartEncoder`."""
870+
871+ def __init__(self, lfa):
872+ self.lfa = lfa
873+ self.position = 0
874+
875+ def __len__(self):
876+ return self.lfa.content.filesize - self.position
877+
878+ def read(self, length=-1):
879+ chunksize = None if length == -1 else length
880+ data = self.lfa.read(chunksize=chunksize)
881+ if chunksize is None:
882+ self.position = self.lfa.content.filesize
883+ else:
884+ self.position += length
885+ return data
886diff --git a/lib/lp/snappy/model/snapstoreclient.py b/lib/lp/snappy/model/snapstoreclient.py
887index feba714..fd0415b 100644
888--- a/lib/lp/snappy/model/snapstoreclient.py
889+++ b/lib/lp/snappy/model/snapstoreclient.py
890@@ -1,4 +1,4 @@
891-# Copyright 2016-2019 Canonical Ltd. This software is licensed under the
892+# Copyright 2016-2021 Canonical Ltd. This software is licensed under the
893 # GNU Affero General Public License version 3 (see the file LICENSE).
894
895 """Communication with the snap store."""
896@@ -29,6 +29,7 @@ from lp.services.crypto.interfaces import (
897 IEncryptedContainer,
898 )
899 from lp.services.features import getFeatureFlag
900+from lp.services.librarian.utils import EncodableLibraryFileAlias
901 from lp.services.memcache.interfaces import IMemcacheClient
902 from lp.services.scripts import log
903 from lp.services.timeline.requesttimeline import get_request_timeline
904@@ -48,27 +49,6 @@ from lp.snappy.interfaces.snapstoreclient import (
905 )
906
907
908-class LibraryFileAliasWrapper:
909- """A `LibraryFileAlias` wrapper usable with a `MultipartEncoder`."""
910-
911- def __init__(self, lfa):
912- self.lfa = lfa
913- self.position = 0
914-
915- @property
916- def len(self):
917- return self.lfa.content.filesize - self.position
918-
919- def read(self, length=-1):
920- chunksize = None if length == -1 else length
921- data = self.lfa.read(chunksize=chunksize)
922- if chunksize is None:
923- self.position = self.lfa.content.filesize
924- else:
925- self.position += length
926- return data
927-
928-
929 class InvalidStoreSecretsError(Exception):
930 pass
931
932@@ -264,7 +244,7 @@ class SnapStoreClient:
933 config.snappy.store_upload_url, "unscanned-upload/")
934 lfa.open()
935 try:
936- lfa_wrapper = LibraryFileAliasWrapper(lfa)
937+ lfa_wrapper = EncodableLibraryFileAlias(lfa)
938 encoder = MultipartEncoder(
939 fields={
940 "binary": (
941diff --git a/setup.py b/setup.py
942index 6e945a8..14763f8 100644
943--- a/setup.py
944+++ b/setup.py
945@@ -182,6 +182,7 @@ setup(
946 'lpjsmin',
947 'Markdown',
948 'meliae',
949+ 'multipart',
950 'oauth',
951 'oauthlib',
952 'oops',

Subscribers

People subscribed via source and target branches

to status/vote changes: