Merge ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master

Proposed by Tom Wardill
Status: Superseded
Proposed branch: ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload
Merge into: launchpad:master
Prerequisite: ~twom/launchpad:db-oci-policy-use-distribution-credentials-in-upload
Diff against target: 1117 lines (+670/-38) (has conflicts)
18 files modified
database/schema/patch-2210-24-0.sql (+11/-0)
lib/lp/oci/browser/ocirecipe.py (+31/-1)
lib/lp/oci/browser/tests/test_ocirecipe.py (+45/-0)
lib/lp/oci/configure.zcml (+12/-0)
lib/lp/oci/interfaces/ocirecipe.py (+12/-0)
lib/lp/oci/model/ocipushrule.py (+22/-0)
lib/lp/oci/model/ocirecipe.py (+35/-6)
lib/lp/oci/templates/ocirecipe-index.pt (+40/-27)
lib/lp/oci/templates/ocirecipe-new.pt (+5/-0)
lib/lp/oci/tests/test_ocirecipe.py (+53/-0)
lib/lp/oci/tests/test_ociregistryclient.py (+52/-0)
lib/lp/registry/browser/configure.zcml (+1/-1)
lib/lp/registry/browser/distribution.py (+115/-2)
lib/lp/registry/browser/tests/test_distribution_views.py (+122/-1)
lib/lp/registry/configure.zcml (+1/-0)
lib/lp/registry/interfaces/distribution.py (+8/-0)
lib/lp/registry/model/distribution.py (+7/-0)
lib/lp/registry/templates/distribution-edit.pt (+98/-0)
Conflict in lib/lp/oci/tests/test_ociregistryclient.py
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+395983@code.launchpad.net

This proposal has been superseded by a proposal from 2021-01-08.

Commit message

Use distribution credentials in OCI upload

To post a comment you must log in.

Unmerged commits

e2dd813... by Tom Wardill

Upload with distribution credentials

20adcdd... by Tom Wardill

Add UI for image_name on OCIRecipe

bb203e9... by Tom Wardill

Remove push rules from templates if using distribution credentials

fa131bc... by Tom Wardill

Add image name to recipe

067424b... by Tom Wardill

Add OCIRecipe.image_name DB patch

a29743d... by Tom Wardill

Add UI for OCI credentials on edit Distribution

2ff17d5... by Tom Wardill

Add DB field to model and interface for Distribution

7679793... by Tom Wardill

