← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-recipe-job into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-job into launchpad:master with ~cjwatson/launchpad:charm-recipe-model as a prerequisite.

Commit message:
Add basic model for charm recipe jobs

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This job is a placeholder and doesn't actually do anything yet: we'll need a model for charm recipe builds first.  I'm adding the job first because I want to take the more modern approach of all charm recipe builds having an associated build request.

We'll need to deploy a "charm-build-job" DB user to production before landing this.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-job into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 8225ec9..c67612d 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2777,3 +2777,34 @@ public.teammembership                   = SELECT
 public.teamparticipation                = SELECT
 public.webhook                          = SELECT
 public.webhookjob                       = SELECT, INSERT
+
+[charm-build-job]
+type=user
+groups=script
+public.account                          = SELECT
+public.builder                          = SELECT
+public.buildfarmjob                     = SELECT, INSERT
+public.buildqueue                       = SELECT, INSERT, UPDATE
+public.charmfile                        = SELECT
+public.charmrecipe                      = SELECT, UPDATE
+public.charmrecipebuild                 = SELECT, INSERT, UPDATE
+public.charmrecipebuildjob              = SELECT, UPDATE
+public.charmrecipejob                   = 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/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 87040fa..ddb0b3e 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -39,4 +39,25 @@
         <allow interface="lp.charms.interfaces.charmrecipe.ICharmRecipeSet" />
     </securedutility>
 
+    <!-- CharmRecipeBuildRequest -->
+    <class class="lp.charms.model.charmrecipe.CharmRecipeBuildRequest">
+        <require
+            permission="launchpad.View"
+            interface="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" />
+    </class>
+
+    <!-- Charm-related jobs -->
+    <class class="lp.charms.model.charmrecipejob.CharmRecipeJob">
+        <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
+    </class>
+    <securedutility
+        component="lp.charms.model.charmrecipejob.CharmRecipeRequestBuildsJob"
+        provides="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJobSource">
+        <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJobSource" />
+    </securedutility>
+    <class class="lp.charms.model.charmrecipejob.CharmRecipeRequestBuildsJob">
+        <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
+        <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJob" />
+    </class>
+
 </configure>
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index d4edafb..0422e18 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -11,17 +11,23 @@ __all__ = [
     "BadCharmRecipeSearchContext",
     "CHARM_RECIPE_ALLOW_CREATE",
     "CHARM_RECIPE_PRIVATE_FEATURE_FLAG",
+    "CharmRecipeBuildRequestStatus",
     "CharmRecipeFeatureDisabled",
     "CharmRecipeNotOwner",
     "CharmRecipePrivacyMismatch",
     "CharmRecipePrivateFeatureDisabled",
     "DuplicateCharmRecipeName",
     "ICharmRecipe",
+    "ICharmRecipeBuildRequest",
     "ICharmRecipeSet",
     "NoSourceForCharmRecipe",
     "NoSuchCharmRecipe",
     ]
 
