Merge ~cjwatson/launchpad:charmhub-client-push-release into launchpad:master
- Git
- lp:~cjwatson/launchpad
- charmhub-client-push-release
- Merge into 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) |
Related bugs: |
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
1 | diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py |
2 | index 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 | + """ |
87 | diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py |
88 | index 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 | |
104 | diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py |
105 | index 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() |
312 | diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py |
313 | index 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) |
331 | diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py |
332 | index 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) |
776 | diff --git a/lib/lp/oci/model/ociregistryclient.py b/lib/lp/oci/model/ociregistryclient.py |
777 | index 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) |
831 | diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf |
832 | index 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. |
846 | diff --git a/lib/lp/services/librarian/utils.py b/lib/lp/services/librarian/utils.py |
847 | index 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 |
886 | diff --git a/lib/lp/snappy/model/snapstoreclient.py b/lib/lp/snappy/model/snapstoreclient.py |
887 | index 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": ( |
941 | diff --git a/setup.py b/setup.py |
942 | index 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', |