Merge ~cjwatson/launchpad:charmcraft-parser into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: a24958d283219938951781d1a9525c793404469e
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charmcraft-parser
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:charm-recipe-request-builds
Diff against target: 514 lines (+492/-0)
4 files modified
lib/lp/charms/adapters/__init__.py (+0/-0)
lib/lp/charms/adapters/buildarch.py (+180/-0)
lib/lp/charms/adapters/tests/__init__.py (+0/-0)
lib/lp/charms/adapters/tests/test_buildarch.py (+312/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Cristian Gonzalez (community) Approve
Review via email: mp+403702@code.launchpad.net

Commit message

Add a charmcraft.yaml parser

Description of the change

Parse the data from charmcraft.yaml and use it to determine which distroarchseries to dispatch builds to. This is somewhat similar to our snapcraft.yaml parser, but charmcraft.yaml specifies series (although it calls them "channels") as well as architectures, so we need to handle those too.

To post a comment you must log in.
a24958d... by Colin Watson

Add a charmcraft.yaml parser

Parse the data from charmcraft.yaml and use it to determine which
distroarchseries to dispatch builds to. This is somewhat similar to our
snapcraft.yaml parser, but charmcraft.yaml specifies series (although it
calls them "channels") as well as architectures, so we need to handle
those too.

Revision history for this message
Cristian Gonzalez (cristiangsp) :
review: Approve
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/charms/adapters/__init__.py b/lib/lp/charms/adapters/__init__.py
2new file mode 100644
3index 0000000..e69de29
4--- /dev/null
5+++ b/lib/lp/charms/adapters/__init__.py
6diff --git a/lib/lp/charms/adapters/buildarch.py b/lib/lp/charms/adapters/buildarch.py
7new file mode 100644
8index 0000000..5c76fa9
9--- /dev/null
10+++ b/lib/lp/charms/adapters/buildarch.py
11@@ -0,0 +1,180 @@
12+# Copyright 2021 Canonical Ltd. This software is licensed under the
13+# GNU Affero General Public License version 3 (see the file LICENSE).
14+
15+from __future__ import absolute_import, print_function, unicode_literals
16+
17+__metaclass__ = type
18+__all__ = [
19+ "determine_instances_to_build",
20+ ]
21+
22+from collections import (
23+ Counter,
24+ OrderedDict,
25+ )
26+import json
27+
28+import six
29+
30+from lp.services.helpers import english_list
31+
32+
33+class CharmBasesParserError(Exception):
34+ """Base class for all exceptions in this module."""
35+
36+
37+class MissingPropertyError(CharmBasesParserError):
38+ """Error for when an expected property is not present in the YAML."""
39+
40+ def __init__(self, prop):
41+ super(MissingPropertyError, self).__init__(
42+ "Base specification is missing the {!r} property".format(prop))
43+ self.property = prop
44+
45+
46+class BadPropertyError(CharmBasesParserError):
47+ """Error for when a YAML property is malformed in some way."""
48+
49+
50+class DuplicateRunOnError(CharmBasesParserError):
51+ """Error for when multiple `run-on`s include the same architecture."""
52+
53+ def __init__(self, duplicates):
54+ super(DuplicateRunOnError, self).__init__(
55+ "{} {} present in the 'run-on' of multiple items".format(
56+ english_list([str(d) for d in duplicates]),
57+ "is" if len(duplicates) == 1 else "are"))
58+
59+
60+@six.python_2_unicode_compatible
61+class CharmBase:
62+ """A single base in charmcraft.yaml."""
63+
64+ def __init__(self, name, channel, architectures=None):
65+ self.name = name
66+ if not isinstance(channel, six.string_types):
67+ raise BadPropertyError(
68+ "Channel {!r} is not a string (missing quotes?)".format(
69+ channel))
70+ self.channel = channel
71+ self.architectures = architectures
72+
73+ @classmethod
74+ def from_dict(cls, properties):
75+ """Create a new base from a dict."""
76+ try:
77+ name = properties["name"]
78+ except KeyError:
79+ raise MissingPropertyError("name")
80+ try:
81+ channel = properties["channel"]
82+ except KeyError:
83+ raise MissingPropertyError("channel")
84+ return cls(
85+ name=name, channel=channel,
86+ architectures=properties.get("architectures"))
87+
88+ def __eq__(self, other):
89+ return (
90+ self.name == other.name and
91+ self.channel == other.channel and
92+ self.architectures == other.architectures)
93+
94+ def __ne__(self, other):
95+ return not self == other
96+
97+ def __hash__(self):
98+ return hash((self.name, self.channel, tuple(self.architectures)))
99+
100+ def __str__(self):
101+ return "{} {} {}".format(
102+ self.name, self.channel, json.dumps(self.architectures))
103+
104+
105+class CharmBaseConfiguration:
106+ """A base configuration entry in charmcraft.yaml."""
107+
108+ def __init__(self, build_on, run_on=None):
109+ self.build_on = build_on
110+ self.run_on = list(build_on) if run_on is None else run_on
111+
112+ @classmethod
113+ def from_dict(cls, properties):
114+ """Create a new base configuration from a dict."""
115+ # Expand short-form configuration into long-form. Account for
116+ # common typos in case the user intends to use long-form but did so
117+ # incorrectly (for better error message handling).
118+ if not any(
119+ item in properties
120+ for item in ("run-on", "run_on", "build-on", "build_on")):
121+ base = CharmBase.from_dict(properties)
122+ return cls([base], run_on=[base])
123+
124+ try:
125+ build_on = properties["build-on"]
126+ except KeyError:
127+ raise MissingPropertyError("build-on")
128+ build_on = [CharmBase.from_dict(item) for item in build_on]
129+ run_on = properties.get("run-on")
130+ if run_on is not None:
131+ run_on = [CharmBase.from_dict(item) for item in run_on]
132+ return cls(build_on, run_on=run_on)
133+
134+
135+def determine_instances_to_build(charmcraft_data, supported_arches,
136+ default_distro_series):
137+ """Return a list of instances to build based on charmcraft.yaml.
138+
139+ :param charmcraft_data: A parsed charmcraft.yaml.
140+ :param supported_arches: An ordered list of all `DistroArchSeries` that
141+ we can create builds for. Note that these may span multiple
142+ `DistroSeries`.
143+ :param default_distro_series: The default `DistroSeries` to use if
144+ charmcraft.yaml does not explicitly declare any bases.
145+ :return: A list of `DistroArchSeries`.
146+ """
147+ bases_list = charmcraft_data.get("bases")
148+
149+ if bases_list:
150+ configs = [
151+ CharmBaseConfiguration.from_dict(item) for item in bases_list]
152+ else:
153+ # If no bases are specified, build one for each supported
154+ # architecture for the default series.
155+ configs = [
156+ CharmBaseConfiguration([
157+ CharmBase(
158+ default_distro_series.distribution.name,
159+ default_distro_series.version, das.architecturetag),
160+ ])
161+ for das in supported_arches
162+ if das.distroseries == default_distro_series]
163+
164+ # Ensure that multiple `run-on` items don't overlap; this is ambiguous
165+ # and forbidden by charmcraft.
166+ run_ons = Counter()
167+ for config in configs:
168+ run_ons.update(config.run_on)
169+ duplicates = {config for config, count in run_ons.items() if count > 1}
170+ if duplicates:
171+ raise DuplicateRunOnError(duplicates)
172+
173+ instances = OrderedDict()
174+ for config in configs:
175+ # Charms are allowed to declare that they build on architectures
176+ # that Launchpad doesn't currently support (perhaps they're
177+ # upcoming, or perhaps they used to be supported). We just ignore
178+ # those.
179+ for build_on in config.build_on:
180+ for das in supported_arches:
181+ if (das.distroseries.distribution.name == build_on.name and
182+ build_on.channel in (
183+ das.distroseries.name,
184+ das.distroseries.version) and
185+ das.architecturetag in build_on.architectures):
186+ instances[das] = None
187+ break
188+ else:
189+ continue
190+ break
191+ return list(instances)
192diff --git a/lib/lp/charms/adapters/tests/__init__.py b/lib/lp/charms/adapters/tests/__init__.py
193new file mode 100644
194index 0000000..e69de29
195--- /dev/null
196+++ b/lib/lp/charms/adapters/tests/__init__.py
197diff --git a/lib/lp/charms/adapters/tests/test_buildarch.py b/lib/lp/charms/adapters/tests/test_buildarch.py
198new file mode 100644
199index 0000000..7511f67
200--- /dev/null
201+++ b/lib/lp/charms/adapters/tests/test_buildarch.py
202@@ -0,0 +1,312 @@
203+# Copyright 2021 Canonical Ltd. This software is licensed under the
204+# GNU Affero General Public License version 3 (see the file LICENSE).
205+
206+from __future__ import absolute_import, print_function, unicode_literals
207+
208+__metaclass__ = type
209+
210+from functools import partial
211+
212+from testscenarios import (
213+ load_tests_apply_scenarios,
214+ WithScenarios,
215+ )
216+from testtools.matchers import (
217+ Equals,
218+ MatchesException,
219+ MatchesListwise,
220+ MatchesStructure,
221+ Raises,
222+ )
223+from zope.component import getUtility
224+
225+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
226+from lp.buildmaster.interfaces.processor import (
227+ IProcessorSet,
228+ ProcessorNotFound,
229+ )
230+from lp.charms.adapters.buildarch import (
231+ CharmBase,
232+ CharmBaseConfiguration,
233+ determine_instances_to_build,
234+ DuplicateRunOnError,
235+ )
236+from lp.testing import (
237+ TestCase,
238+ TestCaseWithFactory,
239+ )
240+from lp.testing.layers import LaunchpadZopelessLayer
241+
242+
243+class TestCharmBaseConfiguration(WithScenarios, TestCase):
244+
245+ scenarios = [
246+ ("expanded", {
247+ "base": {
248+ "build-on": [{
249+ "name": "ubuntu",
250+ "channel": "18.04",
251+ "architectures": ["amd64"],
252+ }],
253+ "run-on": [
254+ {
255+ "name": "ubuntu",
256+ "channel": "20.04",
257+ "architectures": ["amd64", "arm64"],
258+ },
259+ {
260+ "name": "ubuntu",
261+ "channel": "18.04",
262+ "architectures": ["amd64"],
263+ },
264+ ],
265+ },
266+ "expected_build_on": [
267+ CharmBase(
268+ name="ubuntu", channel="18.04", architectures=["amd64"]),
269+ ],
270+ "expected_run_on": [
271+ CharmBase(
272+ name="ubuntu", channel="20.04",
273+ architectures=["amd64", "arm64"]),
274+ CharmBase(
275+ name="ubuntu", channel="18.04", architectures=["amd64"]),
276+ ],
277+ }),
278+ ("short form", {
279+ "base": {
280+ "name": "ubuntu",
281+ "channel": "20.04",
282+ },
283+ "expected_build_on": [CharmBase(name="ubuntu", channel="20.04")],
284+ "expected_run_on": [CharmBase(name="ubuntu", channel="20.04")],
285+ }),
286+ ("no run-on", {
287+ "base": {
288+ "build-on": [{
289+ "name": "ubuntu",
290+ "channel": "20.04",
291+ "architectures": ["amd64"],
292+ }],
293+ },
294+ "expected_build_on": [
295+ CharmBase(
296+ name="ubuntu", channel="20.04", architectures=["amd64"]),
297+ ],
298+ "expected_run_on": [
299+ CharmBase(
300+ name="ubuntu", channel="20.04", architectures=["amd64"]),
301+ ],
302+ }),
303+ ]
304+
305+ def test_base(self):
306+ config = CharmBaseConfiguration.from_dict(self.base)
307+ self.assertEqual(self.expected_build_on, config.build_on)
308+ self.assertEqual(self.expected_run_on, config.run_on)
309+
310+
311+class TestDetermineInstancesToBuild(WithScenarios, TestCaseWithFactory):
312+
313+ layer = LaunchpadZopelessLayer
314+
315+ # Scenarios taken from the charmcraft build providers specification:
316+ # https://docs.google.com/document/d/1Tix0V2J21hfXj-dukgArbTEN8rg31b7za4VM_KylDkc
317+ scenarios = [
318+ ("single entry, single arch", {
319+ "bases": [{
320+ "build-on": [{
321+ "name": "ubuntu",
322+ "channel": "18.04",
323+ "architectures": ["amd64"],
324+ }],
325+ "run-on": [{
326+ "name": "ubuntu",
327+ "channel": "18.04",
328+ "architectures": ["amd64"],
329+ }],
330+ }],
331+ "expected": [("18.04", "amd64")],
332+ }),
333+ ("multiple entries, single arch", {
334+ "bases": [
335+ {
336+ "build-on": [{
337+ "name": "ubuntu",
338+ "channel": "18.04",
339+ "architectures": ["amd64"],
340+ }],
341+ "run-on": [{
342+ "name": "ubuntu",
343+ "channel": "18.04",
344+ "architectures": ["amd64"],
345+ }],
346+ },
347+ {
348+ "build-on": [{
349+ "name": "ubuntu",
350+ "channel": "20.04",
351+ "architectures": ["amd64"],
352+ }],
353+ "run-on": [{
354+ "name": "ubuntu",
355+ "channel": "20.04",
356+ "architectures": ["amd64"],
357+ }],
358+ },
359+ {
360+ "build-on": [{
361+ "name": "ubuntu",
362+ "channel": "20.04",
363+ "architectures": ["riscv64"],
364+ }],
365+ "run-on": [{
366+ "name": "ubuntu",
367+ "channel": "20.04",
368+ "architectures": ["riscv64"],
369+ }],
370+ },
371+ ],
372+ "expected": [
373+ ("18.04", "amd64"), ("20.04", "amd64"), ("20.04", "riscv64")],
374+ }),
375+ ("single entry, multiple arches", {
376+ "bases": [{
377+ "build-on": [{
378+ "name": "ubuntu",
379+ "channel": "20.04",
380+ "architectures": ["amd64"],
381+ }],
382+ "run-on": [{
383+ "name": "ubuntu",
384+ "channel": "20.04",
385+ "architectures": ["amd64", "riscv64"],
386+ }],
387+ }],
388+ "expected": [("20.04", "amd64")],
389+ }),
390+ ("multiple entries, with cross-arch", {
391+ "bases": [
392+ {
393+ "build-on": [{
394+ "name": "ubuntu",
395+ "channel": "20.04",
396+ "architectures": ["amd64"],
397+ }],
398+ "run-on": [{
399+ "name": "ubuntu",
400+ "channel": "20.04",
401+ "architectures": ["riscv64"],
402+ }],
403+ },
404+ {
405+ "build-on": [{
406+ "name": "ubuntu",
407+ "channel": "20.04",
408+ "architectures": ["amd64"],
409+ }],
410+ "run-on": [{
411+ "name": "ubuntu",
412+ "channel": "20.04",
413+ "architectures": ["amd64"],
414+ }],
415+ },
416+ ],
417+ "expected": [("20.04", "amd64")],
418+ }),
419+ ("multiple run-on entries", {
420+ "bases": [{
421+ "build-on": [{
422+ "name": "ubuntu",
423+ "channel": "20.04",
424+ "architectures": ["amd64"],
425+ }],
426+ "run-on": [
427+ {
428+ "name": "ubuntu",
429+ "channel": "18.04",
430+ "architectures": ["amd64"],
431+ },
432+ {
433+ "name": "ubuntu",
434+ "channel": "20.04",
435+ "architectures": ["amd64", "riscv64"],
436+ },
437+ ],
438+ }],
439+ "expected": [("20.04", "amd64")],
440+ }),
441+ ("redundant outputs", {
442+ "bases": [
443+ {
444+ "build-on": [{
445+ "name": "ubuntu",
446+ "channel": "18.04",
447+ "architectures": ["amd64"],
448+ }],
449+ "run-on": [{
450+ "name": "ubuntu",
451+ "channel": "20.04",
452+ "architectures": ["amd64"],
453+ }],
454+ },
455+ {
456+ "build-on": [{
457+ "name": "ubuntu",
458+ "channel": "20.04",
459+ "architectures": ["amd64"],
460+ }],
461+ "run-on": [{
462+ "name": "ubuntu",
463+ "channel": "20.04",
464+ "architectures": ["amd64"],
465+ }],
466+ },
467+ ],
468+ "expected_exception": MatchesException(
469+ DuplicateRunOnError,
470+ r"ubuntu 20\.04 \[\"amd64\"\] is present in the 'run-on' of "
471+ r"multiple items"),
472+ }),
473+ ("no bases specified", {
474+ "bases": None,
475+ "expected": [
476+ ("20.04", "amd64"), ("20.04", "arm64"), ("20.04", "riscv64")],
477+ }),
478+ ]
479+
480+ def test_parser(self):
481+ distro_serieses = [
482+ self.factory.makeDistroSeries(
483+ distribution=getUtility(ILaunchpadCelebrities).ubuntu,
484+ version=version)
485+ for version in ("20.04", "18.04")]
486+ dases = []
487+ for arch_tag in ("amd64", "arm64", "riscv64"):
488+ try:
489+ processor = getUtility(IProcessorSet).getByName(arch_tag)
490+ except ProcessorNotFound:
491+ processor = self.factory.makeProcessor(
492+ name=arch_tag, supports_virtualized=True)
493+ for distro_series in distro_serieses:
494+ dases.append(self.factory.makeDistroArchSeries(
495+ distroseries=distro_series, architecturetag=arch_tag,
496+ processor=processor))
497+ charmcraft_data = {}
498+ if self.bases is not None:
499+ charmcraft_data["bases"] = self.bases
500+ build_instances_factory = partial(
501+ determine_instances_to_build,
502+ charmcraft_data, dases, distro_serieses[0])
503+ if hasattr(self, "expected_exception"):
504+ self.assertThat(
505+ build_instances_factory, Raises(self.expected_exception))
506+ else:
507+ self.assertThat(build_instances_factory(), MatchesListwise([
508+ MatchesStructure(
509+ distroseries=MatchesStructure.byEquality(version=version),
510+ architecturetag=Equals(arch_tag))
511+ for version, arch_tag in self.expected]))
512+
513+
514+load_tests = load_tests_apply_scenarios

Subscribers

People subscribed via source and target branches

to status/vote changes: