← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-jobs into launchpad:master with ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipes as a prerequisite.

Commit message:
Add basic model for craft recipe jobs

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/473830
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-jobs into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index b6db889..b1d833e 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2985,3 +2985,34 @@ public.teammembership                   = SELECT
 public.teamparticipation                = SELECT
 public.webhook                          = SELECT
 public.webhookjob                       = SELECT, INSERT
+
+[craft-build-job]
+type=user
+groups=script
+public.account                          = SELECT
+public.builder                          = SELECT
+public.buildfarmjob                     = SELECT, INSERT
+public.buildqueue                       = SELECT, INSERT, UPDATE
+public.craftfile                        = SELECT
+public.craftrecipe                      = SELECT, UPDATE
+public.craftrecipebuild                 = SELECT, INSERT, UPDATE
+public.craftrecipebuildjob              = SELECT, UPDATE
+public.craftrecipejob                   = 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
\ No newline at end of file
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index fa92a8c..c8ccb59 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -40,4 +40,25 @@
         <allow interface="lp.crafts.interfaces.craftrecipe.ICraftRecipeSet" />
     </lp:securedutility>
 
+    <!-- CraftRecipeBuildRequest -->
+    <class class="lp.crafts.model.craftrecipe.CraftRecipeBuildRequest">
+        <require
+            permission="launchpad.View"
+            interface="lp.crafts.interfaces.craftrecipe.ICraftRecipeBuildRequest" />
+    </class>
+
+    <!-- crafts-related jobs -->
+    <class class="lp.crafts.model.craftrecipejob.CraftRecipeJob">
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
+    </class>
+    <lp:securedutility
+        component="lp.crafts.model.craftrecipejob.CraftRecipeRequestBuildsJob"
+        provides="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJobSource">
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJobSource" />
+    </lp:securedutility>
+    <class class="lp.crafts.model.craftrecipejob.CraftRecipeRequestBuildsJob">
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJob" />
+    </class>
+
 </configure>
