launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31604
[Merge] ~ruinedyourlife/launchpad:implement-craft-recipe-requests-builds-job into launchpad:master
Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:implement-craft-recipe-requests-builds-job into launchpad:master with ~ruinedyourlife/launchpad:add-sourcecraft-yaml-parser as a prerequisite.
Commit message:
Add run logic to CraftRecipeRequestBuildsJob
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/474137
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:implement-craft-recipe-requests-builds-job into launchpad:master.
diff --git a/charm/launchpad-scripts/layer.yaml b/charm/launchpad-scripts/layer.yaml
index 71c739e..3e86dff 100644
--- a/charm/launchpad-scripts/layer.yaml
+++ b/charm/launchpad-scripts/layer.yaml
@@ -18,6 +18,7 @@ options:
- charm-build-job
- checkwatches
- copy_packages
+ - craft-build-job
- cve
- distributionmirror
- distroseriesdifferencejob
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index 905b609..34090aa 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -6,6 +6,8 @@
__all__ = [
"BadCraftRecipeSource",
"BadCraftRecipeSearchContext",
+ "CannotFetchSourcecraftYaml",
+ "CannotParseSourcecraftYaml",
"CRAFT_RECIPE_ALLOW_CREATE",
"CRAFT_RECIPE_PRIVATE_FEATURE_FLAG",
"CraftRecipeBuildAlreadyPending",
@@ -19,6 +21,7 @@ __all__ = [
"ICraftRecipe",
"ICraftRecipeBuildRequest",
"ICraftRecipeSet",
+ "MissingSourcecraftYaml",
"NoSourceForCraftRecipe",
"NoSuchCraftRecipe",
]
@@ -130,6 +133,23 @@ class BadCraftRecipeSearchContext(Exception):
"""The context is not valid for a craft recipe search."""
+class MissingSourcecraftYaml(Exception):
+ """The repository for this craft recipe does not have a
+ sourcecraft.yaml.
+ """
+
+ def __init__(self, branch_name):
+ super().__init__("Cannot find sourcecraft.yaml in %s" % branch_name)
+
+
+class CannotFetchSourcecraftYaml(Exception):
+ """Launchpad cannot fetch this craft recipe's sourcecraft.yaml."""
+
+
+class CannotParseSourcecraftYaml(Exception):
+ """Launchpad cannot parse this craft recipe's sourcecraft.yaml."""
+
+
@error_status(http.client.BAD_REQUEST)
class CraftRecipeBuildAlreadyPending(Exception):
"""A build was requested when an identical build was already pending."""
@@ -311,6 +331,31 @@ class ICraftRecipeView(Interface):
:return: An `ICraftRecipeBuildRequest`.
"""
+ def requestBuildsFromJob(
+ build_request,
+ channels=None,
+ architectures=None,
+ allow_failures=False,
+ logger=None,
+ ):
+ """Synchronous part of `CraftRecipe.requestBuilds`.
+
+ Request that the craft recipe be built for relevant architectures.
+
+ :param build_request: The `ICraftRecipeBuildRequest` job being
+ processed.
+ :param channels: A dictionary mapping snap names to channels to use
+ for these builds.
+ :param architectures: If not None, limit builds to architectures
+ with these architecture tags (in addition to any other
+ applicable constraints).
+ :param allow_failures: If True, log exceptions other than "already
+ pending" from individual build requests; if False, raise them to
+ the caller.
+ :param logger: An optional logger.
+ :return: A sequence of `ICraftRecipeBuild` instances.
+ """
+
def getBuildRequest(job_id):
"""Get an asynchronous build request by ID.
@@ -555,3 +600,20 @@ class ICraftRecipeSet(Interface):
def preloadDataForRecipes(recipes, user):
"""Load the data related to a list of craft recipes."""
+
+ def getSourcecraftYaml(context, logger=None):
+ """Fetch a recipe's sourcecraft.yaml from code hosting, if possible.
+
+ :param context: Either an `ICraftRecipe` or the source branch for a
+ craft recipe.
+ :param logger: An optional logger.
+
+ :return: The recipe's parsed sourcecraft.yaml.
+ :raises MissingSourcecraftYaml: if this recipe has no
+ sourcecraft.yaml.
+ :raises CannotFetchSourcecraftYaml: if it was not possible to fetch
+ sourcecraft.yaml from the code hosting backend for some other
+ reason.
+ :raises CannotParseSourcecraftYaml: if the fetched sourcecraft.yaml
+ cannot be parsed.
+ """
diff --git a/lib/lp/crafts/interfaces/craftrecipebuild.py b/lib/lp/crafts/interfaces/craftrecipebuild.py
index 256fa6d..e99e294 100644
--- a/lib/lp/crafts/interfaces/craftrecipebuild.py
+++ b/lib/lp/crafts/interfaces/craftrecipebuild.py
@@ -68,7 +68,7 @@ class ICraftRecipeBuildView(IPackageBuildView):
description=_(
"A dictionary mapping snap names to channels to use for this "
"build. Currently only 'core', 'core18', 'core20', "
- "and 'craftcraft' keys are supported."
+ "and 'sourcecraft' keys are supported."
),
key_type=TextLine(),
)
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
index cdad7ff..addc6ee 100644
--- a/lib/lp/crafts/interfaces/craftrecipejob.py
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -58,7 +58,7 @@ class ICraftRecipeRequestBuildsJob(IRunnableJob):
description=_(
"A dictionary mapping snap names to channels to use for these "
"builds. Currently only 'core', 'core18', 'core20', and "
- "'craftcraft' keys are supported."
+ "'sourcecraft' keys are supported."
),
key_type=TextLine(),
required=False,
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index 7a1dc0d..1deff6b 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -8,8 +8,9 @@ __all__ = [
]
from datetime import timezone
-from operator import itemgetter
+from operator import attrgetter, itemgetter
+import yaml
from lazr.lifecycle.event import ObjectCreatedEvent
from storm.databases.postgres import JSON
from storm.locals import (
@@ -33,12 +34,16 @@ from lp.app.enums import (
InformationType,
)
from lp.buildmaster.enums import BuildStatus
+from lp.code.errors import GitRepositoryBlobNotFound, GitRepositoryScanFault
from lp.code.model.gitcollection import GenericGitCollection
from lp.code.model.gitrepository import GitRepository
from lp.code.model.reciperegistry import recipe_registry
+from lp.crafts.adapters.buildarch import determine_instances_to_build
from lp.crafts.interfaces.craftrecipe import (
CRAFT_RECIPE_ALLOW_CREATE,
CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+ CannotFetchSourcecraftYaml,
+ CannotParseSourcecraftYaml,
CraftRecipeBuildAlreadyPending,
CraftRecipeBuildDisallowedArchitecture,
CraftRecipeBuildRequestStatus,
@@ -50,6 +55,7 @@ from lp.crafts.interfaces.craftrecipe import (
ICraftRecipe,
ICraftRecipeBuildRequest,
ICraftRecipeSet,
+ MissingSourcecraftYaml,
NoSourceForCraftRecipe,
)
from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuildSet
@@ -377,6 +383,88 @@ class CraftRecipe(StormBase):
)
return self.getBuildRequest(job.job_id)
+ def requestBuildsFromJob(
+ self,
+ build_request,
+ channels=None,
+ architectures=None,
+ allow_failures=False,
+ logger=None,
+ ):
+ """See `ICraftRecipe`."""
+ try:
+ sourcecraft_data = removeSecurityProxy(
+ getUtility(ICraftRecipeSet).getSourcecraftYaml(self)
+ )
+
+ # Sort by (Distribution.id, DistroSeries.id, Processor.id) for
+ # determinism. This is chosen to be a similar order as in
+ # BinaryPackageBuildSet.createForSource, to minimize confusion.
+ supported_arches = [
+ das
+ for das in sorted(
+ self.getAllowedArchitectures(),
+ key=attrgetter(
+ "distroseries.distribution.id",
+ "distroseries.id",
+ "processor.id",
+ ),
+ )
+ if (
+ architectures is None
+ or das.architecturetag in architectures
+ )
+ ]
+ instances_to_build = determine_instances_to_build(
+ sourcecraft_data, supported_arches
+ )
+ except Exception as e:
+ if not allow_failures:
+ raise
+ elif logger is not None:
+ logger.exception(
+ " - %s/%s/%s: %s",
+ self.owner.name,
+ self.project.name,
+ self.name,
+ e,
+ )
+
+ builds = []
+ for das in instances_to_build:
+ try:
+ build = self.requestBuild(
+ build_request, das, channels=channels
+ )
+ if logger is not None:
+ logger.debug(
+ " - %s/%s/%s %s/%s/%s: Build requested.",
+ self.owner.name,
+ self.project.name,
+ self.name,
+ das.distroseries.distribution.name,
+ das.distroseries.name,
+ das.architecturetag,
+ )
+ builds.append(build)
+ except CraftRecipeBuildAlreadyPending:
+ pass
+ except Exception as e:
+ if not allow_failures:
+ raise
+ elif logger is not None:
+ logger.exception(
+ " - %s/%s/%s %s/%s/%s: %s",
+ self.owner.name,
+ self.project.name,
+ self.name,
+ das.distroseries.distribution.name,
+ das.distroseries.name,
+ das.architecturetag,
+ e,
+ )
+ return builds
+
def getBuildRequest(self, job_id):
"""See `ICraftRecipe`."""
return CraftRecipeBuildRequest(self, job_id)
@@ -527,6 +615,54 @@ class CraftRecipeSet:
)
)
+ def getSourcecraftYaml(self, context, logger=None):
+ """See `ICraftRecipeSet`."""
+ if ICraftRecipe.providedBy(context):
+ recipe = context
+ source = context.git_ref
+ else:
+ recipe = None
+ source = context
+ if source is None:
+ raise CannotFetchSourcecraftYaml("Craft source is not defined")
+ try:
+ path = "sourcecraft.yaml"
+ if recipe is not None and recipe.build_path is not None:
+ path = "/".join((recipe.build_path, path))
+ try:
+ blob = source.getBlob(path)
+ except GitRepositoryBlobNotFound:
+ if logger is not None:
+ logger.exception(
+ "Cannot find sourcecraft.yaml in %s",
+ source.unique_name,
+ )
+ raise MissingSourcecraftYaml(source.unique_name)
+ except GitRepositoryScanFault as e:
+ msg = "Failed to get sourcecraft.yaml from %s"
+ if logger is not None:
+ logger.exception(msg, source.unique_name)
+ raise CannotFetchSourcecraftYaml(
+ "%s: %s" % (msg % source.unique_name, e)
+ )
+
+ try:
+ sourcecraft_data = yaml.safe_load(blob)
+ except Exception as e:
+ # Don't bother logging parsing errors from user-supplied YAML.
+ raise CannotParseSourcecraftYaml(
+ "Cannot parse sourcecraft.yaml from %s: %s"
+ % (source.unique_name, e)
+ )
+
+ if not isinstance(sourcecraft_data, dict):
+ raise CannotParseSourcecraftYaml(
+ "The top level of sourcecraft.yaml from %s is not a mapping"
+ % source.unique_name
+ )
+
+ return sourcecraft_data
+
@implementer(ICraftRecipeBuildRequest)
class CraftRecipeBuildRequest:
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
index f249360..799e6e7 100644
--- a/lib/lp/crafts/model/craftrecipejob.py
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -19,6 +19,11 @@ from zope.component import getUtility
from zope.interface import implementer, provider
from lp.app.errors import NotFoundError
+from lp.crafts.interfaces.craftrecipe import (
+ CannotFetchSourcecraftYaml,
+ CannotParseSourcecraftYaml,
+ MissingSourcecraftYaml,
+)
from lp.crafts.interfaces.craftrecipejob import (
ICraftRecipeJob,
ICraftRecipeRequestBuildsJob,
@@ -156,6 +161,12 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
class_job_type = CraftRecipeJobType.REQUEST_BUILDS
+ user_error_types = (
+ CannotParseSourcecraftYaml,
+ MissingSourcecraftYaml,
+ )
+ retry_error_types = (CannotFetchSourcecraftYaml,)
+
max_retries = 5
config = config.ICraftRecipeRequestBuildsJobSource
@@ -304,9 +315,13 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
)
return
try:
- # XXX ruinedyourlife 2024-09-25: Implement this once we have a
- # CraftRecipeBuild model.
- raise NotImplementedError
+ self.builds = self.recipe.requestBuildsFromJob(
+ self.build_request,
+ channels=self.channels,
+ architectures=self.architectures,
+ logger=log,
+ )
+ self.error_message = None
except Exception as e:
self.error_message = str(e)
# The normal job infrastructure will abort the transaction, but
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index 1c0bc7b..47a44f6 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -3,6 +3,9 @@
"""Test craft recipes."""
+from textwrap import dedent
+
+import transaction
from storm.locals import Store
from testtools.matchers import (
Equals,
@@ -15,6 +18,7 @@ from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
from lp.buildmaster.interfaces.buildqueue import IBuildQueue
from lp.buildmaster.interfaces.processor import (
@@ -22,6 +26,7 @@ from lp.buildmaster.interfaces.processor import (
ProcessorNotFound,
)
from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.code.tests.helpers import GitHostingFixture
from lp.crafts.interfaces.craftrecipe import (
CRAFT_RECIPE_ALLOW_CREATE,
CraftRecipeBuildAlreadyPending,
@@ -195,9 +200,9 @@ class TestCraftRecipe(TestCaseWithFactory):
das = self.makeBuildableDistroArchSeries()
build_request = self.factory.makeCraftRecipeBuildRequest(recipe=recipe)
build = recipe.requestBuild(
- build_request, das, channels={"craftcraft": "edge"}
+ build_request, das, channels={"sourcecraft": "edge"}
)
- self.assertEqual({"craftcraft": "edge"}, build.channels)
+ self.assertEqual({"sourcecraft": "edge"}, build.channels)
def test_requestBuild_rejects_repeats(self):
# requestBuild refuses if there is already a pending build.
@@ -330,7 +335,7 @@ class TestCraftRecipe(TestCaseWithFactory):
now = get_transaction_timestamp(IStore(recipe))
with person_logged_in(recipe.owner.teamowner):
request = recipe.requestBuilds(
- recipe.owner.teamowner, channels={"craftcraft": "edge"}
+ recipe.owner.teamowner, channels={"sourcecraft": "edge"}
)
self.assertThat(
request,
@@ -340,7 +345,7 @@ class TestCraftRecipe(TestCaseWithFactory):
recipe=Equals(recipe),
status=Equals(CraftRecipeBuildRequestStatus.PENDING),
error_message=Is(None),
- channels=MatchesDict({"craftcraft": Equals("edge")}),
+ channels=MatchesDict({"sourcecraft": Equals("edge")}),
architectures=Is(None),
),
)
@@ -352,7 +357,7 @@ class TestCraftRecipe(TestCaseWithFactory):
job=MatchesStructure.byEquality(status=JobStatus.WAITING),
recipe=Equals(recipe),
requester=Equals(recipe.owner.teamowner),
- channels=Equals({"craftcraft": "edge"}),
+ channels=Equals({"sourcecraft": "edge"}),
architectures=Is(None),
),
)
@@ -391,6 +396,108 @@ class TestCraftRecipe(TestCaseWithFactory):
),
)
+ def makeRequestBuildsJob(
+ self, distro_series_version, arch_tags, git_ref=None
+ ):
+ recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+ distro_series = self.factory.makeDistroSeries(
+ distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+ version=distro_series_version,
+ )
+ for arch_tag in arch_tags:
+ self.makeBuildableDistroArchSeries(
+ distroseries=distro_series, architecturetag=arch_tag
+ )
+ return getUtility(ICraftRecipeRequestBuildsJobSource).create(
+ recipe, recipe.owner.teamowner, {"sourcecraft": "edge"}
+ )
+
+ def assertRequestedBuildsMatch(
+ self, builds, job, distro_series_version, arch_tags, channels
+ ):
+ self.assertThat(
+ builds,
+ MatchesSetwise(
+ *(
+ MatchesStructure(
+ requester=Equals(job.requester),
+ recipe=Equals(job.recipe),
+ distro_arch_series=MatchesStructure(
+ distroseries=MatchesStructure.byEquality(
+ version=distro_series_version
+ ),
+ architecturetag=Equals(arch_tag),
+ ),
+ channels=Equals(channels),
+ )
+ for arch_tag in arch_tags
+ )
+ ),
+ )
+
+ def test_requestBuildsFromJob_restricts_explicit_list(self):
+ # requestBuildsFromJob limits builds targeted at an explicit list of
+ # architectures to those allowed for the recipe.
+ self.useFixture(
+ GitHostingFixture(
+ blob=dedent(
+ """\
+ base: ubuntu@20.04
+ platforms:
+ sparc:
+ avr:
+ """
+ )
+ )
+ )
+ job = self.makeRequestBuildsJob("20.04", ["sparc", "avr", "mips64el"])
+ self.assertEqual(
+ get_transaction_timestamp(IStore(job.recipe)), job.date_created
+ )
+ transaction.commit()
+ with person_logged_in(job.requester):
+ builds = job.recipe.requestBuildsFromJob(
+ job.build_request, channels=removeSecurityProxy(job.channels)
+ )
+ self.assertRequestedBuildsMatch(
+ builds, job, "20.04", ["sparc", "avr"], job.channels
+ )
+
+ def test_requestBuildsFromJob_architectures_parameter(self):
+ # If an explicit set of architectures was given as a parameter,
+ # requestBuildsFromJob intersects those with any other constraints
+ # when requesting builds.
+ self.useFixture(
+ GitHostingFixture(
+ blob=dedent(
+ """\
+ base: ubuntu@20.04
+ platforms:
+ sparc:
+ i386:
+ avr:
+ riscv64:
+ """
+ )
+ )
+ )
+ job = self.makeRequestBuildsJob(
+ "20.04", ["avr", "mips64el", "riscv64"]
+ )
+ self.assertEqual(
+ get_transaction_timestamp(IStore(job.recipe)), job.date_created
+ )
+ transaction.commit()
+ with person_logged_in(job.requester):
+ builds = job.recipe.requestBuildsFromJob(
+ job.build_request,
+ channels=removeSecurityProxy(job.channels),
+ architectures={"avr", "riscv64"},
+ )
+ self.assertRequestedBuildsMatch(
+ builds, job, "20.04", ["avr", "riscv64"], job.channels
+ )
+
class TestCraftRecipeSet(TestCaseWithFactory):
diff --git a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
index 94f6496..b11d81c 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
@@ -413,11 +413,11 @@ class TestAsyncCraftRecipeBuildBehaviour(
@defer.inlineCallbacks
def test_extraBuildArgs_channels(self):
# If the build needs particular channels, extraBuildArgs sends them.
- job = self.makeJob(channels={"craftcraft": "edge"}, with_builder=True)
+ job = self.makeJob(channels={"sourcecraft": "edge"}, with_builder=True)
with dbuser(config.builddmaster.dbuser):
args = yield job.extraBuildArgs()
self.assertFalse(isProxy(args["channels"]))
- self.assertEqual({"craftcraft": "edge"}, args["channels"])
+ self.assertEqual({"sourcecraft": "edge"}, args["channels"])
@defer.inlineCallbacks
def test_extraBuildArgs_archives_primary(self):
diff --git a/lib/lp/crafts/tests/test_craftrecipejob.py b/lib/lp/crafts/tests/test_craftrecipejob.py
index 8066856..11f3b47 100644
--- a/lib/lp/crafts/tests/test_craftrecipejob.py
+++ b/lib/lp/crafts/tests/test_craftrecipejob.py
@@ -3,7 +3,28 @@
"""Tests for craft recipe jobs."""
-from lp.crafts.interfaces.craftrecipe import CRAFT_RECIPE_ALLOW_CREATE
+from textwrap import dedent
+
+import six
+from testtools.matchers import (
+ AfterPreprocessing,
+ ContainsDict,
+ Equals,
+ GreaterThan,
+ Is,
+ LessThan,
+ MatchesAll,
+ MatchesSetwise,
+ MatchesStructure,
+)
+from zope.component import getUtility
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.code.tests.helpers import GitHostingFixture
+from lp.crafts.interfaces.craftrecipe import (
+ CRAFT_RECIPE_ALLOW_CREATE,
+ CannotParseSourcecraftYaml,
+)
from lp.crafts.interfaces.craftrecipejob import (
ICraftRecipeJob,
ICraftRecipeRequestBuildsJob,
@@ -13,8 +34,15 @@ from lp.crafts.model.craftrecipejob import (
CraftRecipeJobType,
CraftRecipeRequestBuildsJob,
)
+from lp.services.config import config
+from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
+from lp.services.mail.sendmail import format_address_for_person
from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
from lp.testing.layers import ZopelessDatabaseLayer
@@ -45,7 +73,7 @@ class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
def test_provides_interface(self):
# `CraftRecipeRequestBuildsJob` objects provide
- # `ICraftRecipeRequestBuildsJob`."""
+ # `ICraftRecipeRequestBuildsJob`.
recipe = self.factory.makeCraftRecipe()
job = CraftRecipeRequestBuildsJob.create(recipe, recipe.registrant)
self.assertProvides(job, ICraftRecipeRequestBuildsJob)
@@ -59,3 +87,181 @@ class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
% (recipe.owner.name, recipe.project.name, recipe.name),
repr(job),
)
+
+ def makeSeriesAndProcessors(self, distro_series_version, arch_tags):
+ distroseries = self.factory.makeDistroSeries(
+ distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+ version=distro_series_version,
+ )
+ processors = [
+ self.factory.makeProcessor(
+ name=arch_tag, supports_virtualized=True
+ )
+ for arch_tag in arch_tags
+ ]
+ for processor in processors:
+ das = self.factory.makeDistroArchSeries(
+ distroseries=distroseries,
+ architecturetag=processor.name,
+ processor=processor,
+ )
+ das.addOrUpdateChroot(
+ self.factory.makeLibraryFileAlias(
+ filename="fake_chroot.tar.gz", db_only=True
+ )
+ )
+ return distroseries, processors
+
+ def test_run(self):
+ # The job requests builds and records the result.
+ distroseries, _ = self.makeSeriesAndProcessors(
+ "20.04", ["avr2001", "sparc64", "x32"]
+ )
+ [git_ref] = self.factory.makeGitRefs()
+ recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+ expected_date_created = get_transaction_timestamp(IStore(recipe))
+ job = CraftRecipeRequestBuildsJob.create(
+ recipe, recipe.registrant, channels={"core": "stable"}
+ )
+ sourcecraft_yaml = dedent(
+ """\
+ base: ubuntu@20.04
+ platforms:
+ avr2001:
+ x32:
+ """
+ )
+ self.useFixture(GitHostingFixture(blob=sourcecraft_yaml))
+ with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+ JobRunner([job]).runAll()
+ now = get_transaction_timestamp(IStore(recipe))
+ self.assertEmailQueueLength(0)
+ self.assertThat(
+ job,
+ MatchesStructure(
+ job=MatchesStructure.byEquality(status=JobStatus.COMPLETED),
+ date_created=Equals(expected_date_created),
+ date_finished=MatchesAll(
+ GreaterThan(expected_date_created), LessThan(now)
+ ),
+ error_message=Is(None),
+ builds=AfterPreprocessing(
+ set,
+ MatchesSetwise(
+ *[
+ MatchesStructure(
+ build_request=MatchesStructure.byEquality(
+ id=job.job.id
+ ),
+ requester=Equals(recipe.registrant),
+ recipe=Equals(recipe),
+ distro_arch_series=Equals(distroseries[arch]),
+ channels=Equals({"core": "stable"}),
+ )
+ for arch in ("avr2001", "x32")
+ ]
+ ),
+ ),
+ ),
+ )
+
+ def test_run_with_architectures(self):
+ # If the user explicitly requested architectures, the job passes
+ # those through when requesting builds, intersecting them with other
+ # constraints.
+ distroseries, _ = self.makeSeriesAndProcessors(
+ "20.04", ["avr2001", "sparc64", "x32"]
+ )
+ [git_ref] = self.factory.makeGitRefs()
+ recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+ expected_date_created = get_transaction_timestamp(IStore(recipe))
+ job = CraftRecipeRequestBuildsJob.create(
+ recipe,
+ recipe.registrant,
+ channels={"core": "stable"},
+ architectures=["sparc64", "x32"],
+ )
+ sourcecraft_yaml = dedent(
+ """\
+ base: ubuntu@20.04
+ platforms:
+ x32:
+ """
+ )
+ self.useFixture(GitHostingFixture(blob=sourcecraft_yaml))
+ with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+ JobRunner([job]).runAll()
+ now = get_transaction_timestamp(IStore(recipe))
+ self.assertEmailQueueLength(0)
+ self.assertThat(
+ job,
+ MatchesStructure(
+ job=MatchesStructure.byEquality(status=JobStatus.COMPLETED),
+ date_created=Equals(expected_date_created),
+ date_finished=MatchesAll(
+ GreaterThan(expected_date_created), LessThan(now)
+ ),
+ error_message=Is(None),
+ builds=AfterPreprocessing(
+ set,
+ MatchesSetwise(
+ MatchesStructure(
+ build_request=MatchesStructure.byEquality(
+ id=job.job.id
+ ),
+ requester=Equals(recipe.registrant),
+ recipe=Equals(recipe),
+ distro_arch_series=Equals(distroseries["x32"]),
+ channels=Equals({"core": "stable"}),
+ )
+ ),
+ ),
+ ),
+ )
+
+ def test_run_failed(self):
+ # A failed run sets the job status to FAILED and records the error
+ # message.
+ [git_ref] = self.factory.makeGitRefs()
+ recipe = self.factory.makeCraftRecipe(git_ref=git_ref)
+ expected_date_created = get_transaction_timestamp(IStore(recipe))
+ job = CraftRecipeRequestBuildsJob.create(
+ recipe, recipe.registrant, channels={"core": "stable"}
+ )
+ self.useFixture(GitHostingFixture()).getBlob.failure = (
+ CannotParseSourcecraftYaml("Nonsense on stilts")
+ )
+ with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+ JobRunner([job]).runAll()
+ now = get_transaction_timestamp(IStore(recipe))
+ [notification] = self.assertEmailQueueLength(1)
+ self.assertThat(
+ dict(notification),
+ ContainsDict(
+ {
+ "From": Equals(config.canonical.noreply_from_address),
+ "To": Equals(format_address_for_person(recipe.registrant)),
+ "Subject": Equals(
+ "Launchpad error while requesting builds of %s"
+ % recipe.name
+ ),
+ }
+ ),
+ )
+ self.assertEqual(
+ "Launchpad encountered an error during the following operation: "
+ "requesting builds of %s. Nonsense on stilts" % recipe.name,
+ six.ensure_text(notification.get_payload(decode=True)),
+ )
+ self.assertThat(
+ job,
+ MatchesStructure(
+ job=MatchesStructure.byEquality(status=JobStatus.FAILED),
+ date_created=Equals(expected_date_created),
+ date_finished=MatchesAll(
+ GreaterThan(expected_date_created), LessThan(now)
+ ),
+ error_message=Equals("Nonsense on stilts"),
+ builds=AfterPreprocessing(set, MatchesSetwise()),
+ ),
+ )
Follow ups