← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-build into launchpad:master with ~cjwatson/launchpad:charm-recipe-distroseries as a prerequisite.

Commit message:
Add basic model for charm recipe builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403469
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-build into launchpad:master.
diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py
index a96bce0..b3f0abb 100644
--- a/lib/lp/buildmaster/enums.py
+++ b/lib/lp/buildmaster/enums.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Common build interfaces."""
@@ -170,6 +170,12 @@ class BuildFarmJobType(DBEnumeratedType):
         Build an OCI image from a recipe.
         """)
 
+    CHARMRECIPEBUILD = DBItem(8, """
+        Charm recipe build
+
+        Build a charm from a recipe.
+        """)
+
 
 class BuildQueueStatus(DBEnumeratedType):
     """Build queue status.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index ce84fbe..a111fca 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -7,14 +7,22 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    "CharmRecipeNavigation",
     "CharmRecipeURL",
     ]
 
 from zope.component import getUtility
 from zope.interface import implementer
 
+from lp.charms.interfaces.charmrecipe import ICharmRecipe
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
 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)
@@ -34,3 +42,22 @@ class CharmRecipeURL:
     @property
     def path(self):
         return "+charm/%s" % self.recipe.name
+
+
+class CharmRecipeNavigation(Navigation):
+    usedfor = ICharmRecipe
+
+    @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(ICharmRecipeBuildSet, name)
+        if build is None or build.recipe != self.context:
+            return None
+        return build
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index fe38cdb..1f5a8af 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -11,5 +11,16 @@
         <browser:url
             for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
             urldata="lp.charms.browser.charmrecipe.CharmRecipeURL" />
+        <browser:navigation
+            module="lp.charms.browser.charmrecipe"
+            classes="CharmRecipeNavigation" />
+        <browser:url
+            for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
+            path_expression="string:+build-request/${id}"
+            attribute_to_parent="recipe" />
+        <browser:url
+            for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
+            path_expression="string:+build/${id}"
+            attribute_to_parent="recipe" />
     </facet>
 </configure>
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index ddb0b3e..f1deb96 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -46,6 +46,37 @@
             interface="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" />
     </class>
 
+    <!-- CharmRecipeBuild -->
+    <class class="lp.charms.model.charmrecipebuild.CharmRecipeBuild">
+        <require
+            permission="launchpad.View"
+            interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildEdit" />
+        <require
+            permission="launchpad.Admin"
+            interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildAdmin" />
+    </class>
+
+    <!-- CharmRecipeBuildSet -->
+    <securedutility
+        class="lp.charms.model.charmrecipebuild.CharmRecipeBuildSet"
+        provides="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildSet">
+        <allow interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildSet" />
+    </securedutility>
+    <securedutility
+        class="lp.charms.model.charmrecipebuild.CharmRecipeBuildSet"
+        provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+        name="CHARMRECIPEBUILD">
+        <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
+    </securedutility>
+
+    <!-- CharmFile -->
+    <class class="lp.charms.model.charmrecipebuild.CharmFile">
+        <allow interface="lp.charms.interfaces.charmrecipebuild.ICharmFile" />
+    </class>
+
     <!-- Charm-related jobs -->
     <class class="lp.charms.model.charmrecipejob.CharmRecipeJob">
         <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 56ab189..04950bb 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -31,6 +31,7 @@ from lazr.enum import (
     )
 from lazr.restful.declarations import error_status
 from lazr.restful.fields import (
+    CollectionField,
     Reference,
     ReferenceChoice,
     )
@@ -60,6 +61,7 @@ from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.product import IProduct
 from lp.services.fields import (
     PersonChoice,
@@ -186,6 +188,16 @@ class ICharmRecipeBuildRequest(Interface):
     error_message = TextLine(
         title=_("Error message"), required=True, readonly=True)
 
+    builds = CollectionField(
+        title=_("Builds produced by this request"),
+        # Really ICharmRecipeBuild.
+        value_type=Reference(schema=Interface),
+        required=True, readonly=True)
+
+    requester = Reference(
+        title=_("The person requesting the builds."), schema=IPerson,
+        required=True, readonly=True)
+
     channels = Dict(
         title=_("Source snap channels for builds produced by this request"),
         key_type=TextLine(), required=False, readonly=True)
@@ -402,6 +414,9 @@ class ICharmRecipeSet(Interface):
     def isValidInformationType(information_type, owner, git_ref=None):
         """Whether the information type context is valid."""
 
+    def preloadDataForRecipes(recipes, user):
+        """Load the data related to a list of charm recipes."""
+
     def findByGitRepository(repository, paths=None):
         """Return all charm recipes for the given Git repository.
 
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
new file mode 100644
index 0000000..284566f
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -0,0 +1,204 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe build interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "ICharmFile",
+    "ICharmRecipeBuild",
+    "ICharmRecipeBuildSet",
+    ]
+
+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 ISpecificBuildFarmJobSource
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.charms.interfaces.charmrecipe import (
+    ICharmRecipe,
+    ICharmRecipeBuildRequest,
+    )
+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 ICharmRecipeBuildView(IPackageBuild):
+    """`ICharmRecipeBuild` attributes that require launchpad.View."""
+
+    build_request = Reference(
+        ICharmRecipeBuildRequest,
+        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(
+        ICharmRecipe,
+        title=_("The charm 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 'charmcraft' 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)
+
+    can_be_rescored = Bool(
+        title=_("Can be rescored"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be rescored manually."))
+
+    can_be_retried = Bool(
+        title=_("Can be retried"),
+        required=False, readonly=True,
+        description=_("Whether this build record can be retried."))
+
+    can_be_cancelled = Bool(
+        title=_("Can be cancelled"),
+        required=True, readonly=True,
+        description=_("Whether this build record can be cancelled."))
+
+    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 `ICharmFile` records.
+
+        :return: A result set of (`ICharmFile`, `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
+        charm 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 ICharmRecipeBuildEdit(Interface):
+    """`ICharmRecipeBuild` methods that require launchpad.Edit."""
+
+    def addFile(lfa):
+        """Add a file to this build.
+
+        :param lfa: An `ILibraryFileAlias`.
+        :return: An `ICharmFile`.
+        """
+
+    def retry():
+        """Restore the build record to its initial state.
+
+        Build record loses its history, is moved to NEEDSBUILD and a new
+        non-scored BuildQueue entry is created for it.
+        """
+
+    def cancel():
+        """Cancel the build if it is either pending or in progress.
+
+        Check the can_be_cancelled property prior to calling this method to
+        find out if cancelling the build is possible.
+
+        If the build is in progress, it is marked as CANCELLING until the
+        buildd manager terminates the build and marks it CANCELLED.  If the
+        build is not in progress, it is marked CANCELLED immediately and is
+        removed from the build queue.
+
+        If the build is not in a cancellable state, this method is a no-op.
+        """
+
+
+class ICharmRecipeBuildAdmin(Interface):
+    """`ICharmRecipeBuild` methods that require launchpad.Admin."""
+
+    def rescore(score):
+        """Change the build's score."""
+
+
+class ICharmRecipeBuild(
+        ICharmRecipeBuildView, ICharmRecipeBuildEdit, ICharmRecipeBuildAdmin):
+    """A build record for a charm recipe."""
+
+
+class ICharmRecipeBuildSet(ISpecificBuildFarmJobSource):
+    """Utility to create and access `ICharmRecipeBuild`s."""
+
+    def new(build_request, recipe, distro_arch_series, channels=None,
+            store_upload_metadata=None, date_created=DEFAULT):
+        """Create an `ICharmRecipeBuild`."""
+
+    def preloadBuildsData(builds):
+        """Load the data related to a list of charm recipe builds."""
+
+
+class ICharmFile(Interface):
+    """A file produced by a charm recipe build."""
+
+    build = Reference(
+        ICharmRecipeBuild,
+        title=_("The charm 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/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
index 7d0eaf7..9bdc9e5 100644
--- a/lib/lp/charms/interfaces/charmrecipejob.py
+++ b/lib/lp/charms/interfaces/charmrecipejob.py
@@ -20,6 +20,7 @@ from zope.interface import (
 from zope.schema import (
     Datetime,
     Dict,
+    List,
     Set,
     TextLine,
     )
@@ -29,6 +30,7 @@ from lp.charms.interfaces.charmrecipe import (
     ICharmRecipe,
     ICharmRecipeBuildRequest,
     )
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
 from lp.registry.interfaces.person import IPerson
 from lp.services.job.interfaces.job import (
     IJob,
@@ -86,6 +88,11 @@ class ICharmRecipeRequestBuildsJob(IRunnableJob):
         title=_("The build request corresponding to this job."),
         schema=ICharmRecipeBuildRequest, required=True, readonly=True)
 
+    builds = List(
+        title=_("The builds created by this request."),
+        value_type=Reference(schema=ICharmRecipeBuild),
+        required=True, readonly=True)
+
 
 class ICharmRecipeRequestBuildsJobSource(IJobSource):
 
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 1fac03e..41030f0 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -47,10 +47,15 @@ from lp.charms.interfaces.charmrecipe import (
 from lp.charms.interfaces.charmrecipejob import (
     ICharmRecipeRequestBuildsJobSource,
     )
+from lp.code.model.gitcollection import GenericGitCollection
 from lp.code.model.gitrepository import GitRepository
 from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.distribution import IDistributionSet
-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,
@@ -130,6 +135,16 @@ class CharmRecipeBuildRequest:
         return self._job.error_message
 
     @property
+    def builds(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.builds
+
+    @property
+    def requester(self):
+        """See `ICharmRecipeBuildRequest`."""
+        return self._job.requester
+
+    @property
     def channels(self):
         """See `ICharmRecipeBuildRequest`."""
         return self._job.channels
@@ -414,6 +429,28 @@ class CharmRecipeSet:
 
         return True
 
+    def preloadDataForRecipes(self, recipes, user=None):
+        """See `ICharmRecipeSet`."""
+        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))
+
     def findByGitRepository(self, repository, paths=None):
         """See `ICharmRecipeSet`."""
         clauses = [CharmRecipe.git_repository == repository]
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
new file mode 100644
index 0000000..aa84774
--- /dev/null
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -0,0 +1,464 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe builds."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "CharmFile",
+    "CharmRecipeBuild",
+    ]
+
+from datetime import timedelta
+
+import pytz
+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.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.charms.interfaces.charmrecipebuild import (
+    ICharmFile,
+    ICharmRecipeBuild,
+    ICharmRecipeBuildSet,
+    )
+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 (
+    IMasterStore,
+    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(ICharmRecipeBuild)
+class CharmRecipeBuild(PackageBuildMixin, StormBase):
+    """See `ICharmRecipeBuild`."""
+
+    __storm_table__ = "CharmRecipeBuild"
+
+    job_type = BuildFarmJobType.CHARMRECIPEBUILD
+
+    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, "CharmRecipe.id")
+
+    processor_id = Int(name="processor", allow_none=False)
+    processor = Reference(processor_id, "Processor.id")
+
+    channels = JSON("channels", allow_none=True)
+
+    virtualized = Bool(name="virtualized", allow_none=False)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.UTC, allow_none=False)
+    date_started = DateTime(
+        name="date_started", tzinfo=pytz.UTC, allow_none=True)
+    date_finished = DateTime(
+        name="date_finished", tzinfo=pytz.UTC, allow_none=True)
+    date_first_dispatched = DateTime(
+        name="date_first_dispatched", tzinfo=pytz.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, processor,
+                 virtualized, channels=None, store_upload_metadata=None,
+                 date_created=DEFAULT):
+        """Construct a `CharmRecipeBuild`."""
+        requester = build_request.requester
+        super(CharmRecipeBuild, self).__init__()
+        self.build_farm_job = build_farm_job
+        self.build_request_id = build_request.id
+        self.requester = requester
+        self.recipe = recipe
+        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 "<CharmRecipeBuild ~%s/%s/+charm/%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/+charm/%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.recipe.distribution
+
+    @property
+    def distro_series(self):
+        """See `IPackageBuild`."""
+        return self.recipe.distro_series
+
+    @property
+    def distro_arch_series(self):
+        """See `ICharmRecipeBuild`."""
+        return self.recipe.distro_series.getDistroArchSeriesByProcessor(
+            self.processor)
+
+    @property
+    def archive(self):
+        """See `IPackageBuild`."""
+        return self.distribution.main_archive
+
+    @property
+    def pocket(self):
+        """See `IPackageBuild`."""
+        return PackagePublishingPocket.RELEASE
+
+    @property
+    def score(self):
+        """See `ICharmRecipeBuild`."""
+        if self.buildqueue_record is None:
+            return None
+        else:
+            return self.buildqueue_record.lastscore
+
+    @property
+    def can_be_retried(self):
+        """See `ICharmRecipeBuild`."""
+        # First check that the behaviour would accept the build if it
+        # succeeded.
+        if self.distro_series.status == SeriesStatus.OBSOLETE:
+            return False
+
+        failed_statuses = [
+            BuildStatus.FAILEDTOBUILD,
+            BuildStatus.MANUALDEPWAIT,
+            BuildStatus.CHROOTWAIT,
+            BuildStatus.FAILEDTOUPLOAD,
+            BuildStatus.CANCELLED,
+            BuildStatus.SUPERSEDED,
+            ]
+
+        # If the build is currently in any of the failed states,
+        # it may be retried.
+        return self.status in failed_statuses
+
+    @property
+    def can_be_rescored(self):
+        """See `ICharmRecipeBuild`."""
+        return (
+            self.buildqueue_record is not None and
+            self.status is BuildStatus.NEEDSBUILD)
+
+    @property
+    def can_be_cancelled(self):
+        """See `ICharmRecipeBuild`."""
+        if not self.buildqueue_record:
+            return False
+
+        cancellable_statuses = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+            ]
+        return self.status in cancellable_statuses
+
+    def retry(self):
+        """See `ICharmRecipeBuild`."""
+        assert self.can_be_retried, "Build %s cannot be retried" % self.id
+        self.build_farm_job.status = self.status = BuildStatus.NEEDSBUILD
+        self.build_farm_job.date_finished = self.date_finished = None
+        self.date_started = None
+        self.build_farm_job.builder = self.builder = None
+        self.log = None
+        self.upload_log = None
+        self.dependencies = None
+        self.failure_count = 0
+        self.queueBuild()
+
+    def rescore(self, score):
+        """See `ICharmRecipeBuild`."""
+        assert self.can_be_rescored, "Build %s cannot be rescored" % self.id
+        self.buildqueue_record.manualScore(score)
+
+    def cancel(self):
+        """See `ICharmRecipeBuild`."""
+        if not self.can_be_cancelled:
+            return
+        # BuildQueue.cancel() will decide whether to go straight to
+        # CANCELLED, or go through CANCELLING to let buildd-manager clean up
+        # the slave.
+        self.buildqueue_record.cancel()
+
+    def calculateScore(self):
+        """See `IBuildFarmJob`."""
+        # XXX cjwatson 2021-05-28: We'll probably need something like
+        # CharmRecipe.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(
+            (CharmRecipeBuild.date_started, CharmRecipeBuild.date_finished),
+            CharmRecipeBuild.recipe == self.recipe,
+            CharmRecipeBuild.processor == self.processor,
+            CharmRecipeBuild.status == BuildStatus.FULLYBUILT)
+        result.order_by(Desc(CharmRecipeBuild.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 `ICharmRecipeBuild`."""
+        result = Store.of(self).find(
+            (CharmFile, LibraryFileAlias, LibraryFileContent),
+            CharmFile.build == self.id,
+            LibraryFileAlias.id == CharmFile.library_file_id,
+            LibraryFileContent.id == LibraryFileAlias.contentID)
+        return result.order_by([LibraryFileAlias.filename, CharmFile.id])
+
+    def getFileByName(self, filename):
+        """See `ICharmRecipeBuild`."""
+        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,
+                CharmFile.build == self.id,
+                LibraryFileAlias.id == CharmFile.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 `ICharmRecipeBuild`."""
+        charm_file = CharmFile(build=self, library_file=lfa)
+        IMasterStore(CharmFile).add(charm_file)
+        return charm_file
+
+    def verifySuccessfulUpload(self):
+        """See `IPackageBuild`."""
+        return not self.getFiles().is_empty()
+
+    def updateStatus(self, status, builder=None, slave_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(CharmRecipeBuild, self).updateStatus(
+                status, builder=builder, slave_status=slave_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 slave_status is not None:
+                revision_id = slave_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 cjwatson 2021-05-28: Send email notifications.
+
+
+@implementer(ICharmRecipeBuildSet)
+class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
+    """See `ICharmRecipeBuildSet`."""
+
+    def new(self, build_request, recipe, distro_arch_series, channels=None,
+            store_upload_metadata=None, date_created=DEFAULT):
+        """See `ICharmRecipeBuildSet`."""
+        store = IMasterStore(CharmRecipeBuild)
+        build_farm_job = getUtility(IBuildFarmJobSource).new(
+            CharmRecipeBuild.job_type, BuildStatus.NEEDSBUILD, date_created)
+        virtualized = (
+            not distro_arch_series.processor.supports_nonvirtualized
+            or recipe.require_virtualized)
+        build = CharmRecipeBuild(
+            build_farm_job, build_request, recipe,
+            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 = IMasterStore(CharmRecipeBuild)
+        return store.get(CharmRecipeBuild, build_id)
+
+    def getByBuildFarmJob(self, build_farm_job):
+        """See `ISpecificBuildFarmJobSource`."""
+        return Store.of(build_farm_job).find(
+            CharmRecipeBuild, build_farm_job_id=build_farm_job.id).one()
+
+    def preloadBuildsData(self, builds):
+        # Circular import.
+        from lp.charms.model.charmrecipe import CharmRecipe
+        load_related(Person, builds, ["requester_id"])
+        lfas = load_related(LibraryFileAlias, builds, ["log_id"])
+        load_related(LibraryFileContent, lfas, ["contentID"])
+        recipes = load_related(CharmRecipe, builds, ["recipe_id"])
+        getUtility(ICharmRecipeSet).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(
+            CharmRecipeBuild, CharmRecipeBuild.build_farm_job_id.is_in(
+                bfj.id for bfj in build_farm_jobs))
+        return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
+
+
+@implementer(ICharmFile)
+class CharmFile(StormBase):
+    """See `ICharmFile`."""
+
+    __storm_table__ = "CharmFile"
+
+    id = Int(name="id", primary=True)
+
+    build_id = Int(name="build", allow_none=False)
+    build = Reference(build_id, "CharmRecipeBuild.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 `CharmFile`."""
+        super(CharmFile, self).__init__()
+        self.build = build
+        self.library_file = library_file
diff --git a/lib/lp/charms/model/charmrecipejob.py b/lib/lp/charms/model/charmrecipejob.py
index 7cba226..2f1645a 100644
--- a/lib/lp/charms/model/charmrecipejob.py
+++ b/lib/lp/charms/model/charmrecipejob.py
@@ -24,6 +24,7 @@ from storm.locals import (
     Int,
     Reference,
     )
+from storm.store import EmptyResultSet
 import transaction
 from zope.component import getUtility
 from zope.interface import (
@@ -37,6 +38,7 @@ from lp.charms.interfaces.charmrecipejob import (
     ICharmRecipeRequestBuildsJob,
     ICharmRecipeRequestBuildsJobSource,
     )
+from lp.charms.model.charmrecipebuild import CharmRecipeBuild
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.config import config
 from lp.services.database.bulk import load_related
@@ -272,6 +274,21 @@ class CharmRecipeRequestBuildsJob(CharmRecipeJobDerived):
         """See `ICharmRecipeRequestBuildsJob`."""
         return self.recipe.getBuildRequest(self.job.id)
 
+    @property
+    def builds(self):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        build_ids = self.metadata.get("builds")
+        if build_ids:
+            return IStore(CharmRecipeBuild).find(
+                CharmRecipeBuild, CharmRecipeBuild.id.is_in(build_ids))
+        else:
+            return EmptyResultSet()
+
+    @builds.setter
+    def builds(self, builds):
+        """See `ICharmRecipeRequestBuildsJob`."""
+        self.metadata["builds"] = [build.id for build in builds]
+
     def run(self):
         """See `IRunnableJob`."""
         requester = self.requester
diff --git a/lib/lp/charms/tests/test_charmrecipebuild.py b/lib/lp/charms/tests/test_charmrecipebuild.py
new file mode 100644
index 0000000..fde0dca
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmrecipebuild.py
@@ -0,0 +1,336 @@
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test charm package build features."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+
+import pytz
+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.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+    )
+from lp.charms.interfaces.charmrecipebuild import (
+    ICharmRecipeBuild,
+    ICharmRecipeBuildSet,
+    )
+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 (
+    person_logged_in,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.matchers import HasQueryCount
+
+
+class TestCharmRecipeBuild(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestCharmRecipeBuild, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        self.build = self.factory.makeCharmRecipeBuild()
+
+    def test_implements_interfaces(self):
+        # CharmRecipeBuild implements IPackageBuild and ICharmRecipeBuild.
+        self.assertProvides(self.build, IPackageBuild)
+        self.assertProvides(self.build, ICharmRecipeBuild)
+
+    def test___repr__(self):
+        # CharmRecipeBuild has an informative __repr__.
+        self.assertEqual(
+            "<CharmRecipeBuild ~%s/%s/+charm/%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):
+        # CharmRecipeBuild has an informative title.
+        das = self.build.distro_arch_series
+        self.assertEqual(
+            "%s build of /~%s/%s/+charm/%s" % (
+                das.architecturetag, self.build.recipe.owner.name,
+                self.build.recipe.project.name, self.build.recipe.name),
+            self.build.title)
+
+    def test_queueBuild(self):
+        # CharmRecipeBuild 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 CharmRecipeBuild is private iff its recipe or owner are.
+        self.assertFalse(self.build.is_private)
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_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.makeCharmRecipeBuild(
+                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.makeCharmRecipeBuild(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.makeCharmRecipeBuild(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.makeCharmRecipeBuild()
+            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(pytz.UTC)
+        build = self.factory.makeCharmRecipeBuild()
+        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.makeCharmRecipeBuild(
+            requester=self.build.requester, recipe=self.build.recipe,
+            distro_arch_series=self.build.distro_arch_series,
+            status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
+        for i in range(3):
+            self.factory.makeCharmRecipeBuild(
+                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.makeCharmRecipeBuild()
+        self.assertEqual('CHARMRECIPEBUILD-%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.makeCharmFile(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, slave_status={})
+        self.assertIsNone(self.build.revision_id)
+        self.build.updateStatus(
+            BuildStatus.BUILDING, slave_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(self):
+        # The log URL for a charm recipe build will use the recipe context.
+        self.addFakeBuildLog(self.build)
+        self.assertEqual(
+            "http://launchpad.test/~%s/%s/+charm/%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):
+        # CharmRecipeBuild.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):
+        # CharmRecipeBuild.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 TestCharmRecipeBuildSet(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestCharmRecipeBuildSet, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_getByBuildFarmJob_works(self):
+        build = self.factory.makeCharmRecipeBuild()
+        self.assertEqual(
+            build,
+            getUtility(ICharmRecipeBuildSet).getByBuildFarmJob(
+                build.build_farm_job))
+
+    def test_getByBuildFarmJob_returns_None_when_missing(self):
+        bpb = self.factory.makeBinaryPackageBuild()
+        self.assertIsNone(
+            getUtility(ICharmRecipeBuildSet).getByBuildFarmJob(
+                bpb.build_farm_job))
+
+    def test_getByBuildFarmJobs_works(self):
+        builds = [self.factory.makeCharmRecipeBuild() for i in range(10)]
+        self.assertContentEqual(
+            builds,
+            getUtility(ICharmRecipeBuildSet).getByBuildFarmJobs(
+                [build.build_farm_job for build in builds]))
+
+    def test_getByBuildFarmJobs_works_empty(self):
+        self.assertContentEqual(
+            [], getUtility(ICharmRecipeBuildSet).getByBuildFarmJobs([]))
+
+    def test_virtualized_recipe_requires(self):
+        recipe = self.factory.makeCharmRecipe(require_virtualized=True)
+        target = self.factory.makeCharmRecipeBuild(recipe=recipe)
+        self.assertTrue(target.virtualized)
+
+    def test_virtualized_processor_requires(self):
+        recipe = self.factory.makeCharmRecipe(require_virtualized=False)
+        distro_arch_series = self.factory.makeDistroArchSeries(
+            distroseries=recipe.distro_series)
+        distro_arch_series.processor.supports_nonvirtualized = False
+        target = self.factory.makeCharmRecipeBuild(
+            distro_arch_series=distro_arch_series, recipe=recipe)
+        self.assertTrue(target.virtualized)
+
+    def test_virtualized_no_support(self):
+        recipe = self.factory.makeCharmRecipe(require_virtualized=False)
+        distro_arch_series = self.factory.makeDistroArchSeries(
+            distroseries=recipe.distro_series)
+        distro_arch_series.processor.supports_nonvirtualized = True
+        target = self.factory.makeCharmRecipeBuild(
+            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 1a82f23..ab692ab 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -111,6 +111,8 @@ from lp.buildmaster.enums import (
 from lp.buildmaster.interfaces.builder import IBuilderSet
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
+from lp.charms.model.charmrecipebuild import CharmFile
 from lp.code.enums import (
     BranchMergeProposalStatus,
     BranchSubscriptionNotificationLevel,
@@ -5160,6 +5162,58 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         return recipe.requestBuilds(
             requester, channels=channels, architectures=architectures)
 
+    def makeCharmRecipeBuild(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.makeCharmRecipe(registrant=registrant, **kwargs)
+        if distro_arch_series is None:
+            distro_series = self.makeDistroSeries(
+                distribution=recipe.distribution,
+                status=SeriesStatus.DEVELOPMENT)
+            processor = getUtility(IProcessorSet).getByName("386")
+            distro_arch_series = self.makeDistroArchSeries(
+                distroseries=distro_series, architecturetag="i386",
+                processor=processor)
+        else:
+            distro_series = distro_arch_series.distroseries
+            get_property_cache(recipe).distribution = (
+                distro_series.distribution)
+            get_property_cache(recipe).distro_series = distro_series
+        if build_request is None:
+            build_request = self.makeCharmRecipeBuildRequest(
+                recipe=recipe, requester=requester, channels=channels)
+        build = getUtility(ICharmRecipeBuildSet).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 makeCharmFile(self, build=None, library_file=None):
+        if build is None:
+            build = self.makeCharmRecipeBuild()
+        if library_file is None:
+            library_file = self.makeLibraryFileAlias()
+        return ProxyFactory(CharmFile(build=build, library_file=library_file))
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by