Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-explicit-model-for-rock-bases into launchpad:master with ~jugmac00/launchpad:add-webservice-api-for-rock-changes as a prerequisite.
Commit message: [WIP] Add explicit model for rock bases Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/473339 This MP is currently untested as we are waiting for the related DB changes to be merged. Also, again there are updates to the `wadl-to-refhtml.xsl` file missing, which we currently do not know how to generate. see https://git.launchpad.net/launchpad/commit/?id=3561835c72c060af304f192138a126539510c350 -- Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-explicit-model-for-rock-bases into launchpad:master.
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py index 83cbcbd..04aa196 100644 --- a/lib/lp/app/browser/launchpad.py +++ b/lib/lp/app/browser/launchpad.py @@ -99,6 +99,7 @@ from lp.registry.interfaces.product import ( from lp.registry.interfaces.projectgroup import IProjectGroupSet from lp.registry.interfaces.role import IPersonRoles from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet +from lp.rocks.interfaces.rockbase import IRockBaseSet from lp.rocks.interfaces.rockrecipe import IRockRecipeSet from lp.services.config import config from lp.services.features import getFeatureFlag @@ -898,6 +899,7 @@ class LaunchpadRootNavigation(Navigation): "+processors": IProcessorSet, "projects": IProductSet, "projectgroups": IProjectGroupSet, + "+rock-bases": IRockBaseSet, "+rock-recipes": IRockRecipeSet, "+snaps": ISnapSet, "+snap-bases": ISnapBaseSet, diff --git a/lib/lp/rocks/browser/configure.zcml b/lib/lp/rocks/browser/configure.zcml index 18d594f..a81a1ad 100644 --- a/lib/lp/rocks/browser/configure.zcml +++ b/lib/lp/rocks/browser/configure.zcml @@ -87,6 +87,17 @@ for="lp.rocks.interfaces.rockrecipebuild.IRockRecipeBuild" factory="lp.services.webapp.breadcrumb.TitleBreadcrumb" permission="zope.Public" /> + <lp:url + for="lp.rocks.interfaces.rockbase.IRockBase" + path_expression="string:${id}" + parent_utility="lp.rocks.interfaces.rockbase.IRockBaseSet" /> + <lp:url + for="lp.charms.interfaces.rockbase.IRockBaseSet" + path_expression="string:+rock-bases" + parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" /> + <lp:navigation + module="lp.rocks.browser.rockbase" + classes="RockBaseSetNavigation" /> <browser:page for="*" class="lp.app.browser.launchpad.Macro" diff --git a/lib/lp/rocks/browser/rockbase.py b/lib/lp/rocks/browser/rockbase.py new file mode 100644 index 0000000..64d75ec --- /dev/null +++ b/lib/lp/rocks/browser/rockbase.py @@ -0,0 +1,26 @@ +# Copyright 2024 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Views of bases for rocks.""" + +__all__ = [ + "RockBaseSetNavigation", +] + +from zope.component import getUtility + +from lp.rocks.interfaces.rockbase import IRockBaseSet +from lp.services.webapp.publisher import Navigation + + +class RockBaseSetNavigation(Navigation): + """Navigation methods for `IRockBaseSet`.""" + + usedfor = IRockBaseSet + + def traverse(self, name): + try: + base_id = int(name) + except ValueError: + return None + return getUtility(IRockBaseSet).getByID(base_id) diff --git a/lib/lp/rocks/configure.zcml b/lib/lp/rocks/configure.zcml index a985021..a2b7317 100644 --- a/lib/lp/rocks/configure.zcml +++ b/lib/lp/rocks/configure.zcml @@ -94,6 +94,29 @@ factory="lp.rocks.model.rockrecipebuildbehaviour.RockRecipeBuildBehaviour" permission="zope.Public" /> +<<<<<<< lib/lp/rocks/configure.zcml +======= + <!-- Bases for rocks --> + <class class="lp.rocks.model.rockbase.RockBase"> + <allow + interface="lp.rocks.interfaces.rockbase.IRockBaseView + lp.rocks.interfaces.rockbase.IRockBaseEditableAttributes" /> + <require + permission="launchpad.Edit" + interface="lp.rocks.interfaces.rockbase.IRockBaseEdit" + set_schema="lp.rocks.interfaces.rockbase.IRockBaseEditableAttributes" /> + </class> + <lp:securedutility + class="lp.rocks.model.rockbase.RockBaseSet" + provides="lp.rocks.interfaces.rockbase.IRockBaseSet"> + <allow + interface="lp.rocks.interfaces.rockbase.IRockBaseSet" /> + <require + permission="launchpad.Edit" + interface="lp.rocks.interfaces.rockbase.IRockBaseSetEdit" /> + </lp:securedutility> + +>>>>>>> lib/lp/rocks/configure.zcml <!-- rock-related jobs --> <class class="lp.rocks.model.rockrecipejob.RockRecipeJob"> <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeJob" /> diff --git a/lib/lp/rocks/interfaces/rockbase.py b/lib/lp/rocks/interfaces/rockbase.py new file mode 100644 index 0000000..04845f7 --- /dev/null +++ b/lib/lp/rocks/interfaces/rockbase.py @@ -0,0 +1,197 @@ +# Copyright 2024 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Interfaces for bases for rocks.""" + +__all__ = [ + "DuplicateRockBase", + "IRockBase", + "IRockBaseSet", + "NoSuchRockBase", +] + +import http.client + +from lazr.restful.declarations import ( + REQUEST_USER, + call_with, + collection_default_content, + error_status, + export_destructor_operation, + export_factory_operation, + export_read_operation, + export_write_operation, + exported, + exported_as_webservice_collection, + exported_as_webservice_entry, + operation_for_version, + operation_parameters, + operation_returns_entry, +) +from lazr.restful.fields import CollectionField, Reference +from zope.interface import Interface +from zope.schema import Datetime, Dict, Int, List, TextLine + +from lp import _ +from lp.app.errors import NotFoundError +from lp.buildmaster.interfaces.processor import IProcessor +from lp.registry.interfaces.distroseries import IDistroSeries +from lp.services.fields import PublicPersonChoice + + +@error_status(http.client.BAD_REQUEST) +class DuplicateRockBase(Exception): + """Raised for rock bases with duplicate distro series.""" + + def __init__(self, distro_series): + super().__init__( + "%s is already in use by another base." % distro_series + ) + + +class NoSuchRockBase(NotFoundError): + """The requested `RockBase` does not exist.""" + + def __init__(self, distro_series): + self.message = "No base for %s." % distro_series + super().__init__(self.message) + + def __str__(self): + return self.message + + +class IRockBaseView(Interface): + """`IRockBase` attributes that anyone can view.""" + + id = Int(title=_("ID"), required=True, readonly=True) + + date_created = exported( + Datetime(title=_("Date created"), required=True, readonly=True) + ) + + registrant = exported( + PublicPersonChoice( + title=_("Registrant"), + required=True, + readonly=True, + vocabulary="ValidPersonOrTeam", + description=_("The person who registered this base."), + ) + ) + + distro_series = exported( + Reference( + IDistroSeries, + title=_("Distro series"), + required=True, + readonly=True, + ) + ) + + processors = exported( + CollectionField( + title=_("Processors"), + description=_("The architectures that the rock base supports."), + value_type=Reference(schema=IProcessor), + readonly=True, + ) + ) + + +class IRockBaseEditableAttributes(Interface): + """`IRockBase` attributes that can be edited. + + Anyone can view these attributes, but they need launchpad.Edit to change. + """ + + build_channels = exported( + Dict( + title=_("Source snap channels for builds"), + key_type=TextLine(), + required=True, + readonly=False, + description=_( + "A dictionary mapping snap names to channels to use when " + "building rock recipes that specify this base. The special " + "'_byarch' key may have a mapping of architecture names to " + "mappings of snap names to channels, which if present " + "override the channels declared at the top level when " + "building for those architectures." + ), + ) + ) + + +class IRockBaseEdit(Interface): + """`IRockBase` methods that require launchpad.Edit permission.""" + + @operation_parameters( + processors=List(value_type=Reference(schema=IProcessor), required=True) + ) + @export_write_operation() + @operation_for_version("devel") + def setProcessors(processors): + """Set the architectures that the rock base supports.""" + + @export_destructor_operation() + @operation_for_version("devel") + def destroySelf(): + """Delete the specified base.""" + + +# XXX jugmac00 2024-09-17 bug=760849: "beta" is a lie to get WADL +# generation working. Individual attributes must set their version to +# "devel". +@exported_as_webservice_entry(as_of="beta") +class IRockBase(IRockBaseView, IRockBaseEditableAttributes, IRockBaseEdit): + """A base for rocks.""" + + +class IRockBaseSetEdit(Interface): + """`IRockBaseSet` methods that require launchpad.Edit permission.""" + + @call_with(registrant=REQUEST_USER) + @operation_parameters( + processors=List( + value_type=Reference(schema=IProcessor), required=False + ) + ) + @export_factory_operation(IRockBase, ["distro_series", "build_channels"]) + @operation_for_version("devel") + def new( + registrant, + distro_series, + build_channels, + processors=None, + date_created=None, + ): + """Create an `IRockBase`.""" + + +@exported_as_webservice_collection(IRockBase) +class IRockBaseSet(IRockBaseSetEdit): + """Interface representing the set of bases for rocks.""" + + def __iter__(): + """Iterate over `IRockBase`s.""" + + def getByID(id): + """Return the `IRockBase` with this ID, or None.""" + + @operation_parameters( + distro_series=Reference( + schema=IDistroSeries, title=_("Distro series"), required=True + ) + ) + @operation_returns_entry(IRockBase) + @export_read_operation() + @operation_for_version("devel") + def getByDistroSeries(distro_series): + """Return the `IRockBase` for this distro series. + + :raises NoSuchRockBase: if no base exists for this distro series. + """ + + @collection_default_content() + def getAll(): + """Return all `IRockBase`s.""" diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py index 16d92fd..5724ff1 100644 --- a/lib/lp/rocks/interfaces/rockrecipe.py +++ b/lib/lp/rocks/interfaces/rockrecipe.py @@ -429,7 +429,13 @@ class IRockRecipeView(Interface): def visibleByUser(user): """Can the specified user see this rock recipe?""" +<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py def requestBuild(build_request, distro_arch_series, channels=None): +======= + def requestBuild( + build_request, distro_arch_series, charm_base=None, channels=None + ): +>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py """Request a single build of this rock recipe. This method is for internal use; external callers should use @@ -438,6 +444,10 @@ class IRockRecipeView(Interface): :param build_request: The `IRockRecipeBuildRequest` job being processed. :param distro_arch_series: The architecture to build for. +<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py +======= + :param charm_base: The `ICharmBase` to use for this build. +>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py :param channels: A dictionary mapping snap names to channels to use for this build. :return: `IRockRecipeBuild`. diff --git a/lib/lp/rocks/interfaces/webservice.py b/lib/lp/rocks/interfaces/webservice.py index 3f3586c..3a1a56a 100644 --- a/lib/lp/rocks/interfaces/webservice.py +++ b/lib/lp/rocks/interfaces/webservice.py @@ -10,12 +10,15 @@ which tells `lazr.restful` that it should look for webservice exports here. """ __all__ = [ + "IRockBase", + "IRockBaseSet", "IRockRecipe", "IRockRecipeBuild", "IRockRecipeBuildRequest", "IRockRecipeSet", ] +from lp.rocks.interfaces.rockbase import IRockBase, IRockBaseSet from lp.rocks.interfaces.rockrecipe import ( IRockRecipe, IRockRecipeBuildRequest, diff --git a/lib/lp/rocks/model/rockbase.py b/lib/lp/rocks/model/rockbase.py new file mode 100644 index 0000000..4b05a4a --- /dev/null +++ b/lib/lp/rocks/model/rockbase.py @@ -0,0 +1,157 @@ +# Copyright 2024 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Bases for rocks.""" + +__all__ = [ + "RockBase", +] +from datetime import timezone + +from storm.locals import JSON, DateTime, Int, Reference, Store, Storm +from zope.interface import implementer + +from lp.buildmaster.model.processor import Processor +from lp.rocks.interfaces.rockbase import ( + DuplicateRockBase, + IRockBase, + IRockBaseSet, + NoSuchRockBase, +) +from lp.services.database.constants import DEFAULT +from lp.services.database.interfaces import IMasterStore, IStore + + +@implementer(IRockBase) +class RockBase(Storm): + """See `IRockBase`.""" + + __storm_table__ = "RockBase" + + id = Int(primary=True) + + date_created = DateTime( + name="date_created", tzinfo=timezone.utc, allow_none=False + ) + + registrant_id = Int(name="registrant", allow_none=False) + registrant = Reference(registrant_id, "Person.id") + + distro_series_id = Int(name="distro_series", allow_none=False) + distro_series = Reference(distro_series_id, "DistroSeries.id") + + build_channels = JSON(name="build_channels", allow_none=False) + + def __init__( + self, registrant, distro_series, build_channels, date_created=DEFAULT + ): + super().__init__() + self.registrant = registrant + self.distro_series = distro_series + self.build_channels = build_channels + self.date_created = date_created + + def _getProcessors(self): + return list( + Store.of(self).find( + Processor, + Processor.id == RockBaseArch.processor_id, + RockBaseArch.rock_base == self, + ) + ) + + def setProcessors(self, processors): + """See `IRockBase`.""" + enablements = dict( + Store.of(self).find( + (Processor, RockBaseArch), + Processor.id == RockBaseArch.processor_id, + RockBaseArch.rock_base == self, + ) + ) + for proc in enablements: + if proc not in processors: + Store.of(self).remove(enablements[proc]) + for proc in processors: + if proc not in self.processors: + rock_base_arch = RockBaseArch() + rock_base_arch.rock_base = self + rock_base_arch.processor = proc + Store.of(self).add(rock_base_arch) + + processors = property(_getProcessors, setProcessors) + + def destroySelf(self): + """See `IRockBase`.""" + Store.of(self).remove(self) + + +class RockBaseArch(Storm): + """Link table to back `RockBase.processors`.""" + + __storm_table__ = "RockBaseArch" + __storm_primary__ = ("rock_base_id", "processor_id") + + rock_base_id = Int(name="rock_base", allow_none=False) + rock_base = Reference(rock_base_id, "RockBase.id") + + processor_id = Int(name="processor", allow_none=False) + processor = Reference(processor_id, "Processor.id") + + +@implementer(IRockBaseSet) +class RockBaseSet: + """See `IRockBaseSet`.""" + + def new( + self, + registrant, + distro_series, + build_channels, + processors=None, + date_created=DEFAULT, + ): + """See `IRockBaseSet`.""" + try: + self.getByDistroSeries(distro_series) + except NoSuchRockBase: + pass + else: + raise DuplicateRockBase(distro_series) + store = IMasterStore(RockBase) + rock_base = RockBase( + registrant, + distro_series, + build_channels, + date_created=date_created, + ) + store.add(rock_base) + if processors is None: + processors = [ + das.processor for das in distro_series.enabled_architectures + ] + rock_base.setProcessors(processors) + return rock_base + + def __iter__(self): + """See `IRockBaseSet`.""" + return iter(self.getAll()) + + def getByID(self, id): + """See `IRockBaseSet`.""" + return IStore(RockBase).get(RockBase, id) + + def getByDistroSeries(self, distro_series): + """See `IRockBaseSet`.""" + rock_base = ( + IStore(RockBase).find(RockBase, distro_series=distro_series).one() + ) + if rock_base is None: + raise NoSuchRockBase(distro_series) + return rock_base + + def getAll(self): + """See `IRockBaseSet`.""" + return ( + IStore(RockBase).find(RockBase).order_by(RockBase.distro_series_id) + ) diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py index 00d9c80..3d89dcc 100644 --- a/lib/lp/rocks/model/rockrecipe.py +++ b/lib/lp/rocks/model/rockrecipe.py @@ -23,6 +23,7 @@ from storm.locals import ( ======= "get_rock_recipe_privacy_filter", ] + from datetime import timezone from operator import attrgetter, itemgetter @@ -93,6 +94,7 @@ from lp.registry.model.distroseries import DistroSeries from lp.registry.model.product import Product from lp.registry.model.series import ACTIVE_STATUSES from lp.rocks.adapters.buildarch import determine_instances_to_build +from lp.rocks.interfaces.rockbase import IRockBaseSet, NoSuchRockBase from lp.rocks.interfaces.rockrecipe import ( ROCK_RECIPE_ALLOW_CREATE, ROCK_RECIPE_PRIVATE_FEATURE_FLAG, @@ -121,9 +123,11 @@ from lp.rocks.interfaces.rockrecipe import ( ) from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet from lp.rocks.interfaces.rockrecipejob import IRockRecipeRequestBuildsJobSource -from lp.rocks.model.rockrecipebuild import RockRecipeBuild <<<<<<< lib/lp/rocks/model/rockrecipe.py +from lp.rocks.model.rockrecipebuild import RockRecipeBuild ======= +from lp.rocks.model.rockbase import RockBase +from lp.rocks.model.rockrecipebuild import RockRecipeBuild from lp.rocks.model.rockrecipejob import RockRecipeJob >>>>>>> lib/lp/rocks/model/rockrecipe.py from lp.services.database.bulk import load_related @@ -435,6 +439,8 @@ class RockRecipe(StormBase): # XXX jugmac00 2024-08-29: Finish implementing this once we have # more privacy infrastructure. return False + + def _isBuildableArchitectureAllowed(self, das): ======= if user is None: return False @@ -447,15 +453,16 @@ class RockRecipe(StormBase): ) .is_empty() ) ->>>>>>> lib/lp/rocks/model/rockrecipe.py - def _isBuildableArchitectureAllowed(self, das): + def _isBuildableArchitectureAllowed(self, das, rock_base=None): +>>>>>>> lib/lp/rocks/model/rockrecipe.py """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`). """ +<<<<<<< lib/lp/rocks/model/rockrecipe.py return das.enabled and ( das.processor.supports_virtualized or not self.require_virtualized ) @@ -465,6 +472,22 @@ class RockRecipe(StormBase): return ( das.getChroot() is not None and self._isBuildableArchitectureAllowed(das) +======= + return ( + das.enabled + and ( + das.processor.supports_virtualized + or not self.require_virtualized + ) + and (rock_base is None or das.processor in rock_base.processors) + ) + + def _isArchitectureAllowed(self, das, rock_base=None): + """Check whether we may build for a `DistroArchSeries`.""" + return ( + das.getChroot() is not None + and self._isBuildableArchitectureAllowed(das, rock_base=rock_base) +>>>>>>> lib/lp/rocks/model/rockrecipe.py ) def getAllowedArchitectures(self): @@ -489,10 +512,28 @@ class RockRecipe(StormBase): DistroSeries.status.is_in(ACTIVE_STATUSES), ) all_buildable_dases = DecoratedResultSet(results, itemgetter(0)) +<<<<<<< lib/lp/rocks/model/rockrecipe.py return [ das for das in all_buildable_dases if self._isBuildableArchitectureAllowed(das) +======= + rock_bases = { + rock_base.distro_series_id: rock_base + for rock_base in store.find( + RockBase, + RockBase.distro_series_id.is_in( + {das.distroseriesID for das in all_buildable_dases} + ), + ) + } + return [ + das + for das in all_buildable_dases + if self._isBuildableArchitectureAllowed( + das, rock_base=rock_bases.get(das.id) + ) +>>>>>>> lib/lp/rocks/model/rockrecipe.py ] def _checkRequestBuild(self, requester): @@ -503,7 +544,13 @@ class RockRecipe(StormBase): % (requester.display_name, self.owner.display_name) ) +<<<<<<< lib/lp/rocks/model/rockrecipe.py def requestBuild(self, build_request, distro_arch_series, channels=None): +======= + def requestBuild( + self, build_request, distro_arch_series, rock_base=None, channels=None + ): +>>>>>>> lib/lp/rocks/model/rockrecipe.py """Request a single build of this rock recipe. This method is for internal use; external callers should use @@ -517,7 +564,13 @@ class RockRecipe(StormBase): :return: `IRockRecipeBuild`. """ self._checkRequestBuild(build_request.requester) +<<<<<<< lib/lp/rocks/model/rockrecipe.py if not self._isArchitectureAllowed(distro_arch_series): +======= + if not self._isArchitectureAllowed( + distro_arch_series, rock_base=rock_base + ): +>>>>>>> lib/lp/rocks/model/rockrecipe.py raise RockRecipeBuildDisallowedArchitecture(distro_arch_series) if not channels: @@ -604,8 +657,23 @@ class RockRecipe(StormBase): builds = [] for das in instances_to_build: try: + rock_base = getUtility(IRockBaseSet).getByDistroSeries( + das.distroseries + ) + except NoSuchRockBase: + rock_base = None + if rock_base is not None: + arch_channels = dict(rock_base.build_channels) + channels_by_arch = arch_channels.pop("_byarch", {}) + if das.architecturetag in channels_by_arch: + arch_channels.update(channels_by_arch[das.architecturetag]) + if channels is not None: + arch_channels.update(channels) + else: + arch_channels = channels + try: build = self.requestBuild( - build_request, das, channels=channels + build_request, das, channels=arch_channels ) if logger is not None: logger.debug( diff --git a/lib/lp/rocks/security.py b/lib/lp/rocks/security.py index e45cec4..0309d1b 100644 --- a/lib/lp/rocks/security.py +++ b/lib/lp/rocks/security.py @@ -4,11 +4,24 @@ """Security adapters for the rocks package.""" __all__ = [] +<<<<<<< lib/lp/rocks/security.py 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 +======= +from lp.app.security import ( + AnonymousAuthorization, + AuthorizationBase, + DelegatedAuthorization, +) +from lp.rocks.interfaces.rockbase import IRockBase, IRockBaseSet +from lp.rocks.interfaces.rockrecipe import IRockRecipe, IRockRecipeBuildRequest +from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuild +from lp.security import AdminByBuilddAdmin +from lp.services.webapp.security import EditByRegistryExpertsOrAdmins +>>>>>>> lib/lp/rocks/security.py class ViewRockRecipe(AuthorizationBase): @@ -88,3 +101,20 @@ class EditRockRecipeBuild(AdminByBuilddAdmin): class AdminRockRecipeBuild(AdminByBuilddAdmin): usedfor = IRockRecipeBuild +<<<<<<< lib/lp/rocks/security.py +======= + + +class ViewRockBase(AnonymousAuthorization): + """Anyone can view an `IRockBase`.""" + + usedfor = IRockBase + + +class EditCharmBase(EditByRegistryExpertsOrAdmins): + usedfor = IRockBase + + +class EditCharmBaseSet(EditByRegistryExpertsOrAdmins): + usedfor = IRockBaseSet +>>>>>>> lib/lp/rocks/security.py diff --git a/lib/lp/rocks/tests/test_rockbase.py b/lib/lp/rocks/tests/test_rockbase.py new file mode 100644 index 0000000..e253217 --- /dev/null +++ b/lib/lp/rocks/tests/test_rockbase.py @@ -0,0 +1,370 @@ +# Copyright 2024 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Test bases for rocks.""" + +from testtools.matchers import ContainsDict, Equals +from zope.component import getAdapter, getUtility + +from lp.app.interfaces.security import IAuthorization +from lp.rocks.interfaces.rockbase import ( + IRockBase, + IRockBaseSet, + NoSuchRockBase, +) +from lp.services.webapp.interfaces import OAuthPermission +from lp.testing import ( + TestCaseWithFactory, + admin_logged_in, + api_url, + celebrity_logged_in, + logout, + person_logged_in, +) +from lp.testing.layers import DatabaseFunctionalLayer, ZopelessDatabaseLayer +from lp.testing.pages import webservice_for_person + + +class TestRockBase(TestCaseWithFactory): + + layer = ZopelessDatabaseLayer + + def test_implements_interface(self): + # RockBase implements IRockBase. + rock_base = self.factory.makeRockBase() + self.assertProvides(rock_base, IRockBase) + + def test_anonymous(self): + # Anyone can view an `IRockBase`. + rock_base = self.factory.makeRockBase() + authz = getAdapter(rock_base, IAuthorization, name="launchpad.View") + self.assertTrue(authz.checkUnauthenticated()) + + def test_destroySelf(self): + distro_series = self.factory.makeDistroSeries() + rock_base = self.factory.makeRockBase(distro_series=distro_series) + rock_base_set = getUtility(IRockBaseSet) + self.assertEqual( + rock_base, rock_base_set.getByDistroSeries(distro_series) + ) + rock_base.destroySelf() + self.assertRaises( + NoSuchRockBase, rock_base_set.getByDistroSeries, distro_series + ) + + +class TestRockBaseProcessors(TestCaseWithFactory): + + layer = ZopelessDatabaseLayer + + def setUp(self): + super().setUp(user="foo....@canonical.com") + self.unrestricted_procs = [ + self.factory.makeProcessor() for _ in range(3) + ] + self.restricted_procs = [ + self.factory.makeProcessor(restricted=True, build_by_default=False) + for _ in range(2) + ] + self.procs = self.unrestricted_procs + self.restricted_procs + self.factory.makeProcessor() + self.distroseries = self.factory.makeDistroSeries() + for processor in self.procs: + self.factory.makeDistroArchSeries( + distroseries=self.distroseries, + architecturetag=processor.name, + processor=processor, + ) + + def test_new_default_processors(self): + # RockBaseSet.new creates a RockBaseArch for each available + # Processor for the corresponding series. + rock_base = getUtility(IRockBaseSet).new( + registrant=self.factory.makePerson(), + distro_series=self.distroseries, + build_channels={}, + ) + self.assertContentEqual(self.procs, rock_base.processors) + + def test_new_override_processors(self): + # RockBaseSet.new can be given a custom set of processors. + rock_base = getUtility(IRockBaseSet).new( + registrant=self.factory.makePerson(), + distro_series=self.distroseries, + build_channels={}, + processors=self.procs[:2], + ) + self.assertContentEqual(self.procs[:2], rock_base.processors) + + def test_set(self): + # The property remembers its value correctly. + rock_base = self.factory.makeRockBase() + rock_base.setProcessors(self.restricted_procs) + self.assertContentEqual(self.restricted_procs, rock_base.processors) + rock_base.setProcessors(self.procs) + self.assertContentEqual(self.procs, rock_base.processors) + rock_base.setProcessors([]) + self.assertContentEqual([], rock_base.processors) + + +class TestRockBaseSet(TestCaseWithFactory): + + layer = ZopelessDatabaseLayer + + def test_getByDistroSeries(self): + distro_series = self.factory.makeDistroSeries() + rock_base_set = getUtility(IRockBaseSet) + rock_base = self.factory.makeRockBase(distro_series=distro_series) + self.factory.makeRockBase() + self.assertEqual( + rock_base, rock_base_set.getByDistroSeries(distro_series) + ) + self.assertRaises( + NoSuchRockBase, + rock_base_set.getByDistroSeries, + self.factory.makeDistroSeries(), + ) + + def test_getAll(self): + rock_bases = [self.factory.makeRockBase() for _ in range(3)] + self.assertContentEqual(rock_bases, getUtility(IRockBaseSet).getAll()) + + +class TestRockBaseWebservice(TestCaseWithFactory): + + layer = DatabaseFunctionalLayer + + def test_new_unprivileged(self): + # An unprivileged user cannot create a RockBase. + person = self.factory.makePerson() + distroseries = self.factory.makeDistroSeries() + distroseries_url = api_url(distroseries) + webservice = webservice_for_person( + person, permission=OAuthPermission.WRITE_PUBLIC + ) + webservice.default_api_version = "devel" + response = webservice.named_post( + "/+rock-bases", + "new", + distro_series=distroseries_url, + build_channels={"rockcraft": "stable"}, + ) + self.assertEqual(401, response.status) + + def test_new(self): + # A registry expert can create a RockBase. + person = self.factory.makeRegistryExpert() + distroseries = self.factory.makeDistroSeries() + distroseries_url = api_url(distroseries) + webservice = webservice_for_person( + person, permission=OAuthPermission.WRITE_PUBLIC + ) + webservice.default_api_version = "devel" + logout() + response = webservice.named_post( + "/+rock-bases", + "new", + distro_series=distroseries_url, + build_channels={"rockcraft": "stable"}, + ) + self.assertEqual(201, response.status) + rock_base = webservice.get(response.getHeader("Location")).jsonBody() + with person_logged_in(person): + self.assertThat( + rock_base, + ContainsDict( + { + "registrant_link": Equals( + webservice.getAbsoluteUrl(api_url(person)) + ), + "distro_series_link": Equals( + webservice.getAbsoluteUrl(distroseries_url) + ), + "build_channels": Equals({"rockcraft": "stable"}), + } + ), + ) + + def test_new_duplicate_distro_series(self): + # An attempt to create a RockBase with a duplicate distro series is + # rejected. + person = self.factory.makeRegistryExpert() + distroseries = self.factory.makeDistroSeries() + distroseries_str = str(distroseries) + distroseries_url = api_url(distroseries) + webservice = webservice_for_person( + person, permission=OAuthPermission.WRITE_PUBLIC + ) + webservice.default_api_version = "devel" + logout() + response = webservice.named_post( + "/+rock-bases", + "new", + distro_series=distroseries_url, + build_channels={"rockcraft": "stable"}, + ) + self.assertEqual(201, response.status) + response = webservice.named_post( + "/+rock-bases", + "new", + distro_series=distroseries_url, + build_channels={"rockcraft": "stable"}, + ) + self.assertEqual(400, response.status) + self.assertEqual( + ( + "%s is already in use by another base." % distroseries_str + ).encode(), + response.body, + ) + + def test_getByDistroSeries(self): + # lp.rock_bases.getByDistroSeries returns a matching RockBase. + person = self.factory.makePerson() + distroseries = self.factory.makeDistroSeries() + distroseries_url = api_url(distroseries) + webservice = webservice_for_person( + person, permission=OAuthPermission.READ_PUBLIC + ) + webservice.default_api_version = "devel" + with celebrity_logged_in("registry_experts"): + self.factory.makeRockBase(distro_series=distroseries) + response = webservice.named_get( + "/+rock-bases", "getByDistroSeries", distro_series=distroseries_url + ) + self.assertEqual(200, response.status) + self.assertEqual( + webservice.getAbsoluteUrl(distroseries_url), + response.jsonBody()["distro_series_link"], + ) + + def test_getByDistroSeries_missing(self): + # lp.rock_bases.getByDistroSeries returns 404 for a non-existent + # RockBase. + person = self.factory.makePerson() + distroseries = self.factory.makeDistroSeries() + distroseries_str = str(distroseries) + distroseries_url = api_url(distroseries) + webservice = webservice_for_person( + person, permission=OAuthPermission.READ_PUBLIC + ) + webservice.default_api_version = "devel" + logout() + response = webservice.named_get( + "/+rock-bases", "getByDistroSeries", distro_series=distroseries_url + ) + self.assertEqual(404, response.status) + self.assertEqual( + ("No base for %s." % distroseries_str).encode(), response.body + ) + + def setUpProcessors(self): + self.unrestricted_procs = [ + self.factory.makeProcessor() for _ in range(3) + ] + self.unrestricted_proc_names = [ + processor.name for processor in self.unrestricted_procs + ] + self.restricted_procs = [ + self.factory.makeProcessor(restricted=True, build_by_default=False) + for _ in range(2) + ] + self.restricted_proc_names = [ + processor.name for processor in self.restricted_procs + ] + self.procs = self.unrestricted_procs + self.restricted_procs + self.factory.makeProcessor() + self.distroseries = self.factory.makeDistroSeries() + for processor in self.procs: + self.factory.makeDistroArchSeries( + distroseries=self.distroseries, + architecturetag=processor.name, + processor=processor, + ) + + def setProcessors(self, user, rock_base_url, names): + ws = webservice_for_person( + user, permission=OAuthPermission.WRITE_PUBLIC + ) + return ws.named_post( + rock_base_url, + "setProcessors", + processors=["/+processors/%s" % name for name in names], + api_version="devel", + ) + + def assertProcessors(self, user, rock_base_url, names): + body = ( + webservice_for_person(user) + .get(rock_base_url + "/processors", api_version="devel") + .jsonBody() + ) + self.assertContentEqual( + names, [entry["name"] for entry in body["entries"]] + ) + + def test_setProcessors_admin(self): + """An admin can change the supported processor set.""" + self.setUpProcessors() + with admin_logged_in(): + rock_base = self.factory.makeRockBase( + distro_series=self.distroseries, + processors=self.unrestricted_procs, + ) + rock_base_url = api_url(rock_base) + admin = self.factory.makeAdministrator() + self.assertProcessors( + admin, rock_base_url, self.unrestricted_proc_names + ) + + response = self.setProcessors( + admin, + rock_base_url, + [self.unrestricted_proc_names[0], self.restricted_proc_names[0]], + ) + self.assertEqual(200, response.status) + self.assertProcessors( + admin, + rock_base_url, + [self.unrestricted_proc_names[0], self.restricted_proc_names[0]], + ) + + def test_setProcessors_non_admin_forbidden(self): + """Only admins and registry experts can call setProcessors.""" + self.setUpProcessors() + with admin_logged_in(): + rock_base = self.factory.makeRockBase( + distro_series=self.distroseries + ) + rock_base_url = api_url(rock_base) + person = self.factory.makePerson() + + response = self.setProcessors( + person, rock_base_url, [self.unrestricted_proc_names[0]] + ) + self.assertEqual(401, response.status) + + def test_collection(self): + # lp.rock_bases is a collection of all RockBases. + person = self.factory.makePerson() + webservice = webservice_for_person( + person, permission=OAuthPermission.READ_PUBLIC + ) + webservice.default_api_version = "devel" + distroseries_urls = [] + with celebrity_logged_in("registry_experts"): + for _ in range(3): + distroseries = self.factory.makeDistroSeries() + distroseries_urls.append( + webservice.getAbsoluteUrl(api_url(distroseries)) + ) + self.factory.makeRockBase(distro_series=distroseries) + response = webservice.get("/+rock-bases") + self.assertEqual(200, response.status) + self.assertContentEqual( + distroseries_urls, + [ + entry["distro_series_link"] + for entry in response.jsonBody()["entries"] + ], + ) diff --git a/lib/lp/rocks/tests/test_rockrecipe.py b/lib/lp/rocks/tests/test_rockrecipe.py index 3ccc9b0..8c41291 100644 --- a/lib/lp/rocks/tests/test_rockrecipe.py +++ b/lib/lp/rocks/tests/test_rockrecipe.py @@ -344,6 +344,80 @@ class TestRockRecipe(TestCaseWithFactory): ), ) + def test_requestBuildsFromJob_rock_base_architectures(self): + # requestBuildsFromJob intersects the architectures supported by the + # rock base with any other constraints. + self.useFixture(GitHostingFixture(blob="name: foo\n")) + job = self.makeRequestBuildsJob("20.04", ["sparc", "i386", "avr"]) + distroseries = getUtility(ILaunchpadCelebrities).ubuntu.getSeries( + "20.04" + ) + with admin_logged_in(): + self.factory.makeRockBase( + distro_series=distroseries, + build_channels={"rockcraft": "stable/launchpad-buildd"}, + processors=[ + distroseries[arch_tag].processor + for arch_tag in ("sparc", "avr") + ], + ) + transaction.commit() + with person_logged_in(job.requester): + builds = job.recipe.requestBuildsFromJob( + job.build_request, channels=removeSecurityProxy(job.channels) + ) + self.assertRequestedBuildsMatch( + builds, job, "20.04", ["sparc", "avr"], job.channels + ) + + def test_requestBuildsFromJob_rock_base_build_channels_by_arch(self): + # If the rock base declares different build channels for specific + # architectures, then requestBuildsFromJob uses those when + # requesting builds for those architectures. + self.useFixture(GitHostingFixture(blob="name: foo\n")) + job = self.makeRequestBuildsJob("20.04", ["avr", "riscv64"]) + distroseries = getUtility(ILaunchpadCelebrities).ubuntu.getSeries( + "20.04" + ) + with admin_logged_in(): + self.factory.makeRockBase( + distro_series=distroseries, + build_channels={ + "core20": "stable", + "_byarch": {"riscv64": {"core20": "candidate"}}, + }, + ) + transaction.commit() + with person_logged_in(job.requester): + builds = job.recipe.requestBuildsFromJob( + job.build_request, channels=removeSecurityProxy(job.channels) + ) + self.assertThat( + builds, + MatchesSetwise( + *( + MatchesStructure( + requester=Equals(job.requester), + recipe=Equals(job.recipe), + distro_arch_series=MatchesStructure( + distroseries=MatchesStructure.byEquality( + version="20.04" + ), + architecturetag=Equals(arch_tag), + ), + channels=Equals(channels), + ) + for arch_tag, channels in ( + ("avr", {"rockcraft": "edge", "core20": "stable"}), + ( + "riscv64", + {"rockcraft": "edge", "core20": "candidate"}, + ), + ) + ) + ), + ) + def test_requestBuildsFromJob_restricts_explicit_list(self): # requestBuildsFromJob limits builds targeted at an explicit list of # architectures to those allowed for the recipe. diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py index 609fc08..395bcd7 100644 --- a/lib/lp/testing/factory.py +++ b/lib/lp/testing/factory.py @@ -14,6 +14,7 @@ __all__ = [ "remove_security_proxy_and_shout_at_engineer", ] + import base64 import hashlib import os @@ -208,6 +209,10 @@ from lp.registry.model.karma import KarmaTotalCache from lp.registry.model.milestone import Milestone from lp.registry.model.packaging import Packaging from lp.registry.model.suitesourcepackage import SuiteSourcePackage +<<<<<<< lib/lp/testing/factory.py +======= +from lp.rocks.interfaces.rockbase import IRockBaseSet +>>>>>>> lib/lp/testing/factory.py from lp.rocks.interfaces.rockrecipe import IRockRecipeSet from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuildSet from lp.rocks.model.rockrecipebuild import RockFile @@ -7038,6 +7043,32 @@ class LaunchpadObjectFactory(ObjectFactory): library_file = self.makeLibraryFileAlias() return ProxyFactory(RockFile(build=build, library_file=library_file)) +<<<<<<< lib/lp/testing/factory.py +======= + def makeRockBase( + self, + registrant=None, + distro_series=None, + build_channels=None, + processors=None, + date_created=DEFAULT, + ): + """Make a new RockBase.""" + if registrant is None: + registrant = self.makePerson() + if distro_series is None: + distro_series = self.makeDistroSeries() + if build_channels is None: + build_channels = {"rockcraft": "stable"} + return getUtility(IRockBaseSet).new( + registrant, + distro_series, + build_channels, + processors=processors, + date_created=date_created, + ) + +>>>>>>> lib/lp/testing/factory.py def makeCIBuild( self, git_repository=None,
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp