← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add webhooks for OCI recipe builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

DB patch: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/380350
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-webhooks into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index d0832c1..4c1b396 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2620,6 +2620,7 @@ public.livefs                           = SELECT
 public.ociproject                       = SELECT
 public.ociprojectname                   = SELECT
 public.ociprojectseries                 = SELECT
+public.ocirecipe                        = SELECT
 public.person                           = SELECT
 public.product                          = SELECT
 public.snap                             = SELECT
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 483eb85..7d044f5 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -35,8 +35,10 @@ from lp.oci.interfaces.ocirecipe import (
     IOCIRecipe,
     IOCIRecipeSet,
     NoSuchOCIRecipe,
+    OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     canonical_url,
@@ -48,10 +50,11 @@ from lp.services.webapp import (
     stepthrough,
     )
 from lp.services.webapp.breadcrumb import NameBreadcrumb
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.soyuz.browser.build import get_build_by_id_str
 
 
-class OCIRecipeNavigation(Navigation):
+class OCIRecipeNavigation(WebhookTargetNavigationMixin, Navigation):
 
     usedfor = IOCIRecipe
 
@@ -77,7 +80,7 @@ class OCIRecipeNavigationMenu(NavigationMenu):
 
     facet = "overview"
 
-    links = ("admin", "edit", "delete")
+    links = ("admin", "edit", "webhooks", "delete")
 
     @enabled_with_permission("launchpad.Admin")
     def admin(self):
@@ -87,6 +90,12 @@ class OCIRecipeNavigationMenu(NavigationMenu):
     def edit(self):
         return Link("+edit", "Edit OCI recipe", icon="edit")
 
+    @enabled_with_permission('launchpad.Edit')
+    def webhooks(self):
+        return Link(
+            '+webhooks', 'Manage webhooks', icon='edit',
+            enabled=bool(getFeatureFlag(OCI_RECIPE_WEBHOOKS_FEATURE_FLAG)))
+
     @enabled_with_permission("launchpad.Edit")
     def delete(self):
         return Link("+delete", "Delete OCI recipe", icon="trash-icon")
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 7933074..540649f 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -327,10 +327,13 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             processor=processor)
         self.factory.makeBuilder(virtualized=True)
 
-    def makeOCIRecipe(self, **kwargs):
+    def makeOCIRecipe(self, oci_project=None, **kwargs):
+        if oci_project is None:
+            oci_project = self.factory.makeOCIProject(
+                pillar=self.distroseries.distribution)
         return self.factory.makeOCIRecipe(
             registrant=self.person, owner=self.person, name="recipe-name",
-            **kwargs)
+            oci_project=oci_project, **kwargs)
 
     def makeBuild(self, recipe=None, date_created=None, **kwargs):
         if recipe is None:
@@ -343,7 +346,8 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             date_created=date_created, **kwargs)
 
     def test_breadcrumb(self):
-        oci_project = self.factory.makeOCIProject()
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
         oci_project_name = oci_project.name
         oci_project_url = canonical_url(oci_project)
         recipe = self.makeOCIRecipe(oci_project=oci_project)
@@ -369,7 +373,8 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
                         text=re.compile(r"\srecipe-name\s")))))
 
     def test_index(self):
-        oci_project = self.factory.makeOCIProject()
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
         oci_project_name = oci_project.name
         oci_project_display = oci_project.display_name
         [ref] = self.factory.makeGitRefs(
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index a01178a..da8d695 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 <configure
@@ -49,6 +49,14 @@
             permission="launchpad.Admin"
             interface="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuildAdmin" />
     </class>
+    <subscriber
+        for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild
+             lazr.lifecycle.interfaces.IObjectCreatedEvent"
+        handler="lp.oci.subscribers.ocirecipebuild.oci_recipe_build_created" />
+    <subscriber
+        for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild
+             lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.oci.subscribers.ocirecipebuild.oci_recipe_build_status_changed" />
 
     <!-- OCIRecipeBuildSet -->
     <securedutility
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index e665c1e..d5bef5e 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces related to recipes for OCI Images."""
@@ -15,6 +15,7 @@ __all__ = [
     'IOCIRecipeView',
     'NoSourceForOCIRecipe',
     'NoSuchOCIRecipe',
+    'OCI_RECIPE_WEBHOOKS_FEATURE_FLAG',
     'OCIRecipeBuildAlreadyPending',
     'OCIRecipeNotOwner',
     ]
@@ -49,6 +50,10 @@ from lp.services.fields import (
     PersonChoice,
     PublicPersonChoice,
     )
+from lp.services.webhooks.interfaces import IWebhookTarget
+
+
+OCI_RECIPE_WEBHOOKS_FEATURE_FLAG = "oci.recipe.webhooks.enabled"
 
 
 @error_status(http_client.UNAUTHORIZED)
@@ -132,7 +137,7 @@ class IOCIRecipeView(Interface):
         """
 
 
-class IOCIRecipeEdit(Interface):
+class IOCIRecipeEdit(IWebhookTarget):
     """`IOCIRecipe` methods that require launchpad.Edit permission."""
 
     def destroySelf():
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 99593a3..aa3c532 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """A recipe for building Open Container Initiative images."""
@@ -61,6 +61,8 @@ from lp.services.database.stormexpr import (
     Greatest,
     NullsLast,
     )
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.model import WebhookTargetMixin
 
 
 def oci_recipe_modified(recipe, event):
@@ -73,7 +75,7 @@ def oci_recipe_modified(recipe, event):
 
 
 @implementer(IOCIRecipe)
-class OCIRecipe(Storm):
+class OCIRecipe(Storm, WebhookTargetMixin):
 
     __storm_table__ = 'OCIRecipe'
 
@@ -123,6 +125,10 @@ class OCIRecipe(Storm):
         self.date_last_modified = date_created
         self.git_ref = git_ref
 
+    @property
+    def valid_webhook_event_types(self):
+        return ["oci-recipe:build:0.1"]
+
     def destroySelf(self):
         """See `IOCIRecipe`."""
         # XXX twom 2019-11-26 This needs to expand as more build artifacts
@@ -137,6 +143,7 @@ class OCIRecipe(Storm):
         build_farm_job_ids = list(store.find(
             OCIRecipeBuild.build_farm_job_id, OCIRecipeBuild.recipe == self))
         store.find(OCIRecipeBuild, OCIRecipeBuild.recipe == self).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/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 12688cd..89b7215 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """A build record for OCI Recipes."""
@@ -59,7 +59,11 @@ from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
     )
-from lp.services.propertycache import cachedproperty
+from lp.services.propertycache import (
+    cachedproperty,
+    get_property_cache,
+    )
+from lp.services.webapp.snapshot import notify_modified
 
 
 @implementer(IOCIFile)
@@ -270,6 +274,22 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
         return self.distribution.currentseries.getDistroArchSeriesByProcessor(
             self.processor)
 
+    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(OCIRecipeBuild, 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")
+        # notify_modified evaluates all attributes mentioned in the
+        # interface, but we may then make changes that affect self.eta.
+        del get_property_cache(self).eta
+
     def notify(self, extra_info=None):
         """See `IPackageBuild`."""
         if not config.builddmaster.send_build_notification:
diff --git a/lib/lp/oci/subscribers/__init__.py b/lib/lp/oci/subscribers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/subscribers/__init__.py
diff --git a/lib/lp/oci/subscribers/ocirecipebuild.py b/lib/lp/oci/subscribers/ocirecipebuild.py
new file mode 100644
index 0000000..3e7f80d
--- /dev/null
+++ b/lib/lp/oci/subscribers/ocirecipebuild.py
@@ -0,0 +1,42 @@
+# Copyright 2016-2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for OCI recipe builds."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from zope.component import getUtility
+
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+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_oci_recipe_build_webhook(build, action):
+    if getFeatureFlag(OCI_RECIPE_WEBHOOKS_FEATURE_FLAG):
+        payload = {
+            "recipe_build": canonical_url(build, force_local_path=True),
+            "action": action,
+            }
+        payload.update(compose_webhook_payload(
+            IOCIRecipeBuild, build,
+            ["recipe", "status"]))
+        getUtility(IWebhookSet).trigger(
+            build.recipe, "oci-recipe:build:0.1", payload)
+
+
+def oci_recipe_build_created(build, event):
+    """Trigger events when a new OCI recipe build is created."""
+    _trigger_oci_recipe_build_webhook(build, "created")
+
+
+def oci_recipe_build_status_changed(build, event):
+    """Trigger events when OCI recipe build statuses change."""
+    if event.edited_fields is not None:
+        if "status" in event.edited_fields:
+            _trigger_oci_recipe_build_webhook(build, "status-changed")
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index 5b614f0..e930ccc 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -1,10 +1,18 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for OCI image building recipe functionality."""
 
 from __future__ import absolute_import, print_function, unicode_literals
 
+from fixtures import FakeLogger
+from storm.exceptions import LostObjectError
+from testtools.matchers import (
+    Equals,
+    MatchesDict,
+    MatchesStructure,
+    )
+import transaction
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
@@ -15,21 +23,27 @@ from lp.oci.interfaces.ocirecipe import (
     IOCIRecipeSet,
     NoSourceForOCIRecipe,
     NoSuchOCIRecipe,
+    OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     OCIRecipeBuildAlreadyPending,
     OCIRecipeNotOwner,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.services.config import config
 from lp.services.database.constants import (
     ONE_DAY_AGO,
     UTC_NOW,
     )
 from lp.services.database.sqlbase import flush_database_caches
+from lp.services.features.testing import FeatureFixture
+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,
     TestCaseWithFactory,
     )
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import DatabaseFunctionalLayer
 
 
@@ -80,6 +94,40 @@ class TestOCIRecipe(TestCaseWithFactory):
             ocirecipe.requestBuild,
             ocirecipe.owner, oci_arch)
 
+    def test_requestBuild_triggers_webhooks(self):
+        # Requesting a build triggers webhooks.
+        logger = self.useFixture(FakeLogger())
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            recipe = self.factory.makeOCIRecipe()
+            oci_arch = self.factory.makeOCIRecipeArch(recipe=recipe)
+            hook = self.factory.makeWebhook(
+                target=recipe, event_types=["oci-recipe:build:0.1"])
+            build = recipe.requestBuild(recipe.owner, oci_arch)
+
+        expected_payload = {
+            "recipe_build": Equals(
+                canonical_url(build, force_local_path=True)),
+            "action": Equals("created"),
+            "recipe": Equals(canonical_url(recipe, 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("oci-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, "oci-recipe:build:0.1",
+                         MatchesDict(expected_payload))]))
+
     def test_destroySelf(self):
         oci_recipe = self.factory.makeOCIRecipe()
         build_ids = []
@@ -94,6 +142,17 @@ class TestOCIRecipe(TestCaseWithFactory):
         for build_id in build_ids:
             self.assertIsNone(getUtility(IOCIRecipeBuildSet).getByID(build_id))
 
+    def test_related_webhooks_deleted(self):
+        owner = self.factory.makePerson()
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            recipe = self.factory.makeOCIRecipe(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")
+
     def test_getBuilds(self):
         # Test the various getBuilds methods.
         oci_recipe = self.factory.makeOCIRecipe()
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 60aaf93..fcce007 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for OCI image building recipe functionality."""
@@ -7,8 +7,14 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 from datetime import timedelta
 
+from fixtures import FakeLogger
 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
 
@@ -17,18 +23,24 @@ from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
 from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.oci.interfaces.ocirecipebuild import (
     IOCIRecipeBuild,
     IOCIRecipeBuildSet,
     )
 from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet
 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 (
     admin_logged_in,
     StormStatementRecorder,
     TestCaseWithFactory,
     )
+from lp.testing.dbuser import dbuser
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
@@ -111,6 +123,63 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
         self.assertIsNotNone(bq.processor)
         self.assertEqual(bq, self.build.buildqueue_record)
 
+    def test_updateStatus_triggers_webhooks(self):
+        # Updating the status of an OCIRecipeBuild triggers webhooks on the
+        # corresponding OCIRecipe.
+        logger = self.useFixture(FakeLogger())
+        hook = self.factory.makeWebhook(
+            target=self.build.recipe, event_types=["oci-recipe:build:0.1"])
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            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)),
+            "status": Equals("Successfully built"),
+            }
+        self.assertThat(
+            logger.output, LogsScheduledWebhooks([
+                (hook, "oci-recipe:build:0.1",
+                 MatchesDict(expected_payload))]))
+
+        delivery = hook.deliveries.one()
+        self.assertThat(
+            delivery, MatchesStructure(
+                event_type=Equals("oci-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))
+
+    def test_updateStatus_no_change_does_not_trigger_webhooks(self):
+        # An updateStatus call that doesn't change the build's status
+        # attribute does not trigger webhooks.
+        logger = self.useFixture(FakeLogger())
+        hook = self.factory.makeWebhook(
+            target=self.build.recipe, event_types=["oci-recipe:build:0.1"])
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            self.build.updateStatus(BuildStatus.BUILDING)
+        expected_logs = [
+            (hook, "oci-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)
+        expected_logs = [
+            (hook, "oci-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))
+
     def test_eta(self):
         # OCIRecipeBuild.eta returns a non-None value when it should, or
         # None when there's no start time.
@@ -193,16 +262,20 @@ class TestOCIRecipeBuildSet(TestCaseWithFactory):
         self.assertTrue(target.virtualized)
 
     def test_virtualized_processor_requires(self):
-        distro_arch_series = self.factory.makeDistroArchSeries()
-        distro_arch_series.processor.supports_nonvirtualized = False
         recipe = self.factory.makeOCIRecipe(require_virtualized=False)
+        distro_arch_series = self.factory.makeDistroArchSeries(
+            distroseries=self.factory.makeDistroSeries(
+                distribution=recipe.oci_project.distribution))
+        distro_arch_series.processor.supports_nonvirtualized = False
         target = self.factory.makeOCIRecipeBuild(
             distro_arch_series=distro_arch_series, recipe=recipe)
         self.assertTrue(target.virtualized)
 
     def test_virtualized_no_support(self):
         recipe = self.factory.makeOCIRecipe(require_virtualized=False)
-        distro_arch_series = self.factory.makeDistroArchSeries()
+        distro_arch_series = self.factory.makeDistroArchSeries(
+            distroseries=self.factory.makeDistroSeries(
+                distribution=recipe.oci_project.distribution))
         distro_arch_series.processor.supports_nonvirtualized = True
         target = self.factory.makeOCIRecipeBuild(
             recipe=recipe, distro_arch_series=distro_arch_series)
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index 1a0250e..d4d20e3 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Webhook interfaces."""
@@ -76,6 +76,7 @@ WEBHOOK_EVENT_TYPES = {
     "git:push:0.1": "Git push",
     "livefs:build:0.1": "Live filesystem build",
     "merge-proposal:0.1": "Merge proposal",
+    "oci-recipe:build:0.1": "OCI recipe build",
     "snap:build:0.1": "Snap build",
     }
 
diff --git a/lib/lp/services/webhooks/model.py b/lib/lp/services/webhooks/model.py
index 440ce26..cb42ce9 100644
--- a/lib/lp/services/webhooks/model.py
+++ b/lib/lp/services/webhooks/model.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -107,6 +107,9 @@ class Webhook(StormBase):
     livefs_id = Int(name='livefs')
     livefs = Reference(livefs_id, 'LiveFS.id')
 
+    oci_recipe_id = Int(name='oci_recipe')
+    oci_recipe = Reference(oci_recipe_id, 'OCIRecipe.id')
+
     registrant_id = Int(name='registrant', allow_none=False)
     registrant = Reference(registrant_id, 'Person.id')
     date_created = DateTime(tzinfo=utc, allow_none=False)
@@ -128,6 +131,8 @@ class Webhook(StormBase):
             return self.snap
         elif self.livefs is not None:
             return self.livefs
+        elif self.oci_recipe is not None:
+            return self.oci_recipe
         else:
             raise AssertionError("No target.")
 
@@ -181,6 +186,7 @@ class WebhookSet:
             secret):
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
+        from lp.oci.interfaces.ocirecipe import IOCIRecipe
         from lp.snappy.interfaces.snap import ISnap
         from lp.soyuz.interfaces.livefs import ILiveFS
 
@@ -193,6 +199,8 @@ class WebhookSet:
             hook.snap = target
         elif ILiveFS.providedBy(target):
             hook.livefs = target
+        elif IOCIRecipe.providedBy(target):
+            hook.oci_recipe = target
         else:
             raise AssertionError("Unsupported target: %r" % (target,))
         hook.registrant = registrant
@@ -216,6 +224,7 @@ class WebhookSet:
     def findByTarget(self, target):
         from lp.code.interfaces.branch import IBranch
         from lp.code.interfaces.gitrepository import IGitRepository
+        from lp.oci.interfaces.ocirecipe import IOCIRecipe
         from lp.snappy.interfaces.snap import ISnap
         from lp.soyuz.interfaces.livefs import ILiveFS
 
@@ -227,6 +236,8 @@ class WebhookSet:
             target_filter = Webhook.snap == target
         elif ILiveFS.providedBy(target):
             target_filter = Webhook.livefs == target
+        elif IOCIRecipe.providedBy(target):
+            target_filter = Webhook.oci_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 39021bd..95ff526 100644
--- a/lib/lp/services/webhooks/tests/test_browser.py
+++ b/lib/lp/services/webhooks/tests/test_browser.py
@@ -16,6 +16,7 @@ from testtools.matchers import (
 import transaction
 from zope.component import getUtility
 
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.interfaces import IPlacelessAuthUtility
 from lp.services.webapp.publisher import canonical_url
@@ -132,6 +133,27 @@ class LiveFSTestHelpers:
         return [obj]
 
 
+class OCIRecipeTestHelpers:
+    event_type = "oci-recipe:build:0.1"
+    expected_event_types = [
+        ("oci-recipe:build:0.1", "OCI recipe build"),
+        ]
+
+    def setUp(self):
+        super(OCIRecipeTestHelpers, self).setUp()
+
+    def makeTarget(self):
+        self.useFixture(FeatureFixture({
+            'webhooks.new.enabled': 'true',
+            OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: 'on',
+            }))
+        owner = self.factory.makePerson()
+        return self.factory.makeOCIRecipe(registrant=owner, owner=owner)
+
+    def getTraversalStack(self, obj):
+        return [obj]
+
+
 class WebhookTargetViewTestHelpers:
 
     def setUp(self):
@@ -257,6 +279,12 @@ class TestWebhooksViewLiveFS(
     pass
 
 
+class TestWebhooksViewOCIRecipe(
+    TestWebhooksViewBase, OCIRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class TestWebhookAddViewBase(WebhookTargetViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -361,6 +389,12 @@ class TestWebhookAddViewLiveFS(
     pass
 
 
+class TestWebhookAddViewOCIRecipe(
+    TestWebhookAddViewBase, OCIRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class WebhookViewTestHelpers:
 
     def setUp(self):
@@ -469,6 +503,12 @@ class TestWebhookViewLiveFS(
     pass
 
 
+class TestWebhookViewOCIRecipe(
+    TestWebhookViewBase, OCIRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
+
+
 class TestWebhookDeleteViewBase(WebhookViewTestHelpers):
 
     layer = DatabaseFunctionalLayer
@@ -525,3 +565,9 @@ class TestWebhookDeleteViewLiveFS(
     TestWebhookDeleteViewBase, LiveFSTestHelpers, TestCaseWithFactory):
 
     pass
+
+
+class TestWebhookDeleteViewOCIRecipe(
+    TestWebhookDeleteViewBase, OCIRecipeTestHelpers, TestCaseWithFactory):
+
+    pass
diff --git a/lib/lp/services/webhooks/tests/test_job.py b/lib/lp/services/webhooks/tests/test_job.py
index 762f7ff..caf81ce 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-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for `WebhookJob`s."""
@@ -37,6 +37,7 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app import versioninfo
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.services.database.interfaces import IStore
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
@@ -354,6 +355,17 @@ class TestWebhookDeliveryJob(TestCaseWithFactory):
             "<WebhookDeliveryJob for webhook %d on %r>" % (hook.id, livefs),
             repr(job))
 
+    def test_oci_recipe__repr__(self):
+        # `WebhookDeliveryJob` objects for OCI recipes have an informative
+        # __repr__.
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            recipe = self.factory.makeOCIRecipe()
+        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 cd43758..e8810f2 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-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 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,7 @@ from zope.security.checker import getChecker
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.registry.enums import BranchSharingPolicy
 from lp.services.database.interfaces import IStore
 from lp.services.features.testing import FeatureFixture
@@ -414,3 +415,16 @@ class TestWebhookSetLiveFS(TestWebhookSetBase, TestCaseWithFactory):
                              LIVEFS_WEBHOOKS_FEATURE_FLAG: "on"}):
             return self.factory.makeLiveFS(registrant=owner,
                                            owner=owner, **kwargs)
+
+
+class TestWebhookSetOCIRecipe(TestWebhookSetBase, TestCaseWithFactory):
+
+    event_type = 'oci-recipe:build:0.1'
+
+    def makeTarget(self, owner=None, **kwargs):
+        if owner is None:
+            owner = self.factory.makePerson()
+
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            return self.factory.makeOCIRecipe(
+                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 19af1bc..dafc6d0 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-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 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,7 @@ from testtools.matchers import (
     )
 from zope.security.proxy import removeSecurityProxy
 
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.soyuz.interfaces.livefs import (
@@ -394,3 +395,13 @@ class TestWebhookTargetLiveFS(TestWebhookTargetBase, TestCaseWithFactory):
         with FeatureFixture({LIVEFS_FEATURE_FLAG: "on",
                              LIVEFS_WEBHOOKS_FEATURE_FLAG: "on"}):
             return self.factory.makeLiveFS(registrant=owner, owner=owner)
+
+
+class TestWebhookTargetOCIRecipe(TestWebhookTargetBase, TestCaseWithFactory):
+
+    event_type = 'oci-recipe:build:0.1'
+
+    def makeTarget(self):
+        owner = self.factory.makePerson()
+        with FeatureFixture({OCI_RECIPE_WEBHOOKS_FEATURE_FLAG: "on"}):
+            return self.factory.makeOCIRecipe(registrant=owner, owner=owner)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 6a65466..075eb87 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4992,7 +4992,12 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         if requester is None:
             requester = self.makePerson()
         if distro_arch_series is None:
-            distroseries = self.makeDistroSeries(status=SeriesStatus.CURRENT)
+            if recipe is not None:
+                distribution = recipe.oci_project.distribution
+            else:
+                distribution = None
+            distroseries = self.makeDistroSeries(
+                distribution=distribution, status=SeriesStatus.CURRENT)
             processor = getUtility(IProcessorSet).getByName("386")
             distro_arch_series = self.makeDistroArchSeries(
                 distroseries=distroseries, architecturetag="i386",