launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27480
[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)