Merge ~jugmac00/lpci:add-additional-apt-repositories into lpci:main

Proposed by Jürgen Gmach
Status: Merged
Approved by: Jürgen Gmach
Approved revision: 1a9d9b1f565ae73e61ed4b1bcac4e2461995f34c
Merged at revision: 1a9d9b1f565ae73e61ed4b1bcac4e2461995f34c
Proposed branch: ~jugmac00/lpci:add-additional-apt-repositories
Merge into: lpci:main
Diff against target: 564 lines (+392/-10)
7 files modified
NEWS.rst (+2/-0)
docs/configuration.rst (+37/-0)
lpcraft/commands/run.py (+30/-6)
lpcraft/commands/tests/test_run.py (+167/-0)
lpcraft/config.py (+61/-2)
lpcraft/main.py (+15/-0)
lpcraft/tests/test_config.py (+80/-2)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+425829@code.launchpad.net

Commit message

Add new configuration option to provide additional package repositories

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

The auth placeholder feature will be added together with "Pass credentials from Launchpad via launchpad-buildd to lpcraft"
https://warthogs.atlassian.net/browse/LP-812

Revision history for this message
Colin Watson (cjwatson) wrote :

Adding apt archives commonly also involves adding the corresponding GPG key that signs the archive, which is going to be a bit difficult to retrofit to this syntax, so I think we should consider some slightly different syntaxes before committing to this.

Have you considered the approach taken by https://snapcraft.io/docs/package-repositories? Rather than supplying the whole `sources.list` line as a string, it instead assembles it out of a YAML object that specifies the URL, components, and architectures separately, and that can also provide a key ID and possibly a key server to fetch the GPG key. This approach also makes it easy for it to have shortcuts for using Launchpad PPAs, which can be a bit simpler because the signing key data can be fetched from Launchpad (that wouldn't be initially necessary for this branch since we probably wouldn't be doing that sort of thing for Artifactory, but I'd like to have the option to add that in future for broader Launchpad CI needs).

I would definitely like to have something like this that can be a little more easily extended, even if we don't adopt literally the same syntax. But if our needs are compatible with snapcraft's, then there seems no reason not to adopt the same syntax, given that lpcraft is at least somewhat inspired by snapcraft in other areas.

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

Thanks for the review - I really like your ideas.

I have updated my MP and it is ready for review again.

