← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:oci-recipe-schedule-registry-upload into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-schedule-registry-upload into launchpad:master with ~cjwatson/launchpad:oci-detailed-registry-errors as a prerequisite.

Commit message:
Add OCIRecipeBuild.scheduleRegistryUpload

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This allows an upload to be scheduled after configuring the recipe appropriately, without needing to dispatch a new build.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-schedule-registry-upload into launchpad:master.
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
index aa6e291..5fe56a9 100644
--- a/lib/lp/oci/interfaces/ocirecipebuild.py
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    'CannotScheduleRegistryUpload',
     'IOCIFile',
     'IOCIFileSet',
     'IOCIRecipeBuild',
@@ -19,13 +20,17 @@ from lazr.enum import (
     Item,
     )
 from lazr.restful.declarations import (
+    error_status,
     export_as_webservice_entry,
+    export_write_operation,
     exported,
+    operation_for_version,
     )
 from lazr.restful.fields import (
     CollectionField,
     Reference,
     )
+from six.moves import http_client
 from zope.interface import (
     Attribute,
     Interface,
@@ -53,6 +58,11 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
 
+@error_status(http_client.BAD_REQUEST)
+class CannotScheduleRegistryUpload(Exception):
+    """This build cannot be uploaded to registries."""
+
+
 class OCIRecipeBuildRegistryUploadStatus(EnumeratedType):
     """OCI build registry upload status type
 
@@ -216,6 +226,15 @@ class IOCIRecipeBuildEdit(Interface):
         :return: An `IOCILayerFile`.
         """
 
+    @export_write_operation()
+    @operation_for_version("devel")
+    def scheduleRegistryUpload():
+        """Schedule an upload of this build to each configured registry.
+
+        :raises CannotScheduleRegistryUpload: if the build is not in a state
+            where an upload can be scheduled.
+        """
+
     def cancel():
         """Cancel the build if it is either pending or in progress.
 
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 8562778..26c780b 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -163,6 +163,11 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         self.date_last_modified = date_created
         self.git_ref = git_ref
 
+    def __repr__(self):
+        return "<OCIRecipe ~%s/%s/+oci/%s/+recipe/%s>" % (
+            self.owner.name, self.oci_project.pillar.name,
+            self.oci_project.name, self.name)
+
     @property
     def valid_webhook_event_types(self):
         return ["oci-recipe:build:0.1"]
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 9613404..fc84708 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -42,12 +42,14 @@ from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
 from lp.buildmaster.model.packagebuild import PackageBuildMixin
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.oci.interfaces.ocirecipebuild import (
+    CannotScheduleRegistryUpload,
     IOCIFile,
     IOCIFileSet,
     IOCIRecipeBuild,
     IOCIRecipeBuildSet,
     OCIRecipeBuildRegistryUploadStatus,
     )
+from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
 from lp.oci.model.ocirecipebuildjob import (
     OCIRecipeBuildJob,
     OCIRecipeBuildJobType,
@@ -446,6 +448,28 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
         job = self.last_registry_upload_job
         return (job and job.errors) or []
 
+    def scheduleRegistryUpload(self):
+        """See `IOCIRecipeBuild`."""
+        if not self.recipe.can_upload_to_registry:
+            raise CannotScheduleRegistryUpload(
+                "Cannot upload this build to registries because the recipe is "
+                "not properly configured.")
+        if not self.was_built or self.getFiles().is_empty():
+            raise CannotScheduleRegistryUpload(
+                "Cannot upload this build because it has no files.")
+        if (self.registry_upload_status ==
+                OCIRecipeBuildRegistryUploadStatus.PENDING):
+            raise CannotScheduleRegistryUpload(
+                "An upload of this build is already in progress.")
+        elif (self.registry_upload_status ==
+                OCIRecipeBuildRegistryUploadStatus.UPLOADED):
+            # XXX cjwatson 2020-04-22: This won't be quite right in the case
+            # where a recipe has multiple push rules.
+            raise CannotScheduleRegistryUpload(
+                "Cannot upload this build because it has already been "
+                "uploaded.")
+        getUtility(IOCIRegistryUploadJobSource).create(self)
+
 
 @implementer(IOCIRecipeBuildSet)
 class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
diff --git a/lib/lp/oci/subscribers/ocirecipebuild.py b/lib/lp/oci/subscribers/ocirecipebuild.py
index 242d761..606157f 100644
--- a/lib/lp/oci/subscribers/ocirecipebuild.py
+++ b/lib/lp/oci/subscribers/ocirecipebuild.py
@@ -14,6 +14,7 @@ from lp.oci.interfaces.ocirecipe import OCI_RECIPE_WEBHOOKS_FEATURE_FLAG
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
 from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
 from lp.services.features import getFeatureFlag
+from lp.services.scripts import log
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.payload import compose_webhook_payload
@@ -47,4 +48,9 @@ def oci_recipe_build_modified(build, event):
         if status_changed:
             if (build.recipe.can_upload_to_registry and
                     build.status == BuildStatus.FULLYBUILT):
+                log.info("Scheduling upload of %r to registries." % build)
                 getUtility(IOCIRegistryUploadJobSource).create(build)
+            else:
+                log.info(
+                    "%r is not configured for upload to registries." %
+                    build.recipe)
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 80e94d5..1dbd170 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -29,6 +29,7 @@ from lp.oci.interfaces.ocirecipe import (
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     )
 from lp.oci.interfaces.ocirecipebuild import (
+    CannotScheduleRegistryUpload,
     IOCIFileSet,
     IOCIRecipeBuild,
     IOCIRecipeBuildSet,
@@ -36,6 +37,7 @@ from lp.oci.interfaces.ocirecipebuild import (
     )
 from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
 from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet
+from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
@@ -82,7 +84,7 @@ class TestOCIFileSet(TestCaseWithFactory):
         self.assertIsNone(result)
 
 
-class TestOCIRecipeBuild(TestCaseWithFactory):
+class TestOCIRecipeBuild(OCIConfigHelperMixin, TestCaseWithFactory):
 
     layer = LaunchpadZopelessLayer
 
@@ -257,6 +259,38 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
         self.assertEqual(1, hook.deliveries.count())
         self.assertThat(logger.output, LogsScheduledWebhooks(expected_logs))
 
+    def test_updateStatus_failure_does_not_trigger_registry_uploads(self):
+        # A failed OCIRecipeBuild does not trigger registry uploads.
+        self.setConfig()
+        self.factory.makeOCIPushRule(self.build.recipe)
+        with dbuser(config.builddmaster.dbuser):
+            self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        self.assertContentEqual([], self.build.registry_upload_jobs)
+
+    def test_updateStatus_fullybuilt_not_configured(self):
+        # A completed OCIRecipeBuild does not trigger registry uploads if
+        # the recipe is not properly configured for that.
+        logger = self.useFixture(FakeLogger())
+        with dbuser(config.builddmaster.dbuser):
+            self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertEqual(0, len(list(self.build.registry_upload_jobs)))
+        self.assertIn(
+            "%r is not configured for upload to registries." % (
+                self.build.recipe),
+            logger.output.splitlines())
+
+    def test_updateStatus_fullybuilt_triggers_registry_uploads(self):
+        # A completed OCIRecipeBuild triggers registry uploads.
+        self.setConfig()
+        logger = self.useFixture(FakeLogger())
+        self.factory.makeOCIPushRule(self.build.recipe)
+        with dbuser(config.builddmaster.dbuser):
+            self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertEqual(1, len(list(self.build.registry_upload_jobs)))
+        self.assertIn(
+            "Scheduling upload of %r to registries." % self.build,
+            logger.output.splitlines())
+
     def test_eta(self):
         # OCIRecipeBuild.eta returns a non-None value when it should, or
         # None when there's no start time.
@@ -349,6 +383,119 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
             [{"code": "BOOM", "message": "Boom", "detail": "It went boom"}],
             build.registry_upload_errors)
 
+    def test_scheduleRegistryUpload(self):
+        # A build not previously uploaded to a registry can be uploaded
+        # manually.
+        self.setConfig()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeOCIPushRule(recipe=self.build.recipe)
+        self.factory.makeOCIFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        self.build.scheduleRegistryUpload()
+        [job] = getUtility(IOCIRegistryUploadJobSource).iterReady()
+        self.assertEqual(JobStatus.WAITING, job.job.status)
+        self.assertEqual(self.build, job.build)
+
+    def test_scheduleRegistryUpload_not_configured(self):
+        # A build that is not properly configured cannot be uploaded to
+        # registries.
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertRaisesWithContent(
+            CannotScheduleRegistryUpload,
+            "Cannot upload this build to registries because the recipe is not "
+            "properly configured.",
+            self.build.scheduleRegistryUpload)
+        self.assertEqual(
+            [], list(getUtility(IOCIRegistryUploadJobSource).iterReady()))
+
+    def test_scheduleRegistryUpload_no_files(self):
+        # A build with no files cannot be uploaded to registries.
+        self.setConfig()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeOCIPushRule(recipe=self.build.recipe)
+        self.assertRaisesWithContent(
+            CannotScheduleRegistryUpload,
+            "Cannot upload this build because it has no files.",
+            self.build.scheduleRegistryUpload)
+        self.assertEqual(
+            [], list(getUtility(IOCIRegistryUploadJobSource).iterReady()))
+
+    def test_scheduleRegistryUpload_already_in_progress(self):
+        # A build with an upload already in progress will not have another
+        # one created.
+        self.setConfig()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeOCIPushRule(recipe=self.build.recipe)
+        self.factory.makeOCIFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        old_job = getUtility(IOCIRegistryUploadJobSource).create(self.build)
+        self.assertRaisesWithContent(
+            CannotScheduleRegistryUpload,
+            "An upload of this build is already in progress.",
+            self.build.scheduleRegistryUpload)
+        self.assertEqual(
+            [old_job],
+            list(getUtility(IOCIRegistryUploadJobSource).iterReady()))
+
+    def test_scheduleRegistryUpload_already_uploaded(self):
+        # A build with an upload that has already completed will not have
+        # another one created.
+        self.setConfig()
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeOCIPushRule(recipe=self.build.recipe)
+        self.factory.makeOCIFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        old_job = getUtility(IOCIRegistryUploadJobSource).create(self.build)
+        removeSecurityProxy(old_job).job._status = JobStatus.COMPLETED
+        self.assertRaisesWithContent(
+            CannotScheduleRegistryUpload,
+            "Cannot upload this build because it has already been uploaded.",
+            self.build.scheduleRegistryUpload)
+        self.assertEqual(
+            [], list(getUtility(IOCIRegistryUploadJobSource).iterReady()))
+
+    def test_scheduleRegistryUpload_triggers_webhooks(self):
+        # Scheduling a registry upload triggers webhooks on the
+        # corresponding recipe.
+        self.setConfig()
+        logger = self.useFixture(FakeLogger())
+        self.build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeOCIFile(
+            build=self.build,
+            library_file=self.factory.makeLibraryFileAlias(db_only=True))
+        self.factory.makeOCIPushRule(recipe=self.build.recipe)
+        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.scheduleRegistryUpload()
+        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": Is(None),
+            "status": Equals("Successfully built"),
+            "registry_upload_status": Equals("Pending"),
+            }
+        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))]))
+
 
 class TestOCIRecipeBuildSet(TestCaseWithFactory):