← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charmhub-schedule-upload-job into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-schedule-upload-job into launchpad:master with ~cjwatson/launchpad:charmhub-upload-job as a prerequisite.

Commit message:
Schedule Charmhub upload jobs when builds complete

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/407979
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-schedule-upload-job into launchpad:master.
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 6f162cc..cefae7f 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -5,11 +5,20 @@
 
 __metaclass__ = type
 __all__ = [
+    "CannotScheduleStoreUpload",
+    "CharmRecipeBuildStoreUploadStatus",
     "ICharmFile",
     "ICharmRecipeBuild",
     "ICharmRecipeBuildSet",
     ]
 
+import http.client
+
+from lazr.enum import (
+    EnumeratedType,
+    Item,
+    )
+from lazr.restful.declarations import error_status
 from lazr.restful.fields import (
     CollectionField,
     Reference,
@@ -20,6 +29,7 @@ from zope.interface import (
     )
 from zope.schema import (
     Bool,
+    Choice,
     Datetime,
     Dict,
     Int,
@@ -39,6 +49,51 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
 
+@error_status(http.client.BAD_REQUEST)
+class CannotScheduleStoreUpload(Exception):
+    """This build cannot be uploaded to the store."""
+
+
+class CharmRecipeBuildStoreUploadStatus(EnumeratedType):
+    """Charm recipe build store upload status type
+
+    Charm recipe builds may be uploaded to Charmhub. This represents the
+    state of that process.
+    """
+
+    UNSCHEDULED = Item("""
+        Unscheduled
+
+        No upload of this charm recipe build to Charmhub is scheduled.
+        """)
+
+    PENDING = Item("""
+        Pending
+
+        This charm recipe build is queued for upload to Charmhub.
+        """)
+
+    FAILEDTOUPLOAD = Item("""
+        Failed to upload
+
+        The last attempt to upload this charm recipe build to Charmhub
+        failed.
+        """)
+
+    FAILEDTORELEASE = Item("""
+        Failed to release to channels
+
+        The last attempt to release this charm recipe build to its intended
+        set of channels failed.
+        """)
+
+    UPLOADED = Item("""
+        Uploaded
+
+        This charm recipe build was successfully uploaded to Charmhub.
+        """)
+
+
 class ICharmRecipeBuildView(IPackageBuild):
     """`ICharmRecipeBuild` attributes that require launchpad.View."""
 
@@ -120,6 +175,24 @@ class ICharmRecipeBuildView(IPackageBuild):
     last_store_upload_job = Reference(
         title=_("Last store upload job for this build."), schema=Interface)
 
+    store_upload_status = Choice(
+        title=_("Store upload status"),
+        vocabulary=CharmRecipeBuildStoreUploadStatus,
+        required=True, readonly=False)
+
+    store_upload_revision = Int(
+        title=_("Store revision"),
+        description=_(
+            "The revision assigned to this charm recipe build by Charmhub."),
+        required=False, readonly=True)
+
+    store_upload_error_message = TextLine(
+        title=_("Store upload error message"),
+        description=_(
+            "The error message, if any, from the last attempt to upload "
+            "this charm recipe build to Charmhub."),
+        required=False, readonly=True)
+
     store_upload_metadata = Attribute(
         _("A dict of data about store upload progress."))
 
@@ -157,6 +230,13 @@ class ICharmRecipeBuildEdit(Interface):
         :return: An `ICharmFile`.
         """
 
+    def scheduleStoreUpload():
+        """Schedule an upload of this build to the store.
+
+        :raises CannotScheduleStoreUpload: if the build is not in a state
+            where an upload can be scheduled.
+        """
+
     def retry():
         """Restore the build record to its initial state.
 
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index c6b10ec..531d8b2 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -38,10 +38,13 @@ 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 (
+    CannotScheduleStoreUpload,
+    CharmRecipeBuildStoreUploadStatus,
     ICharmFile,
     ICharmRecipeBuild,
     ICharmRecipeBuildSet,
     )
+from lp.charms.interfaces.charmrecipebuildjob import ICharmhubUploadJobSource
 from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
 from lp.charms.model.charmrecipebuildjob import (
     CharmRecipeBuildJob,
@@ -62,6 +65,7 @@ from lp.services.database.interfaces import (
     IStore,
     )
 from lp.services.database.stormbase import StormBase
+from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
 from lp.services.librarian.model import (
     LibraryFileAlias,
@@ -348,6 +352,51 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
     def last_store_upload_job(self):
         return self.store_upload_jobs.first()
 
+    @property
+    def store_upload_status(self):
+        job = self.last_store_upload_job
+        if job is None or job.job.status == JobStatus.SUSPENDED:
+            return CharmRecipeBuildStoreUploadStatus.UNSCHEDULED
+        elif job.job.status in (JobStatus.WAITING, JobStatus.RUNNING):
+            return CharmRecipeBuildStoreUploadStatus.PENDING
+        elif job.job.status == JobStatus.COMPLETED:
+            return CharmRecipeBuildStoreUploadStatus.UPLOADED
+        else:
+            if job.store_revision:
+                return CharmRecipeBuildStoreUploadStatus.FAILEDTORELEASE
+            else:
+                return CharmRecipeBuildStoreUploadStatus.FAILEDTOUPLOAD
+
+    @property
+    def store_upload_revision(self):
+        job = self.last_store_upload_job
+        return job and job.store_revision
+
+    @property
+    def store_upload_error_message(self):
+        job = self.last_store_upload_job
+        return job and job.error_message
+
+    def scheduleStoreUpload(self):
+        """See `ICharmRecipeBuild`."""
+        if not self.recipe.can_upload_to_store:
+            raise CannotScheduleStoreUpload(
+                "Cannot upload this charm to Charmhub because it is not "
+                "properly configured.")
+        if not self.was_built or self.getFiles().is_empty():
+            raise CannotScheduleStoreUpload(
+                "Cannot upload this charm because it has no files.")
+        if (self.store_upload_status ==
+                CharmRecipeBuildStoreUploadStatus.PENDING):
+            raise CannotScheduleStoreUpload(
+                "An upload of this charm is already in progress.")
+        elif (self.store_upload_status ==
+                CharmRecipeBuildStoreUploadStatus.UPLOADED):
+            raise CannotScheduleStoreUpload(
+                "Cannot upload this charm because it has already been "
+                "uploaded.")
+        getUtility(ICharmhubUploadJobSource).create(self)
+
     def getFiles(self):
         """See `ICharmRecipeBuild`."""
         result = Store.of(self).find(
diff --git a/lib/lp/charms/model/charmrecipebuildjob.py b/lib/lp/charms/model/charmrecipebuildjob.py
index bc810eb..54b6dbb 100644
--- a/lib/lp/charms/model/charmrecipebuildjob.py
+++ b/lib/lp/charms/model/charmrecipebuildjob.py
@@ -58,6 +58,7 @@ from lp.services.job.model.job import (
     )
 from lp.services.job.runner import BaseRunnableJob
 from lp.services.propertycache import get_property_cache
+from lp.services.webapp.snapshot import notify_modified
 
 
 class CharmRecipeBuildJobType(DBEnumeratedType):
@@ -188,11 +189,16 @@ class CharmhubUploadJob(CharmRecipeBuildJobDerived):
     @classmethod
     def create(cls, build):
         """See `ICharmhubUploadJobSource`."""
-        charm_recipe_build_job = CharmRecipeBuildJob(
-            build, cls.class_job_type, {})
-        job = cls(charm_recipe_build_job)
-        job.celeryRunOnCommit()
-        del get_property_cache(build).last_store_upload_job
+        edited_fields = set()
+        with notify_modified(build, edited_fields) as before_modification:
+            charm_recipe_build_job = CharmRecipeBuildJob(
+                build, cls.class_job_type, {})
+            job = cls(charm_recipe_build_job)
+            job.celeryRunOnCommit()
+            del get_property_cache(build).last_store_upload_job
+            upload_status = build.store_upload_status
+            if upload_status != before_modification.store_upload_status:
+                edited_fields.add("store_upload_status")
         return job
 
     @property
@@ -246,6 +252,39 @@ class CharmhubUploadJob(CharmRecipeBuildJobDerived):
             self.build.store_upload_metadata = {}
         self.build.store_upload_metadata["status_url"] = url
 
+    # Ideally we'd just override Job._set_status or similar, but
+    # lazr.delegates makes that difficult, so we use this to override all
+    # the individual Job lifecycle methods instead.
+    def _do_lifecycle(self, method_name, manage_transaction=False,
+                      *args, **kwargs):
+        edited_fields = set()
+        with notify_modified(self.build, edited_fields) as before_modification:
+            getattr(super(CharmhubUploadJob, self), method_name)(
+                *args, manage_transaction=manage_transaction, **kwargs)
+            upload_status = self.build.store_upload_status
+            if upload_status != before_modification.store_upload_status:
+                edited_fields.add("store_upload_status")
+        if edited_fields and manage_transaction:
+            transaction.commit()
+
+    def start(self, *args, **kwargs):
+        self._do_lifecycle("start", *args, **kwargs)
+
+    def complete(self, *args, **kwargs):
+        self._do_lifecycle("complete", *args, **kwargs)
+
+    def fail(self, *args, **kwargs):
+        self._do_lifecycle("fail", *args, **kwargs)
+
+    def queue(self, *args, **kwargs):
+        self._do_lifecycle("queue", *args, **kwargs)
+
+    def suspend(self, *args, **kwargs):
+        self._do_lifecycle("suspend", *args, **kwargs)
+
+    def resume(self, *args, **kwargs):
+        self._do_lifecycle("resume", *args, **kwargs)
+
     def getOopsVars(self):
         """See `IRunnableJob`."""
         oops_vars = super(CharmhubUploadJob, self).getOopsVars()
diff --git a/lib/lp/charms/subscribers/charmrecipebuild.py b/lib/lp/charms/subscribers/charmrecipebuild.py
index ebfa796..2eb5534 100644
--- a/lib/lp/charms/subscribers/charmrecipebuild.py
+++ b/lib/lp/charms/subscribers/charmrecipebuild.py
@@ -7,11 +7,14 @@ __metaclass__ = type
 
 from zope.component import getUtility
 
+from lp.buildmaster.enums import BuildStatus
 from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
     )
 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.charms.interfaces.charmrecipebuildjob import ICharmhubUploadJobSource
 from lp.services.features import getFeatureFlag
+from lp.services.scripts import log
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.payload import compose_webhook_payload
@@ -25,7 +28,7 @@ def _trigger_charm_recipe_build_webhook(build, action):
             }
         payload.update(compose_webhook_payload(
             ICharmRecipeBuild, build,
-            ["recipe", "build_request", "status"]))
+            ["recipe", "build_request", "status", "store_upload_status"]))
         getUtility(IWebhookSet).trigger(
             build.recipe, "charm-recipe:build:0.1", payload)
 
@@ -38,5 +41,18 @@ def charm_recipe_build_created(build, event):
 def charm_recipe_build_modified(build, event):
     """Trigger events when a charm recipe build is modified."""
     if event.edited_fields is not None:
-        if "status" in event.edited_fields:
+        status_changed = "status" in event.edited_fields
+        store_upload_status_changed = (
+            "store_upload_status" in event.edited_fields)
+        if status_changed or store_upload_status_changed:
             _trigger_charm_recipe_build_webhook(build, "status-changed")
+        if status_changed:
+            if build.status == BuildStatus.FULLYBUILT:
+                if (build.recipe.can_upload_to_store and
+                        build.recipe.store_upload):
+                    log.info("Scheduling upload of %r to the store." % build)
+                    getUtility(ICharmhubUploadJobSource).create(build)
+                else:
+                    log.info(
+                        "%r is not configured for upload to the store." %
+                        build.recipe)
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index f1e2443..7599d27 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -381,6 +381,7 @@ class TestCharmRecipe(TestCaseWithFactory):
             "build_request": Equals(
                 canonical_url(build_request, force_local_path=True)),
             "status": Equals("Needs building"),
+            "store_upload_status": Equals("Unscheduled"),
             }
         with person_logged_in(recipe.owner):
             delivery = hook.deliveries.one()
@@ -625,6 +626,7 @@ class TestCharmRecipe(TestCaseWithFactory):
                     "build_request": Equals(canonical_url(
                         job.build_request, force_local_path=True)),
                     "status": Equals("Needs building"),
+                    "store_upload_status": Equals("Unscheduled"),
                     })
                 for build in builds]
             self.assertThat(hook.deliveries, MatchesSetwise(*(
diff --git a/lib/lp/charms/tests/test_charmrecipebuild.py b/lib/lp/charms/tests/test_charmrecipebuild.py
index e81bd0d..f4adff6 100644
--- a/lib/lp/charms/tests/test_charmrecipebuild.py
+++ b/lib/lp/charms/tests/test_charmrecipebuild.py
@@ -5,12 +5,15 @@
 
 __metaclass__ = type
 
+import base64
 from datetime import (
     datetime,
     timedelta,
     )
 
 from fixtures import FakeLogger
+from nacl.public import PrivateKey
+from pymacaroons import Macaroon
 import pytz
 import six
 from testtools.matchers import (
@@ -34,16 +37,21 @@ from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
     )
 from lp.charms.interfaces.charmrecipebuild import (
+    CannotScheduleStoreUpload,
+    CharmRecipeBuildStoreUploadStatus,
     ICharmRecipeBuild,
     ICharmRecipeBuildSet,
     )
+from lp.charms.interfaces.charmrecipebuildjob import ICharmhubUploadJobSource
 from lp.registry.enums import (
     PersonVisibility,
     TeamMembershipPolicy,
     )
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
+from lp.services.crypto.interfaces import IEncryptedContainer
 from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
 from lp.services.propertycache import clear_property_cache
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.testing import LogsScheduledWebhooks
@@ -284,6 +292,7 @@ class TestCharmRecipeBuild(TestCaseWithFactory):
             "build_request": Equals(canonical_url(
                 self.build.build_request, force_local_path=True)),
             "status": Equals("Successfully built"),
+            "store_upload_status": Equals("Unscheduled"),
             }
         delivery = hook.deliveries.one()
         self.assertThat(
@@ -334,6 +343,55 @@ class TestCharmRecipeBuild(TestCaseWithFactory):
         self.assertEqual(2, hook.deliveries.count())
         self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
 
+    def test_updateStatus_failure_does_not_trigger_store_uploads(self):
+        # A failed build does not trigger store uploads.
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        self.build.recipe.store_name = self.factory.getUniqueUnicode()
+        self.build.recipe.store_upload = True
+        # CharmRecipe.can_upload_to_store only checks whether
+        # "exchanged_encrypted" is present, so don't bother setting up
+        # encryption keys here.
+        self.build.recipe.store_secrets = {
+            "exchanged_encrypted": Macaroon().serialize()}
+        with dbuser(config.builddmaster.dbuser):
+            self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        self.assertContentEqual([], self.build.store_upload_jobs)
+
+    def test_updateStatus_fullybuilt_not_configured(self):
+        # A completed build does not trigger store uploads if the recipe is
+        # not properly configured for that.
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.builddmaster.dbuser):
+            self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertEqual(0, len(list(self.build.store_upload_jobs)))
+        self.assertIn(
+            "<CharmRecipe ~%s/%s/+charm/%s> is not configured for upload to "
+            "the store." % (
+                self.build.recipe.owner.name, self.build.recipe.project.name,
+                self.build.recipe.name),
+            logger.output.splitlines())
+
+    def test_updateStatus_fullybuilt_triggers_store_uploads(self):
+        # A completed build triggers store uploads.
+        self.pushConfig("charms", charmhub_url="http://charmhub.example/";)
+        logger = self.useFixture(FakeLogger())
+        self.build.recipe.store_name = self.factory.getUniqueUnicode()
+        self.build.recipe.store_upload = True
+        # CharmRecipe.can_upload_to_store only checks whether
+        # "exchanged_encrypted" is present, so don't bother setting up
+        # encryption keys here.
+        self.build.recipe.store_secrets = {
+            "exchanged_encrypted": Macaroon().serialize()}
+        with dbuser(config.builddmaster.dbuser):
+            self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertEqual(1, len(list(self.build.store_upload_jobs)))
+        self.assertIn(
+            "Scheduling upload of <CharmRecipeBuild "
+            "~%s/%s/+charm/%s/+build/%d> to the store." % (
+                self.build.recipe.owner.name, self.build.recipe.project.name,
+                self.build.recipe.name, self.build.id),
+            logger.output.splitlines())
+
     def test_notify_fullybuilt(self):
         # notify does not send mail when a recipe build completes normally.
         build = self.factory.makeCharmRecipeBuild(
@@ -424,6 +482,179 @@ class TestCharmRecipeBuild(TestCaseWithFactory):
         clear_property_cache(self.build)
         self.assertFalse(self.build.estimate)
 
+    def setUpStoreUpload(self):
+        self.private_key = PrivateKey.generate()
+        self.pushConfig(
+            "charms",
+            charmhub_url="http://charmhub.example/";,
+            charmhub_secrets_public_key=base64.b64encode(
+                bytes(self.private_key.public_key)).decode())
+        self.build.recipe.store_name = self.factory.getUniqueUnicode()
+        container = getUtility(IEncryptedContainer, "charmhub-secrets")
+        self.build.recipe.store_secrets = {
+            "exchanged_encrypted": removeSecurityProxy(
+                container.encrypt(Macaroon().serialize().encode())),
+            }
+
+    def test_store_upload_status_unscheduled(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        self.assertEqual(
+            CharmRecipeBuildStoreUploadStatus.UNSCHEDULED,
+            build.store_upload_status)
+
+    def test_store_upload_status_pending(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        getUtility(ICharmhubUploadJobSource).create(build)
+        self.assertEqual(
+            CharmRecipeBuildStoreUploadStatus.PENDING,
+            build.store_upload_status)
+
+    def test_store_upload_status_uploaded(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        job = getUtility(ICharmhubUploadJobSource).create(build)
+        naked_job = removeSecurityProxy(job)
+        naked_job.job._status = JobStatus.COMPLETED
+        self.assertEqual(
+            CharmRecipeBuildStoreUploadStatus.UPLOADED,
+            build.store_upload_status)
+
+    def test_store_upload_status_failed_to_upload(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        job = getUtility(ICharmhubUploadJobSource).create(build)
+        naked_job = removeSecurityProxy(job)
+        naked_job.job._status = JobStatus.FAILED
+        self.assertEqual(
+            CharmRecipeBuildStoreUploadStatus.FAILEDTOUPLOAD,
+            build.store_upload_status)
+
+    def test_store_upload_status_failed_to_release(self):
+        build = self.factory.makeCharmRecipeBuild(
+            status=BuildStatus.FULLYBUILT)
+        job = getUtility(ICharmhubUploadJobSource).create(build)
+        naked_job = removeSecurityProxy(job)
+        naked_job.job._status = JobStatus.FAILED
+        naked_job.store_revision = 1
+        self.assertEqual(
+            CharmRecipeBuildStoreUploadStatus.FAILEDTORELEASE,
+            build.store_upload_status)
+
+    def test_scheduleStoreUpload(self):
+        # A build not previously uploaded to the store can be uploaded
+        # manually.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeCharmFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        self.build.scheduleStoreUpload()
+        [job] = getUtility(ICharmhubUploadJobSource).iterReady()
+        self.assertEqual(JobStatus.WAITING, job.job.status)
+        self.assertEqual(self.build, job.build)
+
+    def test_scheduleStoreUpload_not_configured(self):
+        # A build that is not properly configured cannot be uploaded to the
+        # store.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.build.recipe.store_name = None
+        self.assertRaisesWithContent(
+            CannotScheduleStoreUpload,
+            "Cannot upload this charm to Charmhub because it is not properly "
+            "configured.",
+            self.build.scheduleStoreUpload)
+        self.assertEqual(
+            [], list(getUtility(ICharmhubUploadJobSource).iterReady()))
+
+    def test_scheduleStoreUpload_no_files(self):
+        # A build with no files cannot be uploaded to the store.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertRaisesWithContent(
+            CannotScheduleStoreUpload,
+            "Cannot upload this charm because it has no files.",
+            self.build.scheduleStoreUpload)
+        self.assertEqual(
+            [], list(getUtility(ICharmhubUploadJobSource).iterReady()))
+
+    def test_scheduleStoreUpload_already_in_progress(self):
+        # A build with an upload already in progress will not have another
+        # one created.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeCharmFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        old_job = getUtility(ICharmhubUploadJobSource).create(self.build)
+        self.assertRaisesWithContent(
+            CannotScheduleStoreUpload,
+            "An upload of this charm is already in progress.",
+            self.build.scheduleStoreUpload)
+        self.assertEqual(
+            [old_job], list(getUtility(ICharmhubUploadJobSource).iterReady()))
+
+    def test_scheduleStoreUpload_already_uploaded(self):
+        # A build with an upload that has already completed will not have
+        # another one created.
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeCharmFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        old_job = getUtility(ICharmhubUploadJobSource).create(self.build)
+        removeSecurityProxy(old_job).job._status = JobStatus.COMPLETED
+        self.assertRaisesWithContent(
+            CannotScheduleStoreUpload,
+            "Cannot upload this charm because it has already been uploaded.",
+            self.build.scheduleStoreUpload)
+        self.assertEqual(
+            [], list(getUtility(ICharmhubUploadJobSource).iterReady()))
+
+    def test_scheduleStoreUpload_triggers_webhooks(self):
+        # Scheduling a store upload triggers webhooks on the corresponding
+        # recipe.
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+            }))
+        logger = self.useFixture(FakeLogger())
+        self.setUpStoreUpload()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeCharmFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        hook = self.factory.makeWebhook(
+            target=self.build.recipe, event_types=["charm-recipe:build:0.1"])
+        self.build.scheduleStoreUpload()
+        expected_payload = {
+            "recipe_build": Equals(canonical_url(
+                self.build, force_local_path=True)),
+            "action": Equals("status-changed"),
+            "recipe": Equals(canonical_url(
+                self.build.recipe, force_local_path=True)),
+            "build_request": Equals(canonical_url(
+                self.build.build_request, force_local_path=True)),
+            "status": Equals("Successfully built"),
+            "store_upload_status": Equals("Pending"),
+            }
+        delivery = hook.deliveries.one()
+        self.assertThat(
+            delivery, MatchesStructure(
+                event_type=Equals("charm-recipe:build:0.1"),
+                payload=MatchesDict(expected_payload)))
+        with dbuser(config.IWebhookDeliveryJobSource.dbuser):
+            self.assertEqual(
+                "<WebhookDeliveryJob for webhook %d on %r>" % (
+                    hook.id, hook.target),
+                repr(delivery))
+            self.assertThat(
+                logger.output, LogsScheduledWebhooks([
+                    (hook, "charm-recipe:build:0.1",
+                     MatchesDict(expected_payload))]))
+
 
 class TestCharmRecipeBuildSet(TestCaseWithFactory):
 
diff --git a/lib/lp/charms/tests/test_charmrecipebuildjob.py b/lib/lp/charms/tests/test_charmrecipebuildjob.py
index 97d2f21..33dc021 100644
--- a/lib/lp/charms/tests/test_charmrecipebuildjob.py
+++ b/lib/lp/charms/tests/test_charmrecipebuildjob.py
@@ -8,6 +8,12 @@ __metaclass__ = type
 from datetime import timedelta
 
 from fixtures import FakeLogger
+from testtools.matchers import (
+    Equals,
+    MatchesDict,
+    MatchesListwise,
+    MatchesStructure,
+    )
 import transaction
 from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
@@ -40,6 +46,8 @@ from lp.services.database.interfaces import IStore
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.testing import TestCaseWithFactory
 from lp.testing.dbuser import dbuser
 from lp.testing.fakemethod import FakeMethod
@@ -127,9 +135,44 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
             target=build.recipe, event_types=["charm-recipe:build:0.1"])
         return build
 
+    def assertWebhookDeliveries(self, build,
+                                expected_store_upload_statuses, logger):
+        hook = build.recipe.webhooks.one()
+        deliveries = list(hook.deliveries)
+        deliveries.reverse()
+        expected_payloads = [{
+            "recipe_build": Equals(
+                canonical_url(build, force_local_path=True)),
+            "action": Equals("status-changed"),
+            "recipe": Equals(
+                canonical_url(build.recipe, force_local_path=True)),
+            "build_request": Equals(
+                canonical_url(build.build_request, force_local_path=True)),
+            "status": Equals("Successfully built"),
+            "store_upload_status": Equals(expected),
+            } for expected in expected_store_upload_statuses]
+        matchers = [
+            MatchesStructure(
+                event_type=Equals("charm-recipe:build:0.1"),
+                payload=MatchesDict(expected_payload))
+            for expected_payload in expected_payloads]
+        self.assertThat(deliveries, MatchesListwise(matchers))
+        with dbuser(config.IWebhookDeliveryJobSource.dbuser):
+            for delivery in deliveries:
+                self.assertEqual(
+                    "<WebhookDeliveryJob for webhook %d on %r>" % (
+                        hook.id, hook.target),
+                    repr(delivery))
+            self.assertThat(
+                logger.output, LogsScheduledWebhooks([
+                    (hook, "charm-recipe:build:0.1",
+                     MatchesDict(expected_payload))
+                    for expected_payload in expected_payloads]))
+
     def test_run(self):
         # The job uploads the build to Charmhub and records the Charmhub
         # revision.
+        logger = self.useFixture(FakeLogger())
         build = self.makeCharmRecipeBuild()
         self.assertContentEqual([], build.store_upload_jobs)
         job = CharmhubUploadJob.create(build)
@@ -146,9 +189,11 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertEqual(1, job.store_revision)
         self.assertIsNone(job.error_message)
         self.assertEqual([], pop_notifications())
+        self.assertWebhookDeliveries(build, ["Pending", "Uploaded"], logger)
 
     def test_run_failed(self):
         # A failed run sets the store upload status to FAILED.
+        logger = self.useFixture(FakeLogger())
         build = self.makeCharmRecipeBuild()
         self.assertContentEqual([], build.store_upload_jobs)
         job = CharmhubUploadJob.create(build)
@@ -164,9 +209,12 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertIsNone(job.store_revision)
         self.assertEqual("An upload failure", job.error_message)
         self.assertEqual([], pop_notifications())
+        self.assertWebhookDeliveries(
+            build, ["Pending", "Failed to upload"], logger)
 
     def test_run_unauthorized_notifies(self):
         # A run that gets 401 from Charmhub sends mail.
+        logger = self.useFixture(FakeLogger())
         requester = self.factory.makePerson(name="requester")
         requester_team = self.factory.makeTeam(
             owner=requester, name="requester-team", members=[requester])
@@ -216,10 +264,13 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
             "test-charm/+build/%d\n"
             "Your team Requester Team is the requester of the build.\n" %
             build.id, footer)
+        self.assertWebhookDeliveries(
+            build, ["Pending", "Failed to upload"], logger)
 
     def test_run_502_retries(self):
         # A run that gets a 502 error from Charmhub schedules itself to be
         # retried.
+        logger = self.useFixture(FakeLogger())
         build = self.makeCharmRecipeBuild()
         self.assertContentEqual([], build.store_upload_jobs)
         job = CharmhubUploadJob.create(build)
@@ -237,6 +288,7 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertIsNone(job.error_message)
         self.assertEqual([], pop_notifications())
         self.assertEqual(JobStatus.WAITING, job.job.status)
+        self.assertWebhookDeliveries(build, ["Pending"], logger)
         # Try again.  The upload part of the job is retried, and this time
         # it succeeds.
         job.scheduled_start = None
@@ -254,10 +306,12 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertIsNone(job.error_message)
         self.assertEqual([], pop_notifications())
         self.assertEqual(JobStatus.COMPLETED, job.job.status)
+        self.assertWebhookDeliveries(build, ["Pending", "Uploaded"], logger)
 
     def test_run_upload_failure_notifies(self):
         # A run that gets some other upload failure from Charmhub sends
         # mail.
+        logger = self.useFixture(FakeLogger())
         requester = self.factory.makePerson(name="requester")
         requester_team = self.factory.makeTeam(
             owner=requester, name="requester-team", members=[requester])
@@ -306,12 +360,15 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertEqual(
             "%s\nYour team Requester Team is the requester of the build.\n" %
             build_url, footer)
+        self.assertWebhookDeliveries(
+            build, ["Pending", "Failed to upload"], logger)
         self.assertIn(
             ("error_detail", "The proxy exploded.\n"), job.getOopsVars())
 
     def test_run_review_pending_retries(self):
         # A run that finds that Charmhub has not yet finished reviewing the
         # charm schedules itself to be retried.
+        logger = self.useFixture(FakeLogger())
         build = self.makeCharmRecipeBuild()
         self.assertContentEqual([], build.store_upload_jobs)
         job = CharmhubUploadJob.create(build)
@@ -329,6 +386,7 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertIsNone(job.error_message)
         self.assertEqual([], pop_notifications())
         self.assertEqual(JobStatus.WAITING, job.job.status)
+        self.assertWebhookDeliveries(build, ["Pending"], logger)
         # Try again.  The upload part of the job is not retried, and this
         # time the review completes.
         job.scheduled_start = None
@@ -346,9 +404,11 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertIsNone(job.error_message)
         self.assertEqual([], pop_notifications())
         self.assertEqual(JobStatus.COMPLETED, job.job.status)
+        self.assertWebhookDeliveries(build, ["Pending", "Uploaded"], logger)
 
     def test_run_review_failure_notifies(self):
         # A run that gets a review failure from Charmhub sends mail.
+        logger = self.useFixture(FakeLogger())
         requester = self.factory.makePerson(name="requester")
         requester_team = self.factory.makeTeam(
             owner=requester, name="requester-team", members=[requester])
@@ -397,10 +457,13 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
             "test-charm/+build/%d\n"
             "Your team Requester Team is the requester of the build.\n" %
             build.id, footer)
+        self.assertWebhookDeliveries(
+            build, ["Pending", "Failed to upload"], logger)
 
     def test_run_release(self):
         # A run configured to automatically release the charm to certain
         # channels does so.
+        logger = self.useFixture(FakeLogger())
         build = self.makeCharmRecipeBuild(store_channels=["stable", "edge"])
         self.assertContentEqual([], build.store_upload_jobs)
         job = CharmhubUploadJob.create(build)
@@ -417,10 +480,12 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
         self.assertEqual(1, job.store_revision)
         self.assertIsNone(job.error_message)
         self.assertEqual([], pop_notifications())
+        self.assertWebhookDeliveries(build, ["Pending", "Uploaded"], logger)
 
     def test_run_release_failure_notifies(self):
         # A run configured to automatically release the charm to certain
         # channels but that fails to do so sends mail.
+        logger = self.useFixture(FakeLogger())
         requester = self.factory.makePerson(name="requester")
         requester_team = self.factory.makeTeam(
             owner=requester, name="requester-team", members=[requester])
@@ -467,6 +532,8 @@ class TestCharmhubUploadJob(TestCaseWithFactory):
             "test-charm/+build/%d\n"
             "Your team Requester Team is the requester of the build.\n" %
             build.id, footer)
+        self.assertWebhookDeliveries(
+            build, ["Pending", "Failed to release to channels"], logger)
 
     def test_retry_delay(self):
         # The job is retried every minute, unless it just made one of its