← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ilasc/launchpad:webhook-livefs into launchpad:master

 

Ioana Lasc has proposed merging ~ilasc/launchpad:webhook-livefs into launchpad:master with ~cjwatson/launchpad:fix-webhook-visibility-check as a prerequisite.

Commit message:
Add Webhook for LiveFS builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/377875

Added webhooks for LiveFS builds. 

While open for review, I did notice a test failure at the end of my day in lp.code.scripts.tests.test_request_daily_builds.TestRequestDailyBuilds.test_request_daily_builds after running full test suite with the message: "ERROR    - team-name-100107/snap-name-100108/arch-100021: permission denied for relation account"

Will investigate in the morning - wanted to make sure I get the tidying up and the MP open today.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:webhook-livefs into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 06da0d4..ff7c406 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2596,6 +2596,7 @@ public.branch                           = SELECT
 public.distribution                     = SELECT
 public.gitrepository                    = SELECT
 public.job                              = SELECT, UPDATE
+public.livefs                           = SELECT
 public.ociproject                       = SELECT
 public.ociprojectname                   = SELECT
 public.ociprojectseries                 = SELECT
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index e4533b7..8fe724e 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -75,6 +75,7 @@ from lp.services.webservice.apihelpers import (
 WEBHOOK_EVENT_TYPES = {
     "bzr:push:0.1": "Bazaar push",
     "git:push:0.1": "Git push",
+    "livefs:build:0.1": "Live filesystem build",
     "merge-proposal:0.1": "Merge proposal",
     "snap:build:0.1": "Snap build",
     }
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index 0c38e8e..440ce26 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -104,6 +104,9 @@ class Webhook(StormBase):
     snap_id = Int(name='snap')
     snap = Reference(snap_id, 'Snap.id')
 
+    livefs_id = Int(name='livefs')
+    livefs = Reference(livefs_id, 'LiveFS.id')
+
     registrant_id = Int(name='registrant', allow_none=False)
     registrant = Reference(registrant_id, 'Person.id')
     date_created = DateTime(tzinfo=utc, allow_none=False)
@@ -123,6 +126,8 @@ class Webhook(StormBase):
             return self.branch
         elif self.snap is not None:
             return self.snap
+        elif self.livefs is not None:
+            return self.livefs
         else:
             raise AssertionError("No target.")
 
@@ -177,6 +182,8 @@ class WebhookSet:
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
         from lp.snappy.interfaces.snap import ISnap
+        from lp.soyuz.interfaces.livefs import ILiveFS
+
         hook = Webhook()
         if IGitRepository.providedBy(target):
             hook.git_repository = target
@@ -184,6 +191,8 @@ class WebhookSet:
             hook.branch = target
         elif ISnap.providedBy(target):
             hook.snap = target
+        elif ILiveFS.providedBy(target):
+            hook.livefs = target
         else:
             raise AssertionError("Unsupported target: %r" % (target,))
         hook.registrant = registrant
@@ -208,12 +217,16 @@ class WebhookSet:
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
         from lp.snappy.interfaces.snap import ISnap
+        from lp.soyuz.interfaces.livefs import ILiveFS
+
         if IGitRepository.providedBy(target):
             target_filter = Webhook.git_repository == target
         elif IBranch.providedBy(target):
             target_filter = Webhook.branch == target
         elif ISnap.providedBy(target):
             target_filter = Webhook.snap == target
+        elif ILiveFS.providedBy(target):
+            target_filter = Webhook.livefs == target
         else:
             raise AssertionError("Unsupported target: %r" % (target,))
         return IStore(Webhook).find(Webhook, target_filter).order_by(
diff --git a/lib/lp/services/webhooks/tests/test_browser.py b/lib/lp/services/webhooks/tests/test_browser.py
index 22a4be7..9abccc0 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -18,6 +18,7 @@ import transaction
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.publisher import canonical_url
 from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
 from lp.testing import (
     login_person,
     record_two_runs,
@@ -55,12 +56,11 @@ batch_nav_tag = soupmatchers.Tag(
 
 
 class GitRepositoryTestHelpers:
-
     event_type = "git:push:0.1"
     expected_event_types = [
         ("git:push:0.1", "Git push"),
         ("merge-proposal:0.1", "Merge proposal"),
-        ]
+    ]
 
     def makeTarget(self):
         return self.factory.makeGitRepository()
@@ -70,12 +70,11 @@ class GitRepositoryTestHelpers:
 
 
 class BranchTestHelpers:
-
     event_type = "bzr:push:0.1"
     expected_event_types = [
         ("bzr:push:0.1", "Bazaar push"),
         ("merge-proposal:0.1", "Merge proposal"),
-        ]
+    ]
 
     def makeTarget(self):
         return self.factory.makeBranch()
@@ -85,11 +84,10 @@ class BranchTestHelpers:
 
 
 class SnapTestHelpers:
-
     event_type = "snap:build:0.1"
     expected_event_types = [
         ("snap:build:0.1", "Snap build"),
-        ]
+    ]
 
     def setUp(self):
         super(SnapTestHelpers, self).setUp()
@@ -101,7 +99,7 @@ class SnapTestHelpers:
     def makeTarget(self):
         self.useFixture(FeatureFixture({
             'webhooks.new.enabled': 'true',
-            }))
+        }))
         owner = self.factory.makePerson()
         return self.factory.makeSnap(registrant=owner, owner=owner)
 
