Merge ~jugmac00/lpci:pass-in-credentials into lpci:main

Proposed by Jürgen Gmach
Status: Merged
Merged at revision: 19ac8f544ea983fef7a91b9bab4783a0d6dd4f53
Proposed branch: ~jugmac00/lpci:pass-in-credentials
Merge into: lpci:main
Prerequisite: ~jugmac00/lpci:add-additional-apt-repositories
Diff against target: 462 lines (+302/-4)
7 files modified
NEWS.rst (+2/-0)
docs/cli-interface.rst (+21/-0)
docs/configuration.rst (+5/-2)
lpcraft/commands/run.py (+41/-2)
lpcraft/commands/tests/test_run.py (+230/-0)
setup.cfg (+1/-0)
tox.ini (+2/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+425865@code.launchpad.net

Commit message

Add cli option to pass in a configuration file for credentials

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :
Download full text (3.3 KiB)

I don't really like the fact that this can only allow for a single set of credentials; that seems like an interface smell. I'd also like us to think ahead a bit more: CI environments typically have some way to store secrets safely and pass them into build jobs, and isn't this just a particular kind of secret? It's true that we're under some time pressure, but I don't want that to cause us to write ourselves into a corner where the syntax of `.launchpad.yaml` is concerned, so let's take some time to think through this.

My suggestion on https://code.launchpad.net/~jugmac00/lpcraft/+git/lpcraft/+merge/425829 might help a certain amount here, because then we wouldn't be confined to purely textual substitutions, but we could parse the URL properly and insert the username and password into it using the normal `urllib.parse` functions.

But more broadly, I think we need support for passing in more than one kind of secret, and it would be wise for the actual text of the secrets not to be on the lpcraft command line (as written here it might require extra code in launchpad-buildd to redact it properly, and the command line is just a very leaky sort of place for secrets generally - it's normally better to pass them via files).

How about a `--secrets` option which takes the path to a YAML file, which would contain a mapping of names to scalar values? launchpad-buildd would then take the contents of that file as an XML-RPC parameter (encoded for safe transport over XML-RPC), write it out in such a way as to avoid it showing up in the build log, and pass that option to lpcraft.

For the configuration file syntax, again, we should think ahead to the point where we'll probably want to make secrets accessible to `run` (we can't effectively hide these particular credentials from `run` anyway, since it's running as root inside a container where we've written the credentials to `/etc/apt/sources.list` or similar). I'd prefer to avoid `<...>` for that, since that means something quite different in shell. A natural approach would be to use shell variables: for instance, imagine being able to configure a repository with `MY_SECRET=value`, and then you could evaluate `$MY_SECRET` in a `run` command. We don't have to get all the way there in this branch, but I'd like this to be compatible with that sort of approach in the future.

On the other hand, expanding `$`-prefixed shell variables in a URL is a bit weird. Fortunately, if we've thought ahead enough for the secrets to be a dict mapping keys to scalar values, we have some flexibility here. How about having a secret called something like `apt:canonical.example.org`, which would contain credentials applied to all `package-repositories` entries with that hostname? You'd then write something like this (following Snapcraft's syntax):

    package-repositories:
      - type: apt
        components: [main]
        suites: [focal]
        url: https://canonical.example.org/artifactory/jammy-golang-backport

lpcraft would parse the URL, notice that has the hostname `canonical.example.org` and that it has an `apt:{hostname}` secret matching that, and fill in the credentials.

What do you think? I'm not 100% we...

Read more...

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

I had a quick look what github does:

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-using-secrets

They are using Jinja-like placeholders - the placeholders are defined separately per repository - this also seems a viable way.

About URL parsing... there is not much we need to do - as we use a pydantic validator, it automatically parses the URL and it should be straightforward to use something like `url.user = "username"; url.password = "secret"` to add the credentials to the URL object, and then just use `str(url)` to build it again.

I have this morning off but I'll explore these options, but I think we need to discuss this again before implementing the one or the other option.

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

Oh, the documentation should also explain that `package-repositories.url` is rendered using Jinja2.

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

Thanks, Colin! That is a good idea.

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 df71226..da3bbc7 100644
3--- a/NEWS.rst
4+++ b/NEWS.rst
5@@ -19,6 +19,8 @@ Version history
6 - Rebuild the Snap package to include updated system packages.
7 See https://ubuntu.com/security/notices/USN-5495-1/.
8
9+- Add new CLI option to provide secrets via a YAML-based configuration file.
10+
11 0.0.17 (2022-06-17)
12 ===================
13
14diff --git a/docs/cli-interface.rst b/docs/cli-interface.rst
15index 45a3914..0c44c3f 100644
16--- a/docs/cli-interface.rst
17+++ b/docs/cli-interface.rst
18@@ -29,6 +29,17 @@ lpcraft run optional arguments
19 - ``--plugin-setting``, e.g.
20 ``lpcraft run --plugin-setting="foo=bar"``
21
22+- ``--secrets``, e.g.
23+ ``lpcraft run --secrets="<path-to-configuration-file>"``
24+
25+ The configuration file should look like...
26+
27+ .. code::
28+
29+ key: secret
30+ another_key: another_secret
31+
32+
33 lpcraft run-one
34 ---------------
35
36@@ -52,3 +63,13 @@ lpcraft run-one optional arguments
37
38 - ``--plugin-setting``, e.g.
39 ``lpcraft run-one --plugin-setting="foo=bar" test 0``.
40+
41+- ``--secrets``, e.g.
42+ ``lpcraft run-one --secrets="<path-to-configuration-file>" test 0``.
43+
44+ The configuration file should look like...
45+
46+ .. code::
47+
48+ key: secret
49+ another_key: another_secret
50diff --git a/docs/configuration.rst b/docs/configuration.rst
51index b453270..6e2b84a 100644
52--- a/docs/configuration.rst
53+++ b/docs/configuration.rst
54@@ -167,5 +167,8 @@ More properties can be implemented on demand.
55 One or several of ``bionic``, ``focal``, ``jammy``.
56
57 ``url`` (required)
58- Specifies the URL of the package-repository.
59- e.g. ``http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu``
60+ Specifies the URL of the package-repository,
61+ e.g. ``http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu``.
62+ The URL is rendered using `Jinja2 <https://pypi.org/project/Jinja2/>`_.
63+ This can be used to supply authentication details via the *secrets*
64+ command line option.
65diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
66index 757dfe0..c7128ae 100644
67--- a/lpcraft/commands/run.py
68+++ b/lpcraft/commands/run.py
69@@ -12,10 +12,12 @@ from pathlib import Path, PurePath
70 from tempfile import NamedTemporaryFile
71 from typing import Dict, List, Optional, Set
72
73+import yaml
74 from craft_cli import BaseCommand, EmitterMode, emit
75 from craft_providers import Executor, lxd
76 from craft_providers.actions.snap_installer import install_from_store
77 from dotenv import dotenv_values
78+from jinja2 import BaseLoader, Environment
79 from pluggy import PluginManager
80
81 from lpcraft import env
82@@ -229,6 +231,7 @@ def _install_apt_packages(
83 apt_replacement_repositories: Optional[List[str]],
84 additional_apt_repositories: Optional[List[PackageRepository]],
85 environment: Optional[Dict[str, Optional[str]]],
86+ secrets: Optional[Dict[str, str]],
87 ) -> None:
88 if apt_replacement_repositories or additional_apt_repositories:
89 sources_list_path = "/etc/apt/sources.list"
90@@ -247,9 +250,14 @@ def _install_apt_packages(
91 sources = "\n".join(apt_replacement_repositories) + "\n"
92 if additional_apt_repositories:
93 for repository in additional_apt_repositories:
94- sources += (
95- "\n" + "\n".join(repository.sources_list_lines()) + "\n"
96+ sources += "\n" + "\n".join(repository.sources_list_lines())
97+ if secrets:
98+ template = Environment(loader=BaseLoader()).from_string(
99+ sources
100 )
101+ sources = template.render(**secrets)
102+ sources += "\n"
103+
104 with emit.open_stream("Replacing /etc/apt/sources.list") as stream:
105 instance.push_file_io(
106 destination=PurePath(sources_list_path),
107@@ -339,6 +347,7 @@ def _run_job(
108 additional_apt_repositories: Optional[List[PackageRepository]] = None,
109 env_from_cli: Optional[List[str]] = None,
110 plugin_settings: Optional[List[str]] = None,
111+ secrets: Optional[Dict[str, str]] = None,
112 ) -> None:
113 """Run a single job."""
114 # XXX jugmac00 2022-04-27: we should create a configuration object to be
115@@ -429,6 +438,7 @@ def _run_job(
116 apt_replacement_repositories=apt_replacement_repositories,
117 additional_apt_repositories=additional_apt_repositories,
118 environment=environment,
119+ secrets=secrets,
120 )
121 for cmd in (pre_run_command, run_command, post_run_command):
122 if cmd:
123@@ -509,6 +519,14 @@ class RunCommand(BaseCommand):
124 action="append",
125 help="Set an environment variable.",
126 )
127+ parser.add_argument(
128+ "--secrets",
129+ dest="secrets_file",
130+ metavar="file",
131+ default=None,
132+ type=Path,
133+ help="Pass in a YAML-based configuration file for secrets.",
134+ )
135
136 def run(self, args: Namespace) -> int:
137 """Run the command."""
138@@ -518,6 +536,12 @@ class RunCommand(BaseCommand):
139 provider.ensure_provider_is_available()
140 launched_instances = []
141
142+ secrets = {}
143+ if args.secrets_file:
144+ with open(args.secrets_file) as f:
145+ content = f.read()
146+ secrets = yaml.safe_load(content)
147+
148 try:
149 for stage in config.pipeline:
150 stage_failed = False
151@@ -543,6 +567,7 @@ class RunCommand(BaseCommand):
152 additional_apt_repositories=job.package_repositories, # noqa: E501
153 env_from_cli=args.set_env,
154 plugin_settings=args.plugin_setting,
155+ secrets=secrets,
156 )
157 except CommandError as e:
158 if len(stage) == 1:
159@@ -624,6 +649,14 @@ class RunOneCommand(BaseCommand):
160 action="append",
161 help="Set an environment variable.",
162 )
163+ parser.add_argument(
164+ "--secrets",
165+ dest="secrets_file",
166+ metavar="file",
167+ default=None,
168+ type=Path,
169+ help="Pass in a YAML-based configuration file for secrets.",
170+ )
171
172 def run(self, args: Namespace) -> int:
173 """Run the command."""
174@@ -641,6 +674,11 @@ class RunOneCommand(BaseCommand):
175 provider = get_provider()
176 provider.ensure_provider_is_available()
177
178+ secrets = {}
179+ if args.secrets_file:
180+ with open(args.secrets_file) as f:
181+ content = f.read()
182+ secrets = yaml.safe_load(content)
183 try:
184 _run_job(
185 args.job,
186@@ -651,6 +689,7 @@ class RunOneCommand(BaseCommand):
187 additional_apt_repositories=job.package_repositories,
188 env_from_cli=args.set_env,
189 plugin_settings=args.plugin_setting,
190+ secrets=secrets,
191 )
192 finally:
193 if args.clean:
194diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
195index 0da675a..ddb521b 100644
196--- a/lpcraft/commands/tests/test_run.py
197+++ b/lpcraft/commands/tests/test_run.py
198@@ -2056,6 +2056,101 @@ class TestRun(RunBaseTestCase):
199 file_contents,
200 )
201
202+ @patch("lpcraft.commands.run.get_provider")
203+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
204+ def test_run_with_additional_apt_repositories_with_secrets(
205+ self, mock_get_host_architecture, mock_get_provider
206+ ):
207+ existing_repositories = [
208+ "deb http://archive.ubuntu.com/ubuntu/ focal main restricted",
209+ "deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted",
210+ ]
211+
212+ def fake_pull_file(source: Path, destination: Path) -> None:
213+ destination.write_text("\n".join(existing_repositories))
214+
215+ launcher = Mock(spec=launch)
216+ provider = makeLXDProvider(lxd_launcher=launcher)
217+ mock_get_provider.return_value = provider
218+ execute_run = launcher.return_value.execute_run
219+ execute_run.return_value = subprocess.CompletedProcess([], 0)
220+ launcher.return_value.pull_file.side_effect = fake_pull_file
221+
222+ config = dedent(
223+ """
224+ pipeline:
225+ - test
226+ jobs:
227+ test:
228+ series: focal
229+ architectures: amd64
230+ run: ls -la
231+ packages: [git]
232+ package-repositories:
233+ - type: apt
234+ formats: [deb]
235+ components: [main, universe]
236+ suites: [focal]
237+ url: "https://{{auth}}@canonical.example.org/artifactory/jammy-golang-backport"
238+ """ # noqa: E501
239+ )
240+ Path(".launchpad.yaml").write_text(config)
241+
242+ credentials = dedent('auth: "user:pass"')
243+ Path(".launchpad-secrets.yaml").write_text(credentials)
244+
245+ result = self.run_command(
246+ "run",
247+ "--secrets",
248+ ".launchpad-secrets.yaml",
249+ )
250+
251+ self.assertEqual(0, result.exit_code)
252+ self.assertEqual(
253+ [
254+ call(
255+ ["apt", "update"],
256+ cwd=Path("/root/lpcraft/project"),
257+ env={},
258+ stdout=ANY,
259+ stderr=ANY,
260+ ),
261+ call(
262+ ["apt", "install", "-y", "git"],
263+ cwd=Path("/root/lpcraft/project"),
264+ env={},
265+ stdout=ANY,
266+ stderr=ANY,
267+ ),
268+ call(
269+ ["bash", "--noprofile", "--norc", "-ec", "ls -la"],
270+ cwd=Path("/root/lpcraft/project"),
271+ env={},
272+ stdout=ANY,
273+ stderr=ANY,
274+ ),
275+ ],
276+ execute_run.call_args_list,
277+ )
278+ mock_info = launcher.return_value.push_file_io.call_args_list
279+
280+ self.assertEqual(
281+ Path("/etc/apt/sources.list"), mock_info[0][1]["destination"]
282+ )
283+
284+ file_contents = mock_info[0][1]["content"].read().decode()
285+
286+ self.assertEqual(
287+ dedent(
288+ """\
289+ deb http://archive.ubuntu.com/ubuntu/ focal main restricted
290+ deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted
291+ deb https://user:pass@canonical.example.org/artifactory/jammy-golang-backport focal main universe
292+ """ # noqa: E501
293+ ),
294+ file_contents,
295+ )
296+
297
298 class TestRunOne(RunBaseTestCase):
299 def test_config_file_not_under_project_directory(self):
300@@ -2804,3 +2899,138 @@ class TestRunOne(RunBaseTestCase):
301 exit_code=1, errors=[CommandError("File not found", retcode=1)]
302 ),
303 )
304+
305+ @patch("lpcraft.commands.run.get_provider")
306+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
307+ def test_provide_secrets_file_via_cli(
308+ self, mock_get_host_architecture, mock_get_provider
309+ ):
310+ launcher = Mock(spec=launch)
311+ provider = makeLXDProvider(lxd_launcher=launcher)
312+ mock_get_provider.return_value = provider
313+ execute_run = launcher.return_value.execute_run
314+ execute_run.return_value = subprocess.CompletedProcess([], 0)
315+ config = dedent(
316+ """
317+ pipeline:
318+ - test
319+ jobs:
320+ test:
321+ series: focal
322+ architectures: amd64
323+ run: tox
324+ """
325+ )
326+ Path(".launchpad.yaml").write_text(config)
327+
328+ credentials = dedent('auth: "user:pass"')
329+ Path(".launchpad-secrets.yaml").write_text(credentials)
330+
331+ result = self.run_command(
332+ "run-one",
333+ "--secrets",
334+ ".launchpad-secrets.yaml",
335+ "test",
336+ "0",
337+ )
338+ self.assertEqual(0, result.exit_code)
339+ self.assertIn(
340+ "'--secrets', '.launchpad-secrets.yaml'", result.trace[0]
341+ )
342+
343+ @patch("lpcraft.commands.run.get_provider")
344+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
345+ def test_run_with_additional_apt_repositories_with_secrets(
346+ self, mock_get_host_architecture, mock_get_provider
347+ ):
348+ existing_repositories = [
349+ "deb http://archive.ubuntu.com/ubuntu/ focal main restricted",
350+ "deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted",
351+ ]
352+
353+ def fake_pull_file(source: Path, destination: Path) -> None:
354+ destination.write_text("\n".join(existing_repositories))
355+
356+ launcher = Mock(spec=launch)
357+ provider = makeLXDProvider(lxd_launcher=launcher)
358+ mock_get_provider.return_value = provider
359+ execute_run = launcher.return_value.execute_run
360+ execute_run.return_value = subprocess.CompletedProcess([], 0)
361+ launcher.return_value.pull_file.side_effect = fake_pull_file
362+
363+ config = dedent(
364+ """
365+ pipeline:
366+ - test
367+ jobs:
368+ test:
369+ series: focal
370+ architectures: amd64
371+ run: ls -la
372+ packages: [git]
373+ package-repositories:
374+ - type: apt
375+ formats: [deb]
376+ components: [main, universe]
377+ suites: [focal]
378+ url: https://canonical.example.org/artifactory/jammy-golang-backport
379+ """ # noqa: E501
380+ )
381+ Path(".launchpad.yaml").write_text(config)
382+
383+ credentials = dedent('auth: "user:pass"')
384+ Path(".launchpad-secrets.yaml").write_text(credentials)
385+
386+ result = self.run_command(
387+ "run-one",
388+ "--secrets",
389+ ".launchpad-secrets.yaml",
390+ "test",
391+ "0",
392+ )
393+
394+ self.assertEqual(0, result.exit_code)
395+ self.assertEqual(
396+ [
397+ call(
398+ ["apt", "update"],
399+ cwd=Path("/root/lpcraft/project"),
400+ env={},
401+ stdout=ANY,
402+ stderr=ANY,
403+ ),
404+ call(
405+ ["apt", "install", "-y", "git"],
406+ cwd=Path("/root/lpcraft/project"),
407+ env={},
408+ stdout=ANY,
409+ stderr=ANY,
410+ ),
411+ call(
412+ ["bash", "--noprofile", "--norc", "-ec", "ls -la"],
413+ cwd=Path("/root/lpcraft/project"),
414+ env={},
415+ stdout=ANY,
416+ stderr=ANY,
417+ ),
418+ ],
419+ execute_run.call_args_list,
420+ )
421+ mock_info = launcher.return_value.push_file_io.call_args_list
422+
423+ self.assertEqual(
424+ Path("/etc/apt/sources.list"), mock_info[0][1]["destination"]
425+ )
426+
427+ file_contents = mock_info[0][1]["content"].read().decode()
428+
429+ self.assertEqual(
430+ dedent(
431+ """\
432+ deb http://archive.ubuntu.com/ubuntu/ focal main restricted
433+ deb-src http://archive.ubuntu.com/ubuntu/ focal main restricted
434+ deb https://canonical.example.org/artifactory/jammy-golang-backport focal main universe
435+ """ # noqa: E501
436+ ),
437+ file_contents,
438+ )
439diff --git a/setup.cfg b/setup.cfg
440index 11d2a98..69db240 100644
441--- a/setup.cfg
442+++ b/setup.cfg
443@@ -25,6 +25,7 @@ install_requires =
444 PyYAML
445 craft-cli
446 craft-providers
447+ jinja2
448 pluggy
449 pydantic
450 python-dotenv
451diff --git a/tox.ini b/tox.ini
452index 71f524e..1d9be16 100644
453--- a/tox.ini
454+++ b/tox.ini
455@@ -6,6 +6,8 @@ envlist =
456 py39
457 py310
458 coverage
459+ docs
460+
461 skip_missing_interpreters =
462 true
463

Subscribers

People subscribed via source and target branches