← Back to team overview

launchpad-reviewers team mailing list archive

[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
         )