launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31588
[Merge] ~ruinedyourlife/launchpad:add-sourcecraft-yaml-parser into launchpad:master
Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-sourcecraft-yaml-parser into launchpad:master with ~ruinedyourlife/launchpad:add-build-upload-processing-for-craft-recipes as a prerequisite.
Commit message:
Add sourcecraft yaml parser
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/474016
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-sourcecraft-yaml-parser into launchpad:master.
diff --git a/lib/lp/crafts/adapters/__init__.py b/lib/lp/crafts/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/crafts/adapters/__init__.py
diff --git a/lib/lp/crafts/adapters/buildarch.py b/lib/lp/crafts/adapters/buildarch.py
new file mode 100644
index 0000000..3f1ffde
--- /dev/null
+++ b/lib/lp/crafts/adapters/buildarch.py
@@ -0,0 +1,127 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "determine_instances_to_build",
+]
+
+from collections import OrderedDict
+
+
+class CraftBasesParserError(Exception):
+ """Base class for all exceptions in this module."""
+
+
+class MissingPropertyError(CraftBasesParserError):
+ """Error for when an expected property is not present in the YAML."""
+
+ def __init__(self, prop, msg=None):
+ if msg is None:
+ msg = f"Craft specification is missing the {prop!r} property"
+ super().__init__(msg)
+ self.property = prop
+
+
+class BadPropertyError(CraftBasesParserError):
+ """Error for when a YAML property is malformed in some way."""
+
+
+class CraftBase:
+ """A single base in sourcecraft.yaml."""
+
+ VALID_BASES = {"ubuntu@20.04", "ubuntu@22.04", "ubuntu@24.04", "bare"}
+ VALID_BUILD_BASES = VALID_BASES | {"devel"}
+
+ def __init__(self, base_string, is_build_base=False):
+ if is_build_base:
+ if base_string not in self.VALID_BUILD_BASES:
+ raise BadPropertyError(
+ f"Invalid build-base: {base_string!r}. "
+ f"Must be one of "
+ f"{', '.join(sorted(self.VALID_BUILD_BASES))}"
+ )
+ else:
+ if base_string not in self.VALID_BASES:
+ raise BadPropertyError(
+ f"Invalid base: {base_string!r}. "
+ f"Must be one of {', '.join(sorted(self.VALID_BASES))}"
+ )
+
+ self.base_string = base_string
+ if base_string == "bare":
+ self.name = "bare"
+ self.channel = None
+ elif base_string == "devel":
+ raise BadPropertyError("The 'devel' base is not supported for now")
+ else:
+ self.name, self.channel = base_string.split("@")
+
+ def __eq__(self, other):
+ if not isinstance(other, CraftBase):
+ return NotImplemented
+ return self.name == other.name and self.channel == other.channel
+
+ def __hash__(self):
+ return hash((self.name, self.channel))
+
+ def __str__(self):
+ return f"{self.name}@{self.channel}"
+
+
+def determine_instances_to_build(
+ sourcecraft_data, supported_arches, default_distro_series
+):
+ """Return a list of instances to build based on sourcecraft.yaml.
+
+ :param sourcecraft_data: A parsed sourcecraft.yaml.
+ :param supported_arches: An ordered list of all `DistroArchSeries` that
+ we can create builds for. Note that these may span multiple
+ `DistroSeries`.
+ :param default_distro_series: The default `DistroSeries` to use if
+ sourcecraft.yaml does not explicitly declare a base.
+ :return: A list of `DistroArchSeries`.
+ """
+ base = sourcecraft_data.get("base")
+ if not base:
+ raise MissingPropertyError("base")
+
+ craft_base = CraftBase(base)
+
+ if craft_base.base_string == "bare":
+ build_base = sourcecraft_data.get("build-base")
+ if not build_base:
+ raise MissingPropertyError(
+ "build-base",
+ "When 'base' is 'bare', 'build-base' must be specified",
+ )
+ craft_base = CraftBase(build_base, is_build_base=True)
+ elif "build-base" in sourcecraft_data:
+ craft_base = CraftBase(
+ sourcecraft_data["build-base"], is_build_base=True
+ )
+
+ platforms = sourcecraft_data.get("platforms")
+ if not platforms:
+ raise MissingPropertyError("platforms")
+
+ instances = OrderedDict()
+ for platform, config in platforms.items():
+ build_on = config.get("build-on", [platform])
+ build_for = config.get("build-for", [platform])
+
+ if isinstance(build_on, str):
+ build_on = [build_on]
+ if isinstance(build_for, str):
+ build_for = [build_for]
+
+ for arch in build_on:
+ for das in supported_arches:
+ if (
+ das.distroseries.distribution.name == craft_base.name
+ and das.distroseries.version == craft_base.channel
+ and das.architecturetag == arch
+ ):
+ instances[das] = None
+ break
+
+ return list(instances)
diff --git a/lib/lp/crafts/adapters/tests/__init__.py b/lib/lp/crafts/adapters/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/crafts/adapters/tests/__init__.py
diff --git a/lib/lp/crafts/adapters/tests/test_buildarch.py b/lib/lp/crafts/adapters/tests/test_buildarch.py
new file mode 100644
index 0000000..f64c47f
--- /dev/null
+++ b/lib/lp/crafts/adapters/tests/test_buildarch.py
@@ -0,0 +1,246 @@
+# Copyright 2024 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from functools import partial
+
+from testscenarios import WithScenarios, load_tests_apply_scenarios
+from testtools.matchers import (
+ Equals,
+ MatchesException,
+ MatchesListwise,
+ MatchesStructure,
+ Raises,
+)
+from zope.component import getUtility
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.interfaces.processor import (
+ IProcessorSet,
+ ProcessorNotFound,
+)
+from lp.crafts.adapters.buildarch import (
+ BadPropertyError,
+ CraftBase,
+ MissingPropertyError,
+ determine_instances_to_build,
+)
+from lp.testing import TestCase, TestCaseWithFactory
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class TestCraftBase(TestCase):
+ def test_valid_base(self):
+ base = CraftBase("ubuntu@22.04")
+ self.assertEqual("ubuntu", base.name)
+ self.assertEqual("22.04", base.channel)
+
+ def test_bare_base(self):
+ base = CraftBase("bare")
+ self.assertEqual("bare", base.name)
+ self.assertIsNone(base.channel)
+
+ def test_invalid_base(self):
+ self.assertRaises(BadPropertyError, CraftBase, "ubuntu@18.04")
+
+ def test_invalid_build_base(self):
+ self.assertRaises(
+ BadPropertyError, CraftBase, "ubuntu@18.04", is_build_base=True
+ )
+
+ def test_devel_as_base_not_allowed(self):
+ self.assertRaises(BadPropertyError, CraftBase, "devel")
+
+ def test_str(self):
+ base = CraftBase("ubuntu@22.04")
+ self.assertEqual("ubuntu@22.04", str(base))
+ bare_base = CraftBase("bare")
+ self.assertEqual("bare@None", str(bare_base))
+
+ def test_equality(self):
+ base1 = CraftBase("ubuntu@22.04")
+ base2 = CraftBase("ubuntu@22.04")
+ base3 = CraftBase("ubuntu@20.04")
+ self.assertEqual(base1, base2)
+ self.assertNotEqual(base1, base3)
+
+
+class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
+ layer = LaunchpadZopelessLayer
+
+ scenarios = [
+ (
+ "single platform",
+ {
+ "sourcecraft_data": {
+ "base": "ubuntu@22.04",
+ "platforms": {
+ "amd64": {},
+ },
+ },
+ "expected": [("22.04", "amd64")],
+ },
+ ),
+ (
+ "multiple platforms",
+ {
+ "sourcecraft_data": {
+ "base": "ubuntu@22.04",
+ "platforms": {
+ "amd64": {},
+ "arm64": {},
+ },
+ },
+ "expected": [("22.04", "amd64"), ("22.04", "arm64")],
+ },
+ ),
+ (
+ "build-on specified",
+ {
+ "sourcecraft_data": {
+ "base": "ubuntu@22.04",
+ "platforms": {
+ "amd64": {"build-on": ["amd64", "arm64"]},
+ },
+ },
+ "expected": [("22.04", "amd64"), ("22.04", "arm64")],
+ },
+ ),
+ (
+ "build-for specified",
+ {
+ "sourcecraft_data": {
+ "base": "ubuntu@22.04",
+ "platforms": {
+ "amd64": {"build-for": "amd64"},
+ "arm64": {"build-for": "arm64"},
+ },
+ },
+ "expected": [("22.04", "amd64"), ("22.04", "arm64")],
+ },
+ ),
+ (
+ "bare base",
+ {
+ "sourcecraft_data": {
+ "base": "bare",
+ "build-base": "ubuntu@20.04",
+ "platforms": {
+ "amd64": {},
+ },
+ },
+ "expected": [("20.04", "amd64")],
+ },
+ ),
+ (
+ "missing base",
+ {
+ "sourcecraft_data": {
+ "platforms": {
+ "amd64": {},
+ },
+ },
+ "expected_exception": MatchesException(
+ MissingPropertyError,
+ "Craft specification is missing the 'base' property",
+ ),
+ },
+ ),
+ (
+ "missing build-base for bare",
+ {
+ "sourcecraft_data": {
+ "base": "bare",
+ "platforms": {
+ "amd64": {},
+ },
+ },
+ "expected_exception": MatchesException(
+ MissingPropertyError,
+ "When 'base' is 'bare', 'build-base' must be specified",
+ ),
+ },
+ ),
+ (
+ "missing platforms",
+ {
+ "sourcecraft_data": {
+ "base": "ubuntu@22.04",
+ },
+ "expected_exception": MatchesException(
+ MissingPropertyError,
+ "Craft specification is missing the 'platforms' property",
+ ),
+ },
+ ),
+ (
+ "devel build-base not supported",
+ {
+ "sourcecraft_data": {
+ "base": "ubuntu@22.04",
+ "build-base": "devel",
+ "platforms": {
+ "amd64": {},
+ },
+ },
+ "expected_exception": MatchesException(
+ BadPropertyError,
+ "The 'devel' base is not supported for now",
+ ),
+ },
+ ),
+ ]
+
+ def test_determine_instances_to_build(self):
+ distro_serieses = [
+ self.factory.makeDistroSeries(
+ distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+ version=version,
+ )
+ for version in ("24.04", "22.04", "20.04")
+ ]
+ dases = []
+ for arch_tag in ("amd64", "arm64"):
+ try:
+ processor = getUtility(IProcessorSet).getByName(arch_tag)
+ except ProcessorNotFound:
+ processor = self.factory.makeProcessor(
+ name=arch_tag, supports_virtualized=True
+ )
+ for distro_series in distro_serieses:
+ dases.append(
+ self.factory.makeDistroArchSeries(
+ distroseries=distro_series,
+ architecturetag=arch_tag,
+ processor=processor,
+ )
+ )
+
+ build_instances_factory = partial(
+ determine_instances_to_build,
+ self.sourcecraft_data,
+ dases,
+ distro_serieses[0],
+ )
+
+ if hasattr(self, "expected_exception"):
+ self.assertThat(
+ build_instances_factory, Raises(self.expected_exception)
+ )
+ else:
+ self.assertThat(
+ build_instances_factory(),
+ MatchesListwise(
+ [
+ MatchesStructure(
+ distroseries=MatchesStructure.byEquality(
+ version=version
+ ),
+ architecturetag=Equals(arch_tag),
+ )
+ for version, arch_tag in self.expected
+ ]
+ ),
+ )
+
+
+load_tests = load_tests_apply_scenarios
Follow ups