+from lazr.enum import (
+    EnumeratedType,
+    Item,
+    )
 from lazr.restful.declarations import error_status
 from lazr.restful.fields import (
     Reference,
@@ -36,6 +42,7 @@ from zope.schema import (
     Dict,
     Int,
     List,
+    Set,
     Text,
     TextLine,
     )
@@ -128,6 +135,62 @@ class BadCharmRecipeSearchContext(Exception):
     """The context is not valid for a charm recipe search."""
 
 
+class CharmRecipeBuildRequestStatus(EnumeratedType):
+    """The status of a request to build a charm recipe."""
+
+    PENDING = Item("""
+        Pending
+
+        This charm recipe build request is pending.
+        """)
+
+    FAILED = Item("""
+        Failed
+
+        This charm recipe build request failed.
+        """)
+
+    COMPLETED = Item("""
+        Completed
+
+        This charm recipe build request completed successfully.
+        """)
+
+
+class ICharmRecipeBuildRequest(Interface):
+    """A request to build a charm 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 ICharmRecipe.
+        Interface,
+        title=_("Charm recipe"), required=True, readonly=True)
+
+    status = Choice(
+        title=_("Status"), vocabulary=CharmRecipeBuildRequestStatus,
+        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 ICharmRecipeView(Interface):
     """`ICharmRecipe` attributes that require launchpad.View permission."""
 
@@ -156,6 +219,29 @@ class ICharmRecipeView(Interface):
     def visibleByUser(user):
         """Can the specified user see this charm recipe?"""
 
+    def requestBuilds(requester, channels=None, architectures=None):
+        """Request that the charm 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 `ICharmRecipeBuildRequest`.
+        """
+
+    def getBuildRequest(job_id):
+        """Get an asynchronous build request by ID.
+
+        :param job_id: The ID of the build request.
+        :return: `ICharmRecipeBuildRequest`.
+        """
+
 
 class ICharmRecipeEdit(Interface):
     """`ICharmRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
new file mode 100644
index 0000000..7d0eaf7
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmrecipejob.py
@@ -0,0 +1,122 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe job interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "ICharmRecipeJob",
+    "ICharmRecipeRequestBuildsJob",
+    "ICharmRecipeRequestBuildsJobSource",
+    ]
+
+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.charms.interfaces.charmrecipe import (
+    ICharmRecipe,
+    ICharmRecipeBuildRequest,
+    )
+from lp.registry.interfaces.person import IPerson
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+
+
+class ICharmRecipeJob(Interface):
+    """A job related to a charm recipe."""
+
+    job = Reference(
+        title=_("The common Job attributes."), schema=IJob,
+        required=True, readonly=True)
+
+    recipe = Reference(
+        title=_("The charm recipe to use for this job."),
+        schema=ICharmRecipe, required=True, readonly=True)
+
+    metadata = Attribute(_("A dict of data about the job."))
+
+
+class ICharmRecipeRequestBuildsJob(IRunnableJob):
+    """A Job that processes a request for builds of a charm 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 "
+            "'charmcraft' 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=ICharmRecipeBuildRequest, required=True, readonly=True)
+
+
+class ICharmRecipeRequestBuildsJobSource(IJobSource):
+
+    def create(recipe, requester, channels=None, architectures=None):
+        """Request builds of a charm recipe.
+
+        :param recipe: The charm 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 charm recipe.
+
+        :param recipe: A charm 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 `CharmRecipeRequestBuildsJob`s with the
+            specified recipe.
+        """
+
+    def getByRecipeAndID(recipe, job_id):
+        """Get a job by charm recipe and job ID.
+
+        :return: The `CharmRecipeRequestBuildsJob` 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
+            `CharmRecipeJobType.REQUEST_BUILDS`.
+        """
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 27c0ff5..65cfbc7 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -31,15 +31,20 @@ from lp.app.enums import (
 from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+    CharmRecipeBuildRequestStatus,
     CharmRecipeFeatureDisabled,
     CharmRecipeNotOwner,
     CharmRecipePrivacyMismatch,
     CharmRecipePrivateFeatureDisabled,
     DuplicateCharmRecipeName,
     ICharmRecipe,
+    ICharmRecipeBuildRequest,
     ICharmRecipeSet,
     NoSourceForCharmRecipe,
     )
+from lp.charms.interfaces.charmrecipejob import (
+    ICharmRecipeRequestBuildsJobSource,
+    )
 from lp.code.model.gitrepository import GitRepository
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.person import validate_public_person
@@ -54,6 +59,11 @@ from lp.services.database.interfaces import (
     )
 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 charm_recipe_modified(recipe, event):
@@ -65,6 +75,68 @@ def charm_recipe_modified(recipe, event):
     removeSecurityProxy(recipe).date_last_modified = UTC_NOW
 
 
+@implementer(ICharmRecipeBuildRequest)
+class CharmRecipeBuildRequest:
+    """See `ICharmRecipeBuildRequest`.
+
+    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 `ICharmRecipeBuildRequest`."""
+        request = cls(job.recipe, job.job_id)
+        get_property_cache(request)._job = job
+        return request
+
+    @cachedproperty
+    def _job(self):
+        job_source = getUtility(ICharmRecipeRequestBuildsJobSource)
+        return job_source.getByRecipeAndID(self.recipe, self.id)
+
+    @property
+    def date_requested(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.date_created
+
+    @property
+    def date_finished(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.date_finished
+
+    @property
+    def status(self):
+        """See `ICharmRecipeBuildRequest`."""
+        status_map = {
+            JobStatus.WAITING: CharmRecipeBuildRequestStatus.PENDING,
+            JobStatus.RUNNING: CharmRecipeBuildRequestStatus.PENDING,
+            JobStatus.COMPLETED: CharmRecipeBuildRequestStatus.COMPLETED,
+            JobStatus.FAILED: CharmRecipeBuildRequestStatus.FAILED,
+            JobStatus.SUSPENDED: CharmRecipeBuildRequestStatus.PENDING,
+            }
+        return status_map[self._job.job.status]
+
+    @property
+    def error_message(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.error_message
+
+    @property
+    def channels(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.channels
+
+    @property
+    def architectures(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.architectures
+
+
 @implementer(ICharmRecipe)
 class CharmRecipe(StormBase):
     """See `ICharmRecipe`."""
@@ -217,6 +289,24 @@ class CharmRecipe(StormBase):
         # more privacy infrastructure.
         return False
 
+    def _checkRequestBuild(self, requester):
+        """May `requester` request builds of this charm recipe?"""
+        if not requester.inTeam(self.owner):
+            raise CharmRecipeNotOwner(
+                "%s cannot create charm recipe builds owned by %s." %
+                (requester.display_name, self.owner.display_name))
+
+    def requestBuilds(self, requester, channels=None, architectures=None):
+        """See `ICharmRecipe`."""
+        self._checkRequestBuild(requester)
+        job = getUtility(ICharmRecipeRequestBuildsJobSource).create(
+            self, requester, channels=channels, architectures=architectures)
+        return self.getBuildRequest(job.job_id)
+
+    def getBuildRequest(self, job_id):
+        """See `ICharmRecipe`."""
+        return CharmRecipeBuildRequest(self, job_id)
+
     def destroySelf(self):
         """See `ICharmRecipe`."""
         IStore(CharmRecipe).remove(self)
diff --git a/lib/lp/charms/model/charmrecipejob.py b/lib/lp/charms/model/charmrecipejob.py
new file mode 100644
index 0000000..7cba226
--- /dev/null
+++ b/lib/lp/charms/model/charmrecipejob.py
@@ -0,0 +1,292 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "CharmRecipeJob",
+    "CharmRecipeJobType",
+    "CharmRecipeRequestBuildsJob",
+    ]
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+    DBEnumeratedType,
+    DBItem,
+    )
+import six
+from storm.databases.postgres import JSON
+from storm.locals import (
+    Desc,
+    Int,
+    Reference,
+    )
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+    implementer,
+    provider,
+    )
+
+from lp.app.errors import NotFoundError
+from lp.charms.interfaces.charmrecipejob import (
+    ICharmRecipeJob,
+    ICharmRecipeRequestBuildsJob,
+    ICharmRecipeRequestBuildsJobSource,
+    )
+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 (
+    IMasterStore,
+    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 CharmRecipeJobType(DBEnumeratedType):
+    """Values that `ICharmRecipeJob.job_type` can take."""
+
+    REQUEST_BUILDS = DBItem(0, """
+        Request builds
+
+        This job requests builds of a charm recipe.
+        """)
+
+
+@implementer(ICharmRecipeJob)
+class CharmRecipeJob(StormBase):
+    """See `ICharmRecipeJob`."""
+
+    __storm_table__ = "CharmRecipeJob"
+
+    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, "CharmRecipe.id")
+
+    job_type = DBEnum(
+        name="job_type", enum=CharmRecipeJobType, 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 `ICharmRecipe` this job relates to.
+        :param job_type: The `CharmRecipeJobType` of this job.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(CharmRecipeJob, self).__init__()
+        self.job = Job(**job_args)
+        self.recipe = recipe
+        self.job_type = job_type
+        self.metadata = metadata
+
+    def makeDerived(self):
+        return CharmRecipeJobDerived.makeSubclass(self)
+
+
+@delegate_to(ICharmRecipeJob)
+class CharmRecipeJobDerived(
+        six.with_metaclass(EnumeratedSubclass, BaseRunnableJob)):
+
+    def __init__(self, recipe_job):
+        self.context = recipe_job
+
+    def __repr__(self):
+        """An informative representation of the job."""
+        return "<%s for ~%s/%s/+charm/%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 `CharmRecipeJob` with the specified id, as the current
+            `CharmRecipeJobDerived` 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(CharmRecipeJob).get(CharmRecipeJob, 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 = IMasterStore(CharmRecipeJob).find(
+            CharmRecipeJob,
+            CharmRecipeJob.job_type == cls.class_job_type,
+            CharmRecipeJob.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(CharmRecipeJobDerived, self).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(ICharmRecipeRequestBuildsJob)
+@provider(ICharmRecipeRequestBuildsJobSource)
+class CharmRecipeRequestBuildsJob(CharmRecipeJobDerived):
+    """A Job that processes a request for builds of a charm recipe."""
+
+    class_job_type = CharmRecipeJobType.REQUEST_BUILDS
+
+    max_retries = 5
+
+    config = config.ICharmRecipeRequestBuildsJobSource
+
+    @classmethod
+    def create(cls, recipe, requester, channels=None, architectures=None):
+        """See `ICharmRecipeRequestBuildsJobSource`."""
+        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 = CharmRecipeJob(recipe, cls.class_job_type, metadata)
+        job = cls(recipe_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @classmethod
+    def findByRecipe(cls, recipe, statuses=None, job_ids=None):
+        """See `ICharmRecipeRequestBuildsJobSource`."""
+        clauses = [
+            CharmRecipeJob.recipe == recipe,
+            CharmRecipeJob.job_type == cls.class_job_type,
+            ]
+        if statuses is not None:
+            clauses.extend([
+                CharmRecipeJob.job == Job.id,
+                Job._status.is_in(statuses),
+                ])
+        if job_ids is not None:
+            clauses.append(CharmRecipeJob.job_id.is_in(job_ids))
+        recipe_jobs = IStore(CharmRecipeJob).find(
+            CharmRecipeJob, *clauses).order_by(Desc(CharmRecipeJob.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 `ICharmRecipeRequestBuildsJobSource`."""
+        recipe_job = IStore(CharmRecipeJob).find(
+            CharmRecipeJob,
+            CharmRecipeJob.job_id == job_id,
+            CharmRecipeJob.recipe == recipe,
+            CharmRecipeJob.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 `ICharmRecipeRequestBuildsJob`."""
+        requester_id = self.metadata["requester"]
+        return getUtility(IPersonSet).get(requester_id)
+
+    @property
+    def channels(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        return self.metadata["channels"]
+
+    @property
+    def architectures(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        architectures = self.metadata["architectures"]
+        return set(architectures) if architectures is not None else None
+
+    @property
+    def date_created(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        return self.context.job.date_created
+
+    @property
+    def date_finished(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        return self.context.job.date_finished
+
+    @property
+    def error_message(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        self.metadata["error_message"] = message
+
+    @property
+    def build_request(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        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 cjwatson 2021-05-28: Implement this once we have a
+            # CharmRecipeBuild 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/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 654cfe3..f6a3758 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -7,23 +7,38 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 
+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.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
+    CharmRecipeBuildRequestStatus,
     CharmRecipeFeatureDisabled,
     CharmRecipePrivateFeatureDisabled,
     ICharmRecipe,
     ICharmRecipeSet,
     NoSourceForCharmRecipe,
     )
+from lp.charms.interfaces.charmrecipejob import (
+    ICharmRecipeRequestBuildsJobSource,
+    )
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 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 (
     admin_logged_in,
@@ -90,6 +105,80 @@ class TestCharmRecipe(TestCaseWithFactory):
         self.assertSqlAttributeEqualsDate(
             recipe, "date_last_modified", UTC_NOW)
 
+    def test_requestBuilds(self):
+        # requestBuilds schedules a job and returns a corresponding
+        # CharmRecipeBuildRequest.
+        recipe = self.factory.makeCharmRecipe()
+        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(CharmRecipeBuildRequestStatus.PENDING),
+            error_message=Is(None),
+            channels=Is(None),
+            architectures=Is(None)))
+        [job] = getUtility(ICharmRecipeRequestBuildsJobSource).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.makeCharmRecipe()
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestBuilds(
+                recipe.owner.teamowner, channels={"charmcraft": "edge"})
+        self.assertThat(request, MatchesStructure(
+            date_requested=Equals(now),
+            date_finished=Is(None),
+            recipe=Equals(recipe),
+            status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+            error_message=Is(None),
+            channels=MatchesDict({"charmcraft": Equals("edge")}),
+            architectures=Is(None)))
+        [job] = getUtility(ICharmRecipeRequestBuildsJobSource).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({"charmcraft": "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.makeCharmRecipe()
+        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(CharmRecipeBuildRequestStatus.PENDING),
+            error_message=Is(None),
+            channels=Is(None),
+            architectures=MatchesSetwise(Equals("amd64"), Equals("i386"))))
+        [job] = getUtility(ICharmRecipeRequestBuildsJobSource).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 charm recipe with no builds can be deleted.
         owner = self.factory.makePerson()
diff --git a/lib/lp/charms/tests/test_charmrecipejob.py b/lib/lp/charms/tests/test_charmrecipejob.py
new file mode 100644
index 0000000..49a9e89
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmrecipejob.py
@@ -0,0 +1,63 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for charm recipe jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.charms.interfaces.charmrecipejob import (
+    ICharmRecipeJob,
+    ICharmRecipeRequestBuildsJob,
+    )
+from lp.charms.model.charmrecipejob import (
+    CharmRecipeJob,
+    CharmRecipeJobType,
+    CharmRecipeRequestBuildsJob,
+    )
+from lp.services.features.testing import FeatureFixture
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestCharmRecipeJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestCharmRecipeJob, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `CharmRecipeJob` objects provide `ICharmRecipeJob`.
+        recipe = self.factory.makeCharmRecipe()
+        self.assertProvides(
+            CharmRecipeJob(recipe, CharmRecipeJobType.REQUEST_BUILDS, {}),
+            ICharmRecipeJob)
+
+
+class TestCharmRecipeRequestBuildsJob(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestCharmRecipeRequestBuildsJob, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `CharmRecipeRequestBuildsJob` objects provide
+        # `ICharmRecipeRequestBuildsJob`."""
+        recipe = self.factory.makeCharmRecipe()
+        job = CharmRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+        self.assertProvides(job, ICharmRecipeRequestBuildsJob)
+
+    def test___repr__(self):
+        # `CharmRecipeRequestBuildsJob` objects have an informative __repr__.
+        recipe = self.factory.makeCharmRecipe()
+        job = CharmRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+        self.assertEqual(
+            "<CharmRecipeRequestBuildsJob for ~%s/%s/+charm/%s>" % (
+                recipe.owner.name, recipe.project.name, recipe.name),
+            repr(job))
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 539a0f5..e11855f 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -65,7 +65,10 @@ from lp.buildmaster.interfaces.builder import (
     )
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJob
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
-from lp.charms.interfaces.charmrecipe import ICharmRecipe
+from lp.charms.interfaces.charmrecipe import (
+    ICharmRecipe,
+    ICharmRecipeBuildRequest,
+    )
 from lp.code.interfaces.branch import (
     IBranch,
     user_has_special_branch_access,
@@ -3656,3 +3659,12 @@ class AdminCharmRecipe(AuthorizationBase):
         return (
             user.in_ppa_self_admins
             and EditCharmRecipe(self.obj).checkAuthenticated(user))
+
+
+class ViewCharmRecipeBuildRequest(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = ICharmRecipeBuildRequest
+
+    def __init__(self, obj):
+        super(ViewCharmRecipeBuildRequest, self).__init__(
+            obj, obj.recipe, 'launchpad.View')
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 752093f..728a9bd 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1833,6 +1833,11 @@ runner_class: TwistedJobRunner
 module: lp.code.interfaces.branchjob
 dbuser: upgrade-branches
 
+[ICharmRecipeRequestBuildsJobSource]
+module: lp.charms.interfaces.charmrecipejob
+dbuser: charm-build-job
+crontab_group: MAIN
+
 [ICommercialExpiredJobSource]
 module: lp.registry.interfaces.productjob
 dbuser: product-job
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index a28676c..1a82f23 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -5150,6 +5150,16 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         IStore(recipe).flush()
         return recipe
 
+    def makeCharmRecipeBuildRequest(self, recipe=None, requester=None,
+                                    channels=None, architectures=None):
+        """Make a new CharmRecipeBuildRequest."""
+        if recipe is None:
+            recipe = self.makeCharmRecipe()
+        if requester is None:
+            requester = recipe.owner.teamowner
+        return recipe.requestBuilds(
+            requester, channels=channels, architectures=architectures)
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by