Add DB patch

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/database/schema/patch-2210-24-0.sql b/database/schema/patch-2210-24-0.sql
2new file mode 100644
3index 0000000..833fa88
4--- /dev/null
5+++ b/database/schema/patch-2210-24-0.sql
6@@ -0,0 +1,11 @@
7+-- Copyright 2021 Canonical Ltd. This software is licensed under the
8+-- GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+SET client_min_messages=ERROR;
11+
12+ALTER TABLE Distribution
13+ ADD COLUMN oci_credentials INTEGER REFERENCES OCIRegistryCredentials;
14+
15+COMMENT ON COLUMN Distribution.oci_credentials IS 'Credentials and URL to use for uploading all OCI Images in this distribution to a registry.';
16+
17+INSERT INTO LaunchpadDatabaseRevision VALUES (2210, 24, 0);
18diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
19index f18779d..1e04c97 100644
20--- a/lib/lp/oci/browser/ocirecipe.py
21+++ b/lib/lp/oci/browser/ocirecipe.py
22@@ -4,6 +4,8 @@
23 """OCI recipe views."""
24
25 from __future__ import absolute_import, print_function, unicode_literals
26+import ipdb
27+
28
29 __metaclass__ = type
30 __all__ = [
31@@ -739,6 +741,15 @@ class OCIRecipeFormMixin:
32 build_args[k] = v
33 data['build_args'] = build_args
34
35+ @property
36+ def use_distribution_credentials(self):
37+ if hasattr(self.context, 'oci_project'):
38+ project = self.context.oci_project
39+ else:
40+ project = self.context
41+ distro = project.distribution
42+ return bool(distro and distro.oci_registry_credentials)
43+
44
45 class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
46 OCIRecipeFormMixin):
47@@ -772,6 +783,14 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
48 "The architectures that this OCI recipe builds for. Some "
49 "architectures are restricted and may only be enabled or "
50 "disabled by administrators.")
51+ if self.use_distribution_credentials:
52+ self.form_fields += FormFields(TextLine(
53+ __name__='image_name',
54+ title=u"Image name",
55+ description=(
56+ "Name to use for registry upload. "
57+ "Defaults to the name of the recipe."),
58+ required=False, readonly=False))
59
60 def setUpWidgets(self):
61 """See `LaunchpadFormView`."""
62@@ -816,7 +835,9 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
63 oci_project=self.context, git_ref=data["git_ref"],
64 build_file=data["build_file"], description=data["description"],
65 build_daily=data["build_daily"], build_args=data["build_args"],
66- build_path=data["build_path"], processors=data["processors"])
67+ build_path=data["build_path"], processors=data["processors"],
68+ # image_name is only available if using distribution credentials.
69+ image_name=data.get("image_name"))
70 self.next_url = canonical_url(recipe)
71
72
73@@ -888,6 +909,14 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
74 "The architectures that this OCI recipe builds for. Some "
75 "architectures are restricted and may only be enabled or "
76 "disabled by administrators.")
77+ if self.use_distribution_credentials:
78+ self.form_fields += FormFields(TextLine(
79+ __name__='image_name',
80+ title=u"Image name",
81+ description=(
82+ "Name to use for registry upload. "
83+ "Defaults to the name of the recipe."),
84+ required=False, readonly=False))
85
86 def validate(self, data):
87 """See `LaunchpadFormView`."""
88@@ -924,6 +953,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
89 data["processors"].append(processor)
90 self.validateBuildArgs(data)
91
92+
93 class OCIRecipeDeleteView(BaseOCIRecipeEditView):
94 """View for deleting OCI recipes."""
95
96diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
97index e22665d..ce20835 100644
98--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
99+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
100@@ -6,6 +6,9 @@
101
102 from __future__ import absolute_import, print_function, unicode_literals
103
104+from lp.soyuz.browser.archive import DistributionArchiveURL
105+
106+
107 __metaclass__ = type
108
109 from datetime import (
110@@ -243,6 +246,29 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
111 "Build-time\nARG variables:\nVAR1=10\nVAR2=20",
112 MatchesTagText(content, "build-args"))
113
114+ def test_create_new_recipe_with_image_name(self):
115+ oci_project = self.factory.makeOCIProject()
116+ credentials = self.factory.makeOCIRegistryCredentials()
117+ with person_logged_in(oci_project.distribution.owner):
118+ oci_project.distribution.oci_registry_credentials = credentials
119+ [git_ref] = self.factory.makeGitRefs()
120+ browser = self.getViewBrowser(
121+ oci_project, view_name="+new-recipe", user=self.person)
122+ browser.getControl(name="field.name").value = "recipe-name"
123+ browser.getControl("Description").value = "Recipe description"
124+ browser.getControl(name="field.git_ref.repository").value = (
125+ git_ref.repository.identity)
126+ browser.getControl(name="field.git_ref.path").value = git_ref.path
127+
128+ image_name = self.factory.getUniqueUnicode()
129+ browser.getControl(name="field.image_name").value = image_name
130+ browser.getControl("Create OCI recipe").click()
131+
132+ content = find_main_content(browser.contents)
133+ self.assertThat(
134+ "Registry image name\n{}".format(image_name),
135+ MatchesTagText(content, "image-name"))
136+
137 def test_create_new_recipe_users_teams_as_owner_options(self):
138 # Teams that the user is in are options for the OCI recipe owner.
139 self.factory.makeTeam(
140@@ -539,6 +565,25 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
141 IStore(recipe).reload(recipe)
142 self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args)
143
144+ def test_edit_image_name(self):
145+ self.setUpDistroSeries()
146+ credentials = self.factory.makeOCIRegistryCredentials()
147+ with person_logged_in(self.distribution.owner):
148+ self.distribution.oci_registry_credentials = credentials
149+ oci_project = self.factory.makeOCIProject(pillar=self.distribution)
150+ recipe = self.factory.makeOCIRecipe(
151+ registrant=self.person, owner=self.person, oci_project=oci_project)
152+ browser = self.getViewBrowser(
153+ recipe, view_name="+edit", user=recipe.owner)
154+ image_name = self.factory.getUniqueUnicode()
155+ field = browser.getControl(name="field.image_name")
156+ field.value = image_name
157+ browser.getControl("Update OCI recipe").click()
158+ content = find_main_content(browser.contents)
159+ self.assertThat(
160+ "Registry image name\n{}".format(image_name),
161+ MatchesTagText(content, "image-name"))
162+
163 def test_edit_with_invisible_processor(self):
164 # It's possible for existing recipes to have an enabled processor
165 # that's no longer usable with the current distroseries, which will
166diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
167index 1f7a6c7..86c235b 100644
168--- a/lib/lp/oci/configure.zcml
169+++ b/lib/lp/oci/configure.zcml
170@@ -155,6 +155,18 @@
171 interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
172 </securedutility>
173
174+ <!-- OCIDistributionPushRule -->
175+ <class class="lp.oci.model.ocipushrule.OCIDistributionPushRule">
176+ <require
177+ permission="launchpad.View"
178+ interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleView
179+ lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
180+ <require
181+ permission="launchpad.Edit"
182+ interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleEdit"
183+ set_schema="lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
184+ </class>
185+
186 <!-- OCI related jobs -->
187 <securedutility
188 component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
189diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
190index 131c4d3..51cf370 100644
191--- a/lib/lp/oci/interfaces/ocirecipe.py
192+++ b/lib/lp/oci/interfaces/ocirecipe.py
193@@ -280,6 +280,11 @@ class IOCIRecipeView(Interface):
194 "Whether everything is set up to allow uploading builds of "
195 "this OCI recipe to a registry."))
196
197+ use_distribution_credentials = Bool(
198+ title=_("Use Distribution credentials"), required=True, readonly=True,
199+ description=_("Use the credentials on a Distribution for "
200+ "registry upload"))
201+
202 def requestBuild(requester, architecture):
203 """Request that the OCI recipe is built.
204
205@@ -434,6 +439,13 @@ class IOCIRecipeEditableAttributes(IHasOwner):
206 description=_("If True, this recipe should be built daily."),
207 readonly=False))
208
209+ image_name = exported(TextLine(
210+ title=_("Image name"),
211+ description=_("Image name to use on upload to registry. "
212+ "Defaults to recipe name if not set."),
213+ required=False,
214+ readonly=False))
215+
216
217 class IOCIRecipeAdminAttributes(Interface):
218 """`IOCIRecipe` attributes that can be edited by admins.
219diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
220index 24d5e27..f7736e6 100644
221--- a/lib/lp/oci/model/ocipushrule.py
222+++ b/lib/lp/oci/model/ocipushrule.py
223@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
224
225 __metaclass__ = type
226 __all__ = [
227+ 'OCIDistributionPushRule',
228 'OCIPushRule',
229 'OCIPushRuleSet',
230 ]
231@@ -72,6 +73,27 @@ class OCIPushRule(Storm):
232 IStore(OCIPushRule).remove(self)
233
234
235+@implementer(IOCIPushRule)
236+class OCIDistributionPushRule:
237+ """A none-database instance that is synthesised from data elsewhere."""
238+
239+ registry_credentials = None
240+
241+ def __init__(self, recipe, registry_credentials, image_name):
242+ self.id = -1
243+ self.recipe = recipe
244+ self.registry_credentials = registry_credentials
245+ self.image_name = image_name
246+
247+ @property
248+ def registry_url(self):
249+ return self.registry_credentials.url
250+
251+ @property
252+ def username(self):
253+ return self.registry_credentials.username
254+
255+
256 @implementer(IOCIPushRuleSet)
257 class OCIPushRuleSet:
258
259diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
260index ab7f655..34fd348 100644
261--- a/lib/lp/oci/model/ocirecipe.py
262+++ b/lib/lp/oci/model/ocirecipe.py
263@@ -74,7 +74,10 @@ from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
264 from lp.oci.interfaces.ociregistrycredentials import (
265 IOCIRegistryCredentialsSet,
266 )
267-from lp.oci.model.ocipushrule import OCIPushRule
268+from lp.oci.model.ocipushrule import (
269+ OCIDistributionPushRule,
270+ OCIPushRule,
271+ )
272 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
273 from lp.oci.model.ocirecipejob import OCIRecipeJob
274 from lp.registry.interfaces.distribution import IDistributionSet
275@@ -160,10 +163,13 @@ class OCIRecipe(Storm, WebhookTargetMixin):
276
277 build_daily = Bool(name="build_daily", default=False)
278
279+ _image_name = Unicode(name="image_name", allow_none=True)
280+
281 def __init__(self, name, registrant, owner, oci_project, git_ref,
282 description=None, official=False, require_virtualized=True,
283 build_file=None, build_daily=False, date_created=DEFAULT,
284- allow_internet=True, build_args=None, build_path=None):
285+ allow_internet=True, build_args=None, build_path=None,
286+ image_name=None):
287 if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
288 raise OCIRecipeFeatureDisabled()
289 super(OCIRecipe, self).__init__()
290@@ -182,6 +188,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
291 self.allow_internet = allow_internet
292 self.build_args = build_args or {}
293 self.build_path = build_path
294+ self.image_name = image_name
295
296 def __repr__(self):
297 return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
298@@ -463,10 +470,18 @@ class OCIRecipe(Storm, WebhookTargetMixin):
299
300 @property
301 def push_rules(self):
302+ # if we're in a distribution that has credentials set at that level
303+ # create a push rule using those credentials
304+ if self.use_distribution_credentials:
305+ push_rule = OCIDistributionPushRule(
306+ self,
307+ self.oci_project.distribution.oci_registry_credentials,
308+ self.image_name)
309+ return [push_rule]
310 rules = IStore(self).find(
311 OCIPushRule,
312 OCIPushRule.recipe == self.id)
313- return rules
314+ return list(rules)
315
316 @property
317 def _pending_states(self):
318@@ -527,7 +542,20 @@ class OCIRecipe(Storm, WebhookTargetMixin):
319
320 @property
321 def can_upload_to_registry(self):
322- return not self.push_rules.is_empty()
323+ return bool(self.push_rules)
324+
325+ @property
326+ def use_distribution_credentials(self):
327+ distribution = self.oci_project.distribution
328+ return distribution and distribution.oci_registry_credentials
329+
330+ @property
331+ def image_name(self):
332+ return self._image_name or self.name
333+
334+ @image_name.setter
335+ def image_name(self, value):
336+ self._image_name = value
337
338 def newPushRule(self, registrant, registry_url, image_name, credentials,
339 credentials_owner=None):
340@@ -569,7 +597,8 @@ class OCIRecipeSet:
341 def new(self, name, registrant, owner, oci_project, git_ref, build_file,
342 description=None, official=False, require_virtualized=True,
343 build_daily=False, processors=None, date_created=DEFAULT,
344- allow_internet=True, build_args=None, build_path=None):
345+ allow_internet=True, build_args=None, build_path=None,
346+ image_name=None):
347 """See `IOCIRecipeSet`."""
348 if not registrant.inTeam(owner):
349 if owner.is_team:
350@@ -594,7 +623,7 @@ class OCIRecipeSet:
351 oci_recipe = OCIRecipe(
352 name, registrant, owner, oci_project, git_ref, description,
353 official, require_virtualized, build_file, build_daily,
354- date_created, allow_internet, build_args, build_path)
355+ date_created, allow_internet, build_args, build_path, image_name)
356 store.add(oci_recipe)
357
358 if processors is None:
359diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
360index 9a9aefc..662c742 100644
361--- a/lib/lp/oci/templates/ocirecipe-index.pt
362+++ b/lib/lp/oci/templates/ocirecipe-index.pt
363@@ -81,6 +81,12 @@
364 <pre tal:content="build_args" />
365 </dd>
366 </dl>
367+ <dl id="image-name" tal:condition="context/use_distribution_credentials">
368+ <dt>Registry image name</dt>
369+ <dd>
370+ <span tal:content="context/image_name" />
371+ </dd>
372+ </dl>
373 </div>
374
375 <h2>Latest builds</h2>
376@@ -144,35 +150,42 @@
377 </div>
378
379
380- <h2>Recipe push rules</h2>
381- <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
382- style="margin-bottom: 1em; ">
383- <thead>
384- <tr>
385- <th>Registry URL</th>
386- <th>Username</th>
387- <th>Image Name</th>
388- </tr>
389- </thead>
390- <tbody>
391- <tal:recipe-push-rules repeat="item view/push_rules">
392- <tr tal:define="rule item;
393- show_credentials rule/registry_credentials/required:launchpad.View"
394- tal:attributes="id string:rule-${rule/id}">
395- <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
396- <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
397- <td tal:content="rule/image_name"/>
398+ <div tal:condition=context/use_distribution_credentials>
399+ <h3>Registry upload</h3>
400+ <p>This recipe will use the registry credentials set by the parent distribution</p>
401+ </div>
402+
403+ <div tal:condition="not: context/use_distribution_credentials">
404+ <h2>Recipe push rules</h2>
405+ <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
406+ style="margin-bottom: 1em; ">
407+ <thead>
408+ <tr>
409+ <th>Registry URL</th>
410+ <th>Username</th>
411+ <th>Image Name</th>
412 </tr>
413- </tal:recipe-push-rules>
414- </tbody>
415- </table>
416- <p tal:condition="not: view/has_push_rules">
417- This OCI recipe has no push rules defined yet.
418- </p>
419+ </thead>
420+ <tbody>
421+ <tal:recipe-push-rules repeat="item view/push_rules">
422+ <tr tal:define="rule item;
423+ show_credentials rule/registry_credentials/required:launchpad.View"
424+ tal:attributes="id string:rule-${rule/id}">
425+ <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
426+ <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
427+ <td tal:content="rule/image_name"/>
428+ </tr>
429+ </tal:recipe-push-rules>
430+ </tbody>
431+ </table>
432+ <p tal:condition="not: view/has_push_rules">
433+ This OCI recipe has no push rules defined yet.
434+ </p>
435
436- <div tal:define="link context/menu:context/edit_push_rules"
437- tal:condition="link/enabled">
438- <tal:edit-push-rules replace="structure link/fmt:link"/>
439+ <div tal:define="link context/menu:context/edit_push_rules"
440+ tal:condition="link/enabled">
441+ <tal:edit-push-rules replace="structure link/fmt:link"/>
442+ </div>
443 </div>
444
445 </div>
446diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
447index 1cae71e..00d9b08 100644
448--- a/lib/lp/oci/templates/ocirecipe-new.pt
449+++ b/lib/lp/oci/templates/ocirecipe-new.pt
450@@ -41,6 +41,11 @@
451 <tal:widget define="widget nocall:view/widgets/processors">
452 <metal:block use-macro="context/@@launchpad_form/widget_row" />
453 </tal:widget>
454+ <span tal:condition="view/use_distribution_credentials">
455+ <tal:widget define="widget nocall:view/widgets/image_name" >
456+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
457+ </tal:widget>
458+ </span>
459 </table>
460 </metal:formbody>
461 </div>
462diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
463index a0b1c59..699954a 100644
464--- a/lib/lp/oci/tests/test_ocirecipe.py
465+++ b/lib/lp/oci/tests/test_ocirecipe.py
466@@ -749,6 +749,36 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
467 "VAR3": "A string",
468 }, recipe.build_args)
469
470+ def test_use_distribution_credentials_set(self):
471+ distribution = self.factory.makeDistribution()
472+ credentials = self.factory.makeOCIRegistryCredentials()
473+ with person_logged_in(distribution.owner):
474+ distribution.oci_registry_credentials = credentials
475+ project = self.factory.makeOCIProject(pillar=distribution)
476+ recipe = self.factory.makeOCIRecipe(oci_project=project)
477+ self.assertTrue(recipe.use_distribution_credentials)
478+
479+ def test_use_distribution_credentials_not_set(self):
480+ distribution = self.factory.makeDistribution()
481+ project = self.factory.makeOCIProject(pillar=distribution)
482+ recipe = self.factory.makeOCIRecipe(oci_project=project)
483+ self.assertFalse(recipe.use_distribution_credentials)
484+
485+ def test_image_name_set(self):
486+ distribution = self.factory.makeDistribution()
487+ project = self.factory.makeOCIProject(pillar=distribution)
488+ recipe = self.factory.makeOCIRecipe(oci_project=project)
489+ image_name = self.factory.getUniqueUnicode()
490+ with person_logged_in(recipe.owner):
491+ recipe.image_name = image_name
492+ self.assertEqual(image_name, removeSecurityProxy(recipe)._image_name)
493+
494+ def test_image_name_not_set(self):
495+ distribution = self.factory.makeDistribution()
496+ project = self.factory.makeOCIProject(pillar=distribution)
497+ recipe = self.factory.makeOCIRecipe(oci_project=project)
498+ self.assertEqual(recipe.name, recipe.image_name)
499+
500
501 class TestOCIRecipeProcessors(TestCaseWithFactory):
502
503@@ -1284,6 +1314,29 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
504 self.assertEqual(
505 image_name, push_rules["entries"][0]["image_name"])
506
507+ def test_api_set_image_name(self):
508+ """Can you set and retrieve the image name via the API?"""
509+ self.setConfig()
510+
511+ image_name = self.factory.getUniqueUnicode()
512+
513+ with person_logged_in(self.person):
514+ oci_project = self.factory.makeOCIProject(
515+ registrant=self.person)
516+ recipe = self.factory.makeOCIRecipe(
517+ oci_project=oci_project, owner=self.person,
518+ registrant=self.person)
519+ url = api_url(recipe)
520+
521+ resp = self.webservice.patch(
522+ url, 'application/json',
523+ json.dumps({'image_name': image_name}))
524+
525+ self.assertEqual(209, resp.status, resp.body)
526+
527+ ws_project = self.load_from_api(url)
528+ self.assertEqual(image_name, ws_project['image_name'])
529+
530
531 class TestOCIRecipeAsyncWebservice(TestCaseWithFactory):
532 layer = LaunchpadFunctionalLayer
533diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
534index ecfaee3..403662d 100644
535--- a/lib/lp/oci/tests/test_ociregistryclient.py
536+++ b/lib/lp/oci/tests/test_ociregistryclient.py
537@@ -69,6 +69,7 @@ from lp.services.compat import mock
538 from lp.services.features.testing import FeatureFixture
539 from lp.testing import (
540 admin_logged_in,
541+ person_logged_in,
542 TestCaseWithFactory,
543 )
544 from lp.testing.fixture import ZopeUtilityFixture
545@@ -226,6 +227,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
546 }))
547
548 @responses.activate
549+<<<<<<< lib/lp/oci/tests/test_ociregistryclient.py
550 def test_upload_ignores_superseded_builds(self):
551 self.build.updateStatus(BuildStatus.FULLYBUILT)
552 recipe = self.build.recipe
553@@ -249,6 +251,56 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
554 OCIRecipeBuildRegistryUploadStatus.SUPERSEDED,
555 self.build.registry_upload_status)
556 self.assertEqual(0, len(responses.calls))
557+=======
558+ def test_upload_with_distribution_credentials(self):
559+ self._makeFiles()
560+ self.useFixture(MockPatch(
561+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
562+ self.useFixture(MockPatch(
563+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
564+ return_value=999))
565+ credentials = self.factory.makeOCIRegistryCredentials()
566+ image_name = self.factory.getUniqueUnicode()
567+ self.build.recipe.image_name = image_name
568+ distro = self.build.recipe.oci_project.distribution
569+ with person_logged_in(distro.owner):
570+ distro.oci_registry_credentials = credentials
571+ # we have distribution credentials, we should have a 'push rule'
572+ push_rule = self.build.recipe.push_rules[0]
573+ responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
574+ self.addManifestResponses(push_rule)
575+
576+ self.client.upload(self.build)
577+
578+ request = json.loads(responses.calls[1].request.body)
579+
580+ self.assertThat(request, MatchesDict({
581+ "layers": MatchesListwise([
582+ MatchesDict({
583+ "mediaType": Equals(
584+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
585+ "digest": Equals("diff_id_1"),
586+ "size": Equals(999)}),
587+ MatchesDict({
588+ "mediaType": Equals(
589+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
590+ "digest": Equals("diff_id_2"),
591+ "size": Equals(999)})
592+ ]),
593+ "schemaVersion": Equals(2),
594+ "config": MatchesDict({
595+ "mediaType": Equals(
596+ "application/vnd.docker.container.image.v1+json"),
597+ "digest": Equals(
598+ "sha256:33b69b4b6e106f9fc7a8b93409"
599+ "36c85cf7f84b2d017e7b55bee6ab214761f6ab"),
600+ "size": Equals(52)
601+ }),
602+ "mediaType": Equals(
603+ "application/vnd.docker.distribution.manifest.v2+json")
604+ }))
605+
606+>>>>>>> lib/lp/oci/tests/test_ociregistryclient.py
607
608 @responses.activate
609 def test_upload_formats_credentials(self):
610diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
611index 7c97dc5..6be995f 100644
612--- a/lib/lp/registry/browser/configure.zcml
613+++ b/lib/lp/registry/browser/configure.zcml
614@@ -2275,7 +2275,7 @@
615 for="lp.registry.interfaces.distribution.IDistribution"
616 class="lp.registry.browser.distribution.DistributionEditView"
617 permission="launchpad.Edit"
618- template="../../app/templates/generic-edit.pt"
619+ template="../templates/distribution-edit.pt"
620 />
621 <browser:page
622 name="+admin"
623diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
624index c499689..25950ae 100644
625--- a/lib/lp/registry/browser/distribution.py
626+++ b/lib/lp/registry/browser/distribution.py
627@@ -49,10 +49,15 @@ from zope.formlib.boolwidgets import CheckBoxWidget
628 from zope.formlib.widget import CustomWidgetFactory
629 from zope.interface import implementer
630 from zope.lifecycleevent import ObjectCreatedEvent
631-from zope.schema import Bool
632+from zope.schema import (
633+ Bool,
634+ Password,
635+ TextLine,
636+ )
637 from zope.security.checker import canWrite
638 from zope.security.interfaces import Unauthorized
639
640+from lp import _
641 from lp.answers.browser.faqtarget import FAQTargetNavigationMixin
642 from lp.answers.browser.questiontarget import QuestionTargetTraversalMixin
643 from lp.app.browser.launchpadform import (
644@@ -80,6 +85,9 @@ from lp.bugs.browser.structuralsubscription import (
645 )
646 from lp.buildmaster.interfaces.processor import IProcessorSet
647 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
648+from lp.oci.interfaces.ociregistrycredentials import (
649+ IOCIRegistryCredentialsSet,
650+ )
651 from lp.registry.browser import (
652 add_subscribe_link,
653 RegistryEditFormView,
654@@ -1019,6 +1027,101 @@ class DistributionEditView(RegistryEditFormView,
655 """See `LaunchpadFormView`."""
656 return 'Change %s details' % self.context.displayname
657
658+ def createOCICredentials(self):
659+ return form.Fields(
660+ TextLine(
661+ __name__='oci_credentials_url',
662+ title=u"Registry URL",
663+ description=(
664+ u"URL for the OCI registry to upload images to."
665+ ),
666+ required=False),
667+ TextLine(
668+ __name__='oci_credentials_region',
669+ title=u"OCI registry region",
670+ description=u"Region for the OCI Registry.",
671+ required=False),
672+ TextLine(
673+ __name__='oci_credentials_username',
674+ title=u"OCI registry username",
675+ description=u"Username for the OCI Registry.",
676+ required=False),
677+ Password(
678+ __name__='oci_credentials_password',
679+ title=u"OCI registry password",
680+ description=u"Password for the OCI Registry.",
681+ required=False),
682+ Password(
683+ __name__='oci_credentials_confirm_password',
684+ title=u"Confirm password",
685+ required=False),
686+ Bool(
687+ __name__='oci_credentials_delete',
688+ title=u"Delete",
689+ description=u"Delete these credentials.",
690+ required=False,)
691+ )
692+
693+ def changeOCICredentials(self, data):
694+ delete = data.pop("oci_credentials_delete", None)
695+ if delete and self.context.oci_registry_credentials:
696+ credentials = self.context.oci_registry_credentials
697+ self.context.oci_registry_credentials = None
698+ credentials.destroySelf()
699+ return
700+
701+ url = data.pop("oci_credentials_url", None)
702+ username = data.pop("oci_credentials_username", None)
703+ region = data.pop("oci_credentials_region", None)
704+ # validated against confirm password in validateOCICredentials
705+ password = data.pop("oci_credentials_password", None)
706+ if "oci_credentials_confirm_password" in data:
707+ del(data["oci_credentials_confirm_password"])
708+
709+ # If we're not deleting, but don't have a url, then don't do anything
710+ if not url:
711+ return
712+
713+ current_credentials = self.context.oci_registry_credentials
714+ if current_credentials:
715+ current_credentials.url = url
716+ current_credentials.setCredentials({
717+ "username": username,
718+ "password": password,
719+ "region": region})
720+ return
721+ credentials = getUtility(IOCIRegistryCredentialsSet).new(
722+ self.context.owner,
723+ self.context.owner,
724+ url,
725+ {"username": username,
726+ "password": password,
727+ "region": region})
728+ self.context.oci_registry_credentials = credentials
729+
730+ def validateOCICredentials(self, data):
731+ # if we're deleting credentials, we don't need to validate
732+ if data.get("oci_credentials_delete"):
733+ return
734+ url = data.get("oci_credentials_url")
735+ username = data.get("oci_credentials_username")
736+ if username and not url:
737+ self.setFieldError(
738+ 'oci_credentials_url',
739+ _("A URL is required if a username is present."))
740+ password = data.get("oci_credentials_password")
741+ confirm_password = data.get("oci_credentials_confirm_password")
742+ if password != confirm_password:
743+ self.setFieldError(
744+ "oci_credentials_password",
745+ _("Passwords must match."))
746+ existing_credentials = self.context.oci_registry_credentials
747+ if existing_credentials and not url:
748+ self.setFieldError(
749+ "oci_credentials_url",
750+ _("URL must be specified. "
751+ "Delete credentials to unset URL."))
752+
753 def setUpFields(self):
754 """See `LaunchpadFormView`."""
755 RegistryEditFormView.setUpFields(self)
756@@ -1027,14 +1130,22 @@ class DistributionEditView(RegistryEditFormView,
757 getUtility(IProcessorSet).getAll(),
758 u"The architectures on which the distribution's main archive can "
759 u"build.")
760+ self.form_fields += self.createOCICredentials()
761
762 @property
763 def initial_values(self):
764- return {
765+ data = {
766 'require_virtualized':
767 self.context.main_archive.require_virtualized,
768 'processors': self.context.main_archive.processors,
769 }
770+ # Do OCI initial values
771+ oci_credentials = self.context.oci_registry_credentials
772+ if oci_credentials:
773+ data["oci_credentials_url"] = oci_credentials.url
774+ data["oci_credentials_username"] = oci_credentials.username
775+ data["oci_credentials_region"] = oci_credentials.region
776+ return data
777
778 def validate(self, data):
779 """Constrain bug expiration to Launchpad Bugs tracker."""
780@@ -1044,6 +1155,7 @@ class DistributionEditView(RegistryEditFormView,
781 official_malone = data.get('official_malone', False)
782 if not official_malone:
783 data['enable_bug_expiration'] = False
784+ self.validateOCICredentials(data)
785
786 def change_archive_fields(self, data):
787 # Update context.main_archive.
788@@ -1063,6 +1175,7 @@ class DistributionEditView(RegistryEditFormView,
789 @action("Change", name='change')
790 def change_action(self, action, data):
791 self.change_archive_fields(data)
792+ self.changeOCICredentials(data)
793 self.updateContextFromData(data)
794
795
796diff --git a/lib/lp/registry/browser/tests/test_distribution_views.py b/lib/lp/registry/browser/tests/test_distribution_views.py
797index fde5a8a..195560d 100644
798--- a/lib/lp/registry/browser/tests/test_distribution_views.py
799+++ b/lib/lp/registry/browser/tests/test_distribution_views.py
800@@ -9,6 +9,7 @@ from zope.component import getUtility
801
802 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
803 from lp.buildmaster.interfaces.processor import IProcessorSet
804+from lp.oci.tests.helpers import OCIConfigHelperMixin
805 from lp.registry.browser.distribution import DistributionPublisherConfigView
806 from lp.registry.enums import DistributionDefaultTraversalPolicy
807 from lp.registry.interfaces.distribution import IDistributionSet
808@@ -183,7 +184,7 @@ class TestDistroAddView(TestCaseWithFactory):
809 self.assertContentEqual([], distribution.main_archive.processors)
810
811
812-class TestDistroEditView(TestCaseWithFactory):
813+class TestDistroEditView(OCIConfigHelperMixin, TestCaseWithFactory):
814 """Test the +edit page for a distribution."""
815
816 layer = DatabaseFunctionalLayer
817@@ -193,6 +194,7 @@ class TestDistroEditView(TestCaseWithFactory):
818 self.admin = login_celebrity('admin')
819 self.distribution = self.factory.makeDistribution()
820 self.all_processors = getUtility(IProcessorSet).getAll()
821+ self.setConfig()
822
823 def test_edit_distro_init_value_require_virtualized(self):
824 view = create_initialized_view(
825@@ -260,6 +262,125 @@ class TestDistroEditView(TestCaseWithFactory):
826 method="POST", form=edit_form)
827 self.assertEqual(self.distribution.package_derivatives_email, email)
828
829+ def test_oci_validation_username_no_url(self):
830+ edit_form = self.getDefaultEditDict()
831+ edit_form["field.oci_credentials_username"] = "username"
832+
833+ view = create_initialized_view(
834+ self.distribution, '+edit', principal=self.admin,
835+ method='POST', form=edit_form)
836+ self.assertEqual(
837+ "A URL is required if a username is present.",
838+ view.getFieldError("oci_credentials_url"))
839+
840+ def test_oci_validation_different_passwords(self):
841+ edit_form = self.getDefaultEditDict()
842+ edit_form["field.oci_credentials_password"] = "password1"
843+ edit_form["field.oci_credentials_confirm_password"] = "password2"
844+ view = create_initialized_view(
845+ self.distribution, '+edit', principal=self.admin,
846+ method='POST', form=edit_form)
847+ self.assertEqual(
848+ "Passwords must match.",
849+ view.getFieldError("oci_credentials_password"))
850+
851+ def test_oci_validation_url_unset(self):
852+ edit_form = self.getDefaultEditDict()
853+ edit_form["field.oci_credentials_url"] = ""
854+
855+ credentials = self.factory.makeOCIRegistryCredentials(
856+ registrant=self.distribution.owner,
857+ owner=self.distribution.owner)
858+ self.distribution.oci_registry_credentials = credentials
859+
860+ view = create_initialized_view(
861+ self.distribution, '+edit', principal=self.admin,
862+ method='POST', form=edit_form)
863+ self.assertEqual(
864+ "URL must be specified. Delete credentials to unset URL.",
865+ view.getFieldError("oci_credentials_url"))
866+
867+ def test_oci_create_credentials_url_only(self):
868+ edit_form = self.getDefaultEditDict()
869+ registry_url = self.factory.getUniqueURL()
870+ edit_form["field.oci_credentials_url"] = registry_url
871+
872+ create_initialized_view(
873+ self.distribution, '+edit', principal=self.admin,
874+ method='POST', form=edit_form)
875+ self.assertEqual(
876+ registry_url, self.distribution.oci_registry_credentials.url)
877+
878+ def test_oci_create_credentials(self):
879+ edit_form = self.getDefaultEditDict()
880+ registry_url = self.factory.getUniqueURL()
881+ username = self.factory.getUniqueUnicode()
882+ password = self.factory.getUniqueUnicode()
883+ edit_form["field.oci_credentials_url"] = registry_url
884+ edit_form["field.oci_credentials_username"] = username
885+ edit_form["field.oci_credentials_password"] = password
886+ edit_form["field.oci_credentials_confirm_password"] = password
887+
888+ create_initialized_view(
889+ self.distribution, '+edit', principal=self.admin,
890+ method='POST', form=edit_form)
891+ self.assertEqual(
892+ username, self.distribution.oci_registry_credentials.username)
893+
894+ def test_oci_create_credentials_change_url(self):
895+ edit_form = self.getDefaultEditDict()
896+ credentials = self.factory.makeOCIRegistryCredentials(
897+ registrant=self.distribution.owner,
898+ owner=self.distribution.owner)
899+ self.distribution.oci_registry_credentials = credentials
900+ registry_url = self.factory.getUniqueURL()
901+ edit_form["field.oci_credentials_url"] = registry_url
902+
903+ create_initialized_view(
904+ self.distribution, '+edit', principal=self.admin,
905+ method='POST', form=edit_form)
906+ self.assertEqual(
907+ registry_url, self.distribution.oci_registry_credentials.url)
908+ # This should have mutated, not created new credentials records
909+ self.assertEqual(
910+ credentials.id, self.distribution.oci_registry_credentials.id)
911+
912+ def test_oci_create_credentials_change_password(self):
913+ edit_form = self.getDefaultEditDict()
914+ credentials = self.factory.makeOCIRegistryCredentials(
915+ registrant=self.distribution.owner,
916+ owner=self.distribution.owner)
917+ self.distribution.oci_registry_credentials = credentials
918+ password = self.factory.getUniqueUnicode()
919+ edit_form["field.oci_credentials_url"] = credentials.url
920+ edit_form["field.oci_credentials_username"] = credentials.username
921+ edit_form["field.oci_credentials_password"] = password
922+ edit_form["field.oci_credentials_confirm_password"] = password
923+
924+ create_initialized_view(
925+ self.distribution, '+edit', principal=self.admin,
926+ method='POST', form=edit_form)
927+ distro_credentials = self.distribution.oci_registry_credentials
928+ unencrypted_credentials = distro_credentials.getCredentials()
929+ self.assertEqual(
930+ password, unencrypted_credentials["password"])
931+ # This should not have changed
932+ self.assertEqual(
933+ distro_credentials.url, credentials.url)
934+
935+ def test_oci_delete_credentials(self):
936+ edit_form = self.getDefaultEditDict()
937+ credentials = self.factory.makeOCIRegistryCredentials(
938+ registrant=self.distribution.owner,
939+ owner=self.distribution.owner)
940+ self.distribution.oci_registry_credentials = credentials
941+ edit_form['field.oci_credentials_delete'] = 'on'
942+
943+ create_initialized_view(
944+ self.distribution, '+edit', principal=self.admin,
945+ method='POST', form=edit_form)
946+ self.assertIsNone(self.distribution.oci_registry_credentials)
947+
948
949 class TestDistributionAdminView(TestCaseWithFactory):
950 """Test the +admin page for a distribution."""
951diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
952index 7c2dad8..7a7a396 100644
953--- a/lib/lp/registry/configure.zcml
954+++ b/lib/lp/registry/configure.zcml
955@@ -1833,6 +1833,7 @@
956 mirror_admin
957 mugshot
958 oci_project_admin
959+ oci_registry_credentials
960 official_answers
961 official_blueprints
962 official_malone
963diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
964index 8c23b90..6d13dc5 100644
965--- a/lib/lp/registry/interfaces/distribution.py
966+++ b/lib/lp/registry/interfaces/distribution.py
967@@ -73,6 +73,7 @@ from lp.bugs.interfaces.bugtarget import (
968 from lp.bugs.interfaces.structuralsubscription import (
969 IStructuralSubscriptionTarget,
970 )
971+from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentials
972 from lp.registry.enums import (
973 DistributionDefaultTraversalPolicy,
974 VCSType,
975@@ -719,6 +720,13 @@ class IDistributionPublic(
976 def newOCIProject(registrant, name, description=None):
977 """Create an `IOCIProject` for this distro."""
978
979+ oci_registry_credentials = Reference(
980+ IOCIRegistryCredentials,
981+ title=_("OCI registry credentials"),
982+ description=_("Credentials and URL to use for uploading all OCI "
983+ "Images in this distribution to a registry."),
984+ required=False, readonly=True)
985+
986
987 @exported_as_webservice_entry(as_of="beta")
988 class IDistribution(
989diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
990index a3d4062..cceef00 100644
991--- a/lib/lp/registry/model/distribution.py
992+++ b/lib/lp/registry/model/distribution.py
993@@ -33,6 +33,10 @@ from storm.expr import (
994 SQL,
995 )
996 from storm.info import ClassAlias
997+from storm.locals import (
998+ Int,
999+ Reference,
1000+ )
1001 from storm.store import Store
1002 from zope.component import getUtility
1003 from zope.interface import implementer
1004@@ -267,6 +271,9 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
1005 enum=DistributionDefaultTraversalPolicy, notNull=False,
1006 default=DistributionDefaultTraversalPolicy.SERIES)
1007 redirect_default_traversal = BoolCol(notNull=False, default=False)
1008+ oci_registry_credentialsID = Int(name='oci_credentials', allow_none=True)
1009+ oci_registry_credentials = Reference(
1010+ oci_registry_credentialsID, "OCIRegistryCredentials.id")
1011
1012 def __repr__(self):
1013 display_name = self.display_name.encode('ASCII', 'backslashreplace')
1014diff --git a/lib/lp/registry/templates/distribution-edit.pt b/lib/lp/registry/templates/distribution-edit.pt
1015new file mode 100644
1016index 0000000..a8562b9
1017--- /dev/null
1018+++ b/lib/lp/registry/templates/distribution-edit.pt
1019@@ -0,0 +1,98 @@
1020+<html
1021+ xmlns="http://www.w3.org/1999/xhtml"
1022+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1023+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1024+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1025+ metal:use-macro="view/macro:page/main_side"
1026+ i18n:domain="launchpad"
1027+>
1028+<body>
1029+
1030+<tal:main metal:fill-slot="main">
1031+
1032+ <div metal:use-macro="context/@@launchpad_form/form">
1033+ <metal:formbody fill-slot="widgets">
1034+ <table class="form">
1035+ <tal:widget define="widget nocall:view/widgets/display_name">
1036+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1037+ </tal:widget>
1038+ <tal:widget define="widget nocall:view/widgets/summary">
1039+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1040+ </tal:widget>
1041+ <tal:widget define="widget nocall:view/widgets/description">
1042+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1043+ </tal:widget>
1044+ <tal:widget define="widget nocall:view/widgets/bug_reporting_guidelines">
1045+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1046+ </tal:widget>
1047+ <tal:widget define="widget nocall:view/widgets/bug_reported_acknowledgement">
1048+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1049+ </tal:widget>
1050+ <tal:widget define="widget nocall:view/widgets/package_derivatives_email">
1051+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1052+ </tal:widget>
1053+ <tal:widget define="widget nocall:view/widgets/icon">
1054+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1055+ </tal:widget>
1056+ <tal:widget define="widget nocall:view/widgets/logo">
1057+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1058+ </tal:widget>
1059+ <tal:widget define="widget nocall:view/widgets/mugshot">
1060+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1061+ </tal:widget>
1062+ <tal:widget define="widget nocall:view/widgets/official_malone">
1063+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1064+ </tal:widget>
1065+ <tal:widget define="widget nocall:view/widgets/enable_bug_expiration">
1066+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1067+ </tal:widget>
1068+ <tal:widget define="widget nocall:view/widgets/blueprints_usage">
1069+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1070+ </tal:widget>
1071+ <tal:widget define="widget nocall:view/widgets/translations_usage">
1072+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1073+ </tal:widget>
1074+ <tal:widget define="widget nocall:view/widgets/answers_usage">
1075+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1076+ </tal:widget>
1077+ <tal:widget define="widget nocall:view/widgets/translation_focus">
1078+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1079+ </tal:widget>
1080+ <tal:widget define="widget nocall:view/widgets/default_traversal_policy">
1081+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1082+ </tal:widget>
1083+ <tal:widget define="widget nocall:view/widgets/redirect_default_traversal">
1084+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1085+ </tal:widget>
1086+
1087+ <tr>
1088+ <td><label>OCI registry credentials</label></td>
1089+ <tr>
1090+ <tr>
1091+ <td tal:define="widget nocall:view/widgets/oci_credentials_url">
1092+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1093+ </td>
1094+ <td tal:define="widget nocall:view/widgets/oci_credentials_region">
1095+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1096+ </td>
1097+ <td tal:define="widget nocall:view/widgets/oci_credentials_username">
1098+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1099+ </td>
1100+ <td tal:define="widget nocall:view/widgets/oci_credentials_password">
1101+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1102+ </td>
1103+ <td tal:define="widget nocall:view/widgets/oci_credentials_confirm_password">
1104+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1105+ </td>
1106+ <td tal:define="widget nocall:view/widgets/oci_credentials_delete">
1107+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1108+ </td>
1109+ </tr>
1110+ </table>
1111+ </metal:formbody>
1112+ </div>
1113+
1114+</tal:main>
1115+
1116+</body>
1117+</html>

Subscribers

People subscribed via source and target branches

to status/vote changes: