← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ilasc/launchpad:ui-oci-reg-creds into launchpad:master

 

Ioana Lasc has proposed merging ~ilasc/launchpad:ui-oci-reg-creds into launchpad:master.

Commit message:
Add CRUD Views for OCI registry credentials and push rules

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This branch adds several views in Launchpad for the management of OCI registry credentials and push rules:

View OCI push rules in a table in the context of an OCI recipe. From here allow the user to  create, edit or delete entirely any push rule for the recipe they are viewing.

View, edit, delete OCI registry credentials from the context of existing push rules.

View and edit only (without ability to add) OCI registry credentials from the Person Page.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:ui-oci-reg-creds into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index 3ee0af0..ea702a2 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -55,6 +55,30 @@
             template="../../app/templates/generic-edit.pt" />
         <browser:page
             for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeEditPushRulesView"
+            permission="launchpad.Edit"
+            name="+edit-push-rules"
+            template="../templates/ocirecipe-edit-push-rules.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeAddPushRulesView"
+            permission="launchpad.Edit"
+            name="+add-push-rules"
+            template="../templates/ocirecipe-add-push-rules.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeAddCredentialsView"
+            permission="launchpad.Edit"
+            name="+add_oci_creds"
+            template="../templates/ocirecipe-add-oci-creds.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeEditCredentialsView"
+            permission="launchpad.Edit"
+            name="+edit-credentials"
+            template="../templates/ocirecipe-edit-reg-creds.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
             class="lp.oci.browser.ocirecipe.OCIRecipeDeleteView"
             permission="launchpad.Edit"
             name="+delete"
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index fcced66..add27c7 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -16,6 +16,7 @@ __all__ = [
     'OCIRecipeNavigationMenu',
     'OCIRecipeRequestBuildsView',
     'OCIRecipeView',
+    'OCIRecipeEditPushRulesView',
     ]
 
 from lazr.restful.interface import (
@@ -28,6 +29,7 @@ from zope.schema import (
     Choice,
     List,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.browser.launchpadform import (
     action,
@@ -36,10 +38,16 @@ from lp.app.browser.launchpadform import (
     )
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
-from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.app.widgets.itemswidgets import (
+    LabeledMultiCheckBoxWidget,
+    LaunchpadRadioWidget,
+    )
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.code.browser.widgets.gitref import GitRefWidget
-from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
+from lp.oci.interfaces.ocipushrule import (
+    IOCIPushRuleSet,
+    OCIPushRuleAlreadyExists,
+    )
 from lp.oci.interfaces.ocirecipe import (
     IOCIRecipe,
     IOCIRecipeSet,
@@ -50,6 +58,10 @@ from lp.oci.interfaces.ocirecipe import (
     OCIRecipeFeatureDisabled,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.oci.interfaces.ociregistrycredentials import (
+    IOCIRegistryCredentialsSet,
+    OCIRegistryCredentialsAlreadyExist,
+    )
 from lp.services.features import getFeatureFlag
 from lp.services.helpers import english_list
 from lp.services.propertycache import cachedproperty
@@ -137,12 +149,31 @@ class OCIRecipeContextMenu(ContextMenu):
 
     facet = 'overview'
 
-    links = ('request_builds',)
+    links = ('request_builds', 'edit_push_rules', 'add_push_rules',
+             'add_oci_creds')
 
     @enabled_with_permission('launchpad.Edit')
     def request_builds(self):
         return Link('+request-builds', 'Request builds', icon='add')
 
+    @enabled_with_permission('launchpad.Edit')
+    def edit_push_rules(self):
+        return Link('+edit-push-rules',
+                    'Edit push rules / Add registry credentials',
+                    icon='edit')
+
+    @enabled_with_permission('launchpad.Edit')
+    def add_push_rules(self):
+        return Link('+add-push-rules',
+                    'Add new push rules',
+                    icon='add')
+
+    @enabled_with_permission('launchpad.Edit')
+    def add_oci_creds(self):
+        return Link('+add_oci_creds',
+                    'Add new OCI registry credentials',
+                    icon='add')
+
 
 class OCIRecipeView(LaunchpadView):
     """Default view of an OCI recipe."""
@@ -151,6 +182,15 @@ class OCIRecipeView(LaunchpadView):
     def builds(self):
         return builds_for_recipe(self.context)
 
+    @cachedproperty
+    def push_rules(self):
+        return list(
+            getUtility(IOCIPushRuleSet).findByRecipe(self.context))
+
+    @cachedproperty
+    def has_push_rules(self):
+        return len(self.push_rules) > 0
+
     @property
     def person_picker(self):
         field = copy_field(
@@ -201,6 +241,251 @@ def new_builds_notification_text(builds, already_pending=None):
         return builds_text
 
 
+class OCIRecipeAddCredentialsView(LaunchpadFormView):
+    class schema(Interface):
+        """Schema for editing registry credentials."""
+
+    @property
+    def oci_registry_creds(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
+
+    @property
+    def label(self):
+        return 'Edit OCI Registry Credentials'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action("Save")
+    def save(self, action, data):
+        form = self.request.form
+        url = form.get('newurl')
+        username = form.get('newusername')
+        password = form.get('newpassword')
+        confirm_password = form.get('confirmnewpassword')
+
+        # Add new credentials
+        if url or username or password or confirm_password:
+            if url:
+                if password or confirm_password:
+                    if password == confirm_password:
+                        # create registry_credentials object here
+                        credentials = {
+                            'username': username,
+                            'password': password}
+                        try:
+                            getUtility(IOCIRegistryCredentialsSet).new(
+                                owner=self.context.owner,
+                                url=url,
+                                credentials=credentials)
+                            self.next_url = self.cancel_url
+                        except OCIRegistryCredentialsAlreadyExist:
+                            self.request.response.addErrorNotification(
+                                "Credentials already exist with the "
+                                "same URL and username.")
+                    else:
+                        self.request.response.addErrorNotification(
+                            "Please make sure the new password matches the "
+                            "confirm password field.")
+                else:
+                    credentials = {'username': username}
+                    try:
+                        getUtility(IOCIRegistryCredentialsSet).new(
+                            owner=self.context.owner,
+                            url=url,
+                            credentials=credentials)
+                        self.next_url = self.cancel_url
+                    except OCIRegistryCredentialsAlreadyExist:
+                        self.request.response.addErrorNotification(
+                            "Credentials already exist with the "
+                            "same URL and username.")
+            else:
+                self.request.response.addErrorNotification(
+                    "Registry URL cannot be empty.")
+
+
+class OCIRecipeEditCredentialsView(LaunchpadFormView):
+
+    class schema(Interface):
+        """Schema for editing registry credentials."""
+
+    @property
+    def oci_registry_creds(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
+
+    @property
+    def label(self):
+        return 'Edit OCI Registry Credentials'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action("Save")
+    def save(self, action, data):
+        form = self.request.form
+        for creds in self.oci_registry_creds:
+            url = form.get('url_%d' % creds.id)
+            username = form.get('username_%d' % creds.id)
+            confirm_password = form.get('confirm_edit_password_%d' % creds.id)
+            password = form.get('edit_password_%d' % creds.id)
+            if 'delete_%d' % creds.id in action.form.request.form:
+                # get no of push rules that use these credentials
+                push_rule = removeSecurityProxy(
+                    getUtility(
+                        IOCIPushRuleSet).getNumberByRegistryCredentials(creds))
+                if push_rule:
+                    self.request.response.addErrorNotification(
+                        "These credentials cannot be deleted as there are "
+                        "push rules defined that still use them.")
+                else:
+                    # only remove creds if no push rule is using them
+                    removeSecurityProxy(creds).destroySelf()
+                    self.next_url = self.cancel_url
+            else:
+                if not url:
+                    self.request.response.addErrorNotification(
+                        "Registry URL cannot be empty.")
+                else:
+                    if password or confirm_password:
+                        if password == confirm_password:
+                            creds.url = url
+                            credentials = {
+                                'username': username,
+                                'password': password}
+                            creds.setCredentials(credentials)
+                            self.next_url = self.cancel_url
+                        else:
+                            self.request.response.addErrorNotification(
+                                "Please make sure the new password matches"
+                                " the confirm password field.")
+                            return
+                    else:
+                        removeSecurityProxy(creds).username = username
+                        self.next_url = self.cancel_url
+
+
+class OCIRecipeEditPushRulesView(LaunchpadFormView):
+
+    class schema(Interface):
+        """Schema for editing push rules."""
+        existing_credentials = Choice(
+            vocabulary='OCIExistingRegistryCredentials',
+            title='Choose Existing Creds', required=False)
+
+    custom_widget_existing_credentials = LaunchpadRadioWidget
+
+    @property
+    def label(self):
+        return 'Edit OCI push rules for %s' % self.context.name
+
+    page_title = 'Edit OCI Push Rules'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @property
+    def initial_values(self):
+        """See `LaunchpadFormView`."""
+        return self.context.getExistingCredentials()
+
+    @action("Save")
+    def save(self, action, data):
+        form = self.request.form
+
+        for rule in self.context.push_rules:
+            if 'delete_%d' % rule.id in action.form.request.form:
+                removeSecurityProxy(rule).destroySelf()
+            else:
+                image_name = form.get('image_name_%d' % rule.id)
+                if not image_name:
+                    self.request.response.addErrorNotification(
+                        "Image Name must be set.")
+                else:
+                    removeSecurityProxy(rule).image_name = image_name
+        self.next_url = self.cancel_url
+
+
+class OCIRecipeAddPushRulesView(LaunchpadFormView):
+    class schema(Interface):
+        """Schema for editing push rules."""
+        existing_credentials = Choice(
+            vocabulary='OCIExistingRegistryCredentials',
+            title='Choose Existing Creds', required=False)
+
+    custom_widget_existing_credentials = LaunchpadRadioWidget
+
+    @property
+    def label(self):
+        return 'Add OCI push rule for %s' % self.context.name
+
+    page_title = 'Add OCI Push Rules'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @property
+    def initial_values(self):
+        """See `LaunchpadFormView`."""
+        return self.context.getExistingCredentials()
+
+    @action("Save")
+    def save(self, action, data):
+        form = self.request.form
+        url = form.get('newurl')
+        image_name = form.get('newimagename')
+        username = form.get('newusername')
+        password = form.get('newpassword')
+        confirm_password = form.get('confirm_newpassword')
+
+        if image_name:
+            if data.get('existing_credentials'):
+                try:
+                    getUtility(IOCIPushRuleSet).new(
+                        self.context,
+                        removeSecurityProxy(data.get('existing_credentials')),
+                        image_name)
+                    self.next_url = self.cancel_url
+                except OCIPushRuleAlreadyExists:
+                    self.request.response.addErrorNotification(
+                        "A push rule already exists with the "
+                        "same URL, image name and credentials.")
+            else:
+                if url:
+                    if password == confirm_password:
+                        credentials = {
+                            'username': username,
+                            'password': password}
+                        try:
+                            creds = getUtility(
+                                IOCIRegistryCredentialsSet).getOrCreate(
+                                owner=self.context.owner,
+                                url=url,
+                                credentials=credentials)
+                        except OCIRegistryCredentialsAlreadyExist:
+                            self.request.response.addErrorNotification(
+                                "Credentials already exist with the "
+                                "same URL and username.")
+                        getUtility(IOCIPushRuleSet).new(
+                            self.context, creds, image_name)
+                        self.next_url = self.cancel_url
+                    else:
+                        self.request.response.addErrorNotification(
+                            "Please make sure the new password matches the "
+                            "confirm password field.")
+                else:
+                    self.request.response.addErrorNotification(
+                        "Registry URL must be set.")
+        else:
+            self.request.response.addErrorNotification(
+                "Image name must be set.")
+
+
 class OCIRecipeRequestBuildsView(LaunchpadFormView):
     """A view for requesting builds of an OCI recipe."""
 
@@ -280,6 +565,7 @@ class IOCIRecipeEditSchema(Interface):
         "build_file",
         "build_daily",
         "require_virtualized",
+        "push_rules",
         ])
 
 
@@ -411,6 +697,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
         "git_ref",
         "build_file",
         "build_daily",
+        "push_rules",
         )
     custom_widget_git_ref = GitRefWidget
 
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 1dfb3df..5bce358 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -17,12 +17,15 @@ from fixtures import FakeLogger
 import pytz
 import soupmatchers
 from testtools.matchers import (
+    Equals,
+    MatchesDict,
     MatchesSetwise,
     MatchesStructure,
     )
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
 from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -33,11 +36,16 @@ from lp.oci.browser.ocirecipe import (
     OCIRecipeEditView,
     OCIRecipeView,
     )
+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
 from lp.oci.interfaces.ocirecipe import (
     CannotModifyOCIRecipeProcessor,
     IOCIRecipeSet,
     OCI_RECIPE_ALLOW_CREATE,
     )
+from lp.oci.interfaces.ociregistrycredentials import (
+    IOCIRegistryCredentialsSet,
+    )
+from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import get_property_cache
@@ -812,3 +820,334 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
         self.assertIn(
             "You need to select at least one architecture.",
             extract_text(find_main_content(browser.contents)))
+
+
+class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
+                                     BaseTestOCIRecipeView):
+    def setUp(self):
+        super(TestOCIRecipeEditPushRulesView, self).setUp()
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        self.distroseries = self.factory.makeDistroSeries(
+            distribution=self.ubuntu, name="shiny", displayname="Shiny")
+        self.architectures = []
+        for processor, architecture in ("386", "i386"), ("amd64", "amd64"):
+            das = self.factory.makeDistroArchSeries(
+                distroseries=self.distroseries, architecturetag=architecture,
+                processor=getUtility(IProcessorSet).getByName(processor))
+            das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+            self.architectures.append(das)
+        self.useFixture(FeatureFixture({
+            OCI_RECIPE_ALLOW_CREATE: "on",
+            "oci.build_series.%s" % self.distroseries.distribution.name:
+                self.distroseries.name,
+        }))
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution,
+            ociprojectname="oci-project-name")
+        self.recipe = self.factory.makeOCIRecipe(
+            name="recipe-name", registrant=self.person, owner=self.person,
+            oci_project=oci_project)
+
+        self.setConfig()
+
+    def test_oci_edit_push_rules(self):
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        # assert Image name is displayed correctly
+        self.assertEqual(image_name, browser.getControl(
+            name="image_name_%d" % removeSecurityProxy(push_rule).id).value)
+
+        # set image name to empty string and assert we get the notification
+        browser.getControl(
+            name="image_name_%d" %
+                 removeSecurityProxy(push_rule).id).value = ""
+        browser.getControl("Save").click()
+        self.assertTrue("Image Name must be set." in browser.contents)
+
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        # set image name to valid string
+        browser.getControl(
+            name="image_name_%d" %
+                 removeSecurityProxy(push_rule).id).value = "testimage1"
+        browser.getControl("Save").click()
+        # and assert model changed
+        self.assertTrue(
+            removeSecurityProxy(push_rule).image_name == "testimage1")
+
+        # delete push rule
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        # mark one line of credentials for delete
+        browser.getControl(
+            name="delete_%d" % removeSecurityProxy(
+                push_rule).id).getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        with person_logged_in(self.person):
+            self.assertIsNone(removeSecurityProxy(
+                getUtility(IOCIPushRuleSet).getByID(push_rule.id)))
+
+    def test_oci_add_push_rules(self):
+        url = unicode(self.factory.getUniqueURL())
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Add new push rules").click()
+
+        # Save is not possible without image name
+        browser.getControl("Save").click()
+        self.assertTrue("Image name must be set." in browser.contents)
+
+        # Only with image name we fail with Registry URL must be set
+        browser.getControl(
+            name="newimagename").value = "imagename1"
+        browser.getControl("Save").click()
+        self.assertTrue("Registry URL must be set." in browser.contents)
+
+        # Image name and Registry URL will create an empty Credentials object
+        # and a valid push rule based on the empty creds
+        browser.getControl(
+            name="newimagename").value = "imagename1"
+        browser.getControl(
+            name="newurl").value = url
+        browser.getControl("Save").click()
+        with person_logged_in(self.person):
+            rules = list(removeSecurityProxy(
+                getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+        self.assertIsNotNone(len(rules) == 1)
+        rule = rules[0]
+        self.assertIsNotNone(removeSecurityProxy(rule).registry_credentials)
+        self.assertEqual(u'imagename1', removeSecurityProxy(rule).image_name)
+        self.assertEqual(url, removeSecurityProxy(rule).registry_url)
+        self.assertEqual(url,
+                         removeSecurityProxy(rule).registry_credentials.url)
+        self.assertEqual(
+            u'',
+            removeSecurityProxy(rule).registry_credentials.username)
+        with person_logged_in(self.person):
+            self.assertThat(
+                removeSecurityProxy(
+                    rule).registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals(""),
+                     "password": Equals("")}))
+
+        # Previously added registry credentials can now be chosen
+        # from the radio widget when adding a new rule
+        browser.getLink("Add new push rules").click()
+        browser.getControl(
+            name="newimagename").value = "imagename1"
+        browser.getControl(
+            name="field.existing_credentials").value = (
+            browser.getControl(
+                name="field.existing_credentials").options[1].strip())
+        browser.getControl("Save").click()
+        self.assertTrue(
+            "A push rule already exists with the same URL, "
+            "image name and credentials." in browser.contents)
+
+        # We correctly display the radio buttons widget when
+        # username is empty in registry credentials and
+        # allow correctly adding new rule based on it
+        browser.getControl(
+            name="newimagename").value = "imagename2"
+        browser.getControl("Save").click()
+        with person_logged_in(self.person):
+            rules = list(removeSecurityProxy(
+                getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+        self.assertIsNotNone(len(rules) == 2)
+        rule = rules[1]
+        self.assertIsNotNone(removeSecurityProxy(rule).registry_credentials)
+        self.assertEqual(u'imagename2', removeSecurityProxy(rule).image_name)
+        self.assertEqual(url, removeSecurityProxy(rule).registry_url)
+        self.assertEqual(
+            url,
+            removeSecurityProxy(rule).registry_credentials.url)
+        self.assertEqual(
+            u'',
+            removeSecurityProxy(rule).registry_credentials.username)
+        with person_logged_in(self.person):
+            self.assertThat(
+                removeSecurityProxy(
+                    rule).registry_credentials.getCredentials(),
+                MatchesDict({
+                    "username": Equals(""),
+                    "password": Equals("")}))
+
+    def test_oci_registry_credentials_delete(self):
+        # Test that we do not delete creds when there are
+        # push rules defined to use them
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Edit credentials").click()
+        # assert full rule is displayed
+        self.assertEqual(url, browser.getControl(
+            name="url_%d" % removeSecurityProxy(push_rule).id).value)
+        self.assertEqual(credentials.get('username'), browser.getControl(
+            name="username_%d" % removeSecurityProxy(push_rule).id).value)
+
+        # mark one line of credentials for delete
+        browser.getControl(
+            name="delete_%d" % removeSecurityProxy(
+                push_rule).id).getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        self.assertTrue("These credentials cannot be deleted as there are "
+                        "push rules defined that still use them."
+                        in browser.contents)
+
+        # make sure we don't have any push rules defined to use
+        # the credentials we want to remove
+        removeSecurityProxy(push_rule).destroySelf()
+        browser.getControl(
+            name="delete_%d" % removeSecurityProxy(
+                push_rule).id).getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        with person_logged_in(self.person):
+            self.assertTrue(getUtility(
+                IOCIRegistryCredentialsSet).findByOwner(
+                self.person).count() == 0)
+
+    def test_oci_edit_registry_creds_update_username(self):
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Edit credentials").click()
+
+        # Set the username to a different value in browser
+        browser.getControl(
+            name="username_%d" % removeSecurityProxy(
+                push_rule).id).value = 'different_username'
+
+        browser.getControl("Save").click()
+
+        # Display the new username in browser
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Edit credentials").click()
+        self.assertEqual('different_username', browser.getControl(
+            name="username_%d" % removeSecurityProxy(push_rule).id).value)
+
+        # Username update does not wipe out the password
+        with person_logged_in(self.person):
+            self.assertThat(
+                removeSecurityProxy(
+                    push_rule).registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals("different_username"),
+                     "password": Equals("bar")}))
+
+    def test_oci_edit_registry_creds(self):
+        url = unicode(self.factory.getUniqueURL())
+        newurl = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Edit credentials").click()
+
+        # Set the username to a different value in browser
+        browser.getControl(
+            name="username_%d" % removeSecurityProxy(
+                push_rule).id).value = 'different_username'
+        browser.getControl(
+            name="url_%d" % removeSecurityProxy(
+                push_rule).id).value = newurl
+        browser.getControl(
+            name="edit_password_%d" % removeSecurityProxy(
+                push_rule).id).value = 'newpassword'
+
+        browser.getControl("Save").click()
+        self.assertTrue("Please make sure the new password matches the "
+                        "confirm password field."
+                        in browser.contents)
+
+        browser.getControl(
+            name="username_%d" % removeSecurityProxy(
+                push_rule).id).value = 'different_username'
+        browser.getControl(
+            name="url_%d" % removeSecurityProxy(
+                push_rule).id).value = newurl
+        browser.getControl(
+            name="edit_password_%d" % removeSecurityProxy(
+                push_rule).id).value = 'newpassword'
+        browser.getControl(
+            name="confirm_edit_password_%d" % removeSecurityProxy(
+                push_rule).id).value = 'newpassword'
+
+        browser.getControl("Save").click()
+
+        with person_logged_in(self.person):
+            self.assertThat(
+                removeSecurityProxy(
+                    push_rule).registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals("different_username"),
+                     "password": Equals("newpassword")}))
+            self.assertEqual(newurl, removeSecurityProxy(
+                push_rule).registry_credentials.url)
+
+    def test_oci_add_registry_creds(self):
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        image_name = self.factory.getUniqueUnicode()
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Edit credentials").click()
+        browser.getLink("Add new OCI registry credentials").click()
+
+        browser.getControl(name="newurl").value = url
+        browser.getControl("Save").click()
+        with person_logged_in(self.person):
+            creds = list(getUtility(
+                IOCIRegistryCredentialsSet).findByOwner(
+                self.person))
+
+            self.assertEqual(url, creds[1].url)
+            self.assertThat(
+                removeSecurityProxy(creds[1]).getCredentials(),
+                MatchesDict({"username": Equals("")}))
diff --git a/lib/lp/oci/interfaces/ocipushrule.py b/lib/lp/oci/interfaces/ocipushrule.py
index 26f636d..952120e 100644
--- a/lib/lp/oci/interfaces/ocipushrule.py
+++ b/lib/lp/oci/interfaces/ocipushrule.py
@@ -128,5 +128,11 @@ class IOCIPushRuleSet(Interface):
     def new(recipe, registry_credentials, image_name):
         """Create an `IOCIPushRule`."""
 
+    def findByRecipe(self, recipe):
+        """Find matching `IOCIPushRule` by recipe."""
+
+    def getNumberByRegistryCredentials(self, creds):
+        """Find matching `IOCIPushRule` by credentials."""
+
     def getByID(id):
         """Get a single `IOCIPushRule` by its ID."""
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index ea14d04..ebcfc9f 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -214,6 +214,9 @@ class IOCIRecipeView(Interface):
         :return: Sequence of `IDistroArchSeries` instances.
         """
 
+    def getExistingCredentials():
+        """Return all OCI registry URLs and usernames."""
+
     builds = CollectionField(
         title=_("Completed builds of this OCI recipe."),
         description=_(
diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
index dbdb22e..acf0200 100644
--- a/lib/lp/oci/model/ocipushrule.py
+++ b/lib/lp/oci/model/ocipushrule.py
@@ -69,7 +69,7 @@ class OCIPushRule(Storm):
 
     def destroySelf(self):
         """See `IOCIPushRule`."""
-        IStore(OCIPushRule).get(self.id).remove()
+        IStore(OCIPushRule).remove(self)
 
 
 @implementer(IOCIPushRuleSet)
@@ -90,3 +90,15 @@ class OCIPushRuleSet:
     def getByID(self, id):
         """See `IOCIPushRuleSet`."""
         return IStore(OCIPushRule).get(OCIPushRule, id)
+
+    def findByRecipe(self, recipe):
+        store = IStore(OCIPushRule)
+        return store.find(
+            OCIPushRule,
+            OCIPushRule.recipe == recipe)
+
+    def getNumberByRegistryCredentials(self, creds):
+        store = IStore(OCIPushRule)
+        return store.find(
+            OCIPushRule,
+            OCIPushRule.registry_credentials == creds).count()
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 1f3cf55..3ffa0fb 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -299,6 +299,10 @@ class OCIRecipe(Storm, WebhookTargetMixin):
             das for das in distro_series.buildable_architectures
             if self._isBuildableArchitectureAllowed(das)]
 
+    def getExistingCredentials(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.owner))
+
     def _checkRequestBuild(self, requester):
         if not requester.inTeam(self.owner):
             raise OCIRecipeNotOwner(
diff --git a/lib/lp/oci/model/ociregistrycredentials.py b/lib/lp/oci/model/ociregistrycredentials.py
index e94c597..3307e8c 100644
--- a/lib/lp/oci/model/ociregistrycredentials.py
+++ b/lib/lp/oci/model/ociregistrycredentials.py
@@ -109,11 +109,13 @@ class OCIRegistryCredentials(Storm):
     def username(self):
         return self._credentials.get('username')
 
+    @username.setter
+    def username(self, value):
+        self._credentials['username'] = value
+
     def destroySelf(self):
         """See `IOCIRegistryCredentials`."""
-        store = IStore(OCIRegistryCredentials)
-        store.find(
-            OCIRegistryCredentials, OCIRegistryCredentials.id == self).remove()
+        IStore(OCIRegistryCredentials).remove(self)
 
 
 @implementer(IOCIRegistryCredentialsSet)
diff --git a/lib/lp/oci/templates/ocirecipe-add-oci-creds.pt b/lib/lp/oci/templates/ocirecipe-add-oci-creds.pt
new file mode 100644
index 0000000..c413e3b
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-add-oci-creds.pt
@@ -0,0 +1,57 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+
+    <table class="form">
+
+    <tr><td colspan="1"><h2>Add new registry credentials</h2></td></tr>
+    <tr>
+      <td>
+          <label>Registry URL
+            <input name="newurl" type="text"
+                   tal:attributes="value view/newurl|nothing" />
+          </label>
+      </td>
+      <td>
+          <label>Username
+            <input name="newusername" type="text"
+                   tal:attributes="value view/newusername|nothing" />
+          </label>
+      </td>
+      <td>
+      <label>New Password
+        <input name="newpassword" type="text"
+               tal:attributes="value view/newpassword|nothing" />
+      </label>
+      </td>
+      <td>
+          <label>Confirm Password
+            <input name="confirmnewpassword" type="text"
+                   tal:attributes="value view/confirmnewpassword|nothing" />
+          </label>
+      </td>
+
+    </tr>
+
+  </table>
+
+    </metal:formbody>
+
+  </div>
+
+
+
+
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-add-push-rules.pt b/lib/lp/oci/templates/ocirecipe-add-push-rules.pt
new file mode 100644
index 0000000..ede174d
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-add-push-rules.pt
@@ -0,0 +1,76 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+
+     <p>
+        For this new push rule please enter a new username and password
+          or choose an existing set of credentials from the list below.
+    </p>
+
+    <table class="form">
+        <tr>
+            <td>
+                <label>Image Name
+                <input name="newimagename" type="text"
+                       tal:attributes="value view/newimagename|nothing" />
+                </label>
+            </td>
+        </tr>
+        <tr>
+            <td>
+            <label> Enter new credentials:</label>
+            </td>
+        </tr>
+        <tr>
+            <td>
+                <label>Registry URL
+                <input name="newurl" type="text"
+                   tal:attributes="value view/newurl|nothing" />
+                </label>
+            </td>
+            <td>
+                <label>New Username
+                <input name="newusername" type="text"
+                    tal:attributes="value view/newusername|nothing" />
+                </label>
+            </td>
+            <td>
+                <label>New Password
+                <input name="newpassword" type="text"
+                   tal:attributes="value view/newpassword|nothing" />
+                </label>
+            </td>
+            <td>
+                <label>Confirm Password
+                <input name="confirm_newpassword" type="text"
+                   tal:attributes="value view/confirm_newpassword|nothing" />
+                </label>
+            </td>
+        </tr>
+        <tr>
+            <td>
+                <label> or choose from below list:</label>
+            </td>
+        </tr>
+        <tr>
+            <td>
+                <tal:widget define="widget nocall:view/widgets/existing_credentials">
+                    <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                </tal:widget>
+            </td>
+        </tr>
+    </table>
+    </metal:formbody>
+    </div>
+</div>
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
new file mode 100644
index 0000000..2499ae1
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
@@ -0,0 +1,61 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+
+    <table class="form">
+
+
+        <tr><td colspan="1"><h2>Edit existing OCI push rules</h2></td></tr>
+        <tr>
+          <td><label>OCI Credentials</label></td>
+          <td><label>Image Name</label></td>
+          <td><label>Delete rule</label></td>
+        </tr>
+        <tr tal:repeat="rule context/push_rules">
+            <td>
+                <tal:editable condition="context/required:launchpad.Edit">
+                  <a tal:attributes="href context/fmt:url/+edit-credentials">Edit credentials</a>
+                </tal:editable>
+            </td>
+            <td>
+                <input type="text"
+                       tal:attributes="name string:image_name_${rule/id};
+                                       value rule/image_name" />
+            </td>
+            <td >
+            <label>
+              <input type="checkbox" value="Delete"
+                     tal:attributes="name string:delete_${rule/id}" />
+                          Delete
+            </label>
+            </td>
+
+        </tr>
+
+        <div tal:define="link context/menu:context/add_push_rules"
+             tal:condition="link/enabled">
+          <tal:add-push-rules replace="structure link/fmt:link"/>
+        </div>
+
+  </table>
+
+    </metal:formbody>
+
+  </div>
+
+
+
+
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-edit-reg-creds.pt b/lib/lp/oci/templates/ocirecipe-edit-reg-creds.pt
new file mode 100644
index 0000000..e90f7ce
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-edit-reg-creds.pt
@@ -0,0 +1,72 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+
+
+        <div tal:define="link context/menu:context/add_oci_creds"
+             tal:condition="link/enabled">
+          <tal:add_oci_creds replace="structure link/fmt:link"/>
+        </div>
+
+    <table class="form">
+
+
+        <tr><td colspan="1"><h2>Existing OCI Credentials</h2></td></tr>
+        <tr>
+          <td><label>Registry URL</label></td>
+          <td><label>Username</label></td>
+          <td><label>New password</label></td>
+          <td><label>Confirm password</label></td>
+          <td><label>Delete credentials</label></td>
+
+        </tr>
+        <tr tal:repeat="rule view/oci_registry_creds">
+          <td>
+            <input tal:attributes="name string:url_${rule/id};
+                                   value rule/url"
+                   type="text" style="margin-bottom: 0.5em;"/>
+          </td>
+            <td >
+            <input tal:attributes="name string:username_${rule/id};
+                                   value python: rule.username"
+                   type="text" style="margin-bottom: 0.5em;"/>
+            </td>
+            <td >
+            <input tal:attributes="name string:edit_password_${rule/id}"
+                   type="text" style="margin-bottom: 0.5em;"/>
+            </td>
+            <td>
+            <input tal:attributes="name string:confirm_edit_password_${rule/id}"
+                   type="text" style="margin-bottom: 0.5em;"/>
+            </td>
+            <td >
+            <label>
+              <input type="checkbox"
+                     tal:attributes="name string:delete_${rule/id}" />
+                          Delete
+            </label>
+            </td>
+        </tr>
+
+  </table>
+
+    </metal:formbody>
+
+  </div>
+
+
+
+
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index f7df628..e465e0c 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -116,6 +116,47 @@
          tal:condition="link/enabled">
       <tal:request-builds replace="structure link/fmt:link"/>
     </div>
+
+
+    <h2>Recipe Push Rules</h2>
+    <table id="push-rules-listing" 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"
+              tal:attributes="id string:rule-${rule/id}">
+            <td >
+                <a tal:content="rule/registry_credentials/url"/>
+            </td>
+            <td >
+                <a tal:content="python: rule.registry_credentials.username"/>
+            </td>
+            <td >
+                <a tal:content="rule/image_name"/>
+            </td>
+          </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>
+    <div tal:define="link context/menu:context/add_push_rules"
+       tal:condition="link/enabled">
+       <tal:add-push-rules replace="structure link/fmt:link"/>
+    </div>
   </div>
 
 </body>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
index 2e44624..872ccfd 100644
--- a/lib/lp/oci/vocabularies.py
+++ b/lib/lp/oci/vocabularies.py
@@ -10,6 +10,7 @@ __all__ = []
 
 from zope.schema.vocabulary import SimpleTerm
 
+from lp.oci.model.ociregistrycredentials import OCIRegistryCredentials
 from lp.services.webapp.vocabulary import StormVocabularyBase
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
@@ -28,3 +29,49 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
 
     def __len__(self):
         return len(self.context.getAllowedArchitectures())
+
+
+class OCIExistingRegistryCredentialsVocabulary(StormVocabularyBase):
+
+    _table = OCIRegistryCredentials
+
+    def toTerm(self, obj):
+        if obj.username == u'':
+            token = obj.url
+        else:
+            token = "%s with %s" % (
+                obj.url,
+                obj.username)
+        return SimpleTerm(obj, token)
+
+    @property
+    def _entries(self):
+        return self.context.getExistingCredentials()
+
+    def __contains__(self, value):
+        """See `IVocabulary`."""
+        return value in self._entries
+
+    def __iter__(self):
+        for obj in self._entries:
+            yield self.toTerm(obj)
+
+    def __len__(self):
+        return len(self.context.getExistingCredentials())
+
+    def getTermByToken(self, token):
+        """See `IVocabularyTokenized`."""
+        try:
+            if "with" in token:
+                username = token.rsplit("with")[1].strip()
+                url = token.rsplit("with")[0].strip()
+            else:
+                username = u''
+                url = token
+            for obj in self._entries:
+                    url_match = obj.url == url
+                    username_match = obj.username == username
+                    if url_match and username_match:
+                        return self.toTerm(obj)
+        except ValueError:
+            raise LookupError(token)
diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
index fae4a6d..52bc432 100644
--- a/lib/lp/oci/vocabularies.zcml
+++ b/lib/lp/oci/vocabularies.zcml
@@ -15,4 +15,14 @@
         <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
     </class>
 
+    <securedutility
+        name="OCIExistingRegistryCredentials"
+        component="lp.oci.vocabularies.OCIExistingRegistryCredentialsVocabulary"
+        provides="zope.schema.interfaces.IVocabularyFactory">
+        <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+    </securedutility>
+
+    <class class="lp.oci.vocabularies.OCIExistingRegistryCredentialsVocabulary">
+        <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+    </class>
 </configure>
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index 2fdc177..f8e203e 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -1231,6 +1231,19 @@
         template="../templates/person-oauth-tokens.pt"
         />
     <browser:page
+        name="+oci-registry-creds"
+        for="lp.registry.interfaces.person.IPerson"
+        class="lp.registry.browser.person.OCIRegistryCredentialsView"
+        permission="launchpad.Edit"
+        template="../templates/oci-registry-creds.pt"
+        />
+    <browser:page
+        for="lp.registry.interfaces.person.IPerson"
+        class="lp.registry.browser.person.OCIEditRegistryCredentialsView"
+        permission="launchpad.Edit"
+        name="+edit-credentials"
+        template="../templates/edit-registry-creds.pt" />
+    <browser:page
         name="+index"
         for="lp.registry.interfaces.person.ITeam"
         class="lp.registry.browser.team.TeamIndexView"
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index 8677ecd..4723ea4 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -132,6 +132,10 @@ from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.code.errors import InvalidNamespace
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
 from lp.code.interfaces.gitlookup import IGitTraverser
+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
+from lp.oci.interfaces.ociregistrycredentials import (
+    IOCIRegistryCredentialsSet,
+    )
 from lp.registry.browser import BaseRdfView
 from lp.registry.browser.branding import BrandingChangeView
 from lp.registry.browser.menu import (
@@ -569,6 +573,14 @@ class PersonNavigation(BranchTraversalMixin, Navigation):
             return None
         return irc_nick
 
+    @stepthrough('+oci_creds')
+    def traverse_oci_creds(self, id):
+        """Traverse to this person's IrcIDs on the webservice layer."""
+        oci_creds = getUtility(IOCIRegistryCredentialsSet).get(id)
+        if oci_creds is None or oci_creds.person != self.context:
+            return None
+        return oci_creds
+
     @stepto('+archivesubscriptions')
     def traverse_archive_subscription(self):
         """Traverse to the archive subscription for this person."""
@@ -801,6 +813,7 @@ class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin,
         'view_ppa_subscriptions',
         'ppa',
         'oauth_tokens',
+        'oci_registry_creds',
         'related_software_summary',
         'view_recipes',
         'view_snaps',
@@ -823,6 +836,12 @@ class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin,
         return Link(target, text, enabled=enabled, icon='info')
 
     @enabled_with_permission('launchpad.Edit')
+    def oci_registry_creds(self):
+        target = '+oci-registry-creds'
+        text = 'OCI registry credentials'
+        return Link(target, text, icon='info')
+
+    @enabled_with_permission('launchpad.Edit')
     def editlanguages(self):
         target = '+editlanguages'
         text = 'Set preferred languages'
@@ -1546,6 +1565,16 @@ class PersonView(LaunchpadView, FeedsMixin, ContactViaWebLinksMixin):
             check_permission('launchpad.Edit', self.context))
 
     @property
