Merge ~pelpsi/lpci:snaps-key-improved-to-specify-channel-and-classic-parameters into lpci:main

Proposed by Simone Pelosi
Status: Merged
Approved by: Simone Pelosi
Approved revision: 8e61e16fe92155b72bf6d98876f352e1b7e0a23b
Merge reported by: Simone Pelosi
Merged at revision: 8e61e16fe92155b72bf6d98876f352e1b7e0a23b
Proposed branch: ~pelpsi/lpci:snaps-key-improved-to-specify-channel-and-classic-parameters
Merge into: lpci:main
Diff against target: 627 lines (+484/-11)
7 files modified
NEWS.rst (+6/-0)
docs/configuration.rst (+21/-0)
lpcraft/commands/run.py (+9/-4)
lpcraft/commands/tests/test_run.py (+379/-2)
lpcraft/config.py (+58/-1)
lpcraft/plugin/lib.py (+2/-2)
lpcraft/tests/test_config.py (+9/-2)
Reviewer Review Type Date Requested Status
Jürgen Gmach Approve
Review via email: mp+440175@code.launchpad.net

Commit message

Added support for channel and classic parameters

Snap keys now support channel and classic parameters.
Snap key new syntax <name>:<channel>:<classic>

LP: #1995101

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Using a colon as a delimiter looks like an elegant way to encode information.

As mentioned below, is this an official way?

If not, we should have a quick discussion about alternative ways at the standup, e.g. having separate keys for name, channel and classic.

Anyway, please rewrite the way you parse the snap string.

review: Needs Fixing
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

It looks like that I missed to make you aware that we use the Pydantic library to parse the yaml input. In order to leverage that library, you need to create a new configuration class, see inline comments. Sorry for the extra work! Please reach out in case you need any help or a quick introduction to this topic.

review: Needs Fixing
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

This looks good! Ideally, we could improve the error messages and make them more helpful for our users, see inline comments.

There is one thing we need to discuss. This change breaks backwards compatibility, ie configurations a la
```
snaps = [firefox, chromium]
```
will no longer work. In my last comment (see inline comments when you click on "show diff comments") I showed you a possible way to stay backwards compatible.

Did you try to do it? Have you encountered any problems? Or did you intentionally decide to break backwards compatibility?

Revision history for this message
Jürgen Gmach (jugmac00) wrote :

This looks really good now! Please have a look at my comments before merging.

And please remember... for lpcraft we do not have a merge bot, so after applying the suggested changes, you need to merge the branch yourself.

Imho it would make sense to squash your commits prior to merging.

