← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipes into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipes into launchpad:master.

Commit message:
Add basic model for craft recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/473731
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipes into launchpad:master.
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 4395165..f5c38eb 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -92,6 +92,7 @@ from lp.code.interfaces.gitrepository import IGitRepository
 from lp.code.model.gitcollection import GenericGitCollection
 from lp.code.model.gitref import GitRef
 from lp.code.model.gitrepository import GitRepository
+from lp.code.model.reciperegistry import recipe_registry
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.distribution import IDistributionSet
 from lp.registry.interfaces.person import (
@@ -914,6 +915,7 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
         ).remove()
 
 
+@recipe_registry.register_recipe_type("ICharmRecipeSet", "charm")
 @implementer(ICharmRecipeSet)
 class CharmRecipeSet:
     """See `ICharmRecipeSet`."""
diff --git a/lib/lp/code/interfaces/reciperegistry.py b/lib/lp/code/interfaces/reciperegistry.py
new file mode 100644
index 0000000..ba32253
--- /dev/null
+++ b/lib/lp/code/interfaces/reciperegistry.py
@@ -0,0 +1,33 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for recipe registry."""
+
+__all__ = [
+    "IRecipeSet",
+    "IRecipeRegistry",
+]
+
+from zope.interface import Attribute, Interface
+
+
+class IRecipeSet(Interface):
+    """Base interface for recipe sets."""
+
+    def findByGitRepository(repository):
+        """Find recipes that use the given repository."""
+
+    def detachFromGitRepository(repository):
+        """Detach recipes from the given repository."""
+
+
+class IRecipeRegistry(Interface):
+    """A registry for recipe types."""
+
+    recipe_types = Attribute("List of registered recipe types")
+
+    def register_recipe_type(utility_name, message):
+        """Register a new recipe type."""
+
+    def get_recipe_types():
+        """Get all registered recipe types."""
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 4615a49..4abb208 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -101,8 +101,8 @@ from lp.code.model.gitactivity import GitActivity
 from lp.code.model.gitref import GitRef, GitRefDefault, GitRefFrozen
 from lp.code.model.gitrule import GitRule, GitRuleGrant
 from lp.code.model.gitsubscription import GitSubscription
+from lp.code.model.reciperegistry import recipe_registry
 from lp.code.model.revisionstatus import RevisionStatusReport
-from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.registry.enums import PersonVisibility
 from lp.registry.errors import CannotChangeInformationType
 from lp.registry.interfaces.accesspolicy import (
@@ -1980,53 +1980,21 @@ class GitRepository(
             )
             for recipe in recipes
         )
-        # XXX 2024-09-12 jugmac00: there is potential to refactor the following
-        # parts with a for loop
-        # Additionally, instead of manually creating a mapping of
-        # `ISomethingSet`s and the names for the message, we could and probably
-        # should have a registering decorator, so whenever a new `IRecipeSet`
-        # gets created this mapping will be updated automatically
-
-        if not getUtility(ISnapSet).findByGitRepository(self).is_empty():
-            alteration_operations.append(
-                DeletionCallable(
-                    None,
-                    msg("Some snap packages build from this repository."),
-                    getUtility(ISnapSet).detachFromGitRepository,
-                    self,
-                )
-            )
-        if not getUtility(IOCIRecipeSet).findByGitRepository(self).is_empty():
-            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,
-                )
-            )
-        if not getUtility(IRockRecipeSet).findByGitRepository(self).is_empty():
-            alteration_operations.append(
-                DeletionCallable(
-                    None,
-                    msg("Some rock recipes build from this repository."),
-                    getUtility(IRockRecipeSet).detachFromGitRepository,
-                    self,
+
+        for utility_name, message, _ in recipe_registry.get_recipe_types():
+            if (
+                not getUtility(utility_name)
+                .findByGitRepository(self)
+                .is_empty()
+            ):
+                alteration_operations.append(
+                    DeletionCallable(
+                        None,
+                        msg(message),
+                        getUtility(utility_name).detachFromGitRepository,
+                        self,
+                    )
                 )
-            )
 
         return (alteration_operations, deletion_operations)
 
diff --git a/lib/lp/code/model/reciperegistry.py b/lib/lp/code/model/reciperegistry.py
new file mode 100644
index 0000000..d5a3a92
--- /dev/null
+++ b/lib/lp/code/model/reciperegistry.py
@@ -0,0 +1,31 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Implementation of recipe registry."""
+
+__all__ = [
+    "RecipeRegistry",
+]
+
+from zope.interface import implementer
+
+from lp.code.interfaces.reciperegistry import IRecipeRegistry
+
+
+@implementer(IRecipeRegistry)
+class RecipeRegistry:
+    def __init__(self):
+        self.recipe_types = []
+
+    def register_recipe_type(self, utility_name, message):
+        def decorator(cls):
+            self.recipe_types.append((utility_name, message, cls))
+            return cls
+
+        return decorator
+
+    def get_recipe_types(self):
+        return self.recipe_types
+
+
+recipe_registry = RecipeRegistry()
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 5f8f255..4e48291 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -127,6 +127,7 @@ from lp.code.model.gitrepository import (
 )
 from lp.code.tests.helpers import GitHostingFixture
 from lp.code.xmlrpc.git import GitAPI
