launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27495
[Merge] ~cjwatson/launchpad:charmhub-upload-ui into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-upload-ui into launchpad:master.
Commit message:
Add UI for Charmhub uploads
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/408118
In particular, this makes it possible to retry upload failures.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-upload-ui into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipebuild.py b/lib/lp/charms/browser/charmrecipebuild.py
index 5addc5b..518d8c3 100644
--- a/lib/lp/charms/browser/charmrecipebuild.py
+++ b/lib/lp/charms/browser/charmrecipebuild.py
@@ -16,7 +16,10 @@ from lp.app.browser.launchpadform import (
action,
LaunchpadFormView,
)
-from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.charms.interfaces.charmrecipebuild import (
+ CannotScheduleStoreUpload,
+ ICharmRecipeBuild,
+ )
from lp.services.librarian.browser import (
FileNavigationMixin,
ProxiedLibraryFileAlias,
@@ -26,7 +29,6 @@ from lp.services.webapp import (
canonical_url,
ContextMenu,
enabled_with_permission,
- LaunchpadView,
Link,
Navigation,
)
@@ -65,9 +67,12 @@ class CharmRecipeBuildContextMenu(ContextMenu):
enabled=self.context.can_be_rescored)
-class CharmRecipeBuildView(LaunchpadView):
+class CharmRecipeBuildView(LaunchpadFormView):
"""Default view of a charm recipe build."""
+ class schema(Interface):
+ """Schema for uploading a build."""
+
@property
def label(self):
return self.context.title
@@ -92,6 +97,18 @@ class CharmRecipeBuildView(LaunchpadView):
def next_url(self):
return canonical_url(self.context)
+ @action("Upload build to Charmhub", name="upload")
+ def upload_action(self, action, data):
+ """Schedule an upload of this build to Charmhub."""
+ try:
+ self.context.scheduleStoreUpload()
+ except CannotScheduleStoreUpload as e:
+ self.request.response.addWarningNotification(str(e))
+ else:
+ self.request.response.addInfoNotification(
+ "An upload has been scheduled and will run as soon as "
+ "possible.")
+
class CharmRecipeBuildRetryView(LaunchpadFormView):
"""View for retrying a charm recipe build."""
diff --git a/lib/lp/charms/browser/tests/test_charmrecipebuild.py b/lib/lp/charms/browser/tests/test_charmrecipebuild.py
index 08c13a1..0abb77b 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipebuild.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipebuild.py
@@ -8,6 +8,7 @@ __metaclass__ = type
import re
from fixtures import FakeLogger
+from pymacaroons import Macaroon
import soupmatchers
from storm.locals import Store
from testtools.matchers import StartsWith
@@ -21,7 +22,9 @@ from lp.app.enums import InformationType
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.buildmaster.enums import BuildStatus
from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.charms.interfaces.charmrecipebuildjob import ICharmhubUploadJobSource
from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
from lp.services.webapp import canonical_url
from lp.testing import (
ANONYMOUS,
@@ -98,6 +101,63 @@ class TestCharmRecipeBuildView(TestCaseWithFactory):
"revision ID", "li", attrs={"id": "revision-id"},
text=re.compile(r"^\s*Revision: dummy\s*$"))))
+ def test_store_upload_status_in_progress(self):
+ build = self.factory.makeCharmRecipeBuild(
+ status=BuildStatus.FULLYBUILT)
+ getUtility(ICharmhubUploadJobSource).create(build)
+ build_view = create_initialized_view(build, "+index")
+ self.assertThat(build_view(), soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "store upload status", "li",
+ attrs={"id": "store-upload-status"},
+ text=re.compile(r"^\s*Charmhub upload in progress\s*$"))))
+
+ def test_store_upload_status_completed(self):
+ build = self.factory.makeCharmRecipeBuild(
+ status=BuildStatus.FULLYBUILT)
+ job = getUtility(ICharmhubUploadJobSource).create(build)
+ naked_job = removeSecurityProxy(job)
+ naked_job.job._status = JobStatus.COMPLETED
+ build_view = create_initialized_view(build, "+index")
+ self.assertThat(build_view(), soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "store upload status", "li",
+ attrs={"id": "store-upload-status"},
+ text=re.compile(r"^\s*Uploaded to Charmhub\s*$"))))
+
+ def test_store_upload_status_failed(self):
+ build = self.factory.makeCharmRecipeBuild(
+ status=BuildStatus.FULLYBUILT)
+ job = getUtility(ICharmhubUploadJobSource).create(build)
+ naked_job = removeSecurityProxy(job)
+ naked_job.job._status = JobStatus.FAILED
+ naked_job.error_message = "Review failed."
+ build_view = create_initialized_view(build, "+index")
+ self.assertThat(build_view(), soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "store upload status", "li",
+ attrs={"id": "store-upload-status"},
+ text=re.compile(
+ r"^\s*Charmhub upload failed:\s+"
+ r"Review failed.\s*$"))))
+
+ def test_store_upload_status_release_failed(self):
+ build = self.factory.makeCharmRecipeBuild(
+ status=BuildStatus.FULLYBUILT)
+ job = getUtility(ICharmhubUploadJobSource).create(build)
+ naked_job = removeSecurityProxy(job)
+ naked_job.job._status = JobStatus.FAILED
+ naked_job.store_revision = 1
+ naked_job.error_message = "Failed to publish"
+ build_view = create_initialized_view(build, "+index")
+ self.assertThat(build_view(), soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "store upload status", "li",
+ attrs={"id": "store-upload-status"},
+ text=re.compile(
+ r"^\s*Charmhub release failed:\s+"
+ r"Failed to publish\s*$"))))
+
class TestCharmRecipeBuildOperations(BrowserTestCase):
@@ -232,6 +292,71 @@ class TestCharmRecipeBuildOperations(BrowserTestCase):
"notification", "div", attrs={"class": "warning message"},
text="Cannot rescore this build because it is not queued.")))
+ def setUpStoreUpload(self):
+ self.pushConfig("charms", charmhub_url="http://charmhub.example/")
+ with person_logged_in(self.requester):
+ self.build.recipe.store_name = self.factory.getUniqueUnicode()
+ # CharmRecipe.can_upload_to_store only checks whether
+ # "exchanged_encrypted" is present, so don't bother setting up
+ # encryption keys here.
+ self.build.recipe.store_secrets = {
+ "exchanged_encrypted": Macaroon().serialize()}
+
+ def test_store_upload(self):
+ # A build not previously uploaded to Charmhub can be uploaded
+ # manually.
+ self.setUpStoreUpload()
+ self.build.updateStatus(BuildStatus.FULLYBUILT)
+ self.factory.makeCharmFile(
+ build=self.build,
+ library_file=self.factory.makeLibraryFileAlias(db_only=True))
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ browser.getControl("Upload this charm to Charmhub").click()
+ self.assertEqual(self.build_url, browser.url)
+ login(ANONYMOUS)
+ [job] = getUtility(ICharmhubUploadJobSource).iterReady()
+ self.assertEqual(JobStatus.WAITING, job.job.status)
+ self.assertEqual(self.build, job.build)
+ self.assertEqual(
+ "An upload has been scheduled and will run as soon as possible.",
+ extract_text(find_tags_by_class(browser.contents, "message")[0]))
+
+ def test_store_upload_retry(self):
+ # A build with a previously-failed Charmhub upload can have the
+ # upload retried.
+ self.setUpStoreUpload()
+ self.build.updateStatus(BuildStatus.FULLYBUILT)
+ self.factory.makeCharmFile(
+ build=self.build,
+ library_file=self.factory.makeLibraryFileAlias(db_only=True))
+ old_job = getUtility(ICharmhubUploadJobSource).create(self.build)
+ removeSecurityProxy(old_job).job._status = JobStatus.FAILED
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ browser.getControl("Retry").click()
+ self.assertEqual(self.build_url, browser.url)
+ login(ANONYMOUS)
+ [job] = getUtility(ICharmhubUploadJobSource).iterReady()
+ self.assertEqual(JobStatus.WAITING, job.job.status)
+ self.assertEqual(self.build, job.build)
+ self.assertEqual(
+ "An upload has been scheduled and will run as soon as possible.",
+ extract_text(find_tags_by_class(browser.contents, "message")[0]))
+
+ def test_store_upload_error_notifies(self):
+ # If a build cannot be scheduled for uploading to Charmhub, we issue
+ # a notification.
+ self.setUpStoreUpload()
+ self.build.updateStatus(BuildStatus.FULLYBUILT)
+ browser = self.getViewBrowser(self.build, user=self.requester)
+ browser.getControl("Upload this charm to Charmhub").click()
+ self.assertEqual(self.build_url, browser.url)
+ login(ANONYMOUS)
+ self.assertEqual(
+ [], list(getUtility(ICharmhubUploadJobSource).iterReady()))
+ self.assertEqual(
+ "Cannot upload this charm because it has no files.",
+ extract_text(find_tags_by_class(browser.contents, "message")[0]))
+
def test_builder_history(self):
Store.of(self.build).flush()
self.build.updateStatus(
diff --git a/lib/lp/charms/templates/charmrecipebuild-index.pt b/lib/lp/charms/templates/charmrecipebuild-index.pt
index 1d0e4f0..95115bf 100644
--- a/lib/lp/charms/templates/charmrecipebuild-index.pt
+++ b/lib/lp/charms/templates/charmrecipebuild-index.pt
@@ -155,6 +155,44 @@
tal:attributes="href context/upload_log_url">uploadlog</a>
(<span tal:replace="file/content/filesize/fmt:bytes" />)
</li>
+ <li id="store-upload-status"
+ tal:define="job context/last_store_upload_job"
+ tal:condition="job">
+ <tal:pending
+ condition="context/store_upload_status/enumvalue:PENDING">
+ Charmhub upload in progress
+ </tal:pending>
+ <tal:failed-upload
+ condition="context/store_upload_status/enumvalue:FAILEDTOUPLOAD">
+ Charmhub upload failed:
+ <tal:error-message replace="context/store_upload_error_message" />
+ <form action="" method="POST">
+ <input type="submit" name="field.actions.upload" value="Retry" />
+ </form>
+ </tal:failed-upload>
+ <tal:failed-release
+ condition="context/store_upload_status/enumvalue:FAILEDTORELEASE">
+ Charmhub release failed:
+ <tal:error-message replace="context/store_upload_error_message" />
+ <form action="" method="POST">
+ <input type="submit" name="field.actions.upload" value="Retry" />
+ </form>
+ </tal:failed-release>
+ <tal:uploaded
+ condition="context/store_upload_status/enumvalue:UPLOADED">
+ Uploaded to Charmhub
+ </tal:uploaded>
+ </li>
+ <li id="store-upload-status"
+ tal:condition="python:
+ context.status.title == 'Successfully built' and
+ context.recipe.can_upload_to_store and
+ context.last_store_upload_job is None">
+ <form action="" method="POST">
+ <input type="submit" name="field.actions.upload"
+ value="Upload this charm to Charmhub" />
+ </form>
+ </li>
</ul>
<div