← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~jugmac00/launchpad:add-webservice-api-for-rock-changes into launchpad:master

 

Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-webservice-api-for-rock-changes into launchpad:master with ~jugmac00/launchpad:add-rock-recipe-listing-views as a prerequisite.

Commit message:
Add webservice API for rock recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/473333
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-webservice-api-for-rock-changes into launchpad:master.
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
index 25b5c11..83cbcbd 100644
--- a/lib/lp/app/browser/launchpad.py
+++ b/lib/lp/app/browser/launchpad.py
@@ -99,6 +99,7 @@ from lp.registry.interfaces.product import (
 from lp.registry.interfaces.projectgroup import IProjectGroupSet
 from lp.registry.interfaces.role import IPersonRoles
 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.rocks.interfaces.rockrecipe import IRockRecipeSet
 from lp.services.config import config
 from lp.services.features import getFeatureFlag
 from lp.services.features.interfaces import IFeatureRules
@@ -897,6 +898,7 @@ class LaunchpadRootNavigation(Navigation):
         "+processors": IProcessorSet,
         "projects": IProductSet,
         "projectgroups": IProjectGroupSet,
+        "+rock-recipes": IRockRecipeSet,
         "+snaps": ISnapSet,
         "+snap-bases": ISnapBaseSet,
         "+snappy-series": ISnappySeriesSet,
diff --git a/lib/lp/rocks/browser/configure.zcml b/lib/lp/rocks/browser/configure.zcml
index d26c8ff..18d594f 100644
--- a/lib/lp/rocks/browser/configure.zcml
+++ b/lib/lp/rocks/browser/configure.zcml
@@ -34,6 +34,10 @@
             for="lp.rocks.interfaces.rockrecipe.IRockRecipe"
             factory="lp.rocks.browser.rockrecipe.RockRecipeBreadcrumb"
             permission="zope.Public" />
+         <lp:url
+            for="lp.rocks.interfaces.rockrecipe.IRockRecipeSet"
+            path_expression="string:+rock-recipes"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
 >>>>>>> lib/lp/rocks/browser/configure.zcml
         <lp:url
             for="lp.rocks.interfaces.rockrecipe.IRockRecipeBuildRequest"
diff --git a/lib/lp/rocks/configure.zcml b/lib/lp/rocks/configure.zcml
index 4ca617e..a985021 100644
--- a/lib/lp/rocks/configure.zcml
+++ b/lib/lp/rocks/configure.zcml
@@ -7,6 +7,10 @@
     xmlns:browser="http://namespaces.zope.org/browser";
     xmlns:i18n="http://namespaces.zope.org/i18n";
     xmlns:lp="http://namespaces.canonical.com/lp";
+<<<<<<< lib/lp/rocks/configure.zcml
+=======
+    xmlns:webservice="http://namespaces.canonical.com/webservice";
+>>>>>>> lib/lp/rocks/configure.zcml
     xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc";
     i18n_domain="launchpad">
 
@@ -103,5 +107,9 @@
         <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeJob" />
         <allow interface="lp.rocks.interfaces.rockrecipejob.IRockRecipeRequestBuildsJob" />
     </class>
+<<<<<<< lib/lp/rocks/configure.zcml
 
+=======
+    <webservice:register module="lp.rocks.interfaces.webservice" />
+>>>>>>> lib/lp/rocks/configure.zcml
 </configure>
diff --git a/lib/lp/rocks/interfaces/rockrecipe.py b/lib/lp/rocks/interfaces/rockrecipe.py
index f3dcb96..16d92fd 100644
--- a/lib/lp/rocks/interfaces/rockrecipe.py
+++ b/lib/lp/rocks/interfaces/rockrecipe.py
@@ -26,6 +26,7 @@ __all__ = [
     "IRockRecipeSet",
 <<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 =======
+    "IRockRecipeView",
     "MissingRockcraftYaml",
 >>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     "NoSourceForRockRecipe",
@@ -35,11 +36,29 @@ __all__ = [
 import http.client
 
 from lazr.enum import EnumeratedType, Item
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 from lazr.restful.declarations import error_status, exported
 from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
-<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 from zope.interface import Interface
 =======
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+    REQUEST_USER,
+    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,
+)
+from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
+from lazr.restful.interface import copy_field
 from zope.interface import Attribute, Interface
 >>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
 from zope.schema import (
@@ -209,11 +228,19 @@ class RockRecipeBuildRequestStatus(EnumeratedType):
     )
 
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+# XXX jugmac00 2024-09-16 https://bugs.launchpad.net/lazr.restful/+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")
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
 class IRockRecipeBuildRequest(Interface):
     """A request to build a rock recipe."""
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     date_requested = Datetime(
         title=_("The time when this request was made"),
         required=True,
@@ -251,6 +278,56 @@ class IRockRecipeBuildRequest(Interface):
         value_type=Reference(schema=Interface),
         required=True,
         readonly=True,
+=======
+    date_requested = exported(
+        Datetime(
+            title=_("The time when this request was made"),
+            required=True,
+            readonly=True,
+        )
+    )
+
+    date_finished = exported(
+        Datetime(
+            title=_("The time when this request finished"),
+            required=False,
+            readonly=True,
+        )
+    )
+
+    recipe = exported(
+        Reference(
+            # Really IRockRecipe, patched in lp.rocks.interfaces.webservice
+            Interface,
+            title=_("Rock recipe"),
+            required=True,
+            readonly=True,
+        )
+    )
+
+    status = exported(
+        Choice(
+            title=_("Status"),
+            vocabulary=RockRecipeBuildRequestStatus,
+            required=True,
+            readonly=True,
+        )
+    )
+
+    error_message = exported(
+        TextLine(title=_("Error message"), required=True, readonly=True)
+    )
+
+    builds = exported(
+        CollectionField(
+            title=_("Builds produced by this request"),
+            # Really IRockRecipeBuild, patched in
+            # lp.rocks.interfaces.webservice
+            value_type=Reference(schema=Interface),
+            required=True,
+            readonly=True,
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     )
 
     requester = Reference(
@@ -280,6 +357,7 @@ class IRockRecipeView(Interface):
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     date_created = Datetime(
         title=_("Date created"), required=True, readonly=True
     )
@@ -295,16 +373,51 @@ class IRockRecipeView(Interface):
         description=_("The person who registered this rock recipe."),
     )
 
-<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
-=======
-    source = Attribute("The source branch for this rock recipe.")
-
->>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     private = Bool(
         title=_("Private"),
         required=False,
         readonly=False,
         description=_("Whether this rock recipe is private."),
+=======
+    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 = exported(
+        PublicPersonChoice(
+            title=_("Registrant"),
+            required=True,
+            readonly=True,
+            vocabulary="ValidPersonOrTeam",
+            description=_("The person who registered this rock recipe."),
+        )
+    )
+
+    source = Attribute("The source branch for this rock recipe.")
+
+    private = exported(
+        Bool(
+            title=_("Private"),
+            required=False,
+            readonly=False,
+            description=_("Whether this rock recipe is private."),
+        )
+    )
+
+    can_upload_to_store = exported(
+        Bool(
+            title=_("Can upload to the RockStore"),
+            required=True,
+            readonly=True,
+            description=_(
+                "Whether everything is set up to allow uploading builds of "
+                "this rockrecipe to the RockStore."
+            ),
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     )
 
     def getAllowedInformationTypes(user):
@@ -330,6 +443,24 @@ class IRockRecipeView(Interface):
         :return: `IRockRecipeBuild`.
         """
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+    @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 'rockcraft', 'core', 'core18', "
+                "'core20', and 'core22' keys are supported."
+            ),
+            key_type=TextLine(),
+            required=False,
+        )
+    )
+    @export_factory_operation(IRockRecipeBuildRequest, [])
+    @operation_for_version("devel")
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     def requestBuilds(requester, channels=None, architectures=None):
         """Request that the rock recipe be built.
 
@@ -383,51 +514,72 @@ class IRockRecipeView(Interface):
 
 <<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
 =======
-    pending_build_requests = CollectionField(
-        title=_("Pending build requests for this rock recipe."),
-        value_type=Reference(IRockRecipeBuildRequest),
-        required=True,
-        readonly=True,
+    pending_build_requests = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Pending build requests for this rock recipe."),
+                value_type=Reference(IRockRecipeBuildRequest),
+                required=True,
+                readonly=True,
+            )
+        )
     )
 
-    failed_build_requests = CollectionField(
-        title=_("Failed build requests for this rock recipe."),
-        value_type=Reference(IRockRecipeBuildRequest),
-        required=True,
-        readonly=True,
+    failed_build_requests = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Failed build requests for this rock recipe."),
+                value_type=Reference(IRockRecipeBuildRequest),
+                required=True,
+                readonly=True,
+            )
+        )
     )
 
-    builds = CollectionField(
-        title=_("All builds of this rock recipe."),
-        description=_(
-            "All builds of this rock recipe, sorted in descending order "
-            "of finishing (or starting if not completed successfully)."
-        ),
-        # Really IRockRecipeBuild.
-        value_type=Reference(schema=Interface),
-        readonly=True,
+    builds = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("All builds of this rock recipe."),
+                description=_(
+                    "All builds of this rock recipe, sorted in descending "
+                    "order of finishing (or starting if not completed "
+                    "successfully)."
+                ),
+                # Really IRockRecipeBuild.
+                value_type=Reference(schema=Interface),
+                readonly=True,
+            )
+        )
     )
 
