← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add webservice API for charm recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/408672
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-webservice into launchpad:master.
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
index 822f161..58c3025 100644
--- a/lib/lp/app/browser/launchpad.py
+++ b/lib/lp/app/browser/launchpad.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser code for the launchpad application."""
@@ -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.charmrecipe import ICharmRecipeSet
 from lp.code.errors import (
     CannotHaveLinkedBranch,
     InvalidNamespace,
@@ -854,6 +855,7 @@ class LaunchpadRootNavigation(Navigation):
         'branches': IBranchSet,
         'bugs': IMaloneApplication,
         'builders': IBuilderSet,
+        '+charm-recipes': ICharmRecipeSet,
         '+code-index': IBazaarApplication,
         '+code-imports': ICodeImportSet,
         'codeofconduct': ICodeOfConductSet,
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 2577f78..8e4ef3c 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -78,6 +78,11 @@
             template="../templates/charmrecipe-new.pt" />
 
         <browser:url
+            for="lp.charms.interfaces.charmrecipe.ICharmRecipeSet"
+            path_expression="string:+charm-recipes"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+
+        <browser:url
             for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
             path_expression="string:+build-request/${id}"
             attribute_to_parent="recipe" />
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 71d5a92..7f21ead 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -7,6 +7,7 @@
     xmlns:browser="http://namespaces.zope.org/browser";
     xmlns:i18n="http://namespaces.zope.org/i18n";
     xmlns:lp="http://namespaces.canonical.com/lp";
+    xmlns:webservice="http://namespaces.canonical.com/webservice";
     xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc";
     i18n_domain="launchpad">
 
@@ -135,4 +136,6 @@
         <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJob" />
     </class>
 
+    <webservice:register module="lp.charms.interfaces.webservice" />
+
 </configure>
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 0990d83..325fe9b 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -25,6 +25,7 @@ __all__ = [
     "ICharmRecipe",
     "ICharmRecipeBuildRequest",
     "ICharmRecipeSet",
+    "ICharmRecipeView",
     "MissingCharmcraftYaml",
     "NoSourceForCharmRecipe",
     "NoSuchCharmRecipe",
@@ -34,12 +35,28 @@ from lazr.enum import (
     EnumeratedType,
     Item,
     )
-from lazr.restful.declarations import error_status
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+    call_with,
+    collection_default_content,
+    error_status,
+    export_destructor_operation,
+    export_factory_operation,
+    export_read_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,
     ReferenceChoice,
     )
+from lazr.restful.interface import copy_field
 from six.moves import http_client
 from zope.interface import (
     Attribute,
@@ -211,36 +228,40 @@ class CharmRecipeBuildRequestStatus(EnumeratedType):
         """)
 
 
