← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-webhooks into launchpad:master.

Commit message:
Add charm recipe webhooks

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/407926
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-webhooks into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index 8bbaba6..5bb2a59 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -50,6 +50,7 @@ from lp.charms.interfaces.charmhubclient import (
     )
 from lp.charms.interfaces.charmrecipe import (
     CannotAuthorizeCharmhubUploads,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
     ICharmRecipe,
     ICharmRecipeSet,
     NoSuchCharmRecipe,
@@ -59,6 +60,7 @@ from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.code.interfaces.gitref import IGitRef
 from lp.registry.interfaces.personproduct import IPersonProductFactory
 from lp.registry.interfaces.product import IProduct
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
 from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import (
@@ -78,6 +80,7 @@ from lp.services.webapp.breadcrumb import (
     )
 from lp.services.webapp.candid import request_candid_discharge
 from lp.services.webapp.interfaces import ICanonicalUrlData
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
 from lp.soyuz.browser.build import get_build_by_id_str
 
@@ -101,7 +104,7 @@ class CharmRecipeURL:
         return "+charm/%s" % self.recipe.name
 
 
-class CharmRecipeNavigation(Navigation):
+class CharmRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
     usedfor = ICharmRecipe
 
     @stepthrough("+build-request")
@@ -138,7 +141,7 @@ class CharmRecipeNavigationMenu(NavigationMenu):
 
     facet = "overview"
 
-    links = ("admin", "edit", "authorize", "delete")
+    links = ("admin", "edit", "webhooks", "authorize", "delete")
 
     @enabled_with_permission("launchpad.Admin")
     def admin(self):
@@ -149,6 +152,14 @@ class CharmRecipeNavigationMenu(NavigationMenu):
         return Link("+edit", "Edit charm recipe", icon="edit")
 
     @enabled_with_permission("launchpad.Edit")
+    def webhooks(self):
+        return Link(
+            "+webhooks", "Manage webhooks", icon="edit",
+            enabled=(
+                bool(getFeatureFlag(CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG)) and
+                bool(getFeatureFlag("webhooks.new.enabled"))))
+
+    @enabled_with_permission("launchpad.Edit")
     def authorize(self):
         if self.context.store_secrets:
             text = "Reauthorize Charmhub uploads"
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 397c2eb..122c79b 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -68,6 +68,14 @@
             permission="launchpad.Admin"
             interface="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuildAdmin" />
     </class>
+    <subscriber
+        for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild
+             lazr.lifecycle.interfaces.IObjectCreatedEvent"
+        handler="lp.charms.subscribers.charmrecipebuild.charm_recipe_build_created" />
+    <subscriber
+        for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild
+             lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.charms.subscribers.charmrecipebuild.charm_recipe_build_modified" />
 
     <!-- CharmRecipeBuildSet -->
     <securedutility
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 46195b9..0990d83 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -13,6 +13,7 @@ __all__ = [
     "CHARM_RECIPE_ALLOW_CREATE",
     "CHARM_RECIPE_BUILD_DISTRIBUTION",
     "CHARM_RECIPE_PRIVATE_FEATURE_FLAG",
+    "CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG",
     "CharmRecipeBuildAlreadyPending",
     "CharmRecipeBuildDisallowedArchitecture",
     "CharmRecipeBuildRequestStatus",
@@ -72,12 +73,14 @@ from lp.services.fields import (
     PersonChoice,
     PublicPersonChoice,
     )
+from lp.services.webhooks.interfaces import IWebhookTarget
 from lp.snappy.validators.channels import channels_validator
 
 
 CHARM_RECIPE_ALLOW_CREATE = "charm.recipe.create.enabled"
 CHARM_RECIPE_PRIVATE_FEATURE_FLAG = "charm.recipe.allow_private"
 CHARM_RECIPE_BUILD_DISTRIBUTION = "charm.default_build_distribution"
+CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG = "charm.recipe.webhooks.enabled"
 
 
 @error_status(http_client.UNAUTHORIZED)
@@ -381,7 +384,7 @@ class ICharmRecipeView(Interface):
         value_type=Reference(schema=Interface), readonly=True)
 
 
-class ICharmRecipeEdit(Interface):
+class ICharmRecipeEdit(IWebhookTarget):
     """`ICharmRecipe` methods that require launchpad.Edit permission."""
 
     def beginAuthorization():
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 1907347..63f21ff 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -135,6 +135,8 @@ from lp.services.propertycache import (
     get_property_cache,
     )
 from lp.services.webapp.candid import extract_candid_caveat
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.model import WebhookTargetMixin
 from lp.soyuz.model.distroarchseries import (
     DistroArchSeries,
     PocketChroot,
@@ -223,7 +225,7 @@ class CharmRecipeBuildRequest:
 
 
 @implementer(ICharmRecipe)
-class CharmRecipe(StormBase):
+class CharmRecipe(StormBase, WebhookTargetMixin):
     """See `ICharmRecipe`."""
 
     __storm_table__ = "CharmRecipe"
@@ -442,7 +444,7 @@ class CharmRecipe(StormBase):
             and self._isBuildableArchitectureAllowed(das))
 
     def getAllowedArchitectures(self):
-        """See `IOCIRecipe`."""
+        """See `ICharmRecipe`."""
         store = Store.of(self)
         origin = [
             DistroArchSeries,
@@ -700,6 +702,10 @@ class CharmRecipe(StormBase):
             self.store_secrets is not None and
             "exchanged_encrypted" in self.store_secrets)
 
+    @property
+    def valid_webhook_event_types(self):
+        return ["charm-recipe:build:0.1"]
+
     def destroySelf(self):
         """See `ICharmRecipe`."""
         store = IStore(self)
@@ -728,6 +734,7 @@ class CharmRecipe(StormBase):
             [CharmRecipeJob.job_id],
             And(CharmRecipeJob.job == Job.id, CharmRecipeJob.recipe == self))
         store.find(Job, Job.id.is_in(affected_jobs)).remove()
+        getUtility(IWebhookSet).delete(self.webhooks)
         store.remove(self)
         store.find(
             BuildFarmJob, BuildFarmJob.id.is_in(build_farm_job_ids)).remove()
diff --git a/lib/lp/charms/subscribers/__init__.py b/lib/lp/charms/subscribers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/charms/subscribers/__init__.py
diff --git a/lib/lp/charms/subscribers/charmrecipebuild.py b/lib/lp/charms/subscribers/charmrecipebuild.py
new file mode 100644
index 0000000..ebfa796
--- /dev/null
+++ b/lib/lp/charms/subscribers/charmrecipebuild.py
@@ -0,0 +1,42 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for charm recipe builds."""
+
+__metaclass__ = type
+
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    )
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+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
+
+
+def _trigger_charm_recipe_build_webhook(build, action):
+    if getFeatureFlag(CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG):
+        payload = {
+            "recipe_build": canonical_url(build, force_local_path=True),
+            "action": action,
+            }
+        payload.update(compose_webhook_payload(
+            ICharmRecipeBuild, build,
+            ["recipe", "build_request", "status"]))
+        getUtility(IWebhookSet).trigger(
+            build.recipe, "charm-recipe:build:0.1", payload)
+
+
+def charm_recipe_build_created(build, event):
+    """Trigger events when a new charm recipe build is created."""
+    _trigger_charm_recipe_build_webhook(build, "created")
+
+
+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:
+            _trigger_charm_recipe_build_webhook(build, "status-changed")
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index af961e7..f1e2443 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -9,10 +9,12 @@ import base64
 import json
 from textwrap import dedent
 
+from fixtures import FakeLogger
 from nacl.public import PrivateKey
 from pymacaroons import Macaroon
 from pymacaroons.serializers import JsonSerializer
 import responses
+from storm.exceptions import LostObjectError
 from storm.locals import Store
 from testtools.matchers import (
     AfterPreprocessing,
@@ -48,6 +50,7 @@ from lp.charms.interfaces.charmrecipe import (
     CannotAuthorizeCharmhubUploads,
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_BUILD_DISTRIBUTION,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
     CharmRecipeBuildAlreadyPending,
     CharmRecipeBuildDisallowedArchitecture,
     CharmRecipeBuildRequestStatus,
@@ -83,7 +86,9 @@ from lp.services.database.sqlbase import (
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
+from lp.services.webapp.publisher import canonical_url
 from lp.services.webapp.snapshot import notify_modified
+from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.testing import (
     admin_logged_in,
     person_logged_in,
@@ -355,6 +360,45 @@ class TestCharmRecipe(TestCaseWithFactory):
             recipe.require_virtualized = False
         recipe.requestBuild(build_request, das)
 
+    def test_requestBuild_triggers_webhooks(self):
+        # Requesting a build triggers webhooks.
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+            }))
+        logger = self.useFixture(FakeLogger())
+        recipe = self.factory.makeCharmRecipe()
+        das = self.makeBuildableDistroArchSeries()
+        build_request = self.factory.makeCharmRecipeBuildRequest(recipe=recipe)
+        hook = self.factory.makeWebhook(
+            target=recipe, event_types=["charm-recipe:build:0.1"])
+        build = recipe.requestBuild(build_request, das)
+        expected_payload = {
+            "recipe_build": Equals(
+                canonical_url(build, force_local_path=True)),
+            "action": Equals("created"),
+            "recipe": Equals(canonical_url(recipe, force_local_path=True)),
+            "build_request": Equals(
+                canonical_url(build_request, force_local_path=True)),
+            "status": Equals("Needs building"),
+            }
+        with person_logged_in(recipe.owner):
+            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))]))
+
     def test_requestBuilds(self):
         # requestBuilds schedules a job and returns a corresponding
         # CharmRecipeBuildRequest.
@@ -553,6 +597,47 @@ class TestCharmRecipe(TestCaseWithFactory):
         self.assertRequestedBuildsMatch(
             builds, job, "20.04", ["avr", "riscv64"], job.channels)
 
+    def test_requestBuildsFromJob_triggers_webhooks(self):
+        # requestBuildsFromJob triggers webhooks, and the payload includes a
+        # link to the build request.
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_BUILD_DISTRIBUTION: "ubuntu",
+            "charm.default_build_series.ubuntu": "20.04",
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+            }))
+        self.useFixture(GitHostingFixture(blob="name: foo\n"))
+        logger = self.useFixture(FakeLogger())
+        job = self.makeRequestBuildsJob("20.04", ["mips64el", "riscv64"])
+        hook = self.factory.makeWebhook(
+            target=job.recipe, event_types=["charm-recipe:build:0.1"])
+        with person_logged_in(job.requester):
+            builds = job.recipe.requestBuildsFromJob(
+                job.build_request, channels=removeSecurityProxy(job.channels))
+            self.assertEqual(2, len(builds))
+            payload_matchers = [
+                MatchesDict({
+                    "recipe_build": Equals(canonical_url(
+                        build, force_local_path=True)),
+                    "action": Equals("created"),
+                    "recipe": Equals(canonical_url(
+                        job.recipe, force_local_path=True)),
+                    "build_request": Equals(canonical_url(
+                        job.build_request, force_local_path=True)),
+                    "status": Equals("Needs building"),
+                    })
+                for build in builds]
+            self.assertThat(hook.deliveries, MatchesSetwise(*(
+                MatchesStructure(
+                    event_type=Equals("charm-recipe:build:0.1"),
+                    payload=payload_matcher)
+                for payload_matcher in payload_matchers)))
+            self.assertThat(
+                logger.output,
+                LogsScheduledWebhooks([
+                    (hook, "charm-recipe:build:0.1", payload_matcher)
+                    for payload_matcher in payload_matchers]))
+
     def test_delete_without_builds(self):
         # A charm recipe with no builds can be deleted.
         owner = self.factory.makePerson()
@@ -566,6 +651,16 @@ class TestCharmRecipe(TestCaseWithFactory):
         self.assertFalse(
             getUtility(ICharmRecipeSet).exists(owner, project, "condemned"))
 
+    def test_related_webhooks_deleted(self):
+        owner = self.factory.makePerson()
+        recipe = self.factory.makeCharmRecipe(registrant=owner, owner=owner)
+        webhook = self.factory.makeWebhook(target=recipe)
+        with person_logged_in(recipe.owner):
+            webhook.ping()
+            recipe.destroySelf()
+            transaction.commit()
+            self.assertRaises(LostObjectError, getattr, webhook, "target")
+
 
 class TestCharmRecipeAuthorization(TestCaseWithFactory):
 
diff --git a/lib/lp/charms/tests/test_charmrecipebuild.py b/lib/lp/charms/tests/test_charmrecipebuild.py
index 0bc600a..e81bd0d 100644
--- a/lib/lp/charms/tests/test_charmrecipebuild.py
+++ b/lib/lp/charms/tests/test_charmrecipebuild.py
@@ -10,9 +10,15 @@ from datetime import (
     timedelta,
     )
 
+from fixtures import FakeLogger
 import pytz
 import six
-from testtools.matchers import Equals
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    MatchesDict,
+    MatchesStructure,
+    )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -25,6 +31,7 @@ from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.charms.interfaces.charmrecipe import (
     CHARM_RECIPE_ALLOW_CREATE,
     CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
     )
 from lp.charms.interfaces.charmrecipebuild import (
     ICharmRecipeBuild,
@@ -38,11 +45,14 @@ from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import clear_property_cache
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.testing import (
     person_logged_in,
     StormStatementRecorder,
     TestCaseWithFactory,
     )
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import LaunchpadZopelessLayer
 from lp.testing.mail_helpers import pop_notifications
 from lp.testing.matchers import HasQueryCount
@@ -254,6 +264,76 @@ class TestCharmRecipeBuild(TestCaseWithFactory):
             BuildStatus.BUILDING, slave_status={"revision_id": "dummy"})
         self.assertEqual("dummy", self.build.revision_id)
 
+    def test_updateStatus_triggers_webhooks(self):
+        # Updating the status of a CharmRecipeBuild triggers webhooks on the
+        # corresponding CharmRecipe.
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+            }))
+        logger = self.useFixture(FakeLogger())
+        hook = self.factory.makeWebhook(
+            target=self.build.recipe, event_types=["charm-recipe:build:0.1"])
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        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"),
+            }
+        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))]))
+
+    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.
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+            }))
+        logger = self.useFixture(FakeLogger())
+        hook = self.factory.makeWebhook(
+            target=self.build.recipe, event_types=["charm-recipe:build:0.1"])
+        builder = self.factory.makeBuilder()
+        self.build.updateStatus(BuildStatus.BUILDING)
+        expected_logs = [
+            (hook, "charm-recipe: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, builder=builder,
+            slave_status={"revision_id": "1"})
+        self.assertEqual(1, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+        self.build.updateStatus(BuildStatus.UPLOADING)
+        expected_logs.append(
+            (hook, "charm-recipe:build:0.1", ContainsDict({
+                "action": Equals("status-changed"),
+                "status": Equals("Uploading build"),
+                })))
+        self.assertEqual(2, hook.deliveries.count())
+        self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
+
     def test_notify_fullybuilt(self):
         # notify does not send mail when a recipe build completes normally.
         build = self.factory.makeCharmRecipeBuild(
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index 8b7c009..4e8376a 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Webhook interfaces."""
@@ -74,6 +74,7 @@ from lp.services.webservice.apihelpers import (
 
 WEBHOOK_EVENT_TYPES = {
     "bzr:push:0.1": "Bazaar push",
+    "charm-recipe:build:0.1": "Charm recipe build",
     "git:push:0.1": "Git push",
     "livefs:build:0.1": "Live filesystem build",
     "merge-proposal:0.1": "Merge proposal",
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index 7c6c4c6..24f6ceb 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -116,6 +116,9 @@ class Webhook(StormBase):
     oci_recipe_id = Int(name='oci_recipe')
     oci_recipe = Reference(oci_recipe_id, 'OCIRecipe.id')
 
+    charm_recipe_id = Int(name='charm_recipe')
+    charm_recipe = Reference(charm_recipe_id, 'CharmRecipe.id')
+
     registrant_id = Int(name='registrant', allow_none=False)
     registrant = Reference(registrant_id, 'Person.id')
     date_created = DateTime(tzinfo=utc, allow_none=False)
@@ -139,6 +142,8 @@ class Webhook(StormBase):
             return self.livefs
         elif self.oci_recipe is not None:
             return self.oci_recipe
+        elif self.charm_recipe is not None:
+            return self.charm_recipe
         else:
             raise AssertionError("No target.")
 
@@ -190,6 +195,7 @@ class WebhookSet:
 
     def new(self, target, registrant, delivery_url, event_types, active,
             secret):
+        from lp.charms.interfaces.charmrecipe import ICharmRecipe
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
         from lp.oci.interfaces.ocirecipe import IOCIRecipe
@@ -207,6 +213,8 @@ class WebhookSet:
             hook.livefs = target
         elif IOCIRecipe.providedBy(target):
             hook.oci_recipe = target
+        elif ICharmRecipe.providedBy(target):
+            hook.charm_recipe = target
         else:
             raise AssertionError("Unsupported target: %r" % (target,))
         hook.registrant = registrant
@@ -228,6 +236,7 @@ class WebhookSet:
         return IStore(Webhook).get(Webhook, id)
 
     def findByTarget(self, target):
+        from lp.charms.interfaces.charmrecipe import ICharmRecipe
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
         from lp.oci.interfaces.ocirecipe import IOCIRecipe
@@ -244,6 +253,8 @@ class WebhookSet:
             target_filter = Webhook.livefs == target
         elif IOCIRecipe.providedBy(target):
             target_filter = Webhook.oci_recipe == target
+        elif ICharmRecipe.providedBy(target):
+            target_filter = Webhook.charm_recipe == 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 6157d0c..d011594 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Unit tests for Webhook views."""
@@ -16,6 +16,10 @@ from testtools.matchers import (
 import transaction
 from zope.component import getUtility
 
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    )
 from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -158,6 +162,29 @@ class OCIRecipeTestHelpers:
         return [obj]
 
 
+class CharmRecipeTestHelpers:
+
+    event_type = "charm-recipe:build:0.1"
+    expected_event_types = [
+        ("charm-recipe:build:0.1", "Charm recipe build"),
+        ]
+
+    def setUp(self):
+        super().setUp()
+
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({
+            'webhooks.new.enabled': 'true',
+            CHARM_RECIPE_ALLOW_CREATE: 'on',
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: 'on',
+            }))
+        owner = self.factory.makePerson()
+        return self.factory.makeCharmRecipe(registrant=owner, owner=owner)
+
+    def getTraversalStack(self, obj):
+        return [obj]
+
+
 class WebhookTargetViewTestHelpers:
 
     def setUp(self):
@@ -289,6 +316,12 @@ class TestWebhooksViewOCIRecipe(
     pass
 
 
+class TestWebhooksViewCharmRecipe(
+    TestWebhooksViewBase, CharmRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -399,6 +432,12 @@ class TestWebhookAddViewOCIRecipe(
     pass
 
 
+class TestWebhookAddViewCharmRecipe(
+    TestWebhookAddViewBase, CharmRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class WebhookViewTestHelpers:
 
     def setUp(self):
@@ -513,6 +552,12 @@ class TestWebhookViewOCIRecipe(
     pass
 
 
+class TestWebhookViewCharmRecipe(
+    TestWebhookViewBase, CharmRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class TestWebhookDeleteViewBase(WebhookViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -575,3 +620,9 @@ class TestWebhookDeleteViewOCIRecipe(
     TestWebhookDeleteViewBase, OCIRecipeTestHelpers, TestCaseWithFactory):
 
     pass
+
+
+class TestWebhookDeleteViewCharmRecipe(
+    TestWebhookDeleteViewBase, CharmRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
diff --git a/lib/lp/services/webhooks/tests/test_job.py b/lib/lp/services/webhooks/tests/test_job.py
index 7700a46..4c46afb 100644
--- a/lib/lp/services/webhooks/tests/test_job.py
+++ b/lib/lp/services/webhooks/tests/test_job.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for `WebhookJob`s."""
@@ -41,6 +41,10 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app import versioninfo
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    )
 from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -374,6 +378,18 @@ class TestWebhookDeliveryJob(TestCaseWithFactory):
             "<WebhookDeliveryJob for webhook %d on %r>" % (hook.id, recipe),
             repr(job))
 
+    def test_charm_recipe__repr__(self):
+        # `WebhookDeliveryJob` objects for charm recipes have an informative
+        # __repr__.
+        with FeatureFixture({CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+                             CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            recipe = self.factory.makeCharmRecipe()
+        hook = self.factory.makeWebhook(target=recipe)
+        job = WebhookDeliveryJob.create(hook, 'test', payload={'foo': 'bar'})
+        self.assertEqual(
+            "<WebhookDeliveryJob for webhook %d on %r>" % (hook.id, recipe),
+            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 cc0c5a5..62e42b4 100644
--- a/lib/lp/services/webhooks/tests/test_model.py
+++ b/lib/lp/services/webhooks/tests/test_model.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from storm.store import Store
@@ -13,6 +13,10 @@ from zope.security.checker import getChecker
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    )
 from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -434,3 +438,17 @@ class TestWebhookSetOCIRecipe(TestWebhookSetBase, TestCaseWithFactory):
                              OCI_RECIPE_ALLOW_CREATE: 'on'}):
             return self.factory.makeOCIRecipe(
                 registrant=owner, owner=owner, **kwargs)
+
+
+class TestWebhookSetCharmRecipe(TestWebhookSetBase, TestCaseWithFactory):
+
+    event_type = 'charm-recipe:build:0.1'
+
+    def makeTarget(self, owner=None, **kwargs):
+        if owner is None:
+            owner = self.factory.makePerson()
+
+        with FeatureFixture({CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+                             CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            return self.factory.makeCharmRecipe(
+                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 42408d5..249657b 100644
--- a/lib/lp/services/webhooks/tests/test_webservice.py
+++ b/lib/lp/services/webhooks/tests/test_webservice.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the webhook webservice objects."""
@@ -21,6 +21,10 @@ from testtools.matchers import (
     )
 from zope.security.proxy import removeSecurityProxy
 
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    )
 from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
@@ -408,3 +412,14 @@ class TestWebhookTargetOCIRecipe(TestWebhookTargetBase, TestCaseWithFactory):
         with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
                              OCI_RECIPE_ALLOW_CREATE: 'on'}):
             return self.factory.makeOCIRecipe(registrant=owner, owner=owner)
+
+
+class TestWebhookTargetCharmRecipe(TestWebhookTargetBase, TestCaseWithFactory):
+
+    event_type = 'charm-recipe:build:0.1'
+
+    def makeTarget(self):
+        owner = self.factory.makePerson()
+        with FeatureFixture({CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+                             CHARM_RECIPE_ALLOW_CREATE: "on"}):
+            return self.factory.makeCharmRecipe(registrant=owner, owner=owner)