← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~twom/launchpad:concrete-oci-projects into launchpad:master

 

Tom Wardill has proposed merging ~twom/launchpad:concrete-oci-projects into launchpad:master with ~twom/launchpad:oci-gitrepository as a prerequisite.

Commit message:
OCIRecipe and skeleton of dependant models

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/376132
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:concrete-oci-projects into launchpad:master.
diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py
index c6f0aac..1eae263 100644
--- a/lib/lp/buildmaster/enums.py
+++ b/lib/lp/buildmaster/enums.py
@@ -164,6 +164,12 @@ class BuildFarmJobType(DBEnumeratedType):
         Build a snap package from a recipe.
         """)
 
+    OCIBUILD = DBItem(7, """
+        OCI image build
+
+        Build an OCI image from a recipe.
+        """)
+
 
 class BuildQueueStatus(DBEnumeratedType):
     """Build queue status.
diff --git a/lib/lp/configure.zcml b/lib/lp/configure.zcml
index 533d714..a3e7c08 100644
--- a/lib/lp/configure.zcml
+++ b/lib/lp/configure.zcml
@@ -30,6 +30,7 @@
     <include package="lp.code" />
     <include package="lp.coop.answersbugs" />
     <include package="lp.hardwaredb" />
+    <include package="lp.oci" />
     <include package="lp.snappy" />
     <include package="lp.soyuz" />
     <include package="lp.translations" />
diff --git a/lib/lp/oci/__init__.py b/lib/lp/oci/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/__init__.py
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
new file mode 100644
index 0000000..a301911
--- /dev/null
+++ b/lib/lp/oci/configure.zcml
@@ -0,0 +1,58 @@
+<!-- Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+<configure
+    xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
+    xmlns:i18n="http://namespaces.zope.org/i18n";
+    xmlns:lp="http://namespaces.canonical.com/lp";
+    xmlns:webservice="http://namespaces.canonical.com/webservice";
+    xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc";
+    i18n_domain="launchpad">
+
+    <!-- ocirecipe -->
+    <class
+        class="lp.oci.model.ocirecipe.OCIRecipe">
+        <require
+            permission="launchpad.View"
+            interface="lp.oci.interfaces.ocirecipe.IOCIRecipeView
+                       lp.oci.interfaces.ocirecipe.IOCIRecipeEditableAttributes"/>
+        <require
+            permission="launchpad.Edit"
+            interface="lp.oci.interfaces.ocirecipe.IOCIRecipeEdit"
+            set_schema="lp.oci.interfaces.ocirecipe.IOCIRecipeEditableAttributes" />
+    </class>
+    <securedutility
+        class="lp.oci.model.ocirecipe.OCIRecipeSet"
+        provides="lp.oci.interfaces.ocirecipe.IOCIRecipeSet">
+        <allow
+            interface="lp.oci.interfaces.ocirecipe.IOCIRecipeSet"/>
+    </securedutility>
+
+    <!-- OCIRecipeBuild -->
+    <class class="lp.oci.model.ocirecipebuild.OCIRecipeBuild">
+        <require
+            permission="launchpad.View"
+            interface="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuildView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuildEdit" />
+        <require
+            permission="launchpad.Admin"
+            interface="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuildAdmin" />
+    </class>
+
+    <!-- OCIRecipeBuildSet -->
+    <securedutility
+        class="lp.oci.model.ocirecipebuild.OCIRecipeBuildSet"
+        provides="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuildSet">
+        <allow interface="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuildSet" />
+    </securedutility>
+    <securedutility
+        class="lp.oci.model.ocirecipebuild.OCIRecipeBuildSet"
+        provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+        name="OCIRECIPEBUILD">
+        <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
+    </securedutility>
+
+</configure>
diff --git a/lib/lp/oci/interfaces/__init__.py b/lib/lp/oci/interfaces/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/interfaces/__init__.py
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
new file mode 100644
index 0000000..969f52f
--- /dev/null
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -0,0 +1,103 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces related to recipes for OCI Images."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IOCIRecipe',
+    'IOCIRecipeEdit',
+    'IOCIRecipeEditableAttributes',
+    'IOCIRecipeSet',
+    'IOCIRecipeView',
+    'OCIBuildAlreadyPending',
+    'OCIRecipeNotOwner',
+    ]
+
+import httplib
+
+from lazr.restful.declarations import error_status
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Bool,
+    Datetime,
+    Int,
+    Text,
+    )
+from zope.security.interfaces import Unauthorized
+
+from lp import _
+from lp.registry.interfaces.ociproject import IOCIProject
+from lp.registry.interfaces.role import IHasOwner
+from lp.services.fields import PublicPersonChoice
+
+
+@error_status(httplib.UNAUTHORIZED)
+class OCIRecipeNotOwner(Unauthorized):
+    """The registrant/requester is not the owner or a member of its team."""
+
+
+@error_status(httplib.BAD_REQUEST)
+class OCIBuildAlreadyPending(Exception):
+    """A build was requested when an identical build was already pending."""
+
+    def __init__(self):
+        super(OCIBuildAlreadyPending, self).__init__(
+            "An identical build of this snap package is already pending.")
+
+
+class IOCIRecipeView(Interface):
+    """`IOCIRecipe` attributes that require launchpad.View permission."""
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+    date_created = Datetime(
+        title=_("Date created"), required=True, readonly=True)
+    date_last_modified = Datetime(
+        title=_("Date last modified"), required=True, readonly=True)
+
+    registrant = PublicPersonChoice(
+        title=_("Registrant"),
+        description=_("The user who registered this recipe."),
+        vocabulary='ValidPersonOrTeam', required=True, readonly=True)
+
+
+class IOCIRecipeEdit(Interface):
+    """`IOCIRecipe` methods that require launchpad.Edit permission."""
+
+    def destroySelf():
+        """Delete this snap package, provided that it has no builds."""
+
+
+class IOCIRecipeEditableAttributes(IHasOwner):
+    """`IOCIRecipe` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+
+    ociproject = Reference(
+        IOCIProject,
+        title=_("The OCI project that this recipe is for."),
+        required=True,
+        readonly=True)
+    ociproject_default = Bool(
+        title=_("OCI Project default"), required=True, default=False)
+
+    description = Text(title=_("A short description of this recipe."))
+
+    require_virtualized = Bool(
+        title=_("Require virtualized"), required=True, default=True)
+
+
+class IOCIRecipe(IOCIRecipeView, IOCIRecipeEdit, IOCIRecipeEditableAttributes):
+    """A recipe for building Open Container Initiative images."""
+
+
+class IOCIRecipeSet(Interface):
+    """A utility to create and access OCI Recipes."""
+
+    def new(registrant, owner, ociproject, ociproject_default,
+            require_virtualized):
+        """Create an IOCIRecipe."""
diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
new file mode 100644
index 0000000..e3bf54f
--- /dev/null
+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
@@ -0,0 +1,56 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for a build record for OCI recipes."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IOCIRecipeBuild',
+    'IOCIRecipeBuildSet'
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+
+from lp import _
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.oci.interfaces.ocirecipe import IOCIRecipe
+from lp.services.database.constants import DEFAULT
+from lp.services.fields import PublicPersonChoice
+
+
+class IOCIRecipeBuildEdit(Interface):
+    pass
+
+
+class IOCIRecipeBuildView(IPackageBuild):
+
+    requester = PublicPersonChoice(
+        title=_("Requester"),
+        description=_("The person who requested this OCI recipe build."),
+        vocabulary='ValidPersonOrTeam', required=True, readonly=True)
+
+    recipe = Reference(
+        IOCIRecipe,
+        title=_("The OCI recipe to build."),
+        required=True,
+        readonly=True)
+
+
+class IOCIRecipeBuildAdmin(Interface):
+    pass
+
+
+class IOCIRecipeBuild(IOCIRecipeBuildAdmin, IOCIRecipeBuildEdit,
+                      IOCIRecipeBuildView):
+    """A build record for an OCI recipe."""
+
+
+class IOCIRecipeBuildSet(Interface):
+    """A utility to create and access OCIRecipeBuilds."""
+
+    def new(requester, recipe, channel_name, processor, virtualized,
+            date_created=DEFAULT):
+        """Create an `IOCIRecipeBuild`."""
diff --git a/lib/lp/oci/interfaces/ocirecipechannel.py b/lib/lp/oci/interfaces/ocirecipechannel.py
new file mode 100644
index 0000000..e4871a9
--- /dev/null
+++ b/lib/lp/oci/interfaces/ocirecipechannel.py
@@ -0,0 +1,42 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for defining channel attributes for an OCI Recipe."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IOCIRecipeChannel',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import TextLine
+
+from lp import _
+from lp.oci.interfaces.ocirecipe import IOCIRecipe
+
+
+class IOCIRecipeChannel(Interface):
+    """The channels that exist for an OCI recipe."""
+
+    recipe = Reference(
+        IOCIRecipe,
+        title=_("The OCI recipe for which a channel is specified."),
+        required=True,
+        readonly=True)
+
+    name = TextLine(
+        title=_("The name of this channel."),
+        required=True)
+
+    git_path = TextLine(
+        title=_("The branch within this recipes Git "
+                "repository where its build files are maintained."),
+        required=True)
+
+    build_file = TextLine(
+        title=_("The relative path to the file within this recipe's "
+                "branch that defines how to build the recipe."),
+        required=True)
diff --git a/lib/lp/oci/model/__init__.py b/lib/lp/oci/model/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/model/__init__.py
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
new file mode 100644
index 0000000..66755a5
--- /dev/null
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -0,0 +1,150 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A recipe for building Open Container Initiative images."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipe',
+    'OCIRecipeSet',
+    ]
+
+
+from lazr.lifecycle.event import ObjectCreatedEvent
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Int,
+    Reference,
+    Storm,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implementer
+
+from lp.buildmaster.enums import BuildStatus
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeSet,
+    OCIBuildAlreadyPending,
+    OCIRecipeNotOwner,
+    )
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.oci.model.ocirecipebuild import OCIRecipeBuild
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+
+
+@implementer(IOCIRecipe)
+class OCIRecipe(Storm):
+
+    __storm_table__ = 'OCIRecipe'
+
+    id = Int(primary=True)
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.UTC, allow_none=False)
+    date_last_modified = DateTime(
+        name="date_last_modified", tzinfo=pytz.UTC, allow_none=False)
+
+    registrant_id = Int(name='registrant', allow_none=False)
+    registrant = Reference(registrant_id, "Person.id")
+
+    owner_id = Int(name='owner', allow_none=False)
+    owner = Reference(owner_id, 'Person.id')
+
+    ociproject_id = Int(name='ociproject', allow_none=False)
+    ociproject = Reference(ociproject_id, "OCIProject.id")
+
+    ociproject_default = Bool(name="ociproject_default", default=False)
+
+    description = Unicode(name="description")
+
+    require_virtualized = Bool(name="require_virtualized", default=True)
+
+    def __init__(self, registrant, owner, ociproject, ociproject_default=False,
+                 require_virtualized=True):
+        super(OCIRecipe, self).__init__()
+        self.registrant = registrant
+        self.owner = owner
+        self.ociproject = ociproject
+        self.ociproject_default = ociproject_default
+        self.require_virtualized = require_virtualized
+
+    def destroySelf(self):
+        """See `IOCIRecipe`."""
+        # XXX twom 2019-11-26 This needs to expand as more build artifacts
+        # are added
+        store = IStore(OCIRecipe)
+        store.remove(self)
+
+    def _checkRequestBuild(self, requester):
+        if not requester.inTeam(self.owner):
+            raise OCIRecipeNotOwner(
+                "%s cannot create OCI image builds owned by %s." %
+                (requester.displayname, self.owner.displayname))
+
+    def requestBuild(self, requester, channel, architecture):
+        self._checkRequestBuild(requester)
+
+        pending = IStore(self).find(
+            OCIRecipeBuild,
+            OCIRecipeBuild.recipe == self.id,
+            OCIRecipeBuild.channel_name == channel.name,
+            OCIRecipeBuild.status == BuildStatus.NEEDSBUILD)
+        if pending.any() is not None:
+            raise OCIBuildAlreadyPending
+
+        build = getUtility(IOCIRecipeBuildSet).new(
+            requester, self, channel.name, architecture.processor,
+            self.require_virtualized)
+        build.queueBuild()
+        notify(ObjectCreatedEvent(build, user=requester))
+        return build
+
+
+class OCIRecipeArch(Storm):
+    """Link table back to `OCIRecipe.processors`."""
+
+    __storm_table__ = "OCIRecipeArch"
+    __storm_primary__ = ("recipe_id", "processor_id")
+
+    recipe_id = Int(name="recipe", allow_none=False)
+    recipe = Reference(recipe_id, "OCIRecipe.id")
+
+    processor_id = Int(name="processor", allow_none=False)
+    processor = Reference(processor_id, "Processor.id")
+
+    def __init__(self, recipe, processor):
+        self.recipe = recipe
+        self.processor = processor
+
+
+@implementer(IOCIRecipeSet)
+class OCIRecipeSet:
+
+    def new(self, registrant, owner, ociproject, ociproject_default,
+            require_virtualized):
+        """See `IOCIRecipeSet`."""
+        if not registrant.inTeam(owner):
+            if owner.is_team:
+                raise OCIRecipeNotOwner(
+                    "%s is not a member of %s." %
+                    (registrant.displayname, owner.displayname))
+            else:
+                raise OCIRecipeNotOwner(
+                    "%s cannot create OCI images owned by %s." %
+                    (registrant.displayname, owner.displayname))
+
+        store = IMasterStore(OCIRecipe)
+        oci_recipe = OCIRecipe(
+            registrant, owner, ociproject, ociproject_default,
+            require_virtualized)
+        store.add(oci_recipe)
+
+        return oci_recipe
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
new file mode 100644
index 0000000..987ac86
--- /dev/null
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -0,0 +1,120 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A build record for OCI Recipes."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipeBuild',
+    'OCIRecipeBuildSet'
+    ]
+
+
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Int,
+    Reference,
+    Storm,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.buildmaster.enums import (
+    BuildFarmJobType,
+    BuildStatus,
+    )
+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
+from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.oci.interfaces.ocirecipebuild import (
+    IOCIRecipeBuild,
+    IOCIRecipeBuildSet,
+    )
+from lp.services.database.constants import DEFAULT
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import IMasterStore
+
+
+@implementer(IOCIRecipeBuild)
+class OCIRecipeBuild(PackageBuildMixin, Storm):
+
+    __storm_table__ = 'OCIRecipeBuild'
+
+    job_type = BuildFarmJobType.OCIBUILD
+
+    id = Int(name='id', primary=True)
+
+    build_farm_job_id = Int(name='build_farm_job', allow_none=False)
+    build_farm_job = Reference(build_farm_job_id, 'BuildFarmJob.id')
+
+    requester_id = Int(name='requester', allow_none=False)
+    requester = Reference(requester_id, 'Person.id')
+
+    recipe_id = Int(name='recipe', allow_none=False)
+    recipe = Reference(recipe_id, 'OCIRecipe.id')
+
+    channel_name = Unicode(name="channel_name", allow_none=False)
+
+    processor_id = Int(name='processor', allow_none=False)
+    processor = Reference(processor_id, 'Processor.id')
+    virtualized = Bool(name='virtualized')
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+    date_started = DateTime(name='date_started', tzinfo=pytz.UTC)
+    date_finished = DateTime(name='date_finished', tzinfo=pytz.UTC)
+    date_first_dispatched = DateTime(
+        name='date_first_dispatched', tzinfo=pytz.UTC)
+
+    builder_id = Int(name='builder')
+    builder = Reference(builder_id, 'Builder.id')
+
+    status = DBEnum(name='status', enum=BuildStatus, allow_none=False)
+
+    log_id = Int(name='log')
+    log = Reference(log_id, 'LibraryFileAlias.id')
+
+    upload_log_id = Int(name='upload_log')
+    upload_log = Reference(upload_log_id, 'LibraryFileAlias.id')
+
+    dependencies = Unicode(name='dependencies')
+
+    failure_count = Int(name='failure_count', allow_none=False)
+
+    def __init__(self, build_farm_job, requester, recipe, channel_name,
+                 processor, virtualized, date_created):
+
+        self.build_farm_job = build_farm_job
+        self.requester = requester
+        self.recipe = recipe
+        self.channel_name = channel_name
+        self.processor = processor
+        self.virtualized = virtualized
+        self.date_created = date_created
+        self.status = BuildStatus.NEEDSBUILD
+
+    def queueBuild(self):
+        """See `IPackageBuild`."""
+        # XXX twom 2019-11-28 Currently a no-op skeleton, to be filled in
+        return
+
+
+@implementer(IOCIRecipeBuildSet)
+class OCIRecipeBuildSet:
+    """See `IOCIRecipeBuildSet`."""
+
+    def new(self, requester, recipe, channel_name, processor, virtualized,
+            date_created=DEFAULT):
+        """See `IOCIRecipeBuildSet`."""
+        store = IMasterStore(OCIRecipeBuild)
+        build_farm_job = getUtility(IBuildFarmJobSource).new(
+            OCIRecipeBuild.job_type, BuildStatus.NEEDSBUILD, date_created)
+        ocirecipebuild = OCIRecipeBuild(
+            build_farm_job, requester, recipe, channel_name, processor,
+            virtualized, date_created)
+        store.add(ocirecipebuild)
+        return ocirecipebuild
diff --git a/lib/lp/oci/model/ocirecipechannel.py b/lib/lp/oci/model/ocirecipechannel.py
new file mode 100644
index 0000000..248279e
--- /dev/null
+++ b/lib/lp/oci/model/ocirecipechannel.py
@@ -0,0 +1,42 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A recipe for building Open Container Initiative images."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'OCIRecipeChannel',
+    ]
+
+
+from storm.locals import (
+    Int,
+    Reference,
+    Storm,
+    Unicode,
+    )
+from zope.interface import implementer
+
+from lp.oci.interfaces.ocirecipechannel import IOCIRecipeChannel
+
+
+@implementer(IOCIRecipeChannel)
+class OCIRecipeChannel(Storm):
+
+    __storm_table__ = "OCIRecipeChannel"
+    __storm_primary__ = ("recipe_id", "name")
+
+    recipe_id = Int(name="recipe", allow_none=False)
+    recipe = Reference(recipe_id, "OCIRecipe.id")
+
+    name = Unicode(name="name", allow_none=False)
+    git_path = Unicode(name="git_path", allow_none=False)
+    build_file = Unicode(name="build_file", allow_none=False)
+
+    def __init__(self, recipe, name, git_path, build_file):
+        self.recipe = recipe
+        self.name = name
+        self.git_path = git_path
+        self.build_file = build_file
diff --git a/lib/lp/oci/tests/__init__.py b/lib/lp/oci/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/tests/__init__.py
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
new file mode 100644
index 0000000..a35ccbf
--- /dev/null
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -0,0 +1,79 @@
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.enums import BuildStatus
+from lp.oci.interfaces.ocirecipe import (
+    IOCIRecipe,
+    IOCIRecipeSet,
+    OCIBuildAlreadyPending,
+    OCIRecipeNotOwner,
+    )
+from lp.testing import (
+    admin_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestOCIRecipe(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_implements_interface(self):
+        target = self.factory.makeOCIRecipe()
+        with admin_logged_in():
+            self.assertProvides(target, IOCIRecipe)
+
+    def test_checkRequestBuild(self):
+        ocirecipe = removeSecurityProxy(self.factory.makeOCIRecipe())
+        unrelated_person = self.factory.makePerson()
+        self.assertRaises(
+            OCIRecipeNotOwner,
+            ocirecipe._checkRequestBuild,
+            unrelated_person)
+
+    def test_requestBuild(self):
+        ocirecipe = self.factory.makeOCIRecipe()
+        ocirecipechannel = self.factory.makeOCIRecipeChannel(recipe=ocirecipe)
+        oci_arch = self.factory.makeOCIRecipeArch(recipe=ocirecipe)
+        build = ocirecipe.requestBuild(
+            ocirecipe.owner, ocirecipechannel, oci_arch)
+        self.assertEqual(build.status, BuildStatus.NEEDSBUILD)
+
+    def test_requestBuild_already_exists(self):
+        ocirecipe = self.factory.makeOCIRecipe()
+        ocirecipechannel = self.factory.makeOCIRecipeChannel(recipe=ocirecipe)
+        oci_arch = self.factory.makeOCIRecipeArch(recipe=ocirecipe)
+        ocirecipe.requestBuild(
+            ocirecipe.owner, ocirecipechannel, oci_arch)
+
+        self.assertRaises(
+            OCIBuildAlreadyPending,
+            ocirecipe.requestBuild,
+            ocirecipe.owner, ocirecipechannel, oci_arch)
+
+
+class TestOCIRecipeSet(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_implements_interface(self):
+        target_set = getUtility(IOCIRecipeSet)
+        with admin_logged_in():
+            self.assertProvides(target_set, IOCIRecipeSet)
+
+    def test_new(self):
+        registrant = self.factory.makePerson()
+        owner = self.factory.makeTeam(members=[registrant])
+        ociproject = self.factory.makeOCIProject()
+        target = getUtility(IOCIRecipeSet).new(
+            registrant=registrant,
+            owner=owner,
+            ociproject=ociproject,
+            ociproject_default=False,
+            require_virtualized=False)
+        self.assertEqual(target.registrant, registrant)
+        self.assertEqual(target.owner, owner)
+        self.assertEqual(target.ociproject, ociproject)
+        self.assertEqual(target.ociproject_default, False)
+        self.assertEqual(target.require_virtualized, False)
diff --git a/lib/lp/registry/tests/test_product.py b/lib/lp/registry/tests/test_product.py
index c28eb8b..0eb88af 100644
--- a/lib/lp/registry/tests/test_product.py
+++ b/lib/lp/registry/tests/test_product.py
@@ -126,6 +126,7 @@ from lp.translations.interfaces.translations import (
     TranslationsBranchImportMode,
     )
 
+
 PRIVATE_PROJECT_TYPES = [InformationType.PROPRIETARY]
 
 
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 66c7f73..4e586d1 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -112,6 +112,8 @@ from lp.hardwaredb.interfaces.hwdb import (
     IHWSubmissionDevice,
     IHWVendorID,
     )
+from lp.oci.interfaces.ocirecipe import IOCIRecipe
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
 from lp.registry.enums import PersonVisibility
 from lp.registry.interfaces.announcement import IAnnouncement
 from lp.registry.interfaces.distribution import IDistribution
@@ -3460,3 +3462,13 @@ class EditOCIProjectSeries(AuthorizationBase):
         """Maintainers, drivers, and admins can drive projects."""
         return (user.in_admin or
                 user.isDriver(self.obj.ociproject.pillar))
+
+
+class ViewOCIRecipe(AnonymousAuthorization):
+    """Anyone can view an `IOCIRecipe`."""
+    usedfor = IOCIRecipe
+
+
+class ViewOCIRecipeBuild(AnonymousAuthorization):
+    """Anyone can view an `IOCIRecipe`."""
+    usedfor = IOCIRecipeBuild
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 39d2725..f04596e 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -158,6 +158,11 @@ from lp.hardwaredb.interfaces.hwdb import (
     IHWSubmissionDeviceSet,
     IHWSubmissionSet,
     )
+from lp.oci.model.ocirecipe import (
+    OCIRecipe,
+    OCIRecipeArch,
+    )
+from lp.oci.model.ocirecipechannel import OCIRecipeChannel
 from lp.registry.enums import (
     BranchSharingPolicy,
     BugSharingPolicy,
@@ -4935,6 +4940,46 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             oci_project = self.makeOCIProject(**kwargs)
         return oci_project.newSeries(name, summary, registrant)
 
+    def makeOCIRecipe(self, registrant=None, owner=None, ociproject=None,
+                      ociproject_default=False, require_virtualized=True):
+        """Make a new OCIRecipe."""
+        if registrant is None:
+            registrant = self.makePerson()
+        if owner is None:
+            owner = self.makeTeam(members=[registrant])
+        if ociproject is None:
+            ociproject = self.makeOCIProject()
+        ocirecipe = OCIRecipe(
+            registrant=registrant,
+            owner=owner,
+            ociproject=ociproject,
+            ociproject_default=ociproject_default,
+            require_virtualized=require_virtualized)
+        return ocirecipe
+
+    def makeOCIRecipeChannel(self, recipe=None, name=None, git_path=None,
+                             build_file=None):
+        """Make a new OCIRecipeChannel."""
+        if recipe is None:
+            recipe = self.makeOCIRecipe()
+        if name is None:
+            name = self.getUniqueString(u"oci-recipe-channel-name")
+        if git_path is None:
+            git_path = self.getUniqueString(u"oci-recipe-channel-git-path")
+        if build_file is None:
+            build_file = self.getUniqueString(u"oci-recipe-channel-build-file")
+        oci_channel = OCIRecipeChannel(recipe, name, git_path, build_file)
+        return oci_channel
+
+    def makeOCIRecipeArch(self, recipe=None, processor=None):
+        """Make a new OCIRecipeArch."""
+        if recipe is None:
+            recipe = self.makeOCIRecipe()
+        if processor is None:
+            processor = self.makeProcessor()
+        oci_arch = OCIRecipeArch(recipe, processor)
+        return oci_arch
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by

References