Merge ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master
- Git
- lp:~twom/launchpad
- oci-policy-use-distribution-credentials-in-upload
- Merge into master
Proposed by
Tom Wardill
Status: | Merged |
---|---|
Approved by: | Tom Wardill |
Approved revision: | 47f07b559546d48b07f1863cb6317559d9b75fe1 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload |
Merge into: | launchpad:master |
Prerequisite: | ~twom/launchpad:oci-policy-distribute-the-credentials |
Diff against target: |
614 lines (+323/-36) 11 files modified
lib/lp/oci/browser/ocirecipe.py (+37/-1) lib/lp/oci/browser/tests/test_ocirecipe.py (+46/-1) lib/lp/oci/configure.zcml (+12/-0) lib/lp/oci/interfaces/ocipushrule.py (+1/-1) lib/lp/oci/interfaces/ocirecipe.py (+13/-0) lib/lp/oci/model/ocipushrule.py (+22/-0) lib/lp/oci/model/ocirecipe.py (+40/-6) lib/lp/oci/templates/ocirecipe-index.pt (+41/-27) lib/lp/oci/templates/ocirecipe-new.pt (+5/-0) lib/lp/oci/tests/test_ocirecipe.py (+56/-0) lib/lp/oci/tests/test_ociregistryclient.py (+50/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+395984@code.launchpad.net |
This proposal supersedes a proposal from 2021-01-08.
Commit message
Use distribution credentials in OCI upload
Description of the change
To post a comment you must log in.
- f5fd69e... by Tom Wardill
-
Remove accidental import
- 353dbae... by Tom Wardill
-
Fix index template
- a31a614... by Tom Wardill
-
Integrate official status checks into use of distribution credentials
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
- 0e19af2... by Tom Wardill
-
Fix tests to work without OCI config
- 02d8de1... by Tom Wardill
-
oci_project over project
Revision history for this message
Tom Wardill (twom) : | # |
- bd75daa... by Tom Wardill
-
Wording fixes
- 51143cc... by Tom Wardill
-
Update tests for wording fixes
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
- 47f07b5... by Tom Wardill
-
Use None as sentinel
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py |
2 | index 4f4253a..55d17b8 100644 |
3 | --- a/lib/lp/oci/browser/ocirecipe.py |
4 | +++ b/lib/lp/oci/browser/ocirecipe.py |
5 | @@ -254,6 +254,15 @@ class OCIRecipeView(LaunchpadView): |
6 | "%s=%s" % (k, v) |
7 | for k, v in sorted(self.context.build_args.items())) |
8 | |
9 | + @property |
10 | + def distribution_has_credentials(self): |
11 | + if hasattr(self.context, 'oci_project'): |
12 | + oci_project = self.context.oci_project |
13 | + else: |
14 | + oci_project = self.context |
15 | + distro = oci_project.distribution |
16 | + return bool(distro and distro.oci_registry_credentials) |
17 | + |
18 | |
19 | def builds_for_recipe(recipe): |
20 | """A list of interesting builds. |
21 | @@ -765,6 +774,15 @@ class OCIRecipeFormMixin: |
22 | ) |
23 | return False, message |
24 | |
25 | + @property |
26 | + def distribution_has_credentials(self): |
27 | + if hasattr(self.context, 'oci_project'): |
28 | + oci_project = self.context.oci_project |
29 | + else: |
30 | + oci_project = self.context |
31 | + distro = oci_project.distribution |
32 | + return bool(distro and distro.oci_registry_credentials) |
33 | + |
34 | |
35 | class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin, |
36 | OCIRecipeFormMixin): |
37 | @@ -808,6 +826,14 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin, |
38 | "May only be enabled by the owner of the OCI Project."), |
39 | default=False, |
40 | required=False, readonly=False)) |
41 | + if self.distribution_has_credentials: |
42 | + self.form_fields += FormFields(TextLine( |
43 | + __name__='image_name', |
44 | + title=u"Image name", |
45 | + description=( |
46 | + "Name to use for registry upload. " |
47 | + "Defaults to the name of the recipe."), |
48 | + required=False, readonly=False)) |
49 | |
50 | def setUpGitRefWidget(self): |
51 | """Setup GitRef widget indicating the user to use the default |
52 | @@ -886,7 +912,9 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin, |
53 | build_file=data["build_file"], description=data["description"], |
54 | build_daily=data["build_daily"], build_args=data["build_args"], |
55 | build_path=data["build_path"], processors=data["processors"], |
56 | - official=data.get('official_recipe', False)) |
57 | + official=data.get('official_recipe', False), |
58 | + # image_name is only available if using distribution credentials. |
59 | + image_name=data.get("image_name")) |
60 | self.next_url = canonical_url(recipe) |
61 | |
62 | |
63 | @@ -1011,6 +1039,14 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin, |
64 | "May only be enabled by the owner of the OCI Project."), |
65 | default=self.context.official, |
66 | required=False, readonly=False)) |
67 | + if self.distribution_has_credentials: |
68 | + self.form_fields += FormFields(TextLine( |
69 | + __name__='image_name', |
70 | + title=u"Image name", |
71 | + description=( |
72 | + "Name to use for registry upload. " |
73 | + "Defaults to the name of the recipe."), |
74 | + required=False, readonly=False)) |
75 | |
76 | def validate(self, data): |
77 | """See `LaunchpadFormView`.""" |
78 | diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py |
79 | index 970c49c..c473f4b 100644 |
80 | --- a/lib/lp/oci/browser/tests/test_ocirecipe.py |
81 | +++ b/lib/lp/oci/browser/tests/test_ocirecipe.py |
82 | @@ -151,7 +151,7 @@ class BaseTestOCIRecipeView(BrowserTestCase): |
83 | name="test-person", displayname="Test Person") |
84 | |
85 | |
86 | -class TestOCIRecipeAddView(BaseTestOCIRecipeView): |
87 | +class TestOCIRecipeAddView(OCIConfigHelperMixin, BaseTestOCIRecipeView): |
88 | |
89 | def setUp(self): |
90 | super(TestOCIRecipeAddView, self).setUp() |
91 | @@ -162,6 +162,7 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView): |
92 | "oci.build_series.%s" % self.distribution.name: |
93 | self.distroseries.name, |
94 | })) |
95 | + self.setConfig() |
96 | |
97 | def setUpDistroSeries(self): |
98 | """Set up self.distroseries with some available processors.""" |
99 | @@ -263,6 +264,29 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView): |
100 | "Build-time\nARG variables:\nVAR1=10\nVAR2=20", |
101 | MatchesTagText(content, "build-args")) |
102 | |
103 | + def test_create_new_recipe_with_image_name(self): |
104 | + oci_project = self.factory.makeOCIProject() |
105 | + credentials = self.factory.makeOCIRegistryCredentials() |
106 | + with person_logged_in(oci_project.distribution.owner): |
107 | + oci_project.distribution.oci_registry_credentials = credentials |
108 | + [git_ref] = self.factory.makeGitRefs( |
109 | + paths=['/refs/heads/v2.0-20.04']) |
110 | + browser = self.getViewBrowser( |
111 | + oci_project, view_name="+new-recipe", user=self.person) |
112 | + browser.getControl(name="field.name").value = "recipe-name" |
113 | + browser.getControl("Description").value = "Recipe description" |
114 | + browser.getControl(name="field.git_ref.repository").value = ( |
115 | + git_ref.repository.identity) |
116 | + browser.getControl(name="field.git_ref.path").value = git_ref.path |
117 | + |
118 | + image_name = self.factory.getUniqueUnicode() |
119 | + browser.getControl(name="field.image_name").value = image_name |
120 | + browser.getControl("Create OCI recipe").click() |
121 | + content = find_main_content(browser.contents) |
122 | + self.assertThat( |
123 | + "Registry image name:\n{}".format(image_name), |
124 | + MatchesTagText(content, "image-name")) |
125 | + |
126 | def test_create_new_recipe_users_teams_as_owner_options(self): |
127 | # Teams that the user is in are options for the OCI recipe owner. |
128 | self.factory.makeTeam( |
129 | @@ -741,6 +765,27 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView): |
130 | IStore(recipe).reload(recipe) |
131 | self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args) |
132 | |
133 | + def test_edit_image_name(self): |
134 | + self.setUpDistroSeries() |
135 | + credentials = self.factory.makeOCIRegistryCredentials() |
136 | + with person_logged_in(self.distribution.owner): |
137 | + self.distribution.oci_registry_credentials = credentials |
138 | + oci_project = self.factory.makeOCIProject(pillar=self.distribution) |
139 | + recipe = self.factory.makeOCIRecipe( |
140 | + registrant=self.person, owner=self.person, |
141 | + oci_project=oci_project) |
142 | + oci_project.setOfficialRecipeStatus(recipe, True) |
143 | + browser = self.getViewBrowser( |
144 | + recipe, view_name="+edit", user=recipe.owner) |
145 | + image_name = self.factory.getUniqueUnicode() |
146 | + field = browser.getControl(name="field.image_name") |
147 | + field.value = image_name |
148 | + browser.getControl("Update OCI recipe").click() |
149 | + content = find_main_content(browser.contents) |
150 | + self.assertThat( |
151 | + "Registry image name:\n{}".format(image_name), |
152 | + MatchesTagText(content, "image-name")) |
153 | + |
154 | def test_edit_with_invisible_processor(self): |
155 | # It's possible for existing recipes to have an enabled processor |
156 | # that's no longer usable with the current distroseries, which will |
157 | diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml |
158 | index 9a887b2..1f0e284 100644 |
159 | --- a/lib/lp/oci/configure.zcml |
160 | +++ b/lib/lp/oci/configure.zcml |
161 | @@ -163,6 +163,18 @@ |
162 | interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/> |
163 | </securedutility> |
164 | |
165 | + <!-- OCIDistributionPushRule --> |
166 | + <class class="lp.oci.model.ocipushrule.OCIDistributionPushRule"> |
167 | + <require |
168 | + permission="launchpad.View" |
169 | + interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleView |
170 | + lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" /> |
171 | + <require |
172 | + permission="launchpad.Edit" |
173 | + interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleEdit" |
174 | + set_schema="lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" /> |
175 | + </class> |
176 | + |
177 | <!-- OCI related jobs --> |
178 | <securedutility |
179 | component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob" |
180 | diff --git a/lib/lp/oci/interfaces/ocipushrule.py b/lib/lp/oci/interfaces/ocipushrule.py |
181 | index 67db23e..15dddb4 100644 |
182 | --- a/lib/lp/oci/interfaces/ocipushrule.py |
183 | +++ b/lib/lp/oci/interfaces/ocipushrule.py |
184 | @@ -52,7 +52,7 @@ class IOCIPushRuleView(Interface): |
185 | permission. |
186 | """ |
187 | |
188 | - id = Int(title=_("ID"), required=True, readonly=True) |
189 | + id = Int(title=_("ID"), required=False, readonly=True) |
190 | |
191 | registry_url = exported(TextLine( |
192 | title=_("Registry URL"), |
193 | diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py |
194 | index 633831f..a23fa03 100644 |
195 | --- a/lib/lp/oci/interfaces/ocirecipe.py |
196 | +++ b/lib/lp/oci/interfaces/ocirecipe.py |
197 | @@ -286,6 +286,11 @@ class IOCIRecipeView(Interface): |
198 | description=_("Whether the git branch name is the correct " |
199 | "format for using as a tag name.")) |
200 | |
201 | + use_distribution_credentials = Bool( |
202 | + title=_("Use Distribution credentials"), required=True, readonly=True, |
203 | + description=_("Use the credentials on a Distribution for " |
204 | + "registry upload")) |
205 | + |
206 | def requestBuild(requester, architecture): |
207 | """Request that the OCI recipe is built. |
208 | |
209 | @@ -440,6 +445,14 @@ class IOCIRecipeEditableAttributes(IHasOwner): |
210 | description=_("If True, this recipe should be built daily."), |
211 | readonly=False)) |
212 | |
213 | + image_name = exported(TextLine( |
214 | + title=_("Image name"), |
215 | + description=_("Image name to use on upload to registry. " |
216 | + "Defaults to recipe name if not set. " |
217 | + "Only used when Distribution credentials are set."), |
218 | + required=False, |
219 | + readonly=False)) |
220 | + |
221 | |
222 | class IOCIRecipeAdminAttributes(Interface): |
223 | """`IOCIRecipe` attributes that can be edited by admins. |
224 | diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py |
225 | index 24d5e27..ad9dd30 100644 |
226 | --- a/lib/lp/oci/model/ocipushrule.py |
227 | +++ b/lib/lp/oci/model/ocipushrule.py |
228 | @@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals |
229 | |
230 | __metaclass__ = type |
231 | __all__ = [ |
232 | + 'OCIDistributionPushRule', |
233 | 'OCIPushRule', |
234 | 'OCIPushRuleSet', |
235 | ] |
236 | @@ -72,6 +73,27 @@ class OCIPushRule(Storm): |
237 | IStore(OCIPushRule).remove(self) |
238 | |
239 | |
240 | +@implementer(IOCIPushRule) |
241 | +class OCIDistributionPushRule: |
242 | + """A non-database instance that is synthesised from data elsewhere.""" |
243 | + |
244 | + registry_credentials = None |
245 | + |
246 | + def __init__(self, recipe, registry_credentials, image_name): |
247 | + self.id = None # This is not a database instance |
248 | + self.recipe = recipe |
249 | + self.registry_credentials = registry_credentials |
250 | + self.image_name = image_name |
251 | + |
252 | + @property |
253 | + def registry_url(self): |
254 | + return self.registry_credentials.url |
255 | + |
256 | + @property |
257 | + def username(self): |
258 | + return self.registry_credentials.username |
259 | + |
260 | + |
261 | @implementer(IOCIPushRuleSet) |
262 | class OCIPushRuleSet: |
263 | |
264 | diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py |
265 | index fd76ae6..28ce8a0 100644 |
266 | --- a/lib/lp/oci/model/ocirecipe.py |
267 | +++ b/lib/lp/oci/model/ocirecipe.py |
268 | @@ -76,7 +76,10 @@ from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource |
269 | from lp.oci.interfaces.ociregistrycredentials import ( |
270 | IOCIRegistryCredentialsSet, |
271 | ) |
272 | -from lp.oci.model.ocipushrule import OCIPushRule |
273 | +from lp.oci.model.ocipushrule import ( |
274 | + OCIDistributionPushRule, |
275 | + OCIPushRule, |
276 | + ) |
277 | from lp.oci.model.ocirecipebuild import OCIRecipeBuild |
278 | from lp.oci.model.ocirecipejob import OCIRecipeJob |
279 | from lp.registry.interfaces.distribution import IDistributionSet |
280 | @@ -162,10 +165,13 @@ class OCIRecipe(Storm, WebhookTargetMixin): |
281 | |
282 | build_daily = Bool(name="build_daily", default=False) |
283 | |
284 | + _image_name = Unicode(name="image_name", allow_none=True) |
285 | + |
286 | def __init__(self, name, registrant, owner, oci_project, git_ref, |
287 | description=None, official=False, require_virtualized=True, |
288 | build_file=None, build_daily=False, date_created=DEFAULT, |
289 | - allow_internet=True, build_args=None, build_path=None): |
290 | + allow_internet=True, build_args=None, build_path=None, |
291 | + image_name=None): |
292 | if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE): |
293 | raise OCIRecipeFeatureDisabled() |
294 | super(OCIRecipe, self).__init__() |
295 | @@ -184,6 +190,7 @@ class OCIRecipe(Storm, WebhookTargetMixin): |
296 | self.allow_internet = allow_internet |
297 | self.build_args = build_args or {} |
298 | self.build_path = build_path |
299 | + self.image_name = image_name |
300 | |
301 | def __repr__(self): |
302 | return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % ( |
303 | @@ -469,10 +476,18 @@ class OCIRecipe(Storm, WebhookTargetMixin): |
304 | |
305 | @property |
306 | def push_rules(self): |
307 | + # if we're in a distribution that has credentials set at that level |
308 | + # create a push rule using those credentials |
309 | + if self.use_distribution_credentials: |
310 | + push_rule = OCIDistributionPushRule( |
311 | + self, |
312 | + self.oci_project.distribution.oci_registry_credentials, |
313 | + self.image_name) |
314 | + return [push_rule] |
315 | rules = IStore(self).find( |
316 | OCIPushRule, |
317 | OCIPushRule.recipe == self.id) |
318 | - return rules |
319 | + return list(rules) |
320 | |
321 | @property |
322 | def _pending_states(self): |
323 | @@ -533,7 +548,25 @@ class OCIRecipe(Storm, WebhookTargetMixin): |
324 | |
325 | @property |
326 | def can_upload_to_registry(self): |
327 | - return not self.push_rules.is_empty() |
328 | + return bool(self.push_rules) |
329 | + |
330 | + @property |
331 | + def use_distribution_credentials(self): |
332 | + distribution = self.oci_project.distribution |
333 | + # if we're not in a distribution, we can't use those credentials... |
334 | + if not distribution: |
335 | + return False |
336 | + official = self.official |
337 | + credentials = distribution.oci_registry_credentials |
338 | + return bool(distribution and official and credentials) |
339 | + |
340 | + @property |
341 | + def image_name(self): |
342 | + return self._image_name or self.name |
343 | + |
344 | + @image_name.setter |
345 | + def image_name(self, value): |
346 | + self._image_name = value |
347 | |
348 | def newPushRule(self, registrant, registry_url, image_name, credentials, |
349 | credentials_owner=None): |
350 | @@ -575,7 +608,8 @@ class OCIRecipeSet: |
351 | def new(self, name, registrant, owner, oci_project, git_ref, build_file, |
352 | description=None, official=False, require_virtualized=True, |
353 | build_daily=False, processors=None, date_created=DEFAULT, |
354 | - allow_internet=True, build_args=None, build_path=None): |
355 | + allow_internet=True, build_args=None, build_path=None, |
356 | + image_name=None): |
357 | """See `IOCIRecipeSet`.""" |
358 | if not registrant.inTeam(owner): |
359 | if owner.is_team: |
360 | @@ -600,7 +634,7 @@ class OCIRecipeSet: |
361 | oci_recipe = OCIRecipe( |
362 | name, registrant, owner, oci_project, git_ref, description, |
363 | official, require_virtualized, build_file, build_daily, |
364 | - date_created, allow_internet, build_args, build_path) |
365 | + date_created, allow_internet, build_args, build_path, image_name) |
366 | store.add(oci_recipe) |
367 | |
368 | if processors is None: |
369 | diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt |
370 | index a3e67bb..b575488 100644 |
371 | --- a/lib/lp/oci/templates/ocirecipe-index.pt |
372 | +++ b/lib/lp/oci/templates/ocirecipe-index.pt |
373 | @@ -88,6 +88,12 @@ |
374 | <span tal:condition="not: context/official">No</span> |
375 | </dd> |
376 | </dl> |
377 | + <dl id="image-name" tal:condition="view/distribution_has_credentials"> |
378 | + <dt>Registry image name:</dt> |
379 | + <dd> |
380 | + <span tal:content="context/image_name" /> |
381 | + </dd> |
382 | + </dl> |
383 | </div> |
384 | |
385 | <h2>Latest builds</h2> |
386 | @@ -151,35 +157,43 @@ |
387 | </div> |
388 | |
389 | |
390 | - <h2>Recipe push rules</h2> |
391 | - <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing" |
392 | - style="margin-bottom: 1em; "> |
393 | - <thead> |
394 | - <tr> |
395 | - <th>Registry URL</th> |
396 | - <th>Username</th> |
397 | - <th>Image Name</th> |
398 | - </tr> |
399 | - </thead> |
400 | - <tbody> |
401 | - <tal:recipe-push-rules repeat="item view/push_rules"> |
402 | - <tr tal:define="rule item; |
403 | - show_credentials rule/registry_credentials/required:launchpad.View" |
404 | - tal:attributes="id string:rule-${rule/id}"> |
405 | - <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/> |
406 | - <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/> |
407 | - <td tal:content="rule/image_name"/> |
408 | + <div tal:condition="view/distribution_has_credentials"> |
409 | + <h3>Registry upload</h3> |
410 | + <p tal:condition="context/use_distribution_credentials">This recipe will use the registry credentials set by the parent distribution.</p> |
411 | + <p tal:condition="not: context/use_distribution_credentials">This is not an official recipe for the OCI project, and will not be uploaded to a registry.</p> |
412 | + </div> |
413 | + |
414 | + <div tal:condition="not: view/distribution_has_credentials"> |
415 | + <h2>Recipe push rules</h2> |
416 | + <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing" |
417 | + style="margin-bottom: 1em; "> |
418 | + <thead> |
419 | + <tr> |
420 | + <th>Registry URL</th> |
421 | + <th>Username</th> |
422 | + <th>Image Name</th> |
423 | </tr> |
424 | - </tal:recipe-push-rules> |
425 | - </tbody> |
426 | - </table> |
427 | - <p tal:condition="not: view/has_push_rules"> |
428 | - This OCI recipe has no push rules defined yet. |
429 | - </p> |
430 | + </thead> |
431 | + <tbody> |
432 | + <tal:recipe-push-rules repeat="item view/push_rules"> |
433 | + <tr tal:define="rule item; |
434 | + show_credentials rule/registry_credentials/required:launchpad.View" |
435 | + tal:attributes="id string:rule-${rule/id}"> |
436 | + <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/> |
437 | + <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/> |
438 | + <td tal:content="rule/image_name"/> |
439 | + </tr> |
440 | + </tal:recipe-push-rules> |
441 | + </tbody> |
442 | + </table> |
443 | + <p tal:condition="not: view/has_push_rules"> |
444 | + This OCI recipe has no push rules defined yet. |
445 | + </p> |
446 | |
447 | - <div tal:define="link context/menu:context/edit_push_rules" |
448 | - tal:condition="link/enabled"> |
449 | - <tal:edit-push-rules replace="structure link/fmt:link"/> |
450 | + <div tal:define="link context/menu:context/edit_push_rules" |
451 | + tal:condition="link/enabled"> |
452 | + <tal:edit-push-rules replace="structure link/fmt:link"/> |
453 | + </div> |
454 | </div> |
455 | |
456 | </div> |
457 | diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt |
458 | index 00ed4e2..668c33d 100644 |
459 | --- a/lib/lp/oci/templates/ocirecipe-new.pt |
460 | +++ b/lib/lp/oci/templates/ocirecipe-new.pt |
461 | @@ -44,6 +44,11 @@ |
462 | <tal:widget define="widget nocall:view/widgets/official_recipe"> |
463 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> |
464 | </tal:widget> |
465 | + <span tal:condition="view/distribution_has_credentials"> |
466 | + <tal:widget define="widget nocall:view/widgets/image_name" > |
467 | + <metal:block use-macro="context/@@launchpad_form/widget_row" /> |
468 | + </tal:widget> |
469 | + </span> |
470 | </table> |
471 | </metal:formbody> |
472 | </div> |
473 | diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py |
474 | index 1cb69af..3486d57 100644 |
475 | --- a/lib/lp/oci/tests/test_ocirecipe.py |
476 | +++ b/lib/lp/oci/tests/test_ocirecipe.py |
477 | @@ -750,6 +750,39 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory): |
478 | "VAR3": "A string", |
479 | }, recipe.build_args) |
480 | |
481 | + def test_use_distribution_credentials_set(self): |
482 | + self.setConfig() |
483 | + distribution = self.factory.makeDistribution() |
484 | + credentials = self.factory.makeOCIRegistryCredentials() |
485 | + with person_logged_in(distribution.owner): |
486 | + distribution.oci_registry_credentials = credentials |
487 | + project = self.factory.makeOCIProject(pillar=distribution) |
488 | + recipe = self.factory.makeOCIRecipe(oci_project=project) |
489 | + with person_logged_in(distribution.owner): |
490 | + project.setOfficialRecipeStatus(recipe, True) |
491 | + self.assertTrue(recipe.use_distribution_credentials) |
492 | + |
493 | + def test_use_distribution_credentials_not_set(self): |
494 | + distribution = self.factory.makeDistribution() |
495 | + project = self.factory.makeOCIProject(pillar=distribution) |
496 | + recipe = self.factory.makeOCIRecipe(oci_project=project) |
497 | + self.assertFalse(recipe.use_distribution_credentials) |
498 | + |
499 | + def test_image_name_set(self): |
500 | + distribution = self.factory.makeDistribution() |
501 | + project = self.factory.makeOCIProject(pillar=distribution) |
502 | + recipe = self.factory.makeOCIRecipe(oci_project=project) |
503 | + image_name = self.factory.getUniqueUnicode() |
504 | + with person_logged_in(recipe.owner): |
505 | + recipe.image_name = image_name |
506 | + self.assertEqual(image_name, removeSecurityProxy(recipe)._image_name) |
507 | + |
508 | + def test_image_name_not_set(self): |
509 | + distribution = self.factory.makeDistribution() |
510 | + project = self.factory.makeOCIProject(pillar=distribution) |
511 | + recipe = self.factory.makeOCIRecipe(oci_project=project) |
512 | + self.assertEqual(recipe.name, recipe.image_name) |
513 | + |
514 | |
515 | class TestOCIRecipeProcessors(TestCaseWithFactory): |
516 | |
517 | @@ -1307,6 +1340,29 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory): |
518 | self.assertEqual( |
519 | image_name, push_rules["entries"][0]["image_name"]) |
520 | |
521 | + def test_api_set_image_name(self): |
522 | + """Can you set and retrieve the image name via the API?""" |
523 | + self.setConfig() |
524 | + |
525 | + image_name = self.factory.getUniqueUnicode() |
526 | + |
527 | + with person_logged_in(self.person): |
528 | + oci_project = self.factory.makeOCIProject( |
529 | + registrant=self.person) |
530 | + recipe = self.factory.makeOCIRecipe( |
531 | + oci_project=oci_project, owner=self.person, |
532 | + registrant=self.person) |
533 | + url = api_url(recipe) |
534 | + |
535 | + resp = self.webservice.patch( |
536 | + url, 'application/json', |
537 | + json.dumps({'image_name': image_name})) |
538 | + |
539 | + self.assertEqual(209, resp.status, resp.body) |
540 | + |
541 | + ws_project = self.load_from_api(url) |
542 | + self.assertEqual(image_name, ws_project['image_name']) |
543 | + |
544 | |
545 | class TestOCIRecipeAsyncWebservice(TestCaseWithFactory): |
546 | layer = LaunchpadFunctionalLayer |
547 | diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py |
548 | index aa71aa3..dcb2a8c 100644 |
549 | --- a/lib/lp/oci/tests/test_ociregistryclient.py |
550 | +++ b/lib/lp/oci/tests/test_ociregistryclient.py |
551 | @@ -69,6 +69,7 @@ from lp.services.compat import mock |
552 | from lp.services.features.testing import FeatureFixture |
553 | from lp.testing import ( |
554 | admin_logged_in, |
555 | + person_logged_in, |
556 | TestCaseWithFactory, |
557 | ) |
558 | from lp.testing.fixture import ZopeUtilityFixture |
559 | @@ -264,6 +265,55 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin, |
560 | self.assertEqual(0, len(responses.calls)) |
561 | |
562 | @responses.activate |
563 | + def test_upload_with_distribution_credentials(self): |
564 | + self._makeFiles() |
565 | + self.useFixture(MockPatch( |
566 | + "lp.oci.model.ociregistryclient.OCIRegistryClient._upload")) |
567 | + self.useFixture(MockPatch( |
568 | + "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer", |
569 | + return_value=999)) |
570 | + credentials = self.factory.makeOCIRegistryCredentials() |
571 | + image_name = self.factory.getUniqueUnicode() |
572 | + self.build.recipe.image_name = image_name |
573 | + distro = self.build.recipe.oci_project.distribution |
574 | + with person_logged_in(distro.owner): |
575 | + distro.oci_registry_credentials = credentials |
576 | + # we have distribution credentials, we should have a 'push rule' |
577 | + push_rule = self.build.recipe.push_rules[0] |
578 | + responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200) |
579 | + self.addManifestResponses(push_rule) |
580 | + |
581 | + self.client.upload(self.build) |
582 | + |
583 | + request = json.loads(responses.calls[1].request.body) |
584 | + |
585 | + self.assertThat(request, MatchesDict({ |
586 | + "layers": MatchesListwise([ |
587 | + MatchesDict({ |
588 | + "mediaType": Equals( |
589 | + "application/vnd.docker.image.rootfs.diff.tar.gzip"), |
590 | + "digest": Equals("diff_id_1"), |
591 | + "size": Equals(999)}), |
592 | + MatchesDict({ |
593 | + "mediaType": Equals( |
594 | + "application/vnd.docker.image.rootfs.diff.tar.gzip"), |
595 | + "digest": Equals("diff_id_2"), |
596 | + "size": Equals(999)}) |
597 | + ]), |
598 | + "schemaVersion": Equals(2), |
599 | + "config": MatchesDict({ |
600 | + "mediaType": Equals( |
601 | + "application/vnd.docker.container.image.v1+json"), |
602 | + "digest": Equals( |
603 | + "sha256:33b69b4b6e106f9fc7a8b93409" |
604 | + "36c85cf7f84b2d017e7b55bee6ab214761f6ab"), |
605 | + "size": Equals(52) |
606 | + }), |
607 | + "mediaType": Equals( |
608 | + "application/vnd.docker.distribution.manifest.v2+json") |
609 | + })) |
610 | + |
611 | + @responses.activate |
612 | def test_upload_formats_credentials(self): |
613 | self._makeFiles() |
614 | _upload_fixture = self.useFixture(MockPatch( |