launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27482
[Merge] ~cjwatson/launchpad:charmhub-upload-job into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-upload-job into launchpad:master.
Commit message:
Add a job for uploading builds to Charmhub
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/407975
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-upload-job into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 53b3232..b654cba 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2809,6 +2809,7 @@ public.webhookjob = SELECT, INSERT
type=user
groups=script
public.account = SELECT
+public.archive = SELECT
public.builder = SELECT
public.buildfarmjob = SELECT, INSERT
public.buildqueue = SELECT, INSERT, UPDATE
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 122c79b..71d5a92 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -122,5 +122,17 @@
<allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
<allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJob" />
</class>
+ <class class="lp.charms.model.charmrecipebuildjob.CharmRecipeBuildJob">
+ <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmRecipeBuildJob" />
+ </class>
+ <securedutility
+ component="lp.charms.model.charmrecipebuildjob.CharmhubUploadJob"
+ provides="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJobSource">
+ <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJobSource" />
+ </securedutility>
+ <class class="lp.charms.model.charmrecipebuildjob.CharmhubUploadJob">
+ <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmRecipeBuildJob" />
+ <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJob" />
+ </class>
</configure>
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-releasefailed.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-releasefailed.txt
new file mode 100644
index 0000000..e7006dc
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-releasefailed.txt
@@ -0,0 +1,3 @@
+Launchpad asked Charmhub to release this charm, but it failed:
+
+ %(store_error_message)s
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-reviewfailed.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-reviewfailed.txt
new file mode 100644
index 0000000..f23686b
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-reviewfailed.txt
@@ -0,0 +1,4 @@
+Launchpad uploaded this charm to Charmhub, but Charmhub failed to review
+it:
+
+ %(store_error_message)s
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-unauthorized.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-unauthorized.txt
new file mode 100644
index 0000000..953eeb9
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-unauthorized.txt
@@ -0,0 +1,6 @@
+Launchpad could not upload this charm to Charmhub because of an
+authorization failure. If you still want Launchpad to upload this charm
+to Charmhub on your behalf, you will need to reauthorize it. You can do
+that by following the instructions here:
+
+ %(recipe_authorize_url)s
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-uploadfailed.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-uploadfailed.txt
new file mode 100644
index 0000000..446543a
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-uploadfailed.txt
@@ -0,0 +1,7 @@
+Launchpad failed to upload this charm to Charmhub:
+
+ %(store_error_message)s
+
+You can retry the upload here:
+
+ %(build_url)s
diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py
index 4e9a528..57c069f 100644
--- a/lib/lp/charms/interfaces/charmhubclient.py
+++ b/lib/lp/charms/interfaces/charmhubclient.py
@@ -8,6 +8,7 @@ __all__ = [
"BadRequestPackageUploadResponse",
"BadReviewStatusResponse",
"ICharmhubClient",
+ "CharmhubError",
"ReleaseFailedResponse",
"ReviewFailedResponse",
"UnauthorizedUploadResponse",
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 45b57a4..6f162cc 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -10,7 +10,10 @@ __all__ = [
"ICharmRecipeBuildSet",
]
-from lazr.restful.fields import Reference
+from lazr.restful.fields import (
+ CollectionField,
+ Reference,
+ )
from zope.interface import (
Attribute,
Interface,
@@ -107,6 +110,16 @@ class ICharmRecipeBuildView(IPackageBuild):
"The revision ID of the branch used for this build, if "
"available."))
+ store_upload_jobs = CollectionField(
+ title=_("Store upload jobs for this build."),
+ # Really ICharmhubUploadJob.
+ value_type=Reference(schema=Interface),
+ readonly=True)
+
+ # Really ICharmhubUploadJob.
+ last_store_upload_job = Reference(
+ title=_("Last store upload job for this build."), schema=Interface)
+
store_upload_metadata = Attribute(
_("A dict of data about store upload progress."))
diff --git a/lib/lp/charms/interfaces/charmrecipebuildjob.py b/lib/lp/charms/interfaces/charmrecipebuildjob.py
new file mode 100644
index 0000000..b0d1e20
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmrecipebuildjob.py
@@ -0,0 +1,73 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe build job interfaces."""
+
+__metaclass__ = type
+__all__ = [
+ 'ICharmRecipeBuildJob',
+ 'ICharmhubUploadJob',
+ 'ICharmhubUploadJobSource',
+ ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+ Attribute,
+ Interface,
+ )
+from zope.schema import (
+ Int,
+ TextLine,
+ )
+
+from lp import _
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.services.job.interfaces.job import (
+ IJob,
+ IJobSource,
+ IRunnableJob,
+ )
+
+
+class ICharmRecipeBuildJob(Interface):
+ """A job related to a charm recipe build."""
+
+ job = Reference(
+ title=_("The common Job attributes."), schema=IJob,
+ required=True, readonly=True)
+
+ build = Reference(
+ title=_("The charm recipe build to use for this job."),
+ schema=ICharmRecipeBuild, required=True, readonly=True)
+
+ metadata = Attribute(_("A dict of data about the job."))
+
+
+class ICharmhubUploadJob(IRunnableJob):
+ """A Job that uploads a charm recipe build to Charmhub."""
+
+ store_metadata = Attribute(
+ _("Combined metadata for this job and the matching build"))
+
+ error_message = TextLine(
+ title=_("Error message"), required=False, readonly=True)
+
+ error_detail = TextLine(
+ title=_("Error message detail"), required=False, readonly=True)
+
+ store_revision = Int(
+ title=_("The revision assigned to this build by Charmhub"),
+ required=False, readonly=True)
+
+ status_url = TextLine(
+ title=_("The URL on Charmhub to get the status of this build"),
+ required=False, readonly=True)
+
+
+class ICharmhubUploadJobSource(IJobSource):
+
+ def create(build):
+ """Upload a charm recipe build to Charmhub.
+
+ :param build: The charm recipe build to upload.
+ """
diff --git a/lib/lp/charms/mail/charmrecipebuild.py b/lib/lp/charms/mail/charmrecipebuild.py
index 9f997cb..6b4de1a 100644
--- a/lib/lp/charms/mail/charmrecipebuild.py
+++ b/lib/lp/charms/mail/charmrecipebuild.py
@@ -33,6 +33,62 @@ class CharmRecipeBuildMailer(BaseMailer):
config.canonical.noreply_from_address, "charm-recipe-build-status",
build)
+ @classmethod
+ def forUnauthorizedUpload(cls, build):
+ """Create a mailer for notifying about unauthorized Charmhub uploads.
+
+ :param build: The relevant build.
+ """
+ requester = build.requester
+ recipients = {requester: RecipientReason.forBuildRequester(requester)}
+ return cls(
+ "Charmhub authorization failed for %(recipe_name)s",
+ "charmrecipebuild-unauthorized.txt", recipients,
+ config.canonical.noreply_from_address,
+ "charm-recipe-build-upload-unauthorized", build)
+
+ @classmethod
+ def forUploadFailure(cls, build):
+ """Create a mailer for notifying about Charmhub upload failures.
+
+ :param build: The relevant build.
+ """
+ requester = build.requester
+ recipients = {requester: RecipientReason.forBuildRequester(requester)}
+ return cls(
+ "Charmhub upload failed for %(recipe_name)s",
+ "charmrecipebuild-uploadfailed.txt", recipients,
+ config.canonical.noreply_from_address,
+ "charm-recipe-build-upload-failed", build)
+
+ @classmethod
+ def forUploadReviewFailure(cls, build):
+ """Create a mailer for notifying about Charmhub upload review failures.
+
+ :param build: The relevant build.
+ """
+ requester = build.requester
+ recipients = {requester: RecipientReason.forBuildRequester(requester)}
+ return cls(
+ "Charmhub upload review failed for %(recipe_name)s",
+ "charmrecipebuild-reviewfailed.txt", recipients,
+ config.canonical.noreply_from_address,
+ "charm-recipe-build-upload-review-failed", build)
+
+ @classmethod
+ def forReleaseFailure(cls, build):
+ """Create a mailer for notifying about Charmhub release failures.
+
+ :param build: The relevant build.
+ """
+ requester = build.requester
+ recipients = {requester: RecipientReason.forBuildRequester(requester)}
+ return cls(
+ "Charmhub release failed for %(recipe_name)s",
+ "charmrecipebuild-releasefailed.txt", recipients,
+ config.canonical.noreply_from_address,
+ "charm-recipe-build-release-failed", build)
+
def __init__(self, subject, template_name, recipients, from_address,
notification_type, build):
super(CharmRecipeBuildMailer, self).__init__(
@@ -50,6 +106,11 @@ class CharmRecipeBuildMailer(BaseMailer):
def _getTemplateParams(self, email, recipient):
"""See `BaseMailer`."""
build = self.build
+ upload_job = build.last_store_upload_job
+ if upload_job is None:
+ error_message = ""
+ else:
+ error_message = upload_job.error_message or ""
params = super(CharmRecipeBuildMailer, self)._getTemplateParams(
email, recipient)
params.update({
@@ -60,9 +121,12 @@ class CharmRecipeBuildMailer(BaseMailer):
"build_title": build.title,
"build_url": canonical_url(build),
"builder_url": "",
+ "store_error_message": error_message,
"distroseries": build.distro_series,
"log_url": "",
"project_name": build.recipe.project.name,
+ "recipe_authorize_url": canonical_url(
+ build.recipe, view_name="+authorize"),
"recipe_name": build.recipe.name,
"upload_log_url": "",
})
diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py
index 9b2627f..6de887c 100644
--- a/lib/lp/charms/model/charmhubclient.py
+++ b/lib/lp/charms/model/charmhubclient.py
@@ -238,6 +238,9 @@ class CharmhubClient:
raise BadReviewStatusResponse(response.text)
[revision] = response_data["revisions"]
if revision["status"] == "approved":
+ if revision["revision"] is None:
+ raise ReviewFailedResponse(
+ "Review passed but did not assign a revision.")
return revision["revision"]
elif revision["status"] == "rejected":
error_message = "\n".join(
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index 3a38f2a..c6b10ec 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -43,6 +43,10 @@ from lp.charms.interfaces.charmrecipebuild import (
ICharmRecipeBuildSet,
)
from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
+from lp.charms.model.charmrecipebuildjob import (
+ CharmRecipeBuildJob,
+ CharmRecipeBuildJobType,
+ )
from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.registry.interfaces.series import SeriesStatus
from lp.registry.model.distribution import Distribution
@@ -58,6 +62,7 @@ from lp.services.database.interfaces import (
IStore,
)
from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import Job
from lp.services.librarian.model import (
LibraryFileAlias,
LibraryFileContent,
@@ -324,6 +329,25 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
return self.eta
return self.date_finished
+ @property
+ def store_upload_jobs(self):
+ jobs = Store.of(self).find(
+ CharmRecipeBuildJob,
+ CharmRecipeBuildJob.build == self,
+ CharmRecipeBuildJob.job_type ==
+ CharmRecipeBuildJobType.CHARMHUB_UPLOAD)
+ jobs.order_by(Desc(CharmRecipeBuildJob.job_id))
+
+ def preload_jobs(rows):
+ load_related(Job, rows, ["job_id"])
+
+ return DecoratedResultSet(
+ jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
+
+ @cachedproperty
+ def last_store_upload_job(self):
+ return self.store_upload_jobs.first()
+
def getFiles(self):
"""See `ICharmRecipeBuild`."""
result = Store.of(self).find(
diff --git a/lib/lp/charms/model/charmrecipebuildjob.py b/lib/lp/charms/model/charmrecipebuildjob.py
new file mode 100644
index 0000000..bc810eb
--- /dev/null
+++ b/lib/lp/charms/model/charmrecipebuildjob.py
@@ -0,0 +1,318 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe build jobs."""
+
+__all__ = [
+ "CharmhubUploadJob",
+ "CharmRecipeBuildJob",
+ "CharmRecipeBuildJobType",
+ ]
+
+from datetime import timedelta
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+ DBEnumeratedType,
+ DBItem,
+ )
+from storm.databases.postgres import JSON
+from storm.locals import (
+ Int,
+ Reference,
+ )
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+ implementer,
+ provider,
+ )
+
+from lp.app.errors import NotFoundError
+from lp.charms.interfaces.charmhubclient import (
+ BadReviewStatusResponse,
+ CharmhubError,
+ ICharmhubClient,
+ ReleaseFailedResponse,
+ ReviewFailedResponse,
+ UnauthorizedUploadResponse,
+ UploadFailedResponse,
+ UploadNotReviewedYetResponse,
+ )
+from lp.charms.interfaces.charmrecipebuildjob import (
+ ICharmhubUploadJob,
+ ICharmhubUploadJobSource,
+ ICharmRecipeBuildJob,
+ )
+from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
+from lp.services.config import config
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import (
+ IMasterStore,
+ IStore,
+ )
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import (
+ EnumeratedSubclass,
+ Job,
+ )
+from lp.services.job.runner import BaseRunnableJob
+from lp.services.propertycache import get_property_cache
+
+
+class CharmRecipeBuildJobType(DBEnumeratedType):
+ """Values that `ICharmRecipeBuildJob.job_type` can take."""
+
+ CHARMHUB_UPLOAD = DBItem(0, """
+ Charmhub upload
+
+ This job uploads a charm recipe build to Charmhub.
+ """)
+
+
+@implementer(ICharmRecipeBuildJob)
+class CharmRecipeBuildJob(StormBase):
+ """See `ICharmRecipeBuildJob`."""
+
+ __storm_table__ = "CharmRecipeBuildJob"
+
+ job_id = Int(name="job", primary=True, allow_none=False)
+ job = Reference(job_id, "Job.id")
+
+ build_id = Int(name="build", allow_none=False)
+ build = Reference(build_id, "CharmRecipeBuild.id")
+
+ job_type = EnumCol(enum=CharmRecipeBuildJobType, notNull=True)
+
+ metadata = JSON("json_data", allow_none=False)
+
+ def __init__(self, build, job_type, metadata, **job_args):
+ """Constructor.
+
+ Extra keyword arguments are used to construct the underlying Job
+ object.
+
+ :param build: The `ICharmRecipeBuild` this job relates to.
+ :param job_type: The `CharmRecipeBuildJobType` of this job.
+ :param metadata: The type-specific variables, as a JSON-compatible
+ dict.
+ """
+ super(CharmRecipeBuildJob, self).__init__()
+ self.job = Job(**job_args)
+ self.build = build
+ self.job_type = job_type
+ self.metadata = metadata
+
+ def makeDerived(self):
+ return CharmRecipeBuildJobDerived.makeSubclass(self)
+
+
+@delegate_to(ICharmRecipeBuildJob)
+class CharmRecipeBuildJobDerived(
+ BaseRunnableJob, metaclass=EnumeratedSubclass):
+
+ def __init__(self, charm_recipe_build_job):
+ self.context = charm_recipe_build_job
+
+ def __repr__(self):
+ """An informative representation of the job."""
+ recipe = self.build.recipe
+ return "<%s for ~%s/%s/+charm/%s/+build/%d>" % (
+ self.__class__.__name__, recipe.owner.name, recipe.project.name,
+ recipe.name, self.build.id)
+
+ @classmethod
+ def get(cls, job_id):
+ """Get a job by id.
+
+ :return: The `CharmRecipeBuildJob` with the specified id, as the
+ current `CharmRecipeBuildJobDerived` subclass.
+ :raises: `NotFoundError` if there is no job with the specified id,
+ or its `job_type` does not match the desired subclass.
+ """
+ charm_recipe_build_job = IStore(CharmRecipeBuildJob).get(
+ CharmRecipeBuildJob, job_id)
+ if charm_recipe_build_job.job_type != cls.class_job_type:
+ raise NotFoundError(
+ "No object found with id %d and type %s" %
+ (job_id, cls.class_job_type.title))
+ return cls(charm_recipe_build_job)
+
+ @classmethod
+ def iterReady(cls):
+ """See `IJobSource`."""
+ jobs = IMasterStore(CharmRecipeBuildJob).find(
+ CharmRecipeBuildJob,
+ CharmRecipeBuildJob.job_type == cls.class_job_type,
+ CharmRecipeBuildJob.job == Job.id,
+ Job.id.is_in(Job.ready_jobs))
+ return (cls(job) for job in jobs)
+
+ def getOopsVars(self):
+ """See `IRunnableJob`."""
+ oops_vars = super(CharmRecipeBuildJobDerived, self).getOopsVars()
+ recipe = self.context.build.recipe
+ oops_vars.extend([
+ ("job_id", self.context.job.id),
+ ("job_type", self.context.job_type.title),
+ ("build_id", self.context.build.id),
+ ("recipe_owner_name", recipe.owner.name),
+ ("recipe_project_name", recipe.project.name),
+ ("recipe_name", recipe.name),
+ ])
+ return oops_vars
+
+
+class RetryableCharmhubError(CharmhubError):
+ pass
+
+
+@implementer(ICharmhubUploadJob)
+@provider(ICharmhubUploadJobSource)
+class CharmhubUploadJob(CharmRecipeBuildJobDerived):
+ """A Job that uploads a charm recipe build to Charmhub."""
+
+ class_job_type = CharmRecipeBuildJobType.CHARMHUB_UPLOAD
+
+ user_error_types = (
+ UnauthorizedUploadResponse,
+ ReviewFailedResponse,
+ ReleaseFailedResponse,
+ )
+
+ retry_error_types = (UploadNotReviewedYetResponse, RetryableCharmhubError)
+ max_retries = 30
+
+ config = config.ICharmhubUploadJobSource
+
+ @classmethod
+ def create(cls, build):
+ """See `ICharmhubUploadJobSource`."""
+ charm_recipe_build_job = CharmRecipeBuildJob(
+ build, cls.class_job_type, {})
+ job = cls(charm_recipe_build_job)
+ job.celeryRunOnCommit()
+ del get_property_cache(build).last_store_upload_job
+ return job
+
+ @property
+ def store_metadata(self):
+ """See `ICharmhubUploadJob`."""
+ intermediate = {}
+ intermediate.update(self.metadata)
+ intermediate.update(self.build.store_upload_metadata or {})
+ return intermediate
+
+ @property
+ def error_message(self):
+ """See `ICharmhubUploadJob`."""
+ return self.metadata.get("error_message")
+
+ @error_message.setter
+ def error_message(self, message):
+ """See `ICharmhubUploadJob`."""
+ self.metadata["error_message"] = message
+
+ @property
+ def error_detail(self):
+ """See `ICharmhubUploadJob`."""
+ return self.metadata.get("error_detail")
+
+ @error_detail.setter
+ def error_detail(self, detail):
+ """See `ICharmhubUploadJob`."""
+ self.metadata["error_detail"] = detail
+
+ @property
+ def store_revision(self):
+ """See `ICharmhubUploadJob`."""
+ return self.store_metadata.get("store_revision")
+
+ @store_revision.setter
+ def store_revision(self, revision):
+ """See `ICharmhubUploadJob`."""
+ if self.build.store_upload_metadata is None:
+ self.build.store_upload_metadata = {}
+ self.build.store_upload_metadata["store_revision"] = revision
+
+ @property
+ def status_url(self):
+ """See `ICharmhubUploadJob`."""
+ return self.store_metadata.get("status_url")
+
+ @status_url.setter
+ def status_url(self, url):
+ if self.build.store_upload_metadata is None:
+ self.build.store_upload_metadata = {}
+ self.build.store_upload_metadata["status_url"] = url
+
+ def getOopsVars(self):
+ """See `IRunnableJob`."""
+ oops_vars = super(CharmhubUploadJob, self).getOopsVars()
+ oops_vars.append(("error_detail", self.error_detail))
+ return oops_vars
+
+ @property
+ def retry_delay(self):
+ """See `BaseRunnableJob`."""
+ if "status_url" in self.store_metadata and self.store_revision is None:
+ # At the moment we have to poll the status endpoint to find out
+ # if Charmhub has finished scanning. Try to deal with easy
+ # cases quickly without hammering our job runners or Charmhub
+ # too badly.
+ delays = (15, 15, 30, 30)
+ try:
+ return timedelta(seconds=delays[self.attempt_count - 1])
+ except IndexError:
+ pass
+ return timedelta(minutes=1)
+
+ def run(self):
+ """See `IRunnableJob`."""
+ client = getUtility(ICharmhubClient)
+ try:
+ try:
+ if "status_url" not in self.store_metadata:
+ self.status_url = client.upload(self.build)
+ # We made progress, so reset attempt_count.
+ self.attempt_count = 1
+ if self.store_revision is None:
+ self.store_revision = client.checkStatus(self.status_url)
+ if self.store_revision is None:
+ raise AssertionError(
+ "checkStatus returned successfully but with no "
+ "revision")
+ # We made progress, so reset attempt_count.
+ self.attempt_count = 1
+ if self.build.recipe.store_channels:
+ client.release(self.build, self.store_revision)
+ self.error_message = None
+ except self.retry_error_types:
+ raise
+ except Exception as e:
+ if (isinstance(e, CharmhubError) and e.can_retry and
+ self.attempt_count <= self.max_retries):
+ raise RetryableCharmhubError(e.args[0], detail=e.detail)
+ self.error_message = str(e)
+ self.error_detail = getattr(e, "detail", None)
+ mailer_factory = None
+ if isinstance(e, UnauthorizedUploadResponse):
+ mailer_factory = (
+ CharmRecipeBuildMailer.forUnauthorizedUpload)
+ elif isinstance(e, UploadFailedResponse):
+ mailer_factory = CharmRecipeBuildMailer.forUploadFailure
+ elif isinstance(
+ e, (BadReviewStatusResponse, ReviewFailedResponse)):
+ mailer_factory = (
+ CharmRecipeBuildMailer.forUploadReviewFailure)
+ elif isinstance(e, ReleaseFailedResponse):
+ mailer_factory = CharmRecipeBuildMailer.forReleaseFailure
+ if mailer_factory is not None:
+ mailer_factory(self.build).sendAll()
+ raise
+ except Exception:
+ # The normal job infrastructure will abort the transaction, but
+ # we want to commit instead: the only database changes we make
+ # are to this job's metadata and should be preserved.
+ transaction.commit()
+ raise
diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
index 864c0d6..ce61daa 100644
--- a/lib/lp/charms/tests/test_charmhubclient.py
+++ b/lib/lp/charms/tests/test_charmhubclient.py
@@ -425,6 +425,30 @@ class TestCharmhubClient(TestCaseWithFactory):
self.client.checkStatus, build, status_url)
@responses.activate
+ def test_checkStatus_approved_no_revision(self):
+ self._setUpSecretStorage()
+ build = self.makeUploadableCharmRecipeBuild()
+ status_url = (
+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
+ "?upload-id=123")
+ responses.add(
+ "GET", status_url,
+ json={
+ "revisions": [
+ {
+ "upload-id": "123",
+ "status": "approved",
+ "revision": None,
+ "errors": None,
+ },
+ ],
+ })
+ self.assertRaisesWithContent(
+ ReviewFailedResponse,
+ "Review passed but did not assign a revision.",
+ self.client.checkStatus, build, status_url)
+
+ @responses.activate
def test_checkStatus_approved(self):
self._setUpSecretStorage()
build = self.makeUploadableCharmRecipeBuild()
diff --git a/lib/lp/charms/tests/test_charmrecipebuildjob.py b/lib/lp/charms/tests/test_charmrecipebuildjob.py
new file mode 100644
index 0000000..97d2f21
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmrecipebuildjob.py
@@ -0,0 +1,565 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for charm recipe build jobs."""
+
+__metaclass__ = type
+
+from datetime import timedelta
+
+from fixtures import FakeLogger
+import transaction
+from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.enums import BuildStatus
+from lp.charms.interfaces.charmhubclient import (
+ ICharmhubClient,
+ ReleaseFailedResponse,
+ ReviewFailedResponse,
+ UnauthorizedUploadResponse,
+ UploadFailedResponse,
+ UploadNotReviewedYetResponse,
+ )
+from lp.charms.interfaces.charmrecipe import (
+ CHARM_RECIPE_ALLOW_CREATE,
+ CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+ )
+from lp.charms.interfaces.charmrecipebuildjob import (
+ ICharmhubUploadJob,
+ ICharmRecipeBuildJob,
+ )
+from lp.charms.model.charmrecipebuild import CharmRecipeBuild
+from lp.charms.model.charmrecipebuildjob import (
+ CharmhubUploadJob,
+ CharmRecipeBuildJob,
+ CharmRecipeBuildJobType,
+ )
+from lp.services.config import config
+from lp.services.database.interfaces import IStore
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.fixture import ZopeUtilityFixture
+from lp.testing.layers import (
+ DatabaseFunctionalLayer,
+ LaunchpadZopelessLayer,
+ )
+from lp.testing.mail_helpers import pop_notifications
+
+
+def run_isolated_jobs(jobs):
+ """Run a sequence of jobs, ensuring transaction isolation.
+
+ We abort the transaction after each job to make sure that there is no
+ relevant uncommitted work.
+ """
+ for job in jobs:
+ JobRunner([job]).runAll()
+ transaction.abort()
+
+
+@implementer(ICharmhubClient)
+class FakeCharmhubClient:
+
+ def __init__(self):
+ self.upload = FakeMethod()
+ self.checkStatus = FakeMethod()
+ self.release = FakeMethod()
+
+
+class TestCharmRecipeBuildJob(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+ def test_provides_interface(self):
+ # `CharmRecipeBuildJob` objects provide `ICharmRecipeBuildJob`.
+ build = self.factory.makeCharmRecipeBuild()
+ self.assertProvides(
+ CharmRecipeBuildJob(
+ build, CharmRecipeBuildJobType.CHARMHUB_UPLOAD, {}),
+ ICharmRecipeBuildJob)
+
+
+class TestCharmhubUploadJob(TestCaseWithFactory):
+
+ layer = LaunchpadZopelessLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({
+ CHARM_RECIPE_ALLOW_CREATE: "on",
+ CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+ }))
+ self.status_url = (
+ "http://charmhub.example/v1/charm/test-charm/revisions/review"
+ "?upload-id=123")
+
+ def test_provides_interface(self):
+ # `CharmhubUploadJob` objects provide `ICharmhubUploadJob`.
+ build = self.factory.makeCharmRecipeBuild()
+ job = CharmhubUploadJob.create(build)
+ self.assertProvides(job, ICharmhubUploadJob)
+
+ def test___repr__(self):
+ # `CharmhubUploadJob` objects have an informative __repr__.
+ build = self.factory.makeCharmRecipeBuild()
+ job = CharmhubUploadJob.create(build)
+ self.assertEqual(
+ "<CharmhubUploadJob for ~%s/%s/+charm/%s/+build/%d>" % (
+ build.recipe.owner.name, build.recipe.project.name,
+ build.recipe.name, build.id),
+ repr(job))
+
+ def makeCharmRecipeBuild(self, **kwargs):
+ # Make a build with a builder and a webhook.
+ build = self.factory.makeCharmRecipeBuild(
+ builder=self.factory.makeBuilder(), **kwargs)
+ build.updateStatus(BuildStatus.FULLYBUILT)
+ self.factory.makeWebhook(
+ target=build.recipe, event_types=["charm-recipe:build:0.1"])
+ return build
+
+ def test_run(self):
+ # The job uploads the build to Charmhub and records the Charmhub
+ # revision.
+ build = self.makeCharmRecipeBuild()
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.result = self.status_url
+ client.checkStatus.result = 1
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertEqual(1, job.store_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+
+ def test_run_failed(self):
+ # A failed run sets the store upload status to FAILED.
+ build = self.makeCharmRecipeBuild()
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.failure = ValueError("An upload failure")
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertIsNone(job.store_revision)
+ self.assertEqual("An upload failure", job.error_message)
+ self.assertEqual([], pop_notifications())
+
+ def test_run_unauthorized_notifies(self):
+ # A run that gets 401 from Charmhub sends mail.
+ requester = self.factory.makePerson(name="requester")
+ requester_team = self.factory.makeTeam(
+ owner=requester, name="requester-team", members=[requester])
+ project = self.factory.makeProduct(name="test-project")
+ build = self.makeCharmRecipeBuild(
+ requester=requester_team, name="test-charm", owner=requester_team,
+ project=project)
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.failure = UnauthorizedUploadResponse(
+ "Authorization failed.")
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertIsNone(job.store_revision)
+ self.assertEqual("Authorization failed.", job.error_message)
+ [notification] = pop_notifications()
+ self.assertEqual(
+ config.canonical.noreply_from_address, notification["From"])
+ self.assertEqual(
+ "Requester <%s>" % requester.preferredemail.email,
+ notification["To"])
+ subject = notification["Subject"].replace("\n ", " ")
+ self.assertEqual(
+ "Charmhub authorization failed for test-charm", subject)
+ self.assertEqual(
+ "Requester @requester-team",
+ notification["X-Launchpad-Message-Rationale"])
+ self.assertEqual(
+ requester_team.name, notification["X-Launchpad-Message-For"])
+ self.assertEqual(
+ "charm-recipe-build-upload-unauthorized",
+ notification["X-Launchpad-Notification-Type"])
+ body, footer = (
+ notification.get_payload(decode=True).decode().split("\n-- \n"))
+ self.assertIn(
+ "http://launchpad.test/~requester-team/test-project/+charm/"
+ "test-charm/+authorize",
+ body)
+ self.assertEqual(
+ "http://launchpad.test/~requester-team/test-project/+charm/"
+ "test-charm/+build/%d\n"
+ "Your team Requester Team is the requester of the build.\n" %
+ build.id, footer)
+
+ def test_run_502_retries(self):
+ # A run that gets a 502 error from Charmhub schedules itself to be
+ # retried.
+ build = self.makeCharmRecipeBuild()
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.failure = UploadFailedResponse(
+ "Proxy error", can_retry=True)
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertIsNone(job.store_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.WAITING, job.job.status)
+ # Try again. The upload part of the job is retried, and this time
+ # it succeeds.
+ job.scheduled_start = None
+ client.upload.calls = []
+ client.upload.failure = None
+ client.upload.result = self.status_url
+ client.checkStatus.result = 1
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertEqual(1, job.store_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
+
+ def test_run_upload_failure_notifies(self):
+ # A run that gets some other upload failure from Charmhub sends
+ # mail.
+ requester = self.factory.makePerson(name="requester")
+ requester_team = self.factory.makeTeam(
+ owner=requester, name="requester-team", members=[requester])
+ project = self.factory.makeProduct(name="test-project")
+ build = self.makeCharmRecipeBuild(
+ requester=requester_team, name="test-charm", owner=requester_team,
+ project=project)
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.failure = UploadFailedResponse(
+ "Failed to upload", detail="The proxy exploded.\n")
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertIsNone(job.store_revision)
+ self.assertEqual("Failed to upload", job.error_message)
+ [notification] = pop_notifications()
+ self.assertEqual(
+ config.canonical.noreply_from_address, notification["From"])
+ self.assertEqual(
+ "Requester <%s>" % requester.preferredemail.email,
+ notification["To"])
+ subject = notification["Subject"].replace("\n ", " ")
+ self.assertEqual("Charmhub upload failed for test-charm", subject)
+ self.assertEqual(
+ "Requester @requester-team",
+ notification["X-Launchpad-Message-Rationale"])
+ self.assertEqual(
+ requester_team.name, notification["X-Launchpad-Message-For"])
+ self.assertEqual(
+ "charm-recipe-build-upload-failed",
+ notification["X-Launchpad-Notification-Type"])
+ body, footer = (
+ notification.get_payload(decode=True).decode().split("\n-- \n"))
+ self.assertIn("Failed to upload", body)
+ build_url = (
+ "http://launchpad.test/~requester-team/test-project/+charm/"
+ "test-charm/+build/%d" %
+ build.id)
+ self.assertIn(build_url, body)
+ self.assertEqual(
+ "%s\nYour team Requester Team is the requester of the build.\n" %
+ build_url, footer)
+ self.assertIn(
+ ("error_detail", "The proxy exploded.\n"), job.getOopsVars())
+
+ def test_run_review_pending_retries(self):
+ # A run that finds that Charmhub has not yet finished reviewing the
+ # charm schedules itself to be retried.
+ build = self.makeCharmRecipeBuild()
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.result = self.status_url
+ client.checkStatus.failure = UploadNotReviewedYetResponse()
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertIsNone(job.store_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.WAITING, job.job.status)
+ # Try again. The upload part of the job is not retried, and this
+ # time the review completes.
+ job.scheduled_start = None
+ client.upload.calls = []
+ client.checkStatus.calls = []
+ client.checkStatus.failure = None
+ client.checkStatus.result = 1
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertEqual(1, job.store_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
+
+ def test_run_review_failure_notifies(self):
+ # A run that gets a review failure from Charmhub sends mail.
+ requester = self.factory.makePerson(name="requester")
+ requester_team = self.factory.makeTeam(
+ owner=requester, name="requester-team", members=[requester])
+ project = self.factory.makeProduct(name="test-project")
+ build = self.makeCharmRecipeBuild(
+ requester=requester_team, name="test-charm", owner=requester_team,
+ project=project)
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.result = self.status_url
+ client.checkStatus.failure = ReviewFailedResponse(
+ "Review failed.\nCharm is terrible.")
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertIsNone(job.store_revision)
+ self.assertEqual(
+ "Review failed.\nCharm is terrible.", job.error_message)
+ [notification] = pop_notifications()
+ self.assertEqual(
+ config.canonical.noreply_from_address, notification["From"])
+ self.assertEqual(
+ "Requester <%s>" % requester.preferredemail.email,
+ notification["To"])
+ subject = notification["Subject"].replace("\n ", " ")
+ self.assertEqual(
+ "Charmhub upload review failed for test-charm", subject)
+ self.assertEqual(
+ "Requester @requester-team",
+ notification["X-Launchpad-Message-Rationale"])
+ self.assertEqual(
+ requester_team.name, notification["X-Launchpad-Message-For"])
+ self.assertEqual(
+ "charm-recipe-build-upload-review-failed",
+ notification["X-Launchpad-Notification-Type"])
+ body, footer = (
+ notification.get_payload(decode=True).decode().split("\n-- \n"))
+ self.assertIn("Review failed.", body)
+ self.assertEqual(
+ "http://launchpad.test/~requester-team/test-project/+charm/"
+ "test-charm/+build/%d\n"
+ "Your team Requester Team is the requester of the build.\n" %
+ build.id, footer)
+
+ def test_run_release(self):
+ # A run configured to automatically release the charm to certain
+ # channels does so.
+ build = self.makeCharmRecipeBuild(store_channels=["stable", "edge"])
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.result = self.status_url
+ client.checkStatus.result = 1
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([((build, 1), {})], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertEqual(1, job.store_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+
+ def test_run_release_failure_notifies(self):
+ # A run configured to automatically release the charm to certain
+ # channels but that fails to do so sends mail.
+ requester = self.factory.makePerson(name="requester")
+ requester_team = self.factory.makeTeam(
+ owner=requester, name="requester-team", members=[requester])
+ project = self.factory.makeProduct(name="test-project")
+ build = self.makeCharmRecipeBuild(
+ requester=requester_team, name="test-charm", owner=requester_team,
+ project=project, store_channels=["stable", "edge"])
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.result = self.status_url
+ client.checkStatus.result = 1
+ client.release.failure = ReleaseFailedResponse("Failed to release")
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ JobRunner([job]).runAll()
+ self.assertEqual([((build,), {})], client.upload.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertEqual([((build, 1), {})], client.release.calls)
+ self.assertContentEqual([job], build.store_upload_jobs)
+ self.assertEqual(1, job.store_revision)
+ self.assertEqual("Failed to release", job.error_message)
+ [notification] = pop_notifications()
+ self.assertEqual(
+ config.canonical.noreply_from_address, notification["From"])
+ self.assertEqual(
+ "Requester <%s>" % requester.preferredemail.email,
+ notification["To"])
+ subject = notification["Subject"].replace("\n", " ")
+ self.assertEqual("Charmhub release failed for test-charm", subject)
+ self.assertEqual(
+ "Requester @requester-team",
+ notification["X-Launchpad-Message-Rationale"])
+ self.assertEqual(
+ requester_team.name, notification["X-Launchpad-Message-For"])
+ self.assertEqual(
+ "charm-recipe-build-release-failed",
+ notification["X-Launchpad-Notification-Type"])
+ body, footer = (
+ notification.get_payload(decode=True).decode().split("\n-- \n"))
+ self.assertIn("Failed to release", body)
+ self.assertEqual(
+ "http://launchpad.test/~requester-team/test-project/+charm/"
+ "test-charm/+build/%d\n"
+ "Your team Requester Team is the requester of the build.\n" %
+ build.id, footer)
+
+ def test_retry_delay(self):
+ # The job is retried every minute, unless it just made one of its
+ # first four attempts to poll the status endpoint, in which case the
+ # delays are 15/15/30/30 seconds.
+ self.useFixture(FakeLogger())
+ build = self.makeCharmRecipeBuild()
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.failure = UploadFailedResponse(
+ "Proxy error", can_retry=True)
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertNotIn("status_url", job.metadata)
+ self.assertEqual(timedelta(seconds=60), job.retry_delay)
+ job.scheduled_start = None
+ client.upload.failure = None
+ client.upload.result = self.status_url
+ client.checkStatus.failure = UploadNotReviewedYetResponse()
+ for expected_delay in (15, 15, 30, 30, 60):
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertIn("status_url", job.build.store_upload_metadata)
+ self.assertEqual(
+ timedelta(seconds=expected_delay), job.retry_delay)
+ job.scheduled_start = None
+ client.checkStatus.failure = None
+ client.checkStatus.result = 1
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
+
+ def test_retry_after_upload_does_not_upload(self):
+ # If the job has uploaded, but failed to release, it should
+ # not attempt to upload again on the next run.
+ self.useFixture(FakeLogger())
+ build = self.makeCharmRecipeBuild(store_channels=["stable", "edge"])
+ self.assertContentEqual([], build.store_upload_jobs)
+ job = CharmhubUploadJob.create(build)
+ client = FakeCharmhubClient()
+ client.upload.result = self.status_url
+ client.checkStatus.result = 1
+ client.release.failure = ReleaseFailedResponse(
+ "Proxy error", can_retry=True)
+ self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+
+ previous_upload = client.upload.calls
+ previous_checkStatus = client.checkStatus.calls
+ len_previous_release = len(client.release.calls)
+
+ # Check we uploaded as expected
+ self.assertEqual(1, job.store_revision)
+ self.assertEqual(timedelta(seconds=60), job.retry_delay)
+ self.assertEqual(1, len(client.upload.calls))
+ self.assertIsNone(job.error_message)
+
+ # Run the job again
+ with dbuser(config.ICharmhubUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+
+ # We should not have called `upload`, but moved straight to `release`
+ self.assertEqual(previous_upload, client.upload.calls)
+ self.assertEqual(previous_checkStatus, client.checkStatus.calls)
+ self.assertEqual(len_previous_release + 1, len(client.release.calls))
+ self.assertIsNone(job.error_message)
+
+ def test_with_build_metadata_as_none(self):
+ db_build = self.factory.makeCharmRecipeBuild()
+ removeSecurityProxy(db_build).store_upload_metadata = None
+ store = IStore(CharmRecipeBuild)
+ store.flush()
+ loaded_build = store.find(CharmRecipeBuild, id=db_build.id).one()
+
+ job = CharmhubUploadJob.create(loaded_build)
+ self.assertEqual({}, job.store_metadata)
+
+ def test_with_build_metadata_as_none_set_status(self):
+ db_build = self.factory.makeCharmRecipeBuild()
+ removeSecurityProxy(db_build).store_upload_metadata = None
+ store = IStore(CharmRecipeBuild)
+ store.flush()
+ loaded_build = store.find(CharmRecipeBuild, id=db_build.id).one()
+
+ job = CharmhubUploadJob.create(loaded_build)
+ job.status_url = 'http://example.org'
+ store.flush()
+
+ loaded_build = store.find(CharmRecipeBuild, id=db_build.id).one()
+ self.assertEqual(
+ 'http://example.org',
+ loaded_build.store_upload_metadata['status_url'])
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 413e38b..18e4ffe 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1909,6 +1909,11 @@ runner_class: TwistedJobRunner
module: lp.code.interfaces.branchjob
dbuser: upgrade-branches
+[ICharmhubUploadJobSource]
+module: lp.charms.interfaces.charmrecipebuildjob
+dbuser: charm-build-job
+crontab_group: MAIN
+
[ICharmRecipeRequestBuildsJobSource]
module: lp.charms.interfaces.charmrecipejob
dbuser: charm-build-job