launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27167
[Merge] ~cjwatson/launchpad:charm-recipe-job into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-job into launchpad:master with ~cjwatson/launchpad:charm-recipe-model as a prerequisite.
Commit message:
Add basic model for charm recipe jobs
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403459
This job is a placeholder and doesn't actually do anything yet: we'll need a model for charm recipe builds first. I'm adding the job first because I want to take the more modern approach of all charm recipe builds having an associated build request.
We'll need to deploy a "charm-build-job" DB user to production before landing this.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-job into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 8225ec9..c67612d 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2777,3 +2777,34 @@ public.teammembership = SELECT
public.teamparticipation = SELECT
public.webhook = SELECT
public.webhookjob = SELECT, INSERT
+
+[charm-build-job]
+type=user
+groups=script
+public.account = SELECT
+public.builder = SELECT
+public.buildfarmjob = SELECT, INSERT
+public.buildqueue = SELECT, INSERT, UPDATE
+public.charmfile = SELECT
+public.charmrecipe = SELECT, UPDATE
+public.charmrecipebuild = SELECT, INSERT, UPDATE
+public.charmrecipebuildjob = SELECT, UPDATE
+public.charmrecipejob = SELECT, UPDATE
+public.distribution = SELECT
+public.distroarchseries = SELECT
+public.distroseries = SELECT
+public.emailaddress = SELECT
+public.gitref = SELECT
+public.gitrepository = SELECT
+public.job = SELECT, INSERT, UPDATE
+public.libraryfilealias = SELECT
+public.libraryfilecontent = SELECT
+public.person = SELECT
+public.personsettings = SELECT
+public.pocketchroot = SELECT
+public.processor = SELECT
+public.product = SELECT
+public.teammembership = SELECT
+public.teamparticipation = SELECT
+public.webhook = SELECT
+public.webhookjob = SELECT, INSERT
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 87040fa..ddb0b3e 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -39,4 +39,25 @@
<allow interface="lp.charms.interfaces.charmrecipe.ICharmRecipeSet" />
</securedutility>
+ <!-- CharmRecipeBuildRequest -->
+ <class class="lp.charms.model.charmrecipe.CharmRecipeBuildRequest">
+ <require
+ permission="launchpad.View"
+ interface="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" />
+ </class>
+
+ <!-- Charm-related jobs -->
+ <class class="lp.charms.model.charmrecipejob.CharmRecipeJob">
+ <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
+ </class>
+ <securedutility
+ component="lp.charms.model.charmrecipejob.CharmRecipeRequestBuildsJob"
+ provides="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJobSource">
+ <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJobSource" />
+ </securedutility>
+ <class class="lp.charms.model.charmrecipejob.CharmRecipeRequestBuildsJob">
+ <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
+ <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJob" />
+ </class>
+
</configure>
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index d4edafb..0422e18 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -11,17 +11,23 @@ __all__ = [
"BadCharmRecipeSearchContext",
"CHARM_RECIPE_ALLOW_CREATE",
"CHARM_RECIPE_PRIVATE_FEATURE_FLAG",
+ "CharmRecipeBuildRequestStatus",
"CharmRecipeFeatureDisabled",
"CharmRecipeNotOwner",
"CharmRecipePrivacyMismatch",
"CharmRecipePrivateFeatureDisabled",
"DuplicateCharmRecipeName",
"ICharmRecipe",
+ "ICharmRecipeBuildRequest",
"ICharmRecipeSet",
"NoSourceForCharmRecipe",
"NoSuchCharmRecipe",
]
+from lazr.enum import (
+ EnumeratedType,
+ Item,
+ )
from lazr.restful.declarations import error_status
from lazr.restful.fields import (
Reference,
@@ -36,6 +42,7 @@ from zope.schema import (
Dict,
Int,
List,
+ Set,
Text,
TextLine,
)
@@ -128,6 +135,62 @@ class BadCharmRecipeSearchContext(Exception):
"""The context is not valid for a charm recipe search."""
+class CharmRecipeBuildRequestStatus(EnumeratedType):
+ """The status of a request to build a charm recipe."""
+
+ PENDING = Item("""
+ Pending
+
+ This charm recipe build request is pending.
+ """)
+
+ FAILED = Item("""
+ Failed
+
+ This charm recipe build request failed.
+ """)
+
+ COMPLETED = Item("""
+ Completed
+
+ This charm recipe build request completed successfully.
+ """)
+
+
+class ICharmRecipeBuildRequest(Interface):
+ """A request to build a charm recipe."""
+
+ id = Int(title=_("ID"), required=True, readonly=True)
+
+ date_requested = Datetime(
+ title=_("The time when this request was made"),
+ required=True, readonly=True)
+
+ date_finished = Datetime(
+ title=_("The time when this request finished"),
+ required=False, readonly=True)
+
+ recipe = Reference(
+ # Really ICharmRecipe.
+ Interface,
+ title=_("Charm recipe"), required=True, readonly=True)
+
+ status = Choice(
+ title=_("Status"), vocabulary=CharmRecipeBuildRequestStatus,
+ required=True, readonly=True)
+
+ error_message = TextLine(
+ title=_("Error message"), required=True, readonly=True)
+
+ channels = Dict(
+ title=_("Source snap channels for builds produced by this request"),
+ key_type=TextLine(), required=False, readonly=True)
+
+ architectures = Set(
+ title=_("If set, this request is limited to these architecture tags"),
+ value_type=TextLine(), required=False, readonly=True)
+
+
class ICharmRecipeView(Interface):
"""`ICharmRecipe` attributes that require launchpad.View permission."""
@@ -156,6 +219,29 @@ class ICharmRecipeView(Interface):
def visibleByUser(user):
"""Can the specified user see this charm recipe?"""
+ def requestBuilds(requester, channels=None, architectures=None):
+ """Request that the charm recipe be built.
+
+ This is an asynchronous operation; once the operation has finished,
+ the resulting build request's C{status} will be "Completed" and its
+ C{builds} collection will return the resulting builds.
+
+ :param requester: The person requesting the builds.
+ :param channels: A dictionary mapping snap names to channels to use
+ for these builds.
+ :param architectures: If not None, limit builds to architectures
+ with these architecture tags (in addition to any other
+ applicable constraints).
+ :return: An `ICharmRecipeBuildRequest`.
+ """
+
+ def getBuildRequest(job_id):
+ """Get an asynchronous build request by ID.
+
+ :param job_id: The ID of the build request.
+ :return: `ICharmRecipeBuildRequest`.
+ """
+
class ICharmRecipeEdit(Interface):
"""`ICharmRecipe` methods that require launchpad.Edit permission."""
diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
new file mode 100644
index 0000000..7d0eaf7
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmrecipejob.py
@@ -0,0 +1,122 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe job interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ "ICharmRecipeJob",
+ "ICharmRecipeRequestBuildsJob",
+ "ICharmRecipeRequestBuildsJobSource",
+ ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+ Attribute,
+ Interface,
+ )
+from zope.schema import (
+ Datetime,
+ Dict,
+ Set,
+ TextLine,
+ )
+
+from lp import _
+from lp.charms.interfaces.charmrecipe import (
+ ICharmRecipe,
+ ICharmRecipeBuildRequest,
+ )
+from lp.registry.interfaces.person import IPerson
+from lp.services.job.interfaces.job import (
+ IJob,
+ IJobSource,
+ IRunnableJob,
+ )
+
+
+class ICharmRecipeJob(Interface):
+ """A job related to a charm recipe."""
+
+ job = Reference(
+ title=_("The common Job attributes."), schema=IJob,
+ required=True, readonly=True)
+
+ recipe = Reference(
+ title=_("The charm recipe to use for this job."),
+ schema=ICharmRecipe, required=True, readonly=True)
+
+ metadata = Attribute(_("A dict of data about the job."))
+
+
+class ICharmRecipeRequestBuildsJob(IRunnableJob):
+ """A Job that processes a request for builds of a charm recipe."""
+
+ requester = Reference(
+ title=_("The person requesting the builds."), schema=IPerson,
+ required=True, readonly=True)
+
+ channels = Dict(
+ title=_("Source snap channels to use for these builds."),
+ description=_(
+ "A dictionary mapping snap names to channels to use for these "
+ "builds. Currently only 'core', 'core18', 'core20', and "
+ "'charmcraft' keys are supported."),
+ key_type=TextLine(), required=False, readonly=True)
+
+ architectures = Set(
+ title=_("If set, limit builds to these architecture tags."),
+ value_type=TextLine(), required=False, readonly=True)
+
+ date_created = Datetime(
+ title=_("Time when this job was created."),
+ required=True, readonly=True)
+
+ date_finished = Datetime(
+ title=_("Time when this job finished."),
+ required=True, readonly=True)
+
+ error_message = TextLine(
+ title=_("Error message resulting from running this job."),
+ required=False, readonly=True)
+
+ build_request = Reference(
+ title=_("The build request corresponding to this job."),
+ schema=ICharmRecipeBuildRequest, required=True, readonly=True)
+
+
+class ICharmRecipeRequestBuildsJobSource(IJobSource):
+
+ def create(recipe, requester, channels=None, architectures=None):
+ """Request builds of a charm recipe.
+
+ :param recipe: The charm recipe to build.
+ :param requester: The person requesting the builds.
+ :param channels: A dictionary mapping snap names to channels to use
+ for these builds.
+ :param architectures: If not None, limit builds to architectures
+ with these architecture tags (in addition to any other
+ applicable constraints).
+ """
+
+ def findByRecipe(recipe, statuses=None, job_ids=None):
+ """Find jobs for a charm recipe.
+
+ :param recipe: A charm recipe to search for.
+ :param statuses: An optional iterable of `JobStatus`es to search for.
+ :param job_ids: An optional iterable of job IDs to search for.
+ :return: A sequence of `CharmRecipeRequestBuildsJob`s with the
+ specified recipe.
+ """
+
+ def getByRecipeAndID(recipe, job_id):
+ """Get a job by charm recipe and job ID.
+
+ :return: The `CharmRecipeRequestBuildsJob` with the specified recipe
+ and ID.
+ :raises: `NotFoundError` if there is no job with the specified
+ recipe and ID, or its `job_type` is not
+ `CharmRecipeJobType.REQUEST_BUILDS`.
+ """
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 27c0ff5..65cfbc7 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -31,15 +31,20 @@ from lp.app.enums import (
from lp.charms.interfaces.charmrecipe import (
CHARM_RECIPE_ALLOW_CREATE,
CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+ CharmRecipeBuildRequestStatus,
CharmRecipeFeatureDisabled,
CharmRecipeNotOwner,
CharmRecipePrivacyMismatch,
CharmRecipePrivateFeatureDisabled,
DuplicateCharmRecipeName,
ICharmRecipe,
+ ICharmRecipeBuildRequest,
ICharmRecipeSet,
NoSourceForCharmRecipe,
)
+from lp.charms.interfaces.charmrecipejob import (
+ ICharmRecipeRequestBuildsJobSource,
+ )
from lp.code.model.gitrepository import GitRepository
from lp.registry.errors import PrivatePersonLinkageError
from lp.registry.interfaces.person import validate_public_person
@@ -54,6 +59,11 @@ from lp.services.database.interfaces import (
)
from lp.services.database.stormbase import StormBase
from lp.services.features import getFeatureFlag
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.propertycache import (
+ cachedproperty,
+ get_property_cache,
+ )
def charm_recipe_modified(recipe, event):
@@ -65,6 +75,68 @@ def charm_recipe_modified(recipe, event):
removeSecurityProxy(recipe).date_last_modified = UTC_NOW
+@implementer(ICharmRecipeBuildRequest)
+class CharmRecipeBuildRequest:
+ """See `ICharmRecipeBuildRequest`.
+
+ This is not directly backed by a database table; instead, it is a
+ webservice-friendly view of an asynchronous build request.
+ """
+
+ def __init__(self, recipe, id):
+ self.recipe = recipe
+ self.id = id
+
+ @classmethod
+ def fromJob(cls, job):
+ """See `ICharmRecipeBuildRequest`."""
+ request = cls(job.recipe, job.job_id)
+ get_property_cache(request)._job = job
+ return request
+
+ @cachedproperty
+ def _job(self):
+ job_source = getUtility(ICharmRecipeRequestBuildsJobSource)
+ return job_source.getByRecipeAndID(self.recipe, self.id)
+
+ @property
+ def date_requested(self):
+ """See `ICharmRecipeBuildRequest`."""
+ return self._job.date_created
+
+ @property
+ def date_finished(self):
+ """See `ICharmRecipeBuildRequest`."""
+ return self._job.date_finished
+
+ @property
+ def status(self):
+ """See `ICharmRecipeBuildRequest`."""
+ status_map = {
+ JobStatus.WAITING: CharmRecipeBuildRequestStatus.PENDING,
+ JobStatus.RUNNING: CharmRecipeBuildRequestStatus.PENDING,
+ JobStatus.COMPLETED: CharmRecipeBuildRequestStatus.COMPLETED,
+ JobStatus.FAILED: CharmRecipeBuildRequestStatus.FAILED,
+ JobStatus.SUSPENDED: CharmRecipeBuildRequestStatus.PENDING,
+ }
+ return status_map[self._job.job.status]
+
+ @property
+ def error_message(self):
+ """See `ICharmRecipeBuildRequest`."""
+ return self._job.error_message
+
+ @property
+ def channels(self):
+ """See `ICharmRecipeBuildRequest`."""
+ return self._job.channels
+
+ @property
+ def architectures(self):
+ """See `ICharmRecipeBuildRequest`."""
+ return self._job.architectures
+
+
@implementer(ICharmRecipe)
class CharmRecipe(StormBase):
"""See `ICharmRecipe`."""
@@ -217,6 +289,24 @@ class CharmRecipe(StormBase):
# more privacy infrastructure.
return False
+ def _checkRequestBuild(self, requester):
+ """May `requester` request builds of this charm recipe?"""
+ if not requester.inTeam(self.owner):
+ raise CharmRecipeNotOwner(
+ "%s cannot create charm recipe builds owned by %s." %
+ (requester.display_name, self.owner.display_name))
+
+ def requestBuilds(self, requester, channels=None, architectures=None):
+ """See `ICharmRecipe`."""
+ self._checkRequestBuild(requester)
+ job = getUtility(ICharmRecipeRequestBuildsJobSource).create(
+ self, requester, channels=channels, architectures=architectures)
+ return self.getBuildRequest(job.job_id)
+
+ def getBuildRequest(self, job_id):
+ """See `ICharmRecipe`."""
+ return CharmRecipeBuildRequest(self, job_id)
+
def destroySelf(self):
"""See `ICharmRecipe`."""
IStore(CharmRecipe).remove(self)
diff --git a/lib/lp/charms/model/charmrecipejob.py b/lib/lp/charms/model/charmrecipejob.py
new file mode 100644
index 0000000..7cba226
--- /dev/null
+++ b/lib/lp/charms/model/charmrecipejob.py
@@ -0,0 +1,292 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ "CharmRecipeJob",
+ "CharmRecipeJobType",
+ "CharmRecipeRequestBuildsJob",
+ ]
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+ DBEnumeratedType,
+ DBItem,
+ )
+import six
+from storm.databases.postgres import JSON
+from storm.locals import (
+ Desc,
+ Int,
+ Reference,
+ )
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+ implementer,
+ provider,
+ )
+
+from lp.app.errors import NotFoundError
+from lp.charms.interfaces.charmrecipejob import (
+ ICharmRecipeJob,
+ ICharmRecipeRequestBuildsJob,
+ ICharmRecipeRequestBuildsJobSource,
+ )
+from lp.registry.interfaces.person import IPersonSet
+from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import (
+ IMasterStore,
+ IStore,
+ )
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import (
+ EnumeratedSubclass,
+ Job,
+ )
+from lp.services.job.runner import BaseRunnableJob
+from lp.services.mail.sendmail import format_address_for_person
+from lp.services.propertycache import cachedproperty
+from lp.services.scripts import log
+
+
+class CharmRecipeJobType(DBEnumeratedType):
+ """Values that `ICharmRecipeJob.job_type` can take."""
+
+ REQUEST_BUILDS = DBItem(0, """
+ Request builds
+
+ This job requests builds of a charm recipe.
+ """)
+
+
+@implementer(ICharmRecipeJob)
+class CharmRecipeJob(StormBase):
+ """See `ICharmRecipeJob`."""
+
+ __storm_table__ = "CharmRecipeJob"
+
+ job_id = Int(name="job", primary=True, allow_none=False)
+ job = Reference(job_id, "Job.id")
+
+ recipe_id = Int(name="recipe", allow_none=False)
+ recipe = Reference(recipe_id, "CharmRecipe.id")
+
+ job_type = DBEnum(
+ name="job_type", enum=CharmRecipeJobType, allow_none=False)
+
+ metadata = JSON("json_data", allow_none=False)
+
+ def __init__(self, recipe, job_type, metadata, **job_args):
+ """Constructor.
+
+ Extra keyword arguments are used to construct the underlying Job
+ object.
+
+ :param recipe: The `ICharmRecipe` this job relates to.
+ :param job_type: The `CharmRecipeJobType` of this job.
+ :param metadata: The type-specific variables, as a JSON-compatible
+ dict.
+ """
+ super(CharmRecipeJob, self).__init__()
+ self.job = Job(**job_args)
+ self.recipe = recipe
+ self.job_type = job_type
+ self.metadata = metadata
+
+ def makeDerived(self):
+ return CharmRecipeJobDerived.makeSubclass(self)
+
+
+@delegate_to(ICharmRecipeJob)
+class CharmRecipeJobDerived(
+ six.with_metaclass(EnumeratedSubclass, BaseRunnableJob)):
+
+ def __init__(self, recipe_job):
+ self.context = recipe_job
+
+ def __repr__(self):
+ """An informative representation of the job."""
+ return "<%s for ~%s/%s/+charm/%s>" % (
+ self.__class__.__name__, self.recipe.owner.name,
+ self.recipe.project.name, self.recipe.name)
+
+ @classmethod
+ def get(cls, job_id):
+ """Get a job by id.
+
+ :return: The `CharmRecipeJob` with the specified id, as the current
+ `CharmRecipeJobDerived` subclass.
+ :raises: `NotFoundError` if there is no job with the specified id,
+ or its `job_type` does not match the desired subclass.
+ """
+ recipe_job = IStore(CharmRecipeJob).get(CharmRecipeJob, job_id)
+ if recipe_job.job_type != cls.class_job_type:
+ raise NotFoundError(
+ "No object found with id %d and type %s" %
+ (job_id, cls.class_job_type.title))
+ return cls(recipe_job)
+
+ @classmethod
+ def iterReady(cls):
+ """See `IJobSource`."""
+ jobs = IMasterStore(CharmRecipeJob).find(
+ CharmRecipeJob,
+ CharmRecipeJob.job_type == cls.class_job_type,
+ CharmRecipeJob.job == Job.id,
+ Job.id.is_in(Job.ready_jobs))
+ return (cls(job) for job in jobs)
+
+ def getOopsVars(self):
+ """See `IRunnableJob`."""
+ oops_vars = super(CharmRecipeJobDerived, self).getOopsVars()
+ oops_vars.extend([
+ ("job_id", self.context.job.id),
+ ("job_type", self.context.job_type.title),
+ ("recipe_owner_name", self.context.recipe.owner.name),
+ ("recipe_project_name", self.context.recipe.project.name),
+ ("recipe_name", self.context.recipe.name),
+ ])
+ return oops_vars
+
+
+@implementer(ICharmRecipeRequestBuildsJob)
+@provider(ICharmRecipeRequestBuildsJobSource)
+class CharmRecipeRequestBuildsJob(CharmRecipeJobDerived):
+ """A Job that processes a request for builds of a charm recipe."""
+
+ class_job_type = CharmRecipeJobType.REQUEST_BUILDS
+
+ max_retries = 5
+
+ config = config.ICharmRecipeRequestBuildsJobSource
+
+ @classmethod
+ def create(cls, recipe, requester, channels=None, architectures=None):
+ """See `ICharmRecipeRequestBuildsJobSource`."""
+ metadata = {
+ "requester": requester.id,
+ "channels": channels,
+ # Really a set or None, but sets aren't directly
+ # JSON-serialisable.
+ "architectures": (
+ list(architectures) if architectures is not None else None),
+ }
+ recipe_job = CharmRecipeJob(recipe, cls.class_job_type, metadata)
+ job = cls(recipe_job)
+ job.celeryRunOnCommit()
+ return job
+
+ @classmethod
+ def findByRecipe(cls, recipe, statuses=None, job_ids=None):
+ """See `ICharmRecipeRequestBuildsJobSource`."""
+ clauses = [
+ CharmRecipeJob.recipe == recipe,
+ CharmRecipeJob.job_type == cls.class_job_type,
+ ]
+ if statuses is not None:
+ clauses.extend([
+ CharmRecipeJob.job == Job.id,
+ Job._status.is_in(statuses),
+ ])
+ if job_ids is not None:
+ clauses.append(CharmRecipeJob.job_id.is_in(job_ids))
+ recipe_jobs = IStore(CharmRecipeJob).find(
+ CharmRecipeJob, *clauses).order_by(Desc(CharmRecipeJob.job_id))
+
+ def preload_jobs(rows):
+ load_related(Job, rows, ["job_id"])
+
+ return DecoratedResultSet(
+ recipe_jobs, lambda recipe_job: cls(recipe_job),
+ pre_iter_hook=preload_jobs)
+
+ @classmethod
+ def getByRecipeAndID(cls, recipe, job_id):
+ """See `ICharmRecipeRequestBuildsJobSource`."""
+ recipe_job = IStore(CharmRecipeJob).find(
+ CharmRecipeJob,
+ CharmRecipeJob.job_id == job_id,
+ CharmRecipeJob.recipe == recipe,
+ CharmRecipeJob.job_type == cls.class_job_type).one()
+ if recipe_job is None:
+ raise NotFoundError(
+ "No REQUEST_BUILDS job with ID %d found for %r" %
+ (job_id, recipe))
+ return cls(recipe_job)
+
+ def getOperationDescription(self):
+ return "requesting builds of %s" % self.recipe.name
+
+ def getErrorRecipients(self):
+ if self.requester is None or self.requester.preferredemail is None:
+ return []
+ return [format_address_for_person(self.requester)]
+
+ @cachedproperty
+ def requester(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ requester_id = self.metadata["requester"]
+ return getUtility(IPersonSet).get(requester_id)
+
+ @property
+ def channels(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ return self.metadata["channels"]
+
+ @property
+ def architectures(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ architectures = self.metadata["architectures"]
+ return set(architectures) if architectures is not None else None
+
+ @property
+ def date_created(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ return self.context.job.date_created
+
+ @property
+ def date_finished(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ return self.context.job.date_finished
+
+ @property
+ def error_message(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ return self.metadata.get("error_message")
+
+ @error_message.setter
+ def error_message(self, message):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ self.metadata["error_message"] = message
+
+ @property
+ def build_request(self):
+ """See `ICharmRecipeRequestBuildsJob`."""
+ return self.recipe.getBuildRequest(self.job.id)
+
+ def run(self):
+ """See `IRunnableJob`."""
+ requester = self.requester
+ if requester is None:
+ log.info(
+ "Skipping %r because the requester has been deleted." % self)
+ return
+ try:
+ # XXX cjwatson 2021-05-28: Implement this once we have a
+ # CharmRecipeBuild model.
+ raise NotImplementedError
+ except Exception as e:
+ self.error_message = str(e)
+ # The normal job infrastructure will abort the transaction, but
+ # we want to commit instead: the only database changes we make
+ # are to this job's metadata and should be preserved.
+ transaction.commit()
+ raise
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 654cfe3..f6a3758 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -7,23 +7,38 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
+from testtools.matchers import (
+ Equals,
+ Is,
+ MatchesDict,
+ MatchesSetwise,
+ MatchesStructure,
+ )
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
from lp.charms.interfaces.charmrecipe import (
CHARM_RECIPE_ALLOW_CREATE,
+ CharmRecipeBuildRequestStatus,
CharmRecipeFeatureDisabled,
CharmRecipePrivateFeatureDisabled,
ICharmRecipe,
ICharmRecipeSet,
NoSourceForCharmRecipe,
)
+from lp.charms.interfaces.charmrecipejob import (
+ ICharmRecipeRequestBuildsJobSource,
+ )
+from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.services.database.constants import (
ONE_DAY_AGO,
UTC_NOW,
)
+from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
from lp.services.webapp.snapshot import notify_modified
from lp.testing import (
admin_logged_in,
@@ -90,6 +105,80 @@ class TestCharmRecipe(TestCaseWithFactory):
self.assertSqlAttributeEqualsDate(
recipe, "date_last_modified", UTC_NOW)
+ def test_requestBuilds(self):
+ # requestBuilds schedules a job and returns a corresponding
+ # CharmRecipeBuildRequest.
+ recipe = self.factory.makeCharmRecipe()
+ now = get_transaction_timestamp(IStore(recipe))
+ with person_logged_in(recipe.owner.teamowner):
+ request = recipe.requestBuilds(recipe.owner.teamowner)
+ self.assertThat(request, MatchesStructure(
+ date_requested=Equals(now),
+ date_finished=Is(None),
+ recipe=Equals(recipe),
+ status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ channels=Is(None),
+ architectures=Is(None)))
+ [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+ self.assertThat(job, MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ recipe=Equals(recipe),
+ requester=Equals(recipe.owner.teamowner),
+ channels=Is(None),
+ architectures=Is(None)))
+
+ def test_requestBuilds_with_channels(self):
+ # If asked to build using particular snap channels, requestBuilds
+ # passes those through to the job.
+ recipe = self.factory.makeCharmRecipe()
+ now = get_transaction_timestamp(IStore(recipe))
+ with person_logged_in(recipe.owner.teamowner):
+ request = recipe.requestBuilds(
+ recipe.owner.teamowner, channels={"charmcraft": "edge"})
+ self.assertThat(request, MatchesStructure(
+ date_requested=Equals(now),
+ date_finished=Is(None),
+ recipe=Equals(recipe),
+ status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ channels=MatchesDict({"charmcraft": Equals("edge")}),
+ architectures=Is(None)))
+ [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+ self.assertThat(job, MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ recipe=Equals(recipe),
+ requester=Equals(recipe.owner.teamowner),
+ channels=Equals({"charmcraft": "edge"}),
+ architectures=Is(None)))
+
+ def test_requestBuilds_with_architectures(self):
+ # If asked to build for particular architectures, requestBuilds
+ # passes those through to the job.
+ recipe = self.factory.makeCharmRecipe()
+ now = get_transaction_timestamp(IStore(recipe))
+ with person_logged_in(recipe.owner.teamowner):
+ request = recipe.requestBuilds(
+ recipe.owner.teamowner, architectures={"amd64", "i386"})
+ self.assertThat(request, MatchesStructure(
+ date_requested=Equals(now),
+ date_finished=Is(None),
+ recipe=Equals(recipe),
+ status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+ error_message=Is(None),
+ channels=Is(None),
+ architectures=MatchesSetwise(Equals("amd64"), Equals("i386"))))
+ [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+ self.assertThat(job, MatchesStructure(
+ job_id=Equals(request.id),
+ job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+ recipe=Equals(recipe),
+ requester=Equals(recipe.owner.teamowner),
+ channels=Is(None),
+ architectures=MatchesSetwise(Equals("amd64"), Equals("i386"))))
+
def test_delete_without_builds(self):
# A charm recipe with no builds can be deleted.
owner = self.factory.makePerson()
diff --git a/lib/lp/charms/tests/test_charmrecipejob.py b/lib/lp/charms/tests/test_charmrecipejob.py
new file mode 100644
index 0000000..49a9e89
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmrecipejob.py
@@ -0,0 +1,63 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for charm recipe jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.charms.interfaces.charmrecipejob import (
+ ICharmRecipeJob,
+ ICharmRecipeRequestBuildsJob,
+ )
+from lp.charms.model.charmrecipejob import (
+ CharmRecipeJob,
+ CharmRecipeJobType,
+ CharmRecipeRequestBuildsJob,
+ )
+from lp.services.features.testing import FeatureFixture
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestCharmRecipeJob(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super(TestCharmRecipeJob, self).setUp()
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+ def test_provides_interface(self):
+ # `CharmRecipeJob` objects provide `ICharmRecipeJob`.
+ recipe = self.factory.makeCharmRecipe()
+ self.assertProvides(
+ CharmRecipeJob(recipe, CharmRecipeJobType.REQUEST_BUILDS, {}),
+ ICharmRecipeJob)
+
+
+class TestCharmRecipeRequestBuildsJob(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super(TestCharmRecipeRequestBuildsJob, self).setUp()
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+ def test_provides_interface(self):
+ # `CharmRecipeRequestBuildsJob` objects provide
+ # `ICharmRecipeRequestBuildsJob`."""
+ recipe = self.factory.makeCharmRecipe()
+ job = CharmRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+ self.assertProvides(job, ICharmRecipeRequestBuildsJob)
+
+ def test___repr__(self):
+ # `CharmRecipeRequestBuildsJob` objects have an informative __repr__.
+ recipe = self.factory.makeCharmRecipe()
+ job = CharmRecipeRequestBuildsJob.create(recipe, recipe.registrant)
+ self.assertEqual(
+ "<CharmRecipeRequestBuildsJob for ~%s/%s/+charm/%s>" % (
+ recipe.owner.name, recipe.project.name, recipe.name),
+ repr(job))
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 539a0f5..e11855f 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -65,7 +65,10 @@ from lp.buildmaster.interfaces.builder import (
)
from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJob
from lp.buildmaster.interfaces.packagebuild import IPackageBuild
-from lp.charms.interfaces.charmrecipe import ICharmRecipe
+from lp.charms.interfaces.charmrecipe import (
+ ICharmRecipe,
+ ICharmRecipeBuildRequest,
+ )
from lp.code.interfaces.branch import (
IBranch,
user_has_special_branch_access,
@@ -3656,3 +3659,12 @@ class AdminCharmRecipe(AuthorizationBase):
return (
user.in_ppa_self_admins
and EditCharmRecipe(self.obj).checkAuthenticated(user))
+
+
+class ViewCharmRecipeBuildRequest(DelegatedAuthorization):
+ permission = 'launchpad.View'
+ usedfor = ICharmRecipeBuildRequest
+
+ def __init__(self, obj):
+ super(ViewCharmRecipeBuildRequest, self).__init__(
+ obj, obj.recipe, 'launchpad.View')
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 752093f..728a9bd 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1833,6 +1833,11 @@ runner_class: TwistedJobRunner
module: lp.code.interfaces.branchjob
dbuser: upgrade-branches
+[ICharmRecipeRequestBuildsJobSource]
+module: lp.charms.interfaces.charmrecipejob
+dbuser: charm-build-job
+crontab_group: MAIN
+
[ICommercialExpiredJobSource]
module: lp.registry.interfaces.productjob
dbuser: product-job
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index a28676c..1a82f23 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -5150,6 +5150,16 @@ class BareLaunchpadObjectFactory(ObjectFactory):
IStore(recipe).flush()
return recipe
+ def makeCharmRecipeBuildRequest(self, recipe=None, requester=None,
+ channels=None, architectures=None):
+ """Make a new CharmRecipeBuildRequest."""
+ if recipe is None:
+ recipe = self.makeCharmRecipe()
+ if requester is None:
+ requester = recipe.owner.teamowner
+ return recipe.requestBuilds(
+ requester, channels=channels, architectures=architectures)
+
# Some factory methods return simple Python types. We don't add
# security wrappers for them, as well as for objects created by