launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24543
[Merge] ~twom/launchpad:oci-upload-job into launchpad:master
Tom Wardill has proposed merging ~twom/launchpad:oci-upload-job into launchpad:master.
Commit message:
Process completed OCI builds for upload to librarian
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/381522
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:oci-upload-job into launchpad:master.
diff --git a/database/schema/patch-2210-05-0.sql b/database/schema/patch-2210-05-0.sql
index 00bd779..fa060ca 100644
--- a/database/schema/patch-2210-05-0.sql
+++ b/database/schema/patch-2210-05-0.sql
@@ -1,6 +1,5 @@
-- Copyright 2019 Canonical Ltd. This software is licensed under the
-- GNU Affero General Public License version 3 (see the file LICENSE).
-
SET client_min_messages=ERROR;
CREATE INDEX snap__git_repository_url__idx ON Snap (git_repository_url);
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index f5cca8d..4e10998 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -1432,6 +1432,7 @@ public.message = SELECT, INSERT
public.messagechunk = SELECT, INSERT
public.milestone = SELECT
public.milestonetag = SELECT
+public.ocifile = SELECT, INSERT, UPDATE
public.ociproject = SELECT
public.ociprojectname = SELECT
public.ociprojectseries = SELECT
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index dd4e9d4..cd2829b 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -98,7 +98,10 @@ from lp.hardwaredb.interfaces.hwdb import (
)
from lp.oci.interfaces.ocipushrule import IOCIPushRule
from lp.oci.interfaces.ocirecipe import IOCIRecipe
-from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+from lp.oci.interfaces.ocirecipebuild import (
+ IOCIFile,
+ IOCIRecipeBuild,
+ )
from lp.registry.interfaces.commercialsubscription import (
ICommercialSubscription,
)
@@ -1109,3 +1112,6 @@ patch_collection_property(IOCIRecipe, 'builds', IOCIRecipeBuild)
patch_collection_property(IOCIRecipe, 'completed_builds', IOCIRecipeBuild)
patch_collection_property(IOCIRecipe, 'pending_builds', IOCIRecipeBuild)
patch_collection_property(IOCIRecipe, 'push_rules', IOCIPushRule)
+
+# IOCIRecipeBuild
+patch_reference_property(IOCIFile, 'layer_file_digest', IOCIRecipeBuild)
diff --git a/lib/lp/archiveuploader/ocirecipeupload.py b/lib/lp/archiveuploader/ocirecipeupload.py
new file mode 100644
index 0000000..3254c18
--- /dev/null
+++ b/lib/lp/archiveuploader/ocirecipeupload.py
@@ -0,0 +1,86 @@
+import json
+import os
+
+import scandir
+from zope.component import getUtility
+
+from lp.archiveuploader.utils import UploadError
+from lp.buildmaster.enums import BuildStatus
+from lp.services.helpers import filenameToContentType
+from lp.services.librarian.interfaces import ILibraryFileAliasSet
+
+
+class OCIRecipeUpload:
+ """An OCI image upload."""
+
+ def __init__(self, upload_path, logger):
+ """Create a `OCIRecipeUpload`.
+
+ :param upload_path: A directory containing files to upload.
+ :param logger: The logger to be used.
+ """
+ self.upload_path = upload_path
+ self.logger = logger
+
+ self.librarian = getUtility(ILibraryFileAliasSet)
+
+ def process(self, build):
+ """Process this upload, loading it into the database."""
+ self.logger.debug("Beginning processing.")
+
+ # Find digest file
+ for dirpath, _, filenames in scandir.walk(self.upload_path):
+ if dirpath == self.upload_path:
+ # All relevant files will be in a subdirectory.
+ continue
+ if 'digests.json' not in filenames:
+ continue
+ # Open the digest file
+ digest_path = os.path.join(dirpath, 'digests.json')
+ with open(digest_path, 'r') as digest_fp:
+ digests = json.load(digest_fp)
+
+ # Foreach id in digest file, find matching layer
+ for single_digest in digests:
+ for diff_id, data in single_digest.items():
+ digest = data["digest"]
+ layer_id = data["layer_id"]
+ layer_path = os.path.join(
+ dirpath,
+ "{}.tar.gz".format(layer_id)
+ )
+ if not os.path.exists(layer_path):
+ raise UploadError(
+ "Missing layer file: {}.".format(layer_id))
+ # Upload layer
+ libraryfile = self.librarian.create(
+ os.path.basename(layer_path),
+ os.stat(layer_path).st_size,
+ open(layer_path, "rb"),
+ filenameToContentType(layer_path),
+ restricted=build.is_private)
+ build.addFile(libraryfile, layer_file_digest=digest)
+ # Upload all json files
+ for filename in filenames:
+ if filename.endswith('.json'):
+ file_path = os.path.join(dirpath, filename)
+ libraryfile = self.librarian.create(
+ os.path.basename(file_path),
+ os.stat(file_path).st_size,
+ open(file_path, "rb"),
+ filenameToContentType(file_path),
+ restricted=build.is_private)
+ # This doesn't have a digest as it's not a layer file.
+ build.addFile(libraryfile, layer_file_digest=None)
+ # We've found digest, we can stop now
+ break
+ else:
+ # If we get here, we've not got a digests.json,
+ # something has gone wrong
+ raise UploadError("Build did not produce a digests.json.")
+
+ # The master verifies the status to confirm successful upload.
+ self.logger.debug("Updating %s" % build.title)
+ build.updateStatus(BuildStatus.FULLYBUILT)
+
+ self.logger.debug("Finished upload.")
diff --git a/lib/lp/archiveuploader/tests/test_ocirecipeupload.py b/lib/lp/archiveuploader/tests/test_ocirecipeupload.py
new file mode 100644
index 0000000..6c298aa
--- /dev/null
+++ b/lib/lp/archiveuploader/tests/test_ocirecipeupload.py
@@ -0,0 +1,99 @@
+import json
+import os
+
+from storm.store import Store
+from zope.component import getUtility
+
+from lp.archiveuploader.tests.test_uploadprocessor import (
+ TestUploadProcessorBase,
+ )
+from lp.archiveuploader.uploadprocessor import (
+ UploadHandler,
+ UploadStatusEnum,
+ )
+from lp.buildmaster.enums import BuildStatus
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.osutils import write_file
+
+
+class TestOCIRecipeUploads(TestUploadProcessorBase):
+
+ def setUp(self):
+ super(TestOCIRecipeUploads, self).setUp()
+
+ self.setupBreezy()
+
+ self.switchToAdmin()
+ self.build = self.factory.makeOCIRecipeBuild()
+ Store.of(self.build).flush()
+ self.switchToUploader()
+ self.options.context = "buildd"
+
+ self.uploadprocessor = self.getUploadProcessor(
+ self.layer.txn, builds=True)
+
+ self.digests = [{
+ "diff_id_1": {
+ "digest": "digest_1",
+ "source": "test/base_1",
+ "layer_id": "layer_1"
+ },
+ "diff_id_2": {
+ "digest": "digest_2",
+ "source": "",
+ "layer_id": "layer_2"
+ }
+ }]
+
+ def test_sets_build_and_state(self):
+ # The upload processor uploads files and sets the correct status.
+ self.assertFalse(self.build.verifySuccessfulUpload())
+ upload_dir = os.path.join(
+ self.incoming_folder, "test", str(self.build.id), "ubuntu")
+ write_file(os.path.join(upload_dir, "layer_1.tar.gz"), b"layer_1")
+ write_file(os.path.join(upload_dir, "layer_2.tar.gz"), b"layer_2")
+ write_file(
+ os.path.join(upload_dir, "digests.json"), json.dumps(self.digests))
+ write_file(os.path.join(upload_dir, "manifest.json"), b"manifest")
+ handler = UploadHandler.forProcessor(
+ self.uploadprocessor, self.incoming_folder, "test", self.build)
+ result = handler.processOCIRecipeBuild(self.log)
+ self.assertEqual(
+ UploadStatusEnum.ACCEPTED, result,
+ "OCI upload failed\nGot: %s" % self.log.getLogBuffer())
+ self.assertEqual(BuildStatus.FULLYBUILT, self.build.status)
+ self.assertTrue(self.build.verifySuccessfulUpload())
+
+ def test_requires_digests(self):
+ # The upload processor fails if the upload does not contain the
+ # digests file
+ self.assertFalse(self.build.verifySuccessfulUpload())
+ upload_dir = os.path.join(
+ self.incoming_folder, "test", str(self.build.id), "ubuntu")
+ write_file(os.path.join(upload_dir, "layer_1.tar.gz"), b"layer_1")
+ handler = UploadHandler.forProcessor(
+ self.uploadprocessor, self.incoming_folder, "test", self.build)
+ result = handler.processOCIRecipeBuild(self.log)
+ self.assertEqual(UploadStatusEnum.REJECTED, result)
+ self.assertIn(
+ "ERROR Build did not produce a digests.json.",
+ self.log.getLogBuffer())
+ self.assertFalse(self.build.verifySuccessfulUpload())
+
+ def test_missing_layer_file(self):
+ # The digests.json specifies a layer file that is missing
+ self.assertFalse(self.build.verifySuccessfulUpload())
+ upload_dir = os.path.join(
+ self.incoming_folder, "test", str(self.build.id), "ubuntu")
+ write_file(os.path.join(upload_dir, "layer_1.tar.gz"), b"layer_1")
+ write_file(
+ os.path.join(upload_dir, "digests.json"), json.dumps(self.digests))
+ handler = UploadHandler.forProcessor(
+ self.uploadprocessor, self.incoming_folder, "test", self.build)
+ result = handler.processOCIRecipeBuild(self.log)
+ self.assertEqual(UploadStatusEnum.REJECTED, result)
+ self.assertIn(
+ "ERROR Missing layer file: layer_2.",
+ self.log.getLogBuffer())
+ self.assertFalse(self.build.verifySuccessfulUpload())
diff --git a/lib/lp/archiveuploader/uploadprocessor.py b/lib/lp/archiveuploader/uploadprocessor.py
index 3aee65c..63e3454 100644
--- a/lib/lp/archiveuploader/uploadprocessor.py
+++ b/lib/lp/archiveuploader/uploadprocessor.py
@@ -61,6 +61,7 @@ from lp.archiveuploader.nascentupload import (
EarlyReturnUploadError,
NascentUpload,
)
+from lp.archiveuploader.ocirecipeupload import OCIRecipeUpload
from lp.archiveuploader.snapupload import SnapUpload
from lp.archiveuploader.uploadpolicy import (
BuildDaemonUploadPolicy,
@@ -72,6 +73,7 @@ from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
from lp.code.interfaces.sourcepackagerecipebuild import (
ISourcePackageRecipeBuild,
)
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
from lp.registry.interfaces.distribution import IDistributionSet
from lp.registry.interfaces.person import IPersonSet
from lp.services.log.logger import BufferLogger
@@ -630,6 +632,32 @@ class BuildUploadHandler(UploadHandler):
self.processor.ztm.abort()
raise
+ def processOCIRecipeBuild(self, logger=None):
+ """Process an OCI image upload."""
+ assert IOCIRecipeBuild.providedBy(self.build)
+ if logger is None:
+ logger = self.processor.log
+ try:
+ logger.info(
+ "Processing OCI Image upload {}".format(self.upload_path))
+ OCIRecipeUpload(self.upload_path, logger).process(self.build)
+
+ if self.processor.dry_run:
+ logger.info("Dry run, aborting transaction.")
+ self.processor.ztm.abort()
+ else:
+ logger.info(
+ "Commiting the transaction and any mails associated "
+ "with this upload.")
+ self.processor.ztm.commit()
+ return UploadStatusEnum.ACCEPTED
+ except UploadError as e:
+ logger.error(str(e))
+ return UploadStatusEnum.REJECTED
+ except BaseException:
+ self.processor.ztm.abort()
+ raise
+
def process(self):
"""Process an upload that is the result of a build.
@@ -671,6 +699,8 @@ class BuildUploadHandler(UploadHandler):
result = self.processLiveFS(logger)
elif ISnapBuild.providedBy(self.build):
result = self.processSnap(logger)
+ elif IOCIRecipeBuild.providedBy(self.build):
+ result = self.processOCIRecipeBuild(logger)
else:
self.processor.log.debug("Build %s found" % self.build.id)
[changes_file] = self.locateChangesFiles()
diff --git a/lib/lp/oci/interfaces/ocirecipebuildjob.py b/lib/lp/oci/interfaces/ocirecipebuildjob.py
new file mode 100644
index 0000000..cd72177
--- /dev/null
+++ b/lib/lp/oci/interfaces/ocirecipebuildjob.py
@@ -0,0 +1,38 @@
+# Copyright 2019 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCIRecipe build job interfaces"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'IOCIRecipeBuildJob',
+ ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+ Attribute,
+ Interface,
+ )
+from zope.schema import TextLine
+
+from lp import _
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
+from lp.services.job.interfaces.job import (
+ IJob,
+ IJobSource,
+ IRunnableJob,
+ )
+
+
+class IOCIRecipeBuildJob(Interface):
+ job = Reference(
+ title=_("The common Job attributes."), schema=IJob,
+ required=True, readonly=True)
+
+ build = Reference(
+ title=_("The OCI Recipe Build to use for this job."),
+ schema=IOCIRecipeBuild, required=True, readonly=True)
+
+ json_data = Attribute(_("A dict of data about the job."))
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 68f5e92..69b81db 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -351,4 +351,4 @@ class OCIRecipeSet:
person_ids.add(recipe.owner_id)
list(getUtility(IPersonSet).getPrecachedPersonsFromIDs(
- person_ids, need_validity=True))
+ person_ids, need_validity=True))
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index c36fce4..0aa9d38 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -56,6 +56,7 @@ from lp.services.database.interfaces import (
IStore,
)
from lp.services.features import getFeatureFlag
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
from lp.services.librarian.model import (
LibraryFileAlias,
LibraryFileContent,
@@ -208,23 +209,6 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
return result
raise NotFoundError(filename)
- def getLayerFileByDigest(self, layer_file_digest):
- file_object = Store.of(self).find(
- (OCIFile, LibraryFileAlias, LibraryFileContent),
- OCIFile.build == self.id,
- LibraryFileAlias.id == OCIFile.library_file_id,
- LibraryFileContent.id == LibraryFileAlias.contentID,
- OCIFile.layer_file_digest == layer_file_digest).one()
- if file_object is not None:
- return file_object
- raise NotFoundError(layer_file_digest)
-
- def addFile(self, lfa, layer_file_digest=None):
- oci_file = OCIFile(
- build=self, library_file=lfa, layer_file_digest=layer_file_digest)
- IMasterStore(OCIFile).add(oci_file)
- return oci_file
-
@cachedproperty
def eta(self):
"""The datetime when the build job is estimated to complete.
@@ -308,6 +292,55 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
return
# XXX twom 2019-12-11 This should send mail
+ def getLayerFileByDigest(self, layer_file_digest):
+ file_object = Store.of(self).find(
+ (OCIFile, LibraryFileAlias, LibraryFileContent),
+ OCIFile.build == self.id,
+ LibraryFileAlias.id == OCIFile.library_file_id,
+ LibraryFileContent.id == LibraryFileAlias.contentID,
+ OCIFile.layer_file_digest == layer_file_digest).one()
+ if file_object is not None:
+ return file_object
+ raise NotFoundError(layer_file_digest)
+
+ def addFile(self, lfa, layer_file_digest=None):
+ oci_file = OCIFile(
+ build=self, library_file=lfa, layer_file_digest=layer_file_digest)
+ IMasterStore(OCIFile).add(oci_file)
+ return oci_file
+
+ @property
+ def manifest(self):
+ result = Store.of(self).find(
+ (OCIFile, LibraryFileAlias, LibraryFileContent),
+ OCIFile.build == self.id,
+ LibraryFileAlias.id == OCIFile.library_file_id,
+ LibraryFileContent.id == LibraryFileAlias.contentID,
+ LibraryFileAlias.filename == 'manifest.json')
+ return result.one()
+
+ @property
+ def digests(self):
+ result = Store.of(self).find(
+ (OCIFile, LibraryFileAlias, LibraryFileContent),
+ OCIFile.build == self.id,
+ LibraryFileAlias.id == OCIFile.library_file_id,
+ LibraryFileContent.id == LibraryFileAlias.contentID,
+ LibraryFileAlias.filename == 'digests.json')
+ return result.one()
+
+ def verifySuccessfulUpload(self):
+ """See `IPackageBuild`."""
+ manifest = self.manifest
+ layer_files = Store.of(self).find(
+ OCIFile,
+ OCIFile.build == self.id,
+ OCIFile.layer_file_digest is not None)
+ layer_files_present = not layer_files.is_empty()
+ metadata_present = (self.manifest is not None
+ and self.digests is not None)
+ return layer_files_present and metadata_present
+
@implementer(IOCIRecipeBuildSet)
class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
diff --git a/lib/lp/oci/model/ocirecipebuildjob.py b/lib/lp/oci/model/ocirecipebuildjob.py
new file mode 100644
index 0000000..7d43d2e
--- /dev/null
+++ b/lib/lp/oci/model/ocirecipebuildjob.py
@@ -0,0 +1,137 @@
+# Copyright 2019 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCIRecipe build jobs."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'OCIRecipeBuildJob',
+ 'OCIRecipeBuildJobType',
+ ]
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+ DBEnumeratedType,
+ DBItem,
+ )
+from storm.locals import (
+ Int,
+ JSON,
+ Reference,
+ )
+from zope.interface import implementer
+
+from lp.app.errors import NotFoundError
+from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob
+from lp.services.database.enumcol import DBEnum
+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
+
+
+class OCIRecipeBuildJobType(DBEnumeratedType):
+ """Values that `OCIBuildJobType.job_type` can take."""
+
+ REGISTRY_UPLOAD = DBItem(0, """
+ Registry upload
+
+ This job uploads an OCI Image to registry.
+ """)
+
+
+@implementer(IOCIRecipeBuildJob)
+class OCIRecipeBuildJob(StormBase):
+ """See `IOCIRecipeBuildJob`."""
+
+ __storm_table__ = 'OCIRecipeBuildJob'
+
+ 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, 'OCIRecipeBuild.id')
+
+ job_type = DBEnum(enum=OCIRecipeBuildJobType, allow_none=True)
+
+ json_data = JSON('json_data', allow_none=False)
+
+ def __init__(self, build, job_type, json_data, **job_args):
+ """Constructor.
+
+ Extra keyword arguments are used to construct the underlying Job
+ object.
+
+ :param build: The `IOCIRecipeBuild` this job relates to.
+ :param job_type: The `OCIRecipeBuildJobType` of this job.
+ :param json_data: The type-specific variables, as a JSON-compatible
+ dict.
+ """
+ super(OCIRecipeBuildJob, self).__init__()
+ self.job = Job(**job_args)
+ self.build = build
+ self.job_type = job_type
+ self.json_data = json_data
+
+ def makeDerived(self):
+ return OCIRecipeBuildJob.makeSubclass(self)
+
+
+@delegate_to(IOCIRecipeBuildJob)
+class OCIRecipeBuildJobDerived(BaseRunnableJob):
+
+ __metaclass__ = EnumeratedSubclass
+
+ def __init__(self, oci_build_job):
+ self.context = oci_build_job
+
+ def __repr__(self):
+ """An informative representation of the job."""
+ return "<%s for %s>" % (
+ self.__class__.__name__, self.build.id)
+
+ @classmethod
+ def get(cls, job_id):
+ """Get a job by id.
+
+ :return: The `OCIBuildJob` with the specified id, as the current
+ `OCIBuildJobDerived` subclass.
+ :raises: `NotFoundError` if there is no job with the specified id,
+ or its `job_type` does not match the desired subclass.
+ """
+ oci_build_job = IStore(OCIRecipeBuildJob).get(
+ OCIRecipeBuildJob, job_id)
+ if oci_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(oci_build_job)
+
+ @classmethod
+ def iterReady(cls):
+ """See `IJobSource`."""
+ jobs = IMasterStore(OCIRecipeBuildJob).find(
+ OCIRecipeBuildJob,
+ OCIRecipeBuildJob.job_type == cls.class_job_type,
+ OCIRecipeBuildJob.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(OCIRecipeBuildJobDerived, self).getOopsVars()
+ oops_vars.extend([
+ ('job_type', self.context.job_type.title),
+ ('build_id', self.context.build.id),
+ ('owner_id', self.context.build.recipe.owner.id),
+ ('project_name', self.context.build.recipe.oci_project.name)
+ ])
+ return oops_vars
diff --git a/lib/lp/oci/tests/test_ocirecipebuildjob.py b/lib/lp/oci/tests/test_ocirecipebuildjob.py
new file mode 100644
index 0000000..2c30c4c
--- /dev/null
+++ b/lib/lp/oci/tests/test_ocirecipebuildjob.py
@@ -0,0 +1,49 @@
+# Copyright 2019 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCIRecipeBuildJob tests"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+
+from lp.oci.interfaces.ocirecipebuildjob import IOCIRecipeBuildJob
+from lp.oci.model.ocirecipebuildjob import (
+ OCIRecipeBuildJob,
+ OCIRecipeBuildJobDerived,
+ OCIRecipeBuildJobType,
+ )
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class FakeOCIBuildJob(OCIRecipeBuildJobDerived):
+ """For testing OCIRecipeBuildJobDerived without a child class."""
+
+
+class TestOCIRecipeBuildJob(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_provides_interface(self):
+ oci_build = self.factory.makeOCIRecipeBuild()
+ self.assertProvides(
+ OCIRecipeBuildJob(
+ oci_build, OCIRecipeBuildJobType.REGISTRY_UPLOAD, {}),
+ IOCIRecipeBuildJob)
+
+ def test_getOopsVars(self):
+ oci_build = self.factory.makeOCIRecipeBuild()
+ build_job = OCIRecipeBuildJob(
+ oci_build, OCIRecipeBuildJobType.REGISTRY_UPLOAD, {})
+ derived = FakeOCIBuildJob(build_job)
+ oops = derived.getOopsVars()
+ expected = [
+ ('job_id', build_job.job.id),
+ ('job_type', build_job.job_type.title),
+ ('build_id', oci_build.id),
+ ('owner_id', oci_build.recipe.owner.id),
+ ('project_name', oci_build.recipe.oci_project.name),
+ ]
+ self.assertEqual(expected, oops)