launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #20335
[Merge] lp:~cjwatson/launchpad/snap-store-upload-job into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-store-upload-job into lp:launchpad with lp:~cjwatson/launchpad/snap-store-client as a prerequisite.
Commit message:
Add a job to upload completed snap builds to the store.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1572605 in Launchpad itself: "Automatically upload snap builds to store"
https://bugs.launchpad.net/launchpad/+bug/1572605
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-store-upload-job/+merge/294031
Add a job to upload completed snap builds to the store.
We'll need to create a snap-build-job DB user before landing this.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-store-upload-job into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg 2016-04-15 17:31:36 +0000
+++ database/schema/security.cfg 2016-05-06 16:40:11 +0000
@@ -2519,3 +2519,19 @@
public.sourcepackagename = SELECT
public.webhook = SELECT
public.webhookjob = SELECT, UPDATE
+
+[snap-build-job]
+type=user
+groups=script
+public.distribution = SELECT
+public.distroarchseries = SELECT
+public.distroseries = SELECT
+public.job = SELECT, UPDATE
+public.libraryfilealias = SELECT
+public.libraryfilecontent = SELECT
+public.person = SELECT
+public.snap = SELECT
+public.snapbuild = SELECT, UPDATE
+public.snapbuildjob = SELECT, UPDATE
+public.snapfile = SELECT
+public.snappyseries = SELECT
=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf 2016-05-06 16:40:10 +0000
+++ lib/lp/services/config/schema-lazr.conf 2016-05-06 16:40:11 +0000
@@ -1807,6 +1807,7 @@
IRemoveArtifactSubscriptionsJobSource,
ISelfRenewalNotificationJobSource,
ISevenDayCommercialExpirationJobSource,
+ ISnapStoreUploadJobSource,
ITeamInvitationNotificationJobSource,
ITeamJoinNotificationJobSource,
IThirtyDayCommercialExpirationJobSource
@@ -1948,6 +1949,10 @@
dbuser: product-job
crontab_group: MAIN
+[ISnapStoreUploadJobSource]
+module: lp.snappy.interfaces.snapbuildjob
+dbuser: snap-build-job
+
[ITeamInvitationNotificationJobSource]
module: lp.registry.interfaces.persontransferjob
dbuser: person-transfer-job
=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml 2016-05-06 16:40:10 +0000
+++ lib/lp/snappy/configure.zcml 2016-05-06 16:40:11 +0000
@@ -124,6 +124,20 @@
<allow interface="lp.snappy.interfaces.snapstoreclient.ISnapStoreClient" />
</securedutility>
+ <!-- Snap-related jobs -->
+ <class class="lp.snappy.model.snapbuildjob.SnapBuildJob">
+ <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapBuildJob" />
+ </class>
+ <securedutility
+ component="lp.snappy.model.snapbuildjob.SnapStoreUploadJob"
+ provides="lp.snappy.interfaces.snapbuildjob.ISnapStoreUploadJobSource">
+ <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapStoreUploadJobSource" />
+ </securedutility>
+ <class class="lp.snappy.model.snapbuildjob.SnapStoreUploadJob">
+ <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapBuildJob" />
+ <allow interface="lp.snappy.interfaces.snapbuildjob.ISnapStoreUploadJob" />
+ </class>
+
<webservice:register module="lp.snappy.interfaces.webservice" />
</configure>
=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
--- lib/lp/snappy/interfaces/snapbuild.py 2016-01-19 17:41:11 +0000
+++ lib/lp/snappy/interfaces/snapbuild.py 2016-05-06 16:40:11 +0000
@@ -20,7 +20,10 @@
operation_for_version,
operation_parameters,
)
-from lazr.restful.fields import Reference
+from lazr.restful.fields import (
+ CollectionField,
+ Reference,
+ )
from zope.component.interfaces import IObjectEvent
from zope.interface import Interface
from zope.schema import (
@@ -103,6 +106,12 @@
required=True, readonly=True,
description=_("Whether this build record can be cancelled.")))
+ store_upload_jobs = CollectionField(
+ title=_("Store upload jobs for this build."),
+ # Really ISnapStoreUploadJob.
+ value_type=Reference(schema=Interface),
+ readonly=True)
+
def getFiles():
"""Retrieve the build's `ISnapFile` records.
=== added file 'lib/lp/snappy/interfaces/snapbuildjob.py'
--- lib/lp/snappy/interfaces/snapbuildjob.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/snapbuildjob.py 2016-05-06 16:40:11 +0000
@@ -0,0 +1,58 @@
+# Copyright 2016 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap build job interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'ISnapBuildJob',
+ 'ISnapStoreUploadJob',
+ 'ISnapStoreUploadJobSource',
+ ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+ Attribute,
+ Interface,
+ )
+from zope.schema import TextLine
+
+from lp import _
+from lp.services.job.interfaces.job import (
+ IJob,
+ IJobSource,
+ IRunnableJob,
+ )
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+
+
+class ISnapBuildJob(Interface):
+ """A job related to a snap package."""
+
+ job = Reference(
+ title=_("The common Job attributes."), schema=IJob,
+ required=True, readonly=True)
+
+ snapbuild = Reference(
+ title=_("The snap build to use for this job."),
+ schema=ISnapBuild, required=True, readonly=True)
+
+ metadata = Attribute(_("A dict of data about the job."))
+
+
+class ISnapStoreUploadJob(IRunnableJob):
+ """A Job that uploads a snap build to the store."""
+
+ error_message = TextLine(
+ title=_("Error message"), required=False, readonly=True)
+
+
+class ISnapStoreUploadJobSource(IJobSource):
+
+ def create(snapbuild):
+ """Upload a snap build to the store.
+
+ :param snapbuild: The snap build to upload.
+ """
=== modified file 'lib/lp/snappy/model/snapbuild.py'
--- lib/lp/snappy/model/snapbuild.py 2016-02-19 14:18:22 +0000
+++ lib/lp/snappy/model/snapbuild.py 2016-05-06 16:40:11 +0000
@@ -48,6 +48,7 @@
IStore,
)
from lp.services.features import getFeatureFlag
+from lp.services.job.model.job import Job
from lp.services.librarian.browser import ProxiedLibraryFileAlias
from lp.services.librarian.model import (
LibraryFileAlias,
@@ -65,6 +66,10 @@
ISnapFile,
)
from lp.snappy.mail.snapbuild import SnapBuildMailer
+from lp.snappy.model.snapbuildjob import (
+ SnapBuildJob,
+ SnapBuildJobType,
+ )
from lp.soyuz.interfaces.component import IComponentSet
from lp.soyuz.model.archive import Archive
from lp.soyuz.model.distroarchseries import DistroArchSeries
@@ -346,6 +351,20 @@
def getFileUrls(self):
return [self.lfaUrl(lfa) for _, lfa, _ in self.getFiles()]
+ @property
+ def store_upload_jobs(self):
+ jobs = Store.of(self).find(
+ SnapBuildJob,
+ SnapBuildJob.snapbuild == self,
+ SnapBuildJob.job_type == SnapBuildJobType.STORE_UPLOAD)
+ jobs.order_by(Desc(SnapBuildJob.job_id))
+
+ def preload_jobs(rows):
+ load_related(Job, rows, ["job_id"])
+
+ return DecoratedResultSet(
+ jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
+
@implementer(ISnapBuildSet)
class SnapBuildSet(SpecificBuildFarmJobSourceMixin):
=== added file 'lib/lp/snappy/model/snapbuildjob.py'
--- lib/lp/snappy/model/snapbuildjob.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/snapbuildjob.py 2016-05-06 16:40:11 +0000
@@ -0,0 +1,191 @@
+# Copyright 2016 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap build jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'SnapBuildJob',
+ 'SnapBuildJobType',
+ 'SnapStoreUploadJob',
+ ]
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+ DBEnumeratedType,
+ DBItem,
+ )
+from storm.locals import (
+ Int,
+ JSON,
+ Reference,
+ )
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+ implementer,
+ provider,
+ )
+
+from lp.app.errors import NotFoundError
+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.snappy.interfaces.snapbuildjob import (
+ ISnapBuildJob,
+ ISnapStoreUploadJob,
+ ISnapStoreUploadJobSource,
+ )
+from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
+
+
+class SnapBuildJobType(DBEnumeratedType):
+ """Values that `ISnapBuildJob.job_type` can take."""
+
+ STORE_UPLOAD = DBItem(0, """
+ Store upload
+
+ This job uploads a snap build to the store.
+ """)
+
+
+@implementer(ISnapBuildJob)
+class SnapBuildJob(StormBase):
+ """See `ISnapBuildJob`."""
+
+ __storm_table__ = 'SnapBuildJob'
+
+ job_id = Int(name='job', primary=True, allow_none=False)
+ job = Reference(job_id, 'Job.id')
+
+ snapbuild_id = Int(name='snapbuild', allow_none=False)
+ snapbuild = Reference(snapbuild_id, 'SnapBuild.id')
+
+ job_type = EnumCol(enum=SnapBuildJobType, notNull=True)
+
+ metadata = JSON('json_data', allow_none=False)
+
+ def __init__(self, snapbuild, job_type, metadata, **job_args):
+ """Constructor.
+
+ Extra keyword arguments are used to construct the underlying Job
+ object.
+
+ :param snapbuild: The `ISnapBuild` this job relates to.
+ :param job_type: The `SnapBuildJobType` of this job.
+ :param metadata: The type-specific variables, as a JSON-compatible
+ dict.
+ """
+ super(SnapBuildJob, self).__init__()
+ self.job = Job(**job_args)
+ self.snapbuild = snapbuild
+ self.job_type = job_type
+ self.metadata = metadata
+
+ def makeDerived(self):
+ return SnapBuildJobDerived.makeSubclass(self)
+
+
+@delegate_to(ISnapBuildJob)
+class SnapBuildJobDerived(BaseRunnableJob):
+
+ __metaclass__ = EnumeratedSubclass
+
+ def __init__(self, snap_build_job):
+ self.context = snap_build_job
+
+ def __repr__(self):
+ """An informative representation of the job."""
+ return "<%s for %s>" % (self.__class__.__name__, self.snapbuild.title)
+
+ @classmethod
+ def get(cls, job_id):
+ """Get a job by id.
+
+ :return: The `SnapBuildJob` with the specified id, as the current
+ `SnapBuildJobDerived` subclass.
+ :raises: `NotFoundError` if there is no job with the specified id,
+ or its `job_type` does not match the desired subclass.
+ """
+ snap_build_job = IStore(SnapBuildJob).get(SnapBuildJob, job_id)
+ if snap_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(snap_build_job)
+
+ @classmethod
+ def iterReady(cls):
+ """See `IJobSource`."""
+ jobs = IMasterStore(SnapBuildJob).find(
+ SnapBuildJob,
+ SnapBuildJob.job_type == cls.class_job_type,
+ SnapBuildJob.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(SnapBuildJobDerived, self).getOopsVars()
+ oops_vars.extend([
+ ('job_id', self.context.job.id),
+ ('job_type', self.context.job_type.title),
+ ('snapbuild_id', self.context.snapbuild.id),
+ ('snap_owner_name', self.context.snapbuild.snap.owner.name),
+ ('snap_name', self.context.snapbuild.snap.name),
+ ])
+ return oops_vars
+
+
+@implementer(ISnapStoreUploadJob)
+@provider(ISnapStoreUploadJobSource)
+class SnapStoreUploadJob(SnapBuildJobDerived):
+ """A Job that uploads a snap build to the store."""
+
+ class_job_type = SnapBuildJobType.STORE_UPLOAD
+
+ # XXX cjwatson 2016-05-04: identify transient upload failures and retry
+
+ config = config.ISnapStoreUploadJobSource
+
+ @classmethod
+ def create(cls, snapbuild):
+ """See `ISnapStoreUploadJobSource`."""
+ snap_build_job = SnapBuildJob(snapbuild, cls.class_job_type, {})
+ job = cls(snap_build_job)
+ job.celeryRunOnCommit()
+ return job
+
+ @property
+ def error_message(self):
+ """See `ISnapStoreUploadJob`."""
+ return self.metadata.get("error_message")
+
+ @error_message.setter
+ def error_message(self, message):
+ """See `ISnapStoreUploadJob`."""
+ self.metadata["error_message"] = message
+
+ def run(self):
+ """See `IRunnableJob`."""
+ try:
+ getUtility(ISnapStoreClient).upload(self.snapbuild)
+ self.error_message = None
+ except Exception as e:
+ # Abort work done so far, but make sure that we commit the error
+ # message.
+ transaction.abort()
+ self.error_message = str(e)
+ transaction.commit()
+ raise
=== modified file 'lib/lp/snappy/subscribers/snapbuild.py'
--- lib/lp/snappy/subscribers/snapbuild.py 2016-01-19 17:41:11 +0000
+++ lib/lp/snappy/subscribers/snapbuild.py 2016-05-06 16:40:11 +0000
@@ -9,16 +9,18 @@
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.services.webhooks.payload import compose_webhook_payload
from lp.snappy.interfaces.snap import SNAP_WEBHOOKS_FEATURE_FLAG
from lp.snappy.interfaces.snapbuild import ISnapBuild
+from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
def snap_build_status_changed(snapbuild, event):
- """Trigger webhooks when snap package build statuses change."""
+ """Trigger events when snap package build statuses change."""
if getFeatureFlag(SNAP_WEBHOOKS_FEATURE_FLAG):
payload = {
"snap_build": canonical_url(snapbuild, force_local_path=True),
@@ -28,3 +30,8 @@
ISnapBuild, snapbuild, ["snap", "status"]))
getUtility(IWebhookSet).trigger(
snapbuild.snap, "snap:build:0.1", payload)
+
+ if (snapbuild.snap.store_upload and
+ snapbuild.snap.store_secrets is not None and
+ snapbuild.status == BuildStatus.FULLYBUILT):
+ getUtility(ISnapStoreUploadJobSource).create(snapbuild)
=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py 2016-03-02 21:21:26 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py 2016-05-06 16:40:11 +0000
@@ -250,6 +250,26 @@
hook.id, hook.target),
repr(delivery))
+ def test_updateStatus_failure_does_not_trigger_store_uploads(self):
+ # A failed SnapBuild does not trigger store uploads.
+ self.build.snap.store_series = self.factory.makeSnappySeries()
+ self.build.snap.store_name = self.factory.getUniqueUnicode()
+ self.build.snap.store_upload = True
+ self.build.snap.store_secrets = {
+ "root": "dummy-root", "discharge": "dummy-discharge"}
+ self.build.updateStatus(BuildStatus.FAILEDTOBUILD)
+ self.assertContentEqual([], self.build.store_upload_jobs)
+
+ def test_updateStatus_fullybuilt_triggers_store_uploads(self):
+ # A completed SnapBuild triggers store uploads.
+ self.build.snap.store_series = self.factory.makeSnappySeries()
+ self.build.snap.store_name = self.factory.getUniqueUnicode()
+ self.build.snap.store_upload = True
+ self.build.snap.store_secrets = {
+ "root": "dummy-root", "discharge": "dummy-discharge"}
+ self.build.updateStatus(BuildStatus.FULLYBUILT)
+ self.assertEqual(1, len(list(self.build.store_upload_jobs)))
+
def test_notify_fullybuilt(self):
# notify does not send mail when a SnapBuild completes normally.
person = self.factory.makePerson(name="person")
=== added file 'lib/lp/snappy/tests/test_snapbuildjob.py'
--- lib/lp/snappy/tests/test_snapbuildjob.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_snapbuildjob.py 2016-05-06 16:40:11 +0000
@@ -0,0 +1,105 @@
+# Copyright 2016 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for snap build jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from zope.interface import implementer
+
+from lp.services.config import config
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.runner import JobRunner
+from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
+from lp.snappy.interfaces.snapbuildjob import (
+ ISnapBuildJob,
+ ISnapStoreUploadJob,
+ )
+from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
+from lp.snappy.model.snapbuildjob import (
+ SnapBuildJob,
+ SnapBuildJobType,
+ SnapStoreUploadJob,
+ )
+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,
+ )
+
+
+@implementer(ISnapStoreClient)
+class FakeSnapStoreClient:
+
+ def __init__(self):
+ self.upload = FakeMethod()
+
+
+class TestSnapBuildJob(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestSnapBuildJob, self).setUp()
+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+
+ def test_provides_interface(self):
+ # `SnapBuildJob` objects provide `ISnapBuildJob`.
+ snapbuild = self.factory.makeSnapBuild()
+ self.assertProvides(
+ SnapBuildJob(snapbuild, SnapBuildJobType.STORE_UPLOAD, {}),
+ ISnapBuildJob)
+
+
+class TestSnapStoreUploadJob(TestCaseWithFactory):
+
+ layer = LaunchpadZopelessLayer
+
+ def setUp(self):
+ super(TestSnapStoreUploadJob, self).setUp()
+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+
+ def test_provides_interface(self):
+ # `SnapStoreUploadJob` objects provide `ISnapStoreUploadJob`.
+ snapbuild = self.factory.makeSnapBuild()
+ job = SnapStoreUploadJob.create(snapbuild)
+ self.assertProvides(job, ISnapStoreUploadJob)
+
+ def test___repr__(self):
+ # `SnapStoreUploadJob` objects have an informative __repr__.
+ snapbuild = self.factory.makeSnapBuild()
+ job = SnapStoreUploadJob.create(snapbuild)
+ self.assertEqual(
+ "<SnapStoreUploadJob for %s>" % snapbuild.title, repr(job))
+
+ def test_run(self):
+ # The job uploads the build to the store.
+ snapbuild = self.factory.makeSnapBuild()
+ self.assertContentEqual([], snapbuild.store_upload_jobs)
+ job = SnapStoreUploadJob.create(snapbuild)
+ client = FakeSnapStoreClient()
+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+ JobRunner([job]).runAll()
+ self.assertEqual([((snapbuild,), {})], client.upload.calls)
+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
+ self.assertIsNone(job.error_message)
+
+ def test_run_failed(self):
+ # A failed run sets the store upload status to FAILED.
+ snapbuild = self.factory.makeSnapBuild()
+ self.assertContentEqual([], snapbuild.store_upload_jobs)
+ job = SnapStoreUploadJob.create(snapbuild)
+ client = FakeSnapStoreClient()
+ client.upload.failure = ValueError("An upload failure")
+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+ JobRunner([job]).runAll()
+ self.assertEqual([((snapbuild,), {})], client.upload.calls)
+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
+ self.assertEqual("An upload failure", job.error_message)
Follow ups