Merge ~pelpsi/lpci:jobs-cannot-run-as-root into lpci:main
- Git
- lp:~pelpsi/lpci
- jobs-cannot-run-as-root
- Merge into main
Proposed by
Simone Pelosi
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Simone Pelosi | ||||
Approved revision: | dbc25c6b9140f0113264f2d18fe5bd4c0695695f | ||||
Merge reported by: | Simone Pelosi | ||||
Merged at revision: | dbc25c6b9140f0113264f2d18fe5bd4c0695695f | ||||
Proposed branch: | ~pelpsi/lpci:jobs-cannot-run-as-root | ||||
Merge into: | lpci:main | ||||
Diff against target: |
481 lines (+308/-3) 11 files modified
NEWS.rst (+2/-0) docs/configuration.rst (+6/-0) lpci/commands/run.py (+11/-0) lpci/commands/tests/test_run.py (+49/-0) lpci/config.py (+11/-0) lpci/env.py (+4/-0) lpci/providers/_base.py (+1/-0) lpci/providers/_lxd.py (+34/-0) lpci/providers/tests/test_lxd.py (+144/-3) lpci/tests/test_config.py (+43/-0) lpci/tests/test_env.py (+3/-0) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email:
|
Commit message
Add a root flag
New root flag to choose whether to run commands as root or as default user (_lpci).
Lpci runs jobs as root inside the container.
However, there are some reasonable jobs that call code that refuses to run as root.
LP: #1982954
Description of the change
To post a comment you must log in.
Revision history for this message
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Simone Pelosi (pelpsi) wrote : | # |
> Thanks! I have a collection of nitpicks, but they're mostly just that.
Thank you Colin! I make the necessary fixes and then I merge it.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/NEWS.rst b/NEWS.rst |
2 | index ba2012b..45f0dde 100644 |
3 | --- a/NEWS.rst |
4 | +++ b/NEWS.rst |
5 | @@ -9,6 +9,8 @@ Version history |
6 | - Fix ``policy-rc.d`` issue preventing services |
7 | from running into the container. |
8 | |
9 | +- Add a ``root`` flag. |
10 | + |
11 | 0.1.0 (2023-04-18) |
12 | ================== |
13 | |
14 | diff --git a/docs/configuration.rst b/docs/configuration.rst |
15 | index 65b0b5d..3a71bcd 100644 |
16 | --- a/docs/configuration.rst |
17 | +++ b/docs/configuration.rst |
18 | @@ -72,6 +72,12 @@ Job definitions |
19 | ``plugin`` (optional) |
20 | A plugin which will be used for this job. See :doc:`../plugins` |
21 | |
22 | +``root`` (optional) |
23 | + If ``true``, run shell commands declared by ``run-before``, |
24 | + ``run``, and ``run-after`` as root; |
25 | + if ``false``, run them as a non-root user (``_lpci``). |
26 | + Default value: ``false``. |
27 | + |
28 | ``run-before`` (optional) |
29 | A string (possibly multi-line) containing shell commands to run for this |
30 | job prior to the main ``run`` section. |
31 | diff --git a/lpci/commands/run.py b/lpci/commands/run.py |
32 | index d6c65b2..c543ed1 100644 |
33 | --- a/lpci/commands/run.py |
34 | +++ b/lpci/commands/run.py |
35 | @@ -433,8 +433,12 @@ def _run_instance_command( |
36 | host_architecture: str, |
37 | remote_cwd: Path, |
38 | environment: Optional[Dict[str, Optional[str]]], |
39 | + root: bool = True, |
40 | ) -> None: |
41 | full_run_cmd = ["bash", "--noprofile", "--norc", "-ec", command] |
42 | + if not root: |
43 | + full_run_cmd[:0] = ["runuser", "-u", env.get_non_root_user(), "--"] |
44 | + |
45 | emit.progress("Running command for the job...") |
46 | original_mode = emit.get_mode() |
47 | if original_mode == EmitterMode.BRIEF: |
48 | @@ -475,6 +479,11 @@ def _run_job( |
49 | # XXX jugmac00 2022-04-27: we should create a configuration object to be |
50 | # passed in and not so many arguments |
51 | job = config.jobs[job_name][job_index] |
52 | + root = job.root |
53 | + |
54 | + # workaround necessary to please coverage |
55 | + assert isinstance(root, bool) |
56 | + |
57 | host_architecture = get_host_architecture() |
58 | if host_architecture not in job.architectures: |
59 | return |
60 | @@ -540,6 +549,7 @@ def _run_job( |
61 | series=job.series, |
62 | architecture=host_architecture, |
63 | gpu_nvidia=gpu_nvidia, |
64 | + root=root, |
65 | ) as instance: |
66 | snaps = list(itertools.chain(*pm.hook.lpci_install_snaps())) |
67 | for snap in snaps: |
68 | @@ -583,6 +593,7 @@ def _run_job( |
69 | host_architecture=host_architecture, |
70 | remote_cwd=remote_cwd, |
71 | environment=environment, |
72 | + root=root, |
73 | ) |
74 | if config.license: |
75 | if not job.output: |
76 | diff --git a/lpci/commands/tests/test_run.py b/lpci/commands/tests/test_run.py |
77 | index 07ae61b..c47f1e7 100644 |
78 | --- a/lpci/commands/tests/test_run.py |
79 | +++ b/lpci/commands/tests/test_run.py |
80 | @@ -3537,6 +3537,55 @@ class TestRun(RunBaseTestCase): |
81 | remote="test-remote", |
82 | ) |
83 | |
84 | + @patch("lpci.commands.run.get_provider") |
85 | + @patch("lpci.commands.run.get_host_architecture", return_value="amd64") |
86 | + def test_root_field(self, mock_get_host_architecture, mock_get_provider): |
87 | + launcher = Mock(spec=launch) |
88 | + provider = makeLXDProvider(lxd_launcher=launcher) |
89 | + mock_get_provider.return_value = provider |
90 | + execute_run = launcher.return_value.execute_run |
91 | + execute_run.return_value = subprocess.CompletedProcess("_lpci", 0) |
92 | + config = dedent( |
93 | + """ |
94 | + pipeline: |
95 | + - build |
96 | + |
97 | + jobs: |
98 | + build: |
99 | + root: False |
100 | + series: focal |
101 | + architectures: amd64 |
102 | + run: whoami |
103 | + """ |
104 | + ) |
105 | + Path(".launchpad.yaml").write_text(config) |
106 | + |
107 | + result = self.run_command("run") |
108 | + |
109 | + self.assertEqual(0, result.exit_code) |
110 | + self.assertEqual( |
111 | + [ |
112 | + call( |
113 | + [ |
114 | + "runuser", |
115 | + "-u", |
116 | + "_lpci", |
117 | + "--", |
118 | + "bash", |
119 | + "--noprofile", |
120 | + "--norc", |
121 | + "-ec", |
122 | + "whoami", |
123 | + ], |
124 | + cwd=Path("/build/lpci/project"), |
125 | + env={}, |
126 | + stdout=ANY, |
127 | + stderr=ANY, |
128 | + ) |
129 | + ], |
130 | + execute_run.call_args_list, |
131 | + ) |
132 | + |
133 | |
134 | class TestRunOne(RunBaseTestCase): |
135 | def test_config_file_not_under_project_directory(self): |
136 | diff --git a/lpci/config.py b/lpci/config.py |
137 | index 8e3f77a..4af17e5 100644 |
138 | --- a/lpci/config.py |
139 | +++ b/lpci/config.py |
140 | @@ -290,6 +290,7 @@ class Job(ModelConfigDefaults): |
141 | package_repositories: List[PackageRepository] = [] |
142 | plugin: Optional[StrictStr] |
143 | plugin_config: Optional[BaseConfig] |
144 | + root: Optional[bool] = True |
145 | |
146 | @pydantic.validator("architectures", pre=True) |
147 | def validate_architectures( |
148 | @@ -299,6 +300,16 @@ class Job(ModelConfigDefaults): |
149 | v = [v] |
150 | return v |
151 | |
152 | + @pydantic.validator("root", pre=True) |
153 | + def validate_root(cls, v: Any) -> Any: |
154 | + if type(v) is not bool or v is None: |
155 | + raise ValueError( |
156 | + "You configured `root` parameter, " |
157 | + + "but you did not specify a valid value. " |
158 | + + "Valid values would either be `true` or `false`." |
159 | + ) |
160 | + return v |
161 | + |
162 | @pydantic.validator("snaps", pre=True) |
163 | def validate_snaps(cls, v: List[Any]) -> Any: |
164 | clean_values = [] |
165 | diff --git a/lpci/env.py b/lpci/env.py |
166 | index cfc7331..0aa9b96 100644 |
167 | --- a/lpci/env.py |
168 | +++ b/lpci/env.py |
169 | @@ -6,6 +6,10 @@ |
170 | from pathlib import Path |
171 | |
172 | |
173 | +def get_non_root_user() -> str: |
174 | + return "_lpci" |
175 | + |
176 | + |
177 | def get_managed_environment_home_path() -> Path: |
178 | """Path for home when running in managed environment.""" |
179 | return Path("/root") |
180 | diff --git a/lpci/providers/_base.py b/lpci/providers/_base.py |
181 | index 15d14f7..388891a 100644 |
182 | --- a/lpci/providers/_base.py |
183 | +++ b/lpci/providers/_base.py |
184 | @@ -109,6 +109,7 @@ class Provider(ABC): |
185 | series: str, |
186 | architecture: str, |
187 | gpu_nvidia: bool = False, |
188 | + root: bool = False, |
189 | ) -> Generator[lxd.LXDInstance, None, None]: |
190 | """Launch environment for specified series and architecture. |
191 | |
192 | diff --git a/lpci/providers/_lxd.py b/lpci/providers/_lxd.py |
193 | index 1a20c92..c673502 100644 |
194 | --- a/lpci/providers/_lxd.py |
195 | +++ b/lpci/providers/_lxd.py |
196 | @@ -20,6 +20,7 @@ from pydantic import StrictStr |
197 | from lpci.env import ( |
198 | get_managed_environment_home_path, |
199 | get_managed_environment_project_path, |
200 | + get_non_root_user, |
201 | ) |
202 | from lpci.errors import CommandError |
203 | from lpci.providers._base import Provider, sanitize_lxd_instance_name |
204 | @@ -238,6 +239,34 @@ class LXDProvider(Provider): |
205 | **kwargs, |
206 | ) |
207 | |
208 | + def _set_up_non_root_user( |
209 | + self, |
210 | + instance: lxd.LXDInstance, |
211 | + instance_name: str, |
212 | + ) -> None: |
213 | + |
214 | + default_user = get_non_root_user() |
215 | + create_cmd = "getent passwd " + default_user + " >/dev/null" |
216 | + create_cmd += " || useradd -m -U " + default_user |
217 | + |
218 | + # Create default user if doesn't exist. |
219 | + self._internal_execute_run( |
220 | + instance, instance_name, ["sh", "-c", create_cmd], check=True |
221 | + ) |
222 | + |
223 | + # Give ownership of the project directory to _lpci |
224 | + self._internal_execute_run( |
225 | + instance, |
226 | + instance_name, |
227 | + [ |
228 | + "chown", |
229 | + "-R", |
230 | + f"{default_user}:{default_user}", |
231 | + str(get_managed_environment_project_path()), |
232 | + ], |
233 | + check=True, |
234 | + ) |
235 | + |
236 | @contextmanager |
237 | def launched_environment( |
238 | self, |
239 | @@ -247,6 +276,7 @@ class LXDProvider(Provider): |
240 | series: str, |
241 | architecture: str, |
242 | gpu_nvidia: bool = False, |
243 | + root: bool = True, |
244 | ) -> Generator[lxd.LXDInstance, None, None]: |
245 | """Launch environment for specified series and architecture. |
246 | |
247 | @@ -354,6 +384,10 @@ class LXDProvider(Provider): |
248 | ["rm", "-f", "/usr/local/sbin/policy-rc.d"], |
249 | check=True, |
250 | ) |
251 | + if not root: |
252 | + self._set_up_non_root_user( |
253 | + instance=instance, instance_name=instance_name |
254 | + ) |
255 | except subprocess.CalledProcessError as error: |
256 | raise CommandError(str(error)) from error |
257 | finally: |
258 | diff --git a/lpci/providers/tests/test_lxd.py b/lpci/providers/tests/test_lxd.py |
259 | index 0a4f803..0e9b439 100644 |
260 | --- a/lpci/providers/tests/test_lxd.py |
261 | +++ b/lpci/providers/tests/test_lxd.py |
262 | @@ -500,10 +500,151 @@ class TestLXDProvider(TestCase): |
263 | ), |
264 | call().lxc.exec( |
265 | instance_name=expected_instance_name, |
266 | + command=["rm", "-f", "/usr/local/sbin/policy-rc.d"], |
267 | + project="test-project", |
268 | + remote="test-remote", |
269 | + runner=subprocess.run, |
270 | + check=True, |
271 | + ), |
272 | + call().unmount(target=Path("/root/tmp-project")), |
273 | + ], |
274 | + mock_launcher.mock_calls, |
275 | + ) |
276 | + mock_launcher.reset_mock() |
277 | + |
278 | + self.assertEqual( |
279 | + [ |
280 | + call().lxc.exec( |
281 | + instance_name=expected_instance_name, |
282 | + command=["rm", "-rf", "/build/lpci/project"], |
283 | + project="test-project", |
284 | + remote="test-remote", |
285 | + runner=subprocess.run, |
286 | + check=True, |
287 | + ), |
288 | + call().unmount_all(), |
289 | + call().stop(), |
290 | + ], |
291 | + mock_launcher.mock_calls, |
292 | + ) |
293 | + |
294 | + @patch("os.environ", {"PATH": "not-using-host-path"}) |
295 | + def test_launched_environment_default_user(self): |
296 | + expected_instance_name = "lpci-my-project-12345-focal-amd64" |
297 | + mock_lxc = Mock(spec=LXC) |
298 | + mock_lxc.profile_show.return_value = { |
299 | + "config": {"sentinel": "true"}, |
300 | + "devices": {"eth0": {}}, |
301 | + } |
302 | + mock_lxc.project_list.return_value = [] |
303 | + mock_lxc.remote_list.return_value = {} |
304 | + mock_launcher = Mock(spec=launch) |
305 | + provider = makeLXDProvider(lxc=mock_lxc, lxd_launcher=mock_launcher) |
306 | + |
307 | + with provider.launched_environment( |
308 | + project_name="my-project", |
309 | + project_path=self.mock_path, |
310 | + series="focal", |
311 | + architecture="amd64", |
312 | + root=False, |
313 | + ) as instance: |
314 | + self.assertIsNotNone(instance) |
315 | + mock_lxc.project_list.assert_called_once_with("test-remote") |
316 | + mock_lxc.project_create.assert_called_once_with( |
317 | + project="test-project", remote="test-remote" |
318 | + ) |
319 | + mock_lxc.profile_show.assert_called_once_with( |
320 | + profile="default", project="default", remote="test-remote" |
321 | + ) |
322 | + mock_lxc.profile_edit.assert_called_once_with( |
323 | + profile="default", |
324 | + config={ |
325 | + "config": {"sentinel": "true"}, |
326 | + "devices": {"eth0": {}}, |
327 | + }, |
328 | + project="test-project", |
329 | + remote="test-remote", |
330 | + ) |
331 | + self.assertEqual( |
332 | + [ |
333 | + call( |
334 | + name=expected_instance_name, |
335 | + base_configuration=LPCIBuilddBaseConfiguration( |
336 | + alias=BuilddBaseAlias.FOCAL, |
337 | + environment={"PATH": _base_path}, |
338 | + hostname=expected_instance_name, |
339 | + ), |
340 | + image_name="focal", |
341 | + image_remote="craft-com.ubuntu.cloud-buildd", |
342 | + auto_clean=True, |
343 | + auto_create_project=True, |
344 | + map_user_uid=True, |
345 | + use_base_instance=True, |
346 | + project="test-project", |
347 | + remote="test-remote", |
348 | + lxc=mock_lxc, |
349 | + ), |
350 | + call().mount( |
351 | + host_source=self.mock_path, |
352 | + target=Path("/root/tmp-project"), |
353 | + ), |
354 | + call().lxc.exec( |
355 | + instance_name=expected_instance_name, |
356 | + command=["rm", "-rf", "/build/lpci/project"], |
357 | + project="test-project", |
358 | + remote="test-remote", |
359 | + runner=subprocess.run, |
360 | + check=True, |
361 | + ), |
362 | + call().lxc.exec( |
363 | + instance_name=expected_instance_name, |
364 | + command=["mkdir", "-p", "/build/lpci"], |
365 | + project="test-project", |
366 | + remote="test-remote", |
367 | + runner=subprocess.run, |
368 | + check=True, |
369 | + ), |
370 | + call().lxc.exec( |
371 | + instance_name=expected_instance_name, |
372 | command=[ |
373 | - "rm", |
374 | - "-f", |
375 | - "/usr/local/sbin/policy-rc.d", |
376 | + "cp", |
377 | + "-a", |
378 | + "/root/tmp-project", |
379 | + "/build/lpci/project", |
380 | + ], |
381 | + project="test-project", |
382 | + remote="test-remote", |
383 | + runner=subprocess.run, |
384 | + check=True, |
385 | + ), |
386 | + call().lxc.exec( |
387 | + instance_name=expected_instance_name, |
388 | + command=["rm", "-f", "/usr/local/sbin/policy-rc.d"], |
389 | + project="test-project", |
390 | + remote="test-remote", |
391 | + runner=subprocess.run, |
392 | + check=True, |
393 | + ), |
394 | + call().lxc.exec( |
395 | + instance_name=expected_instance_name, |
396 | + command=[ |
397 | + "sh", |
398 | + "-c", |
399 | + "getent passwd _lpci >/dev/null" |
400 | + + " || useradd -m -U _lpci", |
401 | + ], |
402 | + project="test-project", |
403 | + remote="test-remote", |
404 | + runner=subprocess.run, |
405 | + check=True, |
406 | + ), |
407 | + call().lxc.exec( |
408 | + instance_name=expected_instance_name, |
409 | + command=[ |
410 | + "chown", |
411 | + "-R", |
412 | + "_lpci:_lpci", |
413 | + "/build/lpci/project", |
414 | ], |
415 | project="test-project", |
416 | remote="test-remote", |
417 | diff --git a/lpci/tests/test_config.py b/lpci/tests/test_config.py |
418 | index 7bf45a5..17cc308 100644 |
419 | --- a/lpci/tests/test_config.py |
420 | +++ b/lpci/tests/test_config.py |
421 | @@ -848,3 +848,46 @@ class TestConfig(TestCase): |
422 | Config.load, |
423 | path, |
424 | ) |
425 | + |
426 | + def test_root_value(self): |
427 | + path = self.create_config( |
428 | + dedent( |
429 | + """ |
430 | + pipeline: |
431 | + - test |
432 | + |
433 | + jobs: |
434 | + test: |
435 | + root: True |
436 | + series: focal |
437 | + architectures: amd64 |
438 | + """ |
439 | + ) |
440 | + ) |
441 | + config = Config.load(path) |
442 | + root_value = config.jobs["test"][0].root |
443 | + self.assertEqual(True, root_value) |
444 | + |
445 | + def test_bad_root_value(self): |
446 | + path = self.create_config( |
447 | + dedent( |
448 | + """ |
449 | + pipeline: |
450 | + - test |
451 | + |
452 | + jobs: |
453 | + test: |
454 | + root: bad_value |
455 | + series: focal |
456 | + architectures: amd64 |
457 | + """ |
458 | + ) |
459 | + ) |
460 | + self.assertRaisesRegex( |
461 | + ValidationError, |
462 | + "You configured `root` parameter, " |
463 | + + "but you did not specify a valid value. " |
464 | + + "Valid values would either be `true` or `false`.", |
465 | + Config.load, |
466 | + path, |
467 | + ) |
468 | diff --git a/lpci/tests/test_env.py b/lpci/tests/test_env.py |
469 | index 58b69f1..7079481 100644 |
470 | --- a/lpci/tests/test_env.py |
471 | +++ b/lpci/tests/test_env.py |
472 | @@ -9,6 +9,9 @@ from lpci import env |
473 | |
474 | |
475 | class TestEnvironment(TestCase): |
476 | + def test_get_non_root_user(self): |
477 | + self.assertEqual("_lpci", env.get_non_root_user()) |
478 | + |
479 | def test_get_managed_environment_home_path(self): |
480 | self.assertEqual( |
481 | Path("/root"), env.get_managed_environment_home_path() |
Thanks! I have a collection of nitpicks, but they're mostly just that.