← Back to team overview

launchpad-reviewers team mailing list archive

[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)