← Back to team overview

launchpad-reviewers team mailing list archive

[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