Thanks for your patience!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/NEWS.rst b/NEWS.rst
2index d38f50c..67eb3af 100644
3--- a/NEWS.rst
4+++ b/NEWS.rst
5@@ -2,6 +2,12 @@
6 Version history
7 ===============
8
9+0.0.51 (2023-04-05)
10+===================
11+- Add support for snap's channel and confinement level.
12+ See https://snapcraft.io/docs/channels and
13+ https://snapcraft.io/docs/snap-confinement.
14+
15 0.0.50 (2023-03-20)
16 ===================
17 - Rebuild the Snap package to include updated system packages.
18diff --git a/docs/configuration.rst b/docs/configuration.rst
19index d25c96d..8655653 100644
20--- a/docs/configuration.rst
21+++ b/docs/configuration.rst
22@@ -63,6 +63,7 @@ Job definitions
23
24 ``snaps`` (optional)
25 Snaps to install as dependencies of this job.
26+ Also see the :ref:`snap-properties` section below.
27
28 ``environment`` (optional)
29 A mapping of environment variable names to values, to be set while
30@@ -171,6 +172,26 @@ artifacts. (This mirrors the output file structure created by ``lpcraft run
31 to which the artifacts of the chosen job will be copied; the directory
32 will be created if necessary. Paths may not escape the build tree.
33
34+.. _snap-properties:
35+
36+Snap properties
37+-----------------
38+
39+``name``
40+ The name of a snap will be installed..
41+
42+``channel`` (optional)
43+ `Channel <https://snapcraft.io/docs/channels>`_
44+ defining which release of a snap want to install.
45+ Default value: ``latest/stable``.
46+
47+
48+``classic`` (optional)
49+ Classic boolean defining whether to use or not
50+ `confiment=classic
51+ <https://snapcraft.io/docs/snap-confinement>`_.
52+ Default value: False.
53+
54 .. _package-repositories:
55
56 Package-repositories properties
57diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
58index c0e00d9..3a914df 100644
59--- a/lpcraft/commands/run.py
60+++ b/lpcraft/commands/run.py
61@@ -543,12 +543,17 @@ def _run_job(
62 ) as instance:
63 snaps = list(itertools.chain(*pm.hook.lpcraft_install_snaps()))
64 for snap in snaps:
65- emit.progress(f"Running `snap install {snap}`")
66+ emit.progress(
67+ "Running `snap install "
68+ + f"{snap.name} "
69+ + f"(channel={snap.channel}, "
70+ + f"classic={snap.classic})`"
71+ )
72 install_from_store(
73 executor=instance,
74- snap_name=snap,
75- channel="latest/stable",
76- classic=True,
77+ snap_name=snap.name,
78+ channel=snap.channel,
79+ classic=snap.classic,
80 )
81 packages = list(itertools.chain(*pm.hook.lpcraft_install_packages()))
82 if packages:
83diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
84index 4466295..361661c 100644
85--- a/lpcraft/commands/tests/test_run.py
86+++ b/lpcraft/commands/tests/test_run.py
87@@ -1956,7 +1956,7 @@ class TestRun(RunBaseTestCase):
88 @responses.activate
89 @patch("lpcraft.commands.run.get_provider")
90 @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
91- def test_install_snaps(
92+ def test_install_snaps_classic_parameter(
93 self, mock_get_host_architecture, mock_get_provider
94 ):
95 def run_side_effect(
96@@ -1985,7 +1985,11 @@ class TestRun(RunBaseTestCase):
97 series: focal
98 architectures: amd64
99 run: tox
100- snaps: [chromium, firefox]
101+ snaps:
102+ - name: chromium
103+ classic: True
104+ - name: firefox
105+ classic: True
106 """
107 )
108 Path(".launchpad.yaml").write_text(config)
109@@ -2052,6 +2056,379 @@ class TestRun(RunBaseTestCase):
110 execute_run.call_args_list,
111 )
112
113+ @responses.activate
114+ @patch("lpcraft.commands.run.get_provider")
115+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
116+ def test_install_snaps_provided_as_list_of_strings(
117+ self, mock_get_host_architecture, mock_get_provider
118+ ):
119+ def run_side_effect(
120+ command: List[str], **kwargs: Any
121+ ) -> "subprocess.CompletedProcess[bytes]":
122+ if command[0] == "curl":
123+ response = {"result": {"revision": "1"}, "status-code": 200}
124+ return subprocess.CompletedProcess(
125+ [], 0, stdout=json.dumps(response).encode()
126+ )
127+ else:
128+ return subprocess.CompletedProcess([], 0)
129+
130+ launcher = Mock(spec=launch)
131+ provider = makeLXDProvider(lxd_launcher=launcher)
132+ mock_get_provider.return_value = provider
133+ execute_run = launcher.return_value.execute_run
134+ execute_run.side_effect = run_side_effect
135+ config = dedent(
136+ """
137+ pipeline:
138+ - test
139+
140+ jobs:
141+ test:
142+ series: focal
143+ architectures: amd64
144+ run: tox
145+ snaps: [chromium, firefox]
146+ """
147+ )
148+ Path(".launchpad.yaml").write_text(config)
149+ result = self.run_command("run")
150+ self.assertEqual(0, result.exit_code)
151+ self.assertEqual(
152+ [
153+ call(
154+ [
155+ "snap",
156+ "install",
157+ "chromium",
158+ "--channel",
159+ "latest/stable",
160+ ],
161+ check=True,
162+ capture_output=True,
163+ ),
164+ call(
165+ [
166+ "curl",
167+ "--silent",
168+ "--unix-socket",
169+ "/run/snapd.socket",
170+ "http://localhost/v2/snaps/chromium",
171+ ],
172+ check=True,
173+ capture_output=True,
174+ ),
175+ call(
176+ [
177+ "snap",
178+ "install",
179+ "firefox",
180+ "--channel",
181+ "latest/stable",
182+ ],
183+ check=True,
184+ capture_output=True,
185+ ),
186+ call(
187+ [
188+ "curl",
189+ "--silent",
190+ "--unix-socket",
191+ "/run/snapd.socket",
192+ "http://localhost/v2/snaps/firefox",
193+ ],
194+ check=True,
195+ capture_output=True,
196+ ),
197+ call(
198+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
199+ cwd=Path("/build/lpcraft/project"),
200+ env={},
201+ stdout=ANY,
202+ stderr=ANY,
203+ ),
204+ ],
205+ execute_run.call_args_list,
206+ )
207+
208+ @responses.activate
209+ @patch("lpcraft.commands.run.get_provider")
210+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
211+ def test_install_snaps_wrong_array_format(
212+ self, mock_get_host_architecture, mock_get_provider
213+ ):
214+
215+ launcher = Mock(spec=launch)
216+ provider = makeLXDProvider(lxd_launcher=launcher)
217+ mock_get_provider.return_value = provider
218+ config = dedent(
219+ """
220+ pipeline:
221+ - test
222+
223+ jobs:
224+ test:
225+ series: focal
226+ architectures: amd64
227+ run: tox
228+ snaps: [1, True, 3]
229+ """
230+ )
231+ Path(".launchpad.yaml").write_text(config)
232+ result = self.run_command("run")
233+ self.assertEqual(1, result.exit_code)
234+ self.assertRegex(
235+ str(result.errors[0]),
236+ "You configured a Snap, "
237+ + "but you used an unknown format. "
238+ + "Please refer to the documentation for an "
239+ + "overview of supported formats.",
240+ )
241+
242+ @responses.activate
243+ @patch("lpcraft.commands.run.get_provider")
244+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
245+ def test_install_snaps_channel_parameter(
246+ self, mock_get_host_architecture, mock_get_provider
247+ ):
248+ def run_side_effect(
249+ command: List[str], **kwargs: Any
250+ ) -> "subprocess.CompletedProcess[bytes]":
251+ if command[0] == "curl":
252+ response = {"result": {"revision": "1"}, "status-code": 200}
253+ return subprocess.CompletedProcess(
254+ [], 0, stdout=json.dumps(response).encode()
255+ )
256+ else:
257+ return subprocess.CompletedProcess([], 0)
258+
259+ launcher = Mock(spec=launch)
260+ provider = makeLXDProvider(lxd_launcher=launcher)
261+ mock_get_provider.return_value = provider
262+ execute_run = launcher.return_value.execute_run
263+ execute_run.side_effect = run_side_effect
264+ config = dedent(
265+ """
266+ pipeline:
267+ - test
268+
269+ jobs:
270+ test:
271+ series: focal
272+ architectures: amd64
273+ run: tox
274+ snaps:
275+ - name: black
276+ channel: 22/stable
277+ - firefox
278+ """
279+ )
280+ Path(".launchpad.yaml").write_text(config)
281+
282+ result = self.run_command("run")
283+
284+ self.assertEqual(0, result.exit_code)
285+ self.assertEqual(
286+ [
287+ call(
288+ [
289+ "snap",
290+ "install",
291+ "black",
292+ "--channel",
293+ "22/stable",
294+ ],
295+ check=True,
296+ capture_output=True,
297+ ),
298+ call(
299+ [
300+ "curl",
301+ "--silent",
302+ "--unix-socket",
303+ "/run/snapd.socket",
304+ "http://localhost/v2/snaps/black",
305+ ],
306+ check=True,
307+ capture_output=True,
308+ ),
309+ call(
310+ [
311+ "snap",
312+ "install",
313+ "firefox",
314+ "--channel",
315+ "latest/stable",
316+ ],
317+ check=True,
318+ capture_output=True,
319+ ),
320+ call(
321+ [
322+ "curl",
323+ "--silent",
324+ "--unix-socket",
325+ "/run/snapd.socket",
326+ "http://localhost/v2/snaps/firefox",
327+ ],
328+ check=True,
329+ capture_output=True,
330+ ),
331+ call(
332+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
333+ cwd=Path("/build/lpcraft/project"),
334+ env={},
335+ stdout=ANY,
336+ stderr=ANY,
337+ ),
338+ ],
339+ execute_run.call_args_list,
340+ )
341+
342+ @responses.activate
343+ @patch("lpcraft.commands.run.get_provider")
344+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
345+ def test_install_snaps_snap_name_missing(
346+ self, mock_get_host_architecture, mock_get_provider
347+ ):
348+
349+ launcher = Mock(spec=launch)
350+ provider = makeLXDProvider(lxd_launcher=launcher)
351+ mock_get_provider.return_value = provider
352+ config = dedent(
353+ """
354+ pipeline:
355+ - test
356+
357+ jobs:
358+ test:
359+ series: focal
360+ architectures: amd64
361+ run: tox
362+ snaps:
363+ - classic: True
364+ """
365+ )
366+ Path(".launchpad.yaml").write_text(config)
367+
368+ result = self.run_command("run")
369+
370+ self.assertEqual(1, result.exit_code)
371+ self.assertRegex(
372+ str(result.errors[0]),
373+ "You configured a Snap " + "but you did not specify a name.",
374+ )
375+
376+ @responses.activate
377+ @patch("lpcraft.commands.run.get_provider")
378+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
379+ def test_install_snaps_snap_channel_none(
380+ self, mock_get_host_architecture, mock_get_provider
381+ ):
382+
383+ launcher = Mock(spec=launch)
384+ provider = makeLXDProvider(lxd_launcher=launcher)
385+ mock_get_provider.return_value = provider
386+ config = dedent(
387+ """
388+ pipeline:
389+ - test
390+
391+ jobs:
392+ test:
393+ series: focal
394+ architectures: amd64
395+ run: tox
396+ snaps:
397+ - name: chromium
398+ channel:
399+ """
400+ )
401+ Path(".launchpad.yaml").write_text(config)
402+
403+ result = self.run_command("run")
404+
405+ self.assertEqual(1, result.exit_code)
406+ self.assertRegex(
407+ str(result.errors[0]),
408+ "You configured a Snap `channel`, "
409+ + "but you did not specify a value.",
410+ )
411+
412+ @responses.activate
413+ @patch("lpcraft.commands.run.get_provider")
414+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
415+ def test_install_snaps_snap_classic_none(
416+ self, mock_get_host_architecture, mock_get_provider
417+ ):
418+
419+ launcher = Mock(spec=launch)
420+ provider = makeLXDProvider(lxd_launcher=launcher)
421+ mock_get_provider.return_value = provider
422+ config = dedent(
423+ """
424+ pipeline:
425+ - test
426+
427+ jobs:
428+ test:
429+ series: focal
430+ architectures: amd64
431+ run: tox
432+ snaps:
433+ - name: chromium
434+ classic:
435+ """
436+ )
437+ Path(".launchpad.yaml").write_text(config)
438+
439+ result = self.run_command("run")
440+
441+ self.assertEqual(1, result.exit_code)
442+ self.assertRegex(
443+ str(result.errors[0]),
444+ "You configured a Snap `classic`, "
445+ + "but you did not specify a value. "
446+ + "Valid values would either be `True` or `False`.",
447+ )
448+
449+ @responses.activate
450+ @patch("lpcraft.commands.run.get_provider")
451+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
452+ def test_install_snaps_classic_wrong_value(
453+ self, mock_get_host_architecture, mock_get_provider
454+ ):
455+
456+ launcher = Mock(spec=launch)
457+ provider = makeLXDProvider(lxd_launcher=launcher)
458+ mock_get_provider.return_value = provider
459+ config = dedent(
460+ """
461+ pipeline:
462+ - test
463+
464+ jobs:
465+ test:
466+ series: focal
467+ architectures: amd64
468+ run: tox
469+ snaps:
470+ - name: chromium
471+ classic: wrong_value
472+ """
473+ )
474+ Path(".launchpad.yaml").write_text(config)
475+
476+ result = self.run_command("run")
477+
478+ self.assertEqual(1, result.exit_code)
479+ self.assertRegex(
480+ str(result.errors[0]),
481+ "You configured a Snap `classic`, "
482+ + "but you did not specify a valid value. "
483+ + "Valid values would either be `True` or `False`.",
484+ )
485+
486 @patch("lpcraft.commands.run.get_provider")
487 @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
488 def test_install_system_packages(
489diff --git a/lpcraft/config.py b/lpcraft/config.py
490index 5b8d126..0fdb9b4 100644
491--- a/lpcraft/config.py
492+++ b/lpcraft/config.py
493@@ -242,6 +242,33 @@ class PackageRepository(ModelConfigDefaults):
494 yield f"{format.value} {self.url!s} {suite.value} {' '.join(self.components)}" # noqa: E501
495
496
497+class Snap(ModelConfigDefaults):
498+ """A snap definition."""
499+
500+ name: StrictStr
501+ channel: Optional[StrictStr] = "latest/stable"
502+ classic: Optional[bool] = False
503+
504+ @validator("channel")
505+ def prevent_channel_none(cls, v: StrictStr) -> Any:
506+ if v is None:
507+ raise ValueError(
508+ "You configured a Snap `channel`, "
509+ + "but you did not specify a value."
510+ )
511+ return v
512+
513+ @validator("classic")
514+ def prevent_classic_none(cls, v: bool) -> Any:
515+ if v is None:
516+ raise ValueError(
517+ "You configured a Snap `classic`, "
518+ + "but you did not specify a value. "
519+ + "Valid values would either be `True` or `False`."
520+ )
521+ return v
522+
523+
524 class Job(ModelConfigDefaults):
525 """A job definition."""
526
527@@ -257,7 +284,7 @@ class Job(ModelConfigDefaults):
528 environment: Optional[Dict[str, Optional[str]]]
529 output: Optional[Output]
530 input: Optional[Input]
531- snaps: Optional[List[StrictStr]]
532+ snaps: Optional[List[Snap]]
533 packages: Optional[List[StrictStr]]
534 package_repositories: List[PackageRepository] = []
535 plugin: Optional[StrictStr]
536@@ -271,6 +298,36 @@ class Job(ModelConfigDefaults):
537 v = [v]
538 return v
539
540+ @pydantic.validator("snaps", pre=True)
541+ def validate_snaps(cls, v: List[Any]) -> Any:
542+ clean_values = []
543+ for value in v:
544+ # Backward compatibility, i.e. [chromium, firefox]
545+ if type(value) is str:
546+ clean_values.append({"name": value})
547+ elif type(value) is dict:
548+ if "name" not in value or value["name"] is None:
549+ raise ValueError(
550+ "You configured a Snap "
551+ + "but you did not specify a name."
552+ )
553+ if "classic" in value and value["classic"] is not None:
554+ if type(value["classic"]) is not bool:
555+ raise ValueError(
556+ "You configured a Snap `classic`, "
557+ + "but you did not specify a valid value. "
558+ + "Valid values would either be `True` or `False`."
559+ )
560+ clean_values.append(value)
561+ else:
562+ raise ValueError(
563+ "You configured a Snap, "
564+ + "but you used an unknown format. "
565+ + "Please refer to the documentation for an "
566+ + "overview of supported formats."
567+ )
568+ return clean_values
569+
570 @pydantic.validator("package_repositories")
571 def validate_package_repositories(
572 cls, v: List[PackageRepository], values: Dict[StrictStr, Any]
573diff --git a/lpcraft/plugin/lib.py b/lpcraft/plugin/lib.py
574index 2b22680..92510e6 100644
575--- a/lpcraft/plugin/lib.py
576+++ b/lpcraft/plugin/lib.py
577@@ -10,7 +10,7 @@ The provided functionality can be extended by additional plugins.
578
579 from __future__ import annotations
580
581-from lpcraft.config import Job
582+from lpcraft.config import Job, Snap
583 from lpcraft.plugin import hookimpl
584
585
586@@ -28,7 +28,7 @@ class InternalPlugins:
587 return []
588
589 @hookimpl # type: ignore
590- def lpcraft_install_snaps(self) -> list[str]:
591+ def lpcraft_install_snaps(self) -> list[Snap]:
592 if self.config.snaps:
593 return self.config.snaps
594 return []
595diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
596index d64533a..a5b875a 100644
597--- a/lpcraft/tests/test_config.py
598+++ b/lpcraft/tests/test_config.py
599@@ -26,6 +26,7 @@ from lpcraft.config import (
600 PackageSuite,
601 PackageType,
602 PPAShortFormURL,
603+ Snap,
604 get_ppa_url_parts,
605 )
606 from lpcraft.errors import CommandError
607@@ -370,12 +371,18 @@ class TestConfig(TestCase):
608 test:
609 series: focal
610 architectures: amd64
611- snaps: [chromium, firefox]
612+ snaps: [name: chromium, name: firefox]
613 """
614 )
615 )
616 config = Config.load(path)
617- self.assertEqual(["chromium", "firefox"], config.jobs["test"][0].snaps)
618+ self.assertEqual(
619+ [
620+ Snap(name="chromium", channel="latest/stable", classic=False),
621+ Snap(name="firefox", channel="latest/stable", classic=False),
622+ ],
623+ config.jobs["test"][0].snaps,
624+ )
625
626 def test_load_config_without_snaps(self):
627 path = self.create_config(

Subscribers

People subscribed via source and target branches