launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31543
[Merge] ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-jobs into launchpad:master
Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-jobs into launchpad:master with ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipes as a prerequisite.
Commit message:
Add basic model for craft recipe jobs
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/473830
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-jobs into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index b6db889..b1d833e 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2985,3 +2985,34 @@ public.teammembership = SELECT
public.teamparticipation = SELECT
public.webhook = SELECT
public.webhookjob = SELECT, INSERT
+
+[craft-build-job]
+type=user
+groups=script
+public.account = SELECT
+public.builder = SELECT
+public.buildfarmjob = SELECT, INSERT
+public.buildqueue = SELECT, INSERT, UPDATE
+public.craftfile = SELECT
+public.craftrecipe = SELECT, UPDATE
+public.craftrecipebuild = SELECT, INSERT, UPDATE
+public.craftrecipebuildjob = SELECT, UPDATE
+public.craftrecipejob = SELECT, UPDATE
+public.distribution = SELECT
+public.distroarchseries = SELECT
+public.distroseries = SELECT
+public.emailaddress = SELECT
+public.gitref = SELECT
+public.gitrepository = SELECT
+public.job = SELECT, INSERT, UPDATE
+public.libraryfilealias = SELECT
+public.libraryfilecontent = SELECT
+public.person = SELECT
+public.personsettings = SELECT
+public.pocketchroot = SELECT
+public.processor = SELECT
+public.product = SELECT
+public.teammembership = SELECT
+public.teamparticipation = SELECT
+public.webhook = SELECT
+public.webhookjob = SELECT, INSERT
\ No newline at end of file
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index fa92a8c..c8ccb59 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -40,4 +40,25 @@
<allow interface="lp.crafts.interfaces.craftrecipe.ICraftRecipeSet" />
</lp:securedutility>
+ <!-- CraftRecipeBuildRequest -->
+ <class class="lp.crafts.model.craftrecipe.CraftRecipeBuildRequest">
+ <require
+ permission="launchpad.View"
+ interface="lp.crafts.interfaces.craftrecipe.ICraftRecipeBuildRequest" />
+ </class>
+
+ <!-- crafts-related jobs -->
+ <class class="lp.crafts.model.craftrecipejob.CraftRecipeJob">
+ <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
+ </class>
+ <lp:securedutility
+ component="lp.crafts.model.craftrecipejob.CraftRecipeRequestBuildsJob"
+ provides="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJobSource">
+ <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJobSource" />
+ </lp:securedutility>
+ <class class="lp.crafts.model.craftrecipejob.CraftRecipeRequestBuildsJob">
+ <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
+ <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJob" />
+ </class>
+
</configure>
\ No newline at end of file
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index af8be8f..ecaf34b 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -8,12 +8,14 @@ __all__ = [
"BadCraftRecipeSearchContext",
"CRAFT_RECIPE_ALLOW_CREATE",
"CRAFT_RECIPE_PRIVATE_FEATURE_FLAG",
+ "CraftRecipeBuildRequestStatus",
"CraftRecipeFeatureDisabled",
"CraftRecipeNotOwner",
"CraftRecipePrivacyMismatch",
"CraftRecipePrivateFeatureDisabled",
"DuplicateCraftRecipeName",
"ICraftRecipe",
+ "ICraftRecipeBuildRequest",
"ICraftRecipeSet",
"NoSourceForCraftRecipe",
"NoSuchCraftRecipe",
@@ -21,10 +23,21 @@ __all__ = [
import http.client
+from lazr.enum import EnumeratedType, Item
from lazr.restful.declarations import error_status, exported
from lazr.restful.fields import Reference, ReferenceChoice
from zope.interface import Interface
-from zope.schema import Bool, Choice, Datetime, Dict, Int, List, Text, TextLine
+from zope.schema import (
+ Bool,
+ Choice,
+ Datetime,
+ Dict,
+ Int,
+ List,
+ Set,
+ Text,
+ TextLine,
+)
from zope.security.interfaces import Unauthorized
from lp import _
@@ -114,6 +127,85 @@ class BadCraftRecipeSearchContext(Exception):
"""The context is not valid for a craft recipe search."""
+class CraftRecipeBuildRequestStatus(EnumeratedType):
+ """The status of a request to build a craft recipe."""
+
+ PENDING = Item(
+ """
+ Pending
+
+ This craft recipe build request is pending.
+ """
+ )
+
+ FAILED = Item(
+ """
+ Failed
+
+ This craft recipe build request failed.
+ """
+ )
+
+ COMPLETED = Item(
+ """
+ Completed
+
+ This craft recipe build request completed successfully.
+ """
+ )
+
+
+class ICraftRecipeBuildRequest(Interface):
+ """A request to build a craft recipe."""
+
+ id = Int(title=_("ID"), required=True, readonly=True)
+
+ date_requested = Datetime(
+ title=_("The time when this request was made"),
+ required=True,
+ readonly=True,
+ )
+
+ date_finished = Datetime(
+ title=_("The time when this request finished"),
+ required=False,
+ readonly=True,
+ )
+
+ recipe = Reference(
+ # Really ICraftRecipe.
+ Interface,
+ title=_("Craft recipe"),
+ required=True,
+ readonly=True,
+ )
+
+ status = Choice(
+ title=_("Status"),
+ vocabulary=CraftRecipeBuildRequestStatus,
+ required=True,
+ readonly=True,
+ )
+
+ error_message = TextLine(
+ title=_("Error message"), required=True, readonly=True
+ )
+
+ channels = Dict(
+ title=_("Source snap channels for builds produced by this request"),
+ key_type=TextLine(),
+ required=False,
+ readonly=True,
+ )
+
+ architectures = Set(
+ title=_("If set, this request is limited to these architecture tags"),
+ value_type=TextLine(),
+ required=False,
+ readonly=True,
+ )
+
+
class ICraftRecipeView(Interface):
"""`ICraftRecipe` attributes that require launchpad.View permission."""
@@ -150,6 +242,29 @@ class ICraftRecipeView(Interface):
def visibleByUser(user):
"""Can the specified user see this craft recipe?"""
+ def requestBuilds(requester, channels=None, architectures=None):
+ """Request that the craft recipe be built.
+
+ This is an asynchronous operation; once the operation has finished,
+ the resulting build request's C{status} will be "Completed" and its
+ C{builds} collection will return the resulting builds.
+
+ :param requester: The person requesting the builds.
+ :param channels: A dictionary mapping snap names to channels to use
+ for these builds.
+ :param architectures: If not None, limit builds to architectures
+ with these architecture tags (in addition to any other
+ applicable constraints).
+ :return: An `ICraftRecipeBuildRequest`.
+ """
+
+ def getBuildRequest(job_id):
+ """Get an asynchronous build request by ID.
+
+ :param job_id: The ID of the build request.
+ :return: `ICraftRecipeBuildRequest`.
+ """
+
class ICraftRecipeEdit(Interface):
"""`ICraftRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
new file mode 100644
index 0000000..8a10d2d
--- /dev/null
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -0,0 +1,130 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe job interfaces."""
+
+__all__ = [
+ "ICraftRecipeJob",
+ "ICraftRecipeRequestBuildsJob",
+ "ICraftRecipeRequestBuildsJobSource",
+]
+
+from lazr.restful.fields import Reference
+from zope.interface import Attribute, Interface
+from zope.schema import Datetime, Dict, Set, TextLine
+
+from lp import _
+from lp.crafts.interfaces.craftrecipe import (
+ ICraftRecipe,
+ ICraftRecipeBuildRequest,
+)
+from lp.registry.interfaces.person import IPerson
+from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
+
+
+class ICraftRecipeJob(Interface):
+ """A job related to a craft recipe."""
+
+ job = Reference(
+ title=_("The common Job attributes."),
+ schema=IJob,
+ required=True,
+ readonly=True,
+ )
+
+ recipe = Reference(
+ title=_("The craft recipe to use for this job."),
+ schema=ICraftRecipe,
+ required=True,
+ readonly=True,
+ )
+
+ metadata = Attribute(_("A dict of data about the job."))
+
+
+class ICraftRecipeRequestBuildsJob(IRunnableJob):
+ """A Job that processes a request for builds of a craft recipe."""
+
+ requester = Reference(
+ title=_("The person requesting the builds."),
+ schema=IPerson,
+ required=True,
+ readonly=True,
+ )
+
+ channels = Dict(
+ title=_("Source snap channels to use for these builds."),
+ description=_(
+ "A dictionary mapping snap names to channels to use for these "
+ "builds. Currently only 'core', 'core18', 'core20', and "
+ "'craftcraft' keys are supported."
+ ),
+ key_type=TextLine(),
+ required=False,
+ readonly=True,
+ )
+
+ architectures = Set(
+ title=_("If set, limit builds to these architecture tags."),
+ value_type=TextLine(),
+ required=False,
+ readonly=True,
+ )
+
+ date_created = Datetime(
+ title=_("Time when this job was created."),
+ required=True,
+ readonly=True,
+ )
+
+ date_finished = Datetime(
+ title=_("Time when this job finished."), required=True, readonly=True
+ )
+
+ error_message = TextLine(
+ title=_("Error message resulting from running this job."),
+ required=False,
+ readonly=True,
+ )
+
+ build_request = Reference(
+ title=_("The build request corresponding to this job."),
+ schema=ICraftRecipeBuildRequest,
+ required=True,
+ readonly=True,
+ )
+
+
+class ICraftRecipeRequestBuildsJobSource(IJobSource):
+
+ def create(recipe, requester, channels=None, architectures=None):
+ """Request builds of a craft recipe.
+
+ :param recipe: The craft recipe to build.
+ :param requester: The person requesting the builds.
+ :param channels: A dictionary mapping snap names to channels to use
+ for these builds.
+ :param architectures: If not None, limit builds to architectures
+ with these architecture tags (in addition to any other
+ applicable constraints).
+ """
+
+ def findByRecipe(recipe, statuses=None, job_ids=None):
+ """Find jobs for a craft recipe.
+
+ :param recipe: A craft recipe to search for.
+ :param statuses: An optional iterable of `JobStatus`es to search for.
+ :param job_ids: An optional iterable of job IDs to search for.
+ :return: A sequence of `CraftRecipeRequestBuildsJob`s with the
+ specified recipe.
+ """
+
+ def getByRecipeAndID(recipe, job_id):
+ """Get a job by craft recipe and job ID.
+
+ :return: The `CraftRecipeRequestBuildsJob` with the specified recipe
+ and ID.
+ :raises: `NotFoundError` if there is no job with the specified
+ recipe and ID, or its `job_type` is not
+ `CraftRecipeJobType.REQUEST_BUILDS`.
+ """
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index ffe3bc4..3bcc371 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -25,15 +25,20 @@ from lp.code.model.reciperegistry import recipe_registry
from lp.crafts.interfaces.craftrecipe import (
CRAFT_RECIPE_ALLOW_CREATE,
CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+ CraftRecipeBuildRequestStatus,
CraftRecipeFeatureDisabled,
CraftRecipeNotOwner,
CraftRecipePrivacyMismatch,
CraftRecipePrivateFeatureDisabled,
DuplicateCraftRecipeName,
ICraftRecipe,
+ ICraftRecipeBuildRequest,
ICraftRecipeSet,
NoSourceForCraftRecipe,
)
+from lp.crafts.interfaces.craftrecipejob import (
+ ICraftRecipeRequestBuildsJobSource,
+)
from lp.registry.errors import PrivatePersonLinkageError
from lp.registry.interfaces.person import validate_public_person
from lp.services.database.constants import DEFAULT, UTC_NOW
@@ -41,6 +46,8 @@ from lp.services.database.enumcol import DBEnum
from lp.services.database.interfaces import IPrimaryStore, IStore
from lp.services.database.stormbase import StormBase
from lp.services.features import getFeatureFlag
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import cachedproperty, get_property_cache
def craft_recipe_modified(recipe, event):
@@ -242,6 +249,26 @@ class CraftRecipe(StormBase):
"""See `ICraftRecipe`."""
IStore(CraftRecipe).remove(self)
+ def _checkRequestBuild(self, requester):
+ """May `requester` request builds of this craft recipe?"""
+ if not requester.inTeam(self.owner):
+ raise CraftRecipeNotOwner(
+ "%s cannot create craft recipe builds owned by %s."
+ % (requester.display_name, self.owner.display_name)
+ )
+
+ def requestBuilds(self, requester, channels=None, architectures=None):
+ """See `ICraftRecipe`."""
+ self._checkRequestBuild(requester)
+ job = getUtility(ICraftRecipeRequestBuildsJobSource).create(
+ self, requester, channels=channels, architectures=architectures
+ )
+ return self.getBuildRequest(job.job_id)
+
+ def getBuildRequest(self, job_id):
+ """See `ICraftRecipe`."""
+ return CraftRecipeBuildRequest(self, job_id)
+
@recipe_registry.register_recipe_type(
ICraftRecipeSet, "Some craft recipes build from this repository."
@@ -358,3 +385,65 @@ class CraftRecipeSet:
self.findByGitRepository(repository).set(
git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
)
+
+
+@implementer(ICraftRecipeBuildRequest)
+class CraftRecipeBuildRequest:
+ """See `ICraftRecipeBuildRequest`.
+
+ This is not directly backed by a database table; instead, it is a
+ webservice-friendly view of an asynchronous build request.
+ """
+
+ def __init__(self, recipe, id):
+ self.recipe = recipe
+ self.id = id
+
+ @classmethod
+ def fromJob(cls, job):
+ """See `ICraftRecipeBuildRequest`."""
+ request = cls(job.recipe, job.job_id)
+ get_property_cache(request)._job = job
+ return request
+
+ @cachedproperty
+ def _job(self):
+ job_source = getUtility(ICraftRecipeRequestBuildsJobSource)
+ return job_source.getByRecipeAndID(self.recipe, self.id)
+
+ @property
+ def date_requested(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.date_created
+
+ @property
+ def date_finished(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.date_finished
+
+ @property
+ def status(self):
+ """See `ICraftRecipeBuildRequest`."""
+ status_map = {
+ JobStatus.WAITING: CraftRecipeBuildRequestStatus.PENDING,
+ JobStatus.RUNNING: CraftRecipeBuildRequestStatus.PENDING,
+ JobStatus.COMPLETED: CraftRecipeBuildRequestStatus.COMPLETED,
+ JobStatus.FAILED: CraftRecipeBuildRequestStatus.FAILED,
+ JobStatus.SUSPENDED: CraftRecipeBuildRequestStatus.PENDING,
+ }
+ return status_map[self._job.job.status]
+
+ @property
+ def error_message(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.error_message
+
+ @property
+ def channels(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.channels
+
+ @property
+ def architectures(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.architectures
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
new file mode 100644
index 0000000..e20d29c
--- /dev/null
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -0,0 +1,298 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe jobs."""
+
+__all__ = [
+ "CraftRecipeJob",
+ "CraftRecipeJobType",
+ "CraftRecipeRequestBuildsJob",
+]
+
+import transaction
+from lazr.delegates import delegate_to
+from lazr.enum import DBEnumeratedType, DBItem
+from storm.databases.postgres import JSON
+from storm.locals import Desc, Int, Reference
+from zope.component import getUtility
+from zope.interface import implementer, provider
+
+from lp.app.errors import NotFoundError
+from lp.crafts.interfaces.craftrecipejob import (
+ ICraftRecipeJob,
+ ICraftRecipeRequestBuildsJob,
+ ICraftRecipeRequestBuildsJobSource,
+)
+from lp.registry.interfaces.person import IPersonSet
+from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import IPrimaryStore, IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import EnumeratedSubclass, Job
+from lp.services.job.runner import BaseRunnableJob
+from lp.services.mail.sendmail import format_address_for_person
+from lp.services.propertycache import cachedproperty
+from lp.services.scripts import log
+
+
+class CraftRecipeJobType(DBEnumeratedType):
+ """Values that `ICraftRecipeJob.job_type` can take."""
+
+ REQUEST_BUILDS = DBItem(
+ 0,
+ """
+ Request builds
+
+ This job requests builds of a craft recipe.
+ """,
+ )
+
+
+@implementer(ICraftRecipeJob)
+class CraftRecipeJob(StormBase):
+ """See `ICraftRecipeJob`."""
+
+ __storm_table__ = "CraftRecipeJob"
+
+ job_id = Int(name="job", primary=True, allow_none=False)
+ job = Reference(job_id, "Job.id")
+
+ recipe_id = Int(name="recipe", allow_none=False)
+ recipe = Reference(recipe_id, "CraftRecipe.id")
+
+ job_type = DBEnum(
+ name="job_type", enum=CraftRecipeJobType, allow_none=False
+ )
+
+ metadata = JSON("json_data", allow_none=False)
+
+ def __init__(self, recipe, job_type, metadata, **job_args):
+ """Constructor.
+
+ Extra keyword arguments are used to construct the underlying Job
+ object.
+
+ :param recipe: The `ICraftRecipe` this job relates to.
+ :param job_type: The `CraftRecipeJobType` of this job.
+ :param metadata: The type-specific variables, as a JSON-compatible
+ dict.
+ """
+ super().__init__()
+ self.job = Job(**job_args)
+ self.recipe = recipe
+ self.job_type = job_type
+ self.metadata = metadata
+
+ def makeDerived(self):
+ return CraftRecipeJobDerived.makeSubclass(self)
+
+
+@delegate_to(ICraftRecipeJob)
+class CraftRecipeJobDerived(BaseRunnableJob, metaclass=EnumeratedSubclass):
+
+ def __init__(self, recipe_job):
+ self.context = recipe_job
+
+ def __repr__(self):
+ """An informative representation of the job."""
+ return "<%s for ~%s/%s/+craft/%s>" % (
+ self.__class__.__name__,
+ self.recipe.owner.name,
+ self.recipe.project.name,
+ self.recipe.name,
+ )
+
+ @classmethod
+ def get(cls, job_id):
+ """Get a job by id.
+
+ :return: The `CraftRecipeJob` with the specified id, as the current
+ `CraftRecipeJobDerived` subclass.
+ :raises: `NotFoundError` if there is no job with the specified id,
+ or its `job_type` does not match the desired subclass.
+ """
+ recipe_job = IStore(CraftRecipeJob).get(CraftRecipeJob, job_id)
+ if recipe_job.job_type != cls.class_job_type:
+ raise NotFoundError(
+ "No object found with id %d and type %s"
+ % (job_id, cls.class_job_type.title)
+ )
+ return cls(recipe_job)
+
+ @classmethod
+ def iterReady(cls):
+ """See `IJobSource`."""
+ jobs = IPrimaryStore(CraftRecipeJob).find(
+ CraftRecipeJob,
+ CraftRecipeJob.job_type == cls.class_job_type,
+ CraftRecipeJob.job == Job.id,
+ Job.id.is_in(Job.ready_jobs),
+ )
+ return (cls(job) for job in jobs)
+
+ def getOopsVars(self):
+ """See `IRunnableJob`."""
+ oops_vars = super().getOopsVars()
+ oops_vars.extend(
+ [
+ ("job_id", self.context.job.id),
+ ("job_type", self.context.job_type.title),
+ ("recipe_owner_name", self.context.recipe.owner.name),
+ ("recipe_project_name", self.context.recipe.project.name),
+ ("recipe_name", self.context.recipe.name),
+ ]
+ )
+ return oops_vars
+
+
+@implementer(ICraftRecipeRequestBuildsJob)
+@provider(ICraftRecipeRequestBuildsJobSource)
+class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
+ """A Job that processes a request for builds of a craft recipe."""
+
+ class_job_type = CraftRecipeJobType.REQUEST_BUILDS
+
+ max_retries = 5
+
+ config = config.ICraftRecipeRequestBuildsJobSource
+
+ @classmethod
+ def create(cls, recipe, requester, channels=None, architectures=None):
+ """See `ICraftRecipeRequestBuildsJobSource`."""
+ metadata = {
+ "requester": requester.id,
+ "channels": channels,
+ # Really a set or None, but sets aren't directly
+ # JSON-serialisable.
+ "architectures": (
+ list(architectures) if architectures is not None else None
+ ),
+ }
+ recipe_job = CraftRecipeJob(recipe, cls.class_job_type, metadata)
+ job = cls(recipe_job)
+ job.celeryRunOnCommit()
+ IStore(CraftRecipeJob).flush()
+ return job
+
+ @classmethod
+ def findByRecipe(cls, recipe, statuses=None, job_ids=None):
+ """See `ICraftRecipeRequestBuildsJobSource`."""
+ clauses = [
+ CraftRecipeJob.recipe == recipe,
+ CraftRecipeJob.job_type == cls.class_job_type,
+ ]
+ if statuses is not None:
+ clauses.extend(
+ [
+ CraftRecipeJob.job == Job.id,
+ Job._status.is_in(statuses),
+ ]
+ )
+ if job_ids is not None:
+ clauses.append(CraftRecipeJob.job_id.is_in(job_ids))
+ recipe_jobs = (
+ IStore(CraftRecipeJob)
+ .find(CraftRecipeJob, *clauses)
+ .order_by(Desc(CraftRecipeJob.job_id))
+ )
+
+ def preload_jobs(rows):
+ load_related(Job, rows, ["job_id"])
+
+ return DecoratedResultSet(
+ recipe_jobs,
+ lambda recipe_job: cls(recipe_job),
+ pre_iter_hook=preload_jobs,
+ )
+
+ @classmethod
+ def getByRecipeAndID(cls, recipe, job_id):
+ """See `ICraftRecipeRequestBuildsJobSource`."""
+ recipe_job = (
+ IStore(CraftRecipeJob)
+ .find(
+ CraftRecipeJob,
+ CraftRecipeJob.job_id == job_id,
+ CraftRecipeJob.recipe == recipe,
+ CraftRecipeJob.job_type == cls.class_job_type,
+ )
+ .one()
+ )
+ if recipe_job is None:
+ raise NotFoundError(
+ "No REQUEST_BUILDS job with ID %d found for %r"
+ % (job_id, recipe)
+ )
+ return cls(recipe_job)
+
+ def getOperationDescription(self):
+ return "requesting builds of %s" % self.recipe.name
+
+ def getErrorRecipients(self):
+ if self.requester is None or self.requester.preferredemail is None:
+ return []
+ return [format_address_for_person(self.requester)]
+
+ @cachedproperty
+ def requester(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ requester_id = self.metadata["requester"]
+ return getUtility(IPersonSet).get(requester_id)
+
+ @property
+ def channels(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ return self.metadata["channels"]
+
+ @property
+ def architectures(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ architectures = self.metadata["architectures"]
+ return set(architectures) if architectures is not None else None
+
+ @property
+ def date_created(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ return self.context.job.date_created
+
+ @property
+ def date_finished(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ return self.context.job.date_finished
+
+ @property
+ def error_message(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ return self.metadata.get("error_message")
+
+ @error_message.setter
+ def error_message(self, message):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ self.metadata["error_message"] = message
+
+ @property
+ def build_request(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ return self.recipe.getBuildRequest(self.job.id)
+
+ def run(self):
+ """See `IRunnableJob`."""
+ requester = self.requester
+ if requester is None:
+ log.info(
+ "Skipping %r because the requester has been deleted." % self
+ )
+ return
+ try:
+ # XXX ruinedyourlife 2024-09-25: Implement this once we have a
+ # CraftRecipeBuild model.
+ raise NotImplementedError
+ except Exception as e:
+ self.error_message = str(e)
+ # The normal job infrastructure will abort the transaction, but
+ # we want to commit instead: the only database changes we make
+ # are to this job's metadata and should be preserved.
+ transaction.commit()
+ raise
diff --git a/lib/lp/crafts/security.py b/lib/lp/crafts/security.py
index 011339a..20920c9 100644
--- a/lib/lp/crafts/security.py
+++ b/lib/lp/crafts/security.py
@@ -5,8 +5,11 @@
__all__ = []
-from lp.app.security import AuthorizationBase
-from lp.crafts.interfaces.craftrecipe import ICraftRecipe
+from lp.app.security import AuthorizationBase, DelegatedAuthorization
+from lp.crafts.interfaces.craftrecipe import (
+ ICraftRecipe,
+ ICraftRecipeBuildRequest,
+)
class ViewCraftRecipe(AuthorizationBase):
@@ -49,3 +52,11 @@ class AdminCraftRecipe(AuthorizationBase):
return user.in_ppa_self_admins and EditCraftRecipe(
self.obj
).checkAuthenticated(user)
+
+
+class ViewCraftRecipeBuildRequest(DelegatedAuthorization):
+ permission = "launchpad.View"
+ usedfor = ICraftRecipeBuildRequest
+
+ def __init__(self, obj):
+ super().__init__(obj, obj.recipe, "launchpad.View")
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index c92f9a7..87448f7 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -3,20 +3,34 @@
"""Test craft recipes."""
+from testtools.matchers import (
+ Equals,
+ Is,
+ MatchesDict,
+ MatchesSetwise,
+ MatchesStructure,
+)
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
from lp.crafts.interfaces.craftrecipe import (
CRAFT_RECIPE_ALLOW_CREATE,
+ CraftRecipeBuildRequestStatus,
CraftRecipeFeatureDisabled,
CraftRecipePrivateFeatureDisabled,
ICraftRecipe,
ICraftRecipeSet,
NoSourceForCraftRecipe,
)
+from lp.crafts.interfaces.craftrecipejob import (
+ ICraftRecipeRequestBuildsJobSource,
+)
from lp.services.database.constants import ONE_DAY_AGO, UTC_NOW
+from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
from lp.services.webapp.snapshot import notify_modified
from lp.testing import TestCaseWithFactory, admin_logged_in, person_logged_in
from lp.testing.layers import DatabaseFunctionalLayer, LaunchpadZopelessLayer
@@ -97,6 +111,106 @@ class TestCraftRecipe(TestCaseWithFactory):
getUtility(ICraftRecipeSet).getByName(owner, project, "condemned")
)
+ def test_requestBuilds(self):
+ # requestBuilds schedules a job and returns a corresponding
+ # CraftRecipeBuildRequest.
+ recipe = self.factory.makeCraftRecipe()
+ now = get_transaction_timestamp(IStore(recipe))
+ with person_logged_in(recipe.owner.teamowner):
+ request = recipe.requestBuilds(recipe.owner.teamowner)
+ self.assertThat(
+ request,
+ MatchesStructure(
+ date_requested=Equals(now),
+ date_finished=Is(None),
+ recipe=Equals(recipe),
+ status=Equals(CraftRecipeBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ channels=Is(None),
+ architectures=Is(None),
+ ),
+ )
+ [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+ self.assertThat(
+ job,
+ MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ recipe=Equals(recipe),
+ requester=Equals(recipe.owner.teamowner),
+ channels=Is(None),
+ architectures=Is(None),
+ ),
+ )
+
+ def test_requestBuilds_with_channels(self):
+ # If asked to build using particular snap channels, requestBuilds
+ # passes those through to the job.
+ recipe = self.factory.makeCraftRecipe()
+ now = get_transaction_timestamp(IStore(recipe))
+ with person_logged_in(recipe.owner.teamowner):
+ request = recipe.requestBuilds(
+ recipe.owner.teamowner, channels={"craftcraft": "edge"}
+ )
+ self.assertThat(
+ request,
+ MatchesStructure(
+ date_requested=Equals(now),
+ date_finished=Is(None),
+ recipe=Equals(recipe),
+ status=Equals(CraftRecipeBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ channels=MatchesDict({"craftcraft": Equals("edge")}),
+ architectures=Is(None),
+ ),
+ )
+ [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+ self.assertThat(
+ job,
+ MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ recipe=Equals(recipe),
+ requester=Equals(recipe.owner.teamowner),
+ channels=Equals({"craftcraft": "edge"}),
+ architectures=Is(None),
+ ),
+ )
+
+ def test_requestBuilds_with_architectures(self):
+ # If asked to build for particular architectures, requestBuilds
+ # passes those through to the job.
+ recipe = self.factory.makeCraftRecipe()
+ now = get_transaction_timestamp(IStore(recipe))
+ with person_logged_in(recipe.owner.teamowner):
+ request = recipe.requestBuilds(
+ recipe.owner.teamowner, architectures={"amd64", "i386"}
+ )
+ self.assertThat(
+ request,
+ MatchesStructure(
+ date_requested=Equals(now),
+ date_finished=Is(None),
+ recipe=Equals(recipe),
+ status=Equals(CraftRecipeBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ channels=Is(None),
+ architectures=MatchesSetwise(Equals("amd64"), Equals("i386")),
+ ),
+ )
+ [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+ self.assertThat(
+ job,
+ MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ recipe=Equals(recipe),
+ requester=Equals(recipe.owner.teamowner),
+ channels=Is(None),
+ architectures=MatchesSetwise(Equals("amd64"), Equals("i386")),
+ ),
+ )
+
class TestCraftRecipeSet(TestCaseWithFactory):
diff --git a/lib/lp/crafts/tests/test_craftrecipejob.py b/lib/lp/crafts/tests/test_craftrecipejob.py
new file mode 100644
index 0000000..8066856
--- /dev/null
+++ b/lib/lp/crafts/tests/test_craftrecipejob.py
@@ -0,0 +1,61 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for craft recipe jobs."""
+
+from lp.crafts.interfaces.craftrecipe import CRAFT_RECIPE_ALLOW_CREATE
+from lp.crafts.interfaces.craftrecipejob import (
+ ICraftRecipeJob,
+ ICraftRecipeRequestBuildsJob,
+)
+from lp.crafts.model.craftrecipejob import (
+ CraftRecipeJob,
+ CraftRecipeJobType,
+ CraftRecipeRequestBuildsJob,
+)
+from lp.services.features.testing import FeatureFixture
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestCraftRecipeJob(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+ def test_provides_interface(self):
+ # `CraftRecipeJob` objects provide `ICraftRecipeJob`.
+ recipe = self.factory.makeCraftRecipe()
+ self.assertProvides(
+ CraftRecipeJob(recipe, CraftRecipeJobType.REQUEST_BUILDS, {}),
+ ICraftRecipeJob,
+ )
+
+
+class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+ def test_provides_interface(self):
+ # `CraftRecipeRequestBuildsJob` objects provide
+ # `ICraftRecipeRequestBuildsJob`."""
+ recipe = self.factory.makeCraftRecipe()
+ job = CraftRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+ self.assertProvides(job, ICraftRecipeRequestBuildsJob)
+
+ def test___repr__(self):
+ # `CraftRecipeRequestBuildsJob` objects have an informative __repr__.
+ recipe = self.factory.makeCraftRecipe()
+ job = CraftRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+ self.assertEqual(
+ "<CraftRecipeRequestBuildsJob for ~%s/%s/+craft/%s>"
+ % (recipe.owner.name, recipe.project.name, recipe.name),
+ repr(job),
+ )
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index c285996..f634d11 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -2000,6 +2000,11 @@ module: lp.rocks.interfaces.rockrecipejob
dbuser: rock-build-job
crontab_group: MAIN
+[ICraftRecipeRequestBuildsJobSource]
+module: lp.crafts.interfaces.craftrecipejob
+dbuser: craft-build-job
+crontab_group: MAIN
+
[ICIBuildUploadJobSource]
module: lp.soyuz.interfaces.archivejob
dbuser: uploader
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 8d1c299..7260861 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -6966,6 +6966,18 @@ class LaunchpadObjectFactory(ObjectFactory):
IStore(recipe).flush()
return recipe
+ def makeCraftRecipeBuildRequest(
+ self, recipe=None, requester=None, channels=None, architectures=None
+ ):
+ """Make a new CraftRecipeBuildRequest."""
+ if recipe is None:
+ recipe = self.makeCraftRecipe()
+ if requester is None:
+ requester = recipe.owner.teamowner
+ return recipe.requestBuilds(
+ requester, channels=channels, architectures=architectures
+ )
+
def makeRockRecipe(
self,
registrant=None,