launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27165
[Merge] ~cjwatson/launchpad:charm-recipe-model into launchpad:master
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