Merge ~pelpsi/lpci:jobs-cannot-run-as-root into lpci: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)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+441539@code.launchpad.net

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

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

Thanks! I have a collection of nitpicks, but they're mostly just that.

review: Approve
Revision history for this message
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
diff --git a/NEWS.rst b/NEWS.rst
index ba2012b..45f0dde 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -9,6 +9,8 @@ Version history
9- Fix ``policy-rc.d`` issue preventing services9- Fix ``policy-rc.d`` issue preventing services
10 from running into the container.10 from running into the container.
1111
12- Add a ``root`` flag.
13
120.1.0 (2023-04-18)140.1.0 (2023-04-18)
13==================15==================
1416
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 65b0b5d..3a71bcd 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -72,6 +72,12 @@ Job definitions
72``plugin`` (optional)72``plugin`` (optional)
73 A plugin which will be used for this job. See :doc:`../plugins`73 A plugin which will be used for this job. See :doc:`../plugins`
7474
75``root`` (optional)
76 If ``true``, run shell commands declared by ``run-before``,
77 ``run``, and ``run-after`` as root;
78 if ``false``, run them as a non-root user (``_lpci``).
79 Default value: ``false``.
80
75``run-before`` (optional)81``run-before`` (optional)
76 A string (possibly multi-line) containing shell commands to run for this82 A string (possibly multi-line) containing shell commands to run for this
77 job prior to the main ``run`` section.83 job prior to the main ``run`` section.
diff --git a/lpci/commands/run.py b/lpci/commands/run.py
index d6c65b2..c543ed1 100644
--- a/lpci/commands/run.py
+++ b/lpci/commands/run.py
@@ -433,8 +433,12 @@ def _run_instance_command(
433 host_architecture: str,433 host_architecture: str,
434 remote_cwd: Path,434 remote_cwd: Path,
435 environment: Optional[Dict[str, Optional[str]]],435 environment: Optional[Dict[str, Optional[str]]],
436 root: bool = True,
436) -> None:437) -> None:
437 full_run_cmd = ["bash", "--noprofile", "--norc", "-ec", command]438 full_run_cmd = ["bash", "--noprofile", "--norc", "-ec", command]
439 if not root:
440 full_run_cmd[:0] = ["runuser", "-u", env.get_non_root_user(), "--"]
441
438 emit.progress("Running command for the job...")442 emit.progress("Running command for the job...")
439 original_mode = emit.get_mode()443 original_mode = emit.get_mode()
440 if original_mode == EmitterMode.BRIEF:444 if original_mode == EmitterMode.BRIEF:
@@ -475,6 +479,11 @@ def _run_job(
475 # XXX jugmac00 2022-04-27: we should create a configuration object to be479 # XXX jugmac00 2022-04-27: we should create a configuration object to be
476 # passed in and not so many arguments480 # passed in and not so many arguments
477 job = config.jobs[job_name][job_index]481 job = config.jobs[job_name][job_index]
482 root = job.root
483
484 # workaround necessary to please coverage
485 assert isinstance(root, bool)
486
478 host_architecture = get_host_architecture()487 host_architecture = get_host_architecture()
479 if host_architecture not in job.architectures:488 if host_architecture not in job.architectures:
480 return489 return
@@ -540,6 +549,7 @@ def _run_job(
540 series=job.series,549 series=job.series,
541 architecture=host_architecture,550 architecture=host_architecture,
542 gpu_nvidia=gpu_nvidia,551 gpu_nvidia=gpu_nvidia,
552 root=root,
543 ) as instance:553 ) as instance:
544 snaps = list(itertools.chain(*pm.hook.lpci_install_snaps()))554 snaps = list(itertools.chain(*pm.hook.lpci_install_snaps()))
545 for snap in snaps:555 for snap in snaps:
@@ -583,6 +593,7 @@ def _run_job(
583 host_architecture=host_architecture,593 host_architecture=host_architecture,
584 remote_cwd=remote_cwd,594 remote_cwd=remote_cwd,
585 environment=environment,595 environment=environment,
596 root=root,
586 )597 )
587 if config.license:598 if config.license:
588 if not job.output:599 if not job.output:
diff --git a/lpci/commands/tests/test_run.py b/lpci/commands/tests/test_run.py
index 07ae61b..c47f1e7 100644
--- a/lpci/commands/tests/test_run.py
+++ b/lpci/commands/tests/test_run.py
@@ -3537,6 +3537,55 @@ class TestRun(RunBaseTestCase):
3537 remote="test-remote",3537 remote="test-remote",
3538 )3538 )
35393539
3540 @patch("lpci.commands.run.get_provider")
3541 @patch("lpci.commands.run.get_host_architecture", return_value="amd64")
3542 def test_root_field(self, mock_get_host_architecture, mock_get_provider):
3543 launcher = Mock(spec=launch)
3544 provider = makeLXDProvider(lxd_launcher=launcher)
3545 mock_get_provider.return_value = provider
3546 execute_run = launcher.return_value.execute_run
3547 execute_run.return_value = subprocess.CompletedProcess("_lpci", 0)
3548 config = dedent(
3549 """
3550 pipeline:
3551 - build
3552
3553 jobs:
3554 build:
3555 root: False
3556 series: focal
3557 architectures: amd64
3558 run: whoami
3559 """
3560 )
3561 Path(".launchpad.yaml").write_text(config)
3562
3563 result = self.run_command("run")
3564
3565 self.assertEqual(0, result.exit_code)
3566 self.assertEqual(
3567 [
3568 call(
3569 [
3570 "runuser",
3571 "-u",
3572 "_lpci",
3573 "--",
3574 "bash",
3575 "--noprofile",
3576 "--norc",
3577 "-ec",
3578 "whoami",
3579 ],
3580 cwd=Path("/build/lpci/project"),
3581 env={},
3582 stdout=ANY,
3583 stderr=ANY,
3584 )
3585 ],
3586 execute_run.call_args_list,
3587 )
3588
35403589
3541class TestRunOne(RunBaseTestCase):3590class TestRunOne(RunBaseTestCase):
3542 def test_config_file_not_under_project_directory(self):3591 def test_config_file_not_under_project_directory(self):
diff --git a/lpci/config.py b/lpci/config.py
index 8e3f77a..4af17e5 100644
--- a/lpci/config.py
+++ b/lpci/config.py
@@ -290,6 +290,7 @@ class Job(ModelConfigDefaults):
290 package_repositories: List[PackageRepository] = []290 package_repositories: List[PackageRepository] = []
291 plugin: Optional[StrictStr]291 plugin: Optional[StrictStr]
292 plugin_config: Optional[BaseConfig]292 plugin_config: Optional[BaseConfig]
293 root: Optional[bool] = True
293294
294 @pydantic.validator("architectures", pre=True)295 @pydantic.validator("architectures", pre=True)
295 def validate_architectures(296 def validate_architectures(
@@ -299,6 +300,16 @@ class Job(ModelConfigDefaults):
299 v = [v]300 v = [v]
300 return v301 return v
301302
303 @pydantic.validator("root", pre=True)
304 def validate_root(cls, v: Any) -> Any:
305 if type(v) is not bool or v is None:
306 raise ValueError(
307 "You configured `root` parameter, "
308 + "but you did not specify a valid value. "
309 + "Valid values would either be `true` or `false`."
310 )
311 return v
312
302 @pydantic.validator("snaps", pre=True)313 @pydantic.validator("snaps", pre=True)
303 def validate_snaps(cls, v: List[Any]) -> Any:314 def validate_snaps(cls, v: List[Any]) -> Any:
304 clean_values = []315 clean_values = []
diff --git a/lpci/env.py b/lpci/env.py
index cfc7331..0aa9b96 100644
--- a/lpci/env.py
+++ b/lpci/env.py
@@ -6,6 +6,10 @@
6from pathlib import Path6from pathlib import Path
77
88
9def get_non_root_user() -> str:
10 return "_lpci"
11
12
9def get_managed_environment_home_path() -> Path:13def get_managed_environment_home_path() -> Path:
10 """Path for home when running in managed environment."""14 """Path for home when running in managed environment."""
11 return Path("/root")15 return Path("/root")
diff --git a/lpci/providers/_base.py b/lpci/providers/_base.py
index 15d14f7..388891a 100644
--- a/lpci/providers/_base.py
+++ b/lpci/providers/_base.py
@@ -109,6 +109,7 @@ class Provider(ABC):
109 series: str,109 series: str,
110 architecture: str,110 architecture: str,
111 gpu_nvidia: bool = False,111 gpu_nvidia: bool = False,
112 root: bool = False,
112 ) -> Generator[lxd.LXDInstance, None, None]:113 ) -> Generator[lxd.LXDInstance, None, None]:
113 """Launch environment for specified series and architecture.114 """Launch environment for specified series and architecture.
114115
diff --git a/lpci/providers/_lxd.py b/lpci/providers/_lxd.py
index 1a20c92..c673502 100644
--- a/lpci/providers/_lxd.py
+++ b/lpci/providers/_lxd.py
@@ -20,6 +20,7 @@ from pydantic import StrictStr
20from lpci.env import (20from lpci.env import (
21 get_managed_environment_home_path,21 get_managed_environment_home_path,
22 get_managed_environment_project_path,22 get_managed_environment_project_path,
23 get_non_root_user,
23)24)
24from lpci.errors import CommandError25from lpci.errors import CommandError
25from lpci.providers._base import Provider, sanitize_lxd_instance_name26from lpci.providers._base import Provider, sanitize_lxd_instance_name
@@ -238,6 +239,34 @@ class LXDProvider(Provider):
238 **kwargs,239 **kwargs,
239 )240 )
240241
242 def _set_up_non_root_user(
243 self,
244 instance: lxd.LXDInstance,
245 instance_name: str,
246 ) -> None:
247
248 default_user = get_non_root_user()
249 create_cmd = "getent passwd " + default_user + " >/dev/null"
250 create_cmd += " || useradd -m -U " + default_user
251
252 # Create default user if doesn't exist.
253 self._internal_execute_run(
254 instance, instance_name, ["sh", "-c", create_cmd], check=True
255 )
256
257 # Give ownership of the project directory to _lpci
258 self._internal_execute_run(
259 instance,
260 instance_name,
261 [
262 "chown",
263 "-R",
264 f"{default_user}:{default_user}",
265 str(get_managed_environment_project_path()),
266 ],
267 check=True,
268 )
269
241 @contextmanager270 @contextmanager
242 def launched_environment(271 def launched_environment(
243 self,272 self,
@@ -247,6 +276,7 @@ class LXDProvider(Provider):
247 series: str,276 series: str,
248 architecture: str,277 architecture: str,
249 gpu_nvidia: bool = False,278 gpu_nvidia: bool = False,
279 root: bool = True,
250 ) -> Generator[lxd.LXDInstance, None, None]:280 ) -> Generator[lxd.LXDInstance, None, None]:
251 """Launch environment for specified series and architecture.281 """Launch environment for specified series and architecture.
252282
@@ -354,6 +384,10 @@ class LXDProvider(Provider):
354 ["rm", "-f", "/usr/local/sbin/policy-rc.d"],384 ["rm", "-f", "/usr/local/sbin/policy-rc.d"],
355 check=True,385 check=True,
356 )386 )
387 if not root:
388 self._set_up_non_root_user(
389 instance=instance, instance_name=instance_name
390 )
357 except subprocess.CalledProcessError as error:391 except subprocess.CalledProcessError as error:
358 raise CommandError(str(error)) from error392 raise CommandError(str(error)) from error
359 finally:393 finally:
diff --git a/lpci/providers/tests/test_lxd.py b/lpci/providers/tests/test_lxd.py
index 0a4f803..0e9b439 100644
--- a/lpci/providers/tests/test_lxd.py
+++ b/lpci/providers/tests/test_lxd.py
@@ -500,10 +500,151 @@ class TestLXDProvider(TestCase):
500 ),500 ),
501 call().lxc.exec(501 call().lxc.exec(
502 instance_name=expected_instance_name,502 instance_name=expected_instance_name,
503 command=["rm", "-f", "/usr/local/sbin/policy-rc.d"],
504 project="test-project",
505 remote="test-remote",
506 runner=subprocess.run,
507 check=True,
508 ),
509 call().unmount(target=Path("/root/tmp-project")),
510 ],
511 mock_launcher.mock_calls,
512 )
513 mock_launcher.reset_mock()
514
515 self.assertEqual(
516 [
517 call().lxc.exec(
518 instance_name=expected_instance_name,
519 command=["rm", "-rf", "/build/lpci/project"],
520 project="test-project",
521 remote="test-remote",
522 runner=subprocess.run,
523 check=True,
524 ),
525 call().unmount_all(),
526 call().stop(),
527 ],
528 mock_launcher.mock_calls,
529 )
530
531 @patch("os.environ", {"PATH": "not-using-host-path"})
532 def test_launched_environment_default_user(self):
533 expected_instance_name = "lpci-my-project-12345-focal-amd64"
534 mock_lxc = Mock(spec=LXC)
535 mock_lxc.profile_show.return_value = {
536 "config": {"sentinel": "true"},
537 "devices": {"eth0": {}},
538 }
539 mock_lxc.project_list.return_value = []
540 mock_lxc.remote_list.return_value = {}
541 mock_launcher = Mock(spec=launch)
542 provider = makeLXDProvider(lxc=mock_lxc, lxd_launcher=mock_launcher)
543
544 with provider.launched_environment(
545 project_name="my-project",
546 project_path=self.mock_path,
547 series="focal",
548 architecture="amd64",
549 root=False,
550 ) as instance:
551 self.assertIsNotNone(instance)
552 mock_lxc.project_list.assert_called_once_with("test-remote")
553 mock_lxc.project_create.assert_called_once_with(
554 project="test-project", remote="test-remote"
555 )
556 mock_lxc.profile_show.assert_called_once_with(
557 profile="default", project="default", remote="test-remote"
558 )
559 mock_lxc.profile_edit.assert_called_once_with(
560 profile="default",
561 config={
562 "config": {"sentinel": "true"},
563 "devices": {"eth0": {}},
564 },
565 project="test-project",
566 remote="test-remote",
567 )
568 self.assertEqual(
569 [
570 call(
571 name=expected_instance_name,
572 base_configuration=LPCIBuilddBaseConfiguration(
573 alias=BuilddBaseAlias.FOCAL,
574 environment={"PATH": _base_path},
575 hostname=expected_instance_name,
576 ),
577 image_name="focal",
578 image_remote="craft-com.ubuntu.cloud-buildd",
579 auto_clean=True,
580 auto_create_project=True,
581 map_user_uid=True,
582 use_base_instance=True,
583 project="test-project",
584 remote="test-remote",
585 lxc=mock_lxc,
586 ),
587 call().mount(
588 host_source=self.mock_path,
589 target=Path("/root/tmp-project"),
590 ),
591 call().lxc.exec(
592 instance_name=expected_instance_name,
593 command=["rm", "-rf", "/build/lpci/project"],
594 project="test-project",
595 remote="test-remote",
596 runner=subprocess.run,
597 check=True,
598 ),
599 call().lxc.exec(
600 instance_name=expected_instance_name,
601 command=["mkdir", "-p", "/build/lpci"],
602 project="test-project",
603 remote="test-remote",
604 runner=subprocess.run,
605 check=True,
606 ),
607 call().lxc.exec(
608 instance_name=expected_instance_name,
503 command=[609 command=[
504 "rm",610 "cp",
505 "-f",611 "-a",
506 "/usr/local/sbin/policy-rc.d",612 "/root/tmp-project",
613 "/build/lpci/project",
614 ],
615 project="test-project",
616 remote="test-remote",
617 runner=subprocess.run,
618 check=True,
619 ),
620 call().lxc.exec(
621 instance_name=expected_instance_name,
622 command=["rm", "-f", "/usr/local/sbin/policy-rc.d"],
623 project="test-project",
624 remote="test-remote",
625 runner=subprocess.run,
626 check=True,
627 ),
628 call().lxc.exec(
629 instance_name=expected_instance_name,
630 command=[
631 "sh",
632 "-c",
633 "getent passwd _lpci >/dev/null"
634 + " || useradd -m -U _lpci",
635 ],
636 project="test-project",
637 remote="test-remote",
638 runner=subprocess.run,
639 check=True,
640 ),
641 call().lxc.exec(
642 instance_name=expected_instance_name,
643 command=[
644 "chown",
645 "-R",
646 "_lpci:_lpci",
647 "/build/lpci/project",
507 ],648 ],
508 project="test-project",649 project="test-project",
509 remote="test-remote",650 remote="test-remote",
diff --git a/lpci/tests/test_config.py b/lpci/tests/test_config.py
index 7bf45a5..17cc308 100644
--- a/lpci/tests/test_config.py
+++ b/lpci/tests/test_config.py
@@ -848,3 +848,46 @@ class TestConfig(TestCase):
848 Config.load,848 Config.load,
849 path,849 path,
850 )850 )
851
852 def test_root_value(self):
853 path = self.create_config(
854 dedent(
855 """
856 pipeline:
857 - test
858
859 jobs:
860 test:
861 root: True
862 series: focal
863 architectures: amd64
864 """
865 )
866 )
867 config = Config.load(path)
868 root_value = config.jobs["test"][0].root
869 self.assertEqual(True, root_value)
870
871 def test_bad_root_value(self):
872 path = self.create_config(
873 dedent(
874 """
875 pipeline:
876 - test
877
878 jobs:
879 test:
880 root: bad_value
881 series: focal
882 architectures: amd64
883 """
884 )
885 )
886 self.assertRaisesRegex(
887 ValidationError,
888 "You configured `root` parameter, "
889 + "but you did not specify a valid value. "
890 + "Valid values would either be `true` or `false`.",
891 Config.load,
892 path,
893 )
diff --git a/lpci/tests/test_env.py b/lpci/tests/test_env.py
index 58b69f1..7079481 100644
--- a/lpci/tests/test_env.py
+++ b/lpci/tests/test_env.py
@@ -9,6 +9,9 @@ from lpci import env
99
1010
11class TestEnvironment(TestCase):11class TestEnvironment(TestCase):
12 def test_get_non_root_user(self):
13 self.assertEqual("_lpci", env.get_non_root_user())
14
12 def test_get_managed_environment_home_path(self):15 def test_get_managed_environment_home_path(self):
13 self.assertEqual(16 self.assertEqual(
14 Path("/root"), env.get_managed_environment_home_path()17 Path("/root"), env.get_managed_environment_home_path()

Subscribers

People subscribed via source and target branches