← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:oci-policy-push-tags-to-the-limit into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:oci-policy-push-tags-to-the-limit into launchpad:master with ~twom/launchpad:db-oci-policy-push-tags-to-the-limit as a prerequisite.

Commit message:
Add model for OCIImageTag

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Add a model, interface, and supporting configuration for OCI Image Tag.
Reference to OCIPushRule, with a ReferenceSet back to OCIImageTag.

Can't land until after https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/394958
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-policy-push-tags-to-the-limit into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 11aba6f..5355ce5 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -242,6 +242,7 @@ public.oauthaccesstoken                 = SELECT, INSERT, UPDATE, DELETE
 public.oauthconsumer                    = SELECT, INSERT
 public.oauthrequesttoken                = SELECT, INSERT, UPDATE, DELETE
 public.ocifile                          = SELECT, INSERT, UPDATE, DELETE
+public.ociimagetag                      = SELECT, INSERT, UPDATE, DELETE
 public.ociproject                       = SELECT, INSERT, UPDATE, DELETE
 public.ociprojectname                   = SELECT, INSERT, UPDATE
 public.ociprojectseries                 = SELECT, INSERT, UPDATE, DELETE
@@ -998,6 +999,7 @@ public.livefs                                 = SELECT
 public.livefsbuild                            = SELECT, UPDATE
 public.livefsfile                             = SELECT
 public.ocifile                                = SELECT, UPDATE
+public.ociimagetag                            = SELECT
 public.ociproject                             = SELECT
 public.ociprojectname                         = SELECT
 public.ocipushrule                            = SELECT
@@ -1441,6 +1443,7 @@ public.messagechunk                     = SELECT, INSERT
 public.milestone                        = SELECT
 public.milestonetag                     = SELECT
 public.ocifile                          = SELECT, INSERT
+public.ociimagetag                      = SELECT
 public.ociproject                       = SELECT
 public.ociprojectname                   = SELECT
 public.ociprojectseries                 = SELECT
@@ -2734,6 +2737,7 @@ public.ocirecipebuild                   = SELECT, INSERT, UPDATE
 public.ocirecipebuildjob                = SELECT, UPDATE
 public.ocirecipejob                     = SELECT, UPDATE, INSERT
 public.ocifile                          = SELECT
+public.ociimagetag                      = SELECT
 public.ociproject                       = SELECT
 public.ociprojectname                   = SELECT
 public.ociprojectseries                 = SELECT
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 6e2cbfc..0f679e6 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -84,6 +84,8 @@ from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
 from lp.code.interfaces.sourcepackagerecipebuild import (
     ISourcePackageRecipeBuild,
     )
+from lp.oci.interfaces.ociimagetag import IOCIImageTag
+from lp.oci.interfaces.ocipushrule import IOCIPushRule
 from lp.registry.interfaces.commercialsubscription import (
     ICommercialSubscription,
     )
