← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:oci-policy-distribute-the-credentials into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:oci-policy-distribute-the-credentials into launchpad:master with ~twom/launchpad:db-oci-policy-distribute-the-credentials as a prerequisite.

Commit message:
Add OCI Credentials to the Distribution Edit page

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/395788

Use the OCIRegistryCredentials reference in Distribution to allow adding credentials to an Distribution.
UI similar to existing credentials management, but with only a single row.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-policy-distribute-the-credentials into launchpad:master.
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>