@@ -109,6 +107,25 @@ class SnapTestHelpers:
         return [obj]
 
 
+class LiveFSTestHelpers:
+    event_type = "livefs:build:0.1"
+    expected_event_types = [
+        ("livefs:build:0.1", "Live filesystem build"),
+    ]
+
+    def setUp(self):
+        super(LiveFSTestHelpers, self).setUp()
+
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true',
+                                        LIVEFS_FEATURE_FLAG: "on"}))
+        owner = self.factory.makePerson()
+        return self.factory.makeLiveFS(registrant=owner, owner=owner)
+
+    def getTraversalStack(self, obj):
+        return [obj]
+
+
 class WebhookTargetViewTestHelpers:
 
     def setUp(self):
@@ -122,13 +139,12 @@ class WebhookTargetViewTestHelpers:
         view = create_view(self.target, name, principal=self.owner, **kwargs)
         # To test the breadcrumbs we need a correct traversal stack.
         view.request.traversed_objects = (
-            self.getTraversalStack(self.target) + [view])
+                self.getTraversalStack(self.target) + [view])
         view.initialize()
         return view
 
 
 class TestWebhooksViewBase(WebhookTargetViewTestHelpers):
-
     layer = DatabaseFunctionalLayer
 
     def makeHooksAndMatchers(self, count):
@@ -189,24 +205,25 @@ class TestWebhooksViewBase(WebhookTargetViewTestHelpers):
 
 class TestWebhooksViewGitRepository(
     TestWebhooksViewBase, GitRepositoryTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhooksViewBranch(
     TestWebhooksViewBase, BranchTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhooksViewSnap(
     TestWebhooksViewBase, SnapTestHelpers, TestCaseWithFactory):
+    layer = LaunchpadFunctionalLayer
+
 
+class TestWebhooksViewLiveFS(
+    TestWebhooksViewBase, LiveFSTestHelpers, TestCaseWithFactory):
     layer = LaunchpadFunctionalLayer
 
 
 class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
-
     layer = DatabaseFunctionalLayer
 
     def test_rendering(self):
@@ -287,19 +304,21 @@ class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
 
 class TestWebhookAddViewGitRepository(
     TestWebhookAddViewBase, GitRepositoryTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhookAddViewBranch(
     TestWebhookAddViewBase, BranchTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhookAddViewSnap(
     TestWebhookAddViewBase, SnapTestHelpers, TestCaseWithFactory):
+    layer = LaunchpadFunctionalLayer
+
 
+class TestWebhookAddViewLiveFS(
+    TestWebhookAddViewBase, LiveFSTestHelpers, TestCaseWithFactory):
     layer = LaunchpadFunctionalLayer
 
 
@@ -318,13 +337,12 @@ class WebhookViewTestHelpers:
         view = create_view(self.webhook, name, principal=self.owner, **kwargs)
         # To test the breadcrumbs we need a correct traversal stack.
         view.request.traversed_objects = (
-            self.getTraversalStack(self.target) + [self.webhook, view])
+                self.getTraversalStack(self.target) + [self.webhook, view])
         view.initialize()
         return view
 
 
 class TestWebhookViewBase(WebhookViewTestHelpers):
-
     layer = DatabaseFunctionalLayer
 
     def test_rendering(self):
@@ -389,24 +407,25 @@ class TestWebhookViewBase(WebhookViewTestHelpers):
 
 class TestWebhookViewGitRepository(
     TestWebhookViewBase, GitRepositoryTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhookViewBranch(
     TestWebhookViewBase, BranchTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhookViewSnap(
     TestWebhookViewBase, SnapTestHelpers, TestCaseWithFactory):
+    pass
 
+
+class TestWebhookViewLiveFS(
+    TestWebhookViewBase, LiveFSTestHelpers, TestCaseWithFactory):
     pass
 
 
 class TestWebhookDeleteViewBase(WebhookViewTestHelpers):
-
     layer = DatabaseFunctionalLayer
 
     def test_rendering(self):
@@ -441,17 +460,19 @@ class TestWebhookDeleteViewBase(WebhookViewTestHelpers):
 
 class TestWebhookDeleteViewGitRepository(
     TestWebhookDeleteViewBase, GitRepositoryTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhookDeleteViewBranch(
     TestWebhookDeleteViewBase, BranchTestHelpers, TestCaseWithFactory):
-
     pass
 
 
 class TestWebhookDeleteViewSnap(
     TestWebhookDeleteViewBase, SnapTestHelpers, TestCaseWithFactory):
+    pass
+
 
+class TestWebhookDeleteViewLiveFS(
+    TestWebhookDeleteViewBase, LiveFSTestHelpers, TestCaseWithFactory):
     pass
diff --git a/lib/lp/services/webhooks/tests/test_job.py b/lib/lp/services/webhooks/tests/test_job.py
index 260698c..4f6f43e 100644
--- a/lib/lp/services/webhooks/tests/test_job.py
+++ b/lib/lp/services/webhooks/tests/test_job.py
@@ -60,6 +60,7 @@ from lp.services.webhooks.model import (
     WebhookJobType,
     )
 from lp.services.webhooks.testing import LogsScheduledWebhooks
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
 from lp.testing import (
     login_person,
     TestCaseWithFactory,
@@ -339,6 +340,16 @@ class TestWebhookDeliveryJob(TestCaseWithFactory):
             "<WebhookDeliveryJob for webhook %d on %r>" % (hook.id, snap),
             repr(job))
 
+    def test_livefs__repr__(self):
+        # `WebhookDeliveryJob` objects for livefs have an informative __repr__.
+        with FeatureFixture({LIVEFS_FEATURE_FLAG: "on"}):
+            livefs = self.factory.makeLiveFS()
+        hook = self.factory.makeWebhook(target=livefs)
+        job = WebhookDeliveryJob.create(hook, 'test', payload={'foo': 'bar'})
+        self.assertEqual(
+            "<WebhookDeliveryJob for webhook %d on %r>" % (hook.id, livefs),
+            repr(job))
+
     def test_short_lease_and_timeout(self):
         # Webhook jobs have a request timeout of 30 seconds, a celery
         # timeout of 45 seconds, and a lease of 60 seconds, to give
diff --git a/lib/lp/services/webhooks/tests/test_model.py b/lib/lp/services/webhooks/tests/test_model.py
index 41189e3..1e98a68 100644
--- a/lib/lp/services/webhooks/tests/test_model.py
+++ b/lib/lp/services/webhooks/tests/test_model.py
@@ -15,6 +15,7 @@ from zope.security.proxy import removeSecurityProxy
 from lp.app.enums import InformationType
 from lp.registry.enums import BranchSharingPolicy
 from lp.services.database.interfaces import IStore
+from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.snapshot import notify_modified
 from lp.services.webhooks.interfaces import IWebhookSet
@@ -22,6 +23,7 @@ from lp.services.webhooks.model import (
     WebhookJob,
     WebhookSet,
     )
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
 from lp.testing import (
     admin_logged_in,
     anonymous_logged_in,
@@ -395,3 +397,16 @@ class TestWebhookSetSnap(TestWebhookSetBase, TestCaseWithFactory):
         if owner is None:
             owner = self.factory.makePerson()
         return self.factory.makeSnap(registrant=owner, owner=owner, **kwargs)
+
+
+class TestWebhookSetLiveFS(TestWebhookSetBase, TestCaseWithFactory):
+
+    event_type = 'livefs:build:0.1'
+
+    def makeTarget(self, owner=None, **kwargs):
+        if owner is None:
+            owner = self.factory.makePerson()
+
+        with FeatureFixture({LIVEFS_FEATURE_FLAG: "on"}):
+            return self.factory.makeLiveFS(registrant=owner,
+                                           owner=owner, **kwargs)
diff --git a/lib/lp/services/webhooks/tests/test_webservice.py b/lib/lp/services/webhooks/tests/test_webservice.py
index cdf92b5..f8abf63 100644
--- a/lib/lp/services/webhooks/tests/test_webservice.py
+++ b/lib/lp/services/webhooks/tests/test_webservice.py
@@ -23,6 +23,7 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
 from lp.testing import (
     api_url,
     person_logged_in,
@@ -320,7 +321,8 @@ class TestWebhookTargetBase:
              for entry in representation['entries']])
 
     def test_newWebhook_secret(self):
-        self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
+        self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true',
+                                        LIVEFS_FEATURE_FLAG: "on"}))
         response = self.webservice.named_post(
             self.target_url, 'newWebhook',
             delivery_url='http://example.com/ep',
@@ -379,3 +381,13 @@ class TestWebhookTargetSnap(TestWebhookTargetBase, TestCaseWithFactory):
     def makeTarget(self):
         owner = self.factory.makePerson()
         return self.factory.makeSnap(registrant=owner, owner=owner)
+
+
+class TestWebhookTargetLiveFS(TestWebhookTargetBase, TestCaseWithFactory):
+
+    event_type = 'livefs:build:0.1'
+
+    def makeTarget(self):
+        owner = self.factory.makePerson()
+        with FeatureFixture({LIVEFS_FEATURE_FLAG: "on"}):
+            return self.factory.makeLiveFS(registrant=owner, owner=owner)
diff --git a/lib/lp/soyuz/browser/livefs.py b/lib/lp/soyuz/browser/livefs.py
index 5f1bfb9..e2659ca 100644
--- a/lib/lp/soyuz/browser/livefs.py
+++ b/lib/lp/soyuz/browser/livefs.py
@@ -55,6 +55,7 @@ from lp.services.webapp.breadcrumb import (
     Breadcrumb,
     NameBreadcrumb,
     )
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.soyuz.browser.build import get_build_by_id_str
 from lp.soyuz.interfaces.livefs import (
     ILiveFS,
@@ -66,7 +67,7 @@ from lp.soyuz.interfaces.livefs import (
 from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 
 
-class LiveFSNavigation(Navigation):
+class LiveFSNavigation(WebhookTargetNavigationMixin, Navigation):
     usedfor = ILiveFS
 
     @stepthrough('+build')
diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
index 0e35ccb..da011cc 100644
--- a/lib/lp/soyuz/configure.zcml
+++ b/lib/lp/soyuz/configure.zcml
@@ -1022,6 +1022,14 @@
             permission="launchpad.Admin"
             interface=".interfaces.livefsbuild.ILiveFSBuildAdmin"/>
     </class>
+    <subscriber
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild
+             lazr.lifecycle.interfaces.IObjectCreatedEvent"
+        handler="lp.soyuz.subscribers.livefsbuild.livefs_build_created" />
+    <subscriber
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild
+             lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.soyuz.subscribers.livefsbuild.livefs_build_status_changed" />
 
     <!-- LiveFSBuildSet -->
     <securedutility
diff --git a/lib/lp/soyuz/interfaces/livefs.py b/lib/lp/soyuz/interfaces/livefs.py
index 140f874..9038ed9 100644
--- a/lib/lp/soyuz/interfaces/livefs.py
+++ b/lib/lp/soyuz/interfaces/livefs.py
@@ -69,6 +69,7 @@ from lp.services.fields import (
     PersonChoice,
     PublicPersonChoice,
     )
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
@@ -213,7 +214,7 @@ class ILiveFSView(IPrivacy):
         value_type=Reference(schema=Interface), readonly=True)))
 
 
-class ILiveFSEdit(Interface):
+class ILiveFSEdit(IWebhookTarget):
     """`ILiveFS` methods that require launchpad.Edit permission."""
 
     @export_destructor_operation()
diff --git a/lib/lp/soyuz/model/livefs.py b/lib/lp/soyuz/model/livefs.py
index b79c1ca..c273f0f 100644
--- a/lib/lp/soyuz/model/livefs.py
+++ b/lib/lp/soyuz/model/livefs.py
@@ -9,6 +9,7 @@ __all__ = [
 from datetime import timedelta
 import math
 
+from lazr.lifecycle.event import ObjectCreatedEvent
 import pytz
 from storm.locals import (
     Bool,
@@ -24,6 +25,7 @@ from storm.locals import (
     Unicode,
     )
 from zope.component import getUtility
+from zope.event import notify
 from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
@@ -57,6 +59,7 @@ from lp.services.database.stormexpr import (
     )
 from lp.services.features import getFeatureFlag
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.soyuz.interfaces.archive import ArchiveDisabled
 from lp.soyuz.interfaces.livefs import (
     CannotDeleteLiveFS,
@@ -88,7 +91,7 @@ def livefs_modified(livefs, event):
 
 
 @implementer(ILiveFS, IHasOwner)
-class LiveFS(Storm):
+class LiveFS(Storm, WebhookTargetMixin):
     """See `ILiveFS`."""
 
     __storm_table__ = 'LiveFS'
@@ -139,6 +142,10 @@ class LiveFS(Storm):
         self.keep_binary_files_days = keep_binary_files_days
 
     @property
+    def valid_webhook_event_types(self):
+        return ["livefs:build:0.1"]
+
+    @property
     def private(self):
         """See `IPrivacy`."""
         # A LiveFS has no privacy support of its own, but it is private if
@@ -191,6 +198,7 @@ class LiveFS(Storm):
             unique_key=unique_key, metadata_override=metadata_override,
             version=version)
         build.queueBuild()
+        notify(ObjectCreatedEvent(build, user=requester))
         return build
 
     def _getBuilds(self, filter_term, order_by):
diff --git a/lib/lp/soyuz/model/livefsbuild.py b/lib/lp/soyuz/model/livefsbuild.py
index 310ab5c..2bf500e 100644
--- a/lib/lp/soyuz/model/livefsbuild.py
+++ b/lib/lp/soyuz/model/livefsbuild.py
@@ -50,6 +50,7 @@ from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
     )
+from lp.services.webapp.snapshot import notify_modified
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.livefs import (
     LIVEFS_FEATURE_FLAG,
@@ -315,6 +316,20 @@ class LiveFSBuild(PackageBuildMixin, Storm):
         """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) as previous_obj:
+            super(LiveFSBuild, 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")
+
     def notify(self, extra_info=None):
         """See `IPackageBuild`."""
         if not config.builddmaster.send_build_notification:
diff --git a/lib/lp/soyuz/subscribers/__init__.py b/lib/lp/soyuz/subscribers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/soyuz/subscribers/__init__.py
diff --git a/lib/lp/soyuz/subscribers/livefsbuild.py b/lib/lp/soyuz/subscribers/livefsbuild.py
new file mode 100644
index 0000000..c74df6b
--- /dev/null
+++ b/lib/lp/soyuz/subscribers/livefsbuild.py
@@ -0,0 +1,43 @@
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for livefs builds."""
+from __future__ import absolute_import, print_function, unicode_literals
+
+
+__metaclass__ = type
+
+
+from zope.component import getUtility
+
+from lp.services.features import getFeatureFlag
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.payload import compose_webhook_payload
+from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
+
+
+def _trigger_livefs_build_webhook(livefsbuild, action):
+    if getFeatureFlag(LIVEFS_FEATURE_FLAG):
+        payload = {
+            "livefs_build": canonical_url(livefsbuild, force_local_path=True),
+            "action": action,
+            }
+        payload.update(compose_webhook_payload(
+            ILiveFSBuild, livefsbuild,
+            ["livefs", "status"]))
+        getUtility(IWebhookSet).trigger(
+            livefsbuild.livefs, "livefs:build:0.1", payload)
+
+
+def livefs_build_created(livefsbuild, event):
+    """Trigger events when a new livefs build is created."""
+    _trigger_livefs_build_webhook(livefsbuild, "created")
+
+
+def livefs_build_status_changed(livefsbuild, event):
+    """Trigger events when livefs package build statuses change."""
+    if event.edited_fields is not None:
+        if "status" in event.edited_fields:
+            _trigger_livefs_build_webhook(livefsbuild, "status-changed")
diff --git a/lib/lp/soyuz/tests/test_livefsbuild.py b/lib/lp/soyuz/tests/test_livefsbuild.py
index 987c6b1..06027f9 100644
--- a/lib/lp/soyuz/tests/test_livefsbuild.py
+++ b/lib/lp/soyuz/tests/test_livefsbuild.py
@@ -13,7 +13,14 @@ from datetime import (
     )
 from urllib2 import urlopen
 
+from fixtures import FakeLogger
 import pytz
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    MatchesDict,
+    MatchesStructure,
+    )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -31,6 +38,8 @@ from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.soyuz.enums import ArchivePurpose
 from lp.soyuz.interfaces.livefs import (
     LIVEFS_FEATURE_FLAG,
@@ -48,6 +57,7 @@ from lp.testing import (
     person_logged_in,
     TestCaseWithFactory,
     )
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import (
     LaunchpadFunctionalLayer,
     LaunchpadZopelessLayer,
@@ -162,6 +172,61 @@ class TestLiveFSBuild(TestCaseWithFactory):
         self.assertEqual(BuildStatus.CANCELLED, self.build.status)
         self.assertIsNone(self.build.buildqueue_record)
 
+    def test_updateStatus_triggers_webhooks(self):
+        # Updating the status of a SnapBuild triggers webhooks on the
+        # corresponding Snap.
+        logger = self.useFixture(FakeLogger())
+        hook = self.factory.makeWebhook(
+            target=self.build.livefs, event_types=["livefs:build:0.1"])
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        expected_payload = {
+            "livefs_build": Equals(
+                canonical_url(self.build, force_local_path=True)),
+            "action": Equals("status-changed"),
+            "livefs": Equals(
+                 canonical_url(self.build.livefs, force_local_path=True)),
+            "status": Equals("Successfully built"),
+            }
+        self.assertThat(
+            logger.output, LogsScheduledWebhooks([
+                (hook, "livefs:build:0.1", MatchesDict(expected_payload))]))
+
+        delivery = hook.deliveries.one()
+        self.assertThat(
+            delivery, MatchesStructure(
+                event_type=Equals("livefs: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))
+
+    def test_updateStatus_no_change_does_not_trigger_webhooks(self):
+        # An updateStatus call that changes details such as the revision_id
+        # but that doesn't change the build's status attribute does not
+        # trigger webhooks.
+        logger = self.useFixture(FakeLogger())
+        hook = self.factory.makeWebhook(
+            target=self.build.livefs, event_types=["livefs:build:0.1"])
+        self.build.updateStatus(BuildStatus.BUILDING)
+        expected_logs = [
+            (hook, "livefs:build:0.1", ContainsDict({
+                "action": Equals("status-changed"),
+                "status": Equals("Currently building"),
+                }))]
+        self.assertEqual(1, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+
+        self.build.updateStatus(BuildStatus.BUILDING)
+        expected_logs = [
+            (hook, "livefs:build:0.1", ContainsDict({
+                "action": Equals("status-changed"),
+                "status": Equals("Currently building"),
+                }))]
+        self.assertEqual(1, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+
     def test_cancel_in_progress(self):
         # The cancel() method for a building build leaves it in the
         # CANCELLING state.