+    def should_show_ociregistrycreds_section(self):
+        """Should the 'OCI registry credentials' section be shown?
+
+        It's shown when the person has OCI credentials registered or has rights
+        to register new ones.
+        """
+        return bool(self.context.ociregistrycreds) or (
+            check_permission('launchpad.Edit', self.context))
+
+    @property
     def should_show_jabberids_section(self):
         """Should the 'Jabber IDs' section be shown?
 
@@ -3620,6 +3649,85 @@ class PersonOAuthTokensView(LaunchpadView):
             canonical_url(self.context, view_name='+oauth-tokens'))
 
 
+class OCIRegistryCredentialsView(LaunchpadView):
+    """View for +oci-registry-creds."""
+
+    @cachedproperty
+    def oci_registry_creds(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.context))
+
+    @property
+    def page_title(self):
+        return "OCI credentials"
+
+    @cachedproperty
+    def has_creds(self):
+        return len(self.oci_registry_creds) > 0
+
+
+class OCIEditRegistryCredentialsView(LaunchpadEditFormView):
+
+    class schema(Interface):
+        """Schema for editing registry credentials."""
+
+    @property
+    def oci_registry_creds(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.context))
+
+    @property
+    def label(self):
+        return 'Edit OCI Registry Credentials'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action("Save")
+    def save(self, action, data):
+        form = self.request.form
+        for creds in self.oci_registry_creds:
+            url = form.get('url_%d' % creds.id)
+            username = form.get('username_%d' % creds.id)
+            confirm_password = form.get('confirm_edit_password_%d' % creds.id)
+            password = form.get('edit_password_%d' % creds.id)
+            if 'delete_%d' % creds.id in action.form.request.form:
+                # get no of push rules that use these credentials
+                push_rule = removeSecurityProxy(
+                    getUtility(
+                        IOCIPushRuleSet).getNumberByRegistryCredentials(creds))
+                if push_rule:
+                    self.request.response.addErrorNotification(
+                        "These credentials cannot be deleted as there are "
+                        "push rules defined that still use them.")
+                else:
+                    # only remove creds if no push rule is using them
+                    removeSecurityProxy(creds).destroySelf()
+                    self.next_url = self.cancel_url
+            else:
+                if not url:
+                    self.request.response.addErrorNotification(
+                        "Registry URL cannot be empty.")
+                else:
+                    if password or confirm_password:
+                        if password == confirm_password:
+                            creds.url = url
+                            credentials = {
+                                'username': username,
+                                'password': password}
+                            creds.setCredentials(credentials)
+                            self.next_url = self.cancel_url
+                        else:
+                            self.request.response.addErrorNotification(
+                                "Please make sure the new password matches"
+                                " the confirm password field.")
+                            return
+                    else:
+                        removeSecurityProxy(creds).username = username
+                        self.next_url = self.cancel_url
+
+
 class PersonTimeZoneForm(Interface):
 
     time_zone = Choice(
diff --git a/lib/lp/registry/browser/tests/test_person.py b/lib/lp/registry/browser/tests/test_person.py
index 626cab4..ea73862 100644
--- a/lib/lp/registry/browser/tests/test_person.py
+++ b/lib/lp/registry/browser/tests/test_person.py
@@ -15,6 +15,7 @@ from testtools.matchers import (
     DocTestMatches,
     Equals,
     LessThan,
+    MatchesDict,
     Not,
     )
 from testtools.testcase import ExpectedException
@@ -29,6 +30,12 @@ from lp.app.errors import NotFoundError
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.blueprints.enums import SpecificationImplementationStatus
 from lp.buildmaster.enums import BuildStatus
+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
+from lp.oci.interfaces.ociregistrycredentials import (
+    IOCIRegistryCredentialsSet,
+    )
+from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.registry.browser.person import PersonView
 from lp.registry.browser.team import TeamInvitationView
 from lp.registry.enums import PersonVisibility
@@ -43,6 +50,7 @@ from lp.registry.model.karma import KarmaCategory
 from lp.registry.model.milestone import milestone_sort_key
 from lp.scripts.garbo import PopulateLatestPersonSourcePackageReleaseCache
 from lp.services.config import config
+from lp.services.features.testing import FeatureFixture
 from lp.services.identity.interfaces.account import AccountStatus
 from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
 from lp.services.log.logger import DevNullLogger
@@ -1274,6 +1282,154 @@ class TestPersonRelatedProjectsView(TestCaseWithFactory):
         self.assertThat(view(), next_match)
 
 
+class TestOCIRegistryCredentialsView(BrowserTestCase,
+                                     OCIConfigHelperMixin,
+                                     TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIRegistryCredentialsView, self).setUp()
+        self.setConfig()
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        self.distroseries = self.factory.makeDistroSeries(
+            distribution=self.ubuntu, name="shiny", displayname="Shiny")
+        self.useFixture(FeatureFixture({
+            OCI_RECIPE_ALLOW_CREATE: "on",
+            "oci.build_series.%s" % self.distroseries.distribution.name:
+                self.distroseries.name,
+        }))
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        self.recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person,
+            oci_project=oci_project)
+
+    def test_oci_view_registry_creds_on_person_page(self):
+        # Verify view helper attributes.
+        owner = self.factory.makePerson()
+        self.user = owner
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        getUtility(IOCIRegistryCredentialsSet).new(
+            owner=owner,
+            url=url,
+            credentials=credentials)
+        view = create_initialized_view(self.user, '+oci-registry-creds')
+        self.assertEqual('OCI credentials', view.page_title)
+        self.assertEqual(credentials.get('username'),
+                         removeSecurityProxy(
+                             view.oci_registry_creds[0]
+                         ).getCredentials()['username'])
+        self.assertEqual(url, removeSecurityProxy(
+            view.oci_registry_creds[0]).url)
+
+    def test_oci_edit_registry_creds_on_person_page(self):
+        url = unicode(self.factory.getUniqueURL())
+        newurl = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.user,
+            url=url,
+            credentials=credentials)
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+        browser = self.getViewBrowser(
+            self.user, view_name='+oci-registry-creds', user=self.user)
+        browser.getLink("Edit credentials").click()
+
+        # Set the username to a different value in browser
+        browser.getControl(
+            name="username_%d" % removeSecurityProxy(
+                push_rule).id).value = 'different_username'
+        browser.getControl(
+            name="url_%d" % removeSecurityProxy(
+                push_rule).id).value = newurl
+        browser.getControl(
+            name="edit_password_%d" % removeSecurityProxy(
+                push_rule).id).value = 'newpassword'
+
+        browser.getControl("Save").click()
+        self.assertTrue("Please make sure the new password matches the "
+                        "confirm password field."
+                        in browser.contents)
+
+        browser.getControl(
+            name="username_%d" % removeSecurityProxy(
+                push_rule).id).value = 'different_username'
+        browser.getControl(
+            name="url_%d" % removeSecurityProxy(
+                push_rule).id).value = newurl
+        browser.getControl(
+            name="edit_password_%d" % removeSecurityProxy(
+                push_rule).id).value = 'newpassword'
+        browser.getControl(
+            name="confirm_edit_password_%d" % removeSecurityProxy(
+                push_rule).id).value = 'newpassword'
+
+        browser.getControl("Save").click()
+
+        with person_logged_in(self.person):
+            self.assertThat(
+                removeSecurityProxy(
+                    push_rule).registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals("different_username"),
+                     "password": Equals("newpassword")}))
+            self.assertEqual(newurl, removeSecurityProxy(
+                    push_rule).registry_credentials.url)
+
+    def test_oci_delete_registry_creds_on_person_page(self):
+        # Test that we do not delete creds when there are
+        # push rules defined to use them
+        url = unicode(self.factory.getUniqueURL())
+        credentials = {'username': 'foo', 'password': 'bar'}
+        registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+            owner=self.person,
+            url=url,
+            credentials=credentials)
+        image_name = self.factory.getUniqueUnicode()
+        push_rule = getUtility(IOCIPushRuleSet).new(
+            recipe=self.recipe,
+            registry_credentials=registry_credentials,
+            image_name=image_name)
+
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        browser.getLink("Edit push rules / Add registry credentials").click()
+        browser.getLink("Edit credentials").click()
+        # assert full rule is displayed
+        self.assertEqual(url, browser.getControl(
+            name="url_%d" % removeSecurityProxy(push_rule).id).value)
+        self.assertEqual(credentials.get('username'), browser.getControl(
+            name="username_%d" % removeSecurityProxy(push_rule).id).value)
+
+        # mark one line of credentials for delete
+        browser.getControl(
+            name="delete_%d" % removeSecurityProxy(
+                push_rule).id).getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        self.assertTrue("These credentials cannot be deleted as there are "
+                        "push rules defined that still use them."
+                        in browser.contents)
+
+        # make sure we don't have any push rules defined to use
+        # the credentials we want to remove
+        removeSecurityProxy(push_rule).destroySelf()
+        browser.getControl(
+            name="delete_%d" % removeSecurityProxy(
+                push_rule).id).getControl('Delete').selected = True
+        browser.getControl("Save").click()
+        with person_logged_in(self.person):
+            self.assertTrue(getUtility(
+                IOCIRegistryCredentialsSet).findByOwner(
+                self.person).count() == 0)
+
+
 class TestPersonRelatedPackagesFailedBuild(TestCaseWithFactory):
     """The related packages views display links to failed builds."""
 
diff --git a/lib/lp/registry/templates/edit-registry-creds.pt b/lib/lp/registry/templates/edit-registry-creds.pt
new file mode 100644
index 0000000..6886fe9
--- /dev/null
+++ b/lib/lp/registry/templates/edit-registry-creds.pt
@@ -0,0 +1,55 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+
+        <table class="form">
+            <tr><td colspan="1"><h2>Existing OCI Credentials</h2></td></tr>
+            <tr>
+              <td><label>Registry URL</label></td>
+              <td><label>Username</label></td>
+              <td><label>New password</label></td>
+              <td><label>Confirm password</label></td>
+              <td><label>Delete credentials</label></td>
+            </tr>
+            <tr tal:repeat="rule view/oci_registry_creds">
+              <td>
+                <input tal:attributes="name string:url_${rule/id};
+                                       value rule/url"
+                       type="text" style="margin-bottom: 0.5em;"/>
+              </td>
+                <td >
+                <input tal:attributes="name string:username_${rule/id};
+                                       value python: rule.username"
+                       type="text" style="margin-bottom: 0.5em;"/>
+                </td>
+                <td >
+                <input tal:attributes="name string:edit_password_${rule/id}"
+                       type="text" style="margin-bottom: 0.5em;"/>
+                </td>
+                <td>
+                <input tal:attributes="name string:confirm_edit_password_${rule/id}"
+                       type="text" style="margin-bottom: 0.5em;"/>
+                </td>
+                <td >
+                <label>
+                  <input type="checkbox"
+                         tal:attributes="name string:delete_${rule/id}" />
+                              Delete
+                </label>
+                </td>
+            </tr>
+      </table>
+    </metal:formbody>
+  </div>
+</div>
+</body>
+</html>
diff --git a/lib/lp/registry/templates/oci-registry-creds.pt b/lib/lp/registry/templates/oci-registry-creds.pt
new file mode 100644
index 0000000..0746f52
--- /dev/null
+++ b/lib/lp/registry/templates/oci-registry-creds.pt
@@ -0,0 +1,51 @@
+<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_only"
+  i18n:domain="launchpad"
+>
+<body>
+
+<div metal:fill-slot="main">
+
+    <p id="no-participation" tal:condition="not: view/has_creds">
+      <span tal:replace="context/title">Foo Bar</span>
+       has not set any credentials yet.
+    </p>
+
+    <table id="oci-credentials" class="listing" tal:condition="view/has_creds">
+
+      <thead>
+        <tr>
+          <th>Action</th>
+          <th>Registry</th>
+          <th>Username</th>
+        </tr>
+      </thead>
+
+
+      <tbody>
+        <tr tal:repeat="oci_creds view/oci_registry_creds">
+            <td>
+                <tal:editable condition="context/required:launchpad.Edit">
+                  <a tal:attributes="href context/fmt:url/+edit-credentials">Edit credentials</a>
+                </tal:editable>
+            </td>
+          <td>
+            <a tal:replace="structure oci_creds/url"></a>
+          </td>
+          <td>
+            <a tal:replace="python: oci_creds.username"></a>
+          </td>
+        </tr>
+
+   </tbody>
+
+  </table>
+
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/registry/templates/person-index.pt b/lib/lp/registry/templates/person-index.pt
index 4cfee46..c8e050d 100644
--- a/lib/lp/registry/templates/person-index.pt
+++ b/lib/lp/registry/templates/person-index.pt
@@ -82,6 +82,9 @@
         tal:define="link context/menu:overview/oauth_tokens"
         tal:condition="link/enabled"
         tal:content="structure link/fmt:link" />
+      <li
+        tal:define="link context/menu:overview/oci_registry_creds"
+        tal:content="structure link/fmt:link" />
     </ul>
 
     <div class="yui-g">