launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31620
[Merge] ~ruinedyourlife/launchpad:add-webservice-for-craft-recipes into launchpad:master
Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-webservice-for-craft-recipes into launchpad:master with ~ruinedyourlife/launchpad:completing-craft-api as a prerequisite.
Commit message:
Add webservice for craft recipes
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/474292
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-webservice-for-craft-recipes into launchpad:master.
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index 1fa3858..8bbb95e 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -21,6 +21,7 @@ __all__ = [
"ICraftRecipe",
"ICraftRecipeBuildRequest",
"ICraftRecipeSet",
+ "ICraftRecipeView",
"MissingSourcecraftYaml",
"NoSourceForCraftRecipe",
"NoSuchCraftRecipe",
@@ -29,8 +30,24 @@ __all__ = [
import http.client
from lazr.enum import EnumeratedType, Item
-from lazr.restful.declarations import error_status, exported
+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
from zope.schema import (
Bool,
@@ -199,40 +216,53 @@ class CraftRecipeBuildRequestStatus(EnumeratedType):
)
+# XXX ruinedyourlife 2024-10-02
+# 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")
class ICraftRecipeBuildRequest(Interface):
"""A request to build a craft recipe."""
id = Int(title=_("ID"), required=True, readonly=True)
- date_requested = Datetime(
- title=_("The time when this request was made"),
- required=True,
- readonly=True,
+ date_requested = exported(
+ Datetime(
+ title=_("The time when this request was made"),
+ required=True,
+ readonly=True,
+ )
)
- date_finished = Datetime(
- title=_("The time when this request finished"),
- required=False,
- readonly=True,
+ date_finished = exported(
+ Datetime(
+ title=_("The time when this request finished"),
+ required=False,
+ readonly=True,
+ )
)
- recipe = Reference(
- # Really ICraftRecipe.
- Interface,
- title=_("Craft recipe"),
- required=True,
- readonly=True,
+ recipe = exported(
+ Reference(
+ # Really ICraftRecipe.
+ Interface,
+ title=_("Craft recipe"),
+ required=True,
+ readonly=True,
+ )
)
- status = Choice(
- title=_("Status"),
- vocabulary=CraftRecipeBuildRequestStatus,
- required=True,
- readonly=True,
+ status = exported(
+ Choice(
+ title=_("Status"),
+ vocabulary=CraftRecipeBuildRequestStatus,
+ required=True,
+ readonly=True,
+ )
)
- error_message = TextLine(
- title=_("Error message"), required=True, readonly=True
+ error_message = exported(
+ TextLine(title=_("Error message"), required=True, readonly=True)
)
channels = Dict(
@@ -249,12 +279,14 @@ class ICraftRecipeBuildRequest(Interface):
readonly=True,
)
- builds = CollectionField(
- title=_("Builds produced by this request"),
- # Really ICraftRecipeBuild.
- value_type=Reference(schema=Interface),
- required=True,
- readonly=True,
+ builds = exported(
+ CollectionField(
+ title=_("Builds produced by this request"),
+ # Really ICraftRecipeBuild.
+ value_type=Reference(schema=Interface),
+ required=True,
+ readonly=True,
+ )
)
requester = Reference(
@@ -270,28 +302,44 @@ class ICraftRecipeView(Interface):
id = Int(title=_("ID"), required=True, readonly=True)
- date_created = Datetime(
- title=_("Date created"), required=True, readonly=True
+ date_created = exported(
+ Datetime(title=_("Date created"), required=True, readonly=True)
)
- date_last_modified = Datetime(
- title=_("Date last modified"), required=True, readonly=True
+ date_last_modified = exported(
+ Datetime(title=_("Date last modified"), required=True, readonly=True)
)
- registrant = PublicPersonChoice(
- title=_("Registrant"),
- required=True,
- readonly=True,
- vocabulary="ValidPersonOrTeam",
- description=_("The person who registered this craft recipe."),
+ registrant = exported(
+ PublicPersonChoice(
+ title=_("Registrant"),
+ required=True,
+ readonly=True,
+ vocabulary="ValidPersonOrTeam",
+ description=_("The person who registered this craft recipe."),
+ )
)
source = Attribute("The source branch for this craft recipe.")
- private = Bool(
- title=_("Private"),
- required=False,
- readonly=False,
- description=_("Whether this craft recipe is private."),
+ private = exported(
+ Bool(
+ title=_("Private"),
+ required=False,
+ readonly=False,
+ description=_("Whether this craft recipe is private."),
+ )
+ )
+
+ can_upload_to_store = exported(
+ Bool(
+ title=_("Can upload to the CraftStore"),
+ required=True,
+ readonly=True,
+ description=_(
+ "Whether everything is set up to allow uploading builds of "
+ "this craftrecipe to the CraftStore."
+ ),
+ )
)
def getAllowedInformationTypes(user):
@@ -365,57 +413,80 @@ class ICraftRecipeView(Interface):
:return: `ICraftRecipeBuildRequest`.
"""
- pending_build_requests = CollectionField(
- title=_("Pending build requests for this craft recipe."),
- value_type=Reference(ICraftRecipeBuildRequest),
- required=True,
- readonly=True,
+ pending_build_requests = exported(
+ doNotSnapshot(
+ CollectionField(
+ title=_("Pending build requests for this craft recipe."),
+ value_type=Reference(ICraftRecipeBuildRequest),
+ required=True,
+ readonly=True,
+ )
+ )
)
- failed_build_requests = CollectionField(
- title=_("Failed build requests for this craft recipe."),
- value_type=Reference(ICraftRecipeBuildRequest),
- required=True,
- readonly=True,
+ failed_build_requests = exported(
+ doNotSnapshot(
+ CollectionField(
+ title=_("Failed build requests for this craft recipe."),
+ value_type=Reference(ICraftRecipeBuildRequest),
+ required=True,
+ readonly=True,
+ )
+ )
)
- builds = CollectionField(
- title=_("All builds of this craft recipe."),
- description=_(
- "All builds of this craft recipe, sorted in descending order "
- "of finishing (or starting if not completed successfully)."
- ),
- # Really ICraftRecipeBuild.
- value_type=Reference(schema=Interface),
- readonly=True,
+ builds = exported(
+ doNotSnapshot(
+ CollectionField(
+ title=_("All builds of this craft recipe."),
+ description=_(
+ "All builds of this craft recipe, sorted in descending "
+ "order of finishing (or starting if not completed "
+ "successfully)."
+ ),
+ # Really ICraftRecipeBuild.
+ value_type=Reference(schema=Interface),
+ readonly=True,
+ )
+ )
)
- completed_builds = CollectionField(
- title=_("Completed builds of this craft recipe."),
- description=_(
- "Completed builds of this craft recipe, sorted in descending "
- "order of finishing."
- ),
- # Really ICraftRecipeBuild.
- value_type=Reference(schema=Interface),
- readonly=True,
+ completed_builds = exported(
+ doNotSnapshot(
+ CollectionField(
+ title=_("Completed builds of this craft recipe."),
+ description=_(
+ "Completed builds of this craft recipe, sorted in "
+ "descending order of finishing."
+ ),
+ # Really ICraftRecipeBuild.
+ value_type=Reference(schema=Interface),
+ readonly=True,
+ )
+ )
)
- pending_builds = CollectionField(
- title=_("Pending builds of this craft recipe."),
- description=_(
- "Pending builds of this craft recipe, sorted in descending "
- "order of creation."
- ),
- # Really ICraftRecipeBuild.
- value_type=Reference(schema=Interface),
- readonly=True,
+ pending_builds = exported(
+ doNotSnapshot(
+ CollectionField(
+ title=_("Pending builds of this craft recipe."),
+ description=_(
+ "Pending builds of this craft recipe, sorted in "
+ "descending order of creation."
+ ),
+ # Really ICraftRecipeBuild.
+ value_type=Reference(schema=Interface),
+ readonly=True,
+ )
+ )
)
class ICraftRecipeEdit(Interface):
"""`ICraftRecipe` methods that require launchpad.Edit permission."""
+ @export_destructor_operation()
+ @operation_for_version("devel")
def destroySelf():
"""Delete this craft recipe, provided that it has no builds."""
@@ -436,27 +507,33 @@ class ICraftRecipeEditableAttributes(Interface):
)
)
- project = ReferenceChoice(
- title=_("The project that this craft recipe is associated with"),
- schema=IProduct,
- vocabulary="Product",
- required=True,
- readonly=False,
+ project = exported(
+ ReferenceChoice(
+ title=_("The project that this craft recipe is associated with"),
+ schema=IProduct,
+ vocabulary="Product",
+ required=True,
+ readonly=False,
+ )
)
- name = TextLine(
- title=_("Craft recipe name"),
- required=True,
- readonly=False,
- constraint=name_validator,
- description=_("The name of the craft recipe."),
+ name = exported(
+ TextLine(
+ title=_("Craft recipe name"),
+ required=True,
+ readonly=False,
+ constraint=name_validator,
+ description=_("The name of the craft recipe."),
+ )
)
- description = Text(
- title=_("Description"),
- required=False,
- readonly=False,
- description=_("A description of the craft recipe."),
+ description = exported(
+ Text(
+ title=_("Description"),
+ required=False,
+ readonly=False,
+ description=_("A description of the craft recipe."),
+ )
)
git_repository = ReferenceChoice(
@@ -479,75 +556,93 @@ class ICraftRecipeEditableAttributes(Interface):
),
)
- git_ref = Reference(
- IGitRef,
- title=_("Git branch"),
- required=False,
- readonly=False,
- description=_("The Git branch containing a craft.yaml recipe."),
+ git_ref = exported(
+ Reference(
+ IGitRef,
+ title=_("Git branch"),
+ required=False,
+ readonly=False,
+ description=_("The Git branch containing a craft.yaml recipe."),
+ )
)
- build_path = TextLine(
- title=_("Build path"),
- description=_("Subdirectory within the branch containing craft.yaml."),
- constraint=path_does_not_escape,
- required=False,
- readonly=False,
+ build_path = exported(
+ TextLine(
+ title=_("Build path"),
+ description=_(
+ "Subdirectory within the branch containing craft.yaml."
+ ),
+ constraint=path_does_not_escape,
+ required=False,
+ readonly=False,
+ )
)
- information_type = Choice(
- title=_("Information type"),
- vocabulary=InformationType,
- required=True,
- readonly=False,
- default=InformationType.PUBLIC,
- description=_(
- "The type of information contained in this craft recipe."
- ),
+ information_type = exported(
+ Choice(
+ title=_("Information type"),
+ vocabulary=InformationType,
+ required=True,
+ readonly=False,
+ default=InformationType.PUBLIC,
+ description=_(
+ "The type of information contained in this craft recipe."
+ ),
+ )
)
- auto_build = Bool(
- title=_("Automatically build when branch changes"),
- required=True,
- readonly=False,
- description=_(
- "Whether this craft recipe is built automatically when the branch "
- "containing its craft.yaml recipe changes."
- ),
+ auto_build = exported(
+ Bool(
+ title=_("Automatically build when branch changes"),
+ required=True,
+ readonly=False,
+ description=_(
+ "Whether this craft recipe is built automatically when the "
+ "branch containing its craft.yaml recipe changes."
+ ),
+ )
)
- auto_build_channels = 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 craft recipe. Currently only 'core', 'core18', 'core20', "
- "and 'craft' keys are supported."
- ),
+ 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 craft recipe. Currently only 'core', "
+ "'core18', 'core20', and 'craft' keys are supported."
+ ),
+ )
)
- is_stale = Bool(
- title=_("Craft recipe is stale and is due to be rebuilt."),
- required=True,
- readonly=True,
+ is_stale = exported(
+ Bool(
+ title=_("Craft recipe is stale and is due to be rebuilt."),
+ required=True,
+ readonly=True,
+ )
)
- store_upload = Bool(
- title=_("Automatically upload to store"),
- required=True,
- readonly=False,
- description=_(
- "Whether builds of this craft recipe are automatically uploaded "
- "to the store."
- ),
+ store_upload = exported(
+ Bool(
+ title=_("Automatically upload to store"),
+ required=True,
+ readonly=False,
+ description=_(
+ "Whether builds of this craft recipe are automatically "
+ "uploaded to the store."
+ ),
+ )
)
- store_name = TextLine(
- title=_("Registered store name"),
- required=False,
- readonly=False,
- description=_("The registered name of this craft in the store."),
+ store_name = exported(
+ TextLine(
+ title=_("Registered store name"),
+ required=False,
+ readonly=False,
+ description=_("The registered name of this craft in the store."),
+ )
)
store_secrets = List(
@@ -561,18 +656,20 @@ class ICraftRecipeEditableAttributes(Interface):
),
)
- store_channels = List(
- title=_("Store channels"),
- required=False,
- readonly=False,
- constraint=channels_validator,
- description=_(
- "Channels to release this craft 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'."
- ),
+ store_channels = exported(
+ List(
+ title=_("Store channels"),
+ required=False,
+ readonly=False,
+ constraint=channels_validator,
+ description=_(
+ "Channels to release this craft 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'."
+ ),
+ )
)
@@ -582,14 +679,21 @@ class ICraftRecipeAdminAttributes(Interface):
These attributes need launchpad.View to see, and launchpad.Admin to change.
"""
- require_virtualized = Bool(
- title=_("Require virtualized builders"),
- required=True,
- readonly=False,
- description=_("Only build this craft recipe on virtual builders."),
+ require_virtualized = exported(
+ Bool(
+ title=_("Require virtualized builders"),
+ required=True,
+ readonly=False,
+ description=_("Only build this craft recipe on virtual builders."),
+ )
)
+# XXX ruinedyourlife 2024-10-02
+# 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")
class ICraftRecipe(
ICraftRecipeView,
ICraftRecipeEdit,
@@ -600,9 +704,37 @@ class ICraftRecipe(
"""A buildable craft recipe."""
+# XXX ruinedyourlife 2024-10-02
+# 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_collection(ICraftRecipe)
class ICraftRecipeSet(Interface):
"""A utility to create and access craft recipes."""
+ @call_with(registrant=REQUEST_USER)
+ @operation_parameters(
+ information_type=copy_field(
+ ICraftRecipe["information_type"], required=False
+ )
+ )
+ @export_factory_operation(
+ ICraftRecipe,
+ [
+ "owner",
+ "project",
+ "name",
+ "description",
+ "git_ref",
+ "build_path",
+ "auto_build",
+ "auto_build_channels",
+ "store_upload",
+ "store_name",
+ "store_channels",
+ ],
+ )
+ @operation_for_version("devel")
def new(
registrant,
owner,
@@ -623,6 +755,14 @@ class ICraftRecipeSet(Interface):
):
"""Create an `ICraftRecipe`."""
+ @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(ICraftRecipe)
+ @export_read_operation()
+ @operation_for_version("devel")
def getByName(owner, project, name):
"""Returns the appropriate `ICraftRecipe` for the given objects."""
@@ -651,6 +791,13 @@ class ICraftRecipeSet(Interface):
will have no source and so cannot dispatch new builds.
"""
+ @collection_default_content()
+ def empty_list():
+ """Return an empty collection of craft recipes.
+
+ This only exists to keep lazr.restful happy.
+ """
+
def preloadDataForRecipes(recipes, user):
"""Load the data related to a list of craft recipes."""
diff --git a/lib/lp/crafts/interfaces/craftrecipebuild.py b/lib/lp/crafts/interfaces/craftrecipebuild.py
index e99e294..4c77eb8 100644
--- a/lib/lp/crafts/interfaces/craftrecipebuild.py
+++ b/lib/lp/crafts/interfaces/craftrecipebuild.py
@@ -9,6 +9,7 @@ __all__ = [
"ICraftRecipeBuildSet",
]
+from lazr.restful.declarations import exported, exported_as_webservice_entry
from lazr.restful.fields import Reference
from zope.interface import Attribute, Interface
from zope.schema import Bool, Datetime, Dict, Int, TextLine
@@ -42,45 +43,59 @@ class ICraftRecipeBuildView(IPackageBuildView):
readonly=True,
)
- requester = Reference(
- IPerson,
- title=_("The person who requested this build."),
- required=True,
- readonly=True,
+ requester = exported(
+ Reference(
+ IPerson,
+ title=_("The person who requested this build."),
+ required=True,
+ readonly=True,
+ )
)
- recipe = Reference(
- ICraftRecipe,
- title=_("The craft recipe to build."),
- required=True,
- readonly=True,
+ recipe = exported(
+ Reference(
+ ICraftRecipe,
+ title=_("The craft recipe to build."),
+ required=True,
+ readonly=True,
+ )
)
- distro_arch_series = Reference(
- IDistroArchSeries,
- title=_("The series and architecture for which 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,
+ )
)
- channels = 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 'sourcecraft' keys are supported."
- ),
- key_type=TextLine(),
+ 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 'sourcecraft' keys are supported."
+ ),
+ key_type=TextLine(),
+ )
)
virtualized = Bool(
title=_("If True, this build is virtualized."), readonly=True
)
- 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,
+ )
)
eta = Datetime(
@@ -99,14 +114,16 @@ class ICraftRecipeBuildView(IPackageBuildView):
readonly=True,
)
- revision_id = TextLine(
- title=_("Revision ID"),
- required=False,
- readonly=True,
- description=_(
- "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."
+ ),
+ )
)
store_upload_metadata = Attribute(
@@ -155,6 +172,11 @@ class ICraftRecipeBuildAdmin(Interface):
"""Change the build's score."""
+# XXX ruinedyourlife 2024-10-02
+# 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")
class ICraftRecipeBuild(
ICraftRecipeBuildView,
ICraftRecipeBuildEdit,
diff --git a/lib/lp/crafts/interfaces/webservice.py b/lib/lp/crafts/interfaces/webservice.py
new file mode 100644
index 0000000..ad511cb
--- /dev/null
+++ b/lib/lp/crafts/interfaces/webservice.py
@@ -0,0 +1,44 @@
+# 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.crafts.interfaces.webservice" />
+
+which tells `lazr.restful` that it should look for webservice exports here.
+"""
+
+__all__ = [
+ "ICraftRecipe",
+ "ICraftRecipeBuild",
+ "ICraftRecipeBuildRequest",
+ "ICraftRecipeSet",
+]
+
+from lp.crafts.interfaces.craftrecipe import (
+ ICraftRecipe,
+ ICraftRecipeBuildRequest,
+ ICraftRecipeSet,
+ ICraftRecipeView,
+)
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
+from lp.services.webservice.apihelpers import (
+ patch_collection_property,
+ patch_reference_property,
+)
+
+# ICraftRecipeBuildRequest
+patch_reference_property(ICraftRecipeBuildRequest, "recipe", ICraftRecipe)
+patch_collection_property(
+ ICraftRecipeBuildRequest, "builds", ICraftRecipeBuild
+)
+
+# ICraftRecipeView
+patch_collection_property(ICraftRecipeView, "builds", ICraftRecipeBuild)
+patch_collection_property(
+ ICraftRecipeView, "completed_builds", ICraftRecipeBuild
+)
+patch_collection_property(
+ ICraftRecipeView, "pending_builds", ICraftRecipeBuild
+)
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index cf4670c..cd2203c 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -367,6 +367,12 @@ class CraftRecipe(StormBase):
if self._isBuildableArchitectureAllowed(das)
]
+ @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 `ICraftRecipe`."""
store = IStore(self)
@@ -899,6 +905,10 @@ class CraftRecipeSet:
git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
)
+ def empty_list(self):
+ """See `ICraftRecipeSet`."""
+ return []
+
def getSourcecraftYaml(self, context, logger=None):
"""See `ICraftRecipeSet`."""
if ICraftRecipe.providedBy(context):
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
index e859c6b..aeaf5e7 100644
--- a/lib/lp/crafts/model/craftrecipebuild.py
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -180,6 +180,11 @@ class CraftRecipeBuild(PackageBuildMixin, StormBase):
return self.distro_arch_series.distroseries
@property
+ def arch_tag(self):
+ """See `ICraftRecipeBuild`."""
+ return self.distro_arch_series.architecturetag
+
+ @property
def archive(self):
"""See `IPackageBuild`."""
return self.distribution.main_archive
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index cbff62a..38745e1 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -3,13 +3,21 @@
"""Test craft recipes."""
+import json
+from datetime import timedelta
from textwrap import dedent
+import iso8601
import transaction
from storm.locals import Store
from testtools.matchers import (
+ AfterPreprocessing,
+ ContainsDict,
Equals,
+ GreaterThan,
Is,
+ LessThan,
+ MatchesAll,
MatchesDict,
MatchesSetwise,
MatchesStructure,
@@ -19,7 +27,11 @@ from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
+from lp.buildmaster.enums import (
+ BuildBaseImageType,
+ BuildQueueStatus,
+ BuildStatus,
+)
from lp.buildmaster.interfaces.buildqueue import IBuildQueue
from lp.buildmaster.interfaces.processor import (
IProcessorSet,
@@ -30,6 +42,7 @@ from lp.buildmaster.model.buildqueue import BuildQueue
from lp.code.tests.helpers import GitHostingFixture
from lp.crafts.interfaces.craftrecipe import (
CRAFT_RECIPE_ALLOW_CREATE,
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
BadCraftRecipeSearchContext,
CraftRecipeBuildAlreadyPending,
CraftRecipeBuildDisallowedArchitecture,
@@ -38,6 +51,7 @@ from lp.crafts.interfaces.craftrecipe import (
CraftRecipePrivateFeatureDisabled,
ICraftRecipe,
ICraftRecipeSet,
+ ICraftRecipeView,
NoSourceForCraftRecipe,
)
from lp.crafts.interfaces.craftrecipebuild import (
@@ -49,6 +63,7 @@ from lp.crafts.interfaces.craftrecipejob import (
)
from lp.crafts.model.craftrecipebuild import CraftFile
from lp.crafts.model.craftrecipejob import CraftRecipeJob
+from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
from lp.services.config import config
from lp.services.database.constants import ONE_DAY_AGO, UTC_NOW
from lp.services.database.interfaces import IStore
@@ -59,14 +74,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
class TestCraftRecipeFeatureFlags(TestCaseWithFactory):
@@ -113,6 +140,21 @@ class TestCraftRecipe(TestCaseWithFactory):
repr(recipe),
)
+ def test_avoids_problematic_snapshots(self):
+ self.assertThat(
+ self.factory.makeCraftRecipe(),
+ DoesNotSnapshot(
+ [
+ "pending_build_requests",
+ "failed_build_requests",
+ "builds",
+ "completed_builds",
+ "pending_builds",
+ ],
+ ICraftRecipeView,
+ ),
+ )
+
def test_initial_date_last_modified(self):
# The initial value of date_last_modified is date_created.
recipe = self.factory.makeCraftRecipe(date_created=ONE_DAY_AGO)
@@ -900,3 +942,656 @@ class TestCraftRecipeDeleteWithBuilds(TestCaseWithFactory):
self.assertIsNotNone(
store.get(CraftFile, removeSecurityProxy(other_craft_file).id)
)
+
+
+class TestCraftRecipeWebservice(TestCaseWithFactory):
+
+ layer = LaunchpadFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_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 makeCraftRecipe(
+ 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(
+ "/+craft-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):
+ # Craft 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.makeCraftRecipe(
+ owner=team, project=project, name="test-craft", 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-craft"),
+ "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 CraftRecipe.new work.
+ store_name = self.factory.getUniqueUnicode()
+ recipe = self.makeCraftRecipe(
+ 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 craft 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.makeCraftRecipe(
+ owner=team, project=project, name=name, git_ref=git_ref
+ )
+ response = self.webservice.named_post(
+ "/+craft-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 craft 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,
+ # craft 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(
+ "/+craft-recipes",
+ "new",
+ owner=other_person_url,
+ project=project_url,
+ name="test-craft",
+ git_ref=git_ref_url,
+ )
+ self.assertThat(
+ response,
+ MatchesStructure.byEquality(
+ status=401,
+ body=(
+ b"Test Person cannot create craft recipes owned by "
+ b"Other Person."
+ ),
+ ),
+ )
+ response = self.webservice.named_post(
+ "/+craft-recipes",
+ "new",
+ owner=other_team_url,
+ project=project_url,
+ name="test-craft",
+ 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 craft recipe is public, then trying to change its owner or
+ # git_ref components to be private fails.
+ recipe = self.factory.makeCraftRecipe(
+ 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 craft 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 craft recipe cannot have a private "
+ b"repository.",
+ ),
+ )
+
+ def test_is_stale(self):
+ # is_stale is exported and is read-only.
+ recipe = self.makeCraftRecipe()
+ 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.craft_recipes.getByName returns a matching CraftRecipe.
+ project = self.factory.makeProduct(owner=self.person)
+ name = self.factory.getUniqueUnicode()
+ recipe = self.makeCraftRecipe(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(
+ "/+craft-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.craft_recipes.getByName returns 404 for a non-existent
+ # CraftRecipe.
+ 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(
+ "/+craft-recipes",
+ "getByName",
+ owner=owner_url,
+ project=project_url,
+ name="nonexistent",
+ )
+ self.assertThat(
+ response,
+ MatchesStructure.byEquality(
+ status=404,
+ body=(
+ b"No such craft 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.makeCraftRecipe(git_ref=git_ref)
+ now = get_transaction_timestamp(IStore(distroseries))
+ response = self.webservice.named_post(
+ recipe["self_link"],
+ "requestBuilds",
+ channels={"craftcraft": "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):
+ craftcraft_yaml = "bases:\n"
+ for processor in processors:
+ craftcraft_yaml += (
+ " - build-on:\n"
+ " - name: ubuntu\n"
+ ' channel: "%s"\n'
+ " architectures: [%s]\n"
+ % (distroseries.version, processor.name)
+ )
+ self.useFixture(GitHostingFixture(blob=craftcraft_yaml))
+ [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+ with dbuser(config.ICraftRecipeRequestBuildsJobSource.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({"craftcraft": "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.makeCraftRecipe(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(ICraftRecipeRequestBuildsJobSource).iterReady()
+ with dbuser(config.ICraftRecipeRequestBuildsJobSource.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.makeCraftRecipe(
+ 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 craft 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.makeCraftRecipe(project=project)
+ response = self.webservice.named_post(
+ recipe["self_link"], "requestBuilds"
+ )
+ self.assertEqual(201, response.status)
+ with person_logged_in(self.person):
+ craftcraft_yaml = "bases:\n"
+ for processor in processors:
+ craftcraft_yaml += (
+ " - build-on:\n"
+ " - name: ubuntu\n"
+ ' channel: "%s"\n'
+ " architectures: [%s]\n"
+ % (distroseries.version, processor.name)
+ )
+ self.useFixture(GitHostingFixture(blob=craftcraft_yaml))
+ [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+ with dbuser(config.ICraftRecipeRequestBuildsJobSource.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(ICraftRecipeSet).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):
+ # CraftRecipe has a reasonable query count.
+ recipe = self.factory.makeCraftRecipe(
+ 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)))
diff --git a/lib/lp/crafts/tests/test_craftrecipebuild.py b/lib/lp/crafts/tests/test_craftrecipebuild.py
index f7f5129..5821ae3 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuild.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuild.py
@@ -4,14 +4,16 @@
"""Test craft package build features."""
from datetime import datetime, timedelta, timezone
+from urllib.request import urlopen
import six
-from testtools.matchers import Equals
+from testtools.matchers import ContainsDict, Equals, Is
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.buildmaster.enums import BuildStatus
from lp.buildmaster.interfaces.buildqueue import IBuildQueue
from lp.buildmaster.interfaces.packagebuild import IPackageBuild
@@ -29,14 +31,20 @@ from lp.registry.interfaces.series import SeriesStatus
from lp.services.config import config
from lp.services.features.testing import FeatureFixture
from lp.services.propertycache import clear_property_cache
+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 LaunchpadZopelessLayer
+from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
from lp.testing.mail_helpers import pop_notifications
from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import webservice_for_person
expected_body = """\
* Craft Recipe: craft-1
@@ -452,3 +460,139 @@ class TestCraftRecipeBuildSet(TestCaseWithFactory):
recipe=recipe, distro_arch_series=distro_arch_series
)
self.assertFalse(target.virtualized)
+
+
+class TestCraftRecipeBuildWebservice(TestCaseWithFactory):
+
+ layer = LaunchpadFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_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 craft recipe build are sensible.
+ db_build = self.factory.makeCraftRecipeBuild(
+ 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 craft recipe build with a public recipe is itself public.
+ db_build = self.factory.makeCraftRecipeBuild()
+ 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.makeCraftRecipeBuild(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.makeCraftRecipeBuild(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.makeCraftRecipeBuild(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"])
References