launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24589
[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><oci_recipe.name></var>
</xsl:when>
+ <xsl:when test="@id = 'oci_recipe_build_request'">
+ <xsl:text>/~</xsl:text>
+ <var><person.name></var>
+ <xsl:text>/</xsl:text>
+ <var><distribution.name></var>
+ <xsl:text>/+oci/</xsl:text>
+ <var><oci_project.name></var>
+ <xsl:text>/+recipe/</xsl:text>
+ <var><oci_recipe.name></var>
+ <xsl:text>/+build-request/</xsl:text>
+ <var><build_request.id></var>
+ </xsl:when>
<xsl:when test="@id = 'team' or @id = 'person'">
<xsl:text>/~</xsl:text>
<var><name></var>
Follow ups