launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31477
[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