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",
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp