launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #31462
[Merge] ~jugmac00/launchpad:add-a-rockraft.yaml-parser-like-charms into launchpad:master
Jürgen Gmach has proposed merging ~jugmac00/launchpad:add-a-rockraft.yaml-parser-like-charms into launchpad:master with ~jugmac00/launchpad:send-proxy-arguments-when-building-rocks as a prerequisite.
Commit message:
Add a "rockcraft.yaml" parser
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/473245
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:add-a-rockraft.yaml-parser-like-charms into launchpad:master.
diff --git a/lib/lp/rocks/adapters/__init__.py b/lib/lp/rocks/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/rocks/adapters/__init__.py
diff --git a/lib/lp/rocks/adapters/buildarch.py b/lib/lp/rocks/adapters/buildarch.py
new file mode 100644
index 0000000..7f0cd5a
--- /dev/null
+++ b/lib/lp/rocks/adapters/buildarch.py
@@ -0,0 +1,189 @@
+# 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",
+]
+
+import json
+from collections import Counter, OrderedDict
+
+from lp.services.helpers import english_list
+
+
+class RockBasesParserError(Exception):
+ """Base class for all exceptions in this module."""
+
+
+class MissingPropertyError(RockBasesParserError):
+ """Error for when an expected property is not present in the YAML."""
+
+ def __init__(self, prop):
+ super().__init__(
+ f"Base specification is missing the {prop!r} property"
+ )
+ self.property = prop
+
+
+class BadPropertyError(RockBasesParserError):
+ """Error for when a YAML property is malformed in some way."""
+
+
+class DuplicateRunOnError(RockBasesParserError):
+ """Error for when multiple `run-on`s include the same architecture."""
+
+ def __init__(self, duplicates):
+ super().__init__(
+ "{} {} present in the 'run-on' of multiple items".format(
+ english_list([str(d) for d in duplicates]),
+ "is" if len(duplicates) == 1 else "are",
+ )
+ )
+
+
+class RockBase:
+ """A single base in rockcraft.yaml."""
+
+ def __init__(self, name, channel, architectures=None):
+ self.name = name
+ if not isinstance(channel, str):
+ raise BadPropertyError(
+ "Channel {!r} is not a string (missing quotes?)".format(
+ channel
+ )
+ )
+ self.channel = channel
+ self.architectures = architectures
+
+ @classmethod
+ def from_dict(cls, properties):
+ """Create a new base from a dict."""
+ try:
+ name = properties["name"]
+ except KeyError:
+ raise MissingPropertyError("name")
+ try:
+ channel = properties["channel"]
+ except KeyError:
+ raise MissingPropertyError("channel")
+ return cls(
+ name=name,
+ channel=channel,
+ architectures=properties.get("architectures"),
+ )
+
+ def __eq__(self, other):
+ return (
+ self.name == other.name
+ and self.channel == other.channel
+ and self.architectures == other.architectures
+ )
+
+ def __ne__(self, other):
+ return not self == other
+
+ def __hash__(self):
+ return hash((self.name, self.channel, tuple(self.architectures)))
+
+ def __str__(self):
+ return "{} {} {}".format(
+ self.name, self.channel, json.dumps(self.architectures)
+ )
+
+
+class RockBaseConfiguration:
+ """A base configuration entry in rockcraft.yaml."""
+
+ def __init__(self, build_on, run_on=None):
+ self.build_on = build_on
+ self.run_on = list(build_on) if run_on is None else run_on
+
+ @classmethod
+ def from_dict(cls, properties):
+ """Create a new base configuration from a dict."""
+ # Expand short-form configuration into long-form. Account for
+ # common typos in case the user intends to use long-form but did so
+ # incorrectly (for better error message handling).
+ if not any(
+ item in properties
+ for item in ("run-on", "run_on", "build-on", "build_on")
+ ):
+ base = RockBase.from_dict(properties)
+ return cls([base], run_on=[base])
+
+ try:
+ build_on = properties["build-on"]
+ except KeyError:
+ raise MissingPropertyError("build-on")
+ build_on = [RockBase.from_dict(item) for item in build_on]
+ run_on = properties.get("run-on")
+ if run_on is not None:
+ run_on = [RockBase.from_dict(item) for item in run_on]
+ return cls(build_on, run_on=run_on)
+
+
+def determine_instances_to_build(
+ rockcraft_data, supported_arches, default_distro_series
+):
+ """Return a list of instances to build based on rockcraft.yaml.
+
+ :param rockcraft_data: A parsed rockcraft.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
+ rockcraft.yaml does not explicitly declare any bases.
+ :return: A list of `DistroArchSeries`.
+ """
+ bases_list = rockcraft_data.get("bases")
+
+ if bases_list:
+ configs = [
+ RockBaseConfiguration.from_dict(item) for item in bases_list
+ ]
+ else:
+ # If no bases are specified, build one for each supported
+ # architecture for the default series.
+ configs = [
+ RockBaseConfiguration(
+ [
+ RockBase(
+ default_distro_series.distribution.name,
+ default_distro_series.version,
+ das.architecturetag,
+ ),
+ ]
+ )
+ for das in supported_arches
+ if das.distroseries == default_distro_series
+ ]
+
+ # Ensure that multiple `run-on` items don't overlap; this is ambiguous
+ # and forbidden by rockcraft.
+ run_ons = Counter()
+ for config in configs:
+ run_ons.update(config.run_on)
+ duplicates = {config for config, count in run_ons.items() if count > 1}
+ if duplicates:
+ raise DuplicateRunOnError(duplicates)
+
+ instances = OrderedDict()
+ for config in configs:
+ # Rocks are allowed to declare that they build on architectures
+ # that Launchpad doesn't currently support (perhaps they're
+ # upcoming, or perhaps they used to be supported). We just ignore
+ # those.
+ for build_on in config.build_on:
+ for das in supported_arches:
+ if (
+ das.distroseries.distribution.name == build_on.name
+ and build_on.channel
+ in (das.distroseries.name, das.distroseries.version)
+ and das.architecturetag in build_on.architectures
+ ):
+ instances[das] = None
+ break
+ else:
+ continue
+ break
+ return list(instances)
diff --git a/lib/lp/rocks/adapters/tests/__init__.py b/lib/lp/rocks/adapters/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/rocks/adapters/tests/__init__.py
diff --git a/lib/lp/rocks/adapters/tests/test_buildarch.py b/lib/lp/rocks/adapters/tests/test_buildarch.py
new file mode 100644
index 0000000..0868f9c
--- /dev/null
+++ b/lib/lp/rocks/adapters/tests/test_buildarch.py
@@ -0,0 +1,413 @@
+# 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.rocks.adapters.buildarch import (
+ DuplicateRunOnError,
+ RockBase,
+ RockBaseConfiguration,
+ determine_instances_to_build,
+)
+from lp.testing import TestCase, TestCaseWithFactory
+from lp.testing.layers import LaunchpadZopelessLayer
+
+
+class TestRockBaseConfiguration(WithScenarios, TestCase):
+
+ scenarios = [
+ (
+ "expanded",
+ {
+ "base": {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64", "arm64"],
+ },
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ },
+ ],
+ },
+ "expected_build_on": [
+ RockBase(
+ name="ubuntu", channel="18.04", architectures=["amd64"]
+ ),
+ ],
+ "expected_run_on": [
+ RockBase(
+ name="ubuntu",
+ channel="20.04",
+ architectures=["amd64", "arm64"],
+ ),
+ RockBase(
+ name="ubuntu", channel="18.04", architectures=["amd64"]
+ ),
+ ],
+ },
+ ),
+ (
+ "short form",
+ {
+ "base": {
+ "name": "ubuntu",
+ "channel": "20.04",
+ },
+ "expected_build_on": [
+ RockBase(name="ubuntu", channel="20.04")
+ ],
+ "expected_run_on": [RockBase(name="ubuntu", channel="20.04")],
+ },
+ ),
+ (
+ "no run-on",
+ {
+ "base": {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ },
+ "expected_build_on": [
+ RockBase(
+ name="ubuntu", channel="20.04", architectures=["amd64"]
+ ),
+ ],
+ "expected_run_on": [
+ RockBase(
+ name="ubuntu", channel="20.04", architectures=["amd64"]
+ ),
+ ],
+ },
+ ),
+ ]
+
+ def test_base(self):
+ config = RockBaseConfiguration.from_dict(self.base)
+ self.assertEqual(self.expected_build_on, config.build_on)
+ self.assertEqual(self.expected_run_on, config.run_on)
+
+
+class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
+
+ layer = LaunchpadZopelessLayer
+
+ scenarios = [
+ (
+ "single entry, single arch",
+ {
+ "bases": [
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ }
+ ],
+ "expected": [("18.04", "amd64")],
+ },
+ ),
+ (
+ "multiple entries, single arch",
+ {
+ "bases": [
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ },
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ },
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["riscv64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["riscv64"],
+ }
+ ],
+ },
+ ],
+ "expected": [
+ ("18.04", "amd64"),
+ ("20.04", "amd64"),
+ ("20.04", "riscv64"),
+ ],
+ },
+ ),
+ (
+ "single entry, multiple arches",
+ {
+ "bases": [
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64", "riscv64"],
+ }
+ ],
+ }
+ ],
+ "expected": [("20.04", "amd64")],
+ },
+ ),
+ (
+ "multiple entries, with cross-arch",
+ {
+ "bases": [
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["riscv64"],
+ }
+ ],
+ },
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ },
+ ],
+ "expected": [("20.04", "amd64")],
+ },
+ ),
+ (
+ "multiple run-on entries",
+ {
+ "bases": [
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ },
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64", "riscv64"],
+ },
+ ],
+ }
+ ],
+ "expected": [("20.04", "amd64")],
+ },
+ ),
+ (
+ "redundant outputs",
+ {
+ "bases": [
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "18.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ },
+ {
+ "build-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ "run-on": [
+ {
+ "name": "ubuntu",
+ "channel": "20.04",
+ "architectures": ["amd64"],
+ }
+ ],
+ },
+ ],
+ "expected_exception": MatchesException(
+ DuplicateRunOnError,
+ r"ubuntu 20\.04 \[\"amd64\"\] is present in the 'run-on' "
+ r"of multiple items",
+ ),
+ },
+ ),
+ (
+ "no bases specified",
+ {
+ "bases": None,
+ "expected": [
+ ("20.04", "amd64"),
+ ("20.04", "arm64"),
+ ("20.04", "riscv64"),
+ ],
+ },
+ ),
+ ]
+
+ def test_parser(self):
+ distro_serieses = [
+ self.factory.makeDistroSeries(
+ distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+ version=version,
+ )
+ for version in ("20.04", "18.04")
+ ]
+ dases = []
+ for arch_tag in ("amd64", "arm64", "riscv64"):
+ 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,
+ )
+ )
+ rockcraft_data = {}
+ if self.bases is not None:
+ rockcraft_data["bases"] = self.bases
+ build_instances_factory = partial(
+ determine_instances_to_build,
+ rockcraft_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