← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:oci-recipe-processors into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-processors into launchpad:master.

Commit message:
Add processor selection for OCI recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/381907

This is fairly directly lifted from similar code for snaps.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-processors into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 10e9d4b..1c5d696 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -30,6 +30,7 @@ from lp.app.browser.launchpadform import (
     )
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
+from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.oci.interfaces.ocirecipe import (
     IOCIRecipe,
@@ -53,6 +54,7 @@ from lp.services.webapp import (
     )
 from lp.services.webapp.breadcrumb import NameBreadcrumb
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
+from lp.soyuz.browser.archive import EnableProcessorsMixin
 from lp.soyuz.browser.build import get_build_by_id_str
 
 
@@ -160,7 +162,7 @@ class IOCIRecipeEditSchema(Interface):
         ])
 
 
-class OCIRecipeAddView(LaunchpadFormView):
+class OCIRecipeAddView(LaunchpadFormView, EnableProcessorsMixin):
     """View for creating OCI recipes."""
 
     page_title = label = "Create a new OCI recipe"
@@ -181,6 +183,20 @@ class OCIRecipeAddView(LaunchpadFormView):
         if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
             raise OCIRecipeFeatureDisabled()
 
+    def setUpFields(self):
+        """See `LaunchpadFormView`."""
+        super(OCIRecipeAddView, self).setUpFields()
+        self.form_fields += self.createEnabledProcessors(
+            getUtility(IProcessorSet).getAll(),
+            "The architectures that this OCI recipe builds for. Some "
+            "architectures are restricted and may only be enabled or "
+            "disabled by administrators.")
+
+    def setUpWidgets(self):
+        """See `LaunchpadFormView`."""
+        super(OCIRecipeAddView, self).setUpWidgets()
+        self.widgets["processors"].widget_class = "processors"
+
     @property
     def cancel_url(self):
         """See `LaunchpadFormView`."""
@@ -192,6 +208,9 @@ class OCIRecipeAddView(LaunchpadFormView):
         return {
             "owner": self.user,
             "build_file": "Dockerfile",
+            "processors": [
+                p for p in getUtility(IProcessorSet).getAll()
+                if p.build_by_default],
             }
 
     def validate(self, data):
@@ -213,7 +232,7 @@ class OCIRecipeAddView(LaunchpadFormView):
             name=data["name"], registrant=self.user, owner=data["owner"],
             oci_project=self.context, git_ref=data["git_ref"],
             build_file=data["build_file"], description=data["description"],
-            build_daily=data["build_daily"])
+            build_daily=data["build_daily"], processors=data["processors"])
         self.next_url = canonical_url(recipe)
 
 
@@ -228,6 +247,12 @@ class BaseOCIRecipeEditView(LaunchpadEditFormView):
 
     @action("Update OCI recipe", name="update")
     def request_action(self, action, data):
+        new_processors = data.get("processors")
+        if new_processors is not None:
+            if set(self.context.processors) != set(new_processors):
+                self.context.setProcessors(
+                    new_processors, check_permissions=True, user=self.user)
+            del data["processors"]
         self.updateContextFromData(data)
         self.next_url = canonical_url(self.context)
 
@@ -249,7 +274,7 @@ class OCIRecipeAdminView(BaseOCIRecipeEditView):
     field_names = ("require_virtualized",)
 
 
-class OCIRecipeEditView(BaseOCIRecipeEditView):
+class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
     """View for editing OCI recipes."""
 
     @property
@@ -268,6 +293,15 @@ class OCIRecipeEditView(BaseOCIRecipeEditView):
         )
     custom_widget_git_ref = GitRefWidget
 
+    def setUpFields(self):
+        """See `LaunchpadFormView`."""
+        super(OCIRecipeEditView, self).setUpFields()
+        self.form_fields += self.createEnabledProcessors(
+            self.context.available_processors,
+            "The architectures that this OCI recipe builds for. Some "
+            "architectures are restricted and may only be enabled or "
+            "disabled by administrators.")
+
     def validate(self, data):
         """See `LaunchpadFormView`."""
         super(OCIRecipeEditView, self).validate(data)