-    completed_builds = CollectionField(
-        title=_("Completed builds of this rock recipe."),
-        description=_(
-            "Completed builds of this rock recipe, sorted in descending "
-            "order of finishing."
-        ),
-        # Really IRockRecipeBuild.
-        value_type=Reference(schema=Interface),
-        readonly=True,
+    completed_builds = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Completed builds of this rock recipe."),
+                description=_(
+                    "Completed builds of this rock recipe, sorted in "
+                    "descending order of finishing."
+                ),
+                # Really IRockRecipeBuild.
+                value_type=Reference(schema=Interface),
+                readonly=True,
+            )
+        )
     )
 
-    pending_builds = CollectionField(
-        title=_("Pending builds of this rock recipe."),
-        description=_(
-            "Pending builds of this rock recipe, sorted in descending "
-            "order of creation."
-        ),
-        # Really IRockRecipeBuild.
-        value_type=Reference(schema=Interface),
-        readonly=True,
+    pending_builds = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Pending builds of this rock recipe."),
+                description=_(
+                    "Pending builds of this rock recipe, sorted in descending "
+                    "order of creation."
+                ),
+                # Really IRockRecipeBuild.
+                value_type=Reference(schema=Interface),
+                readonly=True,
+            )
+        )
     )
 
 >>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
@@ -435,6 +587,11 @@ class IRockRecipeView(Interface):
 class IRockRecipeEdit(Interface):
     """`IRockRecipe` methods that require launchpad.Edit permission."""
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+    @export_destructor_operation()
+    @operation_for_version("devel")
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     def destroySelf():
         """Delete this rock recipe, provided that it has no builds."""
 
@@ -455,6 +612,7 @@ class IRockRecipeEditableAttributes(Interface):
         )
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     project = ReferenceChoice(
         title=_("The project that this rock recipe is associated with"),
         schema=IProduct,
@@ -476,6 +634,35 @@ class IRockRecipeEditableAttributes(Interface):
         required=False,
         readonly=False,
         description=_("A description of the rock recipe."),
+=======
+    project = exported(
+        ReferenceChoice(
+            title=_("The project that this rock recipe is associated with"),
+            schema=IProduct,
+            vocabulary="Product",
+            required=True,
+            readonly=False,
+        )
+    )
+
+    name = exported(
+        TextLine(
+            title=_("Rock recipe name"),
+            required=True,
+            readonly=False,
+            constraint=name_validator,
+            description=_("The name of the rock recipe."),
+        )
+    )
+
+    description = exported(
+        Text(
+            title=_("Description"),
+            required=False,
+            readonly=False,
+            description=_("A description of the rock recipe."),
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     )
 
     git_repository = ReferenceChoice(
@@ -503,6 +690,7 @@ class IRockRecipeEditableAttributes(Interface):
         ),
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     git_ref = Reference(
         IGitRef,
         title=_("Git branch"),
@@ -574,6 +762,98 @@ class IRockRecipeEditableAttributes(Interface):
         required=False,
         readonly=False,
         description=_("The registered name of this rock in the store."),
+=======
+    git_ref = exported(
+        Reference(
+            IGitRef,
+            title=_("Git branch"),
+            required=False,
+            readonly=False,
+            description=_(
+                "The Git branch containing a rockcraft.yaml recipe."
+            ),
+        )
+    )
+
+    build_path = exported(
+        TextLine(
+            title=_("Build path"),
+            description=_(
+                "Subdirectory within the branch containing rockcraft.yaml."
+            ),
+            constraint=path_does_not_escape,
+            required=False,
+            readonly=False,
+        )
+    )
+
+    information_type = exported(
+        Choice(
+            title=_("Information type"),
+            vocabulary=InformationType,
+            required=True,
+            readonly=False,
+            default=InformationType.PUBLIC,
+            description=_(
+                "The type of information contained in this rock recipe."
+            ),
+        )
+    )
+
+    auto_build = exported(
+        Bool(
+            title=_("Automatically build when branch changes"),
+            required=True,
+            readonly=False,
+            description=_(
+                "Whether this rock recipe is built automatically when the "
+                "branch containing its rockcraft.yaml recipe changes."
+            ),
+        )
+    )
+
+    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 rock recipe. Currently only 'core', 'core18', "
+                "'core20', and 'rockcraft' keys are supported."
+            ),
+        )
+    )
+
+    is_stale = exported(
+        Bool(
+            title=_("Rock recipe is stale and is due to be rebuilt."),
+            required=True,
+            readonly=True,
+        )
+    )
+
+    store_upload = exported(
+        Bool(
+            title=_("Automatically upload to store"),
+            required=True,
+            readonly=False,
+            description=_(
+                "Whether builds of this rock recipe are automatically "
+                "uploaded to the store."
+            ),
+        )
+    )
+
+    store_name = exported(
+        TextLine(
+            title=_("Registered store name"),
+            required=False,
+            readonly=False,
+            description=_("The registered name of this rock in the store."),
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     )
 
     store_secrets = List(
@@ -587,6 +867,7 @@ class IRockRecipeEditableAttributes(Interface):
         ),
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     store_channels = List(
         title=_("Store channels"),
         required=False,
@@ -599,6 +880,22 @@ class IRockRecipeEditableAttributes(Interface):
             "'2.1/stable/fix-123', '2.1/stable', 'stable/fix-123', or "
             "'stable'."
         ),
+=======
+    store_channels = exported(
+        List(
+            title=_("Store channels"),
+            required=False,
+            readonly=False,
+            constraint=channels_validator,
+            description=_(
+                "Channels to release this rock to after uploading it to the "
+                "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'."
+            ),
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     )
 
 
@@ -608,6 +905,7 @@ class IRockRecipeAdminAttributes(Interface):
     These attributes need launchpad.View to see, and launchpad.Admin to change.
     """
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     require_virtualized = Bool(
         title=_("Require virtualized builders"),
         required=True,
@@ -616,6 +914,22 @@ class IRockRecipeAdminAttributes(Interface):
     )
 
 
+=======
+    require_virtualized = exported(
+        Bool(
+            title=_("Require virtualized builders"),
+            required=True,
+            readonly=False,
+            description=_("Only build this rock recipe on virtual builders."),
+        )
+    )
+
+
+# XXX jugmac00 2024-09-16 https://bugs.launchpad.net/lazr.restful/+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")
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
 class IRockRecipe(
     IRockRecipeView,
     IRockRecipeEdit,
@@ -626,9 +940,39 @@ class IRockRecipe(
     """A buildable rock recipe."""
 
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+class IRockRecipeSet(Interface):
+    """A utility to create and access rock recipes."""
+
+=======
+@exported_as_webservice_collection(IRockRecipe)
 class IRockRecipeSet(Interface):
     """A utility to create and access rock recipes."""
 
+    @call_with(registrant=REQUEST_USER)
+    @operation_parameters(
+        information_type=copy_field(
+            IRockRecipe["information_type"], required=False
+        )
+    )
+    @export_factory_operation(
+        IRockRecipe,
+        [
+            "owner",
+            "project",
+            "name",
+            "description",
+            "git_ref",
+            "build_path",
+            "auto_build",
+            "auto_build_channels",
+            "store_upload",
+            "store_name",
+            "store_channels",
+        ],
+    )
+    @operation_for_version("devel")
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
     def new(
         registrant,
         owner,
@@ -649,10 +993,10 @@ class IRockRecipeSet(Interface):
     ):
         """Create an `IRockRecipe`."""
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     def getByName(owner, project, name):
         """Returns the appropriate `IRockRecipe` for the given objects."""
 
-<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
     def isValidInformationType(information_type, owner, git_ref=None):
         """Whether the information type context is valid."""
 
@@ -661,6 +1005,17 @@ class IRockRecipeSet(Interface):
 
     def findByGitRepository(repository, paths=None):
 =======
+    @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(IRockRecipe)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getByName(owner, project, name):
+        """Returns the appropriate `IRockRecipe` for the given objects."""
+
     def findByPerson(person, visible_by_user=None):
         """Return all rock recipes relevant to `person`.
 
@@ -743,3 +1098,13 @@ class IRockRecipeSet(Interface):
         After this, any rock recipes that previously used this repository
         will have no source and so cannot dispatch new builds.
         """
+<<<<<<< lib/lp/rocks/interfaces/rockrecipe.py
+=======
+
+    @collection_default_content()
+    def empty_list():
+        """Return an empty collection of rock recipes.
+
+        This only exists to keep lazr.restful happy.
+        """
+>>>>>>> lib/lp/rocks/interfaces/rockrecipe.py
diff --git a/lib/lp/rocks/interfaces/rockrecipebuild.py b/lib/lp/rocks/interfaces/rockrecipebuild.py
index f2f5eb7..7161dc0 100644
--- a/lib/lp/rocks/interfaces/rockrecipebuild.py
+++ b/lib/lp/rocks/interfaces/rockrecipebuild.py
@@ -9,6 +9,10 @@ __all__ = [
     "IRockRecipeBuildSet",
 ]
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipebuild.py
+=======
+from lazr.restful.declarations import exported, exported_as_webservice_entry
+>>>>>>> lib/lp/rocks/interfaces/rockrecipebuild.py
 from lazr.restful.fields import Reference
 from zope.interface import Attribute, Interface
 from zope.schema import Bool, Datetime, Dict, Int, TextLine
@@ -39,6 +43,7 @@ class IRockRecipeBuildView(IPackageBuildView):
         readonly=True,
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipebuild.py
     requester = Reference(
         IPerson,
         title=_("The person who requested this build."),
@@ -68,16 +73,68 @@ class IRockRecipeBuildView(IPackageBuildView):
             "and 'rockcraft' keys are supported."
         ),
         key_type=TextLine(),
+=======
+    requester = exported(
+        Reference(
+            IPerson,
+            title=_("The person who requested this build."),
+            required=True,
+            readonly=True,
+        )
+    )
+
+    recipe = exported(
+        Reference(
+            IRockRecipe,
+            title=_("The rock recipe to build."),
+            required=True,
+            readonly=True,
+        )
+    )
+
+    distro_arch_series = exported(
+        Reference(
+            IDistroArchSeries,
+            title=_("The series and architecture for which to build."),
+            required=True,
+            readonly=True,
+        )
+    )
+
+    arch_tag = exported(
+        TextLine(title=_("Architecture tag"), required=True, readonly=True)
+    )
+
+    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 'core', 'core18', 'core20', "
+                "and 'rockcraft' keys are supported."
+            ),
+            key_type=TextLine(),
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipebuild.py
     )
 
     virtualized = Bool(
         title=_("If True, this build is virtualized."), readonly=True
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipebuild.py
     score = Int(
         title=_("Score of the related build farm job (if any)."),
         required=False,
         readonly=True,
+=======
+    score = exported(
+        Int(
+            title=_("Score of the related build farm job (if any)."),
+            required=False,
+            readonly=True,
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipebuild.py
     )
 
     eta = Datetime(
@@ -96,6 +153,7 @@ class IRockRecipeBuildView(IPackageBuildView):
         readonly=True,
     )
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipebuild.py
     revision_id = TextLine(
         title=_("Revision ID"),
         required=False,
@@ -104,6 +162,18 @@ class IRockRecipeBuildView(IPackageBuildView):
             "The revision ID of the branch used for this build, if "
             "available."
         ),
+=======
+    revision_id = exported(
+        TextLine(
+            title=_("Revision ID"),
+            required=False,
+            readonly=True,
+            description=_(
+                "The revision ID of the branch used for this build, if "
+                "available."
+            ),
+        )
+>>>>>>> lib/lp/rocks/interfaces/rockrecipebuild.py
     )
 
     store_upload_metadata = Attribute(
@@ -152,6 +222,13 @@ class IRockRecipeBuildAdmin(Interface):
         """Change the build's score."""
 
 
+<<<<<<< lib/lp/rocks/interfaces/rockrecipebuild.py
+=======
+# XXX jugmac00 2024-09-16 see "beta" is a lie to get WADL generation working,
+# see https://bugs.launchpad.net/lazr.restful/+bug/760849
+# Individual attributes must set their version to "devel".
+@exported_as_webservice_entry(as_of="beta")
+>>>>>>> lib/lp/rocks/interfaces/rockrecipebuild.py
 class IRockRecipeBuild(
     IRockRecipeBuildView,
     IRockRecipeBuildEdit,
diff --git a/lib/lp/rocks/interfaces/webservice.py b/lib/lp/rocks/interfaces/webservice.py
new file mode 100644
index 0000000..3f3586c
--- /dev/null
+++ b/lib/lp/rocks/interfaces/webservice.py
@@ -0,0 +1,40 @@
+# Copyright 2024 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.rocks.interfaces.webservice" />
+
+which tells `lazr.restful` that it should look for webservice exports here.
+"""
+
+__all__ = [
+    "IRockRecipe",
+    "IRockRecipeBuild",
+    "IRockRecipeBuildRequest",
+    "IRockRecipeSet",
+]
+
+from lp.rocks.interfaces.rockrecipe import (
+    IRockRecipe,
+    IRockRecipeBuildRequest,
+    IRockRecipeSet,
+    IRockRecipeView,
+)
+from lp.rocks.interfaces.rockrecipebuild import IRockRecipeBuild
+from lp.services.webservice.apihelpers import (
+    patch_collection_property,
+    patch_reference_property,
+)
+
+# IRockRecipeBuildRequest
+patch_reference_property(IRockRecipeBuildRequest, "recipe", IRockRecipe)
+patch_collection_property(IRockRecipeBuildRequest, "builds", IRockRecipeBuild)
+
+# IRockRecipeView
+patch_collection_property(IRockRecipeView, "builds", IRockRecipeBuild)
+patch_collection_property(
+    IRockRecipeView, "completed_builds", IRockRecipeBuild
+)
+patch_collection_property(IRockRecipeView, "pending_builds", IRockRecipeBuild)
diff --git a/lib/lp/rocks/model/rockrecipe.py b/lib/lp/rocks/model/rockrecipe.py
index affb756..00d9c80 100644
--- a/lib/lp/rocks/model/rockrecipe.py
+++ b/lib/lp/rocks/model/rockrecipe.py
@@ -738,6 +738,12 @@ class RockRecipe(StormBase):
         order_by = Desc(RockRecipeBuild.id)
         return self._getBuilds(filter_term, order_by)
 
+    @property
+    def can_upload_to_store(self):
+        # no store upload planned for the initial implementation, as artifacts
+        # get pulled from Launchpad for now only.
+        return False
+
     def destroySelf(self):
         """See `IRockRecipe`."""
         store = IStore(self)
@@ -1114,6 +1120,10 @@ class RockRecipeSet:
             git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
         )
 
+    def empty_list(self):
+        """See `IRockRecipeSet`."""
+        return []
+
 
 def get_rock_recipe_privacy_filter(user):
     """Return a Storm query filter to find rock recipes visible to `user`."""
diff --git a/lib/lp/rocks/model/rockrecipebuild.py b/lib/lp/rocks/model/rockrecipebuild.py
index 5befbd9..8d7a3f4 100644
--- a/lib/lp/rocks/model/rockrecipebuild.py
+++ b/lib/lp/rocks/model/rockrecipebuild.py
@@ -186,6 +186,14 @@ class RockRecipeBuild(PackageBuildMixin, StormBase):
         return self.distro_arch_series.distroseries
 
     @property
+<<<<<<< lib/lp/rocks/model/rockrecipebuild.py
+=======
+    def arch_tag(self):
+        """See `IRockRecipeBuild`."""
+        return self.distro_arch_series.architecturetag
+
+    @property
+>>>>>>> lib/lp/rocks/model/rockrecipebuild.py
     def archive(self):
         """See `IPackageBuild`."""
         return self.distribution.main_archive
diff --git a/lib/lp/rocks/tests/test_rockrecipe.py b/lib/lp/rocks/tests/test_rockrecipe.py
index 9abf788..3ccc9b0 100644
--- a/lib/lp/rocks/tests/test_rockrecipe.py
+++ b/lib/lp/rocks/tests/test_rockrecipe.py
@@ -4,15 +4,27 @@
 """Test rock recipes."""
 <<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
 
+from storm.locals import Store
+from testtools.matchers import (
+    Equals,
+    Is,
 =======
+import json
+from datetime import timedelta
 from textwrap import dedent
 
+import iso8601
 import transaction
->>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
 from storm.locals import Store
 from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
     Equals,
+    GreaterThan,
     Is,
+    LessThan,
+    MatchesAll,
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     MatchesDict,
     MatchesSetwise,
     MatchesStructure,
@@ -22,10 +34,15 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 <<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
+from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
 =======
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import (
+    BuildBaseImageType,
+    BuildQueueStatus,
+    BuildStatus,
+)
 >>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
-from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.processor import (
     IProcessorSet,
@@ -35,16 +52,21 @@ from lp.buildmaster.interfaces.processor import (
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.rocks.interfaces.rockrecipe import (
     ROCK_RECIPE_ALLOW_CREATE,
+    IRockRecipe,
+    IRockRecipeSet,
 =======
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.tests.helpers import GitHostingFixture
+from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
 from lp.rocks.interfaces.rockrecipe import (
     ROCK_RECIPE_ALLOW_CREATE,
+    ROCK_RECIPE_PRIVATE_FEATURE_FLAG,
     BadRockRecipeSearchContext,
->>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     IRockRecipe,
     IRockRecipeSet,
+    IRockRecipeView,
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     NoSourceForRockRecipe,
     RockRecipeBuildAlreadyPending,
     RockRecipeBuildDisallowedArchitecture,
@@ -81,14 +103,26 @@ 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.snapshot import notify_modified
-from lp.testing import TestCaseWithFactory, admin_logged_in, person_logged_in
+from lp.testing import (
+    ANONYMOUS,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    admin_logged_in,
+    api_url,
+    login,
+    logout,
+    person_logged_in,
+)
 from lp.testing.dbuser import dbuser
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadFunctionalLayer,
     LaunchpadZopelessLayer,
 )
+from lp.testing.matchers import DoesNotSnapshot, HasQueryCount
+from lp.testing.pages import webservice_for_person
 >>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
 
 
@@ -136,6 +170,24 @@ class TestRockRecipe(TestCaseWithFactory):
             repr(recipe),
         )
 
+<<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
+=======
+    def test_avoids_problematic_snapshots(self):
+        self.assertThat(
+            self.factory.makeRockRecipe(),
+            DoesNotSnapshot(
+                [
+                    "pending_build_requests",
+                    "failed_build_requests",
+                    "builds",
+                    "completed_builds",
+                    "pending_builds",
+                ],
+                IRockRecipeView,
+            ),
+        )
+
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
     def test_initial_date_last_modified(self):
         # The initial value of date_last_modified is date_created.
         recipe = self.factory.makeRockRecipe(date_created=ONE_DAY_AGO)
@@ -965,3 +1017,658 @@ class TestRockRecipeSet(TestCaseWithFactory):
             self.assertSqlAttributeEqualsDate(
                 recipe, "date_last_modified", UTC_NOW
             )
+<<<<<<< lib/lp/rocks/tests/test_rockrecipe.py
+=======
+
+
+class TestRockRecipeWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ROCK_RECIPE_ALLOW_CREATE: "on",
+                    ROCK_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 makeRockRecipe(
+        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(
+            "/+rock-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):
+        # Rock 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.makeRockRecipe(
+            owner=team, project=project, name="test-rock", 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-rock"),
+                        "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 RockRecipe.new work.
+        store_name = self.factory.getUniqueUnicode()
+        recipe = self.makeRockRecipe(
+            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 rock 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.makeRockRecipe(
+            owner=team, project=project, name=name, git_ref=git_ref
+        )
+        response = self.webservice.named_post(
+            "/+rock-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 rock 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,
+        # rock 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(
+            "/+rock-recipes",
+            "new",
+            owner=other_person_url,
+            project=project_url,
+            name="test-rock",
+            git_ref=git_ref_url,
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=401,
+                body=(
+                    b"Test Person cannot create rock recipes owned by "
+                    b"Other Person."
+                ),
+            ),
+        )
+        response = self.webservice.named_post(
+            "/+rock-recipes",
+            "new",
+            owner=other_team_url,
+            project=project_url,
+            name="test-rock",
+            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 rock recipe is public, then trying to change its owner or
+        # git_ref components to be private fails.
+        recipe = self.factory.makeRockRecipe(
+            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 rock 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 rock recipe cannot have a private repository.",
+            ),
+        )
+
+    def test_is_stale(self):
+        # is_stale is exported and is read-only.
+        recipe = self.makeRockRecipe()
+        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.rock_recipes.getByName returns a matching RockRecipe.
+        project = self.factory.makeProduct(owner=self.person)
+        name = self.factory.getUniqueUnicode()
+        recipe = self.makeRockRecipe(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(
+            "/+rock-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.rock_recipes.getByName returns 404 for a non-existent
+        # RockRecipe.
+        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(
+            "/+rock-recipes",
+            "getByName",
+            owner=owner_url,
+            project=project_url,
+            name="nonexistent",
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=404,
+                body=(
+                    b"No such rock 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.makeRockRecipe(git_ref=git_ref)
+        now = get_transaction_timestamp(IStore(distroseries))
+        response = self.webservice.named_post(
+            recipe["self_link"],
+            "requestBuilds",
+            channels={"rockcraft": "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):
+            rockcraft_yaml = "bases:\n"
+            for processor in processors:
+                rockcraft_yaml += (
+                    "  - build-on:\n"
+                    "    - name: ubuntu\n"
+                    '      channel: "%s"\n'
+                    "      architectures: [%s]\n"
+                    % (distroseries.version, processor.name)
+                )
+            self.useFixture(GitHostingFixture(blob=rockcraft_yaml))
+            [job] = getUtility(IRockRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.IRockRecipeRequestBuildsJobSource.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({"rockcraft": "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.makeRockRecipe(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(IRockRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.IRockRecipeRequestBuildsJobSource.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.makeRockRecipe(
+            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 rock 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.makeRockRecipe(project=project)
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds"
+        )
+        self.assertEqual(201, response.status)
+        with person_logged_in(self.person):
+            rockcraft_yaml = "bases:\n"
+            for processor in processors:
+                rockcraft_yaml += (
+                    "  - build-on:\n"
+                    "    - name: ubuntu\n"
+                    '      channel: "%s"\n'
+                    "      architectures: [%s]\n"
+                    % (distroseries.version, processor.name)
+                )
+            self.useFixture(GitHostingFixture(blob=rockcraft_yaml))
+            [job] = getUtility(IRockRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.IRockRecipeRequestBuildsJobSource.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(IRockRecipeSet).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):
+        # RockRecipe has a reasonable query count.
+        recipe = self.factory.makeRockRecipe(
+            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)))
+>>>>>>> lib/lp/rocks/tests/test_rockrecipe.py
diff --git a/lib/lp/rocks/tests/test_rockrecipebuild.py b/lib/lp/rocks/tests/test_rockrecipebuild.py
index a9d6ffe..48795aa 100644
--- a/lib/lp/rocks/tests/test_rockrecipebuild.py
+++ b/lib/lp/rocks/tests/test_rockrecipebuild.py
@@ -3,14 +3,25 @@
 
 """Test rock package build features."""
 from datetime import datetime, timedelta, timezone
+<<<<<<< lib/lp/rocks/tests/test_rockrecipebuild.py
 
 import six
 from testtools.matchers import Equals
+=======
+from urllib.request import urlopen
+
+import six
+from testtools.matchers import ContainsDict, Equals, Is
+>>>>>>> lib/lp/rocks/tests/test_rockrecipebuild.py
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 from lp.app.errors import NotFoundError
+<<<<<<< lib/lp/rocks/tests/test_rockrecipebuild.py
+=======
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+>>>>>>> lib/lp/rocks/tests/test_rockrecipebuild.py
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
@@ -28,6 +39,7 @@ from lp.rocks.interfaces.rockrecipebuild import (
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import clear_property_cache
+<<<<<<< lib/lp/rocks/tests/test_rockrecipebuild.py
 from lp.testing import (
     StormStatementRecorder,
     TestCaseWithFactory,
@@ -36,6 +48,22 @@ from lp.testing import (
 from lp.testing.layers import LaunchpadZopelessLayer
 from lp.testing.mail_helpers import pop_notifications
 from lp.testing.matchers import HasQueryCount
+=======
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.testing import (
+    ANONYMOUS,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    api_url,
+    login,
+    logout,
+    person_logged_in,
+)
+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
+>>>>>>> lib/lp/rocks/tests/test_rockrecipebuild.py
 
 expected_body = """\
  * Rock Recipe: rock-1
@@ -447,3 +475,142 @@ class TestRockRecipeBuildSet(TestCaseWithFactory):
             recipe=recipe, distro_arch_series=distro_arch_series
         )
         self.assertFalse(target.virtualized)
+<<<<<<< lib/lp/rocks/tests/test_rockrecipebuild.py
+=======
+
+
+class TestRockRecipeBuildWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ROCK_RECIPE_ALLOW_CREATE: "on",
+                    ROCK_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 rock recipe build are sensible.
+        db_build = self.factory.makeRockRecipeBuild(
+            requester=self.person,
+            date_created=datetime(2021, 9, 15, 16, 21, 0, tzinfo=timezone.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 rock recipe build with a public recipe is itself public.
+        db_build = self.factory.makeRockRecipeBuild()
+        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.makeRockRecipeBuild(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.makeRockRecipeBuild(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.makeRockRecipeBuild(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"])
+>>>>>>> lib/lp/rocks/tests/test_rockrecipebuild.py

Follow ups