\ No newline at end of file
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index af8be8f..ecaf34b 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -8,12 +8,14 @@ __all__ = [
     "BadCraftRecipeSearchContext",
     "CRAFT_RECIPE_ALLOW_CREATE",
     "CRAFT_RECIPE_PRIVATE_FEATURE_FLAG",
+    "CraftRecipeBuildRequestStatus",
     "CraftRecipeFeatureDisabled",
     "CraftRecipeNotOwner",
     "CraftRecipePrivacyMismatch",
     "CraftRecipePrivateFeatureDisabled",
     "DuplicateCraftRecipeName",
     "ICraftRecipe",
+    "ICraftRecipeBuildRequest",
     "ICraftRecipeSet",
     "NoSourceForCraftRecipe",
     "NoSuchCraftRecipe",
@@ -21,10 +23,21 @@ __all__ = [
 
 import http.client
 
+from lazr.enum import EnumeratedType, Item
 from lazr.restful.declarations import error_status, exported
 from lazr.restful.fields import Reference, ReferenceChoice
 from zope.interface import Interface
-from zope.schema import Bool, Choice, Datetime, Dict, Int, List, Text, TextLine
+from zope.schema import (
+    Bool,
+    Choice,
+    Datetime,
+    Dict,
+    Int,
+    List,
+    Set,
+    Text,
+    TextLine,
+)
 from zope.security.interfaces import Unauthorized
 
 from lp import _
@@ -114,6 +127,85 @@ class BadCraftRecipeSearchContext(Exception):
     """The context is not valid for a craft recipe search."""
 
 
+class CraftRecipeBuildRequestStatus(EnumeratedType):
+    """The status of a request to build a craft recipe."""
+
+    PENDING = Item(
+        """
+        Pending
+
+        This craft recipe build request is pending.
+        """
+    )
+
+    FAILED = Item(
+        """
+        Failed
+
+        This craft recipe build request failed.
+        """
+    )
+
+    COMPLETED = Item(
+        """
+        Completed
+
+        This craft recipe build request completed successfully.
+        """
+    )
+
+
+class ICraftRecipeBuildRequest(Interface):
+    """A request to build a craft 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 ICraftRecipe.
+        Interface,
+        title=_("Craft recipe"),
+        required=True,
+        readonly=True,
+    )
+
+    status = Choice(
+        title=_("Status"),
+        vocabulary=CraftRecipeBuildRequestStatus,
+        required=True,
+        readonly=True,
+    )
+
+    error_message = TextLine(
+        title=_("Error message"), 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 ICraftRecipeView(Interface):
     """`ICraftRecipe` attributes that require launchpad.View permission."""
 
@@ -150,6 +242,29 @@ class ICraftRecipeView(Interface):
     def visibleByUser(user):
         """Can the specified user see this craft recipe?"""
 
+    def requestBuilds(requester, channels=None, architectures=None):
+        """Request that the craft 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 `ICraftRecipeBuildRequest`.
+        """
+
+    def getBuildRequest(job_id):
+        """Get an asynchronous build request by ID.
+
+        :param job_id: The ID of the build request.
+        :return: `ICraftRecipeBuildRequest`.
+        """
+
 
 class ICraftRecipeEdit(Interface):
     """`ICraftRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
new file mode 100644
index 0000000..8a10d2d
--- /dev/null
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -0,0 +1,130 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe job interfaces."""
+
+__all__ = [
+    "ICraftRecipeJob",
+    "ICraftRecipeRequestBuildsJob",
+    "ICraftRecipeRequestBuildsJobSource",
+]
+
+from lazr.restful.fields import Reference
+from zope.interface import Attribute, Interface
+from zope.schema import Datetime, Dict, Set, TextLine
+
+from lp import _
+from lp.crafts.interfaces.craftrecipe import (
+    ICraftRecipe,
+    ICraftRecipeBuildRequest,
+)
+from lp.registry.interfaces.person import IPerson
+from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
+
+
+class ICraftRecipeJob(Interface):
+    """A job related to a craft recipe."""
+
+    job = Reference(
+        title=_("The common Job attributes."),
+        schema=IJob,
+        required=True,
+        readonly=True,
+    )
+
+    recipe = Reference(
+        title=_("The craft recipe to use for this job."),
+        schema=ICraftRecipe,
+        required=True,
+        readonly=True,
+    )
+
+    metadata = Attribute(_("A dict of data about the job."))
+
+
+class ICraftRecipeRequestBuildsJob(IRunnableJob):
+    """A Job that processes a request for builds of a craft 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 "
+            "'craftcraft' 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=ICraftRecipeBuildRequest,
+        required=True,
+        readonly=True,
+    )
+
+
+class ICraftRecipeRequestBuildsJobSource(IJobSource):
+
+    def create(recipe, requester, channels=None, architectures=None):
+        """Request builds of a craft recipe.
+
+        :param recipe: The craft 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 craft recipe.
+
+        :param recipe: A craft 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 `CraftRecipeRequestBuildsJob`s with the
+            specified recipe.
+        """
+
+    def getByRecipeAndID(recipe, job_id):
+        """Get a job by craft recipe and job ID.
+
+        :return: The `CraftRecipeRequestBuildsJob` 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
+            `CraftRecipeJobType.REQUEST_BUILDS`.
+        """
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index ffe3bc4..3bcc371 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -25,15 +25,20 @@ from lp.code.model.reciperegistry import recipe_registry
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
     CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+    CraftRecipeBuildRequestStatus,
     CraftRecipeFeatureDisabled,
     CraftRecipeNotOwner,
     CraftRecipePrivacyMismatch,
     CraftRecipePrivateFeatureDisabled,
     DuplicateCraftRecipeName,
     ICraftRecipe,
+    ICraftRecipeBuildRequest,
     ICraftRecipeSet,
     NoSourceForCraftRecipe,
 )
+from lp.crafts.interfaces.craftrecipejob import (
+    ICraftRecipeRequestBuildsJobSource,
+)
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.person import validate_public_person
 from lp.services.database.constants import DEFAULT, UTC_NOW
@@ -41,6 +46,8 @@ 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 craft_recipe_modified(recipe, event):
@@ -242,6 +249,26 @@ class CraftRecipe(StormBase):
         """See `ICraftRecipe`."""
         IStore(CraftRecipe).remove(self)
 
+    def _checkRequestBuild(self, requester):
+        """May `requester` request builds of this craft recipe?"""
+        if not requester.inTeam(self.owner):
+            raise CraftRecipeNotOwner(
+                "%s cannot create craft recipe builds owned by %s."
+                % (requester.display_name, self.owner.display_name)
+            )
+
+    def requestBuilds(self, requester, channels=None, architectures=None):
+        """See `ICraftRecipe`."""
+        self._checkRequestBuild(requester)
+        job = getUtility(ICraftRecipeRequestBuildsJobSource).create(
+            self, requester, channels=channels, architectures=architectures
+        )
+        return self.getBuildRequest(job.job_id)
+
+    def getBuildRequest(self, job_id):
+        """See `ICraftRecipe`."""
+        return CraftRecipeBuildRequest(self, job_id)
+
 
 @recipe_registry.register_recipe_type(
     ICraftRecipeSet, "Some craft recipes build from this repository."
@@ -358,3 +385,65 @@ class CraftRecipeSet:
         self.findByGitRepository(repository).set(
             git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
         )
+
+
+@implementer(ICraftRecipeBuildRequest)
+class CraftRecipeBuildRequest:
+    """See `ICraftRecipeBuildRequest`.
+
+    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 `ICraftRecipeBuildRequest`."""
+        request = cls(job.recipe, job.job_id)
+        get_property_cache(request)._job = job
+        return request
+
+    @cachedproperty
+    def _job(self):
+        job_source = getUtility(ICraftRecipeRequestBuildsJobSource)
+        return job_source.getByRecipeAndID(self.recipe, self.id)
+
+    @property
+    def date_requested(self):
+        """See `ICraftRecipeBuildRequest`."""
+        return self._job.date_created
+
+    @property
+    def date_finished(self):
+        """See `ICraftRecipeBuildRequest`."""
+        return self._job.date_finished
+
+    @property
+    def status(self):
+        """See `ICraftRecipeBuildRequest`."""
+        status_map = {
+            JobStatus.WAITING: CraftRecipeBuildRequestStatus.PENDING,
+            JobStatus.RUNNING: CraftRecipeBuildRequestStatus.PENDING,
+            JobStatus.COMPLETED: CraftRecipeBuildRequestStatus.COMPLETED,
+            JobStatus.FAILED: CraftRecipeBuildRequestStatus.FAILED,
+            JobStatus.SUSPENDED: CraftRecipeBuildRequestStatus.PENDING,
+        }
+        return status_map[self._job.job.status]
+
+    @property
+    def error_message(self):
+        """See `ICraftRecipeBuildRequest`."""
+        return self._job.error_message
+
+    @property
+    def channels(self):
+        """See `ICraftRecipeBuildRequest`."""
+        return self._job.channels
+
+    @property
+    def architectures(self):
+        """See `ICraftRecipeBuildRequest`."""
+        return self._job.architectures
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
new file mode 100644
index 0000000..e20d29c
--- /dev/null
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -0,0 +1,298 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe jobs."""
+
+__all__ = [
+    "CraftRecipeJob",
+    "CraftRecipeJobType",
+    "CraftRecipeRequestBuildsJob",
+]
+
+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 zope.component import getUtility
+from zope.interface import implementer, provider
+
+from lp.app.errors import NotFoundError
+from lp.crafts.interfaces.craftrecipejob import (
+    ICraftRecipeJob,
+    ICraftRecipeRequestBuildsJob,
+    ICraftRecipeRequestBuildsJobSource,
+)
+from lp.registry.interfaces.person import IPersonSet
+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 CraftRecipeJobType(DBEnumeratedType):
+    """Values that `ICraftRecipeJob.job_type` can take."""
+
+    REQUEST_BUILDS = DBItem(
+        0,
+        """
+        Request builds
+
+        This job requests builds of a craft recipe.
+        """,
+    )
+
+
+@implementer(ICraftRecipeJob)
+class CraftRecipeJob(StormBase):
+    """See `ICraftRecipeJob`."""
+
+    __storm_table__ = "CraftRecipeJob"
+
+    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, "CraftRecipe.id")
+
+    job_type = DBEnum(
+        name="job_type", enum=CraftRecipeJobType, 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 `ICraftRecipe` this job relates to.
+        :param job_type: The `CraftRecipeJobType` 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 CraftRecipeJobDerived.makeSubclass(self)
+
+
+@delegate_to(ICraftRecipeJob)
+class CraftRecipeJobDerived(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/+craft/%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 `CraftRecipeJob` with the specified id, as the current
+            `CraftRecipeJobDerived` 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(CraftRecipeJob).get(CraftRecipeJob, 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(CraftRecipeJob).find(
+            CraftRecipeJob,
+            CraftRecipeJob.job_type == cls.class_job_type,
+            CraftRecipeJob.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(ICraftRecipeRequestBuildsJob)
+@provider(ICraftRecipeRequestBuildsJobSource)
+class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
+    """A Job that processes a request for builds of a craft recipe."""
+
+    class_job_type = CraftRecipeJobType.REQUEST_BUILDS
+
+    max_retries = 5
+
+    config = config.ICraftRecipeRequestBuildsJobSource
+
+    @classmethod
+    def create(cls, recipe, requester, channels=None, architectures=None):
+        """See `ICraftRecipeRequestBuildsJobSource`."""
+        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 = CraftRecipeJob(recipe, cls.class_job_type, metadata)
+        job = cls(recipe_job)
+        job.celeryRunOnCommit()
+        IStore(CraftRecipeJob).flush()
+        return job
+
+    @classmethod
+    def findByRecipe(cls, recipe, statuses=None, job_ids=None):
+        """See `ICraftRecipeRequestBuildsJobSource`."""
+        clauses = [
+            CraftRecipeJob.recipe == recipe,
+            CraftRecipeJob.job_type == cls.class_job_type,
+        ]
+        if statuses is not None:
+            clauses.extend(
+                [
+                    CraftRecipeJob.job == Job.id,
+                    Job._status.is_in(statuses),
+                ]
+            )
+        if job_ids is not None:
+            clauses.append(CraftRecipeJob.job_id.is_in(job_ids))
+        recipe_jobs = (
+            IStore(CraftRecipeJob)
+            .find(CraftRecipeJob, *clauses)
+            .order_by(Desc(CraftRecipeJob.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 `ICraftRecipeRequestBuildsJobSource`."""
+        recipe_job = (
+            IStore(CraftRecipeJob)
+            .find(
+                CraftRecipeJob,
+                CraftRecipeJob.job_id == job_id,
+                CraftRecipeJob.recipe == recipe,
+                CraftRecipeJob.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 `ICraftRecipeRequestBuildsJob`."""
+        requester_id = self.metadata["requester"]
+        return getUtility(IPersonSet).get(requester_id)
+
+    @property
+    def channels(self):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        return self.metadata["channels"]
+
+    @property
+    def architectures(self):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        architectures = self.metadata["architectures"]
+        return set(architectures) if architectures is not None else None
+
+    @property
+    def date_created(self):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        return self.context.job.date_created
+
+    @property
+    def date_finished(self):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        return self.context.job.date_finished
+
+    @property
+    def error_message(self):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        self.metadata["error_message"] = message
+
+    @property
+    def build_request(self):
+        """See `ICraftRecipeRequestBuildsJob`."""
+        return self.recipe.getBuildRequest(self.job.id)
+
+    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 ruinedyourlife 2024-09-25: Implement this once we have a
+            # CraftRecipeBuild 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/crafts/security.py b/lib/lp/crafts/security.py
index 011339a..20920c9 100644
--- a/lib/lp/crafts/security.py
+++ b/lib/lp/crafts/security.py
@@ -5,8 +5,11 @@
 
 __all__ = []
 
-from lp.app.security import AuthorizationBase
-from lp.crafts.interfaces.craftrecipe import ICraftRecipe
+from lp.app.security import AuthorizationBase, DelegatedAuthorization
+from lp.crafts.interfaces.craftrecipe import (
+    ICraftRecipe,
+    ICraftRecipeBuildRequest,
+)
 
 
 class ViewCraftRecipe(AuthorizationBase):
@@ -49,3 +52,11 @@ class AdminCraftRecipe(AuthorizationBase):
         return user.in_ppa_self_admins and EditCraftRecipe(
             self.obj
         ).checkAuthenticated(user)
+
+
+class ViewCraftRecipeBuildRequest(DelegatedAuthorization):
+    permission = "launchpad.View"
+    usedfor = ICraftRecipeBuildRequest
+
+    def __init__(self, obj):
+        super().__init__(obj, obj.recipe, "launchpad.View")
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index c92f9a7..87448f7 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -3,20 +3,34 @@
 
 """Test craft 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.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
+    CraftRecipeBuildRequestStatus,
     CraftRecipeFeatureDisabled,
     CraftRecipePrivateFeatureDisabled,
     ICraftRecipe,
     ICraftRecipeSet,
     NoSourceForCraftRecipe,
 )
+from lp.crafts.interfaces.craftrecipejob import (
+    ICraftRecipeRequestBuildsJobSource,
+)
 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
@@ -97,6 +111,106 @@ class TestCraftRecipe(TestCaseWithFactory):
             getUtility(ICraftRecipeSet).getByName(owner, project, "condemned")
         )
 
+    def test_requestBuilds(self):
+        # requestBuilds schedules a job and returns a corresponding
+        # CraftRecipeBuildRequest.
+        recipe = self.factory.makeCraftRecipe()
+        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(CraftRecipeBuildRequestStatus.PENDING),
+                error_message=Is(None),
+                channels=Is(None),
+                architectures=Is(None),
+            ),
+        )
+        [job] = getUtility(ICraftRecipeRequestBuildsJobSource).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.makeCraftRecipe()
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestBuilds(
+                recipe.owner.teamowner, channels={"craftcraft": "edge"}
+            )
+        self.assertThat(
+            request,
+            MatchesStructure(
+                date_requested=Equals(now),
+                date_finished=Is(None),
+                recipe=Equals(recipe),
+                status=Equals(CraftRecipeBuildRequestStatus.PENDING),
+                error_message=Is(None),
+                channels=MatchesDict({"craftcraft": Equals("edge")}),
+                architectures=Is(None),
+            ),
+        )
+        [job] = getUtility(ICraftRecipeRequestBuildsJobSource).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({"craftcraft": "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.makeCraftRecipe()
+        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(CraftRecipeBuildRequestStatus.PENDING),
+                error_message=Is(None),
+                channels=Is(None),
+                architectures=MatchesSetwise(Equals("amd64"), Equals("i386")),
+            ),
+        )
+        [job] = getUtility(ICraftRecipeRequestBuildsJobSource).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")),
+            ),
+        )
+
 
 class TestCraftRecipeSet(TestCaseWithFactory):
 
diff --git a/lib/lp/crafts/tests/test_craftrecipejob.py b/lib/lp/crafts/tests/test_craftrecipejob.py
new file mode 100644
index 0000000..8066856
--- /dev/null
+++ b/lib/lp/crafts/tests/test_craftrecipejob.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 craft recipe jobs."""
+
+from lp.crafts.interfaces.craftrecipe import CRAFT_RECIPE_ALLOW_CREATE
+from lp.crafts.interfaces.craftrecipejob import (
+    ICraftRecipeJob,
+    ICraftRecipeRequestBuildsJob,
+)
+from lp.crafts.model.craftrecipejob import (
+    CraftRecipeJob,
+    CraftRecipeJobType,
+    CraftRecipeRequestBuildsJob,
+)
+from lp.services.features.testing import FeatureFixture
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestCraftRecipeJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `CraftRecipeJob` objects provide `ICraftRecipeJob`.
+        recipe = self.factory.makeCraftRecipe()
+        self.assertProvides(
+            CraftRecipeJob(recipe, CraftRecipeJobType.REQUEST_BUILDS, {}),
+            ICraftRecipeJob,
+        )
+
+
+class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `CraftRecipeRequestBuildsJob` objects provide
+        # `ICraftRecipeRequestBuildsJob`."""
+        recipe = self.factory.makeCraftRecipe()
+        job = CraftRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+        self.assertProvides(job, ICraftRecipeRequestBuildsJob)
+
+    def test___repr__(self):
+        # `CraftRecipeRequestBuildsJob` objects have an informative __repr__.
+        recipe = self.factory.makeCraftRecipe()
+        job = CraftRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+        self.assertEqual(
+            "<CraftRecipeRequestBuildsJob for ~%s/%s/+craft/%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 c285996..f634d11 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -2000,6 +2000,11 @@ module: lp.rocks.interfaces.rockrecipejob
 dbuser: rock-build-job
 crontab_group: MAIN
 
+[ICraftRecipeRequestBuildsJobSource]
+module: lp.crafts.interfaces.craftrecipejob
+dbuser: craft-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 8d1c299..7260861 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -6966,6 +6966,18 @@ class LaunchpadObjectFactory(ObjectFactory):
         IStore(recipe).flush()
         return recipe
 
+    def makeCraftRecipeBuildRequest(
+        self, recipe=None, requester=None, channels=None, architectures=None
+    ):
+        """Make a new CraftRecipeBuildRequest."""
+        if recipe is None:
+            recipe = self.makeCraftRecipe()
+        if requester is None:
+            requester = recipe.owner.teamowner
+        return recipe.requestBuilds(
+            requester, channels=channels, architectures=architectures
+        )
+
     def makeRockRecipe(
         self,
         registrant=None,