+# XXX cjwatson 2021-09-15 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 ICharmRecipeBuildRequest(Interface):
     """A request to build a charm recipe."""
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    date_requested = Datetime(
+    date_requested = exported(Datetime(
         title=_("The time when this request was made"),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    date_finished = Datetime(
+    date_finished = exported(Datetime(
         title=_("The time when this request finished"),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
-    recipe = Reference(
-        # Really ICharmRecipe.
+    recipe = exported(Reference(
+        # Really ICharmRecipe, patched in lp.charms.interfaces.webservice.
         Interface,
-        title=_("Charm recipe"), required=True, readonly=True)
+        title=_("Charm recipe"), required=True, readonly=True))
 
-    status = Choice(
+    status = exported(Choice(
         title=_("Status"), vocabulary=CharmRecipeBuildRequestStatus,
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    error_message = TextLine(
-        title=_("Error message"), required=True, readonly=True)
+    error_message = exported(TextLine(
+        title=_("Error message"), required=True, readonly=True))
 
-    builds = CollectionField(
+    builds = exported(CollectionField(
         title=_("Builds produced by this request"),
-        # Really ICharmRecipeBuild.
+        # Really ICharmRecipeBuild, patched in lp.charms.interfaces.webservice.
         value_type=Reference(schema=Interface),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
     requester = Reference(
         title=_("The person requesting the builds."), schema=IPerson,
@@ -260,28 +281,28 @@ class ICharmRecipeView(Interface):
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    date_created = Datetime(
-        title=_("Date created"), required=True, readonly=True)
-    date_last_modified = Datetime(
-        title=_("Date last modified"), required=True, readonly=True)
+    date_created = exported(Datetime(
+        title=_("Date created"), required=True, readonly=True))
+    date_last_modified = exported(Datetime(
+        title=_("Date last modified"), required=True, readonly=True))
 
-    registrant = PublicPersonChoice(
+    registrant = exported(PublicPersonChoice(
         title=_("Registrant"), required=True, readonly=True,
         vocabulary="ValidPersonOrTeam",
-        description=_("The person who registered this charm recipe."))
+        description=_("The person who registered this charm recipe.")))
 
     source = Attribute(
         "The source branch for this charm recipe (VCS-agnostic).")
 
-    private = Bool(
+    private = exported(Bool(
         title=_("Private"), required=False, readonly=False,
-        description=_("Whether this charm recipe is private."))
+        description=_("Whether this charm recipe is private.")))
 
-    can_upload_to_store = Bool(
+    can_upload_to_store = exported(Bool(
         title=_("Can upload to Charmhub"), required=True, readonly=True,
         description=_(
             "Whether everything is set up to allow uploading builds of this "
-            "charm recipe to Charmhub."))
+            "charm recipe to Charmhub.")))
 
     def getAllowedInformationTypes(user):
         """Get a list of acceptable `InformationType`s for this charm recipe.
@@ -306,6 +327,17 @@ class ICharmRecipeView(Interface):
         :return: `ICharmRecipeBuild`.
         """
 
+    @call_with(requester=REQUEST_USER)
+    @operation_parameters(
+        channels=Dict(
+            title=_("Source snap channels to use for these builds."),
+            description=_(
+                "A dictionary mapping snap names to channels to use for these "
+                "builds.  Currently only 'charmcraft', 'core', 'core18', "
+                "'core20', and 'core22' keys are supported."),
+            key_type=TextLine(), required=False))
+    @export_factory_operation(ICharmRecipeBuildRequest, [])
+    @operation_for_version("devel")
     def requestBuilds(requester, channels=None, architectures=None):
         """Request that the charm recipe be built.
 
@@ -349,39 +381,39 @@ class ICharmRecipeView(Interface):
         :return: `ICharmRecipeBuildRequest`.
         """
 
-    pending_build_requests = CollectionField(
+    pending_build_requests = exported(doNotSnapshot(CollectionField(
         title=_("Pending build requests for this charm recipe."),
         value_type=Reference(ICharmRecipeBuildRequest),
-        required=True, readonly=True)
+        required=True, readonly=True)))
 
-    failed_build_requests = CollectionField(
+    failed_build_requests = exported(doNotSnapshot(CollectionField(
         title=_("Failed build requests for this charm recipe."),
         value_type=Reference(ICharmRecipeBuildRequest),
-        required=True, readonly=True)
+        required=True, readonly=True)))
 
-    builds = CollectionField(
+    builds = exported(doNotSnapshot(CollectionField(
         title=_("All builds of this charm recipe."),
         description=_(
             "All builds of this charm recipe, sorted in descending order "
             "of finishing (or starting if not completed successfully)."),
-        # Really ICharmRecipeBuild.
-        value_type=Reference(schema=Interface), readonly=True)
+        # Really ICharmRecipeBuild, patched in lp.charms.interfaces.webservice.
+        value_type=Reference(schema=Interface), readonly=True)))
 
-    completed_builds = CollectionField(
+    completed_builds = exported(doNotSnapshot(CollectionField(
         title=_("Completed builds of this charm recipe."),
         description=_(
             "Completed builds of this charm recipe, sorted in descending "
             "order of finishing."),
-        # Really ICharmRecipeBuild.
-        value_type=Reference(schema=Interface), readonly=True)
+        # Really ICharmRecipeBuild, patched in lp.charms.interfaces.webservice.
+        value_type=Reference(schema=Interface), readonly=True)))
 
-    pending_builds = CollectionField(
+    pending_builds = exported(doNotSnapshot(CollectionField(
         title=_("Pending builds of this charm recipe."),
         description=_(
             "Pending builds of this charm recipe, sorted in descending "
             "order of creation."),
-        # Really ICharmRecipeBuild.
-        value_type=Reference(schema=Interface), readonly=True)
+        # Really ICharmRecipeBuild, patched in lp.charms.interfaces.webservice.
+        value_type=Reference(schema=Interface), readonly=True)))
 
 
 class ICharmRecipeEdit(IWebhookTarget):
@@ -411,6 +443,8 @@ class ICharmRecipeEdit(IWebhookTarget):
             properly configured for Charmhub uploads.
         """
 
+    @export_destructor_operation()
+    @operation_for_version("devel")
     def destroySelf():
         """Delete this charm recipe, provided that it has no builds."""
 
@@ -421,24 +455,24 @@ class ICharmRecipeEditableAttributes(Interface):
     These attributes need launchpad.View to see, and launchpad.Edit to change.
     """
 
-    owner = PersonChoice(
+    owner = exported(PersonChoice(
         title=_("Owner"), required=True, readonly=False,
         vocabulary="AllUserTeamsParticipationPlusSelf",
-        description=_("The owner of this charm recipe."))
+        description=_("The owner of this charm recipe.")))
 
-    project = ReferenceChoice(
+    project = exported(ReferenceChoice(
         title=_("The project that this charm recipe is associated with"),
         schema=IProduct, vocabulary="Product",
-        required=True, readonly=False)
+        required=True, readonly=False))
 
-    name = TextLine(
+    name = exported(TextLine(
         title=_("Charm recipe name"), required=True, readonly=False,
         constraint=name_validator,
-        description=_("The name of the charm recipe."))
+        description=_("The name of the charm recipe.")))
 
-    description = Text(
+    description = exported(Text(
         title=_("Description"), required=False, readonly=False,
-        description=_("A description of the charm recipe."))
+        description=_("A description of the charm recipe.")))
 
     git_repository = ReferenceChoice(
         title=_("Git repository"),
@@ -451,53 +485,53 @@ class ICharmRecipeEditableAttributes(Interface):
         title=_("Git branch path"), required=False, readonly=True,
         description=_("The path of the Git branch containing a charm recipe."))
 
-    git_ref = Reference(
+    git_ref = exported(Reference(
         IGitRef, title=_("Git branch"), required=False, readonly=False,
-        description=_("The Git branch containing a charm recipe."))
+        description=_("The Git branch containing a charm recipe.")))
 
-    build_path = TextLine(
+    build_path = exported(TextLine(
         title=_("Build path"),
         description=_(
             "Subdirectory within the branch containing metadata.yaml."),
-        constraint=path_does_not_escape, required=False, readonly=False)
+        constraint=path_does_not_escape, required=False, readonly=False))
 
-    information_type = Choice(
+    information_type = exported(Choice(
         title=_("Information type"), vocabulary=InformationType,
         required=True, readonly=False, default=InformationType.PUBLIC,
         description=_(
-            "The type of information contained in this charm recipe."))
+            "The type of information contained in this charm recipe.")))
 
-    auto_build = Bool(
+    auto_build = exported(Bool(
         title=_("Automatically build when branch changes"),
         required=True, readonly=False,
         description=_(
             "Whether this charm recipe is built automatically when its branch "
-            "changes."))
+            "changes.")))
 
-    auto_build_channels = Dict(
+    auto_build_channels = exported(Dict(
         title=_("Source snap channels for automatic builds"),
         key_type=TextLine(), required=False, readonly=False,
         description=_(
             "A dictionary mapping snap names to channels to use when building "
             "this charm recipe.  Currently only 'charmcraft', 'core', "
-            "'core18', 'core20', and 'core22' keys are supported."))
+            "'core18', 'core20', and 'core22' keys are supported.")))
 
-    is_stale = Bool(
+    is_stale = exported(Bool(
         title=_("Charm recipe is stale and is due to be rebuilt."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    store_upload = Bool(
+    store_upload = exported(Bool(
         title=_("Automatically upload to store"),
         required=True, readonly=False,
         description=_(
             "Whether builds of this charm recipe are automatically uploaded "
-            "to the store."))
+            "to the store.")))
 
-    store_name = TextLine(
+    store_name = exported(TextLine(
         title=_("Registered store name"),
         required=False, readonly=False,
         description=_(
-            "The registered name of this charm in the store."))
+            "The registered name of this charm in the store.")))
 
     store_secrets = List(
         value_type=TextLine(), title=_("Store upload tokens"),
@@ -506,7 +540,7 @@ class ICharmRecipeEditableAttributes(Interface):
             "Serialized secrets issued by the store and the login service to "
             "authorize uploads of this charm recipe."))
 
-    store_channels = List(
+    store_channels = exported(List(
         title=_("Store channels"),
         required=False, readonly=False, constraint=channels_validator,
         description=_(
@@ -514,7 +548,7 @@ class ICharmRecipeEditableAttributes(Interface):
             "store. A channel is defined by a combination of an optional "
             "track, a risk, and an optional branch, e.g. "
             "'2.1/stable/fix-123', '2.1/stable', 'stable/fix-123', or "
-            "'stable'."))
+            "'stable'.")))
 
 
 class ICharmRecipeAdminAttributes(Interface):
@@ -523,20 +557,36 @@ class ICharmRecipeAdminAttributes(Interface):
     These attributes need launchpad.View to see, and launchpad.Admin to change.
     """
 
-    require_virtualized = Bool(
+    require_virtualized = exported(Bool(
         title=_("Require virtualized builders"), required=True, readonly=False,
-        description=_("Only build this charm recipe on virtual builders."))
+        description=_("Only build this charm recipe on virtual builders.")))
 
 
+# XXX cjwatson 2021-09-15 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 ICharmRecipe(
         ICharmRecipeView, ICharmRecipeEdit, ICharmRecipeEditableAttributes,
         ICharmRecipeAdminAttributes, IPrivacy, IInformationType):
     """A buildable charm recipe."""
 
 
+@exported_as_webservice_collection(ICharmRecipe)
 class ICharmRecipeSet(Interface):
     """A utility to create and access charm recipes."""
 
+    @call_with(registrant=REQUEST_USER)
+    @operation_parameters(
+        information_type=copy_field(
+            ICharmRecipe["information_type"], required=False))
+    @export_factory_operation(
+        ICharmRecipe, [
+            "owner", "project", "name", "description", "git_ref", "build_path",
+            "auto_build", "auto_build_channels", "store_upload", "store_name",
+            "store_channels",
+            ])
+    @operation_for_version("devel")
     def new(registrant, owner, project, name, description=None, git_ref=None,
             build_path=None, require_virtualized=True,
             information_type=InformationType.PUBLIC, auto_build=False,
@@ -544,6 +594,13 @@ class ICharmRecipeSet(Interface):
             store_secrets=None, store_channels=None, date_created=None):
         """Create an `ICharmRecipe`."""
 
+    @operation_parameters(
+        owner=Reference(IPerson, title=_("Owner"), required=True),
+        project=Reference(IProduct, title=_("Project"), required=True),
+        name=TextLine(title=_("Recipe name"), required=True))
+    @operation_returns_entry(ICharmRecipe)
+    @export_read_operation()
+    @operation_for_version("devel")
     def getByName(owner, project, name):
         """Returns the appropriate `ICharmRecipe` for the given objects."""
 
@@ -622,3 +679,10 @@ class ICharmRecipeSet(Interface):
         After this, any charm recipes that previously used this repository
         will have no source and so cannot dispatch new builds.
         """
+
+    @collection_default_content()
+    def empty_list():
+        """Return an empty collection of charm recipes.
+
+        This only exists to keep lazr.restful happy.
+        """
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index cefae7f..c4de03d 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -18,7 +18,14 @@ from lazr.enum import (
     EnumeratedType,
     Item,
     )
-from lazr.restful.declarations import error_status
+from lazr.restful.declarations import (
+    error_status,
+    export_write_operation,
+    exported,
+    exported_as_webservice_entry,
+    operation_for_version,
+    operation_parameters,
+    )
 from lazr.restful.fields import (
     CollectionField,
     Reference,
@@ -102,50 +109,53 @@ class ICharmRecipeBuildView(IPackageBuild):
         title=_("The build request that caused this build to be created."),
         required=True, readonly=True)
 
-    requester = Reference(
+    requester = exported(Reference(
         IPerson,
         title=_("The person who requested this build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    recipe = Reference(
+    recipe = exported(Reference(
         ICharmRecipe,
         title=_("The charm recipe to build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    distro_arch_series = Reference(
+    distro_arch_series = exported(Reference(
         IDistroArchSeries,
         title=_("The series and architecture for which to build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
+
+    arch_tag = exported(
+        TextLine(title=_("Architecture tag"), required=True, readonly=True))
 
-    channels = Dict(
+    channels = exported(Dict(
         title=_("Source snap channels to use for this build."),
         description=_(
             "A dictionary mapping snap names to channels to use for this "
             "build.  Currently only 'charmcraft', 'core', 'core18', 'core20', "
             "and 'core22' keys are supported."),
-        key_type=TextLine())
+        key_type=TextLine()))
 
     virtualized = Bool(
         title=_("If True, this build is virtualized."), readonly=True)
 
-    score = Int(
+    score = exported(Int(
         title=_("Score of the related build farm job (if any)."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
-    can_be_rescored = Bool(
+    can_be_rescored = exported(Bool(
         title=_("Can be rescored"),
         required=True, readonly=True,
-        description=_("Whether this build record can be rescored manually."))
+        description=_("Whether this build record can be rescored manually.")))
 
-    can_be_retried = Bool(
+    can_be_retried = exported(Bool(
         title=_("Can be retried"),
         required=False, readonly=True,
-        description=_("Whether this build record can be retried."))
+        description=_("Whether this build record can be retried.")))
 
-    can_be_cancelled = Bool(
+    can_be_cancelled = exported(Bool(
         title=_("Can be cancelled"),
         required=True, readonly=True,
-        description=_("Whether this build record can be cancelled."))
+        description=_("Whether this build record can be cancelled.")))
 
     eta = Datetime(
         title=_("The datetime when the build job is estimated to complete."),
@@ -159,11 +169,11 @@ class ICharmRecipeBuildView(IPackageBuild):
             "The date when the build completed or is estimated to complete."),
         readonly=True)
 
-    revision_id = TextLine(
+    revision_id = exported(TextLine(
         title=_("Revision ID"), required=False, readonly=True,
         description=_(
             "The revision ID of the branch used for this build, if "
-            "available."))
+            "available.")))
 
     store_upload_jobs = CollectionField(
         title=_("Store upload jobs for this build."),
@@ -175,23 +185,23 @@ class ICharmRecipeBuildView(IPackageBuild):
     last_store_upload_job = Reference(
         title=_("Last store upload job for this build."), schema=Interface)
 
-    store_upload_status = Choice(
+    store_upload_status = exported(Choice(
         title=_("Store upload status"),
         vocabulary=CharmRecipeBuildStoreUploadStatus,
-        required=True, readonly=False)
+        required=True, readonly=False))
 
-    store_upload_revision = Int(
+    store_upload_revision = exported(Int(
         title=_("Store revision"),
         description=_(
             "The revision assigned to this charm recipe build by Charmhub."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
-    store_upload_error_message = TextLine(
+    store_upload_error_message = exported(TextLine(
         title=_("Store upload error message"),
         description=_(
             "The error message, if any, from the last attempt to upload "
             "this charm recipe build to Charmhub."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
     store_upload_metadata = Attribute(
         _("A dict of data about store upload progress."))
@@ -230,6 +240,8 @@ class ICharmRecipeBuildEdit(Interface):
         :return: An `ICharmFile`.
         """
 
+    @export_write_operation()
+    @operation_for_version("devel")
     def scheduleStoreUpload():
         """Schedule an upload of this build to the store.
 
@@ -237,6 +249,8 @@ class ICharmRecipeBuildEdit(Interface):
             where an upload can be scheduled.
         """
 
+    @export_write_operation()
+    @operation_for_version("devel")
     def retry():
         """Restore the build record to its initial state.
 
@@ -244,6 +258,8 @@ class ICharmRecipeBuildEdit(Interface):
         non-scored BuildQueue entry is created for it.
         """
 
+    @export_write_operation()
+    @operation_for_version("devel")
     def cancel():
         """Cancel the build if it is either pending or in progress.
 
@@ -262,10 +278,17 @@ class ICharmRecipeBuildEdit(Interface):
 class ICharmRecipeBuildAdmin(Interface):
     """`ICharmRecipeBuild` methods that require launchpad.Admin."""
 
+    @operation_parameters(score=Int(title=_("Score"), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
     def rescore(score):
         """Change the build's score."""
 
 
+# XXX cjwatson 2021-09-15 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 ICharmRecipeBuild(
         ICharmRecipeBuildView, ICharmRecipeBuildEdit, ICharmRecipeBuildAdmin):
     """A build record for a charm recipe."""
diff --git a/lib/lp/charms/interfaces/webservice.py b/lib/lp/charms/interfaces/webservice.py
new file mode 100644
index 0000000..213747f
--- /dev/null
+++ b/lib/lp/charms/interfaces/webservice.py
@@ -0,0 +1,42 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""All the interfaces that are exposed through the webservice.
+
+There is a declaration in ZCML somewhere that looks like:
+  <webservice:register module="lp.charms.interfaces.webservice" />
+
+which tells `lazr.restful` that it should look for webservice exports here.
+"""
+
+__all__ = [
+    "ICharmRecipe",
+    "ICharmRecipeBuild",
+    "ICharmRecipeBuildRequest",
+    "ICharmRecipeSet",
+    ]
+
+from lp.charms.interfaces.charmrecipe import (
+    ICharmRecipe,
+    ICharmRecipeBuildRequest,
+    ICharmRecipeSet,
+    ICharmRecipeView,
+    )
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.services.webservice.apihelpers import (
+    patch_collection_property,
+    patch_reference_property,
+    )
+
+
+# ICharmRecipeBuildRequest
+patch_reference_property(ICharmRecipeBuildRequest, "recipe", ICharmRecipe)
+patch_collection_property(
+    ICharmRecipeBuildRequest, "builds", ICharmRecipeBuild)
+
+# ICharmRecipeView
+patch_collection_property(ICharmRecipeView, "builds", ICharmRecipeBuild)
+patch_collection_property(
+    ICharmRecipeView, "completed_builds", ICharmRecipeBuild)
+patch_collection_property(
+    ICharmRecipeView, "pending_builds", ICharmRecipeBuild)
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 63f21ff..7456007 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -981,6 +981,10 @@ class CharmRecipeSet:
         recipes.set(
             git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)
 
+    def empty_list(self):
+        """See `ICharmRecipeSet`."""
+        return []
+
 
 @implementer(IEncryptedContainer)
 class CharmhubSecretsEncryptedContainer(NaClEncryptedContainerBase):
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index 531d8b2..e3c37e8 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -10,16 +10,25 @@ __all__ = [
     ]
 
 from datetime import timedelta
+from operator import attrgetter
 
 import pytz
 import six
 from storm.databases.postgres import JSON
+from storm.expr import (
+    Column,
+    Table,
+    With,
+    )
 from storm.locals import (
+    And,
     Bool,
     DateTime,
     Desc,
     Int,
     Reference,
+    Select,
+    SQL,
     Store,
     Unicode,
     )
@@ -188,6 +197,11 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
         return self.distro_arch_series.distroseries
 
     @property
+    def arch_tag(self):
+        """See `ICharmRecipeBuild`."""
+        return self.distro_arch_series.architecturetag
+
+    @property
     def archive(self):
         """See `IPackageBuild`."""
         return self.distribution.main_archive
@@ -512,6 +526,30 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
         load_related(Distribution, distroserieses, ["distributionID"])
         recipes = load_related(CharmRecipe, builds, ["recipe_id"])
         getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)
+        build_ids = set(map(attrgetter("id"), builds))
+        latest_jobs_cte = With("LatestJobs", Select(
+            (CharmRecipeBuildJob.job_id,
+             SQL(
+                 "rank() OVER "
+                 "(PARTITION BY build ORDER BY job DESC) AS rank")),
+            tables=CharmRecipeBuildJob,
+            where=And(
+                CharmRecipeBuildJob.build_id.is_in(build_ids),
+                CharmRecipeBuildJob.job_type ==
+                    CharmRecipeBuildJobType.CHARMHUB_UPLOAD)))
+        LatestJobs = Table("LatestJobs")
+        crbjs = list(IStore(CharmRecipeBuildJob).with_(latest_jobs_cte).using(
+            CharmRecipeBuildJob, LatestJobs).find(
+                CharmRecipeBuildJob,
+                CharmRecipeBuildJob.job_id == Column("job", LatestJobs),
+                Column("rank", LatestJobs) == 1))
+        crbj_map = {}
+        for crbj in crbjs:
+            crbj_map[crbj.build] = crbj.makeDerived()
+        for build in builds:
+            get_property_cache(build).last_store_upload_job = (
+                crbj_map.get(build))
+        load_related(Job, crbjs, ["job_id"])
 
     def getByBuildFarmJobs(self, build_farm_jobs):
         """See `ISpecificBuildFarmJobSource`."""
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 5817c09..f99f8a8 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -6,10 +6,12 @@
 __metaclass__ = type
 
 import base64
+from datetime import timedelta
 import json
 from textwrap import dedent
 
 from fixtures import FakeLogger
+import iso8601
 from nacl.public import PrivateKey
 from pymacaroons import Macaroon
 from pymacaroons.serializers import JsonSerializer
@@ -20,7 +22,10 @@ from testtools.matchers import (
     AfterPreprocessing,
     ContainsDict,
     Equals,
+    GreaterThan,
     Is,
+    LessThan,
+    MatchesAll,
     MatchesDict,
     MatchesListwise,
     MatchesSetwise,
@@ -50,6 +55,7 @@ from lp.charms.interfaces.charmrecipe import (
     CannotAuthorizeCharmhubUploads,
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_BUILD_DISTRIBUTION,
+    CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
     CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
     CharmRecipeBuildAlreadyPending,
     CharmRecipeBuildDisallowedArchitecture,
@@ -58,6 +64,7 @@ from lp.charms.interfaces.charmrecipe import (
     CharmRecipePrivateFeatureDisabled,
     ICharmRecipe,
     ICharmRecipeSet,
+    ICharmRecipeView,
     NoSourceForCharmRecipe,
     )
 from lp.charms.interfaces.charmrecipebuild import (
@@ -71,6 +78,10 @@ from lp.charms.model.charmrecipebuild import CharmFile
 from lp.charms.model.charmrecipejob import CharmRecipeJob
 from lp.code.errors import GitRepositoryBlobNotFound
 from lp.code.tests.helpers import GitHostingFixture
+from lp.registry.enums import (
+    PersonVisibility,
+    TeamMembershipPolicy,
+    )
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.crypto.interfaces import IEncryptedContainer
@@ -86,12 +97,19 @@ from lp.services.database.sqlbase import (
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webapp.snapshot import notify_modified
 from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.testing import (
     admin_logged_in,
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
     person_logged_in,
+    record_two_runs,
+    StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.dbuser import dbuser
@@ -100,6 +118,11 @@ from lp.testing.layers import (
     LaunchpadFunctionalLayer,
     LaunchpadZopelessLayer,
     )
+from lp.testing.matchers import (
+    DoesNotSnapshot,
+    HasQueryCount,
+    )
+from lp.testing.pages import webservice_for_person
 
 
 class TestCharmRecipeFeatureFlags(TestCaseWithFactory):
@@ -142,6 +165,14 @@ class TestCharmRecipe(TestCaseWithFactory):
                 recipe.owner.name, recipe.project.name, recipe.name),
             repr(recipe))
 
+    def test_avoids_problematic_snapshots(self):
+        self.assertThat(
+            self.factory.makeCharmRecipe(),
+            DoesNotSnapshot(
+                ["pending_build_requests", "failed_build_requests",
+                 "builds", "completed_builds", "pending_builds"],
+                ICharmRecipeView))
+
     def test_initial_date_last_modified(self):
         # The initial value of date_last_modified is date_created.
         recipe = self.factory.makeCharmRecipe(date_created=ONE_DAY_AGO)
@@ -1114,3 +1145,512 @@ class TestCharmRecipeSet(TestCaseWithFactory):
         for recipe in recipes[:2]:
             self.assertSqlAttributeEqualsDate(
                 recipe, "date_last_modified", UTC_NOW)
+
+
+class TestCharmRecipeWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_BUILD_DISTRIBUTION: "ubuntu",
+            CHARM_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+            }))
+        self.person = self.factory.makePerson(displayname="Test Person")
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PUBLIC)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def makeCharmRecipe(self, owner=None, project=None, name=None,
+                        git_ref=None, private=False, webservice=None,
+                        **kwargs):
+        if owner is None:
+            owner = self.person
+        if project is None:
+            project = self.factory.makeProduct(owner=owner)
+        if name is None:
+            name = self.factory.getUniqueUnicode()
+        if git_ref is None:
+            [git_ref] = self.factory.makeGitRefs()
+        if webservice is None:
+            webservice = self.webservice
+        transaction.commit()
+        owner_url = api_url(owner)
+        project_url = api_url(project)
+        git_ref_url = api_url(git_ref)
+        logout()
+        information_type = (InformationType.PROPRIETARY if private else
+                            InformationType.PUBLIC)
+        response = webservice.named_post(
+            "/+charm-recipes", "new", owner=owner_url, project=project_url,
+            name=name, git_ref=git_ref_url,
+            information_type=information_type.title, **kwargs)
+        self.assertEqual(201, response.status)
+        return webservice.get(response.getHeader("Location")).jsonBody()
+
+    def getCollectionLinks(self, entry, member):
+        """Return a list of self_link attributes of entries in a collection."""
+        collection = self.webservice.get(
+            entry["%s_collection_link" % member]).jsonBody()
+        return [entry["self_link"] for entry in collection["entries"]]
+
+    def test_new_git(self):
+        # Charm recipe creation based on a Git branch works.
+        team = self.factory.makeTeam(
+            owner=self.person,
+            membership_policy=TeamMembershipPolicy.RESTRICTED)
+        project = self.factory.makeProduct(owner=team)
+        [ref] = self.factory.makeGitRefs()
+        recipe = self.makeCharmRecipe(
+            owner=team, project=project, name="test-charm", git_ref=ref)
+        with person_logged_in(self.person):
+            self.assertThat(recipe, ContainsDict({
+                "registrant_link": Equals(self.getURL(self.person)),
+                "owner_link": Equals(self.getURL(team)),
+                "project_link": Equals(self.getURL(project)),
+                "name": Equals("test-charm"),
+                "git_ref_link": Equals(self.getURL(ref)),
+                "build_path": Is(None),
+                "require_virtualized": Is(True),
+                }))
+
+    def test_new_store_options(self):
+        # The store-related options in CharmRecipe.new work.
+        store_name = self.factory.getUniqueUnicode()
+        recipe = self.makeCharmRecipe(
+            store_upload=True, store_name=store_name, store_channels=["edge"])
+        with person_logged_in(self.person):
+            self.assertThat(recipe, ContainsDict({
+                "store_upload": Is(True),
+                "store_name": Equals(store_name),
+                "store_channels": Equals(["edge"]),
+                }))
+
+    def test_duplicate(self):
+        # An attempt to create a duplicate charm recipe fails.
+        team = self.factory.makeTeam(
+            owner=self.person,
+            membership_policy=TeamMembershipPolicy.RESTRICTED)
+        project = self.factory.makeProduct(owner=team)
+        name = self.factory.getUniqueUnicode()
+        [git_ref] = self.factory.makeGitRefs()
+        owner_url = api_url(team)
+        project_url = api_url(project)
+        git_ref_url = api_url(git_ref)
+        self.makeCharmRecipe(
+            owner=team, project=project, name=name, git_ref=git_ref)
+        response = self.webservice.named_post(
+            "/+charm-recipes", "new", owner=owner_url, project=project_url,
+            name=name, git_ref=git_ref_url)
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=400,
+            body=(
+                b"There is already a charm recipe with the same project, "
+                b"owner, and name.")))
+
+    def test_not_owner(self):
+        # If the registrant is not the owner or a member of the owner team,
+        # charm recipe creation fails.
+        other_person = self.factory.makePerson(displayname="Other Person")
+        other_team = self.factory.makeTeam(
+            owner=other_person, displayname="Other Team")
+        project = self.factory.makeProduct(owner=self.person)
+        [git_ref] = self.factory.makeGitRefs()
+        transaction.commit()
+        other_person_url = api_url(other_person)
+        other_team_url = api_url(other_team)
+        project_url = api_url(project)
+        git_ref_url = api_url(git_ref)
+        logout()
+        response = self.webservice.named_post(
+            "/+charm-recipes", "new", owner=other_person_url,
+            project=project_url, name="test-charm", git_ref=git_ref_url)
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=401,
+            body=(
+                b"Test Person cannot create charm recipes owned by "
+                b"Other Person.")))
+        response = self.webservice.named_post(
+            "/+charm-recipes", "new", owner=other_team_url,
+            project=project_url, name="test-charm", git_ref=git_ref_url)
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=401,
+            body=b"Test Person is not a member of Other Team."))
+
+    def test_cannot_set_private_components_of_public_recipe(self):
+        # If a charm recipe is public, then trying to change its owner or
+        # git_ref components to be private fails.
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person,
+            git_ref=self.factory.makeGitRefs()[0])
+        private_team = self.factory.makeTeam(
+            owner=self.person, visibility=PersonVisibility.PRIVATE)
+        [private_ref] = self.factory.makeGitRefs(
+            owner=self.person,
+            information_type=InformationType.PRIVATESECURITY)
+        recipe_url = api_url(recipe)
+        with person_logged_in(self.person):
+            private_team_url = api_url(private_team)
+            private_ref_url = api_url(private_ref)
+        logout()
+        private_webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PRIVATE)
+        private_webservice.default_api_version = "devel"
+        response = private_webservice.patch(
+            recipe_url, "application/json",
+            json.dumps({"owner_link": private_team_url}))
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=400,
+            body=b"A public charm recipe cannot have a private owner."))
+        response = private_webservice.patch(
+            recipe_url, "application/json",
+            json.dumps({"git_ref_link": private_ref_url}))
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=400,
+            body=b"A public charm recipe cannot have a private repository."))
+
+    def test_is_stale(self):
+        # is_stale is exported and is read-only.
+        recipe = self.makeCharmRecipe()
+        self.assertTrue(recipe["is_stale"])
+        response = self.webservice.patch(
+            recipe["self_link"], "application/json",
+            json.dumps({"is_stale": False}))
+        self.assertEqual(400, response.status)
+
+    def test_getByName(self):
+        # lp.charm_recipes.getByName returns a matching CharmRecipe.
+        project = self.factory.makeProduct(owner=self.person)
+        name = self.factory.getUniqueUnicode()
+        recipe = self.makeCharmRecipe(project=project, name=name)
+        with person_logged_in(self.person):
+            owner_url = api_url(self.person)
+            project_url = api_url(project)
+        response = self.webservice.named_get(
+            "/+charm-recipes", "getByName",
+            owner=owner_url, project=project_url, name=name)
+        self.assertEqual(200, response.status)
+        self.assertEqual(recipe, response.jsonBody())
+
+    def test_getByName_missing(self):
+        # lp.charm_recipes.getByName returns 404 for a non-existent
+        # CharmRecipe.
+        project = self.factory.makeProduct(owner=self.person)
+        logout()
+        with person_logged_in(self.person):
+            owner_url = api_url(self.person)
+            project_url = api_url(project)
+        response = self.webservice.named_get(
+            "/+charm-recipes", "getByName",
+            owner=owner_url, project=project_url, name="nonexistent")
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=404,
+            body=(
+                b"No such charm recipe with this owner and project: "
+                b"'nonexistent'.")))
+
+    def makeBuildableDistroArchSeries(self, distroseries=None,
+                                      architecturetag=None, processor=None,
+                                      supports_virtualized=True,
+                                      supports_nonvirtualized=True, **kwargs):
+        if architecturetag is None:
+            architecturetag = self.factory.getUniqueUnicode("arch")
+        if processor is None:
+            try:
+                processor = getUtility(IProcessorSet).getByName(
+                    architecturetag)
+            except ProcessorNotFound:
+                processor = self.factory.makeProcessor(
+                    name=architecturetag,
+                    supports_virtualized=supports_virtualized,
+                    supports_nonvirtualized=supports_nonvirtualized)
+        das = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag=architecturetag,
+            processor=processor, **kwargs)
+        # Add both a chroot and a LXD image to test that
+        # getAllowedArchitectures doesn't get confused by multiple
+        # PocketChroot rows for a single DistroArchSeries.
+        fake_chroot = self.factory.makeLibraryFileAlias(
+            filename="fake_chroot.tar.gz", db_only=True)
+        das.addOrUpdateChroot(fake_chroot)
+        fake_lxd = self.factory.makeLibraryFileAlias(
+            filename="fake_lxd.tar.gz", db_only=True)
+        das.addOrUpdateChroot(fake_lxd, image_type=BuildBaseImageType.LXD)
+        return das
+
+    def test_requestBuilds(self):
+        # Requests for builds for all relevant architectures can be
+        # performed over the webservice, and the returned entry indicates
+        # the status of the asynchronous job.
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            registrant=self.person)
+        processors = [
+            self.factory.makeProcessor(supports_virtualized=True)
+            for _ in range(3)]
+        for processor in processors:
+            self.makeBuildableDistroArchSeries(
+                distroseries=distroseries, architecturetag=processor.name,
+                processor=processor, owner=self.person)
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.makeCharmRecipe(git_ref=git_ref)
+        now = get_transaction_timestamp(IStore(distroseries))
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds",
+            channels={"charmcraft": "edge"})
+        self.assertEqual(201, response.status)
+        build_request_url = response.getHeader("Location")
+        build_request = self.webservice.get(build_request_url).jsonBody()
+        self.assertThat(build_request, ContainsDict({
+            "date_requested": AfterPreprocessing(
+                iso8601.parse_date, GreaterThan(now)),
+            "date_finished": Is(None),
+            "recipe_link": Equals(recipe["self_link"]),
+            "status": Equals("Pending"),
+            "error_message": Is(None),
+            "builds_collection_link": Equals(build_request_url + "/builds"),
+            }))
+        self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+        with person_logged_in(self.person):
+            charmcraft_yaml = "bases:\n"
+            for processor in processors:
+                charmcraft_yaml += (
+                    '  - build-on:\n'
+                    '    - name: ubuntu\n'
+                    '      channel: "%s"\n'
+                    '      architectures: [%s]\n' %
+                    (distroseries.version, processor.name))
+            self.useFixture(GitHostingFixture(blob=charmcraft_yaml))
+            [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.ICharmRecipeRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        date_requested = iso8601.parse_date(build_request["date_requested"])
+        now = get_transaction_timestamp(IStore(distroseries))
+        build_request = self.webservice.get(
+            build_request["self_link"]).jsonBody()
+        self.assertThat(build_request, ContainsDict({
+            "date_requested": AfterPreprocessing(
+                iso8601.parse_date, Equals(date_requested)),
+            "date_finished": AfterPreprocessing(
+                iso8601.parse_date,
+                MatchesAll(GreaterThan(date_requested), LessThan(now))),
+            "recipe_link": Equals(recipe["self_link"]),
+            "status": Equals("Completed"),
+            "error_message": Is(None),
+            "builds_collection_link": Equals(build_request_url + "/builds"),
+            }))
+        builds = self.webservice.get(
+            build_request["builds_collection_link"]).jsonBody()["entries"]
+        with person_logged_in(self.person):
+            self.assertThat(builds, MatchesSetwise(*(
+                ContainsDict({
+                    "recipe_link": Equals(recipe["self_link"]),
+                    "archive_link": Equals(
+                        self.getURL(distroseries.main_archive)),
+                    "arch_tag": Equals(processor.name),
+                    "channels": Equals({"charmcraft": "edge"}),
+                    })
+                for processor in processors)))
+
+    def test_requestBuilds_failure(self):
+        # If the asynchronous build request job fails, this is reflected in
+        # the build request entry.
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.makeCharmRecipe(git_ref=git_ref)
+        now = get_transaction_timestamp(IStore(git_ref))
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds")
+        self.assertEqual(201, response.status)
+        build_request_url = response.getHeader("Location")
+        build_request = self.webservice.get(build_request_url).jsonBody()
+        self.assertThat(build_request, ContainsDict({
+            "date_requested": AfterPreprocessing(
+                iso8601.parse_date, GreaterThan(now)),
+            "date_finished": Is(None),
+            "recipe_link": Equals(recipe["self_link"]),
+            "status": Equals("Pending"),
+            "error_message": Is(None),
+            "builds_collection_link": Equals(build_request_url + "/builds"),
+            }))
+        self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+        with person_logged_in(self.person):
+            self.useFixture(GitHostingFixture()).getBlob.failure = Exception(
+                "Something went wrong")
+            [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.ICharmRecipeRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        date_requested = iso8601.parse_date(build_request["date_requested"])
+        now = get_transaction_timestamp(IStore(git_ref))
+        build_request = self.webservice.get(
+            build_request["self_link"]).jsonBody()
+        self.assertThat(build_request, ContainsDict({
+            "date_requested": AfterPreprocessing(
+                iso8601.parse_date, Equals(date_requested)),
+            "date_finished": AfterPreprocessing(
+                iso8601.parse_date,
+                MatchesAll(GreaterThan(date_requested), LessThan(now))),
+            "recipe_link": Equals(recipe["self_link"]),
+            "status": Equals("Failed"),
+            "error_message": Equals("Something went wrong"),
+            "builds_collection_link": Equals(build_request_url + "/builds"),
+            }))
+        self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+
+    def test_requestBuilds_not_owner(self):
+        # If the requester is not the owner or a member of the owner team,
+        # build requests are rejected.
+        other_team = self.factory.makeTeam(
+            displayname="Other Team",
+            membership_policy=TeamMembershipPolicy.RESTRICTED)
+        other_webservice = webservice_for_person(
+            other_team.teamowner, permission=OAuthPermission.WRITE_PUBLIC)
+        other_webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+        recipe = self.makeCharmRecipe(
+            owner=other_team, webservice=other_webservice)
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds")
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=401,
+            body=(
+                b"Test Person cannot create charm recipe builds owned by "
+                b"Other Team.")))
+
+    def test_getBuilds(self):
+        # The builds, completed_builds, and pending_builds properties are as
+        # expected.
+        project = self.factory.makeProduct(owner=self.person)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            registrant=self.person)
+        processors = [
+            self.factory.makeProcessor(supports_virtualized=True)
+            for _ in range(4)]
+        for processor in processors:
+            self.makeBuildableDistroArchSeries(
+                distroseries=distroseries, architecturetag=processor.name,
+                processor=processor, owner=self.person)
+        recipe = self.makeCharmRecipe(project=project)
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds")
+        self.assertEqual(201, response.status)
+        with person_logged_in(self.person):
+            charmcraft_yaml = "bases:\n"
+            for processor in processors:
+                charmcraft_yaml += (
+                    '  - build-on:\n'
+                    '    - name: ubuntu\n'
+                    '      channel: "%s"\n'
+                    '      architectures: [%s]\n' %
+                    (distroseries.version, processor.name))
+            self.useFixture(GitHostingFixture(blob=charmcraft_yaml))
+            [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.ICharmRecipeRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        builds = self.getCollectionLinks(recipe, "builds")
+        self.assertEqual(len(processors), len(builds))
+        self.assertEqual(
+            [], self.getCollectionLinks(recipe, "completed_builds"))
+        self.assertEqual(
+            builds, self.getCollectionLinks(recipe, "pending_builds"))
+
+        with person_logged_in(self.person):
+            db_recipe = getUtility(ICharmRecipeSet).getByName(
+                self.person, project, recipe["name"])
+            db_builds = list(db_recipe.builds)
+            db_builds[0].updateStatus(
+                BuildStatus.BUILDING, date_started=db_recipe.date_created)
+            db_builds[0].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_recipe.date_created + timedelta(minutes=10))
+        # Builds that have not yet been started are listed last.  This does
+        # mean that pending builds that have never been started are sorted
+        # to the end, but means that builds that were cancelled before
+        # starting don't pollute the start of the collection forever.
+        self.assertEqual(builds, self.getCollectionLinks(recipe, "builds"))
+        self.assertEqual(
+            builds[:1], self.getCollectionLinks(recipe, "completed_builds"))
+        self.assertEqual(
+            builds[1:], self.getCollectionLinks(recipe, "pending_builds"))
+
+        with person_logged_in(self.person):
+            db_builds[1].updateStatus(
+                BuildStatus.BUILDING, date_started=db_recipe.date_created)
+            db_builds[1].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_recipe.date_created + timedelta(minutes=20))
+        self.assertEqual(
+            [builds[1], builds[0], builds[2], builds[3]],
+            self.getCollectionLinks(recipe, "builds"))
+        self.assertEqual(
+            [builds[1], builds[0]],
+            self.getCollectionLinks(recipe, "completed_builds"))
+        self.assertEqual(
+            builds[2:], self.getCollectionLinks(recipe, "pending_builds"))
+
+    def test_query_count(self):
+        # CharmRecipe has a reasonable query count.
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person)
+        url = api_url(recipe)
+        logout()
+        store = Store.of(recipe)
+        store.flush()
+        store.invalidate()
+        with StormStatementRecorder() as recorder:
+            self.webservice.get(url)
+        self.assertThat(recorder, HasQueryCount(Equals(19)))
+
+    def test_builds_query_count(self):
+        # The query count of CharmRecipe.builds is constant in the number of
+        # builds, even if they have store upload jobs.
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            registrant=self.person)
+        processor = self.factory.makeProcessor(supports_virtualized=True)
+        das = self.makeBuildableDistroArchSeries(
+            distroseries=distroseries, architecturetag=processor.name,
+            processor=processor, owner=self.person)
+        with person_logged_in(self.person):
+            recipe = self.factory.makeCharmRecipe(
+                registrant=self.person, owner=self.person)
+            recipe.store_name = self.factory.getUniqueUnicode()
+            recipe.store_upload = True
+            # CharmRecipe.can_upload_to_store only checks whether
+            # "exchanged_encrypted" is present, so don't bother setting up
+            # encryption keys here.
+            recipe.store_secrets = {
+                "exchanged_encrypted": Macaroon().serialize()}
+        builds_url = "%s/builds" % api_url(recipe)
+        logout()
+
+        def make_build():
+            with person_logged_in(self.person):
+                builder = self.factory.makeBuilder()
+                build_request = self.factory.makeCharmRecipeBuildRequest(
+                    recipe=recipe)
+                build = recipe.requestBuild(build_request, das)
+                with dbuser(config.builddmaster.dbuser):
+                    build.updateStatus(
+                        BuildStatus.BUILDING, date_started=recipe.date_created)
+                    build.updateStatus(
+                        BuildStatus.FULLYBUILT,
+                        builder=builder,
+                        date_finished=(
+                            recipe.date_created + timedelta(minutes=10)))
+                return build
+
+        def get_builds():
+            response = self.webservice.get(builds_url)
+            self.assertEqual(200, response.status)
+            return response
+
+        recorder1, recorder2 = record_two_runs(get_builds, make_build, 2)
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
diff --git a/lib/lp/charms/tests/test_charmrecipebuild.py b/lib/lp/charms/tests/test_charmrecipebuild.py
index f4adff6..4262531 100644
--- a/lib/lp/charms/tests/test_charmrecipebuild.py
+++ b/lib/lp/charms/tests/test_charmrecipebuild.py
@@ -10,6 +10,7 @@ from datetime import (
     datetime,
     timedelta,
     )
+from urllib.request import urlopen
 
 from fixtures import FakeLogger
 from nacl.public import PrivateKey
@@ -19,6 +20,7 @@ import six
 from testtools.matchers import (
     ContainsDict,
     Equals,
+    Is,
     MatchesDict,
     MatchesStructure,
     )
@@ -27,6 +29,7 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
@@ -53,17 +56,26 @@ from lp.services.crypto.interfaces import IEncryptedContainer
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.propertycache import clear_property_cache
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.testing import (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
     person_logged_in,
     StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.dbuser import dbuser
-from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.layers import (
+    LaunchpadFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
 from lp.testing.mail_helpers import pop_notifications
 from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import webservice_for_person
 
 
 expected_body = """\
@@ -708,3 +720,120 @@ class TestCharmRecipeBuildSet(TestCaseWithFactory):
         target = self.factory.makeCharmRecipeBuild(
             recipe=recipe, distro_arch_series=distro_arch_series)
         self.assertFalse(target.virtualized)
+
+
+class TestCharmRecipeBuildWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+            }))
+        self.person = self.factory.makePerson()
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PRIVATE)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def test_properties(self):
+        # The basic properties of a charm recipe build are sensible.
+        db_build = self.factory.makeCharmRecipeBuild(
+            requester=self.person,
+            date_created=datetime(2021, 9, 15, 16, 21, 0, tzinfo=pytz.UTC))
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        with person_logged_in(self.person):
+            self.assertThat(build, ContainsDict({
+                "requester_link": Equals(self.getURL(self.person)),
+                "recipe_link": Equals(self.getURL(db_build.recipe)),
+                "distro_arch_series_link": Equals(
+                    self.getURL(db_build.distro_arch_series)),
+                "arch_tag": Equals(
+                    db_build.distro_arch_series.architecturetag),
+                "channels": Is(None),
+                "score": Is(None),
+                "can_be_rescored": Is(False),
+                "can_be_retried": Is(False),
+                "can_be_cancelled": Is(False),
+                }))
+
+    def test_public(self):
+        # A charm recipe build with a public recipe is itself public.
+        db_build = self.factory.makeCharmRecipeBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(200, unpriv_webservice.get(build_url).status)
+
+    def test_cancel(self):
+        # The owner of a build can cancel it.
+        db_build = self.factory.makeCharmRecipeBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertTrue(build["can_be_cancelled"])
+        response = unpriv_webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(401, response.status)
+        response = self.webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertFalse(build["can_be_cancelled"])
+        with person_logged_in(self.person):
+            self.assertEqual(BuildStatus.CANCELLED, db_build.status)
+
+    def test_rescore(self):
+        # Buildd administrators can rescore builds.
+        db_build = self.factory.makeCharmRecipeBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+        buildd_admin_webservice = webservice_for_person(
+            buildd_admin, permission=OAuthPermission.WRITE_PUBLIC)
+        buildd_admin_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(2510, build["score"])
+        self.assertTrue(build["can_be_rescored"])
+        response = self.webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(401, response.status)
+        response = buildd_admin_webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(5000, build["score"])
+
+    def assertCanOpenRedirectedUrl(self, browser, url):
+        browser.open(url)
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        urlopen(browser.headers["Location"]).close()
+
+    def test_logs(self):
+        # API clients can fetch the build and upload logs.
+        db_build = self.factory.makeCharmRecipeBuild(requester=self.person)
+        db_build.setLog(self.factory.makeLibraryFileAlias("buildlog.txt.gz"))
+        db_build.storeUploadLog("uploaded")
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        browser = self.getNonRedirectingBrowser(user=self.person)
+        browser.raiseHttpErrors = False
+        self.assertIsNotNone(build["build_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["build_log_url"])
+        self.assertIsNotNone(build["upload_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["upload_log_url"])
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index d0fbf23..c70ffd1 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -277,6 +277,34 @@
                 <xsl:text>/builders/</xsl:text>
                 <var>&lt;builder.name&gt;</var>
             </xsl:when>
+            <xsl:when test="@id = 'charm_recipe'">
+                <xsl:text>/~</xsl:text>
+                <var>&lt;person.name&gt;</var>
+                <xsl:text>/</xsl:text>
+                <var>&lt;project.name&gt;</var>
+                <xsl:text>/+charm/</xsl:text>
+                <var>&lt;recipe.name&gt;</var>
+            </xsl:when>
+            <xsl:when test="@id = 'charm_recipe_build'">
+                <xsl:text>/~</xsl:text>
+                <var>&lt;person.name&gt;</var>
+                <xsl:text>/</xsl:text>
+                <var>&lt;project.name&gt;</var>
+                <xsl:text>/+charm/</xsl:text>
+                <var>&lt;recipe.name&gt;</var>
+                <xsl:text>/+build/</xsl:text>
+                <var>&lt;id&gt;</var>
+            </xsl:when>
+            <xsl:when test="@id = 'charm_recipe_build_request'">
+                <xsl:text>/~</xsl:text>
+                <var>&lt;person.name&gt;</var>
+                <xsl:text>/</xsl:text>
+                <var>&lt;project.name&gt;</var>
+                <xsl:text>/+charm/</xsl:text>
+                <var>&lt;recipe.name&gt;</var>
+                <xsl:text>/+build-request/</xsl:text>
+                <var>&lt;id&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'cve'">
                 <xsl:text>/bugs/cve/</xsl:text>
                 <var>&lt;sequence&gt;</var>
@@ -744,6 +772,9 @@
     <xsl:template name="find-root-object-uri">
         <xsl:value-of select="$base"/>
         <xsl:choose>
+            <xsl:when test="@id = 'charm_recipes'">
+                <xsl:text>/+charm-recipes</xsl:text>
+            </xsl:when>
             <xsl:when test="@id = 'polls'">
                 <xsl:text>/+polls</xsl:text>
             </xsl:when>