← Back to team overview

launchpad-reviewers team mailing list archive

[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