← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~jugmac00/launchpad:add-basic-model-for-rock-recipe-builds-rework into launchpad:master

 

Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-basic-model-for-rock-recipe-builds-rework into launchpad:master.

Commit message:
Add basic model for rock recipe builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This was done similar to charm recipe builds, but including updates which happened meanwhile.
cf https://git.launchpad.net/launchpad/commit/?h=0a093a3776cb31bea61b65e89cf3b577aeb34015
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-basic-model-for-rock-recipe-builds-rework into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 5407957..8358e68 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2895,3 +2895,34 @@ public.teammembership                   = SELECT
 public.teamparticipation                = SELECT
 public.webhook                          = SELECT
 public.webhookjob                       = SELECT, INSERT
+
+[rock-build-job]
+type=user
+groups=script
+public.account                          = SELECT
+public.builder                          = SELECT
+public.buildfarmjob                     = SELECT, INSERT
+public.buildqueue                       = SELECT, INSERT, UPDATE
+public.rockfile                         = SELECT
+public.rockrecipe                       = SELECT, UPDATE
+public.rockrecipebuild                  = SELECT, INSERT, UPDATE
+public.rockrecipebuildjob               = SELECT, UPDATE
+public.rockrecipejob                    = SELECT, UPDATE
+public.distribution                     = SELECT
+public.distroarchseries                 = SELECT
+public.distroseries                     = SELECT
+public.emailaddress                     = SELECT
+public.gitref                           = SELECT
+public.gitrepository                    = SELECT
+public.job                              = SELECT, INSERT, UPDATE
+public.libraryfilealias                 = SELECT
+public.libraryfilecontent               = SELECT
+public.person                           = SELECT
+public.personsettings                   = SELECT
+public.pocketchroot                     = SELECT
+public.processor                        = SELECT
+public.product                          = SELECT
+public.teammembership                   = SELECT
+public.teamparticipation                = SELECT
+public.webhook                          = SELECT
+public.webhookjob                       = SELECT, INSERT
diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py
index 672bc80..be0a30b 100644
--- a/lib/lp/buildmaster/enums.py
+++ b/lib/lp/buildmaster/enums.py
@@ -249,6 +249,15 @@ class BuildFarmJobType(DBEnumeratedType):
         """,
     )
 
+    ROCKRECIPEBUILD = DBItem(
+        10,
+        """
+        Rock recipe build
+
+        Build a rock from a recipe.
+        """,
+    )
+
 
 class BuildQueueStatus(DBEnumeratedType):
     """Build queue status.
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index a1669be..28afbf3 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -126,6 +126,7 @@ from lp.registry.model.accesspolicy import (
 )
 from lp.registry.model.person import Person
 from lp.registry.model.teammembership import TeamParticipation
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
 from lp.services.auth.model import AccessTokenTargetMixin
 from lp.services.config import config
 from lp.services.database import bulk
@@ -2004,6 +2005,15 @@ class GitRepository(
                     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,
+                )
+            )
 
         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 7b70558..5f8f255 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -148,6 +148,7 @@ from lp.registry.interfaces.persondistributionsourcepackage import (
 from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
 from lp.registry.interfaces.personproduct import IPersonProductFactory
 from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
+from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
 from lp.services.auth.enums import AccessTokenScope
 from lp.services.authserver.xmlrpc import AuthServerAPIView
 from lp.services.config import config
@@ -1706,6 +1707,39 @@ class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
         self.assertIsNone(recipe2.git_repository)
         self.assertIsNone(recipe2.git_path)
 
+    def test_rock_recipe_requirements(self):
+        # If a repository is used by a rock recipe, the deletion
+        # requirements indicate this.
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+        [ref] = self.factory.makeGitRefs()
+        self.factory.makeRockRecipe(git_ref=ref)
+        self.assertEqual(
+            {
+                None: (
+                    "alter",
+                    _("Some rock recipes build from this repository."),
+                )
+            },
+            ref.repository.getDeletionRequirements(),
+        )
+
+    def test_rock_recipe_deletion(self):
+        # break_references allows deleting a repository used by a rock
+        # recipe.
+        self.useFixture(FeatureFixture({ROCK_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.makeRockRecipe(git_ref=ref1)
+        recipe2 = self.factory.makeRockRecipe(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 ab2b4b0..8592b8f 100644
--- a/lib/lp/configure.zcml
+++ b/lib/lp/configure.zcml
@@ -35,6 +35,7 @@
     <include package="lp.testing" />
     <include package="lp.testopenid" />
     <include package="lp.registry" />
+    <include package="lp.rocks" />
     <include package="lp.xmlrpc" />
 
     <include file="permissions.zcml" />
diff --git a/lib/lp/registry/browser/personproduct.py b/lib/lp/registry/browser/personproduct.py
index 86c8f7d..b778a73 100644
--- a/lib/lp/registry/browser/personproduct.py
+++ b/lib/lp/registry/browser/personproduct.py
@@ -19,6 +19,7 @@ from lp.code.browser.vcslisting import PersonTargetDefaultVCSNavigationMixin
 from lp.code.interfaces.branchnamespace import get_branch_namespace
 from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory
 from lp.registry.interfaces.personproduct import IPersonProduct
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
 from lp.services.webapp import (
     Navigation,
     StandardLaunchpadFacets,
@@ -67,6 +68,12 @@ class PersonProductNavigation(
             owner=self.context.person, project=self.context.product, name=name
         )
 
+    @stepthrough("+rock")
+    def traverse_rock(self, name):
+        return getUtility(IRockRecipeSet).getByName(
+            owner=self.context.person, project=self.context.product, name=name
+        )
+
 
 @implementer(IMultiFacetedBreadcrumb)
 class PersonProductBreadcrumb(Breadcrumb):
diff --git a/lib/lp/rocks/__init__.py b/lib/lp/rocks/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/rocks/__init__.py
diff --git a/lib/lp/rocks/browser/configure.zcml b/lib/lp/rocks/browser/configure.zcml
new file mode 100644
index 0000000..b70835f
--- /dev/null
+++ b/lib/lp/rocks/browser/configure.zcml
@@ -0,0 +1,27 @@
+<!-- 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.rocks.interfaces.rockrecipe.IRockRecipe"
+            urldata="lp.rocks.browser.rockrecipe.RockRecipeURL" />
+        <lp:navigation
+            module="lp.rocks.browser.rockrecipe"
+            classes="RockRecipeNavigation" />
+        <lp:url
+            for="lp.rocks.interfaces.rockrecipe.IRockRecipeBuildRequest"
+            path_expression="string:+build-request/${id}"
+            attribute_to_parent="recipe" />
+        <lp:url
+            for="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuild"
+            path_expression="string:+build/${id}"
+            attribute_to_parent="recipe" />
+    </lp:facet>
+</configure>
diff --git a/lib/lp/rocks/browser/rockrecipe.py b/lib/lp/rocks/browser/rockrecipe.py
new file mode 100644
index 0000000..0e13dbe
--- /dev/null
+++ b/lib/lp/rocks/browser/rockrecipe.py
@@ -0,0 +1,58 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipe views."""
+
+__all__ = [
+    "RockRecipeNavigation",
+    "RockRecipeURL",
+]
+
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.registry.interfaces.personproduct import IPersonProductFactory
+from lp.rocks.interfaces.rockrecipe import IRockRecipe
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet
+from lp.services.webapp import Navigation, stepthrough
+from lp.services.webapp.interfaces import ICanonicalUrlData
+from lp.soyuz.browser.build import get_build_by_id_str
+
+
+@implementer(ICanonicalUrlData)
+class RockRecipeURL:
+    """Rock 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 "+rock/%s" % self.recipe.name
+
+
+class RockRecipeNavigation(Navigation):
+    usedfor = IRockRecipe
+
+    @stepthrough("+build-request")
+    def traverse_build_request(self, name):
+        try:
+            job_id = int(name)
+        except ValueError:
+            return None
+        return self.context.getBuildRequest(job_id)
+
+    @stepthrough("+build")
+    def traverse_build(self, name):
+        build = get_build_by_id_str(IRockRecipeBuildSet, name)
+        if build is None or build.recipe != self.context:
+            return None
+        return build
diff --git a/lib/lp/rocks/browser/tests/test_rockrecipe.py b/lib/lp/rocks/browser/tests/test_rockrecipe.py
new file mode 100644
index 0000000..229824d
--- /dev/null
+++ b/lib/lp/rocks/browser/tests/test_rockrecipe.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 rock recipe views."""
+
+from lp.rocks.interfaces.rockrecipe import ROCK_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 TestRockRecipeNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        project = self.factory.makeProduct(name="project")
+        recipe = self.factory.makeRockRecipe(
+            registrant=owner, owner=owner, project=project, name="rock"
+        )
+        self.assertEqual(
+            "http://launchpad.test/~person/project/+rock/rock";,
+            canonical_url(recipe),
+        )
diff --git a/lib/lp/rocks/configure.zcml b/lib/lp/rocks/configure.zcml
new file mode 100644
index 0000000..408a760
--- /dev/null
+++ b/lib/lp/rocks/configure.zcml
@@ -0,0 +1,95 @@
+<!-- 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" />
+
+    <!-- RockRecipe -->
+    <class class="lp.rocks.model.rockrecipe.RockRecipe">
+        <require
+            permission="launchpad.View"
+            interface="lp.rocks.interfaces.rockrecipe.IRockRecipeView
+                       lp.rocks.interfaces.rockrecipe.IRockRecipeEditableAttributes
+                       lp.rocks.interfaces.rockrecipe.IRockRecipeAdminAttributes" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.rocks.interfaces.rockrecipe.IRockRecipeEdit"
+            set_schema="lp.rocks.interfaces.rockrecipe.IRockRecipeEditableAttributes" />
+        <require
+            permission="launchpad.Admin"
+            set_schema="lp.rocks.interfaces.rockrecipe.IRockRecipeAdminAttributes" />
+    </class>
+    <subscriber
+        for="lp.rocks.interfaces.rockrecipe.IRockRecipe
+             zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+        handler="lp.rocks.model.rockrecipe.rock_recipe_modified" />
+
+    <!-- RockRecipeSet -->
+    <lp:securedutility
+        class="lp.rocks.model.rockrecipe.RockRecipeSet"
+        provides="lp.rocks.interfaces.rockrecipe.IRockRecipeSet">
+        <allow interface="lp.rocks.interfaces.rockrecipe.IRockRecipeSet" />
+    </lp:securedutility>
+
+    <!-- RockRecipeBuildRequest -->
+    <class class="lp.rocks.model.rockrecipe.RockRecipeBuildRequest">
+        <require
+            permission="launchpad.View"
+            interface="lp.rocks.interfaces.rockrecipe.IRockRecipeBuildRequest" />
+    </class>
+
+    <!-- RockRecipeBuild -->
+    <class class="lp.rocks.model.rockrecipebuild.RockRecipeBuild">
+        <require
+            permission="launchpad.View"
+            interface="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuildView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuildEdit" />
+        <require
+            permission="launchpad.Admin"
+            interface="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuildAdmin" />
+    </class>
+
+    <!-- RockRecipeBuildSet -->
+    <lp:securedutility
+        class="lp.rocks.model.rockrecipebuild.RockRecipeBuildSet"
+        provides="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuildSet">
+        <allow interface="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuildSet" />
+    </lp:securedutility>
+    <lp:securedutility
+        class="lp.rocks.model.rockrecipebuild.RockRecipeBuildSet"
+        provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+        name="ROCKRECIPEBUILD">
+        <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
+    </lp:securedutility>
+
+    <!-- RockFile -->
+    <class class="lp.rocks.model.rockrecipebuild.RockFile">
+        <allow interface="lp.rocks.interfaces.rockrecipebuild.IRockFile" />
+    </class>
+
+    <!-- rock-related jobs -->
+    <class class="lp.rocks.model.rockrecipejob.RockRecipeJob">
+        <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeJob" />
+    </class>
+    <lp:securedutility
+        component="lp.rocks.model.rockrecipejob.RockRecipeRequestBuildsJob"
+        provides="lp.rocks.interfaces.rockrecipejob.IRockRecipeRequestBuildsJobSource">
+        <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeRequestBuildsJobSource" />
+    </lp:securedutility>
+    <class class="lp.rocks.model.rockrecipejob.RockRecipeRequestBuildsJob">
+        <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeJob" />
+        <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeRequestBuildsJob" />
+    </class>
+
+</configure>
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
new file mode 100644
index 0000000..4669930
--- /dev/null
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -0,0 +1,519 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipe interfaces."""
+
+__all__ = [
+    "BadRockRecipeSource",
+    "BadRockRecipeSearchContext",
+    "ROCK_RECIPE_ALLOW_CREATE",
+    "ROCK_RECIPE_PRIVATE_FEATURE_FLAG",
+    "RockRecipeBuildRequestStatus",
+    "RockRecipeFeatureDisabled",
+    "RockRecipeNotOwner",
+    "RockRecipePrivacyMismatch",
+    "RockRecipePrivateFeatureDisabled",
+    "DuplicateRockRecipeName",
+    "IRockRecipe",
+    "IRockRecipeBuildRequest",
+    "IRockRecipeSet",
+    "NoSourceForRockRecipe",
+    "NoSuchRockRecipe",
+]
+
+import http.client
+
+from lazr.enum import EnumeratedType, Item
+from lazr.restful.declarations import error_status, exported
+from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
+from zope.interface import Interface
+from zope.schema import (
+    Bool,
+    Choice,
+    Datetime,
+    Dict,
+    Int,
+    List,
+    Set,
+    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.person import IPerson
+from lp.registry.interfaces.product import IProduct
+from lp.services.fields import PersonChoice, PublicPersonChoice
+from lp.snappy.validators.channels import channels_validator
+
+ROCK_RECIPE_ALLOW_CREATE = "rock.recipe.create.enabled"
+ROCK_RECIPE_PRIVATE_FEATURE_FLAG = "rock.recipe.allow_private"
+
+
+@error_status(http.client.UNAUTHORIZED)
+class RockRecipeFeatureDisabled(Unauthorized):
+    """Only certain users can create new rock recipes."""
+
+    def __init__(self):
+        super().__init__(
+            "You do not have permission to create new rock recipes."
+        )
+
+
+@error_status(http.client.UNAUTHORIZED)
+class RockRecipePrivateFeatureDisabled(Unauthorized):
+    """Only certain users can create private rock recipes."""
+
+    def __init__(self):
+        super().__init__(
+            "You do not have permission to create private rock recipes."
+        )
+
+
+@error_status(http.client.BAD_REQUEST)
+class DuplicateRockRecipeName(Exception):
+    """Raised for rock recipes with duplicate project/owner/name."""
+
+    def __init__(self):
+        super().__init__(
+            "There is already a rock recipe with the same project, owner, "
+            "and name."
+        )
+
+
+@error_status(http.client.UNAUTHORIZED)
+class RockRecipeNotOwner(Unauthorized):
+    """The registrant/requester is not the owner or a member of its team."""
+
+
+class NoSuchRockRecipe(NameLookupFailed):
+    """The requested rock recipe does not exist."""
+
+    _message_prefix = "No such rock recipe with this owner and project"
+
+
+@error_status(http.client.BAD_REQUEST)
+class NoSourceForRockRecipe(Exception):
+    """Rock recipes must have a source (Git branch)."""
+
+    def __init__(self):
+        super().__init__("New rock recipes must have a Git branch.")
+
+
+@error_status(http.client.BAD_REQUEST)
+class BadRockRecipeSource(Exception):
+    """The elements of the source for a rock recipe are inconsistent."""
+
+
+@error_status(http.client.BAD_REQUEST)
+class RockRecipePrivacyMismatch(Exception):
+    """Rock recipe privacy does not match its content."""
+
+    def __init__(self, message=None):
+        super().__init__(
+            message
+            or "Rock recipe contains private information and cannot be public."
+        )
+
+
+class BadRockRecipeSearchContext(Exception):
+    """The context is not valid for a rock recipe search."""
+
+
+class RockRecipeBuildRequestStatus(EnumeratedType):
+    """The status of a request to build a rock recipe."""
+
+    PENDING = Item(
+        """
+        Pending
+
+        This rock recipe build request is pending.
+        """
+    )
+
+    FAILED = Item(
+        """
+        Failed
+
+        This rock recipe build request failed.
+        """
+    )
+
+    COMPLETED = Item(
+        """
+        Completed
+
+        This rock recipe build request completed successfully.
+        """
+    )
+
+
+class IRockRecipeBuildRequest(Interface):
+    """A request to build a rock recipe."""
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+
+    date_requested = Datetime(
+        title=_("The time when this request was made"),
+        required=True,
+        readonly=True,
+    )
+
+    date_finished = Datetime(
+        title=_("The time when this request finished"),
+        required=False,
+        readonly=True,
+    )
+
+    recipe = Reference(
+        # Really IRockRecipe.
+        Interface,
+        title=_("Rock recipe"),
+        required=True,
+        readonly=True,
+    )
+
+    status = Choice(
+        title=_("Status"),
+        vocabulary=RockRecipeBuildRequestStatus,
+        required=True,
+        readonly=True,
+    )
+
+    error_message = TextLine(
+        title=_("Error message"), required=True, readonly=True
+    )
+
+    builds = CollectionField(
+        title=_("Builds produced by this request"),
+        # Really IRockRecipeBuild.
+        value_type=Reference(schema=Interface),
+        required=True,
+        readonly=True,
+    )
+
+    requester = Reference(
+        title=_("The person requesting the builds."),
+        schema=IPerson,
+        required=True,
+        readonly=True,
+    )
+
+    channels = Dict(
+        title=_("Source snap channels for builds produced by this request"),
+        key_type=TextLine(),
+        required=False,
+        readonly=True,
+    )
+
+    architectures = Set(
+        title=_("If set, this request is limited to these architecture tags"),
+        value_type=TextLine(),
+        required=False,
+        readonly=True,
+    )
+
+
+class IRockRecipeView(Interface):
+    """`IRockRecipe` 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 rock recipe."),
+    )
+
+    private = Bool(
+        title=_("Private"),
+        required=False,
+        readonly=False,
+        description=_("Whether this rock recipe is private."),
+    )
+
+    def getAllowedInformationTypes(user):
+        """Get a list of acceptable `InformationType`s for this rock recipe.
+
+        If the user is a Launchpad admin, any type is acceptable.
+        """
+
+    def visibleByUser(user):
+        """Can the specified user see this rock recipe?"""
+
+    def requestBuilds(requester, channels=None, architectures=None):
+        """Request that the rock recipe be built.
+
+        This is an asynchronous operation; once the operation has finished,
+        the resulting build request's C{status} will be "Completed" and its
+        C{builds} collection will return the resulting builds.
+
+        :param requester: The person requesting the builds.
+        :param channels: A dictionary mapping snap names to channels to use
+            for these builds.
+        :param architectures: If not None, limit builds to architectures
+            with these architecture tags (in addition to any other
+            applicable constraints).
+        :return: An `IRockRecipeBuildRequest`.
+        """
+
+    def getBuildRequest(job_id):
+        """Get an asynchronous build request by ID.
+
+        :param job_id: The ID of the build request.
+        :return: `IRockRecipeBuildRequest`.
+        """
+
+
+class IRockRecipeEdit(Interface):
+    """`IRockRecipe` methods that require launchpad.Edit permission."""
+
+    def destroySelf():
+        """Delete this rock recipe, provided that it has no builds."""
+
+
+class IRockRecipeEditableAttributes(Interface):
+    """`IRockRecipe` 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 rock recipe."),
+        )
+    )
+
+    project = ReferenceChoice(
+        title=_("The project that this rock recipe is associated with"),
+        schema=IProduct,
+        vocabulary="Product",
+        required=True,
+        readonly=False,
+    )
+
+    name = TextLine(
+        title=_("Rock recipe name"),
+        required=True,
+        readonly=False,
+        constraint=name_validator,
+        description=_("The name of the rock recipe."),
+    )
+
+    description = Text(
+        title=_("Description"),
+        required=False,
+        readonly=False,
+        description=_("A description of the rock recipe."),
+    )
+
+    git_repository = ReferenceChoice(
+        title=_("Git repository"),
+        schema=IGitRepository,
+        vocabulary="GitRepository",
+        required=False,
+        readonly=True,
+        description=_(
+            "A Git repository with a branch containing a rockcraft.yaml "
+            "recipe."
+        ),
+    )
+
+    git_path = TextLine(
+        title=_("Git branch path"),
+        required=False,
+        readonly=False,
+        description=_(
+            "The path of the Git branch containing a rockcraft.yaml recipe."
+        ),
+    )
+
+    git_ref = Reference(
+        IGitRef,
+        title=_("Git branch"),
+        required=False,
+        readonly=False,
+        description=_("The Git branch containing a rockcraft.yaml recipe."),
+    )
+
+    build_path = TextLine(
+        title=_("Build path"),
+        description=_(
+            "Subdirectory within the branch containing rockcraft.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 rock recipe."
+        ),
+    )
+
+    auto_build = Bool(
+        title=_("Automatically build when branch changes"),
+        required=True,
+        readonly=False,
+        description=_(
+            "Whether this rock recipe is built automatically when the branch "
+            "containing its rockcraft.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 rock recipe.  Currently only 'core', 'core18', 'core20', "
+            "and 'rockcraft' keys are supported."
+        ),
+    )
+
+    is_stale = Bool(
+        title=_("Rock 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 rock recipe are automatically uploaded "
+            "to the store."
+        ),
+    )
+
+    store_name = TextLine(
+        title=_("Registered store name"),
+        required=False,
+        readonly=False,
+        description=_("The registered name of this rock 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 rock recipe."
+        ),
+    )
+
+    store_channels = List(
+        title=_("Store channels"),
+        required=False,
+        readonly=False,
+        constraint=channels_validator,
+        description=_(
+            "Channels to release this rock 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 IRockRecipeAdminAttributes(Interface):
+    """`IRockRecipe` 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 rock recipe on virtual builders."),
+    )
+
+
+class IRockRecipe(
+    IRockRecipeView,
+    IRockRecipeEdit,
+    IRockRecipeEditableAttributes,
+    IRockRecipeAdminAttributes,
+    IInformationType,
+):
+    """A buildable rock recipe."""
+
+
+class IRockRecipeSet(Interface):
+    """A utility to create and access rock 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 `IRockRecipe`."""
+
+    def getByName(owner, project, name):
+        """Returns the appropriate `IRockRecipe` for the given objects."""
+
+    def isValidInformationType(information_type, owner, git_ref=None):
+        """Whether the information type context is valid."""
+
+    def preloadDataForRecipes(recipes, user):
+        """Load the data related to a list of rock recipes."""
+
+    def findByGitRepository(repository, paths=None):
+        """Return all rock recipes for the given Git repository.
+
+        :param repository: An `IGitRepository`.
+        :param paths: If not None, only return rock recipes for one of
+            these Git reference paths.
+        """
+
+    def detachFromGitRepository(repository):
+        """Detach all rock recipes from the given Git repository.
+
+        After this, any rock recipes that previously used this repository
+        will have no source and so cannot dispatch new builds.
+        """
diff --git a/lib/lp/rocks/interfaces/rockrecipebuild.py b/lib/lp/rocks/interfaces/rockrecipebuild.py
new file mode 100644
index 0000000..f2f5eb7
--- /dev/null
+++ b/lib/lp/rocks/interfaces/rockrecipebuild.py
@@ -0,0 +1,196 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipe build interfaces."""
+
+__all__ = [
+    "IRockFile",
+    "IRockRecipeBuild",
+    "IRockRecipeBuildSet",
+]
+
+from lazr.restful.fields import Reference
+from zope.interface import Attribute, Interface
+from zope.schema import Bool, Datetime, Dict, Int, TextLine
+
+from lp import _
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+)
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+)
+from lp.registry.interfaces.person import IPerson
+from lp.rocks.interfaces.rockrecipe import IRockRecipe, IRockRecipeBuildRequest
+from lp.services.database.constants import DEFAULT
+from lp.services.librarian.interfaces import ILibraryFileAlias
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+
+
+class IRockRecipeBuildView(IPackageBuildView):
+    """`IRockRecipeBuild` attributes that require launchpad.View."""
+
+    build_request = Reference(
+        IRockRecipeBuildRequest,
+        title=_("The build request that caused this build to be created."),
+        required=True,
+        readonly=True,
+    )
+
+    requester = Reference(
+        IPerson,
+        title=_("The person who requested this build."),
+        required=True,
+        readonly=True,
+    )
+
+    recipe = Reference(
+        IRockRecipe,
+        title=_("The rock recipe to build."),
+        required=True,
+        readonly=True,
+    )
+
+    distro_arch_series = Reference(
+        IDistroArchSeries,
+        title=_("The series and architecture for which to build."),
+        required=True,
+        readonly=True,
+    )
+
+    channels = Dict(
+        title=_("Source snap channels to use for this build."),
+        description=_(
+            "A dictionary mapping snap names to channels to use for this "
+            "build.  Currently only 'core', 'core18', 'core20', "
+            "and 'rockcraft' keys are supported."
+        ),
+        key_type=TextLine(),
+    )
+
+    virtualized = Bool(
+        title=_("If True, this build is virtualized."), readonly=True
+    )
+
+    score = Int(
+        title=_("Score of the related build farm job (if any)."),
+        required=False,
+        readonly=True,
+    )
+
+    eta = Datetime(
+        title=_("The datetime when the build job is estimated to complete."),
+        readonly=True,
+    )
+
+    estimate = Bool(
+        title=_("If true, the date value is an estimate."), readonly=True
+    )
+
+    date = Datetime(
+        title=_(
+            "The date when the build completed or is estimated to complete."
+        ),
+        readonly=True,
+    )
+
+    revision_id = TextLine(
+        title=_("Revision ID"),
+        required=False,
+        readonly=True,
+        description=_(
+            "The revision ID of the branch used for this build, if "
+            "available."
+        ),
+    )
+
+    store_upload_metadata = Attribute(
+        _("A dict of data about store upload progress.")
+    )
+
+    def getFiles():
+        """Retrieve the build's `IRockFile` records.
+
+        :return: A result set of (`IRockFile`, `ILibraryFileAlias`,
+            `ILibraryFileContent`).
+        """
+
+    def getFileByName(filename):
+        """Return the corresponding `ILibraryFileAlias` in this context.
+
+        The following file types (and extension) can be looked up:
+
+         * Build log: '.txt.gz'
+         * Upload log: '_log.txt'
+
+        Any filename not matching one of these extensions is looked up as a
+        rock recipe output file.
+
+        :param filename: The filename to look up.
+        :raises NotFoundError: if no file exists with the given name.
+        :return: The corresponding `ILibraryFileAlias`.
+        """
+
+
+class IRockRecipeBuildEdit(IBuildFarmJobEdit):
+    """`IRockRecipeBuild` methods that require launchpad.Edit."""
+
+    def addFile(lfa):
+        """Add a file to this build.
+
+        :param lfa: An `ILibraryFileAlias`.
+        :return: An `IRockFile`.
+        """
+
+
+class IRockRecipeBuildAdmin(Interface):
+    """`IRockRecipeBuild` methods that require launchpad.Admin."""
+
+    def rescore(score):
+        """Change the build's score."""
+
+
+class IRockRecipeBuild(
+    IRockRecipeBuildView,
+    IRockRecipeBuildEdit,
+    IRockRecipeBuildAdmin,
+    IPackageBuild,
+):
+    """A build record for a rock recipe."""
+
+
+class IRockRecipeBuildSet(ISpecificBuildFarmJobSource):
+    """Utility to create and access `IRockRecipeBuild`s."""
+
+    def new(
+        build_request,
+        recipe,
+        distro_arch_series,
+        channels=None,
+        store_upload_metadata=None,
+        date_created=DEFAULT,
+    ):
+        """Create an `IRockRecipeBuild`."""
+
+    def preloadBuildsData(builds):
+        """Load the data related to a list of rock recipe builds."""
+
+
+class IRockFile(Interface):
+    """A file produced by a rock recipe build."""
+
+    build = Reference(
+        IRockRecipeBuild,
+        title=_("The rock recipe build producing this file."),
+        required=True,
+        readonly=True,
+    )
+
+    library_file = Reference(
+        ILibraryFileAlias,
+        title=_("The library file alias for this file."),
+        required=True,
+        readonly=True,
+    )
diff --git a/lib/lp/rocks/interfaces/rockrecipejob.py b/lib/lp/rocks/interfaces/rockrecipejob.py
new file mode 100644
index 0000000..f3f79a8
--- /dev/null
+++ b/lib/lp/rocks/interfaces/rockrecipejob.py
@@ -0,0 +1,135 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipe job interfaces."""
+
+__all__ = [
+    "IRockRecipeJob",
+    "IRockRecipeRequestBuildsJob",
+    "IRockRecipeRequestBuildsJobSource",
+]
+
+from lazr.restful.fields import Reference
+from zope.interface import Attribute, Interface
+from zope.schema import Datetime, Dict, List, Set, TextLine
+
+from lp import _
+from lp.registry.interfaces.person import IPerson
+from lp.rocks.interfaces.rockrecipe import IRockRecipe, IRockRecipeBuildRequest
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuild
+from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
+
+
+class IRockRecipeJob(Interface):
+    """A job related to a rock recipe."""
+
+    job = Reference(
+        title=_("The common Job attributes."),
+        schema=IJob,
+        required=True,
+        readonly=True,
+    )
+
+    recipe = Reference(
+        title=_("The rock recipe to use for this job."),
+        schema=IRockRecipe,
+        required=True,
+        readonly=True,
+    )
+
+    metadata = Attribute(_("A dict of data about the job."))
+
+
+class IRockRecipeRequestBuildsJob(IRunnableJob):
+    """A Job that processes a request for builds of a rock recipe."""
+
+    requester = Reference(
+        title=_("The person requesting the builds."),
+        schema=IPerson,
+        required=True,
+        readonly=True,
+    )
+
+    channels = Dict(
+        title=_("Source snap channels to use for these builds."),
+        description=_(
+            "A dictionary mapping snap names to channels to use for these "
+            "builds.  Currently only 'core', 'core18', 'core20', and "
+            "'rockcraft' keys are supported."
+        ),
+        key_type=TextLine(),
+        required=False,
+        readonly=True,
+    )
+
+    architectures = Set(
+        title=_("If set, limit builds to these architecture tags."),
+        value_type=TextLine(),
+        required=False,
+        readonly=True,
+    )
+
+    date_created = Datetime(
+        title=_("Time when this job was created."),
+        required=True,
+        readonly=True,
+    )
+
+    date_finished = Datetime(
+        title=_("Time when this job finished."), required=True, readonly=True
+    )
+
+    error_message = TextLine(
+        title=_("Error message resulting from running this job."),
+        required=False,
+        readonly=True,
+    )
+
+    build_request = Reference(
+        title=_("The build request corresponding to this job."),
+        schema=IRockRecipeBuildRequest,
+        required=True,
+        readonly=True,
+    )
+
+    builds = List(
+        title=_("The builds created by this request."),
+        value_type=Reference(schema=IRockRecipeBuild),
+        required=True,
+        readonly=True,
+    )
+
+
+class IRockRecipeRequestBuildsJobSource(IJobSource):
+
+    def create(recipe, requester, channels=None, architectures=None):
+        """Request builds of a rock recipe.
+
+        :param recipe: The rock recipe to build.
+        :param requester: The person requesting the builds.
+        :param channels: A dictionary mapping snap names to channels to use
+            for these builds.
+        :param architectures: If not None, limit builds to architectures
+            with these architecture tags (in addition to any other
+            applicable constraints).
+        """
+
+    def findByRecipe(recipe, statuses=None, job_ids=None):
+        """Find jobs for a rock recipe.
+
+        :param recipe: A rock recipe to search for.
+        :param statuses: An optional iterable of `JobStatus`es to search for.
+        :param job_ids: An optional iterable of job IDs to search for.
+        :return: A sequence of `RockRecipeRequestBuildsJob`s with the
+            specified recipe.
+        """
+
+    def getByRecipeAndID(recipe, job_id):
+        """Get a job by rock recipe and job ID.
+
+        :return: The `RockRecipeRequestBuildsJob` with the specified recipe
+            and ID.
+        :raises: `NotFoundError` if there is no job with the specified
+            recipe and ID, or its `job_type` is not
+            `RockRecipeJobType.REQUEST_BUILDS`.
+        """
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
new file mode 100644
index 0000000..6e493e5
--- /dev/null
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -0,0 +1,481 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipes."""
+
+__all__ = [
+    "RockRecipe",
+]
+
+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.gitcollection import GenericGitCollection
+from lp.code.model.gitrepository import GitRepository
+from lp.registry.errors import PrivatePersonLinkageError
+from lp.registry.interfaces.person import IPersonSet, validate_public_person
+from lp.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
+    ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
+    DuplicateRockRecipeName,
+    IRockRecipe,
+    IRockRecipeBuildRequest,
+    IRockRecipeSet,
+    NoSourceForRockRecipe,
+    RockRecipeBuildRequestStatus,
+    RockRecipeFeatureDisabled,
+    RockRecipeNotOwner,
+    RockRecipePrivacyMismatch,
+    RockRecipePrivateFeatureDisabled,
+)
+from lp.rocks.interfaces.rockrecipejob import IRockRecipeRequestBuildsJobSource
+from lp.services.database.bulk import load_related
+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
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import cachedproperty, get_property_cache
+
+
+def rock_recipe_modified(recipe, event):
+    """Update the date_last_modified property when a rock recipe is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on rock recipes.
+    """
+    removeSecurityProxy(recipe).date_last_modified = UTC_NOW
+
+
+@implementer(IRockRecipeBuildRequest)
+class RockRecipeBuildRequest:
+    """See `IRockRecipeBuildRequest`.
+
+    This is not directly backed by a database table; instead, it is a
+    webservice-friendly view of an asynchronous build request.
+    """
+
+    def __init__(self, recipe, id):
+        self.recipe = recipe
+        self.id = id
+
+    @classmethod
+    def fromJob(cls, job):
+        """See `IRockRecipeBuildRequest`."""
+        request = cls(job.recipe, job.job_id)
+        get_property_cache(request)._job = job
+        return request
+
+    @cachedproperty
+    def _job(self):
+        job_source = getUtility(IRockRecipeRequestBuildsJobSource)
+        return job_source.getByRecipeAndID(self.recipe, self.id)
+
+    @property
+    def date_requested(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.date_created
+
+    @property
+    def date_finished(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.date_finished
+
+    @property
+    def status(self):
+        """See `IRockRecipeBuildRequest`."""
+        status_map = {
+            JobStatus.WAITING: RockRecipeBuildRequestStatus.PENDING,
+            JobStatus.RUNNING: RockRecipeBuildRequestStatus.PENDING,
+            JobStatus.COMPLETED: RockRecipeBuildRequestStatus.COMPLETED,
+            JobStatus.FAILED: RockRecipeBuildRequestStatus.FAILED,
+            JobStatus.SUSPENDED: RockRecipeBuildRequestStatus.PENDING,
+        }
+        return status_map[self._job.job.status]
+
+    @property
+    def error_message(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.error_message
+
+    @property
+    def builds(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.builds
+
+    @property
+    def requester(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.requester
+
+    @property
+    def channels(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.channels
+
+    @property
+    def architectures(self):
+        """See `IRockRecipeBuildRequest`."""
+        return self._job.architectures
+
+
+@implementer(IRockRecipe)
+class RockRecipe(StormBase):
+    """See `IRockRecipe`."""
+
+    __storm_table__ = "RockRecipe"
+
+    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 RockRecipePrivacyMismatch(
+                    "A public rock 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 RockRecipePrivacyMismatch(
+                    "A public rock 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(IRockRecipeSet).isValidInformationType(
+            value, self.owner, self.git_ref
+        ):
+            raise RockRecipePrivacyMismatch
+        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 `RockRecipe`."""
+        if not getFeatureFlag(ROCK_RECIPE_ALLOW_CREATE):
+            raise RockRecipeFeatureDisabled()
+        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 "<RockRecipe ~%s/%s/+rock/%s>" % (
+            self.owner.name,
+            self.project.name,
+            self.name,
+        )
+
+    @property
+    def private(self):
+        """See `IRockRecipe`."""
+        return self.information_type not in PUBLIC_INFORMATION_TYPES
+
+    @property
+    def git_ref(self):
+        """See `IRockRecipe`."""
+        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 `IRockRecipe`."""
+        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 `IRockRecipe`."""
+        return self._store_channels or []
+
+    @store_channels.setter
+    def store_channels(self, value):
+        """See `IRockRecipe`."""
+        self._store_channels = value or None
+
+    def getAllowedInformationTypes(self, user):
+        """See `IRockRecipe`."""
+        # XXX jugmac00 2024-08-29: Only allow free information types until
+        # we have more privacy infrastructure in place.
+        return FREE_INFORMATION_TYPES
+
+    def visibleByUser(self, user):
+        """See `IRockRecipe`."""
+        if self.information_type in PUBLIC_INFORMATION_TYPES:
+            return True
+        # XXX jugmac00 2024-08-29: Finish implementing this once we have
+        # more privacy infrastructure.
+        return False
+
+    def _checkRequestBuild(self, requester):
+        """May `requester` request builds of this rock recipe?"""
+        if not requester.inTeam(self.owner):
+            raise RockRecipeNotOwner(
+                "%s cannot create rock recipe builds owned by %s."
+                % (requester.display_name, self.owner.display_name)
+            )
+
+    def requestBuilds(self, requester, channels=None, architectures=None):
+        """See `IRockRecipe`."""
+        self._checkRequestBuild(requester)
+        job = getUtility(IRockRecipeRequestBuildsJobSource).create(
+            self, requester, channels=channels, architectures=architectures
+        )
+        return self.getBuildRequest(job.job_id)
+
+    def getBuildRequest(self, job_id):
+        """See `IRockRecipe`."""
+        return RockRecipeBuildRequest(self, job_id)
+
+    def destroySelf(self):
+        """See `IRockRecipe`."""
+        IStore(RockRecipe).remove(self)
+
+
+@implementer(IRockRecipeSet)
+class RockRecipeSet:
+    """See `IRockRecipeSet`."""
+
+    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 `IRockRecipeSet`."""
+        if not registrant.inTeam(owner):
+            if owner.is_team:
+                raise RockRecipeNotOwner(
+                    "%s is not a member of %s."
+                    % (registrant.displayname, owner.displayname)
+                )
+            else:
+                raise RockRecipeNotOwner(
+                    "%s cannot create rock recipes owned by %s."
+                    % (registrant.displayname, owner.displayname)
+                )
+
+        if git_ref is None:
+            raise NoSourceForRockRecipe
+        if self.getByName(owner, project, name) is not None:
+            raise DuplicateRockRecipeName
+
+        # 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 RockRecipePrivacyMismatch
+        store = IPrimaryStore(RockRecipe)
+        recipe = RockRecipe(
+            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 `IRockRecipeSet`."""
+        return (
+            IStore(RockRecipe)
+            .find(RockRecipe, owner=owner, project=project, name=name)
+            .one()
+        )
+
+    def isValidInformationType(self, information_type, owner, git_ref=None):
+        """See `IRockRecipeSet`."""
+        private = information_type not in PUBLIC_INFORMATION_TYPES
+        if private:
+            # If appropriately enabled via feature flag.
+            if not getFeatureFlag(ROCK_RECIPE_PRIVATE_FEATURE_FLAG):
+                raise RockRecipePrivateFeatureDisabled
+            return True
+
+        # Public rock recipes with private sources are not allowed.
+        if git_ref is not None and git_ref.private:
+            return False
+
+        # Public rock recipes owned by private teams are not allowed.
+        if owner is not None and owner.private:
+            return False
+
+        return True
+
+    def preloadDataForRecipes(self, recipes, user=None):
+        """See `IRockRecipeSet`."""
+        recipes = [removeSecurityProxy(recipe) for recipe in recipes]
+
+        person_ids = set()
+        for recipe in recipes:
+            person_ids.add(recipe.registrant_id)
+            person_ids.add(recipe.owner_id)
+
+        repositories = load_related(
+            GitRepository, recipes, ["git_repository_id"]
+        )
+        if repositories:
+            GenericGitCollection.preloadDataForRepositories(repositories)
+
+        # Add repository owners to the list of pre-loaded persons. We need
+        # the target repository owner as well, since repository unique names
+        # aren't trigger-maintained.
+        person_ids.update(repository.owner_id for repository in repositories)
+
+        list(
+            getUtility(IPersonSet).getPrecachedPersonsFromIDs(
+                person_ids, need_validity=True
+            )
+        )
+
+    def findByGitRepository(self, repository, paths=None):
+        """See `IRockRecipeSet`."""
+        clauses = [RockRecipe.git_repository == repository]
+        if paths is not None:
+            clauses.append(RockRecipe.git_path.is_in(paths))
+        # XXX jugmac00 2024-08-29: Check permissions once we have some
+        # privacy infrastructure.
+        return IStore(RockRecipe).find(RockRecipe, *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/rocks/model/rockrecipebuild.py b/lib/lp/rocks/model/rockrecipebuild.py
new file mode 100644
index 0000000..b5935b2
--- /dev/null
+++ b/lib/lp/rocks/model/rockrecipebuild.py
@@ -0,0 +1,443 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipe builds."""
+
+__all__ = [
+    "RockFile",
+    "RockRecipeBuild",
+]
+
+from datetime import timedelta, timezone
+
+import six
+from storm.databases.postgres import JSON
+from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode
+from storm.store import EmptyResultSet
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.app.errors import NotFoundError
+from lp.buildmaster.enums import (
+    BuildFarmJobType,
+    BuildQueueStatus,
+    BuildStatus,
+)
+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
+from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
+from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.person import Person
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
+from lp.rocks.interfaces.rockrecipebuild import (
+    IRockFile,
+    IRockRecipeBuild,
+    IRockRecipeBuildSet,
+)
+from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.constants import DEFAULT
+from lp.services.database.decoratedresultset import DecoratedResultSet
+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.librarian.model import LibraryFileAlias, LibraryFileContent
+from lp.services.propertycache import cachedproperty, get_property_cache
+from lp.services.webapp.snapshot import notify_modified
+
+
+@implementer(IRockRecipeBuild)
+class RockRecipeBuild(PackageBuildMixin, StormBase):
+    """See `IRockRecipeBuild`."""
+
+    __storm_table__ = "RockRecipeBuild"
+
+    job_type = BuildFarmJobType.ROCKRECIPEBUILD
+
+    id = Int(name="id", primary=True)
+
+    build_request_id = Int(name="build_request", allow_none=False)
+
+    requester_id = Int(name="requester", allow_none=False)
+    requester = Reference(requester_id, "Person.id")
+
+    recipe_id = Int(name="recipe", allow_none=False)
+    recipe = Reference(recipe_id, "RockRecipe.id")
+
+    distro_arch_series_id = Int(name="distro_arch_series", allow_none=False)
+    distro_arch_series = Reference(
+        distro_arch_series_id, "DistroArchSeries.id"
+    )
+
+    channels = JSON("channels", allow_none=True)
+
+    processor_id = Int(name="processor", allow_none=False)
+    processor = Reference(processor_id, "Processor.id")
+
+    virtualized = Bool(name="virtualized", allow_none=False)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=timezone.utc, allow_none=False
+    )
+    date_started = DateTime(
+        name="date_started", tzinfo=timezone.utc, allow_none=True
+    )
+    date_finished = DateTime(
+        name="date_finished", tzinfo=timezone.utc, allow_none=True
+    )
+    date_first_dispatched = DateTime(
+        name="date_first_dispatched", tzinfo=timezone.utc, allow_none=True
+    )
+
+    builder_id = Int(name="builder", allow_none=True)
+    builder = Reference(builder_id, "Builder.id")
+
+    status = DBEnum(name="status", enum=BuildStatus, allow_none=False)
+
+    log_id = Int(name="log", allow_none=True)
+    log = Reference(log_id, "LibraryFileAlias.id")
+
+    upload_log_id = Int(name="upload_log", allow_none=True)
+    upload_log = Reference(upload_log_id, "LibraryFileAlias.id")
+
+    dependencies = Unicode(name="dependencies", allow_none=True)
+
+    failure_count = Int(name="failure_count", allow_none=False)
+
+    build_farm_job_id = Int(name="build_farm_job", allow_none=False)
+    build_farm_job = Reference(build_farm_job_id, "BuildFarmJob.id")
+
+    revision_id = Unicode(name="revision_id", allow_none=True)
+
+    store_upload_metadata = JSON("store_upload_json_data", allow_none=True)
+
+    def __init__(
+        self,
+        build_farm_job,
+        build_request,
+        recipe,
+        distro_arch_series,
+        processor,
+        virtualized,
+        channels=None,
+        store_upload_metadata=None,
+        date_created=DEFAULT,
+    ):
+        """Construct a `RockRecipeBuild`."""
+        requester = build_request.requester
+        super().__init__()
+        self.build_farm_job = build_farm_job
+        self.build_request_id = build_request.id
+        self.requester = requester
+        self.recipe = recipe
+        self.distro_arch_series = distro_arch_series
+        self.processor = processor
+        self.virtualized = virtualized
+        self.channels = channels
+        self.store_upload_metadata = store_upload_metadata
+        self.date_created = date_created
+        self.status = BuildStatus.NEEDSBUILD
+
+    @property
+    def build_request(self):
+        return self.recipe.getBuildRequest(self.build_request_id)
+
+    @property
+    def is_private(self):
+        """See `IBuildFarmJob`."""
+        return self.recipe.private or self.recipe.owner.private
+
+    def __repr__(self):
+        return "<RockRecipeBuild ~%s/%s/+rock/%s/+build/%d>" % (
+            self.recipe.owner.name,
+            self.recipe.project.name,
+            self.recipe.name,
+            self.id,
+        )
+
+    @property
+    def title(self):
+        return "%s build of /~%s/%s/+rock/%s" % (
+            self.distro_arch_series.architecturetag,
+            self.recipe.owner.name,
+            self.recipe.project.name,
+            self.recipe.name,
+        )
+
+    @property
+    def distribution(self):
+        """See `IPackageBuild`."""
+        return self.distro_arch_series.distroseries.distribution
+
+    @property
+    def distro_series(self):
+        """See `IPackageBuild`."""
+        return self.distro_arch_series.distroseries
+
+    @property
+    def archive(self):
+        """See `IPackageBuild`."""
+        return self.distribution.main_archive
+
+    @property
+    def pocket(self):
+        """See `IPackageBuild`."""
+        return PackagePublishingPocket.UPDATES
+
+    @property
+    def score(self):
+        """See `IRockRecipeBuild`."""
+        if self.buildqueue_record is None:
+            return None
+        else:
+            return self.buildqueue_record.lastscore
+
+    @property
+    def can_be_retried(self):
+        """See `IBuildFarmJob`."""
+        # First check that the behaviour would accept the build if it
+        # succeeded.
+        if self.distro_series.status == SeriesStatus.OBSOLETE:
+            return False
+        return super().can_be_retried
+
+    def calculateScore(self):
+        """See `IBuildFarmJob`."""
+        # XXX jugmac00 2024-09-08: We'll probably need something like
+        # RockRecipe.relative_build_score at some point.
+        return 2510
+
+    def getMedianBuildDuration(self):
+        """Return the median duration of our successful builds."""
+        store = IStore(self)
+        result = store.find(
+            (RockRecipeBuild.date_started, RockRecipeBuild.date_finished),
+            RockRecipeBuild.recipe == self.recipe,
+            RockRecipeBuild.processor == self.processor,
+            RockRecipeBuild.status == BuildStatus.FULLYBUILT,
+        )
+        result.order_by(Desc(RockRecipeBuild.date_finished))
+        durations = [row[1] - row[0] for row in result[:9]]
+        if len(durations) == 0:
+            return None
+        durations.sort()
+        return durations[len(durations) // 2]
+
+    def estimateDuration(self):
+        """See `IBuildFarmJob`."""
+        median = self.getMedianBuildDuration()
+        if median is not None:
+            return median
+        return timedelta(minutes=10)
+
+    @cachedproperty
+    def eta(self):
+        """The datetime when the build job is estimated to complete.
+
+        This is the BuildQueue.estimated_duration plus the
+        Job.date_started or BuildQueue.getEstimatedJobStartTime.
+        """
+        if self.buildqueue_record is None:
+            return None
+        queue_record = self.buildqueue_record
+        if queue_record.status == BuildQueueStatus.WAITING:
+            start_time = queue_record.getEstimatedJobStartTime()
+        else:
+            start_time = queue_record.date_started
+        if start_time is None:
+            return None
+        duration = queue_record.estimated_duration
+        return start_time + duration
+
+    @property
+    def estimate(self):
+        """If true, the date value is an estimate."""
+        if self.date_finished is not None:
+            return False
+        return self.eta is not None
+
+    @property
+    def date(self):
+        """The date when the build completed or is estimated to complete."""
+        if self.estimate:
+            return self.eta
+        return self.date_finished
+
+    def getFiles(self):
+        """See `IRockRecipeBuild`."""
+        result = Store.of(self).find(
+            (RockFile, LibraryFileAlias, LibraryFileContent),
+            RockFile.build == self.id,
+            LibraryFileAlias.id == RockFile.library_file_id,
+            LibraryFileContent.id == LibraryFileAlias.content_id,
+        )
+        return result.order_by([LibraryFileAlias.filename, RockFile.id])
+
+    def getFileByName(self, filename):
+        """See `IRockRecipeBuild`."""
+        if filename.endswith(".txt.gz"):
+            file_object = self.log
+        elif filename.endswith("_log.txt"):
+            file_object = self.upload_log
+        else:
+            file_object = (
+                Store.of(self)
+                .find(
+                    LibraryFileAlias,
+                    RockFile.build == self.id,
+                    LibraryFileAlias.id == RockFile.library_file_id,
+                    LibraryFileAlias.filename == filename,
+                )
+                .one()
+            )
+
+        if file_object is not None and file_object.filename == filename:
+            return file_object
+
+        raise NotFoundError(filename)
+
+    def addFile(self, lfa):
+        """See `IRockRecipeBuild`."""
+        rock_file = RockFile(build=self, library_file=lfa)
+        IPrimaryStore(RockFile).add(rock_file)
+        return rock_file
+
+    def verifySuccessfulUpload(self):
+        """See `IPackageBuild`."""
+        return not self.getFiles().is_empty()
+
+    def updateStatus(
+        self,
+        status,
+        builder=None,
+        worker_status=None,
+        date_started=None,
+        date_finished=None,
+        force_invalid_transition=False,
+    ):
+        """See `IBuildFarmJob`."""
+        edited_fields = set()
+        with notify_modified(
+            self, edited_fields, snapshot_names=("status", "revision_id")
+        ) as previous_obj:
+            super().updateStatus(
+                status,
+                builder=builder,
+                worker_status=worker_status,
+                date_started=date_started,
+                date_finished=date_finished,
+                force_invalid_transition=force_invalid_transition,
+            )
+            if self.status != previous_obj.status:
+                edited_fields.add("status")
+            if worker_status is not None:
+                revision_id = worker_status.get("revision_id")
+                if revision_id is not None:
+                    self.revision_id = six.ensure_text(revision_id)
+                if revision_id != previous_obj.revision_id:
+                    edited_fields.add("revision_id")
+        # notify_modified evaluates all attributes mentioned in the
+        # interface, but we may then make changes that affect self.eta.
+        del get_property_cache(self).eta
+
+    def notify(self, extra_info=None):
+        """See `IPackageBuild`."""
+        if not config.builddmaster.send_build_notification:
+            return
+        if self.status == BuildStatus.FULLYBUILT:
+            return
+        # XXX jugmac00 2024-09-03: Send email notifications.
+
+
+@implementer(IRockRecipeBuildSet)
+class RockRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
+    """See `IRockRecipeBuildSet`."""
+
+    def new(
+        self,
+        build_request,
+        recipe,
+        distro_arch_series,
+        channels=None,
+        store_upload_metadata=None,
+        date_created=DEFAULT,
+    ):
+        """See `IRockRecipeBuildSet`."""
+        store = IPrimaryStore(RockRecipeBuild)
+        build_farm_job = getUtility(IBuildFarmJobSource).new(
+            RockRecipeBuild.job_type, BuildStatus.NEEDSBUILD, date_created
+        )
+        virtualized = (
+            not distro_arch_series.processor.supports_nonvirtualized
+            or recipe.require_virtualized
+        )
+        build = RockRecipeBuild(
+            build_farm_job,
+            build_request,
+            recipe,
+            distro_arch_series,
+            distro_arch_series.processor,
+            virtualized,
+            channels=channels,
+            store_upload_metadata=store_upload_metadata,
+            date_created=date_created,
+        )
+        store.add(build)
+        return build
+
+    def getByID(self, build_id):
+        """See `ISpecificBuildFarmJobSource`."""
+        store = IPrimaryStore(RockRecipeBuild)
+        return store.get(RockRecipeBuild, build_id)
+
+    def getByBuildFarmJob(self, build_farm_job):
+        """See `ISpecificBuildFarmJobSource`."""
+        return (
+            Store.of(build_farm_job)
+            .find(RockRecipeBuild, build_farm_job_id=build_farm_job.id)
+            .one()
+        )
+
+    def preloadBuildsData(self, builds):
+        # Circular import.
+        from lp.rocks.model.rockrecipe import RockRecipe
+
+        load_related(Person, builds, ["requester_id"])
+        lfas = load_related(LibraryFileAlias, builds, ["log_id"])
+        load_related(LibraryFileContent, lfas, ["contentID"])
+        recipes = load_related(RockRecipe, builds, ["recipe_id"])
+        getUtility(IRockRecipeSet).preloadDataForRecipes(recipes)
+
+    def getByBuildFarmJobs(self, build_farm_jobs):
+        """See `ISpecificBuildFarmJobSource`."""
+        if len(build_farm_jobs) == 0:
+            return EmptyResultSet()
+        rows = Store.of(build_farm_jobs[0]).find(
+            RockRecipeBuild,
+            RockRecipeBuild.build_farm_job_id.is_in(
+                bfj.id for bfj in build_farm_jobs
+            ),
+        )
+        return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
+
+
+@implementer(IRockFile)
+class RockFile(StormBase):
+    """See `IRockFile`."""
+
+    __storm_table__ = "RockFile"
+
+    id = Int(name="id", primary=True)
+
+    build_id = Int(name="build", allow_none=False)
+    build = Reference(build_id, "RockRecipeBuild.id")
+
+    library_file_id = Int(name="library_file", allow_none=False)
+    library_file = Reference(library_file_id, "LibraryFileAlias.id")
+
+    def __init__(self, build, library_file):
+        """Construct a `RockFile`."""
+        super().__init__()
+        self.build = build
+        self.library_file = library_file
diff --git a/lib/lp/rocks/model/rockrecipejob.py b/lib/lp/rocks/model/rockrecipejob.py
new file mode 100644
index 0000000..b0211af
--- /dev/null
+++ b/lib/lp/rocks/model/rockrecipejob.py
@@ -0,0 +1,316 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Rock recipe jobs."""
+
+__all__ = [
+    "RockRecipeJob",
+    "RockRecipeJobType",
+    "RockRecipeRequestBuildsJob",
+]
+
+import transaction
+from lazr.delegates import delegate_to
+from lazr.enum import DBEnumeratedType, DBItem
+from storm.databases.postgres import JSON
+from storm.locals import Desc, Int, Reference
+from storm.store import EmptyResultSet
+from zope.component import getUtility
+from zope.interface import implementer, provider
+
+from lp.app.errors import NotFoundError
+from lp.registry.interfaces.person import IPersonSet
+from lp.rocks.interfaces.rockrecipejob import (
+    IRockRecipeJob,
+    IRockRecipeRequestBuildsJob,
+    IRockRecipeRequestBuildsJobSource,
+)
+from lp.rocks.model.rockrecipebuild import RockRecipeBuild
+from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.decoratedresultset import DecoratedResultSet
+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.job.model.job import EnumeratedSubclass, Job
+from lp.services.job.runner import BaseRunnableJob
+from lp.services.mail.sendmail import format_address_for_person
+from lp.services.propertycache import cachedproperty
+from lp.services.scripts import log
+
+
+class RockRecipeJobType(DBEnumeratedType):
+    """Values that `IRockRecipeJob.job_type` can take."""
+
+    REQUEST_BUILDS = DBItem(
+        0,
+        """
+        Request builds
+
+        This job requests builds of a rock recipe.
+        """,
+    )
+
+
+@implementer(IRockRecipeJob)
+class RockRecipeJob(StormBase):
+    """See `IRockRecipeJob`."""
+
+    __storm_table__ = "RockRecipeJob"
+
+    job_id = Int(name="job", primary=True, allow_none=False)
+    job = Reference(job_id, "Job.id")
+
+    recipe_id = Int(name="recipe", allow_none=False)
+    recipe = Reference(recipe_id, "RockRecipe.id")
+
+    job_type = DBEnum(
+        name="job_type", enum=RockRecipeJobType, allow_none=False
+    )
+
+    metadata = JSON("json_data", allow_none=False)
+
+    def __init__(self, recipe, job_type, metadata, **job_args):
+        """Constructor.
+
+        Extra keyword arguments are used to construct the underlying Job
+        object.
+
+        :param recipe: The `IRockRecipe` this job relates to.
+        :param job_type: The `RockRecipeJobType` of this job.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super().__init__()
+        self.job = Job(**job_args)
+        self.recipe = recipe
+        self.job_type = job_type
+        self.metadata = metadata
+
+    def makeDerived(self):
+        return RockRecipeJobDerived.makeSubclass(self)
+
+
+@delegate_to(IRockRecipeJob)
+class RockRecipeJobDerived(BaseRunnableJob, metaclass=EnumeratedSubclass):
+
+    def __init__(self, recipe_job):
+        self.context = recipe_job
+
+    def __repr__(self):
+        """An informative representation of the job."""
+        return "<%s for ~%s/%s/+rock/%s>" % (
+            self.__class__.__name__,
+            self.recipe.owner.name,
+            self.recipe.project.name,
+            self.recipe.name,
+        )
+
+    @classmethod
+    def get(cls, job_id):
+        """Get a job by id.
+
+        :return: The `RockRecipeJob` with the specified id, as the current
+            `RockRecipeJobDerived` subclass.
+        :raises: `NotFoundError` if there is no job with the specified id,
+            or its `job_type` does not match the desired subclass.
+        """
+        recipe_job = IStore(RockRecipeJob).get(RockRecipeJob, job_id)
+        if recipe_job.job_type != cls.class_job_type:
+            raise NotFoundError(
+                "No object found with id %d and type %s"
+                % (job_id, cls.class_job_type.title)
+            )
+        return cls(recipe_job)
+
+    @classmethod
+    def iterReady(cls):
+        """See `IJobSource`."""
+        jobs = IPrimaryStore(RockRecipeJob).find(
+            RockRecipeJob,
+            RockRecipeJob.job_type == cls.class_job_type,
+            RockRecipeJob.job == Job.id,
+            Job.id.is_in(Job.ready_jobs),
+        )
+        return (cls(job) for job in jobs)
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        oops_vars = super().getOopsVars()
+        oops_vars.extend(
+            [
+                ("job_id", self.context.job.id),
+                ("job_type", self.context.job_type.title),
+                ("recipe_owner_name", self.context.recipe.owner.name),
+                ("recipe_project_name", self.context.recipe.project.name),
+                ("recipe_name", self.context.recipe.name),
+            ]
+        )
+        return oops_vars
+
+
+@implementer(IRockRecipeRequestBuildsJob)
+@provider(IRockRecipeRequestBuildsJobSource)
+class RockRecipeRequestBuildsJob(RockRecipeJobDerived):
+    """A Job that processes a request for builds of a rock recipe."""
+
+    class_job_type = RockRecipeJobType.REQUEST_BUILDS
+
+    max_retries = 5
+
+    config = config.IRockRecipeRequestBuildsJobSource
+
+    @classmethod
+    def create(cls, recipe, requester, channels=None, architectures=None):
+        """See `IRockRecipeRequestBuildsJobSource`."""
+        metadata = {
+            "requester": requester.id,
+            "channels": channels,
+            # Really a set or None, but sets aren't directly
+            # JSON-serialisable.
+            "architectures": (
+                list(architectures) if architectures is not None else None
+            ),
+        }
+        recipe_job = RockRecipeJob(recipe, cls.class_job_type, metadata)
+        job = cls(recipe_job)
+        job.celeryRunOnCommit()
+        IStore(RockRecipeJob).flush()
+        return job
+
+    @classmethod
+    def findByRecipe(cls, recipe, statuses=None, job_ids=None):
+        """See `IRockRecipeRequestBuildsJobSource`."""
+        clauses = [
+            RockRecipeJob.recipe == recipe,
+            RockRecipeJob.job_type == cls.class_job_type,
+        ]
+        if statuses is not None:
+            clauses.extend(
+                [
+                    RockRecipeJob.job == Job.id,
+                    Job._status.is_in(statuses),
+                ]
+            )
+        if job_ids is not None:
+            clauses.append(RockRecipeJob.job_id.is_in(job_ids))
+        recipe_jobs = (
+            IStore(RockRecipeJob)
+            .find(RockRecipeJob, *clauses)
+            .order_by(Desc(RockRecipeJob.job_id))
+        )
+
+        def preload_jobs(rows):
+            load_related(Job, rows, ["job_id"])
+
+        return DecoratedResultSet(
+            recipe_jobs,
+            lambda recipe_job: cls(recipe_job),
+            pre_iter_hook=preload_jobs,
+        )
+
+    @classmethod
+    def getByRecipeAndID(cls, recipe, job_id):
+        """See `IRockRecipeRequestBuildsJobSource`."""
+        recipe_job = (
+            IStore(RockRecipeJob)
+            .find(
+                RockRecipeJob,
+                RockRecipeJob.job_id == job_id,
+                RockRecipeJob.recipe == recipe,
+                RockRecipeJob.job_type == cls.class_job_type,
+            )
+            .one()
+        )
+        if recipe_job is None:
+            raise NotFoundError(
+                "No REQUEST_BUILDS job with ID %d found for %r"
+                % (job_id, recipe)
+            )
+        return cls(recipe_job)
+
+    def getOperationDescription(self):
+        return "requesting builds of %s" % self.recipe.name
+
+    def getErrorRecipients(self):
+        if self.requester is None or self.requester.preferredemail is None:
+            return []
+        return [format_address_for_person(self.requester)]
+
+    @cachedproperty
+    def requester(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        requester_id = self.metadata["requester"]
+        return getUtility(IPersonSet).get(requester_id)
+
+    @property
+    def channels(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        return self.metadata["channels"]
+
+    @property
+    def architectures(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        architectures = self.metadata["architectures"]
+        return set(architectures) if architectures is not None else None
+
+    @property
+    def date_created(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        return self.context.job.date_created
+
+    @property
+    def date_finished(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        return self.context.job.date_finished
+
+    @property
+    def error_message(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `IRockRecipeRequestBuildsJob`."""
+        self.metadata["error_message"] = message
+
+    @property
+    def build_request(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        return self.recipe.getBuildRequest(self.job.id)
+
+    @property
+    def builds(self):
+        """See `IRockRecipeRequestBuildsJob`."""
+        build_ids = self.metadata.get("builds")
+        if build_ids:
+            return IStore(RockRecipeBuild).find(
+                RockRecipeBuild, RockRecipeBuild.id.is_in(build_ids)
+            )
+        else:
+            return EmptyResultSet()
+
+    @builds.setter
+    def builds(self, builds):
+        """See `IRockRecipeRequestBuildsJob`."""
+        self.metadata["builds"] = [build.id for build in builds]
+
+    def run(self):
+        """See `IRunnableJob`."""
+        requester = self.requester
+        if requester is None:
+            log.info(
+                "Skipping %r because the requester has been deleted." % self
+            )
+            return
+        try:
+            # XXX jugmac00 2024-09-06: Implement this once we have a
+            # RockRecipeBuild model.
+            raise NotImplementedError
+        except Exception as e:
+            self.error_message = str(e)
+            # The normal job infrastructure will abort the transaction, but
+            # we want to commit instead: the only database changes we make
+            # are to this job's metadata and should be preserved.
+            transaction.commit()
+            raise
diff --git a/lib/lp/rocks/security.py b/lib/lp/rocks/security.py
new file mode 100644
index 0000000..6aee24b
--- /dev/null
+++ b/lib/lp/rocks/security.py
@@ -0,0 +1,59 @@
+# Copyright 2009-2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Security adapters for the rocks package."""
+
+__all__ = []
+
+from lp.app.security import AuthorizationBase, DelegatedAuthorization
+from lp.rocks.interfaces.rockrecipe import IRockRecipe, IRockRecipeBuildRequest
+
+
+class ViewRockRecipe(AuthorizationBase):
+    """Private rock recipes are only visible to their owners and admins."""
+
+    permission = "launchpad.View"
+    usedfor = IRockRecipe
+
+    def checkAuthenticated(self, user):
+        return self.obj.visibleByUser(user.person)
+
+    def checkUnauthenticated(self):
+        return self.obj.visibleByUser(None)
+
+
+class EditRockRecipe(AuthorizationBase):
+    permission = "launchpad.Edit"
+    usedfor = IRockRecipe
+
+    def checkAuthenticated(self, user):
+        return (
+            user.isOwner(self.obj) or user.in_commercial_admin or user.in_admin
+        )
+
+
+class AdminRockRecipe(AuthorizationBase):
+    """Restrict changing build settings on rock 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 rock recipes that they can already edit.
+    """
+
+    permission = "launchpad.Admin"
+    usedfor = IRockRecipe
+
+    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 EditRockRecipe(
+            self.obj
+        ).checkAuthenticated(user)
+
+
+class ViewCharmRecipeBuildRequest(DelegatedAuthorization):
+    permission = "launchpad.View"
+    usedfor = IRockRecipeBuildRequest
+
+    def __init__(self, obj):
+        super().__init__(obj, obj.recipe, "launchpad.View")
diff --git a/lib/lp/rocks/tests/__init__.py b/lib/lp/rocks/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/rocks/tests/__init__.py
diff --git a/lib/lp/rocks/tests/test_rockrecipe.py b/lib/lp/rocks/tests/test_rockrecipe.py
new file mode 100644
index 0000000..96f9846
--- /dev/null
+++ b/lib/lp/rocks/tests/test_rockrecipe.py
@@ -0,0 +1,372 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test rock recipes."""
+
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesDict,
+    MatchesSetwise,
+    MatchesStructure,
+)
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
+    IRockRecipe,
+    IRockRecipeSet,
+    NoSourceForRockRecipe,
+    RockRecipeBuildRequestStatus,
+    RockRecipeFeatureDisabled,
+    RockRecipePrivateFeatureDisabled,
+)
+from lp.rocks.interfaces.rockrecipejob import IRockRecipeRequestBuildsJobSource
+from lp.services.database.constants import ONE_DAY_AGO, UTC_NOW
+from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+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 TestRockRecipeFeatureFlags(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we wil not create any rock recipes.
+        self.assertRaises(
+            RockRecipeFeatureDisabled, self.factory.makeRockRecipe
+        )
+
+    def test_private_feature_flag_disabled(self):
+        # Without a private feature flag, we will not create new private
+        # rock recipes.
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+        self.assertRaises(
+            RockRecipePrivateFeatureDisabled,
+            self.factory.makeRockRecipe,
+            information_type=InformationType.PROPRIETARY,
+        )
+
+
+class TestRockRecipe(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_implements_interfaces(self):
+        # RockRecipe implements IRockRecipe.
+        recipe = self.factory.makeRockRecipe()
+        with admin_logged_in():
+            self.assertProvides(recipe, IRockRecipe)
+
+    def test___repr__(self):
+        # RockRecipe objects have an informative __repr__.
+        recipe = self.factory.makeRockRecipe()
+        self.assertEqual(
+            "<RockRecipe ~%s/%s/+rock/%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.makeRockRecipe(date_created=ONE_DAY_AGO)
+        self.assertEqual(recipe.date_created, recipe.date_last_modified)
+
+    def test_modifiedevent_sets_date_last_modified(self):
+        # When a RockRecipe receives an object modified event, the last
+        # modified date is set to UTC_NOW.
+        recipe = self.factory.makeRockRecipe(date_created=ONE_DAY_AGO)
+        with notify_modified(removeSecurityProxy(recipe), ["name"]):
+            pass
+        self.assertSqlAttributeEqualsDate(
+            recipe, "date_last_modified", UTC_NOW
+        )
+
+    def test_requestBuilds(self):
+        # requestBuilds schedules a job and returns a corresponding
+        # RockRecipeBuildRequest.
+        recipe = self.factory.makeRockRecipe()
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestBuilds(recipe.owner.teamowner)
+        self.assertThat(
+            request,
+            MatchesStructure(
+                date_requested=Equals(now),
+                date_finished=Is(None),
+                recipe=Equals(recipe),
+                status=Equals(RockRecipeBuildRequestStatus.PENDING),
+                error_message=Is(None),
+                channels=Is(None),
+                architectures=Is(None),
+            ),
+        )
+        [job] = getUtility(IRockRecipeRequestBuildsJobSource).iterReady()
+        self.assertThat(
+            job,
+            MatchesStructure(
+                job_id=Equals(request.id),
+                job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+                recipe=Equals(recipe),
+                requester=Equals(recipe.owner.teamowner),
+                channels=Is(None),
+                architectures=Is(None),
+            ),
+        )
+
+    def test_requestBuilds_with_channels(self):
+        # If asked to build using particular snap channels, requestBuilds
+        # passes those through to the job.
+        recipe = self.factory.makeRockRecipe()
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestBuilds(
+                recipe.owner.teamowner, channels={"rockcraft": "edge"}
+            )
+        self.assertThat(
+            request,
+            MatchesStructure(
+                date_requested=Equals(now),
+                date_finished=Is(None),
+                recipe=Equals(recipe),
+                status=Equals(RockRecipeBuildRequestStatus.PENDING),
+                error_message=Is(None),
+                channels=MatchesDict({"rockcraft": Equals("edge")}),
+                architectures=Is(None),
+            ),
+        )
+        [job] = getUtility(IRockRecipeRequestBuildsJobSource).iterReady()
+        self.assertThat(
+            job,
+            MatchesStructure(
+                job_id=Equals(request.id),
+                job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+                recipe=Equals(recipe),
+                requester=Equals(recipe.owner.teamowner),
+                channels=Equals({"rockcraft": "edge"}),
+                architectures=Is(None),
+            ),
+        )
+
+    def test_requestBuilds_with_architectures(self):
+        # If asked to build for particular architectures, requestBuilds
+        # passes those through to the job.
+        recipe = self.factory.makeRockRecipe()
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestBuilds(
+                recipe.owner.teamowner, architectures={"amd64", "i386"}
+            )
+        self.assertThat(
+            request,
+            MatchesStructure(
+                date_requested=Equals(now),
+                date_finished=Is(None),
+                recipe=Equals(recipe),
+                status=Equals(RockRecipeBuildRequestStatus.PENDING),
+                error_message=Is(None),
+                channels=Is(None),
+                architectures=MatchesSetwise(Equals("amd64"), Equals("i386")),
+            ),
+        )
+        [job] = getUtility(IRockRecipeRequestBuildsJobSource).iterReady()
+        self.assertThat(
+            job,
+            MatchesStructure(
+                job_id=Equals(request.id),
+                job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+                recipe=Equals(recipe),
+                requester=Equals(recipe.owner.teamowner),
+                channels=Is(None),
+                architectures=MatchesSetwise(Equals("amd64"), Equals("i386")),
+            ),
+        )
+
+    def test_delete_without_builds(self):
+        # A rock recipe with no builds can be deleted.
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        recipe = self.factory.makeRockRecipe(
+            registrant=owner, owner=owner, project=project, name="condemned"
+        )
+        self.assertIsNotNone(
+            getUtility(IRockRecipeSet).getByName(owner, project, "condemned")
+        )
+        with person_logged_in(recipe.owner):
+            recipe.destroySelf()
+        self.assertIsNone(
+            getUtility(IRockRecipeSet).getByName(owner, project, "condemned")
+        )
+
+
+class TestRockRecipeSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_class_implements_interfaces(self):
+        # The RockRecipeSet class implements IRockRecipeSet.
+        self.assertProvides(getUtility(IRockRecipeSet), IRockRecipeSet)
+
+    def makeRockRecipeComponents(self, git_ref=None):
+        """Return a dict of values that can be used to make a rock recipe.
+
+        Suggested use: provide as kwargs to IRockRecipeSet.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("rock-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 rock recipe is created for a
+        # Git branch are present on the new object.
+        [ref] = self.factory.makeGitRefs()
+        components = self.makeRockRecipeComponents(git_ref=ref)
+        recipe = getUtility(IRockRecipeSet).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 rock recipe without a Git repository
+        # fails.
+        registrant = self.factory.makePerson()
+        self.assertRaises(
+            NoSourceForRockRecipe,
+            getUtility(IRockRecipeSet).new,
+            registrant,
+            registrant,
+            self.factory.makeProduct(),
+            self.factory.getUniqueUnicode("rock-name"),
+        )
+
+    def test_getByName(self):
+        owner = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        project_recipe = self.factory.makeRockRecipe(
+            registrant=owner, owner=owner, project=project, name="proj-rock"
+        )
+        self.factory.makeRockRecipe(
+            registrant=owner, owner=owner, name="proj-rock"
+        )
+
+        self.assertEqual(
+            project_recipe,
+            getUtility(IRockRecipeSet).getByName(owner, project, "proj-rock"),
+        )
+
+    def test_findByGitRepository(self):
+        # IRockRecipeSet.findByGitRepository returns all rock 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.makeRockRecipe(git_ref=ref))
+        recipe_set = getUtility(IRockRecipeSet)
+        self.assertContentEqual(
+            recipes[:2], recipe_set.findByGitRepository(repositories[0])
+        )
+        self.assertContentEqual(
+            recipes[2:], recipe_set.findByGitRepository(repositories[1])
+        )
+
+    def test_findByGitRepository_paths(self):
+        # IRockRecipeSet.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.makeRockRecipe(git_ref=ref))
+        recipe_set = getUtility(IRockRecipeSet)
+        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):
+        # IRockRecipeSet.detachFromGitRepository clears the given Git
+        # repository from all rock 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.makeRockRecipe(
+                        git_ref=ref, date_created=ONE_DAY_AGO
+                    )
+                )
+        getUtility(IRockRecipeSet).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/rocks/tests/test_rockrecipebuild.py b/lib/lp/rocks/tests/test_rockrecipebuild.py
new file mode 100644
index 0000000..662c2aa
--- /dev/null
+++ b/lib/lp/rocks/tests/test_rockrecipebuild.py
@@ -0,0 +1,367 @@
+# Copyright 2015-2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test rock package build features."""
+
+from datetime import datetime, timedelta, timezone
+
+from testtools.matchers import Equals
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.app.errors import NotFoundError
+from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
+from lp.registry.interfaces.series import SeriesStatus
+from lp.rocks.interfaces.rockrecipe import (
+    ROCK_RECIPE_ALLOW_CREATE,
+    ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
+)
+from lp.rocks.interfaces.rockrecipebuild import (
+    IRockRecipeBuild,
+    IRockRecipeBuildSet,
+)
+from lp.services.features.testing import FeatureFixture
+from lp.services.propertycache import clear_property_cache
+from lp.testing import (
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    person_logged_in,
+)
+from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.matchers import HasQueryCount
+
+
+class TestRockRecipeBuild(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+        self.build = self.factory.makeRockRecipeBuild()
+
+    def test_implements_interfaces(self):
+        # RockRecipeBuild implements IPackageBuild and IRockRecipeBuild.
+        self.assertProvides(self.build, IPackageBuild)
+        self.assertProvides(self.build, IRockRecipeBuild)
+
+    def test___repr__(self):
+        # RockRecipeBuild has an informative __repr__.
+        self.assertEqual(
+            "<RockRecipeBuild ~%s/%s/+rock/%s/+build/%s>"
+            % (
+                self.build.recipe.owner.name,
+                self.build.recipe.project.name,
+                self.build.recipe.name,
+                self.build.id,
+            ),
+            repr(self.build),
+        )
+
+    def test_title(self):
+        # RockRecipeBuild has an informative title.
+        das = self.build.distro_arch_series
+        self.assertEqual(
+            "%s build of /~%s/%s/+rock/%s"
+            % (
+                das.architecturetag,
+                self.build.recipe.owner.name,
+                self.build.recipe.project.name,
+                self.build.recipe.name,
+            ),
+            self.build.title,
+        )
+
+    def test_queueBuild(self):
+        # RockRecipeBuild can create the queue entry for itself.
+        bq = self.build.queueBuild()
+        self.assertProvides(bq, IBuildQueue)
+        self.assertEqual(
+            self.build.build_farm_job, removeSecurityProxy(bq)._build_farm_job
+        )
+        self.assertEqual(self.build, bq.specific_build)
+        self.assertEqual(self.build.virtualized, bq.virtualized)
+        self.assertIsNotNone(bq.processor)
+        self.assertEqual(bq, self.build.buildqueue_record)
+
+    def test_is_private(self):
+        # A RockRecipeBuild is private iff its recipe or owner are.
+        self.assertFalse(self.build.is_private)
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ROCK_RECIPE_ALLOW_CREATE: "on",
+                    ROCK_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        private_team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.MODERATED,
+            visibility=PersonVisibility.PRIVATE,
+        )
+        with person_logged_in(private_team.teamowner):
+            build = self.factory.makeRockRecipeBuild(
+                requester=private_team.teamowner,
+                owner=private_team,
+                information_type=InformationType.PROPRIETARY,
+            )
+            self.assertTrue(build.is_private)
+
+    def test_can_be_retried(self):
+        ok_cases = [
+            BuildStatus.FAILEDTOBUILD,
+            BuildStatus.MANUALDEPWAIT,
+            BuildStatus.CHROOTWAIT,
+            BuildStatus.FAILEDTOUPLOAD,
+            BuildStatus.CANCELLED,
+            BuildStatus.SUPERSEDED,
+        ]
+        for status in BuildStatus.items:
+            build = self.factory.makeRockRecipeBuild(status=status)
+            if status in ok_cases:
+                self.assertTrue(build.can_be_retried)
+            else:
+                self.assertFalse(build.can_be_retried)
+
+    def test_can_be_retried_obsolete_series(self):
+        # Builds for obsolete series cannot be retried.
+        distroseries = self.factory.makeDistroSeries(
+            status=SeriesStatus.OBSOLETE
+        )
+        das = self.factory.makeDistroArchSeries(distroseries=distroseries)
+        build = self.factory.makeRockRecipeBuild(distro_arch_series=das)
+        self.assertFalse(build.can_be_retried)
+
+    def test_can_be_cancelled(self):
+        # For all states that can be cancelled, can_be_cancelled returns True.
+        ok_cases = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+        ]
+        for status in BuildStatus.items:
+            build = self.factory.makeRockRecipeBuild()
+            build.queueBuild()
+            build.updateStatus(status)
+            if status in ok_cases:
+                self.assertTrue(build.can_be_cancelled)
+            else:
+                self.assertFalse(build.can_be_cancelled)
+
+    def test_retry_resets_state(self):
+        # Retrying a build resets most of the state attributes, but does
+        # not modify the first dispatch time.
+        now = datetime.now(timezone.utc)
+        build = self.factory.makeRockRecipeBuild()
+        build.updateStatus(BuildStatus.BUILDING, date_started=now)
+        build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        build.gotFailure()
+        with person_logged_in(build.recipe.owner):
+            build.retry()
+        self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
+        self.assertEqual(now, build.date_first_dispatched)
+        self.assertIsNone(build.log)
+        self.assertIsNone(build.upload_log)
+        self.assertEqual(0, build.failure_count)
+
+    def test_cancel_not_in_progress(self):
+        # The cancel() method for a pending build leaves it in the CANCELLED
+        # state.
+        self.build.queueBuild()
+        self.build.cancel()
+        self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+        self.assertIsNone(self.build.buildqueue_record)
+
+    def test_cancel_in_progress(self):
+        # The cancel() method for a building build leaves it in the
+        # CANCELLING state.
+        bq = self.build.queueBuild()
+        bq.markAsBuilding(self.factory.makeBuilder())
+        self.build.cancel()
+        self.assertEqual(BuildStatus.CANCELLING, self.build.status)
+        self.assertEqual(bq, self.build.buildqueue_record)
+
+    def test_estimateDuration(self):
+        # Without previous builds, the default time estimate is 10m.
+        self.assertEqual(600, self.build.estimateDuration().seconds)
+
+    def test_estimateDuration_with_history(self):
+        # Previous successful builds of the same recipe are used for
+        # estimates.
+        self.factory.makeRockRecipeBuild(
+            requester=self.build.requester,
+            recipe=self.build.recipe,
+            distro_arch_series=self.build.distro_arch_series,
+            status=BuildStatus.FULLYBUILT,
+            duration=timedelta(seconds=335),
+        )
+        for _ in range(3):
+            self.factory.makeRockRecipeBuild(
+                requester=self.build.requester,
+                recipe=self.build.recipe,
+                distro_arch_series=self.build.distro_arch_series,
+                status=BuildStatus.FAILEDTOBUILD,
+                duration=timedelta(seconds=20),
+            )
+        self.assertEqual(335, self.build.estimateDuration().seconds)
+
+    def test_build_cookie(self):
+        build = self.factory.makeRockRecipeBuild()
+        self.assertEqual("ROCKRECIPEBUILD-%d" % build.id, build.build_cookie)
+
+    def test_getFileByName_logs(self):
+        # getFileByName returns the logs when requested by name.
+        self.build.setLog(
+            self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz")
+        )
+        self.assertEqual(
+            self.build.log, self.build.getFileByName("buildlog.txt.gz")
+        )
+        self.assertRaises(NotFoundError, self.build.getFileByName, "foo")
+        self.build.storeUploadLog("uploaded")
+        self.assertEqual(
+            self.build.upload_log,
+            self.build.getFileByName(self.build.upload_log.filename),
+        )
+
+    def test_getFileByName_uploaded_files(self):
+        # getFileByName returns uploaded files when requested by name.
+        filenames = ("ubuntu.squashfs", "ubuntu.manifest")
+        lfas = []
+        for filename in filenames:
+            lfa = self.factory.makeLibraryFileAlias(filename=filename)
+            lfas.append(lfa)
+            self.build.addFile(lfa)
+        self.assertContentEqual(
+            lfas, [row[1] for row in self.build.getFiles()]
+        )
+        for filename, lfa in zip(filenames, lfas):
+            self.assertEqual(lfa, self.build.getFileByName(filename))
+        self.assertRaises(NotFoundError, self.build.getFileByName, "missing")
+
+    def test_verifySuccessfulUpload(self):
+        self.assertFalse(self.build.verifySuccessfulUpload())
+        self.factory.makeRockFile(build=self.build)
+        self.assertTrue(self.build.verifySuccessfulUpload())
+
+    def test_updateStatus_stores_revision_id(self):
+        # If the builder reports a revision_id, updateStatus saves it.
+        self.assertIsNone(self.build.revision_id)
+        self.build.updateStatus(BuildStatus.BUILDING, worker_status={})
+        self.assertIsNone(self.build.revision_id)
+        self.build.updateStatus(
+            BuildStatus.BUILDING, worker_status={"revision_id": "dummy"}
+        )
+        self.assertEqual("dummy", self.build.revision_id)
+
+    def addFakeBuildLog(self, build):
+        build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
+
+    def test_log_url_123(self):
+        # The log URL for a rock recipe build will use the recipe context.
+        self.addFakeBuildLog(self.build)
+        self.build.log_url
+        self.assertEqual(
+            "http://launchpad.test/~%s/%s/+rock/%s/+build/%d/+files/";
+            "mybuildlog.txt"
+            % (
+                self.build.recipe.owner.name,
+                self.build.recipe.project.name,
+                self.build.recipe.name,
+                self.build.id,
+            ),
+            self.build.log_url,
+        )
+
+    def test_eta(self):
+        # RockRecipeBuild.eta returns a non-None value when it should, or
+        # None when there's no start time.
+        self.build.queueBuild()
+        self.assertIsNone(self.build.eta)
+        self.factory.makeBuilder(processors=[self.build.processor])
+        clear_property_cache(self.build)
+        self.assertIsNotNone(self.build.eta)
+
+    def test_eta_cached(self):
+        # The expensive completion time estimate is cached.
+        self.build.queueBuild()
+        self.build.eta
+        with StormStatementRecorder() as recorder:
+            self.build.eta
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+
+    def test_estimate(self):
+        # RockRecipeBuild.estimate returns True until the job is completed.
+        self.build.queueBuild()
+        self.factory.makeBuilder(processors=[self.build.processor])
+        self.build.updateStatus(BuildStatus.BUILDING)
+        self.assertTrue(self.build.estimate)
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        clear_property_cache(self.build)
+        self.assertFalse(self.build.estimate)
+
+
+class TestRockRecipeBuildSet(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_getByBuildFarmJob_works(self):
+        build = self.factory.makeRockRecipeBuild()
+        self.assertEqual(
+            build,
+            getUtility(IRockRecipeBuildSet).getByBuildFarmJob(
+                build.build_farm_job
+            ),
+        )
+
+    def test_getByBuildFarmJob_returns_None_when_missing(self):
+        bpb = self.factory.makeBinaryPackageBuild()
+        self.assertIsNone(
+            getUtility(IRockRecipeBuildSet).getByBuildFarmJob(
+                bpb.build_farm_job
+            )
+        )
+
+    def test_getByBuildFarmJobs_works(self):
+        builds = [self.factory.makeRockRecipeBuild() for i in range(10)]
+        self.assertContentEqual(
+            builds,
+            getUtility(IRockRecipeBuildSet).getByBuildFarmJobs(
+                [build.build_farm_job for build in builds]
+            ),
+        )
+
+    def test_getByBuildFarmJobs_works_empty(self):
+        self.assertContentEqual(
+            [], getUtility(IRockRecipeBuildSet).getByBuildFarmJobs([])
+        )
+
+    def test_virtualized_recipe_requires(self):
+        recipe = self.factory.makeRockRecipe(require_virtualized=True)
+        target = self.factory.makeRockRecipeBuild(recipe=recipe)
+        self.assertTrue(target.virtualized)
+
+    def test_virtualized_processor_requires(self):
+        recipe = self.factory.makeRockRecipe(require_virtualized=False)
+        distro_arch_series = self.factory.makeDistroArchSeries()
+        distro_arch_series.processor.supports_nonvirtualized = False
+        target = self.factory.makeRockRecipeBuild(
+            distro_arch_series=distro_arch_series, recipe=recipe
+        )
+        self.assertTrue(target.virtualized)
+
+    def test_virtualized_no_support(self):
+        recipe = self.factory.makeRockRecipe(require_virtualized=False)
+        distro_arch_series = self.factory.makeDistroArchSeries()
+        distro_arch_series.processor.supports_nonvirtualized = True
+        target = self.factory.makeRockRecipeBuild(
+            recipe=recipe, distro_arch_series=distro_arch_series
+        )
+        self.assertFalse(target.virtualized)
diff --git a/lib/lp/rocks/tests/test_rockrecipejob.py b/lib/lp/rocks/tests/test_rockrecipejob.py
new file mode 100644
index 0000000..f5da198
--- /dev/null
+++ b/lib/lp/rocks/tests/test_rockrecipejob.py
@@ -0,0 +1,61 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for rock recipe jobs."""
+
+from lp.rocks.interfaces.rockrecipe import ROCK_RECIPE_ALLOW_CREATE
+from lp.rocks.interfaces.rockrecipejob import (
+    IRockRecipeJob,
+    IRockRecipeRequestBuildsJob,
+)
+from lp.rocks.model.rockrecipejob import (
+    RockRecipeJob,
+    RockRecipeJobType,
+    RockRecipeRequestBuildsJob,
+)
+from lp.services.features.testing import FeatureFixture
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestRockRecipeJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `RockRecipeJob` objects provide `IRockRecipeJob`.
+        recipe = self.factory.makeRockRecipe()
+        self.assertProvides(
+            RockRecipeJob(recipe, RockRecipeJobType.REQUEST_BUILDS, {}),
+            IRockRecipeJob,
+        )
+
+
+class TestRockRecipeRequestBuildsJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({ROCK_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `RockRecipeRequestBuildsJob` objects provide
+        # `IRockRecipeRequestBuildsJob`."""
+        recipe = self.factory.makeRockRecipe()
+        job = RockRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+        self.assertProvides(job, IRockRecipeRequestBuildsJob)
+
+    def test___repr__(self):
+        # `RockRecipeRequestBuildsJob` objects have an informative __repr__.
+        recipe = self.factory.makeRockRecipe()
+        job = RockRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+        self.assertEqual(
+            "<RockRecipeRequestBuildsJob for ~%s/%s/+rock/%s>"
+            % (recipe.owner.name, recipe.project.name, recipe.name),
+            repr(job),
+        )
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 07f04c6..c285996 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1995,6 +1995,11 @@ module: lp.charms.interfaces.charmrecipejob
 dbuser: charm-build-job
 crontab_group: MAIN
 
+[IRockRecipeRequestBuildsJobSource]
+module: lp.rocks.interfaces.rockrecipejob
+dbuser: rock-build-job
+crontab_group: MAIN
+
 [ICIBuildUploadJobSource]
 module: lp.soyuz.interfaces.archivejob
 dbuser: uploader
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 6cdf9b4..a3f6415 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -208,6 +208,9 @@ from lp.registry.model.karma import KarmaTotalCache
 from lp.registry.model.milestone import Milestone
 from lp.registry.model.packaging import Packaging
 from lp.registry.model.suitesourcepackage import SuiteSourcePackage
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet
+from lp.rocks.model.rockrecipebuild import RockFile
 from lp.services.auth.interfaces import IAccessTokenSet
 from lp.services.auth.utils import create_access_token_secret
 from lp.services.compat import message_as_bytes
@@ -6887,6 +6890,150 @@ class LaunchpadObjectFactory(ObjectFactory):
             date_created=date_created,
         )
 
+    def makeRockRecipe(
+        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 rock recipe."""
+        if registrant is None:
+            registrant = self.makePerson()
+        private = information_type not in PUBLIC_INFORMATION_TYPES
+        if owner is None:
+            # Private rock 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("rock-name")
+        if git_ref is None:
+            git_ref = self.makeGitRefs()[0]
+        recipe = getUtility(IRockRecipeSet).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 makeRockRecipeBuildRequest(
+        self, recipe=None, requester=None, channels=None, architectures=None
+    ):
+        """Make a new RockRecipeBuildRequest."""
+        if recipe is None:
+            recipe = self.makeRockRecipe()
+        if requester is None:
+            requester = recipe.owner.teamowner
+        return recipe.requestBuilds(
+            requester, channels=channels, architectures=architectures
+        )
+
+    def makeRockRecipeBuild(
+        self,
+        registrant=None,
+        recipe=None,
+        build_request=None,
+        requester=None,
+        distro_arch_series=None,
+        channels=None,
+        store_upload_metadata=None,
+        date_created=DEFAULT,
+        status=BuildStatus.NEEDSBUILD,
+        builder=None,
+        duration=None,
+        **kwargs,
+    ):
+        if recipe is None:
+            if registrant is None:
+                if build_request is not None:
+                    registrant = build_request.requester
+                else:
+                    registrant = requester
+            recipe = self.makeRockRecipe(registrant=registrant, **kwargs)
+        if distro_arch_series is None:
+            distro_arch_series = self.makeDistroArchSeries()
+        if build_request is None:
+            build_request = self.makeRockRecipeBuildRequest(
+                recipe=recipe, requester=requester, channels=channels
+            )
+        build = getUtility(IRockRecipeBuildSet).new(
+            build_request,
+            recipe,
+            distro_arch_series,
+            channels=channels,
+            store_upload_metadata=store_upload_metadata,
+            date_created=date_created,
+        )
+        if duration is not None:
+            removeSecurityProxy(build).updateStatus(
+                BuildStatus.BUILDING,
+                builder=builder,
+                date_started=build.date_created,
+            )
+            removeSecurityProxy(build).updateStatus(
+                status,
+                builder=builder,
+                date_finished=build.date_started + duration,
+            )
+        else:
+            removeSecurityProxy(build).updateStatus(status, builder=builder)
+        IStore(build).flush()
+        return build
+
+    def makeRockFile(self, build=None, library_file=None):
+        if build is None:
+            build = self.makeRockRecipeBuild()
+        if library_file is None:
+            library_file = self.makeLibraryFileAlias()
+        return ProxyFactory(RockFile(build=build, library_file=library_file))
+
     def makeCIBuild(
         self,
         git_repository=None,