← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-base into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-base into launchpad:master.

Commit message:
Add explicit model for charm bases

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/409010

This allows us to configure the build channels used for each base, and the architectures for which builds for each base will be dispatched.  In particular, it will allow us to avoid dispatching i386 charm recipe builds for Ubuntu 20.04.

The model is essentially a cut-down version of `SnapBase`, looked up by distroseries instead of name since that's how `charmcraft.yaml` specifies bases.

DB MP: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/409007
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-base into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 8779ef9..f664a4a 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -154,6 +154,8 @@ public.bugtrackercomponentgroup         = SELECT, INSERT, UPDATE
 public.bugtrackerperson                 = SELECT, UPDATE
 public.bugwatchactivity                 = SELECT, INSERT, UPDATE
 public.buildfarmjob                     = DELETE
+public.charmbase                        = SELECT, INSERT, UPDATE, DELETE
+public.charmbasearch                    = SELECT, INSERT, DELETE
 public.charmfile                        = SELECT, INSERT, UPDATE, DELETE
 public.charmrecipe                      = SELECT, INSERT, UPDATE, DELETE
 public.charmrecipebuild                 = SELECT, INSERT, UPDATE, DELETE
@@ -2322,6 +2324,7 @@ public.bugtracker                       = SELECT, UPDATE
 public.bugtrackerperson                 = SELECT, UPDATE
 public.bugwatch                         = SELECT, UPDATE
 public.builder                          = SELECT, UPDATE
+public.charmbase                        = SELECT, UPDATE
 public.charmrecipe                      = SELECT, UPDATE
 public.charmrecipebuild                 = SELECT, UPDATE
 public.codeimport                       = SELECT, UPDATE
@@ -2816,6 +2819,8 @@ public.archive                          = SELECT
 public.builder                          = SELECT
 public.buildfarmjob                     = SELECT, INSERT
 public.buildqueue                       = SELECT, INSERT, UPDATE
+public.charmbase                        = SELECT
+public.charmbasearch                    = SELECT
 public.charmfile                        = SELECT
 public.charmrecipe                      = SELECT, UPDATE
 public.charmrecipebuild                 = SELECT, INSERT, UPDATE
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
index 58c3025..8bc341b 100644
--- a/lib/lp/app/browser/launchpad.py
+++ b/lib/lp/app/browser/launchpad.py
@@ -94,6 +94,7 @@ from lp.bugs.interfaces.bug import IBugSet
 from lp.bugs.interfaces.malone import IMaloneApplication
 from lp.buildmaster.interfaces.builder import IBuilderSet
 from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.charms.interfaces.charmbase import ICharmBaseSet
 from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
 from lp.code.errors import (
     CannotHaveLinkedBranch,
@@ -855,6 +856,7 @@ class LaunchpadRootNavigation(Navigation):
         'branches': IBranchSet,
         'bugs': IMaloneApplication,
         'builders': IBuilderSet,
+        '+charm-bases': ICharmBaseSet,
         '+charm-recipes': ICharmRecipeSet,
         '+code-index': IBazaarApplication,
         '+code-imports': ICodeImportSet,
diff --git a/lib/lp/charms/browser/charmbase.py b/lib/lp/charms/browser/charmbase.py
new file mode 100644
index 0000000..706a404
--- /dev/null
+++ b/lib/lp/charms/browser/charmbase.py
@@ -0,0 +1,26 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Views of bases for charms."""
+
+__metaclass__ = type
+__all__ = [
+    "CharmBaseSetNavigation",
+    ]
+
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmbase import ICharmBaseSet
+from lp.services.webapp.publisher import Navigation
+
+
+class CharmBaseSetNavigation(Navigation):
+    """Navigation methods for `ICharmBaseSet`."""
+    usedfor = ICharmBaseSet
+
+    def traverse(self, name):
+        try:
+            base_id = int(name)
+        except ValueError:
+            return None
+        return getUtility(ICharmBaseSet).getByID(base_id)
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 8e4ef3c..3741679 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -130,6 +130,18 @@
             factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
             permission="zope.Public" />
 
+        <browser:url
+            for="lp.charms.interfaces.charmbase.ICharmBase"
+            path_expression="string:${id}"
+            parent_utility="lp.charms.interfaces.charmbase.ICharmBaseSet" />
+        <browser:url
+            for="lp.charms.interfaces.charmbase.ICharmBaseSet"
+            path_expression="string:+charm-bases"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+        <browser:navigation
+            module="lp.charms.browser.charmbase"
+            classes="CharmBaseSetNavigation" />
+
         <browser:page
             for="*"
             class="lp.app.browser.launchpad.Macro"
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 7f21ead..330a72d 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -103,6 +103,26 @@
         factory="lp.charms.model.charmrecipebuildbehaviour.CharmRecipeBuildBehaviour"
         permission="zope.Public" />
 
+    <!-- Bases for charms -->
+    <class class="lp.charms.model.charmbase.CharmBase">
+        <allow
+            interface="lp.charms.interfaces.charmbase.ICharmBaseView
+                       lp.charms.interfaces.charmbase.ICharmBaseEditableAttributes" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.charms.interfaces.charmbase.ICharmBaseEdit"
+            set_schema="lp.charms.interfaces.charmbase.ICharmBaseEditableAttributes" />
+    </class>
+    <securedutility
+        class="lp.charms.model.charmbase.CharmBaseSet"
+        provides="lp.charms.interfaces.charmbase.ICharmBaseSet">
+        <allow
+            interface="lp.charms.interfaces.charmbase.ICharmBaseSet" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.charms.interfaces.charmbase.ICharmBaseSetEdit" />
+    </securedutility>
+
     <!-- Charmhub interaction -->
     <securedutility
         class="lp.charms.model.charmhubclient.CharmhubClient"
diff --git a/lib/lp/charms/interfaces/charmbase.py b/lib/lp/charms/interfaces/charmbase.py
new file mode 100644
index 0000000..e8dc7d8
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmbase.py
@@ -0,0 +1,177 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for bases for charms."""
+
+__metaclass__ = type
+__all__ = [
+    "DuplicateCharmBase",
+    "ICharmBase",
+    "ICharmBaseSet",
+    "NoSuchCharmBase",
+    ]
+
+import http.client
+
+from lazr.restful.declarations import (
+    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,
+    REQUEST_USER,
+    )
+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 DuplicateCharmBase(Exception):
+    """Raised for charm bases with duplicate distro series."""
+
+    def __init__(self, distro_series):
+        super().__init__(
+            "%s is already in use by another base." % distro_series)
+
+
+class NoSuchCharmBase(NotFoundError):
+    """The requested `CharmBase` 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 ICharmBaseView(Interface):
+    """`ICharmBase` 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 charm base supports."),
+        value_type=Reference(schema=IProcessor),
+        readonly=True))
+
+
+class ICharmBaseEditableAttributes(Interface):
+    """`ICharmBase` 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 "
+            "charm 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 ICharmBaseEdit(Interface):
+    """`ICharmBase` 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 charm base supports."""
+
+    @export_destructor_operation()
+    @operation_for_version("devel")
+    def destroySelf():
+        """Delete the specified base."""
+
+
+# XXX cjwatson 2021-09-22 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 ICharmBase(ICharmBaseView, ICharmBaseEditableAttributes, ICharmBaseEdit):
+    """A base for charms."""
+
+
+class ICharmBaseSetEdit(Interface):
+    """`ICharmBaseSet` 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(ICharmBase, ["distro_series", "build_channels"])
+    @operation_for_version("devel")
+    def new(registrant, distro_series, build_channels, processors=None,
+            date_created=None):
+        """Create an `ICharmBase`."""
+
+
+@exported_as_webservice_collection(ICharmBase)
+class ICharmBaseSet(ICharmBaseSetEdit):
+    """Interface representing the set of bases for charms."""
+
+    def __iter__():
+        """Iterate over `ICharmBase`s."""
+
+    def getByID(id):
+        """Return the `ICharmBase` with this ID, or None."""
+
+    @operation_parameters(
+        distro_series=Reference(
+            schema=IDistroSeries, title=_("Distro series"), required=True))
+    @operation_returns_entry(ICharmBase)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getByDistroSeries(distro_series):
+        """Return the `ICharmBase` for this distro series.
+
+        :raises NoSuchCharmBase: if no base exists for this distro series.
+        """
+
+    @collection_default_content()
+    def getAll():
+        """Return all `ICharmBase`s."""
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 8b7e9d5..e32d18f 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -313,7 +313,8 @@ class ICharmRecipeView(Interface):
     def visibleByUser(user):
         """Can the specified user see this charm recipe?"""
 
-    def requestBuild(build_request, distro_arch_series, channels=None):
+    def requestBuild(build_request, distro_arch_series, charm_base=None,
+                     channels=None):
         """Request a single build of this charm recipe.
 
         This method is for internal use; external callers should use
@@ -322,6 +323,7 @@ class ICharmRecipeView(Interface):
         :param build_request: The `ICharmRecipeBuildRequest` job being
             processed.
         :param distro_arch_series: The architecture to build for.
+        :param charm_base: The `ICharmBase` to use for this build.
         :param channels: A dictionary mapping snap names to channels to use
             for this build.
         :return: `ICharmRecipeBuild`.
diff --git a/lib/lp/charms/interfaces/webservice.py b/lib/lp/charms/interfaces/webservice.py
index 213747f..5294ec1 100644
--- a/lib/lp/charms/interfaces/webservice.py
+++ b/lib/lp/charms/interfaces/webservice.py
@@ -10,12 +10,18 @@ which tells `lazr.restful` that it should look for webservice exports here.
 """
 
 __all__ = [
+    "ICharmBase",
+    "ICharmBaseSet",
     "ICharmRecipe",
     "ICharmRecipeBuild",
     "ICharmRecipeBuildRequest",
     "ICharmRecipeSet",
     ]
 
+from lp.charms.interfaces.charmbase import (
+    ICharmBase,
+    ICharmBaseSet,
+    )
 from lp.charms.interfaces.charmrecipe import (
     ICharmRecipe,
     ICharmRecipeBuildRequest,
diff --git a/lib/lp/charms/model/charmbase.py b/lib/lp/charms/model/charmbase.py
new file mode 100644
index 0000000..b2b980c
--- /dev/null
+++ b/lib/lp/charms/model/charmbase.py
@@ -0,0 +1,148 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Bases for charms."""
+
+__metaclass__ = type
+__all__ = [
+    "CharmBase",
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Int,
+    JSON,
+    Reference,
+    Store,
+    Storm,
+    )
+from zope.interface import implementer
+
+from lp.buildmaster.model.processor import Processor
+from lp.charms.interfaces.charmbase import (
+    DuplicateCharmBase,
+    ICharmBase,
+    ICharmBaseSet,
+    NoSuchCharmBase,
+    )
+from lp.services.database.constants import DEFAULT
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+
+
+@implementer(ICharmBase)
+class CharmBase(Storm):
+    """See `ICharmBase`."""
+
+    __storm_table__ = "CharmBase"
+
+    id = Int(primary=True)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.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 == CharmBaseArch.processor_id,
+            CharmBaseArch.charm_base == self))
+
+    def setProcessors(self, processors):
+        """See `ICharmBase`."""
+        enablements = dict(Store.of(self).find(
+            (Processor, CharmBaseArch),
+            Processor.id == CharmBaseArch.processor_id,
+            CharmBaseArch.charm_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:
+                charm_base_arch = CharmBaseArch()
+                charm_base_arch.charm_base = self
+                charm_base_arch.processor = proc
+                Store.of(self).add(charm_base_arch)
+
+    processors = property(_getProcessors, setProcessors)
+
+    def destroySelf(self):
+        """See `ICharmBase`."""
+        Store.of(self).remove(self)
+
+
+class CharmBaseArch(Storm):
+    """Link table to back `CharmBase.processors`."""
+
+    __storm_table__ = "CharmBaseArch"
+    __storm_primary__ = ("charm_base_id", "processor_id")
+
+    charm_base_id = Int(name="charm_base", allow_none=False)
+    charm_base = Reference(charm_base_id, "CharmBase.id")
+
+    processor_id = Int(name="processor", allow_none=False)
+    processor = Reference(processor_id, "Processor.id")
+
+
+@implementer(ICharmBaseSet)
+class CharmBaseSet:
+    """See `ICharmBaseSet`."""
+
+    def new(self, registrant, distro_series, build_channels, processors=None,
+            date_created=DEFAULT):
+        """See `ICharmBaseSet`."""
+        try:
+            self.getByDistroSeries(distro_series)
+        except NoSuchCharmBase:
+            pass
+        else:
+            raise DuplicateCharmBase(distro_series)
+        store = IMasterStore(CharmBase)
+        charm_base = CharmBase(
+            registrant, distro_series, build_channels,
+            date_created=date_created)
+        store.add(charm_base)
+        if processors is None:
+            processors = [
+                das.processor for das in distro_series.enabled_architectures]
+        charm_base.setProcessors(processors)
+        return charm_base
+
+    def __iter__(self):
+        """See `ICharmBaseSet`."""
+        return iter(self.getAll())
+
+    def getByID(self, id):
+        """See `ICharmBaseSet`."""
+        return IStore(CharmBase).get(CharmBase, id)
+
+    def getByDistroSeries(self, distro_series):
+        """See `ICharmBaseSet`."""
+        charm_base = IStore(CharmBase).find(
+            CharmBase, distro_series=distro_series).one()
+        if charm_base is None:
+            raise NoSuchCharmBase(distro_series)
+        return charm_base
+
+    def getAll(self):
+        """See `ICharmBaseSet`."""
+        return IStore(CharmBase).find(CharmBase).order_by(
+            CharmBase.distro_series_id)
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index ebd765a..cb08d8a 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -61,6 +61,10 @@ from lp.buildmaster.model.builder import Builder
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.charms.adapters.buildarch import determine_instances_to_build
+from lp.charms.interfaces.charmbase import (
+    ICharmBaseSet,
+    NoSuchCharmBase,
+    )
 from lp.charms.interfaces.charmhubclient import ICharmhubClient
 from lp.charms.interfaces.charmrecipe import (
     BadCharmRecipeSearchContext,
@@ -89,6 +93,7 @@ from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
 from lp.charms.interfaces.charmrecipejob import (
     ICharmRecipeRequestBuildsJobSource,
     )
+from lp.charms.model.charmbase import CharmBase
 from lp.charms.model.charmrecipebuild import CharmRecipeBuild
 from lp.charms.model.charmrecipejob import (
     CharmRecipeJob,
@@ -438,7 +443,7 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
             CharmRecipe.id == self.id,
             get_charm_recipe_privacy_filter(user)).is_empty()
 
-    def _isBuildableArchitectureAllowed(self, das):
+    def _isBuildableArchitectureAllowed(self, das, charm_base=None):
         """Check whether we may build for a buildable `DistroArchSeries`.
 
         The caller is assumed to have already checked that a suitable chroot
@@ -449,13 +454,15 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
             das.enabled
             and (
                 das.processor.supports_virtualized
-                or not self.require_virtualized))
+                or not self.require_virtualized)
+            and (charm_base is None or das.processor in charm_base.processors))
 
-    def _isArchitectureAllowed(self, das):
+    def _isArchitectureAllowed(self, das, charm_base=None):
         """Check whether we may build for a `DistroArchSeries`."""
         return (
             das.getChroot() is not None
-            and self._isBuildableArchitectureAllowed(das))
+            and self._isBuildableArchitectureAllowed(
+                das, charm_base=charm_base))
 
     def getAllowedArchitectures(self):
         """See `ICharmRecipe`."""
@@ -476,9 +483,16 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
             (DistroArchSeries, DistroSeries, Distribution),
             DistroSeries.status.is_in(ACTIVE_STATUSES)).config(distinct=True)
         all_buildable_dases = DecoratedResultSet(results, itemgetter(0))
+        charm_bases = {
+            charm_base.distro_series_id: charm_base
+            for charm_base in store.find(
+                CharmBase,
+                CharmBase.distro_series_id.is_in(
+                    set(das.distroseriesID for das in all_buildable_dases)))}
         return [
             das for das in all_buildable_dases
-            if self._isBuildableArchitectureAllowed(das)]
+            if self._isBuildableArchitectureAllowed(
+                das, charm_base=charm_bases.get(das.id))]
 
     def _checkRequestBuild(self, requester):
         """May `requester` request builds of this charm recipe?"""
@@ -487,7 +501,8 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
                 "%s cannot create charm recipe builds owned by %s." %
                 (requester.display_name, self.owner.display_name))
 
-    def requestBuild(self, build_request, distro_arch_series, channels=None):
+    def requestBuild(self, build_request, distro_arch_series, charm_base=None,
+                     channels=None):
         """Request a single build of this charm recipe.
 
         This method is for internal use; external callers should use
@@ -501,7 +516,8 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
         :return: `ICharmRecipeBuild`.
         """
         self._checkRequestBuild(build_request.requester)
-        if not self._isArchitectureAllowed(distro_arch_series):
+        if not self._isArchitectureAllowed(
+                distro_arch_series, charm_base=charm_base):
             raise CharmRecipeBuildDisallowedArchitecture(distro_arch_series)
 
         if not channels:
@@ -569,8 +585,22 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
         builds = []
         for das in instances_to_build:
             try:
+                charm_base = getUtility(ICharmBaseSet).getByDistroSeries(
+                    das.distroseries)
+            except NoSuchCharmBase:
+                charm_base = None
+            if charm_base is not None:
+                arch_channels = dict(charm_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(
                         " - %s/%s/%s %s/%s/%s: Build requested.",
diff --git a/lib/lp/charms/tests/test_charmbase.py b/lib/lp/charms/tests/test_charmbase.py
new file mode 100644
index 0000000..c2be96e
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmbase.py
@@ -0,0 +1,315 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test bases for charms."""
+
+__metaclass__ = type
+
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    )
+from zope.component import (
+    getAdapter,
+    getUtility,
+    )
+
+from lp.app.interfaces.security import IAuthorization
+from lp.charms.interfaces.charmbase import (
+    ICharmBase,
+    ICharmBaseSet,
+    NoSuchCharmBase,
+    )
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.testing import (
+    admin_logged_in,
+    api_url,
+    celebrity_logged_in,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    ZopelessDatabaseLayer,
+    )
+from lp.testing.pages import webservice_for_person
+
+
+class TestCharmBase(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_implements_interface(self):
+        # CharmBase implements ICharmBase.
+        charm_base = self.factory.makeCharmBase()
+        self.assertProvides(charm_base, ICharmBase)
+
+    def test_anonymous(self):
+        # Anyone can view an `ICharmBase`.
+        charm_base = self.factory.makeCharmBase()
+        authz = getAdapter(charm_base, IAuthorization, name="launchpad.View")
+        self.assertTrue(authz.checkUnauthenticated())
+
+    def test_destroySelf(self):
+        distro_series = self.factory.makeDistroSeries()
+        charm_base = self.factory.makeCharmBase(distro_series=distro_series)
+        charm_base_set = getUtility(ICharmBaseSet)
+        self.assertEqual(
+            charm_base, charm_base_set.getByDistroSeries(distro_series))
+        charm_base.destroySelf()
+        self.assertRaises(
+            NoSuchCharmBase, charm_base_set.getByDistroSeries, distro_series)
+
+
+class TestCharmBaseProcessors(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):
+        # CharmBaseSet.new creates a CharmBaseArch for each available
+        # Processor for the corresponding series.
+        charm_base = getUtility(ICharmBaseSet).new(
+            registrant=self.factory.makePerson(),
+            distro_series=self.distroseries, build_channels={})
+        self.assertContentEqual(self.procs, charm_base.processors)
+
+    def test_new_override_processors(self):
+        # CharmBaseSet.new can be given a custom set of processors.
+        charm_base = getUtility(ICharmBaseSet).new(
+            registrant=self.factory.makePerson(),
+            distro_series=self.distroseries, build_channels={},
+            processors=self.procs[:2])
+        self.assertContentEqual(self.procs[:2], charm_base.processors)
+
+    def test_set(self):
+        # The property remembers its value correctly.
+        charm_base = self.factory.makeCharmBase()
+        charm_base.setProcessors(self.restricted_procs)
+        self.assertContentEqual(self.restricted_procs, charm_base.processors)
+        charm_base.setProcessors(self.procs)
+        self.assertContentEqual(self.procs, charm_base.processors)
+        charm_base.setProcessors([])
+        self.assertContentEqual([], charm_base.processors)
+
+
+class TestCharmBaseSet(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_getByDistroSeries(self):
+        distro_series = self.factory.makeDistroSeries()
+        charm_base_set = getUtility(ICharmBaseSet)
+        charm_base = self.factory.makeCharmBase(distro_series=distro_series)
+        self.factory.makeCharmBase()
+        self.assertEqual(
+            charm_base, charm_base_set.getByDistroSeries(distro_series))
+        self.assertRaises(
+            NoSuchCharmBase, charm_base_set.getByDistroSeries,
+            self.factory.makeDistroSeries())
+
+    def test_getAll(self):
+        charm_bases = [self.factory.makeCharmBase() for _ in range(3)]
+        self.assertContentEqual(
+            charm_bases, getUtility(ICharmBaseSet).getAll())
+
+
+class TestCharmBaseWebservice(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_new_unpriv(self):
+        # An unprivileged user cannot create a CharmBase.
+        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(
+            "/+charm-bases", "new", distro_series=distroseries_url,
+            build_channels={"charmcraft": "stable"})
+        self.assertEqual(401, response.status)
+
+    def test_new(self):
+        # A registry expert can create a CharmBase.
+        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(
+            "/+charm-bases", "new", distro_series=distroseries_url,
+            build_channels={"charmcraft": "stable"})
+        self.assertEqual(201, response.status)
+        charm_base = webservice.get(response.getHeader("Location")).jsonBody()
+        with person_logged_in(person):
+            self.assertThat(charm_base, ContainsDict({
+                "registrant_link": Equals(
+                    webservice.getAbsoluteUrl(api_url(person))),
+                "distro_series_link": Equals(
+                    webservice.getAbsoluteUrl(distroseries_url)),
+                "build_channels": Equals({"charmcraft": "stable"}),
+                }))
+
+    def test_new_duplicate_distro_series(self):
+        # An attempt to create a CharmBase 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(
+            "/+charm-bases", "new", distro_series=distroseries_url,
+            build_channels={"charmcraft": "stable"})
+        self.assertEqual(201, response.status)
+        response = webservice.named_post(
+            "/+charm-bases", "new", distro_series=distroseries_url,
+            build_channels={"charmcraft": "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.charm_bases.getByDistroSeries returns a matching CharmBase.
+        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.makeCharmBase(distro_series=distroseries)
+        response = webservice.named_get(
+            "/+charm-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.charm_bases.getByDistroSeries returns 404 for a non-existent
+        # CharmBase.
+        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(
+            "/+charm-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, charm_base_url, names):
+        ws = webservice_for_person(
+            user, permission=OAuthPermission.WRITE_PUBLIC)
+        return ws.named_post(
+            charm_base_url, "setProcessors",
+            processors=["/+processors/%s" % name for name in names],
+            api_version="devel")
+
+    def assertProcessors(self, user, charm_base_url, names):
+        body = webservice_for_person(user).get(
+            charm_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():
+            charm_base = self.factory.makeCharmBase(
+                distro_series=self.distroseries,
+                processors=self.unrestricted_procs)
+            charm_base_url = api_url(charm_base)
+        admin = self.factory.makeAdministrator()
+        self.assertProcessors(
+            admin, charm_base_url, self.unrestricted_proc_names)
+
+        response = self.setProcessors(
+            admin, charm_base_url,
+            [self.unrestricted_proc_names[0], self.restricted_proc_names[0]])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(
+            admin, charm_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():
+            charm_base = self.factory.makeCharmBase(
+                distro_series=self.distroseries)
+            charm_base_url = api_url(charm_base)
+        person = self.factory.makePerson()
+
+        response = self.setProcessors(
+            person, charm_base_url, [self.unrestricted_proc_names[0]])
+        self.assertEqual(401, response.status)
+
+    def test_collection(self):
+        # lp.charm_bases is a collection of all CharmBases.
+        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 i in range(3):
+                distroseries = self.factory.makeDistroSeries()
+                distroseries_urls.append(
+                    webservice.getAbsoluteUrl(api_url(distroseries)))
+                self.factory.makeCharmBase(distro_series=distroseries)
+        response = webservice.get("/+charm-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/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index a399743..39987f2 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -635,6 +635,59 @@ class TestCharmRecipe(TestCaseWithFactory):
         self.assertRequestedBuildsMatch(
             builds, job, "20.04", ["avr", "riscv64"], job.channels)
 
+    def test_requestBuildsFromJob_charm_base_architectures(self):
+        # requestBuildsFromJob intersects the architectures supported by the
+        # charm 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.makeCharmBase(
+                distro_series=distroseries,
+                build_channels={"charmcraft": "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_charm_base_build_channels_by_arch(self):
+        # If the charm 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.makeCharmBase(
+                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", {"charmcraft": "edge", "core20": "stable"}),
+                  ("riscv64", {"charmcraft": "edge", "core20": "candidate"}),
+                  ))))
+
     def test_requestBuildsFromJob_triggers_webhooks(self):
         # requestBuildsFromJob triggers webhooks, and the payload includes a
         # link to the build request.
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 9cd579b..08f1065 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -65,6 +65,10 @@ from lp.buildmaster.interfaces.builder import (
     )
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJob
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.charms.interfaces.charmbase import (
+    ICharmBase,
+    ICharmBaseSet,
+    )
 from lp.charms.interfaces.charmrecipe import (
     ICharmRecipe,
     ICharmRecipeBuildRequest,
@@ -3701,3 +3705,16 @@ class EditCharmRecipeBuild(AdminByBuilddAdmin):
 
 class AdminCharmRecipeBuild(AdminByBuilddAdmin):
     usedfor = ICharmRecipeBuild
+
+
+class ViewCharmBase(AnonymousAuthorization):
+    """Anyone can view an `ICharmBase`."""
+    usedfor = ICharmBase
+
+
+class EditCharmBase(EditByRegistryExpertsOrAdmins):
+    usedfor = ICharmBase
+
+
+class EditCharmBaseSet(EditByRegistryExpertsOrAdmins):
+    usedfor = ICharmBaseSet
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index c70ffd1..96efcf6 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -277,6 +277,10 @@
                 <xsl:text>/builders/</xsl:text>
                 <var>&lt;builder.name&gt;</var>
             </xsl:when>
+            <xsl:when test="@id = 'charm_base'">
+                <xsl:text>/+charm-bases/</xsl:text>
+                <var>&lt;name&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'charm_recipe'">
                 <xsl:text>/~</xsl:text>
                 <var>&lt;person.name&gt;</var>
@@ -772,6 +776,9 @@
     <xsl:template name="find-root-object-uri">
         <xsl:value-of select="$base"/>
         <xsl:choose>
+            <xsl:when test="@id = 'charm_bases'">
+                <xsl:text>/+charm-bases</xsl:text>
+            </xsl:when>
             <xsl:when test="@id = 'charm_recipes'">
                 <xsl:text>/+charm-recipes</xsl:text>
             </xsl:when>
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 084cee1..9793724 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -109,6 +109,7 @@ from lp.buildmaster.enums import (
     )
 from lp.buildmaster.interfaces.builder import IBuilderSet
 from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.charms.interfaces.charmbase import ICharmBaseSet
 from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
 from lp.charms.model.charmrecipebuild import CharmFile
@@ -5205,6 +5206,20 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             library_file = self.makeLibraryFileAlias()
         return ProxyFactory(CharmFile(build=build, library_file=library_file))
 
+    def makeCharmBase(self, registrant=None, distro_series=None,
+                      build_channels=None, processors=None,
+                      date_created=DEFAULT):
+        """Make a new CharmBase."""
+        if registrant is None:
+            registrant = self.makePerson()
+        if distro_series is None:
+            distro_series = self.makeDistroSeries()
+        if build_channels is None:
+            build_channels = {u"charmcraft": u"stable"}
+        return getUtility(ICharmBaseSet).new(
+            registrant, distro_series, build_channels, processors=processors,
+            date_created=date_created)
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by