@@ -288,6 +322,19 @@ class OCIRecipeEditView(BaseOCIRecipeEditView):
                             self.context.oci_project.display_name))
             except NoSuchOCIRecipe:
                 pass
+        if "processors" in data:
+            available_processors = set(self.context.available_processors)
+            widget = self.widgets["processors"]
+            for processor in self.context.processors:
+                if processor not in data["processors"]:
+                    if processor not in available_processors:
+                        # This processor is not currently available for
+                        # selection, but is enabled.  Leave it untouched.
+                        data["processors"].append(processor)
+                    elif processor.name in widget.disabled_items:
+                        # This processor is restricted and currently
+                        # enabled. Leave it untouched.
+                        data["processors"].append(processor)
 
 
 class OCIRecipeDeleteView(BaseOCIRecipeEditView):
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index bd87975..b840d8c 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -16,6 +16,10 @@ import re
 from fixtures import FakeLogger
 import pytz
 import soupmatchers
+from testtools.matchers import (
+    MatchesSetwise,
+    MatchesStructure,
+    )
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
@@ -29,7 +33,11 @@ from lp.oci.browser.ocirecipe import (
     OCIRecipeEditView,
     OCIRecipeView,
     )
-from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
+from lp.oci.interfaces.ocirecipe import (
+    CannotModifyOCIRecipeProcessor,
+    IOCIRecipeSet,
+    OCI_RECIPE_ALLOW_CREATE,
+    )
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import get_property_cache
@@ -104,7 +112,31 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
 
     def setUp(self):
         super(TestOCIRecipeAddView, self).setUp()
-        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+        self.distroseries = self.factory.makeDistroSeries()
+        self.distribution = self.distroseries.distribution
+        self.useFixture(FeatureFixture({
+            OCI_RECIPE_ALLOW_CREATE: "on",
+            "oci.build_series.%s" % self.distribution.name:
+                self.distroseries.name,
+            }))
+
+    def setUpDistroSeries(self):
+        """Set up self.distroseries with some available processors."""
+        processor_names = ["386", "amd64", "hppa"]
+        for name in processor_names:
+            processor = getUtility(IProcessorSet).getByName(name)
+            self.factory.makeDistroArchSeries(
+                distroseries=self.distroseries, architecturetag=name,
+                processor=processor)
+
+    def assertProcessorControls(self, processors_control, enabled, disabled):
+        matchers = [
+            MatchesStructure.byEquality(optionValue=name, disabled=False)
+            for name in enabled]
+        matchers.extend([
+            MatchesStructure.byEquality(optionValue=name, disabled=True)
+            for name in disabled])
+        self.assertThat(processors_control.controls, MatchesSetwise(*matchers))
 
     def test_create_new_recipe_not_logged_in(self):
         oci_project = self.factory.makeOCIProject()
@@ -158,6 +190,52 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
             ["Test Person (test-person)", "Test Team (test-team)"],
             sorted(str(option) for option in options))
 
+    def test_create_new_recipe_display_processors(self):
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(
+            ["Intel 386 (386)", "AMD 64bit (amd64)", "HPPA Processor (hppa)"],
+            [extract_text(option) for option in processors.displayOptions])
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.options)
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+
+    def test_create_new_recipe_display_restricted_processors(self):
+        # A restricted processor is shown disabled in the UI.
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        proc_armhf = self.factory.makeProcessor(
+            name="armhf", restricted=True, build_by_default=False)
+        self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries, architecturetag="armhf",
+            processor=proc_armhf)
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        processors = browser.getControl(name="field.processors")
+        self.assertProcessorControls(
+            processors, ["386", "amd64", "hppa"], ["armhf"])
+
+    def test_create_new_recipe_processors(self):
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        [git_ref] = self.factory.makeGitRefs()
+        browser = self.getViewBrowser(
+            oci_project, view_name="+new-recipe", user=self.person)
+        processors = browser.getControl(name="field.processors")
+        processors.value = ["386", "amd64"]
+        browser.getControl(name="field.name").value = "recipe-name"
+        browser.getControl("Git repository").value = (
+            git_ref.repository.identity)
+        browser.getControl("Git branch").value = git_ref.path
+        browser.getControl("Create OCI recipe").click()
+        login_person(self.person)
+        recipe = getUtility(IOCIRecipeSet).getByName(
+            self.person, oci_project, "recipe-name")
+        self.assertContentEqual(
+            ["386", "amd64"], [proc.name for proc in recipe.processors])
+
 
 class TestOCIRecipeAdminView(BaseTestOCIRecipeView):
 
@@ -215,7 +293,35 @@ class TestOCIRecipeEditView(BaseTestOCIRecipeView):
 
     def setUp(self):
         super(TestOCIRecipeEditView, self).setUp()
-        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+        self.distroseries = self.factory.makeDistroSeries()
+        self.distribution = self.distroseries.distribution
+        self.useFixture(FeatureFixture({
+            OCI_RECIPE_ALLOW_CREATE: "on",
+            "oci.build_series.%s" % self.distribution.name:
+                self.distroseries.name,
+            }))
+
+    def setUpDistroSeries(self):
+        """Set up self.distroseries with some available processors."""
+        processor_names = ["386", "amd64", "hppa"]
+        for name in processor_names:
+            processor = getUtility(IProcessorSet).getByName(name)
+            self.factory.makeDistroArchSeries(
+                distroseries=self.distroseries, architecturetag=name,
+                processor=processor)
+
+    def assertRecipeProcessors(self, recipe, names):
+        self.assertContentEqual(
+            names, [processor.name for processor in recipe.processors])
+
+    def assertProcessorControls(self, processors_control, enabled, disabled):
+        matchers = [
+            MatchesStructure.byEquality(optionValue=name, disabled=False)
+            for name in enabled]
+        matchers.extend([
+            MatchesStructure.byEquality(optionValue=name, disabled=True)
+            for name in disabled])
+        self.assertThat(processors_control.controls, MatchesSetwise(*matchers))
 
     def test_edit_recipe(self):
         oci_project = self.factory.makeOCIProject()
@@ -290,6 +396,118 @@ class TestOCIRecipeEditView(BaseTestOCIRecipeView):
             "this name." % oci_project_display,
             extract_text(find_tags_by_class(browser.contents, "message")[1]))
 
+    def test_display_processors(self):
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, oci_project=oci_project)
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(
+            ["Intel 386 (386)", "AMD 64bit (amd64)", "HPPA Processor (hppa)"],
+            [extract_text(option) for option in processors.displayOptions])
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.options)
+
+    def test_edit_processors(self):
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, oci_project=oci_project)
+        self.assertRecipeProcessors(recipe, ["386", "amd64", "hppa"])
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+        processors.value = ["386", "amd64"]
+        browser.getControl("Update OCI recipe").click()
+        login_person(self.person)
+        self.assertRecipeProcessors(recipe, ["386", "amd64"])
+
+    def test_edit_with_invisible_processor(self):
+        # It's possible for existing recipes to have an enabled processor
+        # that's no longer usable with the current distroseries, which will
+        # mean it's hidden from the UI, but the non-admin
+        # OCIRecipe.setProcessors isn't allowed to disable it.  Editing the
+        # processor list of such a recipe leaves the invisible processor
+        # intact.
+        proc_386 = getUtility(IProcessorSet).getByName("386")
+        proc_amd64 = getUtility(IProcessorSet).getByName("amd64")
+        proc_armel = self.factory.makeProcessor(
+            name="armel", restricted=True, build_by_default=False)
+        self.setUpDistroSeries()
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, oci_project=oci_project)
+        recipe.setProcessors([proc_386, proc_amd64, proc_armel])
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64"], processors.value)
+        processors.value = ["amd64"]
+        browser.getControl("Update OCI recipe").click()
+        login_person(self.person)
+        self.assertRecipeProcessors(recipe, ["amd64", "armel"])
+
+    def test_edit_processors_restricted(self):
+        # A restricted processor is shown disabled in the UI and cannot be
+        # enabled.
+        self.setUpDistroSeries()
+        proc_armhf = self.factory.makeProcessor(
+            name="armhf", restricted=True, build_by_default=False)
+        self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries, architecturetag="armhf",
+            processor=proc_armhf)
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, oci_project=oci_project)
+        self.assertRecipeProcessors(recipe, ["386", "amd64", "hppa"])
+        browser = self.getViewBrowser(
+            recipe, view_name="+edit", user=recipe.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+        self.assertProcessorControls(
+            processors, ["386", "amd64", "hppa"], ["armhf"])
+        # Even if the user works around the disabled checkbox and forcibly
+        # enables it, they can't enable the restricted processor.
+        for control in processors.controls:
+            if control.optionValue == "armhf":
+                del control._control.attrs["disabled"]
+        processors.value = ["386", "amd64", "armhf"]
+        self.assertRaises(
+            CannotModifyOCIRecipeProcessor,
+            browser.getControl("Update OCI recipe").click)
+
+    def test_edit_processors_restricted_already_enabled(self):
+        # A restricted processor that is already enabled is shown disabled
+        # in the UI.  This causes form submission to omit it, but the
+        # validation code fixes that up behind the scenes so that we don't
+        # get CannotModifyOCIRecipeProcessor.
+        proc_386 = getUtility(IProcessorSet).getByName("386")
+        proc_amd64 = getUtility(IProcessorSet).getByName("amd64")
+        proc_armhf = self.factory.makeProcessor(
+            name="armhf", restricted=True, build_by_default=False)
+        self.setUpDistroSeries()
+        self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries, architecturetag="armhf",
+            processor=proc_armhf)
+        oci_project = self.factory.makeOCIProject(pillar=self.distribution)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=self.person, owner=self.person, oci_project=oci_project)
+        recipe.setProcessors([proc_386, proc_amd64, proc_armhf])
+        self.assertRecipeProcessors(recipe, ["386", "amd64", "armhf"])
+        browser = self.getUserBrowser(
+            canonical_url(recipe) + "/+edit", user=recipe.owner)
+        processors = browser.getControl(name="field.processors")
+        # armhf is checked but disabled.
+        self.assertContentEqual(["386", "amd64", "armhf"], processors.value)
+        self.assertProcessorControls(
+            processors, ["386", "amd64", "hppa"], ["armhf"])
+        processors.value = ["386"]
+        browser.getControl("Update OCI recipe").click()
+        login_person(self.person)
+        self.assertRecipeProcessors(recipe, ["386", "armhf"])
+
 
 class TestOCIRecipeDeleteView(BaseTestOCIRecipeView):
 
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index ca70e38..ed3f9b2 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    'CannotModifyOCIRecipeProcessor',
     'DuplicateOCIRecipeName',
     'IOCIRecipe',
     'IOCIRecipeEdit',
@@ -23,9 +24,14 @@ __all__ = [
     ]
 
 from lazr.restful.declarations import (
+    call_with,
     error_status,
     export_as_webservice_entry,
+    export_write_operation,
     exported,
+    operation_for_version,
+    operation_parameters,
+    REQUEST_USER,
     )
 from lazr.restful.fields import (
     CollectionField,
@@ -33,11 +39,15 @@ from lazr.restful.fields import (
     ReferenceChoice,
     )
 from six.moves import http_client
-from zope.interface import Interface
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
 from zope.schema import (
     Bool,
     Datetime,
     Int,
+    List,
     Text,
     TextLine,
     )
@@ -47,8 +57,11 @@ from lp import _
 from lp.app.errors import NameLookupFailed
 from lp.app.validators.name import name_validator
 from lp.app.validators.path import path_does_not_escape
+from lp.buildmaster.interfaces.processor import IProcessor
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.ociproject import IOCIProject
 from lp.registry.interfaces.role import IHasOwner
 from lp.services.database.constants import DEFAULT
@@ -105,6 +118,19 @@ class NoSourceForOCIRecipe(Exception):
             "New OCI recipes must have a git branch and build file.")
 
 
+@error_status(http_client.FORBIDDEN)
+class CannotModifyOCIRecipeProcessor(Exception):
+    """Tried to enable or disable a restricted processor on an OCI recipe."""
+
+    _fmt = (
+        '%(processor)s is restricted, and may only be enabled or disabled '
+        'by administrators.')
+
+    def __init__(self, processor):
+        super(CannotModifyOCIRecipeProcessor, self).__init__(
+            self._fmt % {'processor': processor.name})
+
+
 class IOCIRecipeView(Interface):
     """`IOCIRecipe` attributes that require launchpad.View permission."""
 
@@ -119,6 +145,35 @@ class IOCIRecipeView(Interface):
         description=_("The user who registered this recipe."),
         vocabulary='ValidPersonOrTeam', required=True, readonly=True))
 
+    distribution = Reference(
+        IDistribution, title=_("Distribution"),
+        required=True, readonly=True,
+        description=_("The distribution that this recipe is associated with."))
+
+    distro_series = Reference(
+        IDistroSeries, title=_("Distro series"),
+        required=True, readonly=True,
+        description=_("The series for which the recipe should be built."))
+
+    available_processors = Attribute(
+        "The architectures that are available to be enabled or disabled for "
+        "this OCI recipe.")
+
+    @call_with(check_permissions=True, user=REQUEST_USER)
+    @operation_parameters(
+        processors=List(
+            value_type=Reference(schema=IProcessor), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def setProcessors(processors, check_permissions=False, user=None):
+        """Set the architectures for which the recipe should be built."""
+
+    def getAllowedArchitectures():
+        """Return all distroarchseries that this recipe can build for.
+
+        :return: Sequence of `IDistroArchSeries` instances.
+        """
+
     builds = CollectionField(
         title=_("Completed builds of this OCI recipe."),
         description=_(
@@ -253,6 +308,13 @@ class IOCIRecipeAdminAttributes(Interface):
         title=_("Require virtualized builders"), required=True, readonly=False,
         description=_("Only build this OCI recipe on virtual builders."))
 
+    processors = exported(CollectionField(
+        title=_("Processors"),
+        description=_(
+            "The architectures for which the OCI recipe should be built."),
+        value_type=Reference(schema=IProcessor),
+        readonly=False))
+
 
 class IOCIRecipe(IOCIRecipeView, IOCIRecipeEdit, IOCIRecipeEditableAttributes,
                  IOCIRecipeAdminAttributes):
@@ -267,7 +329,7 @@ class IOCIRecipeSet(Interface):
 
     def new(name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
-            build_daily=False, date_created=DEFAULT):
+            build_daily=False, processors=None, date_created=DEFAULT):
         """Create an IOCIRecipe."""
 
     def exists(owner, oci_project, name):
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index 21a936b..4e3273a 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -27,16 +27,23 @@ from storm.locals import (
     Storm,
     Unicode,
     )
-from zope.component import getUtility
+from zope.component import (
+    getAdapter,
+    getUtility,
+    )
 from zope.event import notify
 from zope.interface import implementer
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.interfaces.security import IAuthorization
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
 from lp.buildmaster.model.buildfarmjob import BuildFarmJob
 from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.buildmaster.model.processor import Processor
 from lp.oci.interfaces.ocirecipe import (
+    CannotModifyOCIRecipeProcessor,
     DuplicateOCIRecipeName,
     IOCIRecipe,
     IOCIRecipeSet,
@@ -51,6 +58,9 @@ from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
 from lp.oci.model.ocipushrule import OCIPushRule
 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
 from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.role import IPersonRoles
+from lp.registry.model.distroseries import DistroSeries
+from lp.registry.model.series import ACTIVE_STATUSES
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
@@ -67,6 +77,7 @@ from lp.services.database.stormexpr import (
 from lp.services.features import getFeatureFlag
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.model import WebhookTargetMixin
+from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
 def oci_recipe_modified(recipe, event):
@@ -141,6 +152,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
         # XXX twom 2019-11-26 This needs to expand as more build artifacts
         # are added
         store = IStore(OCIRecipe)
+        store.find(OCIRecipeArch, OCIRecipeArch.recipe == self).remove()
         buildqueue_records = store.find(
             BuildQueue,
             BuildQueue._build_farm_job_id == OCIRecipeBuild.build_farm_job_id,
@@ -172,6 +184,112 @@ class OCIRecipe(Storm, WebhookTargetMixin):
             self.git_repository = None
             self.git_path = None
 
+    @property
+    def distribution(self):
+        # XXX twom 2019-12-05 This may need to change when an OCIProject
+        # pillar isn't just a distribution
+        return self.oci_project.distribution
+
+    @property
+    def distro_series(self):
+        # For OCI builds we default to the series set by the feature flag.
+        # If the feature flag is not set we default to the current series of
+        # the recipe's distribution.
+        oci_series = getFeatureFlag('oci.build_series.%s'
+                                    % self.distribution.name)
+        if oci_series:
+            return self.distribution.getSeries(oci_series)
+        else:
+            return self.distribution.currentseries
+
+    @property
+    def available_processors(self):
+        """See `IOCIRecipe`."""
+        clauses = [Processor.id == DistroArchSeries.processor_id]
+        if self.distro_series is not None:
+            clauses.append(DistroArchSeries.id.is_in(
+                self.distro_series.enabled_architectures.get_select_expr(
+                    DistroArchSeries.id)))
+        else:
+            # We might not know the series if the OCI project's distribution
+            # has no series at all, which can happen in tests.  Fall back to
+            # just returning enabled architectures for any active series,
+            # which is a bit of a hack but works.
+            clauses.extend([
+                DistroArchSeries.enabled,
+                DistroArchSeries.distroseriesID == DistroSeries.id,
+                DistroSeries.status.is_in(ACTIVE_STATUSES),
+                ])
+        return Store.of(self).find(Processor, *clauses).config(distinct=True)
+
+    def _getProcessors(self):
+        return list(Store.of(self).find(
+            Processor,
+            Processor.id == OCIRecipeArch.processor_id,
+            OCIRecipeArch.recipe == self))
+
+    def setProcessors(self, processors, check_permissions=False, user=None):
+        """See `IOCIRecipe`."""
+        if check_permissions:
+            can_modify = None
+            if user is not None:
+                roles = IPersonRoles(user)
+                authz = lambda perm: getAdapter(self, IAuthorization, perm)
+                if authz('launchpad.Admin').checkAuthenticated(roles):
+                    can_modify = lambda proc: True
+                elif authz('launchpad.Edit').checkAuthenticated(roles):
+                    can_modify = lambda proc: not proc.restricted
+            if can_modify is None:
+                raise Unauthorized(
+                    'Permission launchpad.Admin or launchpad.Edit required '
+                    'on %s.' % self)
+        else:
+            can_modify = lambda proc: True
+
+        enablements = dict(Store.of(self).find(
+            (Processor, OCIRecipeArch),
+            Processor.id == OCIRecipeArch.processor_id,
+            OCIRecipeArch.recipe == self))
+        for proc in enablements:
+            if proc not in processors:
+                if not can_modify(proc):
+                    raise CannotModifyOCIRecipeProcessor(proc)
+                Store.of(self).remove(enablements[proc])
+        for proc in processors:
+            if proc not in self.processors:
+                if not can_modify(proc):
+                    raise CannotModifyOCIRecipeProcessor(proc)
+                Store.of(self).add(OCIRecipeArch(self, proc))
+
+    processors = property(_getProcessors, setProcessors)
+
+    def _isBuildableArchitectureAllowed(self, das):
+        """Check whether we may build for a buildable `DistroArchSeries`.
+
+        The caller is assumed to have already checked that a suitable chroot
+        is available (either directly or via
+        `DistroSeries.buildable_architectures`).
+        """
+        return (
+            das.enabled
+            and das.processor in self.processors
+            and (
+                das.processor.supports_virtualized
+                or not self.require_virtualized))
+
+    def _isArchitectureAllowed(self, das, pocket):
+        return (
+            das.getChroot(pocket=pocket) is not None
+            and self._isBuildableArchitectureAllowed(das))
+
+    def getAllowedArchitectures(self, distro_series=None):
+        """See `IOCIRecipe`."""
+        if distro_series is None:
+            distro_series = self.distro_series
+        return [
+            das for das in distro_series.buildable_architectures
+            if self._isBuildableArchitectureAllowed(das)]
+
     def _checkRequestBuild(self, requester):
         if not requester.inTeam(self.owner):
             raise OCIRecipeNotOwner(
@@ -282,7 +400,7 @@ class OCIRecipeSet:
 
     def new(self, name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
-            build_daily=False, date_created=DEFAULT):
+            build_daily=False, processors=None, date_created=DEFAULT):
         """See `IOCIRecipeSet`."""
         if not registrant.inTeam(owner):
             if owner.is_team:
@@ -307,6 +425,12 @@ class OCIRecipeSet:
             date_created)
         store.add(oci_recipe)
 
+        if processors is None:
+            processors = [
+                p for p in oci_recipe.available_processors
+                if p.build_by_default]
+        oci_recipe.setProcessors(processors)
+
         return oci_recipe
 
     def _getByName(self, owner, oci_project, name):
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index 12046d6..d8bd496 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -55,7 +55,6 @@ from lp.services.database.interfaces import (
     IMasterStore,
     IStore,
     )
-from lp.services.features import getFeatureFlag
 from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
@@ -139,13 +138,6 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
     # it is not a target, nor referenced in the final build.
     pocket = PackagePublishingPocket.UPDATES
 
-    @property
-    def distro_series(self):
-        # XXX twom 2020-02-14 - This really needs to be set elsewhere,
-        # as this may not be an LTS release and ties the OCI target to
-        # a completely unrelated process.
-        return self.distribution.currentseries
-
     def __init__(self, build_farm_job, requester, recipe,
                  processor, virtualized, date_created):
 
@@ -245,27 +237,20 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
     def archive(self):
         # XXX twom 2019-12-05 This may need to change when an OCIProject
         # pillar isn't just a distribution
-        return self.recipe.oci_project.distribution.main_archive
+        return self.recipe.distribution.main_archive
 
     @property
     def distribution(self):
-        # XXX twom 2019-12-05 This may need to change when an OCIProject
-        # pillar isn't just a distribution
-        return self.recipe.oci_project.distribution
+        return self.recipe.distribution
+
+    @property
+    def distro_series(self):
+        return self.recipe.distro_series
 
     @property
     def distro_arch_series(self):
-        # For OCI builds we default to the series set by the feature flag.
-        # If the feature flag is not set we default to current series under
-        # the OCIRecipeBuild distribution.
-
-        oci_series = getFeatureFlag('oci.build_series.%s'
-                                    % self.distribution.name)
-        if oci_series:
-            oci_series = self.distribution.getSeries(oci_series)
-        else:
-            oci_series = self.distribution.currentseries
-        return oci_series.getDistroArchSeriesByProcessor(self.processor)
+        return self.recipe.distro_series.getDistroArchSeriesByProcessor(
+            self.processor)
 
     def updateStatus(self, status, builder=None, slave_status=None,
                      date_started=None, date_finished=None,
diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
index a2d3809..e5f2686 100644
--- a/lib/lp/oci/templates/ocirecipe-new.pt
+++ b/lib/lp/oci/templates/ocirecipe-new.pt
@@ -32,6 +32,9 @@
         <tal:widget define="widget nocall:view/widgets/build_daily">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/processors">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
       </table>
     </metal:formbody>
   </div>
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index e15dd8e..e158e2e 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -23,7 +23,9 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.oci.interfaces.ocirecipe import (
+    CannotModifyOCIRecipeProcessor,
     DuplicateOCIRecipeName,
     IOCIRecipe,
     IOCIRecipeSet,
@@ -217,6 +219,127 @@ class TestOCIRecipe(TestCaseWithFactory):
             self.assertEqual(rule.recipe, oci_recipe)
 
 
+class TestOCIRecipeProcessors(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIRecipeProcessors, self).setUp(
+            user="foo.bar@xxxxxxxxxxxxx")
+        self.default_procs = [
+            getUtility(IProcessorSet).getByName("386"),
+            getUtility(IProcessorSet).getByName("amd64")]
+        self.unrestricted_procs = (
+            self.default_procs + [getUtility(IProcessorSet).getByName("hppa")])
+        self.arm = self.factory.makeProcessor(
+            name="arm", restricted=True, build_by_default=False)
+        self.distroseries = self.factory.makeDistroSeries()
+        self.useFixture(FeatureFixture({
+            OCI_RECIPE_ALLOW_CREATE: "on",
+            "oci.build_series.%s" % self.distroseries.distribution.name:
+                self.distroseries.name
+            }))
+
+    def test_available_processors(self):
+        # Only those processors that are enabled for the recipe's
+        # distroseries are available.
+        for processor in self.default_procs:
+            self.factory.makeDistroArchSeries(
+                distroseries=self.distroseries, architecturetag=processor.name,
+                processor=processor)
+        self.factory.makeDistroArchSeries(
+            architecturetag=self.arm.name, processor=self.arm)
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        recipe = self.factory.makeOCIRecipe(oci_project=oci_project)
+        self.assertContentEqual(
+            self.default_procs, recipe.available_processors)
+
+    def test_new_default_processors(self):
+        # OCIRecipeSet.new creates an OCIRecipeArch for each available
+        # Processor with build_by_default set.
+        new_procs = [
+            self.factory.makeProcessor(name="default", build_by_default=True),
+            self.factory.makeProcessor(
+                name="nondefault", build_by_default=False),
+            ]
+        owner = self.factory.makePerson()
+        for processor in self.unrestricted_procs + [self.arm] + new_procs:
+            self.factory.makeDistroArchSeries(
+                distroseries=self.distroseries, architecturetag=processor.name,
+                processor=processor)
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        recipe = getUtility(IOCIRecipeSet).new(
+            name=self.factory.getUniqueUnicode(), registrant=owner,
+            owner=owner, oci_project=oci_project,
+            git_ref=self.factory.makeGitRefs()[0],
+            build_file=self.factory.getUniqueUnicode())
+        self.assertContentEqual(
+            ["386", "amd64", "hppa", "default"],
+            [processor.name for processor in recipe.processors])
+
+    def test_new_override_processors(self):
+        # OCIRecipeSet.new can be given a custom set of processors.
+        owner = self.factory.makePerson()
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution)
+        recipe = getUtility(IOCIRecipeSet).new(
+            name=self.factory.getUniqueUnicode(), registrant=owner,
+            owner=owner, oci_project=oci_project,
+            git_ref=self.factory.makeGitRefs()[0],
+            build_file=self.factory.getUniqueUnicode(), processors=[self.arm])
+        self.assertContentEqual(
+            ["arm"], [processor.name for processor in recipe.processors])
+
+    def test_set(self):
+        # The property remembers its value correctly.
+        recipe = self.factory.makeOCIRecipe()
+        recipe.setProcessors([self.arm])
+        self.assertContentEqual([self.arm], recipe.processors)
+        recipe.setProcessors(self.unrestricted_procs + [self.arm])
+        self.assertContentEqual(
+            self.unrestricted_procs + [self.arm], recipe.processors)
+        recipe.setProcessors([])
+        self.assertContentEqual([], recipe.processors)
+
+    def test_set_non_admin(self):
+        """Non-admins can only enable or disable unrestricted processors."""
+        recipe = self.factory.makeOCIRecipe()
+        recipe.setProcessors(self.default_procs)
+        self.assertContentEqual(self.default_procs, recipe.processors)
+        with person_logged_in(recipe.owner) as owner:
+            # Adding arm is forbidden ...
+            self.assertRaises(
+                CannotModifyOCIRecipeProcessor, recipe.setProcessors,
+                [self.default_procs[0], self.arm],
+                check_permissions=True, user=owner)
+            # ... but removing amd64 is OK.
+            recipe.setProcessors(
+                [self.default_procs[0]], check_permissions=True, user=owner)
+            self.assertContentEqual([self.default_procs[0]], recipe.processors)
+        with admin_logged_in() as admin:
+            recipe.setProcessors(
+                [self.default_procs[0], self.arm],
+                check_permissions=True, user=admin)
+            self.assertContentEqual(
+                [self.default_procs[0], self.arm], recipe.processors)
+        with person_logged_in(recipe.owner) as owner:
+            hppa = getUtility(IProcessorSet).getByName("hppa")
+            self.assertFalse(hppa.restricted)
+            # Adding hppa while removing arm is forbidden ...
+            self.assertRaises(
+                CannotModifyOCIRecipeProcessor, recipe.setProcessors,
+                [self.default_procs[0], hppa],
+                check_permissions=True, user=owner)
+            # ... but adding hppa while retaining arm is OK.
+            recipe.setProcessors(
+                [self.default_procs[0], self.arm, hppa],
+                check_permissions=True, user=owner)
+            self.assertContentEqual(
+                [self.default_procs[0], self.arm, hppa], recipe.processors)
+
+
 class TestOCIRecipeSet(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer