Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-model into launchpad:master.
Commit message: Add basic model for charm recipes Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403406 DB patch: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403405 -- Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-model into launchpad:master.
diff --git a/lib/lp/charms/__init__.py b/lib/lp/charms/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/lp/charms/__init__.py diff --git a/lib/lp/charms/browser/__init__.py b/lib/lp/charms/browser/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/lp/charms/browser/__init__.py diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py new file mode 100644 index 0000000..ce84fbe --- /dev/null +++ b/lib/lp/charms/browser/charmrecipe.py @@ -0,0 +1,36 @@ +# Copyright 2021 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Charm recipe views.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + "CharmRecipeURL", + ] + +from zope.component import getUtility +from zope.interface import implementer + +from lp.registry.interfaces.personproduct import IPersonProductFactory +from lp.services.webapp.interfaces import ICanonicalUrlData + + +@implementer(ICanonicalUrlData) +class CharmRecipeURL: + """Charm recipe URL creation rules.""" + rootsite = 'mainsite' + + def __init__(self, recipe): + self.recipe = recipe + + @property + def inside(self): + owner = self.recipe.owner + project = self.recipe.project + return getUtility(IPersonProductFactory).create(owner, project) + + @property + def path(self): + return "+charm/%s" % self.recipe.name diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml new file mode 100644 index 0000000..fe38cdb --- /dev/null +++ b/lib/lp/charms/browser/configure.zcml @@ -0,0 +1,15 @@ +<!-- Copyright 2021 Canonical Ltd. This software is licensed under the + GNU Affero General Public License version 3 (see the file LICENSE). +--> + +<configure + xmlns="http://namespaces.zope.org/zope" + xmlns:browser="http://namespaces.zope.org/browser" + xmlns:i18n="http://namespaces.zope.org/i18n" + i18n_domain="launchpad"> + <facet facet="overview"> + <browser:url + for="lp.charms.interfaces.charmrecipe.ICharmRecipe" + urldata="lp.charms.browser.charmrecipe.CharmRecipeURL" /> + </facet> +</configure> diff --git a/lib/lp/charms/browser/tests/__init__.py b/lib/lp/charms/browser/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/lp/charms/browser/tests/__init__.py diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py new file mode 100644 index 0000000..89f2945 --- /dev/null +++ b/lib/lp/charms/browser/tests/test_charmrecipe.py @@ -0,0 +1,32 @@ +# Copyright 2021 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Test charm recipe views.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type + +from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE +from lp.services.features.testing import FeatureFixture +from lp.services.webapp import canonical_url +from lp.testing import TestCaseWithFactory +from lp.testing.layers import DatabaseFunctionalLayer + + +class TestCharmRecipeNavigation(TestCaseWithFactory): + + layer = DatabaseFunctionalLayer + + def setUp(self): + super(TestCharmRecipeNavigation, self).setUp() + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) + + def test_canonical_url(self): + owner = self.factory.makePerson(name="person") + project = self.factory.makeProduct(name="project") + recipe = self.factory.makeCharmRecipe( + registrant=owner, owner=owner, project=project, name="charm") + self.assertEqual( + "http://launchpad.test/~person/project/+charm/charm", + canonical_url(recipe)) diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml new file mode 100644 index 0000000..87040fa --- /dev/null +++ b/lib/lp/charms/configure.zcml @@ -0,0 +1,42 @@ +<!-- Copyright 2021 Canonical Ltd. This software is licensed under the + GNU Affero General Public License version 3 (see the file LICENSE). +--> + +<configure + xmlns="http://namespaces.zope.org/zope" + xmlns:browser="http://namespaces.zope.org/browser" + xmlns:i18n="http://namespaces.zope.org/i18n" + xmlns:lp="http://namespaces.canonical.com/lp" + xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc" + i18n_domain="launchpad"> + + <include package=".browser" /> + + <!-- CharmRecipe --> + <class class="lp.charms.model.charmrecipe.CharmRecipe"> + <require + permission="launchpad.View" + interface="lp.charms.interfaces.charmrecipe.ICharmRecipeView + lp.charms.interfaces.charmrecipe.ICharmRecipeEditableAttributes + lp.charms.interfaces.charmrecipe.ICharmRecipeAdminAttributes" /> + <require + permission="launchpad.Edit" + interface="lp.charms.interfaces.charmrecipe.ICharmRecipeEdit" + set_schema="lp.charms.interfaces.charmrecipe.ICharmRecipeEditableAttributes" /> + <require + permission="launchpad.Admin" + set_schema="lp.charms.interfaces.charmrecipe.ICharmRecipeAdminAttributes" /> + </class> + <subscriber + for="lp.charms.interfaces.charmrecipe.ICharmRecipe + zope.lifecycleevent.interfaces.IObjectModifiedEvent" + handler="lp.charms.model.charmrecipe.charm_recipe_modified" /> + + <!-- CharmRecipeSet --> + <securedutility + class="lp.charms.model.charmrecipe.CharmRecipeSet" + provides="lp.charms.interfaces.charmrecipe.ICharmRecipeSet"> + <allow interface="lp.charms.interfaces.charmrecipe.ICharmRecipeSet" /> + </securedutility> + +</configure> diff --git a/lib/lp/charms/interfaces/__init__.py b/lib/lp/charms/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/lp/charms/interfaces/__init__.py diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py new file mode 100644 index 0000000..d4edafb --- /dev/null +++ b/lib/lp/charms/interfaces/charmrecipe.py @@ -0,0 +1,318 @@ +# Copyright 2021 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Charm recipe interfaces.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + "BadCharmRecipeSource", + "BadCharmRecipeSearchContext", + "CHARM_RECIPE_ALLOW_CREATE", + "CHARM_RECIPE_PRIVATE_FEATURE_FLAG", + "CharmRecipeFeatureDisabled", + "CharmRecipeNotOwner", + "CharmRecipePrivacyMismatch", + "CharmRecipePrivateFeatureDisabled", + "DuplicateCharmRecipeName", + "ICharmRecipe", + "ICharmRecipeSet", + "NoSourceForCharmRecipe", + "NoSuchCharmRecipe", + ] + +from lazr.restful.declarations import error_status +from lazr.restful.fields import ( + Reference, + ReferenceChoice, + ) +from six.moves import http_client +from zope.interface import Interface +from zope.schema import ( + Bool, + Choice, + Datetime, + Dict, + Int, + List, + Text, + TextLine, + ) +from zope.security.interfaces import Unauthorized + +from lp import _ +from lp.app.enums import InformationType +from lp.app.errors import NameLookupFailed +from lp.app.interfaces.informationtype import IInformationType +from lp.app.interfaces.launchpad import IPrivacy +from lp.app.validators.name import name_validator +from lp.app.validators.path import path_does_not_escape +from lp.code.interfaces.gitref import IGitRef +from lp.code.interfaces.gitrepository import IGitRepository +from lp.registry.interfaces.product import IProduct +from lp.services.fields import ( + PersonChoice, + PublicPersonChoice, + ) +from lp.snappy.validators.channels import channels_validator + + +CHARM_RECIPE_ALLOW_CREATE = "charm.recipe.create.enabled" +CHARM_RECIPE_PRIVATE_FEATURE_FLAG = "charm.recipe.allow_private" + + +@error_status(http_client.UNAUTHORIZED) +class CharmRecipeFeatureDisabled(Unauthorized): + """Only certain users can create new charm recipes.""" + + def __init__(self): + super(CharmRecipeFeatureDisabled, self).__init__( + "You do not have permission to create new charm recipes.") + + +@error_status(http_client.UNAUTHORIZED) +class CharmRecipePrivateFeatureDisabled(Unauthorized): + """Only certain users can create private charm recipes.""" + + def __init__(self): + super(CharmRecipePrivateFeatureDisabled, self).__init__( + "You do not have permission to create private charm recipes.") + + +@error_status(http_client.BAD_REQUEST) +class DuplicateCharmRecipeName(Exception): + """Raised for charm recipes with duplicate project/owner/name.""" + + def __init__(self): + super(DuplicateCharmRecipeName, self).__init__( + "There is already a charm recipe with the same project, owner, " + "and name.") + + +@error_status(http_client.UNAUTHORIZED) +class CharmRecipeNotOwner(Unauthorized): + """The registrant/requester is not the owner or a member of its team.""" + + +class NoSuchCharmRecipe(NameLookupFailed): + """The requested charm recipe does not exist.""" + _message_prefix = "No such charm recipe with this owner and project" + + +@error_status(http_client.BAD_REQUEST) +class NoSourceForCharmRecipe(Exception): + """Charm recipes must have a source (Git branch).""" + + def __init__(self): + super(NoSourceForCharmRecipe, self).__init__( + "New charm recipes must have a Git branch.") + + +@error_status(http_client.BAD_REQUEST) +class BadCharmRecipeSource(Exception): + """The elements of the source for a charm recipe are inconsistent.""" + + +@error_status(http_client.BAD_REQUEST) +class CharmRecipePrivacyMismatch(Exception): + """Charm recipe privacy does not match its content.""" + + def __init__(self, message=None): + super(CharmRecipePrivacyMismatch, self).__init__( + message or + "Charm recipe contains private information and cannot be public.") + + +class BadCharmRecipeSearchContext(Exception): + """The context is not valid for a charm recipe search.""" + + +class ICharmRecipeView(Interface): + """`ICharmRecipe` attributes that require launchpad.View permission.""" + + id = Int(title=_("ID"), required=True, readonly=True) + + date_created = Datetime( + title=_("Date created"), required=True, readonly=True) + date_last_modified = Datetime( + title=_("Date last modified"), required=True, readonly=True) + + registrant = PublicPersonChoice( + title=_("Registrant"), required=True, readonly=True, + vocabulary="ValidPersonOrTeam", + description=_("The person who registered this charm recipe.")) + + private = Bool( + title=_("Private"), required=False, readonly=False, + description=_("Whether this charm recipe is private.")) + + def getAllowedInformationTypes(user): + """Get a list of acceptable `InformationType`s for this charm recipe. + + If the user is a Launchpad admin, any type is acceptable. + """ + + def visibleByUser(user): + """Can the specified user see this charm recipe?""" + + +class ICharmRecipeEdit(Interface): + """`ICharmRecipe` methods that require launchpad.Edit permission.""" + + def destroySelf(): + """Delete this charm recipe, provided that it has no builds.""" + + +class ICharmRecipeEditableAttributes(Interface): + """`ICharmRecipe` attributes that can be edited. + + These attributes need launchpad.View to see, and launchpad.Edit to change. + """ + + owner = PersonChoice( + title=_("Owner"), required=True, readonly=False, + vocabulary="AllUserTeamsParticipationPlusSelf", + description=_("The owner of this charm recipe.")) + + project = ReferenceChoice( + title=_("The project that this charm recipe is associated with"), + schema=IProduct, vocabulary="Product", + required=True, readonly=False) + + name = TextLine( + title=_("Charm recipe name"), required=True, readonly=False, + constraint=name_validator, + description=_("The name of the charm recipe.")) + + description = Text( + title=_("Description"), required=False, readonly=False, + description=_("A description of the charm recipe.")) + + git_repository = ReferenceChoice( + title=_("Git repository"), + schema=IGitRepository, vocabulary="GitRepository", + required=False, readonly=True, + description=_( + "A Git repository with a branch containing a charmcraft.yaml " + "recipe.")) + + git_path = TextLine( + title=_("Git branch path"), required=False, readonly=False, + description=_( + "The path of the Git branch containing a charmcraft.yaml " + "recipe.")) + + git_ref = Reference( + IGitRef, title=_("Git branch"), required=False, readonly=False, + description=_("The Git branch containing a charmcraft.yaml recipe.")) + + build_path = TextLine( + title=_("Build path"), + description=_( + "Subdirectory within the branch containing charmcraft.yaml."), + constraint=path_does_not_escape, required=False, readonly=False) + + information_type = Choice( + title=_("Information type"), vocabulary=InformationType, + required=True, readonly=False, default=InformationType.PUBLIC, + description=_( + "The type of information contained in this charm recipe.")) + + auto_build = Bool( + title=_("Automatically build when branch changes"), + required=True, readonly=False, + description=_( + "Whether this charm recipe is built automatically when the branch " + "containing its charmcraft.yaml recipe changes.")) + + auto_build_channels = Dict( + title=_("Source snap channels for automatic builds"), + key_type=TextLine(), required=False, readonly=False, + description=_( + "A dictionary mapping snap names to channels to use when building " + "this charm recipe. Currently only 'core', 'core18', 'core20', " + "and 'charmcraft' keys are supported.")) + + is_stale = Bool( + title=_("Charm recipe is stale and is due to be rebuilt."), + required=True, readonly=True) + + store_upload = Bool( + title=_("Automatically upload to store"), + required=True, readonly=False, + description=_( + "Whether builds of this charm recipe are automatically uploaded " + "to the store.")) + + store_name = TextLine( + title=_("Registered store name"), + required=False, readonly=False, + description=_( + "The registered name of this charm in the store.")) + + store_secrets = List( + value_type=TextLine(), title=_("Store upload tokens"), + required=False, readonly=False, + description=_( + "Serialized secrets issued by the store and the login service to " + "authorize uploads of this charm recipe.")) + + store_channels = List( + title=_("Store channels"), + required=False, readonly=False, constraint=channels_validator, + description=_( + "Channels to release this charm to after uploading it to the " + "store. A channel is defined by a combination of an optional " + "track, a risk, and an optional branch, e.g. " + "'2.1/stable/fix-123', '2.1/stable', 'stable/fix-123', or " + "'stable'.")) + + +class ICharmRecipeAdminAttributes(Interface): + """`ICharmRecipe` attributes that can be edited by admins. + + These attributes need launchpad.View to see, and launchpad.Admin to change. + """ + + require_virtualized = Bool( + title=_("Require virtualized builders"), required=True, readonly=False, + description=_("Only build this charm recipe on virtual builders.")) + + +class ICharmRecipe( + ICharmRecipeView, ICharmRecipeEdit, ICharmRecipeEditableAttributes, + ICharmRecipeAdminAttributes, IPrivacy, IInformationType): + """A buildable charm recipe.""" + + +class ICharmRecipeSet(Interface): + """A utility to create and access charm recipes.""" + + def new(registrant, owner, project, name, description=None, git_ref=None, + build_path=None, require_virtualized=True, + information_type=InformationType.PUBLIC, auto_build=False, + auto_build_channels=None, store_upload=False, store_name=None, + store_secrets=None, store_channels=None, date_created=None): + """Create an `ICharmRecipe`.""" + + def getByName(owner, project, name): + """Returns the appropriate `ICharmRecipe` for the given objects.""" + + def isValidInformationType(information_type, owner, git_ref=None): + """Whether the information type context is valid.""" + + def findByGitRepository(repository, paths=None): + """Return all charm recipes for the given Git repository. + + :param repository: An `IGitRepository`. + :param paths: If not None, only return charm recipes for one of + these Git reference paths. + """ + + def detachFromGitRepository(repository): + """Detach all charm recipes from the given Git repository. + + After this, any charm recipes that previously used this repository + will have no source and so cannot dispatch new builds. + """ diff --git a/lib/lp/charms/model/__init__.py b/lib/lp/charms/model/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/lp/charms/model/__init__.py diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py new file mode 100644 index 0000000..27c0ff5 --- /dev/null +++ b/lib/lp/charms/model/charmrecipe.py @@ -0,0 +1,309 @@ +# Copyright 2021 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Charm recipes.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + "CharmRecipe", + ] + +import pytz +from storm.databases.postgres import JSON +from storm.locals import ( + Bool, + DateTime, + Int, + Reference, + Unicode, + ) +from zope.component import getUtility +from zope.interface import implementer +from zope.security.proxy import removeSecurityProxy + +from lp.app.enums import ( + FREE_INFORMATION_TYPES, + InformationType, + PUBLIC_INFORMATION_TYPES, + ) +from lp.charms.interfaces.charmrecipe import ( + CHARM_RECIPE_ALLOW_CREATE, + CHARM_RECIPE_PRIVATE_FEATURE_FLAG, + CharmRecipeFeatureDisabled, + CharmRecipeNotOwner, + CharmRecipePrivacyMismatch, + CharmRecipePrivateFeatureDisabled, + DuplicateCharmRecipeName, + ICharmRecipe, + ICharmRecipeSet, + NoSourceForCharmRecipe, + ) +from lp.code.model.gitrepository import GitRepository +from lp.registry.errors import PrivatePersonLinkageError +from lp.registry.interfaces.person import validate_public_person +from lp.services.database.constants import ( + DEFAULT, + UTC_NOW, + ) +from lp.services.database.enumcol import DBEnum +from lp.services.database.interfaces import ( + IMasterStore, + IStore, + ) +from lp.services.database.stormbase import StormBase +from lp.services.features import getFeatureFlag + + +def charm_recipe_modified(recipe, event): + """Update the date_last_modified property when a charm recipe is modified. + + This method is registered as a subscriber to `IObjectModifiedEvent` + events on charm recipes. + """ + removeSecurityProxy(recipe).date_last_modified = UTC_NOW + + +@implementer(ICharmRecipe) +class CharmRecipe(StormBase): + """See `ICharmRecipe`.""" + + __storm_table__ = "CharmRecipe" + + id = Int(primary=True) + + date_created = DateTime( + name="date_created", tzinfo=pytz.UTC, allow_none=False) + date_last_modified = DateTime( + name="date_last_modified", tzinfo=pytz.UTC, allow_none=False) + + registrant_id = Int(name="registrant", allow_none=False) + registrant = Reference(registrant_id, "Person.id") + + def _validate_owner(self, attr, value): + if not self.private: + try: + validate_public_person(self, attr, value) + except PrivatePersonLinkageError: + raise CharmRecipePrivacyMismatch( + "A public charm recipe cannot have a private owner.") + return value + + owner_id = Int(name="owner", allow_none=False, validator=_validate_owner) + owner = Reference(owner_id, "Person.id") + + project_id = Int(name="project", allow_none=False) + project = Reference(project_id, "Product.id") + + name = Unicode(name="name", allow_none=False) + + description = Unicode(name="description", allow_none=True) + + def _validate_git_repository(self, attr, value): + if not self.private and value is not None: + if IStore(GitRepository).get(GitRepository, value).private: + raise CharmRecipePrivacyMismatch( + "A public charm recipe cannot have a private repository.") + return value + + git_repository_id = Int( + name="git_repository", allow_none=True, + validator=_validate_git_repository) + git_repository = Reference(git_repository_id, "GitRepository.id") + + git_path = Unicode(name="git_path", allow_none=True) + + build_path = Unicode(name="build_path", allow_none=True) + + require_virtualized = Bool(name="require_virtualized") + + def _valid_information_type(self, attr, value): + if not getUtility(ICharmRecipeSet).isValidInformationType( + value, self.owner, self.git_ref): + raise CharmRecipePrivacyMismatch + return value + + information_type = DBEnum( + enum=InformationType, default=InformationType.PUBLIC, + name="information_type", validator=_valid_information_type, + allow_none=False) + + auto_build = Bool(name="auto_build", allow_none=False) + + auto_build_channels = JSON("auto_build_channels", allow_none=True) + + is_stale = Bool(name="is_stale", allow_none=False) + + def __init__(self, registrant, owner, project, name, description=None, + git_ref=None, build_path=None, require_virtualized=True, + information_type=InformationType.PUBLIC, auto_build=False, + auto_build_channels=None, store_upload=False, + store_name=None, store_secrets=None, store_channels=None, + date_created=DEFAULT): + """Construct a `CharmRecipe`.""" + if not getFeatureFlag(CHARM_RECIPE_ALLOW_CREATE): + raise CharmRecipeFeatureDisabled() + super(CharmRecipe, self).__init__() + + # Set this first for use by other validators. + self.information_type = information_type + + self.date_created = date_created + self.date_last_modified = date_created + self.registrant = registrant + self.owner = owner + self.project = project + self.name = name + self.description = description + self.git_ref = git_ref + self.build_path = build_path + self.require_virtualized = require_virtualized + self.auto_build = auto_build + self.auto_build_channels = auto_build_channels + self.store_upload = store_upload + self.store_name = store_name + self.store_secrets = store_secrets + self.store_channels = store_channels + + def __repr__(self): + return "<CharmRecipe ~%s/%s/+charm/%s>" % ( + self.owner.name, self.project.name, self.name) + + @property + def private(self): + """See `ICharmRecipe`.""" + return self.information_type not in PUBLIC_INFORMATION_TYPES + + @property + def git_ref(self): + """See `ICharmRecipe`.""" + if self.git_repository is not None: + return self.git_repository.getRefByPath(self.git_path) + else: + return None + + @git_ref.setter + def git_ref(self, value): + """See `ICharmRecipe`.""" + if value is not None: + self.git_repository = value.repository + self.git_path = value.path + else: + self.git_repository = None + self.git_path = None + + @property + def store_channels(self): + """See `ICharmRecipe`.""" + return self._store_channels or [] + + @store_channels.setter + def store_channels(self, value): + """See `ICharmRecipe`.""" + self._store_channels = value or None + + def getAllowedInformationTypes(self, user): + """See `ICharmRecipe`.""" + # XXX cjwatson 2021-05-26: Only allow free information types until + # we have more privacy infrastructure in place. + return FREE_INFORMATION_TYPES + + def visibleByUser(self, user): + """See `ICharmRecipe`.""" + if self.information_type in PUBLIC_INFORMATION_TYPES: + return True + # XXX cjwatson 2021-05-27: Finish implementing this once we have + # more privacy infrastructure. + return False + + def destroySelf(self): + """See `ICharmRecipe`.""" + IStore(CharmRecipe).remove(self) + + +@implementer(ICharmRecipeSet) +class CharmRecipeSet: + """See `ICharmRecipeSet`.""" + + def new(self, registrant, owner, project, name, description=None, + git_ref=None, build_path=None, require_virtualized=True, + information_type=InformationType.PUBLIC, auto_build=False, + auto_build_channels=None, store_upload=False, store_name=None, + store_secrets=None, store_channels=None, date_created=DEFAULT): + """See `ICharmRecipeSet`.""" + if not registrant.inTeam(owner): + if owner.is_team: + raise CharmRecipeNotOwner( + "%s is not a member of %s." % + (registrant.displayname, owner.displayname)) + else: + raise CharmRecipeNotOwner( + "%s cannot create charm recipes owned by %s." % + (registrant.displayname, owner.displayname)) + + if git_ref is None: + raise NoSourceForCharmRecipe + if self.getByName(owner, project, name) is not None: + raise DuplicateCharmRecipeName + + # The relevant validators will do their own checks as well, but we + # do a single up-front check here in order to avoid an + # IntegrityError due to exceptions being raised during object + # creation and to ensure that everything relevant is in the Storm + # cache. + if not self.isValidInformationType( + information_type, owner, git_ref): + raise CharmRecipePrivacyMismatch + + store = IMasterStore(CharmRecipe) + recipe = CharmRecipe( + registrant, owner, project, name, description=description, + git_ref=git_ref, build_path=build_path, + require_virtualized=require_virtualized, + information_type=information_type, auto_build=auto_build, + auto_build_channels=auto_build_channels, + store_upload=store_upload, store_name=store_name, + store_secrets=store_secrets, store_channels=store_channels, + date_created=date_created) + store.add(recipe) + + return recipe + + def getByName(self, owner, project, name): + """See `ICharmRecipeSet`.""" + return IStore(CharmRecipe).find( + CharmRecipe, owner=owner, project=project, name=name).one() + + def isValidInformationType(self, information_type, owner, git_ref=None): + """See `ICharmRecipeSet`.""" + private = information_type not in PUBLIC_INFORMATION_TYPES + if private: + # If appropriately enabled via feature flag. + if not getFeatureFlag(CHARM_RECIPE_PRIVATE_FEATURE_FLAG): + raise CharmRecipePrivateFeatureDisabled + return True + + # Public charm recipes with private sources are not allowed. + if git_ref is not None and git_ref.private: + return False + + # Public charm recipes owned by private teams are not allowed. + if owner is not None and owner.private: + return False + + return True + + def findByGitRepository(self, repository, paths=None): + """See `ICharmRecipeSet`.""" + clauses = [CharmRecipe.git_repository == repository] + if paths is not None: + clauses.append(CharmRecipe.git_path.is_in(paths)) + # XXX cjwatson 2021-05-26: Check permissions once we have some + # privacy infrastructure. + return IStore(CharmRecipe).find(CharmRecipe, *clauses) + + def detachFromGitRepository(self, repository): + """See `ICharmRecipeSet`.""" + self.findByGitRepository(repository).set( + git_repository_id=None, git_path=None, date_last_modified=UTC_NOW) diff --git a/lib/lp/charms/tests/__init__.py b/lib/lp/charms/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/lp/charms/tests/__init__.py diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py new file mode 100644 index 0000000..654cfe3 --- /dev/null +++ b/lib/lp/charms/tests/test_charmrecipe.py @@ -0,0 +1,246 @@ +# Copyright 2021 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Test charm recipes.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type + +from zope.component import getUtility +from zope.security.proxy import removeSecurityProxy + +from lp.app.enums import InformationType +from lp.charms.interfaces.charmrecipe import ( + CHARM_RECIPE_ALLOW_CREATE, + CharmRecipeFeatureDisabled, + CharmRecipePrivateFeatureDisabled, + ICharmRecipe, + ICharmRecipeSet, + NoSourceForCharmRecipe, + ) +from lp.services.database.constants import ( + ONE_DAY_AGO, + UTC_NOW, + ) +from lp.services.features.testing import FeatureFixture +from lp.services.webapp.snapshot import notify_modified +from lp.testing import ( + admin_logged_in, + person_logged_in, + TestCaseWithFactory, + ) +from lp.testing.layers import ( + DatabaseFunctionalLayer, + LaunchpadZopelessLayer, + ) + + +class TestCharmRecipeFeatureFlags(TestCaseWithFactory): + + layer = LaunchpadZopelessLayer + + def test_feature_flag_disabled(self): + # Without a feature flag, we wil not create any charm recipes. + self.assertRaises( + CharmRecipeFeatureDisabled, self.factory.makeCharmRecipe) + + def test_private_feature_flag_disabled(self): + # Without a private feature flag, we wil not create new private + # charm recipes. + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) + self.assertRaises( + CharmRecipePrivateFeatureDisabled, self.factory.makeCharmRecipe, + information_type=InformationType.PROPRIETARY) + + +class TestCharmRecipe(TestCaseWithFactory): + + layer = DatabaseFunctionalLayer + + def setUp(self): + super(TestCharmRecipe, self).setUp() + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) + + def test_implements_interfaces(self): + # CharmRecipe implements ICharmRecipe. + recipe = self.factory.makeCharmRecipe() + with admin_logged_in(): + self.assertProvides(recipe, ICharmRecipe) + + def test___repr__(self): + # CharmRecipe objects have an informative __repr__. + recipe = self.factory.makeCharmRecipe() + self.assertEqual( + "<CharmRecipe ~%s/%s/+charm/%s>" % ( + recipe.owner.name, recipe.project.name, recipe.name), + repr(recipe)) + + def test_initial_date_last_modified(self): + # The initial value of date_last_modified is date_created. + recipe = self.factory.makeCharmRecipe(date_created=ONE_DAY_AGO) + self.assertEqual(recipe.date_created, recipe.date_last_modified) + + def test_modifiedevent_sets_date_last_modified(self): + # When a CharmRecipe receives an object modified event, the last + # modified date is set to UTC_NOW. + recipe = self.factory.makeCharmRecipe(date_created=ONE_DAY_AGO) + with notify_modified(removeSecurityProxy(recipe), ["name"]): + pass + self.assertSqlAttributeEqualsDate( + recipe, "date_last_modified", UTC_NOW) + + def test_delete_without_builds(self): + # A charm recipe with no builds can be deleted. + owner = self.factory.makePerson() + project = self.factory.makeProduct() + recipe = self.factory.makeCharmRecipe( + registrant=owner, owner=owner, project=project, name="condemned") + self.assertIsNotNone( + getUtility(ICharmRecipeSet).getByName(owner, project, "condemned")) + with person_logged_in(recipe.owner): + recipe.destroySelf() + self.assertIsNone( + getUtility(ICharmRecipeSet).getByName(owner, project, "condemned")) + + +class TestCharmRecipeSet(TestCaseWithFactory): + + layer = DatabaseFunctionalLayer + + def setUp(self): + super(TestCharmRecipeSet, self).setUp() + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) + + def test_class_implements_interfaces(self): + # The CharmRecipeSet class implements ICharmRecipeSet. + self.assertProvides(getUtility(ICharmRecipeSet), ICharmRecipeSet) + + def makeCharmRecipeComponents(self, git_ref=None): + """Return a dict of values that can be used to make a charm recipe. + + Suggested use: provide as kwargs to ICharmRecipeSet.new. + + :param git_ref: An `IGitRef`, or None. + """ + registrant = self.factory.makePerson() + components = { + "registrant": registrant, + "owner": self.factory.makeTeam(owner=registrant), + "project": self.factory.makeProduct(), + "name": self.factory.getUniqueUnicode("charm-name"), + } + if git_ref is None: + git_ref = self.factory.makeGitRefs()[0] + components["git_ref"] = git_ref + return components + + def test_creation_git(self): + # The metadata entries supplied when a charm recipe is created for a + # Git branch are present on the new object. + [ref] = self.factory.makeGitRefs() + components = self.makeCharmRecipeComponents(git_ref=ref) + recipe = getUtility(ICharmRecipeSet).new(**components) + self.assertEqual(components["registrant"], recipe.registrant) + self.assertEqual(components["owner"], recipe.owner) + self.assertEqual(components["project"], recipe.project) + self.assertEqual(components["name"], recipe.name) + self.assertEqual(ref.repository, recipe.git_repository) + self.assertEqual(ref.path, recipe.git_path) + self.assertEqual(ref, recipe.git_ref) + self.assertIsNone(recipe.build_path) + self.assertFalse(recipe.auto_build) + self.assertIsNone(recipe.auto_build_channels) + self.assertTrue(recipe.require_virtualized) + self.assertFalse(recipe.private) + self.assertFalse(recipe.store_upload) + self.assertIsNone(recipe.store_name) + self.assertIsNone(recipe.store_secrets) + self.assertEqual([], recipe.store_channels) + + def test_creation_no_source(self): + # Attempting to create a charm recipe without a Git repository + # fails. + registrant = self.factory.makePerson() + self.assertRaises( + NoSourceForCharmRecipe, getUtility(ICharmRecipeSet).new, + registrant, registrant, self.factory.makeProduct(), + self.factory.getUniqueUnicode("charm-name")) + + def test_getByName(self): + owner = self.factory.makePerson() + project = self.factory.makeProduct() + project_recipe = self.factory.makeCharmRecipe( + registrant=owner, owner=owner, project=project, name="proj-charm") + self.factory.makeCharmRecipe( + registrant=owner, owner=owner, name="proj-charm") + + self.assertEqual( + project_recipe, + getUtility(ICharmRecipeSet).getByName( + owner, project, "proj-charm")) + + def test_findByGitRepository(self): + # ICharmRecipeSet.findByGitRepository returns all charm recipes with + # the given Git repository. + repositories = [self.factory.makeGitRepository() for i in range(2)] + recipes = [] + for repository in repositories: + for i in range(2): + [ref] = self.factory.makeGitRefs(repository=repository) + recipes.append(self.factory.makeCharmRecipe(git_ref=ref)) + recipe_set = getUtility(ICharmRecipeSet) + self.assertContentEqual( + recipes[:2], recipe_set.findByGitRepository(repositories[0])) + self.assertContentEqual( + recipes[2:], recipe_set.findByGitRepository(repositories[1])) + + def test_findByGitRepository_paths(self): + # ICharmRecipeSet.findByGitRepository can restrict by reference + # paths. + repositories = [self.factory.makeGitRepository() for i in range(2)] + recipes = [] + for repository in repositories: + for i in range(3): + [ref] = self.factory.makeGitRefs(repository=repository) + recipes.append(self.factory.makeCharmRecipe(git_ref=ref)) + recipe_set = getUtility(ICharmRecipeSet) + self.assertContentEqual( + [], recipe_set.findByGitRepository(repositories[0], paths=[])) + self.assertContentEqual( + [recipes[0]], + recipe_set.findByGitRepository( + repositories[0], paths=[recipes[0].git_ref.path])) + self.assertContentEqual( + recipes[:2], + recipe_set.findByGitRepository( + repositories[0], + paths=[recipes[0].git_ref.path, recipes[1].git_ref.path])) + + def test_detachFromGitRepository(self): + # ICharmRecipeSet.detachFromGitRepository clears the given Git + # repository from all charm recipes. + repositories = [self.factory.makeGitRepository() for i in range(2)] + recipes = [] + paths = [] + refs = [] + for repository in repositories: + for i in range(2): + [ref] = self.factory.makeGitRefs(repository=repository) + paths.append(ref.path) + refs.append(ref) + recipes.append(self.factory.makeCharmRecipe( + git_ref=ref, date_created=ONE_DAY_AGO)) + getUtility(ICharmRecipeSet).detachFromGitRepository(repositories[0]) + self.assertEqual( + [None, None, repositories[1], repositories[1]], + [recipe.git_repository for recipe in recipes]) + self.assertEqual( + [None, None, paths[2], paths[3]], + [recipe.git_path for recipe in recipes]) + self.assertEqual( + [None, None, refs[2], refs[3]], + [recipe.git_ref for recipe in recipes]) + for recipe in recipes[:2]: + self.assertSqlAttributeEqualsDate( + recipe, "date_last_modified", UTC_NOW) diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py index d0ac773..0a98bd3 100644 --- a/lib/lp/code/model/gitrepository.py +++ b/lib/lp/code/model/gitrepository.py @@ -93,6 +93,7 @@ from lp.app.interfaces.launchpad import ( IPrivacy, ) from lp.app.interfaces.services import IService +from lp.charms.interfaces.charmrecipe import ICharmRecipeSet from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta from lp.code.enums import ( BranchMergeProposalStatus, @@ -1647,6 +1648,11 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): alteration_operations.append(DeletionCallable( None, msg("Some OCI recipes build from this repository."), getUtility(IOCIRecipeSet).detachFromGitRepository, self)) + if not getUtility(ICharmRecipeSet).findByGitRepository( + self).is_empty(): + alteration_operations.append(DeletionCallable( + None, msg("Some charm recipes build from this repository."), + getUtility(ICharmRecipeSet).detachFromGitRepository, self)) return (alteration_operations, deletion_operations) diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py index ff79c65..e8e7205 100644 --- a/lib/lp/code/model/tests/test_gitrepository.py +++ b/lib/lp/code/model/tests/test_gitrepository.py @@ -59,6 +59,7 @@ from lp.app.enums import ( ) from lp.app.errors import NotFoundError from lp.app.interfaces.launchpad import ILaunchpadCelebrities +from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE from lp.code.enums import ( BranchMergeProposalStatus, BranchSubscriptionDiffSize, @@ -1163,6 +1164,33 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): self.assertIsNone(snap2.git_repository) self.assertIsNone(snap2.git_path) + def test_charm_recipe_requirements(self): + # If a repository is used by a charm recipe, the deletion + # requirements indicate this. + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) + [ref] = self.factory.makeGitRefs() + self.factory.makeCharmRecipe(git_ref=ref) + self.assertEqual( + {None: + ("alter", _("Some charm recipes build from this repository."))}, + ref.repository.getDeletionRequirements()) + + def test_charm_recipe_deletion(self): + # break_references allows deleting a repository used by a charm + # recipe. + self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) + repository = self.factory.makeGitRepository() + [ref1, ref2] = self.factory.makeGitRefs( + repository=repository, paths=["refs/heads/1", "refs/heads/2"]) + recipe1 = self.factory.makeCharmRecipe(git_ref=ref1) + recipe2 = self.factory.makeCharmRecipe(git_ref=ref2) + repository.destroySelf(break_references=True) + transaction.commit() + self.assertIsNone(recipe1.git_repository) + self.assertIsNone(recipe1.git_path) + self.assertIsNone(recipe2.git_repository) + self.assertIsNone(recipe2.git_path) + def test_ClearPrerequisiteRepository(self): # ClearPrerequisiteRepository.__call__ must clear the prerequisite # repository. diff --git a/lib/lp/configure.zcml b/lib/lp/configure.zcml index b77284d..a646f98 100644 --- a/lib/lp/configure.zcml +++ b/lib/lp/configure.zcml @@ -1,4 +1,4 @@ -<!-- Copyright 2009-2010 Canonical Ltd. This software is licensed under the +<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the GNU Affero General Public License version 3 (see the file LICENSE). --> @@ -27,6 +27,7 @@ <include package="lp.blueprints" /> <include package="lp.bugs" /> <include package="lp.buildmaster" /> + <include package="lp.charms" /> <include package="lp.code" /> <include package="lp.coop.answersbugs" /> <include package="lp.oci" /> diff --git a/lib/lp/registry/browser/personproduct.py b/lib/lp/registry/browser/personproduct.py index 7d850e3..280550c 100644 --- a/lib/lp/registry/browser/personproduct.py +++ b/lib/lp/registry/browser/personproduct.py @@ -18,6 +18,7 @@ from zope.interface import implementer from zope.traversing.interfaces import IPathAdapter from lp.app.errors import NotFoundError +from lp.charms.interfaces.charmrecipe import ICharmRecipeSet from lp.code.browser.vcslisting import PersonTargetDefaultVCSNavigationMixin from lp.code.interfaces.branchnamespace import get_branch_namespace from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory @@ -61,6 +62,13 @@ class PersonProductNavigation(PersonTargetDefaultVCSNavigationMixin, pillar=self.context.product, name=name) + @stepthrough('+charm') + def traverse_charm(self, name): + return getUtility(ICharmRecipeSet).getByName( + owner=self.context.person, + project=self.context.product, + name=name) + @implementer(IMultiFacetedBreadcrumb) class PersonProductBreadcrumb(Breadcrumb): diff --git a/lib/lp/security.py b/lib/lp/security.py index 1a57e6a..539a0f5 100644 --- a/lib/lp/security.py +++ b/lib/lp/security.py @@ -65,6 +65,7 @@ from lp.buildmaster.interfaces.builder import ( ) from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJob from lp.buildmaster.interfaces.packagebuild import IPackageBuild +from lp.charms.interfaces.charmrecipe import ICharmRecipe from lp.code.interfaces.branch import ( IBranch, user_has_special_branch_access, @@ -3615,3 +3616,43 @@ class OCIPushRuleEdit(AuthorizationBase): return ( user.isOwner(self.obj.recipe) or user.in_commercial_admin or user.in_admin) + + +class ViewCharmRecipe(AuthorizationBase): + """Private charm recipes are only visible to their owners and admins.""" + permission = 'launchpad.View' + usedfor = ICharmRecipe + + def checkAuthenticated(self, user): + return self.obj.visibleByUser(user.person) + + def checkUnauthenticated(self): + return self.obj.visibleByUser(None) + + +class EditCharmRecipe(AuthorizationBase): + permission = 'launchpad.Edit' + usedfor = ICharmRecipe + + def checkAuthenticated(self, user): + return ( + user.isOwner(self.obj) or + user.in_commercial_admin or user.in_admin) + + +class AdminCharmRecipe(AuthorizationBase): + """Restrict changing build settings on charm recipes. + + The security of the non-virtualised build farm depends on these + settings, so they can only be changed by "PPA"/commercial admins, or by + "PPA" self admins on charm recipes that they can already edit. + """ + permission = 'launchpad.Admin' + usedfor = ICharmRecipe + + def checkAuthenticated(self, user): + if user.in_ppa_admin or user.in_commercial_admin or user.in_admin: + return True + return ( + user.in_ppa_self_admins + and EditCharmRecipe(self.obj).checkAuthenticated(user)) diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py index 7959dcc..a28676c 100644 --- a/lib/lp/testing/factory.py +++ b/lib/lp/testing/factory.py @@ -110,6 +110,7 @@ from lp.buildmaster.enums import ( ) from lp.buildmaster.interfaces.builder import IBuilderSet from lp.buildmaster.interfaces.processor import IProcessorSet +from lp.charms.interfaces.charmrecipe import ICharmRecipeSet from lp.code.enums import ( BranchMergeProposalStatus, BranchSubscriptionNotificationLevel, @@ -5105,6 +5106,50 @@ class BareLaunchpadObjectFactory(ObjectFactory): registry_credentials=registry_credentials, image_name=image_name) + def makeCharmRecipe(self, registrant=None, owner=None, project=None, + name=None, description=None, git_ref=None, + build_path=None, require_virtualized=True, + information_type=InformationType.PUBLIC, + auto_build=False, auto_build_channels=None, + is_stale=None, store_upload=False, store_name=None, + store_secrets=None, store_channels=None, + date_created=DEFAULT): + """Make a new charm recipe.""" + if registrant is None: + registrant = self.makePerson() + private = information_type not in PUBLIC_INFORMATION_TYPES + if owner is None: + # Private charm recipes cannot be owned by non-moderated teams. + membership_policy = ( + TeamMembershipPolicy.OPEN if private + else TeamMembershipPolicy.MODERATED) + owner = self.makeTeam( + registrant, membership_policy=membership_policy) + if project is None: + branch_sharing_policy = ( + BranchSharingPolicy.PUBLIC_OR_PROPRIETARY if not private + else BranchSharingPolicy.PROPRIETARY) + project = self.makeProduct( + owner=registrant, registrant=registrant, + information_type=information_type, + branch_sharing_policy=branch_sharing_policy) + if name is None: + name = self.getUniqueUnicode(u"charm-name") + if git_ref is None: + git_ref = self.makeGitRefs()[0] + recipe = getUtility(ICharmRecipeSet).new( + registrant=registrant, owner=owner, project=project, name=name, + description=description, git_ref=git_ref, build_path=build_path, + require_virtualized=require_virtualized, + information_type=information_type, auto_build=auto_build, + auto_build_channels=auto_build_channels, store_upload=store_upload, + store_name=store_name, store_secrets=store_secrets, + store_channels=store_channels, date_created=date_created) + if is_stale is not None: + removeSecurityProxy(recipe).is_stale = is_stale + IStore(recipe).flush() + return recipe + # Some factory methods return simple Python types. We don't add # security wrappers for them, as well as for objects created by
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : [email protected] Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp

