← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:async-request-oci-recipe-build into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:async-request-oci-recipe-build into launchpad:master.

Commit message:
Async API to request builds for OCI Recipe.

This adds a new operation on OCIRecipe's API to requestBuilds, and this will run in background (on Celery) to trigger a build for each available architecture of OCIRecipe. This operation exposes an API to check the status of the build, following the same format we have for Snaps.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This MP should be merged only after https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/382224 is merged and applied to production.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:async-request-oci-recipe-build into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 37369b4..2350744 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -97,7 +97,10 @@ from lp.hardwaredb.interfaces.hwdb import (
     IHWVendorID,
     )
 from lp.oci.interfaces.ocipushrule import IOCIPushRule
-from lp.oci.interfaces.ocirecipe import IOCIRecipe
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeBuildRequest,
+    )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
 from lp.registry.interfaces.commercialsubscription import (
     ICommercialSubscription,
@@ -1110,3 +1113,7 @@ patch_collection_property(IOCIRecipe, 'builds', IOCIRecipeBuild)
 patch_collection_property(IOCIRecipe, 'completed_builds', IOCIRecipeBuild)
 patch_collection_property(IOCIRecipe, 'pending_builds', IOCIRecipeBuild)
 patch_collection_property(IOCIRecipe, 'push_rules', IOCIPushRule)
+
+# IOCIRecipeRequestBuild
+patch_reference_property(IOCIRecipeBuildRequest, 'oci_recipe', IOCIRecipe)
+patch_collection_property(IOCIRecipeBuildRequest, 'builds', IOCIRecipeBuild)
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index 3fbb0f1..7463918 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -66,6 +66,10 @@
             permission="zope.Public" />
 
         <browser:url
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipeBuildRequest"
+            path_expression="string:+build-request/${id}"
+            attribute_to_parent="oci_recipe" />
+        <browser:url
             for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
             path_expression="string:+build/${id}"
             attribute_to_parent="recipe" />
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 42a32a7..a1ff5ae 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -73,6 +73,14 @@ class OCIRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
 
     usedfor = IOCIRecipe
 
+    @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(IOCIRecipeBuildSet, name)
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 565412d..5c96c75 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -41,6 +41,21 @@
             interface="lp.oci.interfaces.ocirecipe.IOCIRecipeSet"/>
     </securedutility>
 
+    <!-- OCIRecipeRequestBuildsJob related classes -->
+    <class class="lp.oci.model.ocirecipe.OCIRecipeBuildRequest">
+        <require
+            permission="launchpad.View"
+            interface="lp.oci.interfaces.ocirecipe.IOCIRecipeBuildRequest" />
+    </class>
+    <class class="lp.oci.model.ocirecipejob.OCIRecipeRequestBuildsJob">
+        <allow interface="lp.oci.interfaces.ocirecipejob.IOCIRecipeRequestBuildsJob" />
+    </class>
+    <securedutility
+        component="lp.oci.model.ocirecipejob.OCIRecipeRequestBuildsJob"
+        provides="lp.oci.interfaces.ocirecipejob.IOCIRecipeRequestBuildsJobSource">
+        <allow interface="lp.oci.interfaces.ocirecipejob.IOCIRecipeRequestBuildsJobSource" />
+    </securedutility>
+
     <!-- OCIRecipeBuild -->
     <class class="lp.oci.model.ocirecipebuild.OCIRecipeBuild">
         <require
diff --git a/lib/lp/oci/enums.py b/lib/lp/oci/enums.py
new file mode 100644
index 0000000..924000e
--- /dev/null
+++ b/lib/lp/oci/enums.py
@@ -0,0 +1,36 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Enums for the OCI app."""
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipeBuildRequestStatus',
+    ]
+
+from lazr.enum import (
+    EnumeratedType,
+    Item,
+    )
+
+
+class OCIRecipeBuildRequestStatus(EnumeratedType):
+    """The status of a request to build an oci recipe."""
+
+    PENDING = Item("""
+        Pending
+
+        This OCI recipe build request is pending.
+        """)
+
+    FAILED = Item("""
+        Failed
+
+        This OCI recipe build request failed.
+        """)
+
+    COMPLETED = Item("""
+        Completed
+
+        This OCI recipe build request completed successfully.
+        """)
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 25a8967..3f25b4d 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -10,23 +10,25 @@ __all__ = [
     'CannotModifyOCIRecipeProcessor',
     'DuplicateOCIRecipeName',
     'IOCIRecipe',
+    'IOCIRecipeBuildRequest',
     'IOCIRecipeEdit',
     'IOCIRecipeEditableAttributes',
     'IOCIRecipeSet',
     'IOCIRecipeView',
     'NoSourceForOCIRecipe',
     'NoSuchOCIRecipe',
-    'OCI_RECIPE_ALLOW_CREATE',
-    'OCI_RECIPE_WEBHOOKS_FEATURE_FLAG',
     'OCIRecipeBuildAlreadyPending',
     'OCIRecipeFeatureDisabled',
     'OCIRecipeNotOwner',
+    'OCI_RECIPE_ALLOW_CREATE',
+    'OCI_RECIPE_WEBHOOKS_FEATURE_FLAG',
     ]
 
 from lazr.restful.declarations import (
     call_with,
     error_status,
     export_as_webservice_entry,
+    export_factory_operation,
     export_write_operation,
     exported,
     operation_for_version,
@@ -45,6 +47,7 @@ from zope.interface import (
     )
 from zope.schema import (
     Bool,
+    Choice,
     Datetime,
     Int,
     List,
@@ -60,9 +63,11 @@ from lp.app.validators.path import path_does_not_escape
 from lp.buildmaster.interfaces.processor import IProcessor
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.oci.enums import OCIRecipeBuildRequestStatus
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.ociproject import IOCIProject
+from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.role import IHasOwner
 from lp.services.database.constants import DEFAULT
 from lp.services.fields import (
@@ -131,6 +136,45 @@ class CannotModifyOCIRecipeProcessor(Exception):
             self._fmt % {'processor': processor.name})
 
 
+class IOCIRecipeBuildRequest(Interface):
+    """A request to build an OCI Recipe."""
+    export_as_webservice_entry(
+        publish_web_link=True, as_of="devel",
+        singular_name="oci_recipe_build_request")
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+
+    date_requested = exported(Datetime(
+        title=_("The time when this request was made"),
+        required=True, readonly=True))
+
+    date_finished = exported(Datetime(
+        title=_("The time when this request finished"),
+        required=False, readonly=True))
+
+    requester = exported(Reference(
+        IPerson, title=_("The IPerson that requested this build"),
+        required=True, readonly=True))
+
+    oci_recipe = exported(Reference(
+        # Really IOCIRecipe, patched in _schema_circular_imports.
+        Interface,
+        title=_("OCI Recipe"), required=True, readonly=True))
+
+    status = exported(Choice(
+        title=_("Status"), vocabulary=OCIRecipeBuildRequestStatus,
+        required=True, readonly=True))
+
+    error_message = exported(TextLine(
+        title=_("Error message"), required=True, readonly=True))
+
+    builds = exported(CollectionField(
+        title=_("Builds produced by this request"),
+        # Really IOCIRecipeBuild, patched in _schema_circular_imports.
+        value_type=Reference(schema=Interface),
+        required=True, readonly=True))
+
+
 class IOCIRecipeView(Interface):
     """`IOCIRecipe` attributes that require launchpad.View permission."""
 
@@ -207,6 +251,21 @@ class IOCIRecipeView(Interface):
         :return: `IOCIRecipeBuild`.
         """
 
+    @call_with(requester=REQUEST_USER)
+    @export_factory_operation(IOCIRecipeBuildRequest, [])
+    @operation_for_version("devel")
+    def requestBuilds(requester):
+        """Request that the OCI recipe is built for all available
+        architectures.
+
+        :param requester: The person requesting the build.
+        :return: A `IOCIRecipeBuildRequest` instance.
+        """
+
+    def getBuildRequest(job_id):
+        """Get a OCIRecipeBuildRequest object for the given job_id.
+        """
+
     push_rules = CollectionField(
         title=_("Push rules for this OCI recipe."),
         description=_("All of the push rules for registry upload "
diff --git a/lib/lp/oci/interfaces/ocirecipejob.py b/lib/lp/oci/interfaces/ocirecipejob.py
new file mode 100644
index 0000000..fec02f9
--- /dev/null
+++ b/lib/lp/oci/interfaces/ocirecipejob.py
@@ -0,0 +1,88 @@
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces related to OCI recipe jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IOCIRecipeJob',
+    'IOCIRecipeRequestBuildsJob',
+    'IOCIRecipeRequestBuildsJobSource',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    List,
+    TextLine,
+    )
+
+from lp import _
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeBuildRequest,
+    )
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+from lp.registry.interfaces.person import IPerson
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+
+
+class IOCIRecipeJob(Interface):
+    """A job related to an OCI Recipe."""
+
+    job = Reference(
+        title=_("The common Job attributes."), schema=IJob,
+        required=True, readonly=True)
+
+    oci_recipe = Reference(
+        title=_("The OCI recipe to use for this job."),
+        schema=IOCIRecipe, required=True, readonly=True)
+
+    metadata = Attribute(_("A dict of data about the job."))
+
+
+class IOCIRecipeRequestBuildsJob(IRunnableJob):
+    """A Job that processes a request for builds of an OCI recipe."""
+
+    requester = Reference(
+        title=_("The person requesting the builds."), schema=IPerson,
+        required=True, readonly=True)
+
+    oci_recipe = Reference(
+        title=_("The OCI Recipe being built."), schema=IOCIRecipe,
+        required=True, readonly=True)
+
+    build_request = Reference(
+        title=_("The build request corresponding to this job."),
+        schema=IOCIRecipeBuildRequest, required=True, readonly=True)
+
+    builds = List(
+        title=_("The builds created by this request."),
+        value_type=Reference(schema=IOCIRecipeBuild), required=True,
+        readonly=True)
+
+    error_message = TextLine(
+        title=_("Error message"), required=True, readonly=True)
+
+
+class IOCIRecipeRequestBuildsJobSource(IJobSource):
+
+    def create(oci_recipe, requester):
+        """Request builds of an OCI Recipe.
+
+        :param oci_recipe: The OCI Recipe to build.
+        :param requester: The person requesting the builds.
+        """
+
+    def findByOCIRecipeAndID(self, oci_recipe, job_id):
+        """Retrieve the build job by OCI recipe and the given job ID.
+        """
diff --git a/lib/lp/oci/interfaces/webservice.py b/lib/lp/oci/interfaces/webservice.py
index 0a6933b..35f64ff 100644
--- a/lib/lp/oci/interfaces/webservice.py
+++ b/lib/lp/oci/interfaces/webservice.py
@@ -7,8 +7,12 @@ __all__ = [
     'IOCIProject',
     'IOCIProjectSeries',
     'IOCIRecipe',
+    'IOCIRecipeBuildRequest'
     ]
 
-from lp.oci.interfaces.ocirecipe import IOCIRecipe
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeBuildRequest,
+    )
 from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 4e3273a..4c22faf 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -8,10 +8,10 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 __all__ = [
     'OCIRecipe',
+    'OCIRecipeBuildRequest',
     'OCIRecipeSet',
     ]
 
