launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25967
[Merge] ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master
Tom Wardill has proposed merging ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master with ~twom/launchpad:db-oci-policy-use-distribution-credentials-in-upload as a prerequisite.
Commit message:
Use distribution credentials in OCI upload
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/395983
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-policy-use-distribution-credentials-in-upload into launchpad:master.
diff --git a/database/schema/patch-2210-24-0.sql b/database/schema/patch-2210-24-0.sql
new file mode 100644
index 0000000..833fa88
--- /dev/null
+++ b/database/schema/patch-2210-24-0.sql
@@ -0,0 +1,11 @@
+-- Copyright 2021 Canonical Ltd. This software is licensed under the
+-- GNU Affero General Public License version 3 (see the file LICENSE).
+
+SET client_min_messages=ERROR;
+
+ALTER TABLE Distribution
+ ADD COLUMN oci_credentials INTEGER REFERENCES OCIRegistryCredentials;
+
+COMMENT ON COLUMN Distribution.oci_credentials IS 'Credentials and URL to use for uploading all OCI Images in this distribution to a registry.';
+
+INSERT INTO LaunchpadDatabaseRevision VALUES (2210, 24, 0);
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index f18779d..1e04c97 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -4,6 +4,8 @@
"""OCI recipe views."""
from __future__ import absolute_import, print_function, unicode_literals
+import ipdb
+
__metaclass__ = type
__all__ = [
@@ -739,6 +741,15 @@ class OCIRecipeFormMixin:
build_args[k] = v
data['build_args'] = build_args
+ @property
+ def use_distribution_credentials(self):
+ if hasattr(self.context, 'oci_project'):
+ project = self.context.oci_project
+ else:
+ project = self.context
+ distro = project.distribution
+ return bool(distro and distro.oci_registry_credentials)
+
class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
OCIRecipeFormMixin):
@@ -772,6 +783,14 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
"The architectures that this OCI recipe builds for. Some "
"architectures are restricted and may only be enabled or "
"disabled by administrators.")
+ if self.use_distribution_credentials:
+ self.form_fields += FormFields(TextLine(
+ __name__='image_name',
+ title=u"Image name",
+ description=(
+ "Name to use for registry upload. "
+ "Defaults to the name of the recipe."),
+ required=False, readonly=False))
def setUpWidgets(self):
"""See `LaunchpadFormView`."""
@@ -816,7 +835,9 @@ class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin,
oci_project=self.context, git_ref=data["git_ref"],
build_file=data["build_file"], description=data["description"],
build_daily=data["build_daily"], build_args=data["build_args"],
- build_path=data["build_path"], processors=data["processors"])
+ build_path=data["build_path"], processors=data["processors"],
+ # image_name is only available if using distribution credentials.
+ image_name=data.get("image_name"))
self.next_url = canonical_url(recipe)
@@ -888,6 +909,14 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
"The architectures that this OCI recipe builds for. Some "
"architectures are restricted and may only be enabled or "
"disabled by administrators.")
+ if self.use_distribution_credentials:
+ self.form_fields += FormFields(TextLine(
+ __name__='image_name',
+ title=u"Image name",
+ description=(
+ "Name to use for registry upload. "
+ "Defaults to the name of the recipe."),
+ required=False, readonly=False))
def validate(self, data):
"""See `LaunchpadFormView`."""
@@ -924,6 +953,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin,
data["processors"].append(processor)
self.validateBuildArgs(data)
+
class OCIRecipeDeleteView(BaseOCIRecipeEditView):
"""View for deleting OCI recipes."""
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index e22665d..ce20835 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -6,6 +6,9 @@
from __future__ import absolute_import, print_function, unicode_literals
+from lp.soyuz.browser.archive import DistributionArchiveURL
+
+
__metaclass__ = type
from datetime import (
@@ -243,6 +246,29 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
"Build-time\nARG variables:\nVAR1=10\nVAR2=20",
MatchesTagText(content, "build-args"))
+ def test_create_new_recipe_with_image_name(self):
+ oci_project = self.factory.makeOCIProject()
+ credentials = self.factory.makeOCIRegistryCredentials()
+ with person_logged_in(oci_project.distribution.owner):
+ oci_project.distribution.oci_registry_credentials = credentials
+ [git_ref] = self.factory.makeGitRefs()
+ browser = self.getViewBrowser(
+ oci_project, view_name="+new-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "recipe-name"
+ browser.getControl("Description").value = "Recipe description"
+ browser.getControl(name="field.git_ref.repository").value = (
+ git_ref.repository.identity)
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
+
+ image_name = self.factory.getUniqueUnicode()
+ browser.getControl(name="field.image_name").value = image_name
+ browser.getControl("Create OCI recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertThat(
+ "Registry image name\n{}".format(image_name),
+ MatchesTagText(content, "image-name"))
+
def test_create_new_recipe_users_teams_as_owner_options(self):
# Teams that the user is in are options for the OCI recipe owner.
self.factory.makeTeam(
@@ -539,6 +565,25 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
IStore(recipe).reload(recipe)
self.assertEqual({"VAR1": "xxx", "VAR2": "uu"}, recipe.build_args)
+ def test_edit_image_name(self):
+ self.setUpDistroSeries()
+ credentials = self.factory.makeOCIRegistryCredentials()
+ with person_logged_in(self.distribution.owner):
+ self.distribution.oci_registry_credentials = credentials
+ oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+ recipe = self.factory.makeOCIRecipe(
+ registrant=self.person, owner=self.person, oci_project=oci_project)
+ browser = self.getViewBrowser(
+ recipe, view_name="+edit", user=recipe.owner)
+ image_name = self.factory.getUniqueUnicode()
+ field = browser.getControl(name="field.image_name")
+ field.value = image_name
+ browser.getControl("Update OCI recipe").click()
+ content = find_main_content(browser.contents)
+ self.assertThat(
+ "Registry image name\n{}".format(image_name),
+ MatchesTagText(content, "image-name"))
+
def test_edit_with_invisible_processor(self):
# It's possible for existing recipes to have an enabled processor
# that's no longer usable with the current distroseries, which will
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 1f7a6c7..86c235b 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -155,6 +155,18 @@
interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
</securedutility>
+ <!-- OCIDistributionPushRule -->
+ <class class="lp.oci.model.ocipushrule.OCIDistributionPushRule">
+ <require
+ permission="launchpad.View"
+ interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleView
+ lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
+ <require
+ permission="launchpad.Edit"
+ interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleEdit"
+ set_schema="lp.oci.interfaces.ocipushrule.IOCIPushRuleEditableAttributes" />
+ </class>
+
<!-- OCI related jobs -->
<securedutility
component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 131c4d3..51cf370 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -280,6 +280,11 @@ class IOCIRecipeView(Interface):
"Whether everything is set up to allow uploading builds of "
"this OCI recipe to a registry."))
+ use_distribution_credentials = Bool(
+ title=_("Use Distribution credentials"), required=True, readonly=True,
+ description=_("Use the credentials on a Distribution for "
+ "registry upload"))
+
def requestBuild(requester, architecture):
"""Request that the OCI recipe is built.
@@ -434,6 +439,13 @@ class IOCIRecipeEditableAttributes(IHasOwner):
description=_("If True, this recipe should be built daily."),
readonly=False))
+ image_name = exported(TextLine(
+ title=_("Image name"),
+ description=_("Image name to use on upload to registry. "
+ "Defaults to recipe name if not set."),
+ required=False,
+ readonly=False))
+
class IOCIRecipeAdminAttributes(Interface):
"""`IOCIRecipe` attributes that can be edited by admins.
diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
index 24d5e27..f7736e6 100644
--- a/lib/lp/oci/model/ocipushrule.py
+++ b/lib/lp/oci/model/ocipushrule.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
+ 'OCIDistributionPushRule',
'OCIPushRule',
'OCIPushRuleSet',
]
@@ -72,6 +73,27 @@ class OCIPushRule(Storm):
IStore(OCIPushRule).remove(self)
+@implementer(IOCIPushRule)
+class OCIDistributionPushRule:
+ """A none-database instance that is synthesised from data elsewhere."""
+
+ registry_credentials = None
+
+ def __init__(self, recipe, registry_credentials, image_name):
+ self.id = -1
+ self.recipe = recipe
+ self.registry_credentials = registry_credentials
+ self.image_name = image_name
+
+ @property
+ def registry_url(self):
+ return self.registry_credentials.url
+
+ @property
+ def username(self):
+ return self.registry_credentials.username
+
+
@implementer(IOCIPushRuleSet)
class OCIPushRuleSet:
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index ab7f655..34fd348 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -74,7 +74,10 @@ from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
from lp.oci.interfaces.ociregistrycredentials import (
IOCIRegistryCredentialsSet,
)
-from lp.oci.model.ocipushrule import OCIPushRule
+from lp.oci.model.ocipushrule import (
+ OCIDistributionPushRule,
+ OCIPushRule,
+ )
from lp.oci.model.ocirecipebuild import OCIRecipeBuild
from lp.oci.model.ocirecipejob import OCIRecipeJob
from lp.registry.interfaces.distribution import IDistributionSet
@@ -160,10 +163,13 @@ class OCIRecipe(Storm, WebhookTargetMixin):
build_daily = Bool(name="build_daily", default=False)
+ _image_name = Unicode(name="image_name", allow_none=True)
+
def __init__(self, name, registrant, owner, oci_project, git_ref,
description=None, official=False, require_virtualized=True,
build_file=None, build_daily=False, date_created=DEFAULT,
- allow_internet=True, build_args=None, build_path=None):
+ allow_internet=True, build_args=None, build_path=None,
+ image_name=None):
if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
raise OCIRecipeFeatureDisabled()
super(OCIRecipe, self).__init__()
@@ -182,6 +188,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
self.allow_internet = allow_internet
self.build_args = build_args or {}
self.build_path = build_path
+ self.image_name = image_name
def __repr__(self):
return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
@@ -463,10 +470,18 @@ class OCIRecipe(Storm, WebhookTargetMixin):
@property
def push_rules(self):
+ # if we're in a distribution that has credentials set at that level
+ # create a push rule using those credentials
+ if self.use_distribution_credentials:
+ push_rule = OCIDistributionPushRule(
+ self,
+ self.oci_project.distribution.oci_registry_credentials,
+ self.image_name)
+ return [push_rule]
rules = IStore(self).find(
OCIPushRule,
OCIPushRule.recipe == self.id)
- return rules
+ return list(rules)
@property
def _pending_states(self):
@@ -527,7 +542,20 @@ class OCIRecipe(Storm, WebhookTargetMixin):
@property
def can_upload_to_registry(self):
- return not self.push_rules.is_empty()
+ return bool(self.push_rules)
+
+ @property
+ def use_distribution_credentials(self):
+ distribution = self.oci_project.distribution
+ return distribution and distribution.oci_registry_credentials
+
+ @property
+ def image_name(self):
+ return self._image_name or self.name
+
+ @image_name.setter
+ def image_name(self, value):
+ self._image_name = value
def newPushRule(self, registrant, registry_url, image_name, credentials,
credentials_owner=None):
@@ -569,7 +597,8 @@ class OCIRecipeSet:
def new(self, name, registrant, owner, oci_project, git_ref, build_file,
description=None, official=False, require_virtualized=True,
build_daily=False, processors=None, date_created=DEFAULT,
- allow_internet=True, build_args=None, build_path=None):
+ allow_internet=True, build_args=None, build_path=None,
+ image_name=None):
"""See `IOCIRecipeSet`."""
if not registrant.inTeam(owner):
if owner.is_team:
@@ -594,7 +623,7 @@ class OCIRecipeSet:
oci_recipe = OCIRecipe(
name, registrant, owner, oci_project, git_ref, description,
official, require_virtualized, build_file, build_daily,
- date_created, allow_internet, build_args, build_path)
+ date_created, allow_internet, build_args, build_path, image_name)
store.add(oci_recipe)
if processors is None:
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index 9a9aefc..662c742 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -81,6 +81,12 @@
<pre tal:content="build_args" />
</dd>
</dl>
+ <dl id="image-name" tal:condition="context/use_distribution_credentials">
+ <dt>Registry image name</dt>
+ <dd>
+ <span tal:content="context/image_name" />
+ </dd>
+ </dl>
</div>
<h2>Latest builds</h2>
@@ -144,35 +150,42 @@
</div>
- <h2>Recipe push rules</h2>
- <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
- style="margin-bottom: 1em; ">
- <thead>
- <tr>
- <th>Registry URL</th>
- <th>Username</th>
- <th>Image Name</th>
- </tr>
- </thead>
- <tbody>
- <tal:recipe-push-rules repeat="item view/push_rules">
- <tr tal:define="rule item;
- show_credentials rule/registry_credentials/required:launchpad.View"
- tal:attributes="id string:rule-${rule/id}">
- <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
- <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
- <td tal:content="rule/image_name"/>
+ <div tal:condition=context/use_distribution_credentials>
+ <h3>Registry upload</h3>
+ <p>This recipe will use the registry credentials set by the parent distribution</p>
+ </div>
+
+ <div tal:condition="not: context/use_distribution_credentials">
+ <h2>Recipe push rules</h2>
+ <table id="push-rules-listing" tal:condition="view/has_push_rules" class="listing"
+ style="margin-bottom: 1em; ">
+ <thead>
+ <tr>
+ <th>Registry URL</th>
+ <th>Username</th>
+ <th>Image Name</th>
</tr>
- </tal:recipe-push-rules>
- </tbody>
- </table>
- <p tal:condition="not: view/has_push_rules">
- This OCI recipe has no push rules defined yet.
- </p>
+ </thead>
+ <tbody>
+ <tal:recipe-push-rules repeat="item view/push_rules">
+ <tr tal:define="rule item;
+ show_credentials rule/registry_credentials/required:launchpad.View"
+ tal:attributes="id string:rule-${rule/id}">
+ <td tal:content="python: rule.registry_credentials.url if show_credentials else ''"/>
+ <td tal:content="python: rule.registry_credentials.username if show_credentials else ''"/>
+ <td tal:content="rule/image_name"/>
+ </tr>
+ </tal:recipe-push-rules>
+ </tbody>
+ </table>
+ <p tal:condition="not: view/has_push_rules">
+ This OCI recipe has no push rules defined yet.
+ </p>
- <div tal:define="link context/menu:context/edit_push_rules"
- tal:condition="link/enabled">
- <tal:edit-push-rules replace="structure link/fmt:link"/>
+ <div tal:define="link context/menu:context/edit_push_rules"
+ tal:condition="link/enabled">
+ <tal:edit-push-rules replace="structure link/fmt:link"/>
+ </div>
</div>
</div>
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
index 1cae71e..00d9b08 100644
--- a/lib/lp/oci/templates/ocirecipe-new.pt
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -41,6 +41,11 @@
<tal:widget define="widget nocall:view/widgets/processors">
<metal:block use-macro="context/@@launchpad_form/widget_row" />
</tal:widget>
+ <span tal:condition="view/use_distribution_credentials">
+ <tal:widget define="widget nocall:view/widgets/image_name" >
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </span>
</table>
</metal:formbody>
</div>
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index a0b1c59..699954a 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -749,6 +749,36 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
"VAR3": "A string",
}, recipe.build_args)
+ def test_use_distribution_credentials_set(self):
+ distribution = self.factory.makeDistribution()
+ credentials = self.factory.makeOCIRegistryCredentials()
+ with person_logged_in(distribution.owner):
+ distribution.oci_registry_credentials = credentials
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ self.assertTrue(recipe.use_distribution_credentials)
+
+ def test_use_distribution_credentials_not_set(self):
+ distribution = self.factory.makeDistribution()
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ self.assertFalse(recipe.use_distribution_credentials)
+
+ def test_image_name_set(self):
+ distribution = self.factory.makeDistribution()
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ image_name = self.factory.getUniqueUnicode()
+ with person_logged_in(recipe.owner):
+ recipe.image_name = image_name
+ self.assertEqual(image_name, removeSecurityProxy(recipe)._image_name)
+
+ def test_image_name_not_set(self):
+ distribution = self.factory.makeDistribution()
+ project = self.factory.makeOCIProject(pillar=distribution)
+ recipe = self.factory.makeOCIRecipe(oci_project=project)
+ self.assertEqual(recipe.name, recipe.image_name)
+
class TestOCIRecipeProcessors(TestCaseWithFactory):
@@ -1284,6 +1314,29 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
self.assertEqual(
image_name, push_rules["entries"][0]["image_name"])
+ def test_api_set_image_name(self):
+ """Can you set and retrieve the image name via the API?"""
+ self.setConfig()
+
+ image_name = self.factory.getUniqueUnicode()
+
+ with person_logged_in(self.person):
+ oci_project = self.factory.makeOCIProject(
+ registrant=self.person)
+ recipe = self.factory.makeOCIRecipe(
+ oci_project=oci_project, owner=self.person,
+ registrant=self.person)
+ url = api_url(recipe)
+
+ resp = self.webservice.patch(
+ url, 'application/json',
+ json.dumps({'image_name': image_name}))
+
+ self.assertEqual(209, resp.status, resp.body)
+
+ ws_project = self.load_from_api(url)
+ self.assertEqual(image_name, ws_project['image_name'])
+
class TestOCIRecipeAsyncWebservice(TestCaseWithFactory):
layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/oci/tests/test_ociregistryclient.py b/lib/lp/oci/tests/test_ociregistryclient.py
index ecfaee3..403662d 100644
--- a/lib/lp/oci/tests/test_ociregistryclient.py
+++ b/lib/lp/oci/tests/test_ociregistryclient.py
@@ -69,6 +69,7 @@ from lp.services.compat import mock
from lp.services.features.testing import FeatureFixture
from lp.testing import (
admin_logged_in,
+ person_logged_in,
TestCaseWithFactory,
)
from lp.testing.fixture import ZopeUtilityFixture
@@ -226,6 +227,7 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
}))
@responses.activate
+<<<<<<< lib/lp/oci/tests/test_ociregistryclient.py
def test_upload_ignores_superseded_builds(self):
self.build.updateStatus(BuildStatus.FULLYBUILT)
recipe = self.build.recipe
@@ -249,6 +251,56 @@ class TestOCIRegistryClient(OCIConfigHelperMixin, SpyProxyCallsMixin,
OCIRecipeBuildRegistryUploadStatus.SUPERSEDED,
self.build.registry_upload_status)
self.assertEqual(0, len(responses.calls))
+=======
+ def test_upload_with_distribution_credentials(self):
+ self._makeFiles()
+ self.useFixture(MockPatch(
+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload"))
+ self.useFixture(MockPatch(
+ "lp.oci.model.ociregistryclient.OCIRegistryClient._upload_layer",
+ return_value=999))
+ credentials = self.factory.makeOCIRegistryCredentials()
+ image_name = self.factory.getUniqueUnicode()
+ self.build.recipe.image_name = image_name
+ distro = self.build.recipe.oci_project.distribution
+ with person_logged_in(distro.owner):
+ distro.oci_registry_credentials = credentials
+ # we have distribution credentials, we should have a 'push rule'
+ push_rule = self.build.recipe.push_rules[0]
+ responses.add("GET", "%s/v2/" % push_rule.registry_url, status=200)
+ self.addManifestResponses(push_rule)
+
+ self.client.upload(self.build)
+
+ request = json.loads(responses.calls[1].request.body)
+
+ self.assertThat(request, MatchesDict({
+ "layers": MatchesListwise([
+ MatchesDict({
+ "mediaType": Equals(
+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+ "digest": Equals("diff_id_1"),
+ "size": Equals(999)}),
+ MatchesDict({
+ "mediaType": Equals(
+ "application/vnd.docker.image.rootfs.diff.tar.gzip"),
+ "digest": Equals("diff_id_2"),
+ "size": Equals(999)})
+ ]),
+ "schemaVersion": Equals(2),
+ "config": MatchesDict({
+ "mediaType": Equals(
+ "application/vnd.docker.container.image.v1+json"),
+ "digest": Equals(
+ "sha256:33b69b4b6e106f9fc7a8b93409"
+ "36c85cf7f84b2d017e7b55bee6ab214761f6ab"),
+ "size": Equals(52)
+ }),
+ "mediaType": Equals(
+ "application/vnd.docker.distribution.manifest.v2+json")
+ }))
+
+>>>>>>> lib/lp/oci/tests/test_ociregistryclient.py
@responses.activate
def test_upload_formats_credentials(self):
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 7c97dc5..6be995f 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -2275,7 +2275,7 @@
for="lp.registry.interfaces.distribution.IDistribution"
class="lp.registry.browser.distribution.DistributionEditView"
permission="launchpad.Edit"
- template="../../app/templates/generic-edit.pt"
+ template="../templates/distribution-edit.pt"
/>
<browser:page
name="+admin"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index c499689..25950ae 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -49,10 +49,15 @@ from zope.formlib.boolwidgets import CheckBoxWidget
from zope.formlib.widget import CustomWidgetFactory
from zope.interface import implementer
from zope.lifecycleevent import ObjectCreatedEvent
-from zope.schema import Bool
+from zope.schema import (
+ Bool,
+ Password,
+ TextLine,
+ )
from zope.security.checker import canWrite
from zope.security.interfaces import Unauthorized
+from lp import _
from lp.answers.browser.faqtarget import FAQTargetNavigationMixin
from lp.answers.browser.questiontarget import QuestionTargetTraversalMixin
from lp.app.browser.launchpadform import (
@@ -80,6 +85,9 @@ from lp.bugs.browser.structuralsubscription import (
)
from lp.buildmaster.interfaces.processor import IProcessorSet
from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
+from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ )
from lp.registry.browser import (
add_subscribe_link,
RegistryEditFormView,
@@ -1019,6 +1027,101 @@ class DistributionEditView(RegistryEditFormView,
"""See `LaunchpadFormView`."""
return 'Change %s details' % self.context.displayname
+ def createOCICredentials(self):
+ return form.Fields(
+ TextLine(
+ __name__='oci_credentials_url',
+ title=u"Registry URL",
+ description=(
+ u"URL for the OCI registry to upload images to."
+ ),
+ required=False),
+ TextLine(
+ __name__='oci_credentials_region',
+ title=u"OCI registry region",
+ description=u"Region for the OCI Registry.",
+ required=False),
+ TextLine(
+ __name__='oci_credentials_username',
+ title=u"OCI registry username",
+ description=u"Username for the OCI Registry.",
+ required=False),
+ Password(
+ __name__='oci_credentials_password',
+ title=u"OCI registry password",
+ description=u"Password for the OCI Registry.",
+ required=False),
+ Password(
+ __name__='oci_credentials_confirm_password',
+ title=u"Confirm password",
+ required=False),
+ Bool(
+ __name__='oci_credentials_delete',
+ title=u"Delete",
+ description=u"Delete these credentials.",
+ required=False,)
+ )
+
+ def changeOCICredentials(self, data):
+ delete = data.pop("oci_credentials_delete", None)
+ if delete and self.context.oci_registry_credentials:
+ credentials = self.context.oci_registry_credentials
+ self.context.oci_registry_credentials = None
+ credentials.destroySelf()
+ return
+
+ url = data.pop("oci_credentials_url", None)
+ username = data.pop("oci_credentials_username", None)
+ region = data.pop("oci_credentials_region", None)
+ # validated against confirm password in validateOCICredentials
+ password = data.pop("oci_credentials_password", None)
+ if "oci_credentials_confirm_password" in data:
+ del(data["oci_credentials_confirm_password"])
+
+ # If we're not deleting, but don't have a url, then don't do anything
+ if not url:
+ return
+
+ current_credentials = self.context.oci_registry_credentials
+ if current_credentials:
+ current_credentials.url = url
+ current_credentials.setCredentials({
+ "username": username,
+ "password": password,
+ "region": region})
+ return
+ credentials = getUtility(IOCIRegistryCredentialsSet).new(
+ self.context.owner,
+ self.context.owner,
+ url,
+ {"username": username,
+ "password": password,
+ "region": region})
+ self.context.oci_registry_credentials = credentials
+
+ def validateOCICredentials(self, data):
+ # if we're deleting credentials, we don't need to validate
+ if data.get("oci_credentials_delete"):
+ return
+ url = data.get("oci_credentials_url")
+ username = data.get("oci_credentials_username")
+ if username and not url:
+ self.setFieldError(
+ 'oci_credentials_url',
+ _("A URL is required if a username is present."))
+ password = data.get("oci_credentials_password")
+ confirm_password = data.get("oci_credentials_confirm_password")
+ if password != confirm_password:
+ self.setFieldError(
+ "oci_credentials_password",
+ _("Passwords must match."))
+ existing_credentials = self.context.oci_registry_credentials
+ if existing_credentials and not url:
+ self.setFieldError(
+ "oci_credentials_url",
+ _("URL must be specified. "
+ "Delete credentials to unset URL."))
+
def setUpFields(self):
"""See `LaunchpadFormView`."""
RegistryEditFormView.setUpFields(self)
@@ -1027,14 +1130,22 @@ class DistributionEditView(RegistryEditFormView,
getUtility(IProcessorSet).getAll(),
u"The architectures on which the distribution's main archive can "
u"build.")
+ self.form_fields += self.createOCICredentials()
@property
def initial_values(self):
- return {
+ data = {
'require_virtualized':
self.context.main_archive.require_virtualized,
'processors': self.context.main_archive.processors,
}
+ # Do OCI initial values
+ oci_credentials = self.context.oci_registry_credentials
+ if oci_credentials:
+ data["oci_credentials_url"] = oci_credentials.url
+ data["oci_credentials_username"] = oci_credentials.username
+ data["oci_credentials_region"] = oci_credentials.region
+ return data
def validate(self, data):
"""Constrain bug expiration to Launchpad Bugs tracker."""
@@ -1044,6 +1155,7 @@ class DistributionEditView(RegistryEditFormView,
official_malone = data.get('official_malone', False)
if not official_malone:
data['enable_bug_expiration'] = False
+ self.validateOCICredentials(data)
def change_archive_fields(self, data):
# Update context.main_archive.
@@ -1063,6 +1175,7 @@ class DistributionEditView(RegistryEditFormView,
@action("Change", name='change')
def change_action(self, action, data):
self.change_archive_fields(data)
+ self.changeOCICredentials(data)
self.updateContextFromData(data)
diff --git a/lib/lp/registry/browser/tests/test_distribution_views.py b/lib/lp/registry/browser/tests/test_distribution_views.py
index fde5a8a..195560d 100644
--- a/lib/lp/registry/browser/tests/test_distribution_views.py
+++ b/lib/lp/registry/browser/tests/test_distribution_views.py
@@ -9,6 +9,7 @@ from zope.component import getUtility
from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.oci.tests.helpers import OCIConfigHelperMixin
from lp.registry.browser.distribution import DistributionPublisherConfigView
from lp.registry.enums import DistributionDefaultTraversalPolicy
from lp.registry.interfaces.distribution import IDistributionSet
@@ -183,7 +184,7 @@ class TestDistroAddView(TestCaseWithFactory):
self.assertContentEqual([], distribution.main_archive.processors)
-class TestDistroEditView(TestCaseWithFactory):
+class TestDistroEditView(OCIConfigHelperMixin, TestCaseWithFactory):
"""Test the +edit page for a distribution."""
layer = DatabaseFunctionalLayer
@@ -193,6 +194,7 @@ class TestDistroEditView(TestCaseWithFactory):
self.admin = login_celebrity('admin')
self.distribution = self.factory.makeDistribution()
self.all_processors = getUtility(IProcessorSet).getAll()
+ self.setConfig()
def test_edit_distro_init_value_require_virtualized(self):
view = create_initialized_view(
@@ -260,6 +262,125 @@ class TestDistroEditView(TestCaseWithFactory):
method="POST", form=edit_form)
self.assertEqual(self.distribution.package_derivatives_email, email)
+ def test_oci_validation_username_no_url(self):
+ edit_form = self.getDefaultEditDict()
+ edit_form["field.oci_credentials_username"] = "username"
+
+ view = create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertEqual(
+ "A URL is required if a username is present.",
+ view.getFieldError("oci_credentials_url"))
+
+ def test_oci_validation_different_passwords(self):
+ edit_form = self.getDefaultEditDict()
+ edit_form["field.oci_credentials_password"] = "password1"
+ edit_form["field.oci_credentials_confirm_password"] = "password2"
+ view = create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertEqual(
+ "Passwords must match.",
+ view.getFieldError("oci_credentials_password"))
+
+ def test_oci_validation_url_unset(self):
+ edit_form = self.getDefaultEditDict()
+ edit_form["field.oci_credentials_url"] = ""
+
+ credentials = self.factory.makeOCIRegistryCredentials(
+ registrant=self.distribution.owner,
+ owner=self.distribution.owner)
+ self.distribution.oci_registry_credentials = credentials
+
+ view = create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertEqual(
+ "URL must be specified. Delete credentials to unset URL.",
+ view.getFieldError("oci_credentials_url"))
+
+ def test_oci_create_credentials_url_only(self):
+ edit_form = self.getDefaultEditDict()
+ registry_url = self.factory.getUniqueURL()
+ edit_form["field.oci_credentials_url"] = registry_url
+
+ create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertEqual(
+ registry_url, self.distribution.oci_registry_credentials.url)
+
+ def test_oci_create_credentials(self):
+ edit_form = self.getDefaultEditDict()
+ registry_url = self.factory.getUniqueURL()
+ username = self.factory.getUniqueUnicode()
+ password = self.factory.getUniqueUnicode()
+ edit_form["field.oci_credentials_url"] = registry_url
+ edit_form["field.oci_credentials_username"] = username
+ edit_form["field.oci_credentials_password"] = password
+ edit_form["field.oci_credentials_confirm_password"] = password
+
+ create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertEqual(
+ username, self.distribution.oci_registry_credentials.username)
+
+ def test_oci_create_credentials_change_url(self):
+ edit_form = self.getDefaultEditDict()
+ credentials = self.factory.makeOCIRegistryCredentials(
+ registrant=self.distribution.owner,
+ owner=self.distribution.owner)
+ self.distribution.oci_registry_credentials = credentials
+ registry_url = self.factory.getUniqueURL()
+ edit_form["field.oci_credentials_url"] = registry_url
+
+ create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertEqual(
+ registry_url, self.distribution.oci_registry_credentials.url)
+ # This should have mutated, not created new credentials records
+ self.assertEqual(
+ credentials.id, self.distribution.oci_registry_credentials.id)
+
+ def test_oci_create_credentials_change_password(self):
+ edit_form = self.getDefaultEditDict()
+ credentials = self.factory.makeOCIRegistryCredentials(
+ registrant=self.distribution.owner,
+ owner=self.distribution.owner)
+ self.distribution.oci_registry_credentials = credentials
+ password = self.factory.getUniqueUnicode()
+ edit_form["field.oci_credentials_url"] = credentials.url
+ edit_form["field.oci_credentials_username"] = credentials.username
+ edit_form["field.oci_credentials_password"] = password
+ edit_form["field.oci_credentials_confirm_password"] = password
+
+ create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ distro_credentials = self.distribution.oci_registry_credentials
+ unencrypted_credentials = distro_credentials.getCredentials()
+ self.assertEqual(
+ password, unencrypted_credentials["password"])
+ # This should not have changed
+ self.assertEqual(
+ distro_credentials.url, credentials.url)
+
+ def test_oci_delete_credentials(self):
+ edit_form = self.getDefaultEditDict()
+ credentials = self.factory.makeOCIRegistryCredentials(
+ registrant=self.distribution.owner,
+ owner=self.distribution.owner)
+ self.distribution.oci_registry_credentials = credentials
+ edit_form['field.oci_credentials_delete'] = 'on'
+
+ create_initialized_view(
+ self.distribution, '+edit', principal=self.admin,
+ method='POST', form=edit_form)
+ self.assertIsNone(self.distribution.oci_registry_credentials)
+
class TestDistributionAdminView(TestCaseWithFactory):
"""Test the +admin page for a distribution."""
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 7c2dad8..7a7a396 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1833,6 +1833,7 @@
mirror_admin
mugshot
oci_project_admin
+ oci_registry_credentials
official_answers
official_blueprints
official_malone
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 8c23b90..6d13dc5 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -73,6 +73,7 @@ from lp.bugs.interfaces.bugtarget import (
from lp.bugs.interfaces.structuralsubscription import (
IStructuralSubscriptionTarget,
)
+from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentials
from lp.registry.enums import (
DistributionDefaultTraversalPolicy,
VCSType,
@@ -719,6 +720,13 @@ class IDistributionPublic(
def newOCIProject(registrant, name, description=None):
"""Create an `IOCIProject` for this distro."""
+ oci_registry_credentials = Reference(
+ IOCIRegistryCredentials,
+ title=_("OCI registry credentials"),
+ description=_("Credentials and URL to use for uploading all OCI "
+ "Images in this distribution to a registry."),
+ required=False, readonly=True)
+
@exported_as_webservice_entry(as_of="beta")
class IDistribution(
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index a3d4062..cceef00 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -33,6 +33,10 @@ from storm.expr import (
SQL,
)
from storm.info import ClassAlias
+from storm.locals import (
+ Int,
+ Reference,
+ )
from storm.store import Store
from zope.component import getUtility
from zope.interface import implementer
@@ -267,6 +271,9 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
enum=DistributionDefaultTraversalPolicy, notNull=False,
default=DistributionDefaultTraversalPolicy.SERIES)
redirect_default_traversal = BoolCol(notNull=False, default=False)
+ oci_registry_credentialsID = Int(name='oci_credentials', allow_none=True)
+ oci_registry_credentials = Reference(
+ oci_registry_credentialsID, "OCIRegistryCredentials.id")
def __repr__(self):
display_name = self.display_name.encode('ASCII', 'backslashreplace')
diff --git a/lib/lp/registry/templates/distribution-edit.pt b/lib/lp/registry/templates/distribution-edit.pt
new file mode 100644
index 0000000..a8562b9
--- /dev/null
+++ b/lib/lp/registry/templates/distribution-edit.pt
@@ -0,0 +1,98 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_side"
+ i18n:domain="launchpad"
+>
+<body>
+
+<tal:main metal:fill-slot="main">
+
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <metal:formbody fill-slot="widgets">
+ <table class="form">
+ <tal:widget define="widget nocall:view/widgets/display_name">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/summary">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/description">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/bug_reporting_guidelines">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/bug_reported_acknowledgement">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/package_derivatives_email">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/icon">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/logo">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/mugshot">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/official_malone">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/enable_bug_expiration">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/blueprints_usage">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/translations_usage">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/answers_usage">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/translation_focus">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/default_traversal_policy">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/redirect_default_traversal">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+
+ <tr>
+ <td><label>OCI registry credentials</label></td>
+ <tr>
+ <tr>
+ <td tal:define="widget nocall:view/widgets/oci_credentials_url">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/oci_credentials_region">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/oci_credentials_username">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/oci_credentials_password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/oci_credentials_confirm_password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/oci_credentials_delete">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ </tr>
+ </table>
+ </metal:formbody>
+ </div>
+
+</tal:main>
+
+</body>
+</html>
Follow ups