@@ -688,6 +690,9 @@ patch_collection_property(
 patch_collection_property(
     IHasSpecifications, 'api_valid_specifications', ISpecification)
 
+# IOCIPushRule
+patch_collection_property(IOCIPushRule, 'tags', IOCIImageTag)
+
 
 ###
 #
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 1f7a6c7..9f2871a 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -155,6 +155,23 @@
             interface="lp.oci.interfaces.ocipushrule.IOCIPushRuleSet"/>
     </securedutility>
 
+    <!-- OCIImageTag -->
+    <class class="lp.oci.model.ociimagetag.OCIImageTag">
+        <require
+            permission="launchpad.View"
+            interface="lp.oci.interfaces.ociimagetag.IOCIImageTagView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.oci.interfaces.ociimagetag.IOCIImageTagEdit" />
+    </class>
+
+    <securedutility
+        class="lp.oci.model.ociimagetag.OCIImageTagSet"
+        provides="lp.oci.interfaces.ociimagetag.IOCIImageTagSet">
+        <allow
+            interface="lp.oci.interfaces.ociimagetag.IOCIImageTagSet"/>
+    </securedutility>
+
     <!-- OCI related jobs -->
     <securedutility
         component="lp.oci.model.ocirecipebuildjob.OCIRegistryUploadJob"
diff --git a/lib/lp/oci/interfaces/ociimagetag.py b/lib/lp/oci/interfaces/ociimagetag.py
new file mode 100644
index 0000000..fe7220d
--- /dev/null
+++ b/lib/lp/oci/interfaces/ociimagetag.py
@@ -0,0 +1,71 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Registry tags for OCI Images."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IOCIImageTag',
+    'IOCIImageTagSet',
+    'OCIImageTagAlreadyExists',
+    ]
+
+from lazr.restful.declarations import error_status
+from lazr.restful.fields import Reference
+from six.moves import http_client
+from zope.interface import Interface
+from zope.schema import (
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.oci.interfaces.ocipushrule import IOCIPushRule
+
+
+@error_status(http_client.CONFLICT)
+class OCIImageTagAlreadyExists(Exception):
+    """A OCIPushRule already has a tag with the same details."""
+
+    def __init__(self):
+        super(OCIImageTagAlreadyExists).__init__(
+            "The tag already exists for this push rule.")
+
+
+class IOCIImageTagView(Interface):
+    """`IOCIImageTag` methods that require Launchpad.View permission."""
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+    tag = TextLine(
+        title=_("Tag"),
+        description=_("The tag to apply to an image on upload."),
+        required=True,
+        readonly=False)
+    push_rule = Reference(
+        IOCIPushRule,
+        title=_("Push Rule"),
+        description=_("The Push Rule that uses this tag."),
+        required=True,
+        readonly=True)
+
+
+class IOCIImageTagEdit(Interface):
+    """`IOCIImageTag` methods that require launchpad.Edit
+    permission.
+    """
+
+    def destroySelf():
+        """Destroy this image tag."""
+
+
+class IOCIImageTag(IOCIImageTagView, IOCIImageTagEdit):
+    """A tag to be added to an OCI Image on push to a registry."""
+
+
+class IOCIImageTagSet(Interface):
+    """A utility to create and access OCI Image tags."""
+
+    def new(push_rule, tag):
+        """Create an `IOCIImageTag`."""
diff --git a/lib/lp/oci/interfaces/ocipushrule.py b/lib/lp/oci/interfaces/ocipushrule.py
index 67db23e..b1d97e4 100644
--- a/lib/lp/oci/interfaces/ocipushrule.py
+++ b/lib/lp/oci/interfaces/ocipushrule.py
@@ -22,7 +22,10 @@ from lazr.restful.declarations import (
     operation_for_version,
     operation_parameters,
     )
-from lazr.restful.fields import Reference
+from lazr.restful.fields import (
+    Reference,
+    CollectionField,
+    )
 from six.moves import http_client
 from zope.interface import Interface
 from zope.schema import (
@@ -37,7 +40,7 @@ from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentials
 
 @error_status(http_client.CONFLICT)
 class OCIPushRuleAlreadyExists(Exception):
-    """A new OCIPushRuleAlreadyExists was added with the
+    """A new OCIPushRule was added with the
        same details as an existing one.
     """
 
@@ -102,6 +105,13 @@ class IOCIPushRuleEditableAttributes(Interface):
     def setNewImageName(image_name):
         """Set the new image name, checking for uniqueness."""
 
+    tags = CollectionField(
+        title=_("Image tags"),
+        description=_(
+            "The list of tags that will be applied to an image on push."),
+        value_type=Reference(schema=Interface),  # IOCIImageTag
+        required=False, default=[], readonly=True)
+
 
 class IOCIPushRuleEdit(Interface):
     """`IOCIPushRule` methods that require launchpad.Edit
diff --git a/lib/lp/oci/model/ociimagetag.py b/lib/lp/oci/model/ociimagetag.py
new file mode 100644
index 0000000..6ae7802
--- /dev/null
+++ b/lib/lp/oci/model/ociimagetag.py
@@ -0,0 +1,59 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Registry tags for an OCI Image, used by an `OCIPushRule`."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIImageTag',
+    'OCIImageTagSet'
+]
+
+from storm.locals import (
+    Int,
+    Reference,
+    Storm,
+    Unicode,
+    )
+from zope.interface import implementer
+
+from lp.oci.interfaces.ociimagetag import (
+    IOCIImageTag,
+    IOCIImageTagSet,
+    OCIImageTagAlreadyExists,
+    )
+from lp.services.database.interfaces import IStore
+
+
+@implementer(IOCIImageTag)
+class OCIImageTag(Storm):
+
+    __storm_table__ = 'OCIImageTag'
+
+    id = Int(primary=True)
+    tag = Unicode(name='tag', allow_none=False)
+    push_rule_id = Int(name="push_rule", allow_none=False)
+    push_rule = Reference(push_rule_id, 'OCIPushRule.id')
+
+    def __init__(self, push_rule, tag):
+        self.push_rule = push_rule
+        self.tag = tag
+
+    def destroySelf(self):
+        """See `IOCIImageTag`."""
+        IStore(OCIImageTag).remove(self)
+
+
+@implementer(IOCIImageTagSet)
+class OCIImageTagSet:
+
+    def new(self, push_rule, tag):
+        """See `IOCIImageTagSet`."""
+        for existing in push_rule.tags:
+            if tag == existing.tag:
+                raise OCIImageTagAlreadyExists()
+        tag = OCIImageTag(push_rule, tag)
+        IStore(OCIImageTag).add(tag)
+        return tag
diff --git a/lib/lp/oci/model/ocipushrule.py b/lib/lp/oci/model/ocipushrule.py
index 24d5e27..df950d6 100644
--- a/lib/lp/oci/model/ocipushrule.py
+++ b/lib/lp/oci/model/ocipushrule.py
@@ -5,6 +5,8 @@
 
 from __future__ import absolute_import, print_function, unicode_literals
 
+from storm.references import ReferenceSet
+
 __metaclass__ = type
 __all__ = [
     'OCIPushRule',
@@ -44,6 +46,9 @@ class OCIPushRule(Storm):
 
     image_name = Unicode(name="image_name", allow_none=False)
 
+    tags = ReferenceSet(
+        'id', 'OCIImageTag.push_rule_id', order_by='OCIImageTag.id')
+
     @property
     def registry_url(self):
         return self.registry_credentials.url
@@ -69,6 +74,8 @@ class OCIPushRule(Storm):
 
     def destroySelf(self):
         """See `IOCIPushRule`."""
+        for tag in self.tags:
+            tag.removeSelf()
         IStore(OCIPushRule).remove(self)
 
 
diff --git a/lib/lp/oci/tests/test_ociimagetag.py b/lib/lp/oci/tests/test_ociimagetag.py
new file mode 100644
index 0000000..5b9dbe4
--- /dev/null
+++ b/lib/lp/oci/tests/test_ociimagetag.py
@@ -0,0 +1,77 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for OCI Image tags."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+
+__metaclass__ = type
+
+from storm.store import Store
+from testtools.matchers import MatchesStructure
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+
+from lp.oci.interfaces.ociimagetag import (
+    IOCIImageTag,
+    IOCIImageTagSet,
+    )
+from lp.oci.model.ociimagetag import OCIImageTag
+from lp.oci.tests.helpers import OCIConfigHelperMixin
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestOCIImageTag(OCIConfigHelperMixin, TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIImageTag, self).setUp()
+        self.setConfig()
+
+    def test_implements_interface(self):
+        image_tag = self.factory.makeOCIImageTag()
+        with person_logged_in(image_tag.push_rule.recipe.owner):
+            self.assertProvides(image_tag, IOCIImageTag)
+
+    def test_destroySelf(self):
+        image_tag = self.factory.makeOCIImageTag()
+        store = Store.of(image_tag)
+        self.assertEqual(1, store.find(OCIImageTag).count())
+        # Launchpad.Edit required
+        self.assertRaises(Unauthorized, getattr, image_tag, 'destroySelf')
+        # Launchpad.Edit is the owner of the containing recipe
+        with person_logged_in(image_tag.push_rule.recipe.owner):
+            image_tag.destroySelf()
+        self.assertEqual(0, store.find(OCIImageTag).count())
+
+
+class TestOCIImageTagSet(OCIConfigHelperMixin, TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIImageTagSet, self).setUp()
+        self.setConfig()
+
+    def test_implements_interface(self):
+        image_tag_set = getUtility(IOCIImageTagSet)
+        self.assertProvides(image_tag_set, IOCIImageTagSet)
+
+    def test_new(self):
+        push_rule = self.factory.makeOCIPushRule()
+        tag = self.factory.getUniqueUnicode()
+        image_tag = getUtility(IOCIImageTagSet).new(
+            push_rule=push_rule,
+            tag=tag)
+
+        self.assertThat(
+            image_tag,
+            MatchesStructure.byEquality(
+                push_rule=push_rule,
+                tag=tag))
diff --git a/lib/lp/oci/tests/test_ocipushrule.py b/lib/lp/oci/tests/test_ocipushrule.py
index 4c6db56..31798d6 100644
--- a/lib/lp/oci/tests/test_ocipushrule.py
+++ b/lib/lp/oci/tests/test_ocipushrule.py
@@ -71,6 +71,14 @@ class TestOCIPushRule(OCIConfigHelperMixin, TestCaseWithFactory):
         # Avoid trying to flush the incomplete object on cleanUp.
         Store.of(owner).rollback()
 
+    def test_tags_exist(self):
+        push_rule = self.factory.makeOCIPushRule()
+        first_tag = self.factory.makeOCIImageTag(push_rule=push_rule)
+        second_tag = self.factory.makeOCIImageTag(push_rule=push_rule)
+        tag_names = [x.tag for x in push_rule.tags]
+        self.assertIn(first_tag.tag, tag_names)
+        self.assertIn(second_tag.tag, tag_names)
+
 
 class TestOCIPushRuleSet(OCIConfigHelperMixin, TestCaseWithFactory):
 
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 459ab8c..f4c7f6e 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -101,6 +101,7 @@ from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
 from lp.code.interfaces.sourcepackagerecipebuild import (
     ISourcePackageRecipeBuild,
     )
+from lp.oci.interfaces.ociimagetag import IOCIImageTag
 from lp.oci.interfaces.ocipushrule import IOCIPushRule
 from lp.oci.interfaces.ocirecipe import (
     IOCIRecipe,
@@ -3503,7 +3504,7 @@ class ViewOCIPushRule(AnonymousAuthorization):
     usedfor = IOCIPushRule
 
 
-class OCIPushRuleEdit(AuthorizationBase):
+class EditOCIPushRule(AuthorizationBase):
     permission = 'launchpad.Edit'
     usedfor = IOCIPushRule
 
@@ -3511,3 +3512,19 @@ class OCIPushRuleEdit(AuthorizationBase):
         return (
             user.isOwner(self.obj.recipe) or
             user.in_commercial_admin or user.in_admin)
+
+
+class ViewOCIImageTag(AnonymousAuthorization):
+    """Anyone can view an `IOCIImageTag`."""
+    usedfor = IOCIImageTag
+
+
+class EditOCIImageTag(AuthorizationBase):
+    permission = 'launchpad.Edit'
+    usedfor = IOCIImageTag
+
+    def checkAuthenticated(self, user):
+        auth_push_rule = EditOCIPushRule(self.obj.push_rule)
+        if auth_push_rule.checkAuthenticated(user):
+            return True
+        return super(EditOCIImageTag, self).checkAuthenticated(user)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 38b9ffb..c86237e 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -42,6 +42,8 @@ import types
 import uuid
 import warnings
 
+from lp.oci.interfaces.ociimagetag import IOCIImageTag
+
 from breezy.plugins.builder.recipe import BaseRecipeBranch
 from breezy.revision import Revision as BzrRevision
 from cryptography.utils import int_to_bytes
@@ -150,6 +152,7 @@ from lp.code.model.diff import (
     Diff,
     PreviewDiff,
     )
+from lp.oci.interfaces.ociimagetag import IOCIImageTagSet
 from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
@@ -5093,6 +5096,16 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             registry_credentials=registry_credentials,
             image_name=image_name)
 
+    def makeOCIImageTag(self, push_rule=None, tag=None):
+        """Make a new OCIImageTag."""
+        if push_rule is None:
+            push_rule = self.makeOCIPushRule()
+        if tag is None:
+            tag = self.getUniqueUnicode("tag-")
+        return getUtility(IOCIImageTagSet).new(
+            push_rule=push_rule,
+            tag=tag)
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by