launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31479
[Merge] ~jugmac00/launchpad:add-explicit-model-for-rock-bases into launchpad:master
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.bar@xxxxxxxxxxxxx")
+ 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,
Follow ups