Merge ~jugmac00/lpci:pass-in-credentials into lpci:main
- Git
- lp:~jugmac00/lpci
- pass-in-credentials
- Merge into main
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) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email:
|
Commit message
Add cli option to pass in a configuration file for credentials
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jürgen Gmach (jugmac00) wrote (last edit ): | # |
I had a quick look what github does:
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Colin Watson (cjwatson) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Colin Watson (cjwatson) wrote : | # |
Oh, the documentation should also explain that `package-
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jürgen Gmach (jugmac00) wrote : | # |
Thanks, Colin! That is a good idea.
Preview Diff
1 | diff --git a/NEWS.rst b/NEWS.rst |
2 | index 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 | |
14 | diff --git a/docs/cli-interface.rst b/docs/cli-interface.rst |
15 | index 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 |
50 | diff --git a/docs/configuration.rst b/docs/configuration.rst |
51 | index 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. |
65 | diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py |
66 | index 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: |
194 | diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py |
195 | index 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 | + ) |
439 | diff --git a/setup.cfg b/setup.cfg |
440 | index 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 |
451 | diff --git a/tox.ini b/tox.ini |
452 | index 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 |
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: /canonical. example. org/artifactory /jammy- golang- backport
- type: apt
components: [main]
suites: [focal]
url: https:/
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...