← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~vaishnavi-asawale/launchpad:binary-build-status-webhooks into launchpad:master

 

Vaishnavi Asawale has proposed merging ~vaishnavi-asawale/launchpad:binary-build-status-webhooks into launchpad:master.

Commit message:
Add webhooks for binary builds
    
Add subscriber and handler functions to trigger webhooks when
a binary build status changes to FULLYBUILT/FAILEDTOBUILD/SUPERSEDED.
Add tests to ensure webhooks are triggered/ not triggered based on whether
the build status changes and the feature flag to configure webhooks
on archives is on.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~vaishnavi-asawale/launchpad/+git/launchpad/+merge/494779
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~vaishnavi-asawale/launchpad:binary-build-status-webhooks into launchpad:master.
diff --git a/lib/lp/archiveuploader/nascentupload.py b/lib/lp/archiveuploader/nascentupload.py
index 9bee114..2a6eb11 100644
--- a/lib/lp/archiveuploader/nascentupload.py
+++ b/lib/lp/archiveuploader/nascentupload.py
@@ -826,6 +826,7 @@ class NascentUpload:
                 changes_file_object=changes_file_object,
                 logger=self.logger,
             )
+        self.queue_root.setRejected()
 
     def _createQueueEntry(self):
         """Return a PackageUpload object."""
diff --git a/lib/lp/buildmaster/model/buildfarmjob.py b/lib/lp/buildmaster/model/buildfarmjob.py
index e3a910a..70dbd64 100644
--- a/lib/lp/buildmaster/model/buildfarmjob.py
+++ b/lib/lp/buildmaster/model/buildfarmjob.py
@@ -7,7 +7,7 @@ __all__ = [
     "BuildFarmJobMixin",
     "SpecificBuildFarmJobSourceMixin",
 ]
-
+import logging
 from datetime import datetime, timezone
 
 from storm.expr import Desc, LeftJoin, Or
@@ -55,6 +55,8 @@ VALID_STATUS_TRANSITIONS = {
     BuildStatus.CANCELLED: (BuildStatus.NEEDSBUILD,),
 }
 
+logger = logging.getLogger(__name__)
+
 
 @implementer(IBuildFarmJob, IBuildFarmJobDB)
 @provider(IBuildFarmJobSource)
@@ -229,8 +231,10 @@ class BuildFarmJobMixin:
                 "Can't change build status from %s to %s."
                 % (self.build_farm_job.status.name, status.name)
             )
-
+        logger.debug(self.status)
+        logger.debug("BUILF JOB STATUS CHANGE")
         self.build_farm_job.status = self.status = status
+        logger.debug(self.status)
 
         # If there's a builder provided, set it if we don't already have
         # one, or otherwise crash if it's different from the one we
diff --git a/lib/lp/services/features/flags.py b/lib/lp/services/features/flags.py
index 85a37d0..57f5f2b 100644
--- a/lib/lp/services/features/flags.py
+++ b/lib/lp/services/features/flags.py
@@ -349,6 +349,14 @@ flag_info = sorted(
             "",
             "",
         ),
+        (
+            "archive.webhooks.enabled",
+            "boolean",
+            "If true, allow webhooks to be configured on archives",
+            "enabled",
+            "",
+            "",
+        ),
     ]
 )
 
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index d9c6f67..cf6fae5 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -68,6 +68,16 @@ WEBHOOK_EVENT_TYPES = {
     "snap:build:0.1": "Snap build",
     "craft-recipe:build:0.1": "Craft recipe build",
     "archive:source-package-upload:0.1": "Source package upload",
+    "archive:source-package-upload:0.1::accepted": "Source package"
+    "upload accepted",
+    "archive:source-package-upload:0.1::rejected": "Source package"
+    "upload rejected",
+    "archive:source-package-upload:0.1::unapproved": "Source package"
+    "upload unapproved",
+    "archive:binary-build:0.1": "Binary build",
+    "archive:binary-build:0.1::fullybuilt": "Binary build fully built",
+    "archive:binary-build:0.1::failedtobuild": "Binary build failed to build",
+    "archive:binary-build:0.1::superseded": "Binary build superseded",
 }
 
 
diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
index 9d38192..aa4053d 100644
--- a/lib/lp/soyuz/configure.zcml
+++ b/lib/lp/soyuz/configure.zcml
@@ -43,6 +43,20 @@
                 set_schema="lp.soyuz.interfaces.publishing.IBinaryPackagePublishingHistory"/>
         </class>
 
+    <!-- PackageUploadStatusChange -->
+    <subscriber
+        for="lp.soyuz.interfaces.queue.IPackageUpload
+            lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.soyuz.subscribers.archive.package_status_change_webhook"
+    />
+
+    <!-- BuildStatusChange -->
+    <subscriber
+        for="lp.soyuz.interfaces.binarypackagebuild.IBinaryPackageBuild
+            lazr.lifecycle.interfaces.IObjectModifiedEvent"
+        handler="lp.soyuz.subscribers.archive.build_status_change_webhook"
+    />
+
     <!-- DistroSeriesPackageCache -->
     <class
         class="lp.soyuz.model.distroseriespackagecache.DistroSeriesPackageCache">
diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
index 656ea34..68b67d6 100644
--- a/lib/lp/soyuz/interfaces/archive.py
+++ b/lib/lp/soyuz/interfaces/archive.py
@@ -10,6 +10,7 @@ __all__ = [
     "ArchiveDependencyError",
     "ArchiveDisabled",
     "ArchiveNotPrivate",
+    "ARCHIVE_WEBHOOKS_FEATURE_FLAG",
     "CannotCopy",
     "CannotSwitchPrivacy",
     "ComponentNotFound",
@@ -121,6 +122,7 @@ from lp.soyuz.interfaces.buildrecords import IHasBuildRecords
 from lp.soyuz.interfaces.component import IComponent
 
 NAMED_AUTH_TOKEN_FEATURE_FLAG = "soyuz.named_auth_token.allow_new"
+ARCHIVE_WEBHOOKS_FEATURE_FLAG = "archive.webhooks.enabled"
 
 
 @error_status(http.client.BAD_REQUEST)
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index 78c4d54..474dc80 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -232,7 +232,16 @@ class Archive(StormBase, WebhookTargetMixin):
 
     @property
     def valid_webhook_event_types(self):
-        return ["archive:source-package-upload:0.1"]
+        return [
+            "archive:source-package-upload:0.1",
+            "archive:source-package-upload:0.1::accepted",
+            "archive:source-package-upload:0.1::rejected",
+            "archive:source-package-upload:0.1::unapproved",
+            "archive:binary-build:0.1",
+            "archive:binary-build:0.1::fullybuilt",
+            "archive:binary-build:0.1::failedtobuild",
+            "archive:binary-build:0.1::superseded",
+        ]
 
     def _validate_archive_name(self, attr, value):
         """Only allow renaming of COPY archives.
diff --git a/lib/lp/soyuz/model/binarypackagebuild.py b/lib/lp/soyuz/model/binarypackagebuild.py
index c866d17..40ebed1 100644
--- a/lib/lp/soyuz/model/binarypackagebuild.py
+++ b/lib/lp/soyuz/model/binarypackagebuild.py
@@ -60,6 +60,7 @@ from lp.services.macaroons.interfaces import (
     IMacaroonIssuer,
 )
 from lp.services.macaroons.model import MacaroonIssuerBase
+from lp.services.webapp.snapshot import notify_modified
 from lp.soyuz.adapters.buildarch import determine_architectures_to_build
 from lp.soyuz.enums import ArchivePurpose, BinarySourceReferenceType
 from lp.soyuz.interfaces.archive import (
@@ -539,6 +540,30 @@ class BinaryPackageBuild(PackageBuildMixin, StormBase):
             for bp in self.binarypackages
         ]
 
+    def updateStatus(
+        self,
+        status,
+        builder=None,
+        worker_status=None,
+        date_started=None,
+        date_finished=None,
+        force_invalid_transition=False,
+    ):
+        edited_fields = set()
+        with notify_modified(
+            self, edited_fields, snapshot_names=("status",)
+        ) as previous_obj:
+            super().updateStatus(
+                status,
+                builder=builder,
+                worker_status=worker_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")
+
     @property
     def can_be_retried(self):
         """See `IBuildFarmJob`."""
diff --git a/lib/lp/soyuz/model/queue.py b/lib/lp/soyuz/model/queue.py
index 7c7fda2..f2c2f26 100644
--- a/lib/lp/soyuz/model/queue.py
+++ b/lib/lp/soyuz/model/queue.py
@@ -51,6 +51,7 @@ from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent
 from lp.services.mail.signedmessage import strip_pgp_signature
 from lp.services.propertycache import cachedproperty, get_property_cache
+from lp.services.webapp.snapshot import notify_modified
 from lp.soyuz.enums import PackageUploadCustomFormat, PackageUploadStatus
 from lp.soyuz.interfaces.archive import (
     ComponentNotFound,
@@ -356,13 +357,22 @@ class PackageUpload(StormBase):
 
         raise NotFoundError(filename)
 
+    def setPackageUploadStatus(self, status):
+        edited_fields = set()
+        with notify_modified(
+            self, edited_fields, snapshot_names=("status",)
+        ) as previous_obj:
+            self.status = PassthroughStatusValue(status)
+            if self.status != previous_obj.status:
+                edited_fields.add("status")
+
     def setUnapproved(self):
         """See `IPackageUpload`."""
         if self.status != PackageUploadStatus.NEW:
             raise QueueInconsistentStateError(
                 "Can not set modified queue items to UNAPPROVED."
             )
-        self.status = PassthroughStatusValue(PackageUploadStatus.UNAPPROVED)
+        self.setPackageUploadStatus(PackageUploadStatus.UNAPPROVED)
 
     def setAccepted(self):
         """See `IPackageUpload`."""
@@ -403,7 +413,7 @@ class PackageUpload(StormBase):
                 raise QueueInconsistentStateError(info)
 
         # if the previous checks applied and pass we do set the value
-        self.status = PassthroughStatusValue(PackageUploadStatus.ACCEPTED)
+        self.setPackageUploadStatus(PackageUploadStatus.ACCEPTED)
 
     def _checkForBinariesinDestinationArchive(self, builds):
         """
@@ -475,7 +485,7 @@ class PackageUpload(StormBase):
         """See `IPackageUpload`."""
         if self.status == PackageUploadStatus.DONE:
             raise QueueInconsistentStateError("Queue item already done")
-        self.status = PassthroughStatusValue(PackageUploadStatus.DONE)
+        self.setPackageUploadStatus(PackageUploadStatus.DONE)
 
     def setRejected(self):
         """See `IPackageUpload`."""
@@ -487,7 +497,7 @@ class PackageUpload(StormBase):
             raise QueueInconsistentStateError(
                 "Unable to reject queue item due to status."
             )
-        self.status = PassthroughStatusValue(PackageUploadStatus.REJECTED)
+        self.setPackageUploadStatus(PackageUploadStatus.REJECTED)
 
     def _validateBuildsForSource(self, sourcepackagerelease, builds):
         """Check if the sourcepackagerelease generates at least one build.
diff --git a/lib/lp/soyuz/subscribers/archive.py b/lib/lp/soyuz/subscribers/archive.py
new file mode 100644
index 0000000..c832a9e
--- /dev/null
+++ b/lib/lp/soyuz/subscribers/archive.py
@@ -0,0 +1,81 @@
+# Copyright 2025 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Event subscribers for source package uploads"""
+
+from zope.component import getUtility
+
+from lp.buildmaster.enums import BuildStatus
+from lp.services.features import getFeatureFlag
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.soyuz.enums import PackageUploadStatus
+from lp.soyuz.interfaces.archive import ARCHIVE_WEBHOOKS_FEATURE_FLAG
+
+
+def _trigger_source_package_status_change_webhook(upload, event_type, action):
+    if getFeatureFlag(ARCHIVE_WEBHOOKS_FEATURE_FLAG):
+        payload = {
+            "package_upload": canonical_url(upload, force_local_path=True),
+            "action": action,
+            "status": upload.status.name,
+            "archive": canonical_url(upload.archive, force_local_path=True),
+        }
+
+        # Source package information
+        if upload.sources[0] and upload.sources[0].sourcepackagerelease:
+            payload["package_name"] = str(
+                upload.sources[0].sourcepackagerelease.sourcepackagename
+            )
+            payload["package_version"] = str(
+                upload.sources[0].sourcepackagerelease.version
+            )
+
+        getUtility(IWebhookSet).trigger(upload.archive, event_type, payload)
+
+
+def _trigger_build_status_change_webhook(build, event_type, action):
+    if getFeatureFlag(ARCHIVE_WEBHOOKS_FEATURE_FLAG):
+        payload = {
+            "build": canonical_url(build, force_local_path=True),
+            "action": action,
+            "status": build.status.name,
+            "archive": canonical_url(build.archive, force_local_path=True),
+        }
+
+        getUtility(IWebhookSet).trigger(build.archive, event_type, payload)
+
+
+def package_status_change_webhook(upload, event):
+    """Webhook for source and binary package uploads."""
+
+    # For source packages
+    # Instead of checking upload.sources, we check upload.builds, because
+    # there are instances of rejected source package uploads which do not have
+    # any sources
+    if not upload.builds:
+        if "status" in event.edited_fields and (
+            upload.status == PackageUploadStatus.ACCEPTED
+            or upload.status == PackageUploadStatus.REJECTED
+            or upload.status == PackageUploadStatus.UNAPPROVED
+        ):
+            _trigger_source_package_status_change_webhook(
+                upload,
+                f"archive:source-package-upload:0.1::"
+                f"{upload.status.name.lower()}",
+                "status-changed",
+            )
+
+
+def build_status_change_webhook(build, event):
+    """Webhook for builds"""
+    if "status" in event.edited_fields and (
+        build.status == BuildStatus.FULLYBUILT
+        or build.status == BuildStatus.FAILEDTOBUILD
+        or build.status == BuildStatus.SUPERSEDED
+    ):
+        _trigger_build_status_change_webhook(
+            build,
+            f"archive:binary-build:0.1::" f"{build.status.name.lower()}",
+            "status-changed",
+        )
diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
index bffac3e..d3ac7ae 100644
--- a/lib/lp/soyuz/tests/test_archive.py
+++ b/lib/lp/soyuz/tests/test_archive.py
@@ -70,6 +70,7 @@ from lp.services.signing.interfaces.signingkey import IArchiveSigningKeySet
 from lp.services.timeout import default_timeout
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.publisher import canonical_url
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
 from lp.soyuz.adapters.overrides import BinaryOverride, SourceOverride
@@ -81,8 +82,10 @@ from lp.soyuz.enums import (
     ArchiveStatus,
     PackageCopyPolicy,
     PackagePublishingStatus,
+    PackageUploadStatus,
 )
 from lp.soyuz.interfaces.archive import (
+    ARCHIVE_WEBHOOKS_FEATURE_FLAG,
     NAMED_AUTH_TOKEN_FEATURE_FLAG,
     ArchiveDependencyError,
     ArchiveDisabled,
@@ -7355,3 +7358,208 @@ class TestArchiveWebhooks(TestCaseWithFactory):
             archive.delete(archive.owner)
             transaction.commit()
             self.assertRaises(LostObjectError, getattr, webhook, "event_types")
+
+
+class TestSourcePackageUploadWebhooks(TestCaseWithFactory):
+    """Tests that source package upload webhooks fire correctly."""
+
+    layer = LaunchpadZopelessLayer
+
+    def test_source_package_upload_status_change_triggers_webhook(self):
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ARCHIVE_WEBHOOKS_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        archive = self.factory.makeArchive(name="test-archive")
+        hook = self.factory.makeWebhook(
+            target=archive,
+            delivery_url="http://localhost/test-webhook";,
+            event_types=["archive:source-package-upload:0.1"],
+        )
+
+        upload = self.factory.makePackageUpload(
+            archive=archive,
+            status=PackageUploadStatus.NEW,
+        )
+        upload.addSource(
+            self.factory.makeSourcePackageRelease(
+                sourcepackagename=self.factory.makeSourcePackageName(
+                    name="mypkg"
+                ),
+                version="1.0.0",
+            )
+        )
+
+        upload_unwrapped = removeSecurityProxy(upload)
+        upload_unwrapped.setAccepted()
+
+        job = hook.deliveries.one()
+        self.assertEqual(job.event_type, "archive:source-package-upload:0.1")
+
+        payload = job.payload
+        self.assertEqual(
+            payload["package_upload"],
+            canonical_url(upload, force_local_path=True),
+        )
+        self.assertEqual(payload["action"], "status-changed")
+        self.assertEqual(payload["status"], "ACCEPTED")
+        self.assertEqual(
+            payload["archive"],
+            canonical_url(archive, force_local_path=True),
+        )
+        self.assertEqual(payload["package_name"], "mypkg")
+        self.assertEqual(payload["package_version"], "1.0.0")
+
+    def test_source_package_upload_no_status_change_does_not_trigger_webhook(
+        self,
+    ):
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ARCHIVE_WEBHOOKS_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        archive = self.factory.makeArchive(name="test-archive")
+        hook = self.factory.makeWebhook(
+            target=archive,
+            delivery_url="http://localhost/test-webhook";,
+            event_types=["archive:source-package-upload:0.1"],
+        )
+
+        upload = self.factory.makePackageUpload(
+            archive=archive,
+            status=PackageUploadStatus.NEW,
+        )
+        upload.addSource(
+            self.factory.makeSourcePackageRelease(
+                sourcepackagename=self.factory.makeSourcePackageName(
+                    name="mypkg"
+                ),
+                version="1.0.0",
+            )
+        )
+
+        self.assertEqual(hook.deliveries.count(), 0)
+
+    def test_no_webhook_triggered_when_feature_flag_is_not_on(self):
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ARCHIVE_WEBHOOKS_FEATURE_FLAG: "",
+                }
+            )
+        )
+        archive = self.factory.makeArchive(name="test-archive")
+        hook = self.factory.makeWebhook(
+            target=archive,
+            delivery_url="http://localhost/test-webhook";,
+            event_types=["archive:source-package-upload:0.1"],
+        )
+
+        upload = self.factory.makePackageUpload(
+            archive=archive,
+            status=PackageUploadStatus.NEW,
+        )
+        upload.addSource(
+            self.factory.makeSourcePackageRelease(
+                sourcepackagename=self.factory.makeSourcePackageName(
+                    name="mypkg"
+                ),
+                version="1.0.0",
+            )
+        )
+
+        upload_unwrapped = removeSecurityProxy(upload)
+        upload_unwrapped.setAccepted()
+
+        self.assertEqual(hook.deliveries.count(), 0)
+
+
+class TestBinaryBuildFinishWebhooks(TestCaseWithFactory):
+    """Tests that webhooks fire correctly on binary package status change."""
+
+    layer = LaunchpadZopelessLayer
+
+    def test_binary_build_status_change_triggers_webhook(self):
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ARCHIVE_WEBHOOKS_FEATURE_FLAG: "on",
+                }
+            )
+        )
+
+        archive = self.factory.makeArchive(name="test-archive")
+        hook = self.factory.makeWebhook(
+            target=archive,
+            delivery_url="http://localhost/test-webhook";,
+            event_types=["archive:binary-build:0.1"],
+        )
+
+        build = self.factory.makeBinaryPackageBuild(
+            archive=archive, status=BuildStatus.UPLOADING
+        )
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        job = hook.deliveries.one()
+        self.assertEqual(job.event_type, "archive:binary-build:0.1")
+
+        payload = job.payload
+        self.assertEqual(
+            payload["build"],
+            canonical_url(build, force_local_path=True),
+        )
+        self.assertEqual(payload["action"], "status-changed")
+        self.assertEqual(payload["status"], "FULLYBUILT")
+        self.assertEqual(
+            payload["archive"],
+            canonical_url(archive, force_local_path=True),
+        )
+
+    def test_binary_build_no_status_change_does_not_trigger_webhook(self):
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ARCHIVE_WEBHOOKS_FEATURE_FLAG: "on",
+                }
+            )
+        )
+
+        archive = self.factory.makeArchive(name="test-archive")
+        hook = self.factory.makeWebhook(
+            target=archive,
+            delivery_url="http://localhost/test-webhook";,
+            event_types=["archive:binary-build:0.1"],
+        )
+
+        self.factory.makeBinaryPackageBuild(
+            archive=archive, status=BuildStatus.UPLOADING
+        )
+
+        self.assertEqual(hook.deliveries.count(), 0)
+
+    def test_no_webhook_triggered_when_feature_flag_is_not_on(self):
+        self.useFixture(
+            FeatureFixture(
+                {
+                    ARCHIVE_WEBHOOKS_FEATURE_FLAG: "",
+                }
+            )
+        )
+
+        archive = self.factory.makeArchive(name="test-archive")
+        hook = self.factory.makeWebhook(
+            target=archive,
+            delivery_url="http://localhost/test-webhook";,
+            event_types=["archive:binary-build:0.1"],
+        )
+
+        build = self.factory.makeBinaryPackageBuild(
+            archive=archive, status=BuildStatus.UPLOADING
+        )
+        build.updateStatus(BuildStatus.FULLYBUILT)
+
+        self.assertEqual(hook.deliveries.count(), 0)
diff --git a/requirements/launchpad.txt b/requirements/launchpad.txt
index 906b35a..dfb4119 100644
--- a/requirements/launchpad.txt
+++ b/requirements/launchpad.txt
@@ -185,7 +185,7 @@ toml==0.10.2
 transaction==3.0.1
 treq==18.6.0
 # lp:~launchpad/twisted:lp-backport
-Twisted==20.3.0+lp10
+Twisted==20.3.0+lp9
 # Python 3.8 and twisted 20.3.0+lp9
 txfixtures==0.4.3
 # Python 3.10+ and twisted 24.11