launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #30997
[Merge] ~pelpsi/launchpad:snap-component-integration into launchpad:master
You have been requested to review the proposed merge of ~pelpsi/launchpad:snap-component-integration into launchpad:master.
For more details, see:
https://code.launchpad.net/~pelpsi/launchpad/+git/launchpad/+merge/461063
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~pelpsi/launchpad:snap-component-integration into launchpad:master.
diff --git a/lib/lp/snappy/interfaces/snapbuildjob.py b/lib/lp/snappy/interfaces/snapbuildjob.py
index 12e4b8b..90a8d37 100644
--- a/lib/lp/snappy/interfaces/snapbuildjob.py
+++ b/lib/lp/snappy/interfaces/snapbuildjob.py
@@ -13,7 +13,7 @@ __all__ = [
from lazr.restful.fields import Reference
from zope.interface import Attribute, Interface
from zope.interface.interfaces import IObjectEvent
-from zope.schema import Int, TextLine
+from zope.schema import Dict, Int, TextLine
from lp import _
from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
@@ -68,6 +68,17 @@ class ISnapStoreUploadJob(IRunnableJob):
readonly=True,
)
+ components_ids = Dict(
+ title=_(
+ "The IDs returned by the store when uploading snap components."
+ "The key is the component name and the value is the related id."
+ ),
+ key_type=TextLine(),
+ value_type=TextLine(),
+ required=False,
+ readonly=True,
+ )
+
status_url = TextLine(
title=_("The URL on the store to get the status of this build"),
required=False,
diff --git a/lib/lp/snappy/model/snapbuildjob.py b/lib/lp/snappy/model/snapbuildjob.py
index 539fb1c..80260c1 100644
--- a/lib/lp/snappy/model/snapbuildjob.py
+++ b/lib/lp/snappy/model/snapbuildjob.py
@@ -29,6 +29,12 @@ 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
+from lp.snappy.interfaces.snap import (
+ CannotFetchSnapcraftYaml,
+ CannotParseSnapcraftYaml,
+ ISnapSet,
+ MissingSnapcraftYaml,
+)
from lp.snappy.interfaces.snapbuildjob import (
ISnapBuildJob,
ISnapBuildStoreUploadStatusChangedEvent,
@@ -244,6 +250,18 @@ class SnapStoreUploadJob(SnapBuildJobDerived):
self.snapbuild.store_upload_metadata["upload_id"] = upload_id
@property
+ def components_ids(self):
+ """See `ISnapStoreUploadJob`."""
+ return self.store_metadata.get("components_ids")
+
+ @components_ids.setter
+ def components_ids(self, components_ids):
+ """See `ISnapStoreUploadJob`."""
+ if self.snapbuild.store_upload_metadata is None:
+ self.snapbuild.store_upload_metadata = {}
+ self.snapbuild.store_upload_metadata["components_ids"] = components_ids
+
+ @property
def status_url(self):
"""See `ISnapStoreUploadJob`."""
return self.store_metadata.get("status_url")
@@ -333,6 +351,24 @@ class SnapStoreUploadJob(SnapBuildJobDerived):
pass
return timedelta(minutes=1)
+ @staticmethod
+ def _extract_component_name(filename, snapcraft_components):
+ """Extract component name from filename
+
+ Use the snapcraft_components keys to extract the component name
+ from the built component filename. Each key correspond to
+ <component_name> while the built filename has the following pattern:
+ <snap_name>+<component_name>_<component_revision>.comp.
+ """
+ for component in snapcraft_components:
+ # Surround component name with `+` and `_` to avoid
+ # superset of the same component name.
+ # ie. testcomp -> testcomponent
+ built_component = "+%s_" % component
+ if built_component in filename:
+ return component
+ return filename
+
def run(self):
"""See `IRunnableJob`."""
client = getUtility(ISnapStoreClient)
@@ -350,16 +386,54 @@ class SnapStoreUploadJob(SnapBuildJobDerived):
# Nothing to do.
self.error_message = None
return
+
+ components_ids = {}
+ components = []
+ try:
+ # Get components from snapcraft.yaml
+ snapcraft_yaml = getUtility(ISnapSet).getSnapcraftYaml(
+ self.snapbuild.snap
+ )
+ if snapcraft_yaml["components"]:
+ for component in snapcraft_yaml["components"]:
+ components_ids[component] = None
+ # Get built components from built files
+ for _, lfa, _ in self.snapbuild.getFiles():
+ if lfa.filename.endswith(".comp"):
+ components.append(lfa)
+ except (
+ MissingSnapcraftYaml,
+ CannotFetchSnapcraftYaml,
+ CannotParseSnapcraftYaml,
+ ):
+ pass
+
+ if "components_ids" not in self.store_metadata:
+ self.components_ids = components_ids
if "upload_id" not in self.store_metadata:
self.upload_id = client.uploadFile(snap_lfa)
# We made progress, so reset attempt_count.
self.attempt_count = 1
+ # Process components
+ for component in components:
+ # if the id is None, we need to upload the component
+ # because it means that that component was never uploaded.
+ # Note that the id is returned directly from SnapStore API.
+ comp_name = self._extract_component_name(
+ component.filename, components_ids
+ )
+ if self.components_ids.get(comp_name) == None:
+ self.components_ids[comp_name] = client.uploadFile(
+ component
+ )
+ self.attempt_count = 1
if "status_url" not in self.store_metadata:
self.status_url = client.push(
- self.snapbuild, self.upload_id
+ self.snapbuild, self.upload_id, self.components_ids
)
# We made progress, so reset attempt_count.
self.attempt_count = 1
+
if self.store_url is None:
# This is no longer strictly necessary as the store is
# handling releases via the release intent, but we
diff --git a/lib/lp/snappy/model/snapstoreclient.py b/lib/lp/snappy/model/snapstoreclient.py
index 536ba17..98d2753 100644
--- a/lib/lp/snappy/model/snapstoreclient.py
+++ b/lib/lp/snappy/model/snapstoreclient.py
@@ -293,7 +293,7 @@ class SnapStoreClient:
lfa.close()
@classmethod
- def _push(cls, snapbuild, upload_id):
+ def _push(cls, snapbuild, upload_id, components=None):
"""Create a new store upload based on the uploaded file."""
snap = snapbuild.snap
assert snap.can_upload_to_store
@@ -305,6 +305,10 @@ class SnapStoreClient:
"series": snap.store_series.name,
"built_at": snapbuild.date_started.isoformat(),
}
+
+ if components:
+ data["components"] = components
+
# The security proxy is useless and breaks JSON serialisation.
channels = removeSecurityProxy(snap.store_channels)
if channels:
@@ -343,10 +347,10 @@ class SnapStoreClient:
raise cls._makeSnapStoreError(UploadFailedResponse, e)
@classmethod
- def push(cls, snapbuild, upload_id):
+ def push(cls, snapbuild, upload_id, components=None):
"""See `ISnapStoreClient`."""
return cls.refreshIfNecessary(
- snapbuild.snap, cls._push, snapbuild, upload_id
+ snapbuild.snap, cls._push, snapbuild, upload_id, components
)
@classmethod
diff --git a/lib/lp/snappy/tests/test_snapbuildjob.py b/lib/lp/snappy/tests/test_snapbuildjob.py
index 7e69f3c..d5239ae 100644
--- a/lib/lp/snappy/tests/test_snapbuildjob.py
+++ b/lib/lp/snappy/tests/test_snapbuildjob.py
@@ -18,6 +18,7 @@ from zope.interface import implementer
from zope.security.proxy import removeSecurityProxy
from lp.buildmaster.enums import BuildStatus
+from lp.code.tests.helpers import BranchHostingFixture
from lp.services.config import config
from lp.services.database.interfaces import IStore
from lp.services.job.interfaces.job import JobStatus
@@ -61,10 +62,30 @@ def run_isolated_jobs(jobs):
transaction.abort()
+class FakeMethodWithMaxCalls(FakeMethod):
+ """Method that throws error after a given number of calls"""
+
+ def __init__(self, max_calls=-1):
+ self.max_calls = max_calls
+ super().__init__(result=1, failure=None)
+
+ def __call__(self, *args, **kwargs):
+ if self.call_count == self.max_calls:
+ self.failure = UploadFailedResponse("Proxy error", can_retry=True)
+ self.result = None
+ else:
+ self.result = 1
+ self.failure = None
+ return super().__call__(*args, **kwargs)
+
+
@implementer(ISnapStoreClient)
class FakeSnapStoreClient:
- def __init__(self):
- self.uploadFile = FakeMethod()
+ def __init__(self, max_calls=-1):
+ if max_calls >= 0:
+ self.uploadFile = FakeMethodWithMaxCalls(max_calls)
+ else:
+ self.uploadFile = FakeMethod()
self.push = FakeMethod()
self.checkStatus = FakeMethod()
@@ -144,6 +165,49 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
)
return snapbuild
+ def makeSnapBuildWithComponents(self, num_components):
+ # Make a build with a builder, components, and a webhook.
+ snapcraft_yaml = """
+ name : test-snap,
+ components:
+ """
+ branch = self.factory.makeBranch()
+ snap = self.factory.makeSnap(store_name="test-snap", branch=branch)
+ snapbuild = self.factory.makeSnapBuild(
+ snap=snap, builder=self.factory.makeBuilder()
+ )
+ snapbuild.updateStatus(BuildStatus.FULLYBUILT)
+ snap_lfa = self.factory.makeLibraryFileAlias(
+ filename="test-snap_0_all.snap", content=b"dummy snap content"
+ )
+ for i in range(num_components):
+ self.factory.makeSnapFile(
+ snapbuild=snapbuild, libraryfile=snap_lfa
+ )
+ component_lfa = self.factory.makeLibraryFileAlias(
+ filename="test-snap+somecomponent%s_0.comp" % i,
+ content=b"component",
+ )
+ self.factory.makeSnapFile(
+ snapbuild=snapbuild, libraryfile=component_lfa
+ )
+ snapcraft_yaml += (
+ """
+ somecomponent%s:
+ description: test
+ """
+ % i
+ )
+ self.useFixture(
+ BranchHostingFixture(
+ blob=bytes(snapcraft_yaml, "utf-8"),
+ )
+ )
+ self.factory.makeWebhook(
+ target=snapbuild.snap, event_types=["snap:build:0.1"]
+ )
+ return snapbuild
+
def assertWebhookDeliveries(
self, snapbuild, expected_store_upload_statuses, logger
):
@@ -207,7 +271,101 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
+ self.assertEqual(self.store_url, job.store_url)
+ self.assertEqual(1, job.store_revision)
+ self.assertEqual(1, snapbuild.store_upload_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertWebhookDeliveries(
+ snapbuild, ["Pending", "Uploaded"], logger
+ )
+
+ def test_run_with_component(self):
+ # The job uploads the build to the storage with its component
+ # and then pushes it to the store and records the store URL
+ # and revision.
+ logger = self.useFixture(FakeLogger())
+ snapbuild = self.makeSnapBuildWithComponents(1)
+ self.assertContentEqual([], snapbuild.store_upload_jobs)
+ job = SnapStoreUploadJob.create(snapbuild)
+ client = FakeSnapStoreClient()
+ client.uploadFile.result = 1
+ client.push.result = self.status_url
+ client.checkStatus.result = (self.store_url, 1)
+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+
+ # Check if uploadFile is called for snap and its component.
+ self.assertThat(
+ [client.uploadFile.calls[0]], FileUploaded("test-snap_0_all.snap")
+ )
+ self.assertThat(
+ [client.uploadFile.calls[1]],
+ FileUploaded("test-snap+somecomponent0_0.comp"),
+ )
+ self.assertEqual(
+ [((snapbuild, 1, {"somecomponent0": 1}), {})],
+ client.push.calls,
+ )
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
+ self.assertEqual(self.store_url, job.store_url)
+ self.assertEqual(1, job.store_revision)
+ self.assertEqual(1, snapbuild.store_upload_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertWebhookDeliveries(
+ snapbuild, ["Pending", "Uploaded"], logger
+ )
+
+ def test_run_with_multiple_components(self):
+ # The job uploads the build to the storage with its components
+ # and then pushes it to the store and records the store URL
+ # and revision.
+ logger = self.useFixture(FakeLogger())
+ snapbuild = self.makeSnapBuildWithComponents(2)
+ self.assertContentEqual([], snapbuild.store_upload_jobs)
+ job = SnapStoreUploadJob.create(snapbuild)
+ client = FakeSnapStoreClient()
+ client.uploadFile.result = 1
+ client.push.result = self.status_url
+ client.checkStatus.result = (self.store_url, 1)
+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+
+ # Check if uploadFile is called for snap and its components.
+ self.assertThat(
+ [client.uploadFile.calls[0]], FileUploaded("test-snap_0_all.snap")
+ )
+ self.assertThat(
+ [client.uploadFile.calls[1]],
+ FileUploaded("test-snap+somecomponent0_0.comp"),
+ )
+ self.assertThat(
+ [client.uploadFile.calls[2]],
+ FileUploaded("test-snap+somecomponent1_0.comp"),
+ )
+ self.assertEqual(
+ [
+ (
+ (
+ snapbuild,
+ 1,
+ {
+ "somecomponent0": 1,
+ "somecomponent1": 1,
+ },
+ ),
+ {},
+ )
+ ],
+ client.push.calls,
+ )
self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertEqual(self.store_url, job.store_url)
@@ -268,7 +426,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
self.assertEqual([], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertIsNone(job.store_url)
@@ -313,6 +471,106 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
snapbuild, ["Pending", "Failed to upload"], logger
)
+ def test_run_502_retries_with_components(self):
+ # A run that gets a 502 error from the store schedules itself to be
+ # retried.
+ logger = self.useFixture(FakeLogger())
+ snapbuild = self.makeSnapBuildWithComponents(2)
+ self.assertContentEqual([], snapbuild.store_upload_jobs)
+ job = SnapStoreUploadJob.create(snapbuild)
+
+ # The error raises on the third uploadFile call.
+ client = FakeSnapStoreClient(2)
+ client.uploadFile.result = 1
+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+ self.assertThat(
+ [client.uploadFile.calls[0]], FileUploaded("test-snap_0_all.snap")
+ )
+ self.assertThat(
+ [client.uploadFile.calls[1]],
+ FileUploaded("test-snap+somecomponent0_0.comp"),
+ )
+ self.assertThat(
+ [client.uploadFile.calls[2]],
+ FileUploaded("test-snap+somecomponent1_0.comp"),
+ )
+ self.assertEqual([], client.push.calls)
+ self.assertEqual([], client.checkStatus.calls)
+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
+
+ # Component upload is incomplete.
+ self.assertEqual(
+ {
+ "somecomponent0": 1,
+ "somecomponent1": None,
+ },
+ job.components_ids,
+ )
+ self.assertIsNone(job.store_url)
+ self.assertIsNone(job.store_revision)
+ self.assertIsNone(snapbuild.store_upload_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.WAITING, job.job.status)
+ self.assertWebhookDeliveries(snapbuild, ["Pending"], logger)
+
+ # Try again. The upload part of the job is retried, and this time
+ # it succeeds.
+ job.scheduled_start = None
+ client.uploadFile.max_calls = -1
+ client.uploadFile.failure = None
+ client.uploadFile.result = 1
+ client.push.result = self.status_url
+ client.checkStatus.result = (self.store_url, 1)
+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
+ run_isolated_jobs([job])
+
+ # The last call should contains just the component that failed.
+ self.assertEqual(len(client.uploadFile.calls), 4)
+ self.assertThat(
+ [client.uploadFile.calls[3]],
+ FileUploaded("test-snap+somecomponent1_0.comp"),
+ )
+
+ self.assertEqual(
+ {
+ "somecomponent0": 1,
+ "somecomponent1": 1,
+ },
+ job.components_ids,
+ )
+
+ # After that the snap is uploaded to the store.
+ self.assertEqual(
+ [
+ (
+ (
+ snapbuild,
+ 1,
+ {
+ "somecomponent0": 1,
+ "somecomponent1": 1,
+ },
+ ),
+ {},
+ )
+ ],
+ client.push.calls,
+ )
+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
+ self.assertEqual(self.store_url, job.store_url)
+ self.assertEqual(1, job.store_revision)
+ self.assertEqual(1, snapbuild.store_upload_revision)
+ self.assertIsNone(job.error_message)
+ self.assertEqual([], pop_notifications())
+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
+ self.assertWebhookDeliveries(
+ snapbuild, ["Pending", "Uploaded"], logger
+ )
+
def test_run_502_retries(self):
# A run that gets a 502 error from the store schedules itself to be
# retried.
@@ -353,7 +611,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertEqual(self.store_url, job.store_url)
@@ -388,7 +646,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
self.assertEqual([], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertIsNone(job.store_url)
@@ -524,7 +782,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 2), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 2, {}), {})], client.push.calls)
self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertIsNone(job.store_url)
@@ -586,7 +844,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 2), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 2, {}), {})], client.push.calls)
self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertIsNone(job.store_url)
@@ -655,7 +913,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertIsNone(job.store_url)
@@ -684,7 +942,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
self.assertThat(
client.uploadFile.calls, FileUploaded("test-snap.snap")
)
- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
self.assertContentEqual([job], snapbuild.store_upload_jobs)
self.assertEqual(self.store_url, job.store_url)
References