Revision history for this message
Jürgen Gmach (jugmac00) :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks for the review - I applied most suggestions :-)

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 72603d6..1d6b28c 100644
3--- a/NEWS.rst
4+++ b/NEWS.rst
5@@ -9,6 +9,8 @@ Version history
6
7 - Hide the internal ``run-one`` command from ``--help`` output.
8
9+- Add new configuration option to provide additional package repositories.
10+
11 0.0.17 (2022-06-17)
12 ===================
13
14diff --git a/docs/configuration.rst b/docs/configuration.rst
15index ecaa727..b453270 100644
16--- a/docs/configuration.rst
17+++ b/docs/configuration.rst
18@@ -50,6 +50,11 @@ Job definitions
19 ``packages`` (optional)
20 Packages to install using ``apt`` as dependencies of this job.
21
22+``package-repositories`` (optional)
23+ Repositories which will be added to the already existing ones in
24+ `/etc/apt/sources.list`.
25+ Also see the :ref:`package-repositories` section below.
26+
27 ``snaps`` (optional)
28 Snaps to install as dependencies of this job.
29
30@@ -132,3 +137,35 @@ Output properties
31 This value is parsed using `pydantic's standard timedelta parsing
32 <https://pydantic-docs.helpmanual.io/usage/types/#datetime-types>`_,
33 restricted to non-negative timedeltas.
34+
35+
36+.. _package-repositories:
37+
38+Package-repositories properties
39+-------------------------------
40+
41+The properties are inspired by the properties of `Snapcraft
42+<https://snapcraft.io/docs/package-repositories>`_.
43+Only a subset of them is currently implemented.
44+
45+More properties can be implemented on demand.
46+
47+``type`` (required)
48+ Specifies the type of package-repository.
49+ Currently only `apt` is supported.
50+
51+``formats`` (required)
52+ Specifies the format of the package-repository.
53+ Supported values: ``deb`` and ``deb-src``.
54+
55+``components`` (required)
56+ Specifies the component of the package-repository,
57+ One or several of ``main``, ``restricted``, ``universe``, ``multiverse``.
58+
59+``suites`` (required)
60+ Specifies the suite of the package-repository.
61+ One or several of ``bionic``, ``focal``, ``jammy``.
62+
63+``url`` (required)
64+ Specifies the URL of the package-repository.
65+ e.g. ``http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu``
66diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
67index dd39c1c..757dfe0 100644
68--- a/lpcraft/commands/run.py
69+++ b/lpcraft/commands/run.py
70@@ -9,6 +9,7 @@ import os
71 import shlex
72 from argparse import ArgumentParser, Namespace
73 from pathlib import Path, PurePath
74+from tempfile import NamedTemporaryFile
75 from typing import Dict, List, Optional, Set
76
77 from craft_cli import BaseCommand, EmitterMode, emit
78@@ -18,7 +19,7 @@ from dotenv import dotenv_values
79 from pluggy import PluginManager
80
81 from lpcraft import env
82-from lpcraft.config import Config, Job, Output
83+from lpcraft.config import Config, Job, Output, PackageRepository
84 from lpcraft.errors import CommandError
85 from lpcraft.plugin.manager import get_plugin_manager
86 from lpcraft.plugins import PLUGINS
87@@ -226,19 +227,38 @@ def _install_apt_packages(
88 host_architecture: str,
89 remote_cwd: Path,
90 apt_replacement_repositories: Optional[List[str]],
91+ additional_apt_repositories: Optional[List[PackageRepository]],
92 environment: Optional[Dict[str, Optional[str]]],
93 ) -> None:
94- if apt_replacement_repositories:
95- # replace sources.list
96- lines = "\n".join(apt_replacement_repositories) + "\n"
97+ if apt_replacement_repositories or additional_apt_repositories:
98+ sources_list_path = "/etc/apt/sources.list"
99+
100+ with NamedTemporaryFile(mode="w+") as tmpfile:
101+ try:
102+ instance.pull_file(
103+ source=Path(sources_list_path),
104+ destination=Path(tmpfile.name),
105+ )
106+ except Exception as e:
107+ raise CommandError(str(e), retcode=1)
108+ sources = tmpfile.read()
109+
110+ if apt_replacement_repositories:
111+ sources = "\n".join(apt_replacement_repositories) + "\n"
112+ if additional_apt_repositories:
113+ for repository in additional_apt_repositories:
114+ sources += (
115+ "\n" + "\n".join(repository.sources_list_lines()) + "\n"
116+ )
117 with emit.open_stream("Replacing /etc/apt/sources.list") as stream:
118 instance.push_file_io(
119- destination=PurePath("/etc/apt/sources.list"),
120- content=io.BytesIO(lines.encode()),
121+ destination=PurePath(sources_list_path),
122+ content=io.BytesIO(sources.encode()),
123 file_mode="0644",
124 group="root",
125 user="root",
126 )
127+
128 # update local repository information
129 apt_update = ["apt", "update"]
130 with emit.open_stream(f"Running {apt_update}") as stream:
131@@ -316,6 +336,7 @@ def _run_job(
132 provider: Provider,
133 output: Optional[Path],
134 apt_replacement_repositories: Optional[List[str]] = None,
135+ additional_apt_repositories: Optional[List[PackageRepository]] = None,
136 env_from_cli: Optional[List[str]] = None,
137 plugin_settings: Optional[List[str]] = None,
138 ) -> None:
139@@ -406,6 +427,7 @@ def _run_job(
140 host_architecture=host_architecture,
141 remote_cwd=remote_cwd,
142 apt_replacement_repositories=apt_replacement_repositories,
143+ additional_apt_repositories=additional_apt_repositories,
144 environment=environment,
145 )
146 for cmd in (pre_run_command, run_command, post_run_command):
147@@ -518,6 +540,7 @@ class RunCommand(BaseCommand):
148 apt_replacement_repositories=(
149 args.apt_replace_repositories
150 ),
151+ additional_apt_repositories=job.package_repositories, # noqa: E501
152 env_from_cli=args.set_env,
153 plugin_settings=args.plugin_setting,
154 )
155@@ -625,6 +648,7 @@ class RunOneCommand(BaseCommand):
156 provider,
157 args.output_directory,
158 apt_replacement_repositories=args.apt_replace_repositories,
159+ additional_apt_repositories=job.package_repositories,
160 env_from_cli=args.set_env,
161 plugin_settings=args.plugin_setting,
162 )
163diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
164index 165d435..0da675a 100644
165--- a/lpcraft/commands/tests/test_run.py
166+++ b/lpcraft/commands/tests/test_run.py
167@@ -1995,6 +1995,67 @@ class TestRun(RunBaseTestCase):
168 execute_run.call_args_list,
169 )
170
171+ @patch("lpcraft.commands.run.get_provider")
172+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
173+ def test_run_with_additional_package_repositories(
174+ self, mock_get_host_architecture, mock_get_provider
175+ ):
176+ existing_repositories = [
177+ "deb http://archive.ubuntu.com/ubuntu/ focal main restricted",
178+ "deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted",
179+ ]
180+
181+ def fake_pull_file(source: Path, destination: Path) -> None:
182+ destination.write_text("\n".join(existing_repositories))
183+
184+ launcher = Mock(spec=launch)
185+ provider = makeLXDProvider(lxd_launcher=launcher)
186+ mock_get_provider.return_value = provider
187+ execute_run = launcher.return_value.execute_run
188+ execute_run.return_value = subprocess.CompletedProcess([], 0)
189+ launcher.return_value.pull_file.side_effect = fake_pull_file
190+
191+ config = dedent(
192+ """
193+ pipeline:
194+ - test
195+ jobs:
196+ test:
197+ series: focal
198+ architectures: amd64
199+ run: ls -la
200+ packages: [git]
201+ package-repositories:
202+ - type: apt
203+ formats: [deb]
204+ components: [main, universe]
205+ suites: [focal]
206+ url: https://canonical.example.org/artifactory/jammy-golang-backport
207+ """ # noqa: E501
208+ )
209+ Path(".launchpad.yaml").write_text(config)
210+
211+ result = self.run_command("run")
212+
213+ self.assertEqual(0, result.exit_code)
214+
215+ mock_info = launcher.return_value.push_file_io.call_args_list
216+ self.assertEqual(
217+ Path("/etc/apt/sources.list"), mock_info[0][1]["destination"]
218+ )
219+
220+ file_contents = mock_info[0][1]["content"].read().decode()
221+ self.assertEqual(
222+ dedent(
223+ """\
224+ deb http://archive.ubuntu.com/ubuntu/ focal main restricted
225+ deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted
226+ deb https://canonical.example.org/artifactory/jammy-golang-backport focal main universe
227+ """ # noqa: E501
228+ ),
229+ file_contents,
230+ )
231+
232
233 class TestRunOne(RunBaseTestCase):
234 def test_config_file_not_under_project_directory(self):
235@@ -2637,3 +2698,109 @@ class TestRunOne(RunBaseTestCase):
236 ],
237 execute_run.call_args_list,
238 )
239+
240+ @patch("lpcraft.commands.run.get_provider")
241+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
242+ def test_run_with_additional_package_repositories(
243+ self, mock_get_host_architecture, mock_get_provider
244+ ):
245+ existing_repositories = [
246+ "deb http://archive.ubuntu.com/ubuntu/ focal main restricted",
247+ "deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted",
248+ ]
249+
250+ def fake_pull_file(source: Path, destination: Path) -> None:
251+ destination.write_text("\n".join(existing_repositories))
252+
253+ launcher = Mock(spec=launch)
254+ provider = makeLXDProvider(lxd_launcher=launcher)
255+ mock_get_provider.return_value = provider
256+ execute_run = launcher.return_value.execute_run
257+ execute_run.return_value = subprocess.CompletedProcess([], 0)
258+ launcher.return_value.pull_file.side_effect = fake_pull_file
259+ config = dedent(
260+ """
261+ pipeline:
262+ - test
263+ jobs:
264+ test:
265+ series: focal
266+ architectures: amd64
267+ run: ls -la
268+ packages: [git]
269+ package-repositories:
270+ - type: apt
271+ formats: [deb]
272+ components: [main, universe]
273+ suites: [focal]
274+ url: https://canonical.example.org/artifactory/jammy-golang-backport
275+ """ # noqa: E501
276+ )
277+ Path(".launchpad.yaml").write_text(config)
278+
279+ result = self.run_command("run-one", "test", "0")
280+
281+ self.assertEqual(0, result.exit_code)
282+
283+ mock_info = launcher.return_value.push_file_io.call_args_list
284+ self.assertEqual(
285+ Path("/etc/apt/sources.list"), mock_info[0][1]["destination"]
286+ )
287+
288+ file_contents = mock_info[0][1]["content"].read().decode()
289+ self.assertEqual(
290+ dedent(
291+ """\
292+ deb http://archive.ubuntu.com/ubuntu/ focal main restricted
293+ deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted
294+ deb https://canonical.example.org/artifactory/jammy-golang-backport focal main universe
295+ """ # noqa: E501
296+ ),
297+ file_contents,
298+ )
299+
300+ @patch("lpcraft.env.get_managed_environment_project_path")
301+ @patch("lpcraft.commands.run.get_provider")
302+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
303+ def test_fails_pulling_sources_list(
304+ self,
305+ mock_get_host_architecture,
306+ mock_get_provider,
307+ mock_get_project_path,
308+ ):
309+ launcher = Mock(spec=launch)
310+ provider = makeLXDProvider(lxd_launcher=launcher)
311+ mock_get_provider.return_value = provider
312+ execute_run = LocalExecuteRun(self.tmp_project_path)
313+ launcher.return_value.execute_run = execute_run
314+ launcher.return_value.pull_file.side_effect = FileNotFoundError(
315+ "File not found"
316+ )
317+ config = dedent(
318+ """
319+ pipeline:
320+ - test
321+ jobs:
322+ test:
323+ series: focal
324+ architectures: amd64
325+ run: ls -la
326+ packages: [git]
327+ package-repositories:
328+ - type: apt
329+ formats: [deb]
330+ components: [main, universe]
331+ suites: [focal]
332+ url: https://canonical.example.org/repodir
333+ """
334+ )
335+ Path(".launchpad.yaml").write_text(config)
336+
337+ result = self.run_command("run-one", "test", "0")
338+
339+ self.assertThat(
340+ result,
341+ MatchesStructure.byEquality(
342+ exit_code=1, errors=[CommandError("File not found", retcode=1)]
343+ ),
344+ )
345diff --git a/lpcraft/config.py b/lpcraft/config.py
346index 0a4120d..0e835e2 100644
347--- a/lpcraft/config.py
348+++ b/lpcraft/config.py
349@@ -5,10 +5,10 @@ import re
350 from datetime import timedelta
351 from enum import Enum
352 from pathlib import Path
353-from typing import Any, Dict, List, Optional, Type, Union
354+from typing import Any, Dict, Iterator, List, Optional, Type, Union
355
356 import pydantic
357-from pydantic import StrictStr
358+from pydantic import AnyHttpUrl, StrictStr
359
360 from lpcraft.errors import ConfigurationError
361 from lpcraft.plugins import PLUGINS
362@@ -76,6 +76,64 @@ def _validate_plugin_config(
363 return values
364
365
366+class PackageType(str, Enum):
367+ """Specifies the type of the package repository.
368+
369+ Currently only supports apt.
370+ """
371+
372+ apt = "apt"
373+
374+
375+class PackageFormat(str, Enum):
376+ """Specifies the format of the package repository."""
377+
378+ deb = "deb"
379+ deb_src = "deb-src"
380+
381+
382+class PackageComponent(str, Enum):
383+ """Specifies the component of the package repository."""
384+
385+ main = "main"
386+ restricted = "restricted"
387+ universe = "universe"
388+ multiverse = "multiverse"
389+
390+
391+class PackageSuite(str, Enum):
392+ """Specifies the suite of the package repository.
393+
394+ e.g. xenial, focal, ...
395+ """
396+
397+ bionic = "bionic" # 18.04
398+ focal = "focal" # 20.04
399+ jammy = "jammy" # 22.04
400+
401+
402+class PackageRepository(ModelConfigDefaults):
403+ """A representation of a package repository.
404+
405+ inspired by https://snapcraft.io/docs/package-repositories
406+ """
407+
408+ type: PackageType # e.g. `apt``
409+ formats: List[PackageFormat] # e.g. `[deb, deb-src]`
410+ components: List[PackageComponent] # e.g. `[main, universe]`
411+ suites: List[PackageSuite] # e.g. `[bionic, focal]`
412+ url: AnyHttpUrl
413+
414+ def sources_list_lines(self) -> Iterator[str]:
415+ """Yield repository lines as strings.
416+
417+ e.g. 'deb https://canonical.example.org/artifactory/jammy-golang-backport focal main'
418+ """ # noqa: E501
419+ for format in self.formats:
420+ for suite in self.suites:
421+ yield f"{format} {self.url!s} {suite} {' '.join(self.components)}" # noqa: E501
422+
423+
424 class Job(ModelConfigDefaults):
425 """A job definition."""
426
427@@ -92,6 +150,7 @@ class Job(ModelConfigDefaults):
428 output: Optional[Output]
429 snaps: Optional[List[StrictStr]]
430 packages: Optional[List[StrictStr]]
431+ package_repositories: Optional[List[PackageRepository]]
432 plugin: Optional[StrictStr]
433 plugin_config: Optional[BaseConfig]
434
435diff --git a/lpcraft/main.py b/lpcraft/main.py
436index 83fe767..f6263cf 100644
437--- a/lpcraft/main.py
438+++ b/lpcraft/main.py
439@@ -63,6 +63,21 @@ def main(argv: Optional[List[str]] = None) -> int:
440 )
441 ]
442
443+ # dispatcher = Dispatcher(
444+ # "lpcraft",
445+ # command_groups,
446+ # summary=summary,
447+ # extra_global_args=extra_global_args,
448+ # default_command=RunCommand,
449+ # )
450+ # global_args = dispatcher.pre_parse_args(argv)
451+ # if global_args["version"]:
452+ # emit.message(lpcraft_version)
453+ # emit.ended_ok()
454+ # return 0
455+ # dispatcher.load_command(None)
456+ # ret = dispatcher.run() or 0
457+
458 try:
459 dispatcher = Dispatcher(
460 "lpcraft",
461diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
462index d34b718..3daf342 100644
463--- a/lpcraft/tests/test_config.py
464+++ b/lpcraft/tests/test_config.py
465@@ -7,7 +7,7 @@ from pathlib import Path
466 from textwrap import dedent
467
468 from fixtures import TempDir
469-from pydantic import ValidationError
470+from pydantic import AnyHttpUrl, ValidationError
471 from testtools import TestCase
472 from testtools.matchers import (
473 Equals,
474@@ -16,7 +16,7 @@ from testtools.matchers import (
475 MatchesStructure,
476 )
477
478-from lpcraft.config import Config, OutputDistributeEnum
479+from lpcraft.config import Config, OutputDistributeEnum, PackageRepository
480 from lpcraft.errors import CommandError
481
482
483@@ -403,3 +403,81 @@ class TestConfig(TestCase):
484 config = Config.load(path)
485
486 self.assertEqual("tox", config.jobs["test"][0].plugin)
487+
488+ def test_package_repositories(self):
489+ path = self.create_config(
490+ dedent(
491+ """
492+ pipeline:
493+ - test
494+
495+ jobs:
496+ test:
497+ series: focal
498+ architectures: amd64
499+ packages: [nginx, apache2]
500+ package-repositories:
501+ - type: apt
502+ formats: [deb]
503+ components: [main]
504+ suites: [focal]
505+ url: https://canonical.example.org/artifactory/jammy-golang-backport
506+ """ # noqa: E501
507+ )
508+ )
509+
510+ config = Config.load(path)
511+
512+ self.assertEqual(
513+ [
514+ PackageRepository(
515+ type="apt",
516+ formats=["deb"],
517+ components=["main"],
518+ suites=["focal"],
519+ url=AnyHttpUrl(
520+ "https://canonical.example.org/artifactory/jammy-golang-backport", # noqa: E501
521+ scheme="https",
522+ host="canonical.example.org",
523+ tld="org",
524+ host_type="domain",
525+ path="/artifactory/jammy-golang-backport",
526+ ),
527+ )
528+ ],
529+ config.jobs["test"][0].package_repositories,
530+ )
531+
532+ def test_package_repositories_as_string(self):
533+ path = self.create_config(
534+ dedent(
535+ """
536+ pipeline:
537+ - test
538+
539+ jobs:
540+ test:
541+ series: focal
542+ architectures: amd64
543+ packages: [nginx, apache2]
544+ package-repositories:
545+ - type: apt
546+ formats: [deb, deb-src]
547+ components: [main]
548+ suites: [focal, bionic]
549+ url: https://canonical.example.org/artifactory/jammy-golang-backport
550+ """ # noqa: E501
551+ )
552+ )
553+ config = Config.load(path)
554+ expected = [
555+ "deb https://canonical.example.org/artifactory/jammy-golang-backport focal main", # noqa: E501
556+ "deb https://canonical.example.org/artifactory/jammy-golang-backport bionic main", # noqa: E501
557+ "deb-src https://canonical.example.org/artifactory/jammy-golang-backport focal main", # noqa: E501
558+ "deb-src https://canonical.example.org/artifactory/jammy-golang-backport bionic main", # noqa: E501
559+ ]
560+ repositories = config.jobs["test"][0].package_repositories
561+ assert repositories is not None # workaround necessary to please mypy
562+ self.assertEqual(
563+ expected, (list(repositories[0].sources_list_lines()))
564+ )

Subscribers

People subscribed via source and target branches