launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31412
[Merge] ~jugmac00/launchpad:implement-rockrecipe-requestbuild into launchpad:master
Jürgen Gmach has proposed merging ~jugmac00/launchpad:implement-rockrecipe-requestbuild into launchpad:master with ~jugmac00/launchpad:handle-rock-recipes-in-personmerge as a prerequisite.
Commit message:
Implement RockRecipe.requestBuild
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/472906
similar to https://git.launchpad.net/launchpad/commit/?id=7cb2458a8c0c5c02504902d5b1b0602d35215711
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:implement-rockrecipe-requestbuild into launchpad:master.
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
index f07e809..49fe8cd 100644
--- a/lib/lp/rocks/interfaces/rockrecipe.py
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -8,6 +8,8 @@ __all__ = [
"BadRockRecipeSearchContext",
"ROCK_RECIPE_ALLOW_CREATE",
"ROCK_RECIPE_PRIVATE_FEATURE_FLAG",
+ "RockRecipeBuildAlreadyPending",
+ "RockRecipeBuildDisallowedArchitecture",
"RockRecipeBuildRequestStatus",
"RockRecipeFeatureDisabled",
"RockRecipeNotOwner",
@@ -127,6 +129,27 @@ class BadRockRecipeSearchContext(Exception):
"""The context is not valid for a rock recipe search."""
+@error_status(http.client.BAD_REQUEST)
+class RockRecipeBuildAlreadyPending(Exception):
+ """A build was requested when an identical build was already pending."""
+
+ def __init__(self):
+ super().__init__(
+ "An identical build of this rock recipe is already pending."
+ )
+
+
+@error_status(http.client.BAD_REQUEST)
+class RockRecipeBuildDisallowedArchitecture(Exception):
+ """A build was requested for a disallowed architecture."""
+
+ def __init__(self, das):
+ super().__init__(
+ "This rock recipe is not allowed to build for %s/%s."
+ % (das.distroseries.name, das.architecturetag)
+ )
+
+
class RockRecipeBuildRequestStatus(EnumeratedType):
"""The status of a request to build a rock recipe."""
@@ -257,6 +280,20 @@ class IRockRecipeView(Interface):
def visibleByUser(user):
"""Can the specified user see this rock recipe?"""
+ def requestBuild(build_request, distro_arch_series, channels=None):
+ """Request a single build of this rock recipe.
+
+ This method is for internal use; external callers should use
+ `requestBuilds` instead.
+
+ :param build_request: The `IRockRecipeBuildRequest` job being
+ processed.
+ :param distro_arch_series: The architecture to build for.
+ :param channels: A dictionary mapping snap names to channels to use
+ for this build.
+ :return: `IRockRecipeBuild`.
+ """
+
def requestBuilds(requester, channels=None, architectures=None):
"""Request that the rock recipe be built.
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index b4b85c1..f227ce7 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -8,10 +8,22 @@ __all__ = [
]
from datetime import timezone
+from operator import itemgetter
+from lazr.lifecycle.event import ObjectCreatedEvent
from storm.databases.postgres import JSON
-from storm.locals import Bool, DateTime, Int, Reference, Unicode
+from storm.locals import (
+ Bool,
+ DateTime,
+ Int,
+ Join,
+ Or,
+ Reference,
+ Store,
+ Unicode,
+)
from zope.component import getUtility
+from zope.event import notify
from zope.interface import implementer
from zope.security.proxy import removeSecurityProxy
@@ -20,10 +32,14 @@ from lp.app.enums import (
PUBLIC_INFORMATION_TYPES,
InformationType,
)
+from lp.buildmaster.enums import BuildStatus
from lp.code.model.gitcollection import GenericGitCollection
from lp.code.model.gitrepository import GitRepository
from lp.registry.errors import PrivatePersonLinkageError
from lp.registry.interfaces.person import IPersonSet, validate_public_person
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distroseries import DistroSeries
+from lp.registry.model.series import ACTIVE_STATUSES
from lp.rocks.interfaces.rockrecipe import (
ROCK_RECIPE_ALLOW_CREATE,
ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
@@ -32,21 +48,28 @@ from lp.rocks.interfaces.rockrecipe import (
IRockRecipeBuildRequest,
IRockRecipeSet,
NoSourceForRockRecipe,
+ RockRecipeBuildAlreadyPending,
+ RockRecipeBuildDisallowedArchitecture,
RockRecipeBuildRequestStatus,
RockRecipeFeatureDisabled,
RockRecipeNotOwner,
RockRecipePrivacyMismatch,
RockRecipePrivateFeatureDisabled,
)
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet
from lp.rocks.interfaces.rockrecipejob import IRockRecipeRequestBuildsJobSource
+from lp.rocks.model.rockrecipebuild import RockRecipeBuild
from lp.services.database.bulk import load_related
from lp.services.database.constants import DEFAULT, UTC_NOW
+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.features import getFeatureFlag
from lp.services.job.interfaces.job import JobStatus
+from lp.services.librarian.model import LibraryFileAlias
from lp.services.propertycache import cachedproperty, get_property_cache
+from lp.soyuz.model.distroarchseries import DistroArchSeries, PocketChroot
def rock_recipe_modified(recipe, event):
@@ -316,6 +339,52 @@ class RockRecipe(StormBase):
# more privacy infrastructure.
return False
+ def _isBuildableArchitectureAllowed(self, das):
+ """Check whether we may build for a buildable `DistroArchSeries`.
+
+ The caller is assumed to have already checked that a suitable chroot
+ is available (either directly or via
+ `DistroSeries.buildable_architectures`).
+ """
+ return das.enabled and (
+ das.processor.supports_virtualized or not self.require_virtualized
+ )
+
+ def _isArchitectureAllowed(self, das):
+ """Check whether we may build for a `DistroArchSeries`."""
+ return (
+ das.getChroot() is not None
+ and self._isBuildableArchitectureAllowed(das)
+ )
+
+ def getAllowedArchitectures(self):
+ """See `ICharmRecipe`."""
+ store = Store.of(self)
+ origin = [
+ DistroArchSeries,
+ Join(
+ DistroSeries, DistroArchSeries.distroseries == DistroSeries.id
+ ),
+ Join(Distribution, DistroSeries.distribution == Distribution.id),
+ Join(
+ PocketChroot,
+ PocketChroot.distroarchseries == DistroArchSeries.id,
+ ),
+ Join(LibraryFileAlias, PocketChroot.chroot == LibraryFileAlias.id),
+ ]
+ # Preload DistroSeries and Distribution, since we'll need those in
+ # determine_architectures_to_build.
+ results = store.using(*origin).find(
+ (DistroArchSeries, DistroSeries, Distribution),
+ DistroSeries.status.is_in(ACTIVE_STATUSES),
+ )
+ all_buildable_dases = DecoratedResultSet(results, itemgetter(0))
+ return [
+ das
+ for das in all_buildable_dases
+ if self._isBuildableArchitectureAllowed(das)
+ ]
+
def _checkRequestBuild(self, requester):
"""May `requester` request builds of this rock recipe?"""
if not requester.inTeam(self.owner):
@@ -324,6 +393,47 @@ class RockRecipe(StormBase):
% (requester.display_name, self.owner.display_name)
)
+ def requestBuild(self, build_request, distro_arch_series, channels=None):
+ """Request a single build of this rock recipe.
+
+ This method is for internal use; external callers should use
+ `requestBuilds` instead.
+
+ :param build_request: The `IRockRecipeBuildRequest` job being
+ processed.
+ :param distro_arch_series: The architecture to build for.
+ :param channels: A dictionary mapping snap names to channels to use
+ for this build.
+ :return: `IRockRecipeBuild`.
+ """
+ self._checkRequestBuild(build_request.requester)
+ if not self._isArchitectureAllowed(distro_arch_series):
+ raise RockRecipeBuildDisallowedArchitecture(distro_arch_series)
+
+ if not channels:
+ channels_clause = Or(
+ RockRecipeBuild.channels == None,
+ RockRecipeBuild.channels == {},
+ )
+ else:
+ channels_clause = RockRecipeBuild.channels == channels
+ pending = IStore(self).find(
+ RockRecipeBuild,
+ RockRecipeBuild.recipe == self,
+ RockRecipeBuild.processor == distro_arch_series.processor,
+ channels_clause,
+ RockRecipeBuild.status == BuildStatus.NEEDSBUILD,
+ )
+ if pending.any() is not None:
+ raise RockRecipeBuildAlreadyPending
+
+ build = getUtility(IRockRecipeBuildSet).new(
+ build_request, self, distro_arch_series, channels=channels
+ )
+ build.queueBuild()
+ notify(ObjectCreatedEvent(build, user=build_request.requester))
+ return build
+
def requestBuilds(self, requester, channels=None, architectures=None):
"""See `IRockRecipe`."""
self._checkRequestBuild(requester)
diff --git a/lib/lp/rocks/security.py b/lib/lp/rocks/security.py
index 6aee24b..e45cec4 100644
--- a/lib/lp/rocks/security.py
+++ b/lib/lp/rocks/security.py
@@ -7,6 +7,8 @@ __all__ = []
from lp.app.security import AuthorizationBase, DelegatedAuthorization
from lp.rocks.interfaces.rockrecipe import IRockRecipe, IRockRecipeBuildRequest
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuild
+from lp.security import AdminByBuilddAdmin
class ViewRockRecipe(AuthorizationBase):
@@ -57,3 +59,32 @@ class ViewCharmRecipeBuildRequest(DelegatedAuthorization):
def __init__(self, obj):
super().__init__(obj, obj.recipe, "launchpad.View")
+
+
+class ViewRockRecipeBuild(DelegatedAuthorization):
+ permission = "launchpad.View"
+ usedfor = IRockRecipeBuild
+
+ def iter_objects(self):
+ yield self.obj.recipe
+
+
+class EditRockRecipeBuild(AdminByBuilddAdmin):
+ permission = "launchpad.Edit"
+ usedfor = IRockRecipeBuild
+
+ def checkAuthenticated(self, user):
+ """Check edit access for rock recipe builds.
+
+ Allow admins, buildd admins, and the owner of the rock recipe.
+ (Note that the requester of the build is required to be in the team
+ that owns the rock recipe.)
+ """
+ auth_recipe = EditRockRecipe(self.obj.recipe)
+ if auth_recipe.checkAuthenticated(user):
+ return True
+ return super().checkAuthenticated(user)
+
+
+class AdminRockRecipeBuild(AdminByBuilddAdmin):
+ usedfor = IRockRecipeBuild
diff --git a/lib/lp/rocks/tests/test_rockrecipe.py b/lib/lp/rocks/tests/test_rockrecipe.py
index 91c3eb7..1f9c807 100644
--- a/lib/lp/rocks/tests/test_rockrecipe.py
+++ b/lib/lp/rocks/tests/test_rockrecipe.py
@@ -3,6 +3,7 @@
"""Test rock recipes."""
+from storm.locals import Store
from testtools.matchers import (
Equals,
Is,
@@ -14,15 +15,25 @@ from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
+from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
+from lp.buildmaster.interfaces.processor import (
+ IProcessorSet,
+ ProcessorNotFound,
+)
+from lp.buildmaster.model.buildqueue import BuildQueue
from lp.rocks.interfaces.rockrecipe import (
ROCK_RECIPE_ALLOW_CREATE,
IRockRecipe,
IRockRecipeSet,
NoSourceForRockRecipe,
+ RockRecipeBuildAlreadyPending,
+ RockRecipeBuildDisallowedArchitecture,
RockRecipeBuildRequestStatus,
RockRecipeFeatureDisabled,
RockRecipePrivateFeatureDisabled,
)
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuild
from lp.rocks.interfaces.rockrecipejob import IRockRecipeRequestBuildsJobSource
from lp.services.database.constants import ONE_DAY_AGO, UTC_NOW
from lp.services.database.interfaces import IStore
@@ -209,6 +220,175 @@ class TestRockRecipe(TestCaseWithFactory):
getUtility(IRockRecipeSet).getByName(owner, project, "condemned")
)
+ def makeBuildableDistroArchSeries(
+ self,
+ architecturetag=None,
+ processor=None,
+ supports_virtualized=True,
+ supports_nonvirtualized=True,
+ **kwargs,
+ ):
+ if architecturetag is None:
+ architecturetag = self.factory.getUniqueUnicode("arch")
+ if processor is None:
+ try:
+ processor = getUtility(IProcessorSet).getByName(
+ architecturetag
+ )
+ except ProcessorNotFound:
+ processor = self.factory.makeProcessor(
+ name=architecturetag,
+ supports_virtualized=supports_virtualized,
+ supports_nonvirtualized=supports_nonvirtualized,
+ )
+ das = self.factory.makeDistroArchSeries(
+ architecturetag=architecturetag, processor=processor, **kwargs
+ )
+ fake_chroot = self.factory.makeLibraryFileAlias(
+ filename="fake_chroot.tar.gz", db_only=True
+ )
+ das.addOrUpdateChroot(fake_chroot)
+ return das
+
+ def test_requestBuild(self):
+ # requestBuild creates a new RockRecipeBuild.
+ recipe = self.factory.makeRockRecipe()
+ das = self.makeBuildableDistroArchSeries()
+ build_request = self.factory.makeRockRecipeBuildRequest(recipe=recipe)
+ build = recipe.requestBuild(build_request, das)
+ self.assertTrue(IRockRecipeBuild.providedBy(build))
+ self.assertThat(
+ build,
+ MatchesStructure(
+ requester=Equals(recipe.owner.teamowner),
+ distro_arch_series=Equals(das),
+ channels=Is(None),
+ status=Equals(BuildStatus.NEEDSBUILD),
+ ),
+ )
+ store = Store.of(build)
+ store.flush()
+ build_queue = store.find(
+ BuildQueue,
+ BuildQueue._build_farm_job_id
+ == removeSecurityProxy(build).build_farm_job_id,
+ ).one()
+ self.assertProvides(build_queue, IBuildQueue)
+ self.assertEqual(recipe.require_virtualized, build_queue.virtualized)
+ self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
+
+ def test_requestBuild_score(self):
+ # Build requests have a relatively low queue score (2510).
+ recipe = self.factory.makeRockRecipe()
+ das = self.makeBuildableDistroArchSeries()
+ build_request = self.factory.makeRockRecipeBuildRequest(recipe=recipe)
+ build = recipe.requestBuild(build_request, das)
+ queue_record = build.buildqueue_record
+ queue_record.score()
+ self.assertEqual(2510, queue_record.lastscore)
+
+ def test_requestBuild_channels(self):
+ # requestBuild can select non-default channels.
+ recipe = self.factory.makeRockRecipe()
+ das = self.makeBuildableDistroArchSeries()
+ build_request = self.factory.makeRockRecipeBuildRequest(recipe=recipe)
+ build = recipe.requestBuild(
+ build_request, das, channels={"rockcraft": "edge"}
+ )
+ self.assertEqual({"rockcraft": "edge"}, build.channels)
+
+ def test_requestBuild_rejects_repeats(self):
+ # requestBuild refuses if there is already a pending build.
+ recipe = self.factory.makeRockRecipe()
+ distro_series = self.factory.makeDistroSeries()
+ arches = [
+ self.makeBuildableDistroArchSeries(distroseries=distro_series)
+ for _ in range(2)
+ ]
+ build_request = self.factory.makeRockRecipeBuildRequest(recipe=recipe)
+ old_build = recipe.requestBuild(build_request, arches[0])
+ self.assertRaises(
+ RockRecipeBuildAlreadyPending,
+ recipe.requestBuild,
+ build_request,
+ arches[0],
+ )
+ # We can build for a different distroarchseries.
+ recipe.requestBuild(build_request, arches[1])
+ # channels=None and channels={} are treated as equivalent, but
+ # anything else allows a new build.
+ self.assertRaises(
+ RockRecipeBuildAlreadyPending,
+ recipe.requestBuild,
+ build_request,
+ arches[0],
+ channels={},
+ )
+ recipe.requestBuild(
+ build_request, arches[0], channels={"core": "edge"}
+ )
+ self.assertRaises(
+ RockRecipeBuildAlreadyPending,
+ recipe.requestBuild,
+ build_request,
+ arches[0],
+ channels={"core": "edge"},
+ )
+ # Changing the status of the old build allows a new build.
+ old_build.updateStatus(BuildStatus.BUILDING)
+ old_build.updateStatus(BuildStatus.FULLYBUILT)
+ recipe.requestBuild(build_request, arches[0])
+
+ def test_requestBuild_virtualization(self):
+ # New builds are virtualized if any of the processor or recipe
+ # require it.
+ recipe = self.factory.makeRockRecipe()
+ distro_series = self.factory.makeDistroSeries()
+ dases = {}
+ for proc_nonvirt in True, False:
+ das = self.makeBuildableDistroArchSeries(
+ distroseries=distro_series,
+ supports_virtualized=True,
+ supports_nonvirtualized=proc_nonvirt,
+ )
+ dases[proc_nonvirt] = das
+ for proc_nonvirt, recipe_virt, build_virt in (
+ (True, False, False),
+ (True, True, True),
+ (False, False, True),
+ (False, True, True),
+ ):
+ das = dases[proc_nonvirt]
+ recipe = self.factory.makeRockRecipe(
+ require_virtualized=recipe_virt
+ )
+ build_request = self.factory.makeRockRecipeBuildRequest(
+ recipe=recipe
+ )
+ build = recipe.requestBuild(build_request, das)
+ self.assertEqual(build_virt, build.virtualized)
+
+ def test_requestBuild_nonvirtualized(self):
+ # A non-virtualized processor can build a rock recipe iff the
+ # recipe has require_virtualized set to False.
+ recipe = self.factory.makeRockRecipe()
+ distro_series = self.factory.makeDistroSeries()
+ das = self.makeBuildableDistroArchSeries(
+ distroseries=distro_series,
+ supports_virtualized=False,
+ supports_nonvirtualized=True,
+ )
+ build_request = self.factory.makeRockRecipeBuildRequest(recipe=recipe)
+ self.assertRaises(
+ RockRecipeBuildDisallowedArchitecture,
+ recipe.requestBuild,
+ build_request,
+ das,
+ )
+ with admin_logged_in():
+ recipe.require_virtualized = False
+ recipe.requestBuild(build_request, das)
+
class TestRockRecipeSet(TestCaseWithFactory):
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index a3f6415..609fc08 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -6971,6 +6971,10 @@ class LaunchpadObjectFactory(ObjectFactory):
recipe = self.makeRockRecipe()
if requester is None:
requester = recipe.owner.teamowner
+ if recipe.owner.is_team:
+ requester = recipe.owner.teamowner
+ else:
+ requester = recipe.owner
return recipe.requestBuilds(
requester, channels=channels, architectures=architectures
)