-
 from lazr.lifecycle.event import ObjectCreatedEvent
 import pytz
 from storm.expr import (
@@ -42,10 +42,12 @@ from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.buildmaster.model.processor import Processor
+from lp.oci.enums import OCIRecipeBuildRequestStatus
 from lp.oci.interfaces.ocirecipe import (
     CannotModifyOCIRecipeProcessor,
     DuplicateOCIRecipeName,
     IOCIRecipe,
+    IOCIRecipeBuildRequest,
     IOCIRecipeSet,
     NoSourceForOCIRecipe,
     NoSuchOCIRecipe,
@@ -55,6 +57,7 @@ from lp.oci.interfaces.ocirecipe import (
     OCIRecipeNotOwner,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
 from lp.oci.model.ocipushrule import OCIPushRule
 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
 from lp.registry.interfaces.person import IPersonSet
@@ -75,6 +78,8 @@ from lp.services.database.stormexpr import (
     NullsLast,
     )
 from lp.services.features import getFeatureFlag
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import cachedproperty
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.model import WebhookTargetMixin
 from lp.soyuz.model.distroarchseries import DistroArchSeries
@@ -296,23 +301,50 @@ class OCIRecipe(Storm, WebhookTargetMixin):
                 "%s cannot create OCI image builds owned by %s." %
                 (requester.display_name, self.owner.display_name))
 
-    def requestBuild(self, requester, distro_arch_series):
-        self._checkRequestBuild(requester)
-
+    def _hasPendingBuilds(self, processors):
+        """Checks if this OCIRecipe has pending builds for any of the given
+        processors."""
         pending = IStore(self).find(
             OCIRecipeBuild,
             OCIRecipeBuild.recipe == self.id,
-            OCIRecipeBuild.processor == distro_arch_series.processor,
+            OCIRecipeBuild.processor_id.is_in([i.id for i in processors]),
             OCIRecipeBuild.status == BuildStatus.NEEDSBUILD)
-        if pending.any() is not None:
-            raise OCIRecipeBuildAlreadyPending
+        return pending.any() is not None
 
+    def _createBuild(self, requester, distro_arch_series):
+        """Creates a build without checking anything."""
         build = getUtility(IOCIRecipeBuildSet).new(
             requester, self, distro_arch_series)
         build.queueBuild()
         notify(ObjectCreatedEvent(build, user=requester))
         return build
 
+    def requestBuild(self, requester, distro_arch_series):
+        self._checkRequestBuild(requester)
+        if self._hasPendingBuilds([distro_arch_series.processor]):
+            raise OCIRecipeBuildAlreadyPending
+
+        return self._createBuild(requester, distro_arch_series)
+
+    def getBuildRequest(self, job_id):
+        return OCIRecipeBuildRequest(self, job_id)
+
+    def requestBuildsFromJob(self, requester):
+        self._checkRequestBuild(requester)
+        processors = self.available_processors
+        if self._hasPendingBuilds(processors):
+            raise OCIRecipeBuildAlreadyPending
+
+        builds = []
+        for distro_arch_series in self.oci_project.distribution.architectures:
+            builds.append(self._createBuild(requester, distro_arch_series))
+        return builds
+
+    def requestBuilds(self, requester):
+        job = getUtility(IOCIRecipeRequestBuildsJobSource).create(
+            self, requester)
+        return self.getBuildRequest(job.job_id)
+
     @property
     def push_rules(self):
         rules = IStore(self).find(
@@ -483,3 +515,47 @@ class OCIRecipeSet:
 
         list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
             person_ids, need_validity=True))
+
+
+@implementer(IOCIRecipeBuildRequest)
+class OCIRecipeBuildRequest:
+    def __init__(self, oci_recipe, id):
+        self.oci_recipe = oci_recipe
+        self.id = id
+
+    @cachedproperty
+    def job(self):
+        util = getUtility(IOCIRecipeRequestBuildsJobSource)
+        return util.findByOCIRecipeAndID(
+            self.oci_recipe, self.id)
+
+    @property
+    def date_requested(self):
+        return self.job.date_created
+
+    @property
+    def date_finished(self):
+        return self.job.date_finished
+
+    @property
+    def status(self):
+        status_map = {
+            JobStatus.WAITING: OCIRecipeBuildRequestStatus.PENDING,
+            JobStatus.RUNNING: OCIRecipeBuildRequestStatus.PENDING,
+            JobStatus.COMPLETED: OCIRecipeBuildRequestStatus.COMPLETED,
+            JobStatus.FAILED: OCIRecipeBuildRequestStatus.FAILED,
+            JobStatus.SUSPENDED: OCIRecipeBuildRequestStatus.PENDING,
+        }
+        return status_map[self.job.status]
+
+    @property
+    def error_message(self):
+        return self.job.error_message
+
+    @property
+    def builds(self):
+        return self.job.builds
+
+    @property
+    def requester(self):
+        return self.job.requester
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 21c1cce..076631f 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -158,7 +158,7 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
     def title(self):
         # XXX cjwatson 2020-02-19: This should use a DAS architecture tag
         # rather than a processor name once we can do that.
-        return "%s build of ~%s/%s/+oci/%s/+recipe/%s" % (
+        return "%s build of /~%s/%s/+oci/%s/+recipe/%s" % (
             self.processor.name, self.recipe.owner.name,
             self.recipe.oci_project.pillar.name, self.recipe.oci_project.name,
             self.recipe.name)
diff --git a/lib/lp/oci/model/ocirecipejob.py b/lib/lp/oci/model/ocirecipejob.py
new file mode 100644
index 0000000..87c233c
--- /dev/null
+++ b/lib/lp/oci/model/ocirecipejob.py
@@ -0,0 +1,256 @@
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A build job for OCI Recipe."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    ]
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+    DBEnumeratedType,
+    DBItem,
+    )
+from storm.properties import (
+    Int,
+    JSON,
+    )
+from storm.references import Reference
+from storm.store import EmptyResultSet
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+    implementer,
+    provider,
+    )
+
+from lp.app.errors import NotFoundError
+from lp.oci.interfaces.ocirecipejob import (
+    IOCIRecipeJob,
+    IOCIRecipeRequestBuildsJob,
+    IOCIRecipeRequestBuildsJobSource,
+    )
+from lp.oci.model.ocirecipebuild import OCIRecipeBuild
+from lp.registry.interfaces.person import IPersonSet
+from lp.services.config import config
+from lp.services.database.enumcol import EnumCol
+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 OCIRecipeJobType(DBEnumeratedType):
+    """Values that `IOCIRecipeJob.job_type` can take."""
+
+    REQUEST_BUILDS = DBItem(0, """
+        Request builds
+
+        This job requests builds of an OCI recipe.
+        """)
+
+
+@implementer(IOCIRecipeJob)
+class OCIRecipeJob(StormBase):
+    """See `IOCIRecipeJob`."""
+
+    __storm_table__ = 'OCIRecipeJob'
+
+    job_id = Int(name='job', primary=True, allow_none=False)
+    job = Reference(job_id, 'Job.id')
+
+    oci_recipe_id = Int(name='oci_recipe', allow_none=False)
+    oci_recipe = Reference(oci_recipe_id, 'OCIRecipe.id')
+
+    job_type = EnumCol(enum=OCIRecipeJobType, notNull=True)
+
+    metadata = JSON('json_data', allow_none=False)
+
+    def __init__(self, oci_recipe, job_type, metadata, **job_args):
+        """Constructor.
+
+        Extra keyword arguments are used to construct the underlying Job
+        object.
+
+        :param oci_recipe: The `IOCIRecipe` this job relates to.
+        :param job_type: The `OCIRecipeJobType` of this job.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(OCIRecipeJob, self).__init__()
+        self.job = Job(**job_args)
+        self.oci_recipe = oci_recipe
+        self.job_type = job_type
+        self.metadata = metadata
+
+    def makeDerived(self):
+        return OCIRecipeJobDerived.makeSubclass(self)
+
+
+@delegate_to(IOCIRecipeJob)
+class OCIRecipeJobDerived(BaseRunnableJob):
+
+    __metaclass__ = EnumeratedSubclass
+
+    def __init__(self, oci_recipe_job):
+        self.context = oci_recipe_job
+
+    def __repr__(self):
+        """An informative representation of the job."""
+        return "<%s for %s>" % (
+            self.__class__.__name__, self.oci_recipe)
+
+    @classmethod
+    def get(cls, job_id):
+        """Get a job by id.
+
+        :return: The `IOCIRecipeJob` with the specified id, as the current
+            `IOCIRecipeJobDerived` subclass.
+        :raises: `NotFoundError` if there is no job with the specified id,
+            or its `job_type` does not match the desired subclass.
+        """
+        oci_recipe_job = IStore(IOCIRecipeJob).get(IOCIRecipeJob, job_id)
+        if oci_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(oci_recipe_job)
+
+    @classmethod
+    def iterReady(cls):
+        """See `IJobSource`."""
+        jobs = IMasterStore(OCIRecipeJob).find(
+            OCIRecipeJob,
+            OCIRecipeJob.job_type == cls.class_job_type,
+            OCIRecipeJob.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(OCIRecipeJobDerived, self).getOopsVars()
+        oops_vars.extend([
+            ("job_id", self.context.job.id),
+            ("job_type", self.context.job_type.title),
+            ("oci_recipe_owner_name", self.context.oci_recipe.owner.name),
+            ("oci_recipe_name", self.context.oci_recipe.name),
+            ])
+        return oops_vars
+
+
+@implementer(IOCIRecipeRequestBuildsJob)
+@provider(IOCIRecipeRequestBuildsJobSource)
+class OCIRecipeRequestBuildsJob(OCIRecipeJobDerived):
+    """A Job that processes a request for builds of an OCI Recipe package."""
+
+    class_job_type = OCIRecipeJobType.REQUEST_BUILDS
+
+    user_error_types = tuple()
+    retry_error_types = tuple()
+
+    max_retries = 5
+
+    @classmethod
+    def create(cls, oci_recipe, requester):
+        """See `OCIRecipeRequestBuildsJob`."""
+        metadata = {"requester": requester.id}
+        oci_recipe_job = OCIRecipeJob(oci_recipe, cls.class_job_type, metadata)
+        job = cls(oci_recipe_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @classmethod
+    def findByOCIRecipeAndID(cls, oci_recipe, job_id):
+        job = IStore(OCIRecipeJob).find(
+            OCIRecipeJob,
+            OCIRecipeJob.oci_recipe == oci_recipe,
+            OCIRecipeJob.job_id == job_id).one()
+        if job is None:
+            raise NotFoundError("Could not find job ID %s" % job_id)
+        return cls(job)
+
+    def getOperationDescription(self):
+        return "requesting builds of %s" % self.oci_recipe
+
+    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 `OCIRecipeRequestBuildsJob`."""
+        requester_id = self.metadata["requester"]
+        return getUtility(IPersonSet).get(requester_id)
+
+    @property
+    def date_created(self):
+        """See `OCIRecipeRequestBuildsJob`."""
+        return self.context.job.date_created
+
+    @property
+    def date_finished(self):
+        """See `OCIRecipeRequestBuildsJob`."""
+        return self.context.job.date_finished
+
+    @property
+    def error_message(self):
+        """See `OCIRecipeRequestBuildsJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `OCIRecipeRequestBuildsJob`."""
+        self.metadata["error_message"] = message
+
+    @property
+    def build_request(self):
+        """See `OCIRecipeRequestBuildsJob`."""
+        return self.oci_recipe.getBuildRequest(self.job.id)
+
+    @property
+    def builds(self):
+        """See `OCIRecipeRequestBuildsJob`."""
+        build_ids = self.metadata.get("builds")
+        if build_ids:
+            return IStore(OCIRecipeBuild).find(
+                OCIRecipeBuild, OCIRecipeBuild.id.is_in(build_ids))
+        else:
+            return EmptyResultSet()
+
+    @builds.setter
+    def builds(self, builds):
+        """See `OCIRecipeRequestBuildsJob`."""
+        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:
+            self.builds = self.oci_recipe.requestBuildsFromJob(requester)
+            self.error_message = None
+        except self.retry_error_types:
+            raise
+        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/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index e158e2e..d13ed94 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -8,7 +8,10 @@ from __future__ import absolute_import, print_function, unicode_literals
 import base64
 import json
 
-from fixtures import FakeLogger
+from fixtures import (
+    FakeLogger,
+    MockPatch,
+    )
 from nacl.public import PrivateKey
 from six import string_types
 from storm.exceptions import LostObjectError
@@ -16,6 +19,7 @@ from testtools.matchers import (
     ContainsDict,
     Equals,
     MatchesDict,
+    MatchesSetwise,
     MatchesStructure,
     )
 import transaction
@@ -34,9 +38,11 @@ from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     OCIRecipeBuildAlreadyPending,
+    OCIRecipeBuildRequestStatus,
     OCIRecipeNotOwner,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.database.constants import (
     ONE_DAY_AGO,
@@ -55,7 +61,10 @@ from lp.testing import (
     TestCaseWithFactory,
     )
 from lp.testing.dbuser import dbuser
-from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
 from lp.testing.pages import webservice_for_person
 
 
@@ -94,6 +103,60 @@ class TestOCIRecipe(TestCaseWithFactory):
             ocirecipe._checkRequestBuild,
             unrelated_person)
 
+    def getDistroArchSeries(self, distroseries, proc_name="386",
+                            arch_tag="i386"):
+        processor = getUtility(IProcessorSet).getByName(proc_name)
+        return self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag=arch_tag,
+            processor=processor)
+
+    def test_hasPendingBuilds(self):
+        ocirecipe = removeSecurityProxy(self.factory.makeOCIRecipe())
+        distro = ocirecipe.oci_project.distribution
+        series = self.factory.makeDistroSeries(
+            distribution=distro, status=SeriesStatus.CURRENT)
+
+        arch_series_386 = self.getDistroArchSeries(series, "386", "386")
+        arch_series_amd64 = self.getDistroArchSeries(series, "amd64", "amd64")
+        proc_i386 = arch_series_386.processor
+        proc_amd64 = arch_series_amd64.processor
+
+        # Successful build (i386)
+        self.factory.makeOCIRecipeBuild(
+            recipe=ocirecipe, status=BuildStatus.FULLYBUILT,
+            distro_arch_series=arch_series_386)
+        # Failed build (i386)
+        self.factory.makeOCIRecipeBuild(
+            recipe=ocirecipe, status=BuildStatus.FAILEDTOBUILD,
+            distro_arch_series=arch_series_386)
+        # Building build (i386)
+        self.factory.makeOCIRecipeBuild(
+            recipe=ocirecipe, status=BuildStatus.BUILDING,
+            distro_arch_series=arch_series_386)
+        # Building build (amd64)
+        self.factory.makeOCIRecipeBuild(
+            recipe=ocirecipe, status=BuildStatus.BUILDING,
+            distro_arch_series=arch_series_amd64)
+
+        self.assertFalse(
+            ocirecipe._hasPendingBuilds([proc_i386]))
+        self.assertFalse(
+            ocirecipe._hasPendingBuilds([proc_amd64]))
+        self.assertFalse(
+            ocirecipe._hasPendingBuilds([proc_i386, proc_amd64]))
+
+        # The only pending build, for i386.
+        self.factory.makeOCIRecipeBuild(
+            recipe=ocirecipe, status=BuildStatus.NEEDSBUILD,
+            distro_arch_series=arch_series_386)
+
+        self.assertTrue(
+            ocirecipe._hasPendingBuilds([proc_i386]))
+        self.assertFalse(
+            ocirecipe._hasPendingBuilds([proc_amd64]))
+        self.assertTrue(
+            ocirecipe._hasPendingBuilds([proc_i386, proc_amd64]))
+
     def test_requestBuild(self):
         ocirecipe = self.factory.makeOCIRecipe()
         oci_arch = self.factory.makeOCIRecipeArch(recipe=ocirecipe)
@@ -145,6 +208,53 @@ class TestOCIRecipe(TestCaseWithFactory):
                         (hook, "oci-recipe:build:0.1",
                          MatchesDict(expected_payload))]))
 
+    def test_requestBuildsFromJob_creates_builds(self):
+        ocirecipe = removeSecurityProxy(self.factory.makeOCIRecipe())
+        owner = ocirecipe.owner
+        distro = ocirecipe.oci_project.distribution
+        series = self.factory.makeDistroSeries(
+            distribution=distro, status=SeriesStatus.CURRENT)
+
+        arch_series_386 = self.getDistroArchSeries(series, "386", "386")
+        arch_series_amd64 = self.getDistroArchSeries(series, "amd64", "amd64")
+
+        with person_logged_in(owner):
+            builds = ocirecipe.requestBuildsFromJob(owner)
+            self.assertThat(builds, MatchesSetwise(
+                MatchesStructure(
+                    recipe=Equals(ocirecipe),
+                    processor=Equals(arch_series_386.processor)),
+                MatchesStructure(
+                    recipe=Equals(ocirecipe),
+                    processor=Equals(arch_series_amd64.processor))
+            ))
+
+    def test_requestBuildsFromJob_unauthorized_user(self):
+        ocirecipe = removeSecurityProxy(self.factory.makeOCIRecipe())
+        another_user = self.factory.makePerson()
+        with person_logged_in(another_user):
+            self.assertRaises(
+                OCIRecipeNotOwner,
+                ocirecipe.requestBuildsFromJob, another_user)
+
+    def test_requestBuildsFromJob_with_pending_jobs(self):
+        ocirecipe = removeSecurityProxy(self.factory.makeOCIRecipe())
+        distro = ocirecipe.oci_project.distribution
+        series = self.factory.makeDistroSeries(
+            distribution=distro, status=SeriesStatus.CURRENT)
+
+        arch_series_386 = self.getDistroArchSeries(series, "386", "386")
+        self.getDistroArchSeries(series, "amd64", "amd64")
+
+        self.factory.makeOCIRecipeBuild(
+            recipe=ocirecipe, status=BuildStatus.NEEDSBUILD,
+            distro_arch_series=arch_series_386)
+
+        with person_logged_in(ocirecipe.owner):
+            self.assertRaises(
+                OCIRecipeBuildAlreadyPending,
+                ocirecipe.requestBuildsFromJob, ocirecipe.owner)
+
     def test_destroySelf(self):
         oci_recipe = self.factory.makeOCIRecipe()
         build_ids = []
@@ -549,7 +659,8 @@ class TestOCIRecipeWebservice(TestCaseWithFactory):
             recipe_abs_url = self.getAbsoluteURL(recipe)
             self.assertThat(ws_recipe, ContainsDict(dict(
                 date_created=Equals(recipe.date_created.isoformat()),
-                date_last_modified=Equals(recipe.date_last_modified.isoformat()),
+                date_last_modified=Equals(
+                    recipe.date_last_modified.isoformat()),
                 registrant_link=Equals(self.getAbsoluteURL(recipe.registrant)),
                 webhooks_collection_link=Equals(recipe_abs_url + "/webhooks"),
                 name=Equals(recipe.name),
@@ -693,3 +804,84 @@ class TestOCIRecipeWebservice(TestCaseWithFactory):
 
         resp = self.webservice.named_post(oci_project_url, "newRecipe", **obj)
         self.assertEqual(401, resp.status, resp.body)
+
+
+class TestOCIRecipeAsyncWebservice(TestCaseWithFactory):
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIRecipeAsyncWebservice, self).setUp()
+        self.person = self.factory.makePerson(
+            displayname="Test Person")
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel")
+        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+
+    def getDistroArchSeries(self, distroseries, proc_name="386",
+                            arch_tag="i386"):
+        processor = getUtility(IProcessorSet).getByName(proc_name)
+        return self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag=arch_tag,
+            processor=processor)
+
+    def prepareArchSeries(self, ocirecipe):
+        distro = ocirecipe.oci_project.distribution
+        series = self.factory.makeDistroSeries(
+            distribution=distro, status=SeriesStatus.CURRENT)
+
+        return [
+            self.getDistroArchSeries(series, "386", "386"),
+            self.getDistroArchSeries(series, "amd64", "amd64")]
+
+    def test_requestBuilds_creates_builds(self):
+        celeryRunOnCommit = MockPatch(
+            'lp.oci.model.ocirecipejob.OCIRecipeRequestBuildsJob.'
+            'celeryRunOnCommit', lambda instance: instance.run())
+        # This will make the celery task run synchronously during the request.
+        self.useFixture(celeryRunOnCommit)
+
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            oci_project = self.factory.makeOCIProject(
+                registrant=self.person, pillar=distro)
+            oci_recipe = self.factory.makeOCIRecipe(
+                oci_project=oci_project,
+                owner=self.person, registrant=self.person)
+            distro_arch_series = self.prepareArchSeries(oci_recipe)
+            recipe_url = api_url(oci_recipe)
+
+        response = self.webservice.named_post(recipe_url, "requestBuilds")
+        self.assertEqual(201, response.status, response.body)
+
+        build_request_url = response.getHeader("Location")
+        job_id = int(build_request_url.split('/')[-1])
+
+        fmt_date = lambda x: x if x is None else x.isoformat()
+        abs_url = lambda x: self.webservice.getAbsoluteUrl(api_url(x))
+
+        ws_build_request = self.webservice.get(build_request_url).jsonBody()
+        with person_logged_in(self.person):
+            build_request = oci_recipe.getBuildRequest(job_id)
+
+            self.assertThat(ws_build_request, ContainsDict(dict(
+                requester_link=Equals(abs_url(self.person)),
+                oci_recipe_link=Equals(abs_url(build_request.oci_recipe)),
+                status=Equals(OCIRecipeBuildRequestStatus.PENDING.title),
+                date_requested=Equals(fmt_date(build_request.date_requested)),
+                date_finished=Equals(fmt_date(build_request.date_finished)),
+                error_message=Equals(build_request.error_message),
+                builds_collection_link=Equals(build_request_url + '/builds')
+            )))
+
+        builds = self.webservice.get(
+            ws_build_request["builds_collection_link"]).jsonBody()["entries"]
+        with person_logged_in(self.person):
+            self.assertThat(builds, MatchesSetwise(*[
+                ContainsDict({
+                    "buildstate": Equals("Needs building"),
+                    "title": Equals(
+                        "%s build of %s" % (
+                            arch_series.processor.name, api_url(oci_recipe)))
+                })
+                for arch_series in distro_arch_series]))
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 683af3f..4e3984a 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -113,7 +113,10 @@ from lp.hardwaredb.interfaces.hwdb import (
     IHWVendorID,
     )
 from lp.oci.interfaces.ocipushrule import IOCIPushRule
-from lp.oci.interfaces.ocirecipe import IOCIRecipe
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeBuildRequest,
+    )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
 from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentials
 from lp.registry.enums import PersonVisibility
@@ -3476,6 +3479,15 @@ class EditOCIProjectSeries(AuthorizationBase):
                 user.isDriver(self.obj.oci_project.pillar))
 
 
+class ViewOCIRecipeBuildRequest(DelegatedAuthorization):
+    permission = 'launchpad.View'
+    usedfor = IOCIRecipeBuildRequest
+
+    def __init__(self, obj):
+        super(ViewOCIRecipeBuildRequest, self).__init__(
+            obj, obj.oci_recipe, 'launchpad.View')
+
+
 class ViewOCIRecipe(AnonymousAuthorization):
     """Anyone can view an `IOCIRecipe`."""
     usedfor = IOCIRecipe
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index 5664cab..3a4fc2f 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -479,6 +479,18 @@
                 <xsl:text>/+recipe/</xsl:text>
                 <var>&lt;oci_recipe.name&gt;</var>
             </xsl:when>
+            <xsl:when test="@id = 'oci_recipe_build_request'">
+                <xsl:text>/~</xsl:text>
+                <var>&lt;person.name&gt;</var>
+                <xsl:text>/</xsl:text>
+                <var>&lt;distribution.name&gt;</var>
+                <xsl:text>/+oci/</xsl:text>
+                <var>&lt;oci_project.name&gt;</var>
+                <xsl:text>/+recipe/</xsl:text>
+                <var>&lt;oci_recipe.name&gt;</var>
+                <xsl:text>/+build-request/</xsl:text>
+                <var>&lt;build_request.id&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'team' or @id = 'person'">
                 <xsl:text>/~</xsl:text>
                 <var>&lt;name&gt;</var>

Follow ups