+from lp.crafts.interfaces.craftrecipe import CRAFT_RECIPE_ALLOW_CREATE
 from lp.registry.enums import (
     BranchSharingPolicy,
     PersonVisibility,
@@ -1740,6 +1741,39 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
         self.assertIsNone(recipe2.git_repository)
         self.assertIsNone(recipe2.git_path)
 
+    def test_craft_recipe_requirements(self):
+        # If a repository is used by a craft recipe, the deletion
+        # requirements indicate this.
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+        [ref] = self.factory.makeGitRefs()
+        self.factory.makeCraftRecipe(git_ref=ref)
+        self.assertEqual(
+            {
+                None: (
+                    "alter",
+                    _("Some craft recipes build from this repository."),
+                )
+            },
+            ref.repository.getDeletionRequirements(),
+        )
+
+    def test_craft_recipe_deletion(self):
+        # break_references allows deleting a repository used by a craft
+        # recipe.
+        self.useFixture(FeatureFixture({CRAFT_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.makeCraftRecipe(git_ref=ref1)
+        recipe2 = self.factory.makeCraftRecipe(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 8592b8f..800322f 100644
--- a/lib/lp/configure.zcml
+++ b/lib/lp/configure.zcml
@@ -36,6 +36,7 @@
     <include package="lp.testopenid" />
     <include package="lp.registry" />
     <include package="lp.rocks" />
+    <include package="lp.crafts" />
     <include package="lp.xmlrpc" />
 
     <include file="permissions.zcml" />
diff --git a/lib/lp/crafts/__init__.py b/lib/lp/crafts/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/crafts/__init__.py
diff --git a/lib/lp/crafts/browser/configure.zcml b/lib/lp/crafts/browser/configure.zcml
new file mode 100644
index 0000000..94f07c4
--- /dev/null
+++ b/lib/lp/crafts/browser/configure.zcml
@@ -0,0 +1,16 @@
+<!-- Copyright 2024 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";
+    i18n_domain="launchpad">
+    <lp:facet facet="overview">
+        <lp:url
+            for="lp.craft.interfaces.craftrecipe.ICraftRecipe"
+            urldata="lp.craft.browser.craftrecipe.CraftRecipeURL" />
+    </lp:facet>
+</configure>
\ No newline at end of file
diff --git a/lib/lp/crafts/browser/craftrecipe.py b/lib/lp/crafts/browser/craftrecipe.py
new file mode 100644
index 0000000..3ff948d
--- /dev/null
+++ b/lib/lp/crafts/browser/craftrecipe.py
@@ -0,0 +1,34 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe views."""
+
+__all__ = [
+    "CraftRecipeURL",
+]
+
+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 CraftRecipeURL:
+    """Craft 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 "+craft/%s" % self.recipe.name
diff --git a/lib/lp/crafts/browser/tests/test_craftrecipe.py b/lib/lp/crafts/browser/tests/test_craftrecipe.py
new file mode 100644
index 0000000..23c3b32
--- /dev/null
+++ b/lib/lp/crafts/browser/tests/test_craftrecipe.py
@@ -0,0 +1,30 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test craft recipe views."""
+
+from lp.crafts.interfaces.craftrecipe import CRAFT_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 TestCraftRecipeNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        project = self.factory.makeProduct(name="project")
+        recipe = self.factory.makeCraftRecipe(
+            registrant=owner, owner=owner, project=project, name="craft"
+        )
+        self.assertEqual(
+            "http://launchpad.test/~person/project/+craft/craft";,
+            canonical_url(recipe),
+        )
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
new file mode 100644
index 0000000..e32a27e
--- /dev/null
+++ b/lib/lp/crafts/configure.zcml
@@ -0,0 +1,43 @@
+<!-- Copyright 2024 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">
+
+    <lp:authorizations module=".security" />
+    <include package=".browser" />
+
+    <!-- CraftRecipe -->
+    <class class="lp.craft.model.craftrecipe.CraftRecipe">
+        <require
+            permission="launchpad.View"
+            interface="lp.craft.interfaces.craftrecipe.ICraftRecipeView
+                       lp.craft.interfaces.craftrecipe.ICraftRecipeEditableAttributes
+                       lp.craft.interfaces.craftrecipe.ICraftRecipeAdminAttributes" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.craft.interfaces.craftrecipe.ICraftRecipeEdit"
+            set_schema="lp.craft.interfaces.craftrecipe.ICraftRecipeEditableAttributes" />
+        <require
+            permission="launchpad.Admin"
+            set_schema="lp.craft.interfaces.craftrecipe.ICraftRecipeAdminAttributes" />
+    </class>
+    <subscriber
+        for="lp.craft.interfaces.craftrecipe.ICraftRecipe
+             zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+        handler="lp.craft.model.craftrecipe.craft_recipe_modified" />
+
+    <!-- CraftRecipeSet -->
+    <lp:securedutility
+        class="lp.craft.model.craftrecipe.CraftRecipeSet"
+        provides="lp.craft.interfaces.craftrecipe.ICraftRecipeSet">
+        <allow interface="lp.craft.interfaces.craftrecipe.ICraftRecipeSet" />
+    </lp:securedutility>
+
+</configure>
\ No newline at end of file
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
new file mode 100644
index 0000000..af8be8f
--- /dev/null
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -0,0 +1,383 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe interfaces."""
+
+__all__ = [
+    "BadCraftRecipeSource",
+    "BadCraftRecipeSearchContext",
+    "CRAFT_RECIPE_ALLOW_CREATE",
+    "CRAFT_RECIPE_PRIVATE_FEATURE_FLAG",
+    "CraftRecipeFeatureDisabled",
+    "CraftRecipeNotOwner",
+    "CraftRecipePrivacyMismatch",
+    "CraftRecipePrivateFeatureDisabled",
+    "DuplicateCraftRecipeName",
+    "ICraftRecipe",
+    "ICraftRecipeSet",
+    "NoSourceForCraftRecipe",
+    "NoSuchCraftRecipe",
+]
+
+import http.client
+
+from lazr.restful.declarations import error_status, exported
+from lazr.restful.fields import Reference, ReferenceChoice
+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.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
+
+CRAFT_RECIPE_ALLOW_CREATE = "craft.recipe.create.enabled"
+CRAFT_RECIPE_PRIVATE_FEATURE_FLAG = "craft.recipe.allow_private"
+
+
+@error_status(http.client.UNAUTHORIZED)
+class CraftRecipeFeatureDisabled(Unauthorized):
+    """Only certain users can create new craft recipes."""
+
+    def __init__(self):
+        super().__init__(
+            "You do not have permission to create new craft recipes."
+        )
+
+
+@error_status(http.client.UNAUTHORIZED)
+class CraftRecipePrivateFeatureDisabled(Unauthorized):
+    """Only certain users can create private craft recipes."""
+
+    def __init__(self):
+        super().__init__(
+            "You do not have permission to create private craft recipes."
+        )
+
+
+@error_status(http.client.BAD_REQUEST)
+class DuplicateCraftRecipeName(Exception):
+    """Raised for craft recipes with duplicate project/owner/name."""
+
+    def __init__(self):
+        super().__init__(
+            "There is already a craft recipe with the same project, owner, "
+            "and name."
+        )
+
+
+@error_status(http.client.UNAUTHORIZED)
+class CraftRecipeNotOwner(Unauthorized):
+    """The registrant/requester is not the owner or a member of its team."""
+
+
+class NoSuchCraftRecipe(NameLookupFailed):
+    """The requested craft recipe does not exist."""
+
+    _message_prefix = "No such craft recipe with this owner and project"
+
+
+@error_status(http.client.BAD_REQUEST)
+class NoSourceForCraftRecipe(Exception):
+    """Craft recipes must have a source (Git branch)."""
+
+    def __init__(self):
+        super().__init__("New craft recipes must have a Git branch.")
+
+
+@error_status(http.client.BAD_REQUEST)
+class BadCraftRecipeSource(Exception):
+    """The elements of the source for a craft recipe are inconsistent."""
+
+
+@error_status(http.client.BAD_REQUEST)
+class CraftRecipePrivacyMismatch(Exception):
+    """Craft recipe privacy does not match its content."""
+
+    def __init__(self, message=None):
+        super().__init__(
+            message
+            or "Craft recipe contains private information and cannot be "
+            "public."
+        )
+
+
+class BadCraftRecipeSearchContext(Exception):
+    """The context is not valid for a craft recipe search."""
+
+
+class ICraftRecipeView(Interface):
+    """`ICraftRecipe` 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 craft recipe."),
+    )
+
+    private = Bool(
+        title=_("Private"),
+        required=False,
+        readonly=False,
+        description=_("Whether this craft recipe is private."),
+    )
+
+    def getAllowedInformationTypes(user):
+        """Get a list of acceptable `InformationType`s for this craft recipe.
+
+        If the user is a Launchpad admin, any type is acceptable.
+        """
+
+    def visibleByUser(user):
+        """Can the specified user see this craft recipe?"""
+
+
+class ICraftRecipeEdit(Interface):
+    """`ICraftRecipe` methods that require launchpad.Edit permission."""
+
+    def destroySelf():
+        """Delete this craft recipe, provided that it has no builds."""
+
+
+class ICraftRecipeEditableAttributes(Interface):
+    """`ICraftRecipe` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+
+    owner = exported(
+        PersonChoice(
+            title=_("Owner"),
+            required=True,
+            readonly=False,
+            vocabulary="AllUserTeamsParticipationPlusSelf",
+            description=_("The owner of this craft recipe."),
+        )
+    )
+
+    project = ReferenceChoice(
+        title=_("The project that this craft recipe is associated with"),
+        schema=IProduct,
+        vocabulary="Product",
+        required=True,
+        readonly=False,
+    )
+
+    name = TextLine(
+        title=_("Craft recipe name"),
+        required=True,
+        readonly=False,
+        constraint=name_validator,
+        description=_("The name of the craft recipe."),
+    )
+
+    description = Text(
+        title=_("Description"),
+        required=False,
+        readonly=False,
+        description=_("A description of the craft recipe."),
+    )
+
+    git_repository = ReferenceChoice(
+        title=_("Git repository"),
+        schema=IGitRepository,
+        vocabulary="GitRepository",
+        required=False,
+        readonly=True,
+        description=_(
+            "A Git repository with a branch containing a craft.yaml " "recipe."
+        ),
+    )
+
+    git_path = TextLine(
+        title=_("Git branch path"),
+        required=False,
+        readonly=False,
+        description=_(
+            "The path of the Git branch containing a craft.yaml recipe."
+        ),
+    )
+
+    git_ref = Reference(
+        IGitRef,
+        title=_("Git branch"),
+        required=False,
+        readonly=False,
+        description=_("The Git branch containing a craft.yaml recipe."),
+    )
+
+    build_path = TextLine(
+        title=_("Build path"),
+        description=_("Subdirectory within the branch containing craft.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 craft recipe."
+        ),
+    )
+
+    auto_build = Bool(
+        title=_("Automatically build when branch changes"),
+        required=True,
+        readonly=False,
+        description=_(
+            "Whether this craft recipe is built automatically when the branch "
+            "containing its craft.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 craft recipe.  Currently only 'core', 'core18', 'core20', "
+            "and 'craft' keys are supported."
+        ),
+    )
+
+    is_stale = Bool(
+        title=_("Craft 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 craft recipe are automatically uploaded "
+            "to the store."
+        ),
+    )
+
+    store_name = TextLine(
+        title=_("Registered store name"),
+        required=False,
+        readonly=False,
+        description=_("The registered name of this craft 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 craft recipe."
+        ),
+    )
+
+    store_channels = List(
+        title=_("Store channels"),
+        required=False,
+        readonly=False,
+        constraint=channels_validator,
+        description=_(
+            "Channels to release this craft 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 ICraftRecipeAdminAttributes(Interface):
+    """`ICraftRecipe` 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 craft recipe on virtual builders."),
+    )
+
+
+class ICraftRecipe(
+    ICraftRecipeView,
+    ICraftRecipeEdit,
+    ICraftRecipeEditableAttributes,
+    ICraftRecipeAdminAttributes,
+    IInformationType,
+):
+    """A buildable craft recipe."""
+
+
+class ICraftRecipeSet(Interface):
+    """A utility to create and access craft 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 `ICraftRecipe`."""
+
+    def getByName(owner, project, name):
+        """Returns the appropriate `ICraftRecipe` 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 craft recipes for the given Git repository.
+
+        :param repository: An `IGitRepository`.
+        :param paths: If not None, only return craft recipes for one of
+            these Git reference paths.
+        """
+
+    def detachFromGitRepository(repository):
+        """Detach all craft recipes from the given Git repository.
+
+        After this, any craft recipes that previously used this repository
+        will have no source and so cannot dispatch new builds.
+        """
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
new file mode 100644
index 0000000..218550a
--- /dev/null
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -0,0 +1,358 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipes."""
+
+__all__ = [
+    "CraftRecipe",
+]
+
+from datetime import timezone
+
+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,
+    PUBLIC_INFORMATION_TYPES,
+    InformationType,
+)
+from lp.code.model.gitrepository import GitRepository
+from lp.code.model.reciperegistry import recipe_registry
+from lp.crafts.interfaces.craftrecipe import (
+    CRAFT_RECIPE_ALLOW_CREATE,
+    CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+    CraftRecipeFeatureDisabled,
+    CraftRecipeNotOwner,
+    CraftRecipePrivacyMismatch,
+    CraftRecipePrivateFeatureDisabled,
+    DuplicateCraftRecipeName,
+    ICraftRecipe,
+    ICraftRecipeSet,
+    NoSourceForCraftRecipe,
+)
+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 IPrimaryStore, IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.features import getFeatureFlag
+
+
+def craft_recipe_modified(recipe, event):
+    """Update the date_last_modified property when a craft recipe is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on craft recipes.
+    """
+    removeSecurityProxy(recipe).date_last_modified = UTC_NOW
+
+
+@implementer(ICraftRecipe)
+class CraftRecipe(StormBase):
+    """See `ICraftRecipe`."""
+
+    __storm_table__ = "CraftRecipe"
+
+    id = Int(primary=True)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=timezone.utc, allow_none=False
+    )
+    date_last_modified = DateTime(
+        name="date_last_modified", tzinfo=timezone.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 CraftRecipePrivacyMismatch(
+                    "A public craft 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 CraftRecipePrivacyMismatch(
+                    "A public craft 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(ICraftRecipeSet).isValidInformationType(
+            value, self.owner, self.git_ref
+        ):
+            raise CraftRecipePrivacyMismatch
+        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)
+
+    store_upload = Bool(name="store_upload", allow_none=False)
+
+    store_name = Unicode(name="store_name", allow_none=True)
+
+    store_secrets = JSON("store_secrets", allow_none=True)
+
+    _store_channels = JSON("store_channels", allow_none=True)
+
+    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 `CraftRecipe`."""
+        if not getFeatureFlag(CRAFT_RECIPE_ALLOW_CREATE):
+            raise CraftRecipeFeatureDisabled()
+        super().__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 "<CraftRecipe ~%s/%s/+craft/%s>" % (
+            self.owner.name,
+            self.project.name,
+            self.name,
+        )
+
+    @property
+    def private(self):
+        """See `ICraftRecipe`."""
+        return self.information_type not in PUBLIC_INFORMATION_TYPES
+
+    @property
+    def git_ref(self):
+        """See `ICraftRecipe`."""
+        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 `ICraftRecipe`."""
+        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 `ICraftRecipe`."""
+        return self._store_channels or []
+
+    @store_channels.setter
+    def store_channels(self, value):
+        """See `ICraftRecipe`."""
+        self._store_channels = value or None
+
+    def getAllowedInformationTypes(self, user):
+        """See `ICraftRecipe`."""
+        # XXX ruinedyourlife 2024-09-24: Only allow free information types
+        # until we have more privacy infrastructure in place.
+        return FREE_INFORMATION_TYPES
+
+    def visibleByUser(self, user):
+        """See `ICraftRecipe`."""
+        if self.information_type in PUBLIC_INFORMATION_TYPES:
+            return True
+        # XXX ruinedyourlife 2024-09-24: Finish implementing this once we have
+        # more privacy infrastructure.
+        return False
+
+    def destroySelf(self):
+        """See `ICraftRecipe`."""
+        IStore(CraftRecipe).remove(self)
+
+
+@recipe_registry.register_recipe_type("ICraftRecipeSet", "craft")
+@implementer(ICraftRecipeSet)
+class CraftRecipeSet:
+    """See `ICraftRecipeSet`."""
+
+    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 `ICraftRecipeSet`."""
+        if not registrant.inTeam(owner):
+            if owner.is_team:
+                raise CraftRecipeNotOwner(
+                    "%s is not a member of %s."
+                    % (registrant.displayname, owner.displayname)
+                )
+            else:
+                raise CraftRecipeNotOwner(
+                    "%s cannot create craft recipes owned by %s."
+                    % (registrant.displayname, owner.displayname)
+                )
+
+        if git_ref is None:
+            raise NoSourceForCraftRecipe
+        if self.getByName(owner, project, name) is not None:
+            raise DuplicateCraftRecipeName
+
+        # 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 CraftRecipePrivacyMismatch
+        store = IPrimaryStore(CraftRecipe)
+        recipe = CraftRecipe(
+            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 `ICraftRecipeSet`."""
+        return (
+            IStore(CraftRecipe)
+            .find(CraftRecipe, owner=owner, project=project, name=name)
+            .one()
+        )
+
+    def isValidInformationType(self, information_type, owner, git_ref=None):
+        """See `ICraftRecipeSet`."""
+        private = information_type not in PUBLIC_INFORMATION_TYPES
+        if private:
+            # If appropriately enabled via feature flag.
+            if not getFeatureFlag(CRAFT_RECIPE_PRIVATE_FEATURE_FLAG):
+                raise CraftRecipePrivateFeatureDisabled
+            return True
+
+        # Public craft recipes with private sources are not allowed.
+        if git_ref is not None and git_ref.private:
+            return False
+
+        # Public craft 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 `ICraftRecipeSet`."""
+        clauses = [CraftRecipe.git_repository == repository]
+        if paths is not None:
+            clauses.append(CraftRecipe.git_path.is_in(paths))
+        # XXX ruinedyourlife 2024-09-24: Check permissions once we have some
+        # privacy infrastructure.
+        return IStore(CraftRecipe).find(CraftRecipe, *clauses)
+
+    def detachFromGitRepository(self, repository):
+        """See `ICraftRecipeSet`."""
+        self.findByGitRepository(repository).set(
+            git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
+        )
diff --git a/lib/lp/crafts/security.py b/lib/lp/crafts/security.py
new file mode 100644
index 0000000..011339a
--- /dev/null
+++ b/lib/lp/crafts/security.py
@@ -0,0 +1,51 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Security adapters for the craft package."""
+
+__all__ = []
+
+from lp.app.security import AuthorizationBase
+from lp.crafts.interfaces.craftrecipe import ICraftRecipe
+
+
+class ViewCraftRecipe(AuthorizationBase):
+    """Private craft recipes are only visible to their owners and admins."""
+
+    permission = "launchpad.View"
+    usedfor = ICraftRecipe
+
+    def checkAuthenticated(self, user):
+        return self.obj.visibleByUser(user.person)
+
+    def checkUnauthenticated(self):
+        return self.obj.visibleByUser(None)
+
+
+class EditCraftRecipe(AuthorizationBase):
+    permission = "launchpad.Edit"
+    usedfor = ICraftRecipe
+
+    def checkAuthenticated(self, user):
+        return (
+            user.isOwner(self.obj) or user.in_commercial_admin or user.in_admin
+        )
+
+
+class AdminCraftRecipe(AuthorizationBase):
+    """Restrict changing build settings on craft 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 craft recipes that they can already edit.
+    """
+
+    permission = "launchpad.Admin"
+    usedfor = ICraftRecipe
+
+    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 EditCraftRecipe(
+            self.obj
+        ).checkAuthenticated(user)
diff --git a/lib/lp/crafts/tests/__init__.py b/lib/lp/crafts/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/crafts/tests/__init__.py
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
new file mode 100644
index 0000000..c92f9a7
--- /dev/null
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -0,0 +1,262 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test craft recipes."""
+
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.crafts.interfaces.craftrecipe import (
+    CRAFT_RECIPE_ALLOW_CREATE,
+    CraftRecipeFeatureDisabled,
+    CraftRecipePrivateFeatureDisabled,
+    ICraftRecipe,
+    ICraftRecipeSet,
+    NoSourceForCraftRecipe,
+)
+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 TestCaseWithFactory, admin_logged_in, person_logged_in
+from lp.testing.layers import DatabaseFunctionalLayer, LaunchpadZopelessLayer
+
+
+class TestCraftRecipeFeatureFlags(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create any craft recipes.
+        self.assertRaises(
+            CraftRecipeFeatureDisabled, self.factory.makeCraftRecipe
+        )
+
+    def test_private_feature_flag_disabled(self):
+        # Without a private feature flag, we will not create new private
+        # craft recipes.
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+        self.assertRaises(
+            CraftRecipePrivateFeatureDisabled,
+            self.factory.makeCraftRecipe,
+            information_type=InformationType.PROPRIETARY,
+        )
+
+
+class TestCraftRecipe(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_implements_interfaces(self):
+        # CraftRecipe implements ICraftRecipe.
+        recipe = self.factory.makeCraftRecipe()
+        with admin_logged_in():
+            self.assertProvides(recipe, ICraftRecipe)
+
+    def test___repr__(self):
+        # CraftRecipe objects have an informative __repr__.
+        recipe = self.factory.makeCraftRecipe()
+        self.assertEqual(
+            "<CraftRecipe ~%s/%s/+craft/%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.makeCraftRecipe(date_created=ONE_DAY_AGO)
+        self.assertEqual(recipe.date_created, recipe.date_last_modified)
+
+    def test_modifiedevent_sets_date_last_modified(self):
+        # When a CraftRecipe receives an object modified event, the last
+        # modified date is set to UTC_NOW.
+        recipe = self.factory.makeCraftRecipe(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 craft recipe with no builds can be deleted.
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        recipe = self.factory.makeCraftRecipe(
+            registrant=owner, owner=owner, project=project, name="condemned"
+        )
+        self.assertIsNotNone(
+            getUtility(ICraftRecipeSet).getByName(owner, project, "condemned")
+        )
+        with person_logged_in(recipe.owner):
+            recipe.destroySelf()
+        self.assertIsNone(
+            getUtility(ICraftRecipeSet).getByName(owner, project, "condemned")
+        )
+
+
+class TestCraftRecipeSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_class_implements_interfaces(self):
+        # The CraftRecipeSet class implements ICraftRecipeSet.
+        self.assertProvides(getUtility(ICraftRecipeSet), ICraftRecipeSet)
+
+    def makeCraftRecipeComponents(self, git_ref=None):
+        """Return a dict of values that can be used to make a craft recipe.
+
+        Suggested use: provide as kwargs to ICraftRecipeSet.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("craft-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 craft recipe is created for a
+        # Git branch are present on the new object.
+        [ref] = self.factory.makeGitRefs()
+        components = self.makeCraftRecipeComponents(git_ref=ref)
+        recipe = getUtility(ICraftRecipeSet).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 craft recipe without a Git repository
+        # fails.
+        registrant = self.factory.makePerson()
+        self.assertRaises(
+            NoSourceForCraftRecipe,
+            getUtility(ICraftRecipeSet).new,
+            registrant,
+            registrant,
+            self.factory.makeProduct(),
+            self.factory.getUniqueUnicode("craft-name"),
+        )
+
+    def test_getByName(self):
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        project_recipe = self.factory.makeCraftRecipe(
+            registrant=owner, owner=owner, project=project, name="proj-craft"
+        )
+        self.factory.makeCraftRecipe(
+            registrant=owner, owner=owner, name="proj-craft"
+        )
+
+        self.assertEqual(
+            project_recipe,
+            getUtility(ICraftRecipeSet).getByName(
+                owner, project, "proj-craft"
+            ),
+        )
+
+    def test_findByGitRepository(self):
+        # ICraftRecipeSet.findByGitRepository returns all craft recipes with
+        # the given Git repository.
+        repositories = [self.factory.makeGitRepository() for i in range(2)]
+        recipes = []
+        for repository in repositories:
+            for _ in range(2):
+                [ref] = self.factory.makeGitRefs(repository=repository)
+                recipes.append(self.factory.makeCraftRecipe(git_ref=ref))
+        recipe_set = getUtility(ICraftRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByGitRepository(repositories[0])
+        )
+        self.assertContentEqual(
+            recipes[2:], recipe_set.findByGitRepository(repositories[1])
+        )
+
+    def test_findByGitRepository_paths(self):
+        # ICraftRecipeSet.findByGitRepository can restrict by reference
+        # paths.
+        repositories = [self.factory.makeGitRepository() for i in range(2)]
+        recipes = []
+        for repository in repositories:
+            for _ in range(3):
+                [ref] = self.factory.makeGitRefs(repository=repository)
+                recipes.append(self.factory.makeCraftRecipe(git_ref=ref))
+        recipe_set = getUtility(ICraftRecipeSet)
+        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):
+        # ICraftRecipeSet.detachFromGitRepository clears the given Git
+        # repository from all craft recipes.
+        repositories = [self.factory.makeGitRepository() for i in range(2)]
+        recipes = []
+        paths = []
+        refs = []
+        for repository in repositories:
+            for _ in range(2):
+                [ref] = self.factory.makeGitRefs(repository=repository)
+                paths.append(ref.path)
+                refs.append(ref)
+                recipes.append(
+                    self.factory.makeCraftRecipe(
+                        git_ref=ref, date_created=ONE_DAY_AGO
+                    )
+                )
+        getUtility(ICraftRecipeSet).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/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 92d55f0..e612d5c 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -40,6 +40,7 @@ from lp.buildmaster.model.processor import Processor
 from lp.code.model.gitcollection import GenericGitCollection
 from lp.code.model.gitref import GitRef
 from lp.code.model.gitrepository import GitRepository
+from lp.code.model.reciperegistry import recipe_registry
 from lp.oci.enums import OCIRecipeBuildRequestStatus
 from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
 from lp.oci.interfaces.ocirecipe import (
@@ -883,6 +884,7 @@ class OCIRecipeArch(StormBase):
         self.processor = processor
 
 
+@recipe_registry.register_recipe_type("IOCIRecipeSet", "oci")
 @implementer(IOCIRecipeSet)
 class OCIRecipeSet:
     def new(
diff --git a/lib/lp/registry/browser/personproduct.py b/lib/lp/registry/browser/personproduct.py
index b778a73..2ef8ef2 100644
--- a/lib/lp/registry/browser/personproduct.py
+++ b/lib/lp/registry/browser/personproduct.py
@@ -17,6 +17,7 @@ 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.crafts.interfaces.craftrecipe import ICraftRecipeSet
 from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
 from lp.registry.interfaces.personproduct import IPersonProduct
 from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
@@ -74,6 +75,12 @@ class PersonProductNavigation(
             owner=self.context.person, project=self.context.product, name=name
         )
 
+    @stepthrough("+craft")
+    def traverse_craft(self, name):
+        return getUtility(ICraftRecipeSet).getByName(
+            owner=self.context.person, project=self.context.product, name=name
+        )
+
 
 @implementer(IMultiFacetedBreadcrumb)
 class PersonProductBreadcrumb(Breadcrumb):
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index b4057e7..90b9778 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -53,6 +53,7 @@ from lp.code.interfaces.gitrepository import IGitRepository
 from lp.code.model.gitcollection import GenericGitCollection
 from lp.code.model.gitref import GitRef
 from lp.code.model.gitrepository import GitRepository
+from lp.code.model.reciperegistry import recipe_registry
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.person import (
     IPerson,
@@ -761,6 +762,7 @@ class RockRecipe(StormBase):
         ).remove()
 
 
+@recipe_registry.register_recipe_type("IRockRecipeSet", "rock")
 @implementer(IRockRecipeSet)
 class RockRecipeSet:
     """See `IRockRecipeSet`."""
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index 8553b3e..91a8b11 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -84,6 +84,7 @@ from lp.code.model.branchnamespace import (
 )
 from lp.code.model.gitcollection import GenericGitCollection
 from lp.code.model.gitrepository import GitRepository
+from lp.code.model.reciperegistry import recipe_registry
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.accesspolicy import (
     IAccessArtifactGrantSource,
@@ -1504,6 +1505,7 @@ class SnapArch(StormBase):
     processor = Reference(processor_id, "Processor.id")
 
 
+@recipe_registry.register_recipe_type("ISnapSet", "snap")
 @implementer(ISnapSet)
 class SnapSet:
     """See `ISnapSet`."""
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 2bb6367..8d1c299 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -134,6 +134,7 @@ from lp.code.interfaces.sourcepackagerecipebuild import (
 )
 from lp.code.model.diff import Diff, PreviewDiff
 from lp.code.tests.helpers import GitHostingFixture
+from lp.crafts.interfaces.craftrecipe import ICraftRecipeSet
 from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
@@ -6892,6 +6893,79 @@ class LaunchpadObjectFactory(ObjectFactory):
             date_created=date_created,
         )
 
+    def makeCraftRecipe(
+        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 craft recipe."""
+        if registrant is None:
+            registrant = self.makePerson()
+        private = information_type not in PUBLIC_INFORMATION_TYPES
+        if owner is None:
+            # Private craft 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
+                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("craft-name")
+        if git_ref is None:
+            git_ref = self.makeGitRefs()[0]
+        recipe = getUtility(ICraftRecipeSet).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
+
     def makeRockRecipe(
         self,
         registrant=None,

Follow ups