launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31544
[Merge] ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-builds into launchpad:master
Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-builds into launchpad:master with ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-jobs as a prerequisite.
Commit message:
Add basic model for craft recipe builds
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/473836
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-basic-model-for-craft-recipe-builds into launchpad:master.
diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py
index be0a30b..3bcda9e 100644
--- a/lib/lp/buildmaster/enums.py
+++ b/lib/lp/buildmaster/enums.py
@@ -258,6 +258,15 @@ class BuildFarmJobType(DBEnumeratedType):
""",
)
+ CRAFTRECIPEBUILD = DBItem(
+ 11,
+ """
+ Craft recipe build
+
+ Build a craft from a recipe.
+ """,
+ )
+
class BuildQueueStatus(DBEnumeratedType):
"""Build queue status.
diff --git a/lib/lp/crafts/browser/configure.zcml b/lib/lp/crafts/browser/configure.zcml
index 812ae95..9c54635 100644
--- a/lib/lp/crafts/browser/configure.zcml
+++ b/lib/lp/crafts/browser/configure.zcml
@@ -12,5 +12,16 @@
<lp:url
for="lp.crafts.interfaces.craftrecipe.ICraftRecipe"
urldata="lp.crafts.browser.craftrecipe.CraftRecipeURL" />
+ <lp:navigation
+ module="lp.crafts.browser.craftrecipe"
+ classes="CraftRecipeNavigation" />
+ <lp:url
+ for="lp.crafts.interfaces.craftrecipe.ICraftRecipeBuildRequest"
+ path_expression="string:+build-request/${id}"
+ attribute_to_parent="recipe" />
+ <lp:url
+ for="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuild"
+ path_expression="string:+build/${id}"
+ attribute_to_parent="recipe" />
</lp:facet>
</configure>
\ No newline at end of file
diff --git a/lib/lp/crafts/browser/craftrecipe.py b/lib/lp/crafts/browser/craftrecipe.py
index 3ff948d..6769d3c 100644
--- a/lib/lp/crafts/browser/craftrecipe.py
+++ b/lib/lp/crafts/browser/craftrecipe.py
@@ -10,8 +10,12 @@ __all__ = [
from zope.component import getUtility
from zope.interface import implementer
+from lp.crafts.interfaces.craftrecipe import ICraftRecipe
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuildSet
from lp.registry.interfaces.personproduct import IPersonProductFactory
+from lp.services.webapp import Navigation, stepthrough
from lp.services.webapp.interfaces import ICanonicalUrlData
+from lp.soyuz.browser.build import get_build_by_id_str
@implementer(ICanonicalUrlData)
@@ -32,3 +36,22 @@ class CraftRecipeURL:
@property
def path(self):
return "+craft/%s" % self.recipe.name
+
+
+class CraftRecipeNavigation(Navigation):
+ usedfor = ICraftRecipe
+
+ @stepthrough("+build-request")
+ def traverse_build_request(self, name):
+ try:
+ job_id = int(name)
+ except ValueError:
+ return None
+ return self.context.getBuildRequest(job_id)
+
+ @stepthrough("+build")
+ def traverse_build(self, name):
+ build = get_build_by_id_str(ICraftRecipeBuildSet, name)
+ if build is None or build.recipe != self.context:
+ return None
+ return build
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index c8ccb59..48b79fd 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -61,4 +61,35 @@
<allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJob" />
</class>
+ <!-- CraftRecipeBuild -->
+ <class class="lp.crafts.model.craftrecipebuild.CraftRecipeBuild">
+ <require
+ permission="launchpad.View"
+ interface="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildView" />
+ <require
+ permission="launchpad.Edit"
+ interface="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildEdit" />
+ <require
+ permission="launchpad.Admin"
+ interface="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildAdmin" />
+ </class>
+
+ <!-- CraftRecipeBuildSet -->
+ <lp:securedutility
+ class="lp.crafts.model.craftrecipebuild.CraftRecipeBuildSet"
+ provides="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildSet">
+ <allow interface="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildSet" />
+ </lp:securedutility>
+ <lp:securedutility
+ class="lp.crafts.model.craftrecipebuild.CraftRecipeBuildSet"
+ provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+ name="CRAFTRECIPEBUILD">
+ <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
+ </lp:securedutility>
+
+ <!-- CraftFile -->
+ <class class="lp.crafts.model.craftrecipebuild.CraftFile">
+ <allow interface="lp.crafts.interfaces.craftrecipebuild.ICraftFile" />
+ </class>
+
</configure>
\ No newline at end of file
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index ecaf34b..1668752 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -25,7 +25,7 @@ import http.client
from lazr.enum import EnumeratedType, Item
from lazr.restful.declarations import error_status, exported
-from lazr.restful.fields import Reference, ReferenceChoice
+from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
from zope.interface import Interface
from zope.schema import (
Bool,
@@ -48,6 +48,7 @@ from lp.app.validators.name import name_validator
from lp.app.validators.path import path_does_not_escape
from lp.code.interfaces.gitref import IGitRef
from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.person import IPerson
from lp.registry.interfaces.product import IProduct
from lp.services.fields import PersonChoice, PublicPersonChoice
from lp.snappy.validators.channels import channels_validator
@@ -205,6 +206,21 @@ class ICraftRecipeBuildRequest(Interface):
readonly=True,
)
+ builds = CollectionField(
+ title=_("Builds produced by this request"),
+ # Really ICraftRecipeBuild.
+ value_type=Reference(schema=Interface),
+ required=True,
+ readonly=True,
+ )
+
+ requester = Reference(
+ title=_("The person requesting the builds."),
+ schema=IPerson,
+ required=True,
+ readonly=True,
+ )
+
class ICraftRecipeView(Interface):
"""`ICraftRecipe` attributes that require launchpad.View permission."""
@@ -496,3 +512,6 @@ class ICraftRecipeSet(Interface):
After this, any craft recipes that previously used this repository
will have no source and so cannot dispatch new builds.
"""
+
+ 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
new file mode 100644
index 0000000..256fa6d
--- /dev/null
+++ b/lib/lp/crafts/interfaces/craftrecipebuild.py
@@ -0,0 +1,199 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe build interfaces."""
+
+__all__ = [
+ "ICraftFile",
+ "ICraftRecipeBuild",
+ "ICraftRecipeBuildSet",
+]
+
+from lazr.restful.fields import Reference
+from zope.interface import Attribute, Interface
+from zope.schema import Bool, Datetime, Dict, Int, TextLine
+
+from lp import _
+from lp.buildmaster.interfaces.buildfarmjob import (
+ IBuildFarmJobEdit,
+ ISpecificBuildFarmJobSource,
+)
+from lp.buildmaster.interfaces.packagebuild import (
+ IPackageBuild,
+ IPackageBuildView,
+)
+from lp.crafts.interfaces.craftrecipe import (
+ ICraftRecipe,
+ ICraftRecipeBuildRequest,
+)
+from lp.registry.interfaces.person import IPerson
+from lp.services.database.constants import DEFAULT
+from lp.services.librarian.interfaces import ILibraryFileAlias
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+
+
+class ICraftRecipeBuildView(IPackageBuildView):
+ """ICraftRecipeBuild attributes that require launchpad.View."""
+
+ build_request = Reference(
+ ICraftRecipeBuildRequest,
+ title=_("The build request that caused this build to be created."),
+ required=True,
+ readonly=True,
+ )
+
+ requester = 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,
+ )
+
+ distro_arch_series = 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 'craftcraft' 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,
+ )
+
+ eta = Datetime(
+ title=_("The datetime when the build job is estimated to complete."),
+ readonly=True,
+ )
+
+ estimate = Bool(
+ title=_("If true, the date value is an estimate."), readonly=True
+ )
+
+ date = Datetime(
+ title=_(
+ "The date when the build completed or is estimated to complete."
+ ),
+ 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."
+ ),
+ )
+
+ store_upload_metadata = Attribute(
+ _("A dict of data about store upload progress.")
+ )
+
+ def getFiles():
+ """Retrieve the build's ICraftFile records.
+
+ :return: A result set of (ICraftFile, ILibraryFileAlias,
+ ILibraryFileContent).
+ """
+
+ def getFileByName(filename):
+ """Return the corresponding ILibraryFileAlias in this context.
+
+ The following file types (and extension) can be looked up:
+
+ * Build log: '.txt.gz'
+ * Upload log: '_log.txt'
+
+ Any filename not matching one of these extensions is looked up as a
+ craft recipe output file.
+
+ :param filename: The filename to look up.
+ :raises NotFoundError: if no file exists with the given name.
+ :return: The corresponding ILibraryFileAlias.
+ """
+
+
+class ICraftRecipeBuildEdit(IBuildFarmJobEdit):
+ """ICraftRecipeBuild methods that require launchpad.Edit."""
+
+ def addFile(lfa):
+ """Add a file to this build.
+
+ :param lfa: An ILibraryFileAlias.
+ :return: An ICraftFile.
+ """
+
+
+class ICraftRecipeBuildAdmin(Interface):
+ """ICraftRecipeBuild methods that require launchpad.Admin."""
+
+ def rescore(score):
+ """Change the build's score."""
+
+
+class ICraftRecipeBuild(
+ ICraftRecipeBuildView,
+ ICraftRecipeBuildEdit,
+ ICraftRecipeBuildAdmin,
+ IPackageBuild,
+):
+ """A build record for a craft recipe."""
+
+
+class ICraftRecipeBuildSet(ISpecificBuildFarmJobSource):
+ """Utility to create and access ICraftRecipeBuilds."""
+
+ def new(
+ build_request,
+ recipe,
+ distro_arch_series,
+ channels=None,
+ store_upload_metadata=None,
+ date_created=DEFAULT,
+ ):
+ """Create an ICraftRecipeBuild."""
+
+ def preloadBuildsData(builds):
+ """Load the data related to a list of craft recipe builds."""
+
+
+class ICraftFile(Interface):
+ """A file produced by a craft recipe build."""
+
+ build = Reference(
+ ICraftRecipeBuild,
+ title=_("The craft recipe build producing this file."),
+ required=True,
+ readonly=True,
+ )
+
+ library_file = Reference(
+ ILibraryFileAlias,
+ title=_("The library file alias for this file."),
+ required=True,
+ readonly=True,
+ )
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
index 8a10d2d..cdad7ff 100644
--- a/lib/lp/crafts/interfaces/craftrecipejob.py
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -11,13 +11,14 @@ __all__ = [
from lazr.restful.fields import Reference
from zope.interface import Attribute, Interface
-from zope.schema import Datetime, Dict, Set, TextLine
+from zope.schema import Datetime, Dict, List, Set, TextLine
from lp import _
from lp.crafts.interfaces.craftrecipe import (
ICraftRecipe,
ICraftRecipeBuildRequest,
)
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
from lp.registry.interfaces.person import IPerson
from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
@@ -94,6 +95,13 @@ class ICraftRecipeRequestBuildsJob(IRunnableJob):
readonly=True,
)
+ builds = List(
+ title=_("The builds created by this request."),
+ value_type=Reference(schema=ICraftRecipeBuild),
+ required=True,
+ readonly=True,
+ )
+
class ICraftRecipeRequestBuildsJobSource(IJobSource):
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index 3bcc371..ebbbf99 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -20,6 +20,7 @@ from lp.app.enums import (
PUBLIC_INFORMATION_TYPES,
InformationType,
)
+from lp.code.model.gitcollection import GenericGitCollection
from lp.code.model.gitrepository import GitRepository
from lp.code.model.reciperegistry import recipe_registry
from lp.crafts.interfaces.craftrecipe import (
@@ -40,7 +41,8 @@ from lp.crafts.interfaces.craftrecipejob import (
ICraftRecipeRequestBuildsJobSource,
)
from lp.registry.errors import PrivatePersonLinkageError
-from lp.registry.interfaces.person import validate_public_person
+from lp.registry.interfaces.person import IPersonSet, validate_public_person
+from lp.services.database.bulk import load_related
from lp.services.database.constants import DEFAULT, UTC_NOW
from lp.services.database.enumcol import DBEnum
from lp.services.database.interfaces import IPrimaryStore, IStore
@@ -386,6 +388,31 @@ class CraftRecipeSet:
git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
)
+ def preloadDataForRecipes(self, recipes, user=None):
+ """See `ICraftRecipeSet`."""
+ recipes = [removeSecurityProxy(recipe) for recipe in recipes]
+ person_ids = set()
+ for recipe in recipes:
+ person_ids.add(recipe.registrant_id)
+ person_ids.add(recipe.owner_id)
+
+ repositories = load_related(
+ GitRepository, recipes, ["git_repository_id"]
+ )
+ if repositories:
+ GenericGitCollection.preloadDataForRepositories(repositories)
+
+ # Add repository owners to the list of pre-loaded persons. We need
+ # the target repository owner as well, since repository unique names
+ # aren't trigger-maintained.
+ person_ids.update(repository.owner_id for repository in repositories)
+
+ list(
+ getUtility(IPersonSet).getPrecachedPersonsFromIDs(
+ person_ids, need_validity=True
+ )
+ )
+
@implementer(ICraftRecipeBuildRequest)
class CraftRecipeBuildRequest:
@@ -439,6 +466,16 @@ class CraftRecipeBuildRequest:
return self._job.error_message
@property
+ def builds(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.builds
+
+ @property
+ def requester(self):
+ """See `ICraftRecipeBuildRequest`."""
+ return self._job.requester
+
+ @property
def channels(self):
"""See `ICraftRecipeBuildRequest`."""
return self._job.channels
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
new file mode 100644
index 0000000..30d9450
--- /dev/null
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -0,0 +1,443 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Craft recipe builds."""
+
+__all__ = [
+ "CraftFile",
+ "CraftRecipeBuild",
+]
+
+from datetime import timedelta, timezone
+
+import six
+from storm.databases.postgres import JSON
+from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode
+from storm.store import EmptyResultSet
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.app.errors import NotFoundError
+from lp.buildmaster.enums import (
+ BuildFarmJobType,
+ BuildQueueStatus,
+ BuildStatus,
+)
+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
+from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
+from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.crafts.interfaces.craftrecipe import ICraftRecipeSet
+from lp.crafts.interfaces.craftrecipebuild import (
+ ICraftFile,
+ ICraftRecipeBuild,
+ ICraftRecipeBuildSet,
+)
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.person import Person
+from lp.services.config import config
+from lp.services.database.bulk import load_related
+from lp.services.database.constants import DEFAULT
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import IPrimaryStore, IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent
+from lp.services.propertycache import cachedproperty, get_property_cache
+from lp.services.webapp.snapshot import notify_modified
+
+
+@implementer(ICraftRecipeBuild)
+class CraftRecipeBuild(PackageBuildMixin, StormBase):
+ """See `ICraftRecipeBuild`."""
+
+ __storm_table__ = "CraftRecipeBuild"
+
+ job_type = BuildFarmJobType.CRAFTRECIPEBUILD
+
+ id = Int(name="id", primary=True)
+
+ build_request_id = Int(name="build_request", allow_none=False)
+
+ requester_id = Int(name="requester", allow_none=False)
+ requester = Reference(requester_id, "Person.id")
+
+ recipe_id = Int(name="recipe", allow_none=False)
+ recipe = Reference(recipe_id, "CraftRecipe.id")
+
+ distro_arch_series_id = Int(name="distro_arch_series", allow_none=False)
+ distro_arch_series = Reference(
+ distro_arch_series_id, "DistroArchSeries.id"
+ )
+
+ channels = JSON("channels", allow_none=True)
+
+ processor_id = Int(name="processor", allow_none=False)
+ processor = Reference(processor_id, "Processor.id")
+
+ virtualized = Bool(name="virtualized", allow_none=False)
+
+ date_created = DateTime(
+ name="date_created", tzinfo=timezone.utc, allow_none=False
+ )
+ date_started = DateTime(
+ name="date_started", tzinfo=timezone.utc, allow_none=True
+ )
+ date_finished = DateTime(
+ name="date_finished", tzinfo=timezone.utc, allow_none=True
+ )
+ date_first_dispatched = DateTime(
+ name="date_first_dispatched", tzinfo=timezone.utc, allow_none=True
+ )
+
+ builder_id = Int(name="builder", allow_none=True)
+ builder = Reference(builder_id, "Builder.id")
+
+ status = DBEnum(name="status", enum=BuildStatus, allow_none=False)
+
+ log_id = Int(name="log", allow_none=True)
+ log = Reference(log_id, "LibraryFileAlias.id")
+
+ upload_log_id = Int(name="upload_log", allow_none=True)
+ upload_log = Reference(upload_log_id, "LibraryFileAlias.id")
+
+ dependencies = Unicode(name="dependencies", allow_none=True)
+
+ failure_count = Int(name="failure_count", allow_none=False)
+
+ build_farm_job_id = Int(name="build_farm_job", allow_none=False)
+ build_farm_job = Reference(build_farm_job_id, "BuildFarmJob.id")
+
+ revision_id = Unicode(name="revision_id", allow_none=True)
+
+ store_upload_metadata = JSON("store_upload_json_data", allow_none=True)
+
+ def __init__(
+ self,
+ build_farm_job,
+ build_request,
+ recipe,
+ distro_arch_series,
+ processor,
+ virtualized,
+ channels=None,
+ store_upload_metadata=None,
+ date_created=DEFAULT,
+ ):
+ """Construct a `CraftRecipeBuild`."""
+ requester = build_request.requester
+ super().__init__()
+ self.build_farm_job = build_farm_job
+ self.build_request_id = build_request.id
+ self.requester = requester
+ self.recipe = recipe
+ self.distro_arch_series = distro_arch_series
+ self.processor = processor
+ self.virtualized = virtualized
+ self.channels = channels
+ self.store_upload_metadata = store_upload_metadata
+ self.date_created = date_created
+ self.status = BuildStatus.NEEDSBUILD
+
+ @property
+ def build_request(self):
+ return self.recipe.getBuildRequest(self.build_request_id)
+
+ @property
+ def is_private(self):
+ """See `IBuildFarmJob`."""
+ return self.recipe.private or self.recipe.owner.private
+
+ def __repr__(self):
+ return "<CraftRecipeBuild ~%s/%s/+craft/%s/+build/%d>" % (
+ self.recipe.owner.name,
+ self.recipe.project.name,
+ self.recipe.name,
+ self.id,
+ )
+
+ @property
+ def title(self):
+ return "%s build of /~%s/%s/+craft/%s" % (
+ self.distro_arch_series.architecturetag,
+ self.recipe.owner.name,
+ self.recipe.project.name,
+ self.recipe.name,
+ )
+
+ @property
+ def distribution(self):
+ """See `IPackageBuild`."""
+ return self.distro_arch_series.distroseries.distribution
+
+ @property
+ def distro_series(self):
+ """See `IPackageBuild`."""
+ return self.distro_arch_series.distroseries
+
+ @property
+ def archive(self):
+ """See `IPackageBuild`."""
+ return self.distribution.main_archive
+
+ @property
+ def pocket(self):
+ """See `IPackageBuild`."""
+ return PackagePublishingPocket.UPDATES
+
+ @property
+ def score(self):
+ """See `ICraftRecipeBuild`."""
+ if self.buildqueue_record is None:
+ return None
+ else:
+ return self.buildqueue_record.lastscore
+
+ @property
+ def can_be_retried(self):
+ """See `IBuildFarmJob`."""
+ # First check that the behaviour would accept the build if it
+ # succeeded.
+ if self.distro_series.status == SeriesStatus.OBSOLETE:
+ return False
+ return super().can_be_retried
+
+ def calculateScore(self):
+ """See `IBuildFarmJob`."""
+ # XXX ruinedyourlife 2024-09-25: We'll probably need something like
+ # CraftRecipe.relative_build_score at some point.
+ return 2510
+
+ def getMedianBuildDuration(self):
+ """Return the median duration of our successful builds."""
+ store = IStore(self)
+ result = store.find(
+ (CraftRecipeBuild.date_started, CraftRecipeBuild.date_finished),
+ CraftRecipeBuild.recipe == self.recipe,
+ CraftRecipeBuild.processor == self.processor,
+ CraftRecipeBuild.status == BuildStatus.FULLYBUILT,
+ )
+ result.order_by(Desc(CraftRecipeBuild.date_finished))
+ durations = [row[1] - row[0] for row in result[:9]]
+ if len(durations) == 0:
+ return None
+ durations.sort()
+ return durations[len(durations) // 2]
+
+ def estimateDuration(self):
+ """See `IBuildFarmJob`."""
+ median = self.getMedianBuildDuration()
+ if median is not None:
+ return median
+ return timedelta(minutes=10)
+
+ @cachedproperty
+ def eta(self):
+ """The datetime when the build job is estimated to complete.
+
+ This is the BuildQueue.estimated_duration plus the
+ Job.date_started or BuildQueue.getEstimatedJobStartTime.
+ """
+ if self.buildqueue_record is None:
+ return None
+ queue_record = self.buildqueue_record
+ if queue_record.status == BuildQueueStatus.WAITING:
+ start_time = queue_record.getEstimatedJobStartTime()
+ else:
+ start_time = queue_record.date_started
+ if start_time is None:
+ return None
+ duration = queue_record.estimated_duration
+ return start_time + duration
+
+ @property
+ def estimate(self):
+ """If true, the date value is an estimate."""
+ if self.date_finished is not None:
+ return False
+ return self.eta is not None
+
+ @property
+ def date(self):
+ """The date when the build completed or is estimated to complete."""
+ if self.estimate:
+ return self.eta
+ return self.date_finished
+
+ def getFiles(self):
+ """See `ICraftRecipeBuild`."""
+ result = Store.of(self).find(
+ (CraftFile, LibraryFileAlias, LibraryFileContent),
+ CraftFile.build == self.id,
+ LibraryFileAlias.id == CraftFile.library_file_id,
+ LibraryFileContent.id == LibraryFileAlias.content_id,
+ )
+ return result.order_by([LibraryFileAlias.filename, CraftFile.id])
+
+ def getFileByName(self, filename):
+ """See `ICraftRecipeBuild`."""
+ if filename.endswith(".txt.gz"):
+ file_object = self.log
+ elif filename.endswith("_log.txt"):
+ file_object = self.upload_log
+ else:
+ file_object = (
+ Store.of(self)
+ .find(
+ LibraryFileAlias,
+ CraftFile.build == self.id,
+ LibraryFileAlias.id == CraftFile.library_file_id,
+ LibraryFileAlias.filename == filename,
+ )
+ .one()
+ )
+
+ if file_object is not None and file_object.filename == filename:
+ return file_object
+
+ raise NotFoundError(filename)
+
+ def addFile(self, lfa):
+ """See `ICraftRecipeBuild`."""
+ craft_file = CraftFile(build=self, library_file=lfa)
+ IPrimaryStore(CraftFile).add(craft_file)
+ return craft_file
+
+ def verifySuccessfulUpload(self):
+ """See `IPackageBuild`."""
+ return not self.getFiles().is_empty()
+
+ def updateStatus(
+ self,
+ status,
+ builder=None,
+ worker_status=None,
+ date_started=None,
+ date_finished=None,
+ force_invalid_transition=False,
+ ):
+ """See `IBuildFarmJob`."""
+ edited_fields = set()
+ with notify_modified(
+ self, edited_fields, snapshot_names=("status", "revision_id")
+ ) as previous_obj:
+ super().updateStatus(
+ status,
+ builder=builder,
+ worker_status=worker_status,
+ date_started=date_started,
+ date_finished=date_finished,
+ force_invalid_transition=force_invalid_transition,
+ )
+ if self.status != previous_obj.status:
+ edited_fields.add("status")
+ if worker_status is not None:
+ revision_id = worker_status.get("revision_id")
+ if revision_id is not None:
+ self.revision_id = six.ensure_text(revision_id)
+ if revision_id != previous_obj.revision_id:
+ edited_fields.add("revision_id")
+ # notify_modified evaluates all attributes mentioned in the
+ # interface, but we may then make changes that affect self.eta.
+ del get_property_cache(self).eta
+
+ def notify(self, extra_info=None):
+ """See `IPackageBuild`."""
+ if not config.builddmaster.send_build_notification:
+ return
+ if self.status == BuildStatus.FULLYBUILT:
+ return
+ # XXX ruinedyourlife 2024-09-25: Send email notifications.
+
+
+@implementer(ICraftRecipeBuildSet)
+class CraftRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
+ """See `ICraftRecipeBuildSet`."""
+
+ def new(
+ self,
+ build_request,
+ recipe,
+ distro_arch_series,
+ channels=None,
+ store_upload_metadata=None,
+ date_created=DEFAULT,
+ ):
+ """See `ICraftRecipeBuildSet`."""
+ store = IPrimaryStore(CraftRecipeBuild)
+ build_farm_job = getUtility(IBuildFarmJobSource).new(
+ CraftRecipeBuild.job_type, BuildStatus.NEEDSBUILD, date_created
+ )
+ virtualized = (
+ not distro_arch_series.processor.supports_nonvirtualized
+ or recipe.require_virtualized
+ )
+ build = CraftRecipeBuild(
+ build_farm_job,
+ build_request,
+ recipe,
+ distro_arch_series,
+ distro_arch_series.processor,
+ virtualized,
+ channels=channels,
+ store_upload_metadata=store_upload_metadata,
+ date_created=date_created,
+ )
+ store.add(build)
+ return build
+
+ def getByID(self, build_id):
+ """See `ISpecificBuildFarmJobSource`."""
+ store = IPrimaryStore(CraftRecipeBuild)
+ return store.get(CraftRecipeBuild, build_id)
+
+ def getByBuildFarmJob(self, build_farm_job):
+ """See `ISpecificBuildFarmJobSource`."""
+ return (
+ Store.of(build_farm_job)
+ .find(CraftRecipeBuild, build_farm_job_id=build_farm_job.id)
+ .one()
+ )
+
+ def preloadBuildsData(self, builds):
+ # Circular import.
+ from lp.crafts.model.craftrecipe import CraftRecipe
+
+ load_related(Person, builds, ["requester_id"])
+ lfas = load_related(LibraryFileAlias, builds, ["log_id"])
+ load_related(LibraryFileContent, lfas, ["contentID"])
+ recipes = load_related(CraftRecipe, builds, ["recipe_id"])
+ getUtility(ICraftRecipeSet).preloadDataForRecipes(recipes)
+
+ def getByBuildFarmJobs(self, build_farm_jobs):
+ """See `ISpecificBuildFarmJobSource`."""
+ if len(build_farm_jobs) == 0:
+ return EmptyResultSet()
+ rows = Store.of(build_farm_jobs[0]).find(
+ CraftRecipeBuild,
+ CraftRecipeBuild.build_farm_job_id.is_in(
+ bfj.id for bfj in build_farm_jobs
+ ),
+ )
+ return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
+
+
+@implementer(ICraftFile)
+class CraftFile(StormBase):
+ """See `ICraftFile`."""
+
+ __storm_table__ = "CraftFile"
+
+ id = Int(name="id", primary=True)
+
+ build_id = Int(name="build", allow_none=False)
+ build = Reference(build_id, "CraftRecipeBuild.id")
+
+ library_file_id = Int(name="library_file", allow_none=False)
+ library_file = Reference(library_file_id, "LibraryFileAlias.id")
+
+ def __init__(self, build, library_file):
+ """Construct a `CraftFile`."""
+ super().__init__()
+ self.build = build
+ self.library_file = library_file
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
index e20d29c..f249360 100644
--- a/lib/lp/crafts/model/craftrecipejob.py
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -14,6 +14,7 @@ from lazr.delegates import delegate_to
from lazr.enum import DBEnumeratedType, DBItem
from storm.databases.postgres import JSON
from storm.locals import Desc, Int, Reference
+from storm.store import EmptyResultSet
from zope.component import getUtility
from zope.interface import implementer, provider
@@ -23,6 +24,7 @@ from lp.crafts.interfaces.craftrecipejob import (
ICraftRecipeRequestBuildsJob,
ICraftRecipeRequestBuildsJobSource,
)
+from lp.crafts.model.craftrecipebuild import CraftRecipeBuild
from lp.registry.interfaces.person import IPersonSet
from lp.services.config import config
from lp.services.database.bulk import load_related
@@ -277,6 +279,22 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
"""See `ICraftRecipeRequestBuildsJob`."""
return self.recipe.getBuildRequest(self.job.id)
+ @property
+ def builds(self):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ build_ids = self.metadata.get("builds")
+ if build_ids:
+ return IStore(CraftRecipeBuild).find(
+ CraftRecipeBuild, CraftRecipeBuild.id.is_in(build_ids)
+ )
+ else:
+ return EmptyResultSet()
+
+ @builds.setter
+ def builds(self, builds):
+ """See `ICraftRecipeRequestBuildsJob`."""
+ self.metadata["builds"] = [build.id for build in builds]
+
def run(self):
"""See `IRunnableJob`."""
requester = self.requester
diff --git a/lib/lp/crafts/tests/test_craftrecipebuild.py b/lib/lp/crafts/tests/test_craftrecipebuild.py
new file mode 100644
index 0000000..8b118b9
--- /dev/null
+++ b/lib/lp/crafts/tests/test_craftrecipebuild.py
@@ -0,0 +1,367 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test craft package build features."""
+
+from datetime import datetime, timedelta, timezone
+
+from testtools.matchers import Equals
+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.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.buildqueue import IBuildQueue
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.crafts.interfaces.craftrecipe import (
+ CRAFT_RECIPE_ALLOW_CREATE,
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+)
+from lp.crafts.interfaces.craftrecipebuild import (
+ ICraftRecipeBuild,
+ ICraftRecipeBuildSet,
+)
+from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.features.testing import FeatureFixture
+from lp.services.propertycache import clear_property_cache
+from lp.testing import (
+ StormStatementRecorder,
+ TestCaseWithFactory,
+ person_logged_in,
+)
+from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.matchers import HasQueryCount
+
+
+class TestCraftRecipeBuild(TestCaseWithFactory):
+
+ layer = LaunchpadZopelessLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+ self.build = self.factory.makeCraftRecipeBuild()
+
+ def test_implements_interfaces(self):
+ # CraftRecipeBuild implements IPackageBuild and ICraftRecipeBuild.
+ self.assertProvides(self.build, IPackageBuild)
+ self.assertProvides(self.build, ICraftRecipeBuild)
+
+ def test___repr__(self):
+ # CraftRecipeBuild has an informative __repr__.
+ self.assertEqual(
+ "<CraftRecipeBuild ~%s/%s/+craft/%s/+build/%s>"
+ % (
+ self.build.recipe.owner.name,
+ self.build.recipe.project.name,
+ self.build.recipe.name,
+ self.build.id,
+ ),
+ repr(self.build),
+ )
+
+ def test_title(self):
+ # CraftRecipeBuild has an informative title.
+ das = self.build.distro_arch_series
+ self.assertEqual(
+ "%s build of /~%s/%s/+craft/%s"
+ % (
+ das.architecturetag,
+ self.build.recipe.owner.name,
+ self.build.recipe.project.name,
+ self.build.recipe.name,
+ ),
+ self.build.title,
+ )
+
+ def test_queueBuild(self):
+ # CraftRecipeBuild can create the queue entry for itself.
+ bq = self.build.queueBuild()
+ self.assertProvides(bq, IBuildQueue)
+ self.assertEqual(
+ self.build.build_farm_job, removeSecurityProxy(bq)._build_farm_job
+ )
+ self.assertEqual(self.build, bq.specific_build)
+ self.assertEqual(self.build.virtualized, bq.virtualized)
+ self.assertIsNotNone(bq.processor)
+ self.assertEqual(bq, self.build.buildqueue_record)
+
+ def test_is_private(self):
+ # A CraftRecipeBuild is private iff its recipe or owner are.
+ self.assertFalse(self.build.is_private)
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ private_team = self.factory.makeTeam(
+ membership_policy=TeamMembershipPolicy.MODERATED,
+ visibility=PersonVisibility.PRIVATE,
+ )
+ with person_logged_in(private_team.teamowner):
+ build = self.factory.makeCraftRecipeBuild(
+ requester=private_team.teamowner,
+ owner=private_team,
+ information_type=InformationType.PROPRIETARY,
+ )
+ self.assertTrue(build.is_private)
+
+ def test_can_be_retried(self):
+ ok_cases = [
+ BuildStatus.FAILEDTOBUILD,
+ BuildStatus.MANUALDEPWAIT,
+ BuildStatus.CHROOTWAIT,
+ BuildStatus.FAILEDTOUPLOAD,
+ BuildStatus.CANCELLED,
+ BuildStatus.SUPERSEDED,
+ ]
+ for status in BuildStatus.items:
+ build = self.factory.makeCraftRecipeBuild(status=status)
+ if status in ok_cases:
+ self.assertTrue(build.can_be_retried)
+ else:
+ self.assertFalse(build.can_be_retried)
+
+ def test_can_be_retried_obsolete_series(self):
+ # Builds for obsolete series cannot be retried.
+ distroseries = self.factory.makeDistroSeries(
+ status=SeriesStatus.OBSOLETE
+ )
+ das = self.factory.makeDistroArchSeries(distroseries=distroseries)
+ build = self.factory.makeCraftRecipeBuild(distro_arch_series=das)
+ self.assertFalse(build.can_be_retried)
+
+ def test_can_be_cancelled(self):
+ # For all states that can be cancelled, can_be_cancelled returns True.
+ ok_cases = [
+ BuildStatus.BUILDING,
+ BuildStatus.NEEDSBUILD,
+ ]
+ for status in BuildStatus.items:
+ build = self.factory.makeCraftRecipeBuild()
+ build.queueBuild()
+ build.updateStatus(status)
+ if status in ok_cases:
+ self.assertTrue(build.can_be_cancelled)
+ else:
+ self.assertFalse(build.can_be_cancelled)
+
+ def test_retry_resets_state(self):
+ # Retrying a build resets most of the state attributes, but does
+ # not modify the first dispatch time.
+ now = datetime.now(timezone.utc)
+ build = self.factory.makeCraftRecipeBuild()
+ build.updateStatus(BuildStatus.BUILDING, date_started=now)
+ build.updateStatus(BuildStatus.FAILEDTOBUILD)
+ build.gotFailure()
+ with person_logged_in(build.recipe.owner):
+ build.retry()
+ self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
+ self.assertEqual(now, build.date_first_dispatched)
+ self.assertIsNone(build.log)
+ self.assertIsNone(build.upload_log)
+ self.assertEqual(0, build.failure_count)
+
+ def test_cancel_not_in_progress(self):
+ # The cancel() method for a pending build leaves it in the CANCELLED
+ # state.
+ self.build.queueBuild()
+ self.build.cancel()
+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+ self.assertIsNone(self.build.buildqueue_record)
+
+ def test_cancel_in_progress(self):
+ # The cancel() method for a building build leaves it in the
+ # CANCELLING state.
+ bq = self.build.queueBuild()
+ bq.markAsBuilding(self.factory.makeBuilder())
+ self.build.cancel()
+ self.assertEqual(BuildStatus.CANCELLING, self.build.status)
+ self.assertEqual(bq, self.build.buildqueue_record)
+
+ def test_estimateDuration(self):
+ # Without previous builds, the default time estimate is 10m.
+ self.assertEqual(600, self.build.estimateDuration().seconds)
+
+ def test_estimateDuration_with_history(self):
+ # Previous successful builds of the same recipe are used for
+ # estimates.
+ self.factory.makeCraftRecipeBuild(
+ requester=self.build.requester,
+ recipe=self.build.recipe,
+ distro_arch_series=self.build.distro_arch_series,
+ status=BuildStatus.FULLYBUILT,
+ duration=timedelta(seconds=335),
+ )
+ for _ in range(3):
+ self.factory.makeCraftRecipeBuild(
+ requester=self.build.requester,
+ recipe=self.build.recipe,
+ distro_arch_series=self.build.distro_arch_series,
+ status=BuildStatus.FAILEDTOBUILD,
+ duration=timedelta(seconds=20),
+ )
+ self.assertEqual(335, self.build.estimateDuration().seconds)
+
+ def test_build_cookie(self):
+ build = self.factory.makeCraftRecipeBuild()
+ self.assertEqual("CRAFTRECIPEBUILD-%d" % build.id, build.build_cookie)
+
+ def test_getFileByName_logs(self):
+ # getFileByName returns the logs when requested by name.
+ self.build.setLog(
+ self.factory.makeLibraryFileAlias(filename="buildlog.txt.gz")
+ )
+ self.assertEqual(
+ self.build.log, self.build.getFileByName("buildlog.txt.gz")
+ )
+ self.assertRaises(NotFoundError, self.build.getFileByName, "foo")
+ self.build.storeUploadLog("uploaded")
+ self.assertEqual(
+ self.build.upload_log,
+ self.build.getFileByName(self.build.upload_log.filename),
+ )
+
+ def test_getFileByName_uploaded_files(self):
+ # getFileByName returns uploaded files when requested by name.
+ filenames = ("ubuntu.squashfs", "ubuntu.manifest")
+ lfas = []
+ for filename in filenames:
+ lfa = self.factory.makeLibraryFileAlias(filename=filename)
+ lfas.append(lfa)
+ self.build.addFile(lfa)
+ self.assertContentEqual(
+ lfas, [row[1] for row in self.build.getFiles()]
+ )
+ for filename, lfa in zip(filenames, lfas):
+ self.assertEqual(lfa, self.build.getFileByName(filename))
+ self.assertRaises(NotFoundError, self.build.getFileByName, "missing")
+
+ def test_verifySuccessfulUpload(self):
+ self.assertFalse(self.build.verifySuccessfulUpload())
+ self.factory.makeCraftFile(build=self.build)
+ self.assertTrue(self.build.verifySuccessfulUpload())
+
+ def test_updateStatus_stores_revision_id(self):
+ # If the builder reports a revision_id, updateStatus saves it.
+ self.assertIsNone(self.build.revision_id)
+ self.build.updateStatus(BuildStatus.BUILDING, worker_status={})
+ self.assertIsNone(self.build.revision_id)
+ self.build.updateStatus(
+ BuildStatus.BUILDING, worker_status={"revision_id": "dummy"}
+ )
+ self.assertEqual("dummy", self.build.revision_id)
+
+ def addFakeBuildLog(self, build):
+ build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
+
+ def test_log_url_123(self):
+ # The log URL for a craft recipe build will use the recipe context.
+ self.addFakeBuildLog(self.build)
+ self.build.log_url
+ self.assertEqual(
+ "http://launchpad.test/~%s/%s/+craft/%s/+build/%d/+files/"
+ "mybuildlog.txt"
+ % (
+ self.build.recipe.owner.name,
+ self.build.recipe.project.name,
+ self.build.recipe.name,
+ self.build.id,
+ ),
+ self.build.log_url,
+ )
+
+ def test_eta(self):
+ # CraftRecipeBuild.eta returns a non-None value when it should, or
+ # None when there's no start time.
+ self.build.queueBuild()
+ self.assertIsNone(self.build.eta)
+ self.factory.makeBuilder(processors=[self.build.processor])
+ clear_property_cache(self.build)
+ self.assertIsNotNone(self.build.eta)
+
+ def test_eta_cached(self):
+ # The expensive completion time estimate is cached.
+ self.build.queueBuild()
+ self.build.eta
+ with StormStatementRecorder() as recorder:
+ self.build.eta
+ self.assertThat(recorder, HasQueryCount(Equals(0)))
+
+ def test_estimate(self):
+ # CraftRecipeBuild.estimate returns True until the job is completed.
+ self.build.queueBuild()
+ self.factory.makeBuilder(processors=[self.build.processor])
+ self.build.updateStatus(BuildStatus.BUILDING)
+ self.assertTrue(self.build.estimate)
+ self.build.updateStatus(BuildStatus.FULLYBUILT)
+ clear_property_cache(self.build)
+ self.assertFalse(self.build.estimate)
+
+
+class TestCraftRecipeBuildSet(TestCaseWithFactory):
+
+ layer = LaunchpadZopelessLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+
+ def test_getByBuildFarmJob_works(self):
+ build = self.factory.makeCraftRecipeBuild()
+ self.assertEqual(
+ build,
+ getUtility(ICraftRecipeBuildSet).getByBuildFarmJob(
+ build.build_farm_job
+ ),
+ )
+
+ def test_getByBuildFarmJob_returns_None_when_missing(self):
+ bpb = self.factory.makeBinaryPackageBuild()
+ self.assertIsNone(
+ getUtility(ICraftRecipeBuildSet).getByBuildFarmJob(
+ bpb.build_farm_job
+ )
+ )
+
+ def test_getByBuildFarmJobs_works(self):
+ builds = [self.factory.makeCraftRecipeBuild() for i in range(10)]
+ self.assertContentEqual(
+ builds,
+ getUtility(ICraftRecipeBuildSet).getByBuildFarmJobs(
+ [build.build_farm_job for build in builds]
+ ),
+ )
+
+ def test_getByBuildFarmJobs_works_empty(self):
+ self.assertContentEqual(
+ [], getUtility(ICraftRecipeBuildSet).getByBuildFarmJobs([])
+ )
+
+ def test_virtualized_recipe_requires(self):
+ recipe = self.factory.makeCraftRecipe(require_virtualized=True)
+ target = self.factory.makeCraftRecipeBuild(recipe=recipe)
+ self.assertTrue(target.virtualized)
+
+ def test_virtualized_processor_requires(self):
+ recipe = self.factory.makeCraftRecipe(require_virtualized=False)
+ distro_arch_series = self.factory.makeDistroArchSeries()
+ distro_arch_series.processor.supports_nonvirtualized = False
+ target = self.factory.makeCraftRecipeBuild(
+ distro_arch_series=distro_arch_series, recipe=recipe
+ )
+ self.assertTrue(target.virtualized)
+
+ def test_virtualized_no_support(self):
+ recipe = self.factory.makeCraftRecipe(require_virtualized=False)
+ distro_arch_series = self.factory.makeDistroArchSeries()
+ distro_arch_series.processor.supports_nonvirtualized = True
+ target = self.factory.makeCraftRecipeBuild(
+ recipe=recipe, distro_arch_series=distro_arch_series
+ )
+ self.assertFalse(target.virtualized)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 7260861..9dece89 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -135,6 +135,8 @@ from lp.code.interfaces.sourcepackagerecipebuild import (
from lp.code.model.diff import Diff, PreviewDiff
from lp.code.tests.helpers import GitHostingFixture
from lp.crafts.interfaces.craftrecipe import ICraftRecipeSet
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuildSet
+from lp.crafts.model.craftrecipebuild import CraftFile
from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
@@ -6978,6 +6980,65 @@ class LaunchpadObjectFactory(ObjectFactory):
requester, channels=channels, architectures=architectures
)
+ def makeCraftRecipeBuild(
+ self,
+ registrant=None,
+ recipe=None,
+ build_request=None,
+ requester=None,
+ distro_arch_series=None,
+ channels=None,
+ store_upload_metadata=None,
+ date_created=DEFAULT,
+ status=BuildStatus.NEEDSBUILD,
+ builder=None,
+ duration=None,
+ **kwargs,
+ ):
+ if recipe is None:
+ if registrant is None:
+ if build_request is not None:
+ registrant = build_request.requester
+ else:
+ registrant = requester
+ recipe = self.makeCraftRecipe(registrant=registrant, **kwargs)
+ if distro_arch_series is None:
+ distro_arch_series = self.makeDistroArchSeries()
+ if build_request is None:
+ build_request = self.makeCraftRecipeBuildRequest(
+ recipe=recipe, requester=requester, channels=channels
+ )
+ build = getUtility(ICraftRecipeBuildSet).new(
+ build_request,
+ recipe,
+ distro_arch_series,
+ channels=channels,
+ store_upload_metadata=store_upload_metadata,
+ date_created=date_created,
+ )
+ if duration is not None:
+ removeSecurityProxy(build).updateStatus(
+ BuildStatus.BUILDING,
+ builder=builder,
+ date_started=build.date_created,
+ )
+ removeSecurityProxy(build).updateStatus(
+ status,
+ builder=builder,
+ date_finished=build.date_started + duration,
+ )
+ else:
+ removeSecurityProxy(build).updateStatus(status, builder=builder)
+ IStore(build).flush()
+ return build
+
+ def makeCraftFile(self, build=None, library_file=None):
+ if build is None:
+ build = self.makeCraftRecipeBuild()
+ if library_file is None:
+ library_file = self.makeLibraryFileAlias()
+ return ProxyFactory(CraftFile(build=build, library_file=library_file))
+
def makeRockRecipe(
self,
registrant=None,