← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pelpsi/lpci:jobs-cannot-run-as-root into lpci:main

 

Simone Pelosi has proposed merging ~pelpsi/lpci:jobs-cannot-run-as-root into lpci:main.

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


Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1982954 in lpci: "Some jobs cannot run as root"
  https://bugs.launchpad.net/lpci/+bug/1982954

For more details, see:
https://code.launchpad.net/~pelpsi/lpci/+git/lpcraft/+merge/441539
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pelpsi/lpci:jobs-cannot-run-as-root into lpci:main.
diff --git a/NEWS.rst b/NEWS.rst
index ba2012b..45f0dde 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -9,6 +9,8 @@ Version history
 - Fix ``policy-rc.d`` issue preventing services
   from running into the container.
 
+- Add a ``root`` flag.
+
 0.1.0 (2023-04-18)
 ==================
 
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 65b0b5d..db9f589 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -61,6 +61,10 @@ Job definitions
     `/etc/apt/sources.list`.
     Also see the :ref:`package-repositories` section below.
 
+``root`` (optional)
+    If False, allow running commands as default user (_lpci).
+    Default value: True.
+
 ``snaps`` (optional)
     Snaps to install as dependencies of this job.
     Also see the :ref:`snap-properties` section below.
diff --git a/lpci/commands/run.py b/lpci/commands/run.py
index d6c65b2..ef29525 100644
--- a/lpci/commands/run.py
+++ b/lpci/commands/run.py
@@ -433,8 +433,14 @@ def _run_instance_command(
     host_architecture: str,
     remote_cwd: Path,
     environment: Optional[Dict[str, Optional[str]]],
+    root: bool = True,
 ) -> None:
     full_run_cmd = ["bash", "--noprofile", "--norc", "-ec", command]
+    if not root:
+        prefix = ["runuser", "-u", env.get_default_user(), "--"]
+        prefix.extend(full_run_cmd)
+        full_run_cmd = prefix
+
     emit.progress("Running command for the job...")
     original_mode = emit.get_mode()
     if original_mode == EmitterMode.BRIEF:
@@ -475,6 +481,11 @@ def _run_job(
     # XXX jugmac00 2022-04-27: we should create a configuration object to be
     # passed in and not so many arguments
     job = config.jobs[job_name][job_index]
+    root = job.root
+
+    # workaround necessary to please coverage
+    assert isinstance(root, bool)
+
     host_architecture = get_host_architecture()
     if host_architecture not in job.architectures:
         return
@@ -540,6 +551,7 @@ def _run_job(
         series=job.series,
         architecture=host_architecture,
         gpu_nvidia=gpu_nvidia,
+        root=root,
     ) as instance:
         snaps = list(itertools.chain(*pm.hook.lpci_install_snaps()))
         for snap in snaps:
@@ -583,6 +595,7 @@ def _run_job(
                     host_architecture=host_architecture,
                     remote_cwd=remote_cwd,
                     environment=environment,
+                    root=root,
                 )
         if config.license:
             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):
             remote="test-remote",
         )
 
+    @patch("lpci.commands.run.get_provider")
+    @patch("lpci.commands.run.get_host_architecture", return_value="amd64")
+    def test_root_field(self, mock_get_host_architecture, mock_get_provider):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess("_lpci", 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    root: False
+                    series: focal
+                    architectures: amd64
+                    run: whoami
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run")
+
+        self.assertEqual(0, result.exit_code)
+        self.assertEqual(
+            [
+                call(
+                    [
+                        "runuser",
+                        "-u",
+                        "_lpci",
+                        "--",
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "whoami",
+                    ],
+                    cwd=Path("/build/lpci/project"),
+                    env={},
+                    stdout=ANY,
+                    stderr=ANY,
+                )
+            ],
+            execute_run.call_args_list,
+        )
+
 
 class TestRunOne(RunBaseTestCase):
     def test_config_file_not_under_project_directory(self):
diff --git a/lpci/config.py b/lpci/config.py
index 8e3f77a..6eda475 100644
--- a/lpci/config.py
+++ b/lpci/config.py
@@ -290,6 +290,7 @@ class Job(ModelConfigDefaults):
     package_repositories: List[PackageRepository] = []
     plugin: Optional[StrictStr]
     plugin_config: Optional[BaseConfig]
+    root: Optional[bool] = True
 
     @pydantic.validator("architectures", pre=True)
     def validate_architectures(
@@ -299,6 +300,16 @@ class Job(ModelConfigDefaults):
             v = [v]
         return v
 
+    @pydantic.validator("root", pre=True)
+    def validate_root(cls, v: Any) -> Any:
+        if type(v) is not bool or v is None:
+            raise ValueError(
+                "You configured `root` parameter, "
+                + "but you did not specify a valid value. "
+                + "Valid values would either be `True` or `False`."
+            )
+        return v
+
     @pydantic.validator("snaps", pre=True)
     def validate_snaps(cls, v: List[Any]) -> Any:
         clean_values = []
diff --git a/lpci/env.py b/lpci/env.py
index cfc7331..2490fdf 100644
--- a/lpci/env.py
+++ b/lpci/env.py
@@ -6,6 +6,10 @@
 from pathlib import Path
 
 
+def get_default_user() -> str:
+    return "_lpci"
+
+
 def get_managed_environment_home_path() -> Path:
     """Path for home when running in managed environment."""
     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):
         series: str,
         architecture: str,
         gpu_nvidia: bool = False,
+        root: bool = False,
     ) -> Generator[lxd.LXDInstance, None, None]:
         """Launch environment for specified series and architecture.
 
diff --git a/lpci/providers/_lxd.py b/lpci/providers/_lxd.py
index 1a20c92..5a0e8b2 100644
--- a/lpci/providers/_lxd.py
+++ b/lpci/providers/_lxd.py
@@ -18,6 +18,7 @@ from craft_providers import Base, bases, lxd
 from pydantic import StrictStr
 
 from lpci.env import (
+    get_default_user,
     get_managed_environment_home_path,
     get_managed_environment_project_path,
 )
@@ -238,6 +239,34 @@ class LXDProvider(Provider):
             **kwargs,
         )
 
+    def _create_default_user(
+        self,
+        instance: lxd.LXDInstance,
+        instance_name: str,
+    ) -> None:
+
+        default_user = get_default_user()
+        create_cmd = "getent passwd " + default_user + " >/dev/null"
+        create_cmd += " || useradd -m -U " + default_user
+
+        # Create default user if doesn't exist.
+        self._internal_execute_run(
+            instance, instance_name, ["sh", "-c", create_cmd], check=True
+        )
+
+        # Drop privileges to _lpci
+        self._internal_execute_run(
+            instance,
+            instance_name,
+            [
+                "chown",
+                "-R",
+                default_user,
+                str(get_managed_environment_project_path()),
+            ],
+            check=True,
+        )
+
     @contextmanager
     def launched_environment(
         self,
@@ -247,6 +276,7 @@ class LXDProvider(Provider):
         series: str,
         architecture: str,
         gpu_nvidia: bool = False,
+        root: bool = True,
     ) -> Generator[lxd.LXDInstance, None, None]:
         """Launch environment for specified series and architecture.
 
@@ -354,6 +384,10 @@ class LXDProvider(Provider):
                     ["rm", "-f", "/usr/local/sbin/policy-rc.d"],
                     check=True,
                 )
+                if not root:
+                    self._create_default_user(
+                        instance=instance, instance_name=instance_name
+                    )
             except subprocess.CalledProcessError as error:
                 raise CommandError(str(error)) from error
             finally:
diff --git a/lpci/providers/tests/test_lxd.py b/lpci/providers/tests/test_lxd.py
index 0a4f803..4957410 100644
--- a/lpci/providers/tests/test_lxd.py
+++ b/lpci/providers/tests/test_lxd.py
@@ -500,10 +500,151 @@ class TestLXDProvider(TestCase):
                     ),
                     call().lxc.exec(
                         instance_name=expected_instance_name,
+                        command=["rm", "-f", "/usr/local/sbin/policy-rc.d"],
+                        project="test-project",
+                        remote="test-remote",
+                        runner=subprocess.run,
+                        check=True,
+                    ),
+                    call().unmount(target=Path("/root/tmp-project")),
+                ],
+                mock_launcher.mock_calls,
+            )
+            mock_launcher.reset_mock()
+
+        self.assertEqual(
+            [
+                call().lxc.exec(
+                    instance_name=expected_instance_name,
+                    command=["rm", "-rf", "/build/lpci/project"],
+                    project="test-project",
+                    remote="test-remote",
+                    runner=subprocess.run,
+                    check=True,
+                ),
+                call().unmount_all(),
+                call().stop(),
+            ],
+            mock_launcher.mock_calls,
+        )
+
+    @patch("os.environ", {"PATH": "not-using-host-path"})
+    def test_launched_environment_default_user(self):
+        expected_instance_name = "lpci-my-project-12345-focal-amd64"
+        mock_lxc = Mock(spec=LXC)
+        mock_lxc.profile_show.return_value = {
+            "config": {"sentinel": "true"},
+            "devices": {"eth0": {}},
+        }
+        mock_lxc.project_list.return_value = []
+        mock_lxc.remote_list.return_value = {}
+        mock_launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxc=mock_lxc, lxd_launcher=mock_launcher)
+
+        with provider.launched_environment(
+            project_name="my-project",
+            project_path=self.mock_path,
+            series="focal",
+            architecture="amd64",
+            root=False,
+        ) as instance:
+            self.assertIsNotNone(instance)
+            mock_lxc.project_list.assert_called_once_with("test-remote")
+            mock_lxc.project_create.assert_called_once_with(
+                project="test-project", remote="test-remote"
+            )
+            mock_lxc.profile_show.assert_called_once_with(
+                profile="default", project="default", remote="test-remote"
+            )
+            mock_lxc.profile_edit.assert_called_once_with(
+                profile="default",
+                config={
+                    "config": {"sentinel": "true"},
+                    "devices": {"eth0": {}},
+                },
+                project="test-project",
+                remote="test-remote",
+            )
+            self.assertEqual(
+                [
+                    call(
+                        name=expected_instance_name,
+                        base_configuration=LPCIBuilddBaseConfiguration(
+                            alias=BuilddBaseAlias.FOCAL,
+                            environment={"PATH": _base_path},
+                            hostname=expected_instance_name,
+                        ),
+                        image_name="focal",
+                        image_remote="craft-com.ubuntu.cloud-buildd",
+                        auto_clean=True,
+                        auto_create_project=True,
+                        map_user_uid=True,
+                        use_base_instance=True,
+                        project="test-project",
+                        remote="test-remote",
+                        lxc=mock_lxc,
+                    ),
+                    call().mount(
+                        host_source=self.mock_path,
+                        target=Path("/root/tmp-project"),
+                    ),
+                    call().lxc.exec(
+                        instance_name=expected_instance_name,
+                        command=["rm", "-rf", "/build/lpci/project"],
+                        project="test-project",
+                        remote="test-remote",
+                        runner=subprocess.run,
+                        check=True,
+                    ),
+                    call().lxc.exec(
+                        instance_name=expected_instance_name,
+                        command=["mkdir", "-p", "/build/lpci"],
+                        project="test-project",
+                        remote="test-remote",
+                        runner=subprocess.run,
+                        check=True,
+                    ),
+                    call().lxc.exec(
+                        instance_name=expected_instance_name,
                         command=[
-                            "rm",
-                            "-f",
-                            "/usr/local/sbin/policy-rc.d",
+                            "cp",
+                            "-a",
+                            "/root/tmp-project",
+                            "/build/lpci/project",
+                        ],
+                        project="test-project",
+                        remote="test-remote",
+                        runner=subprocess.run,
+                        check=True,
+                    ),
+                    call().lxc.exec(
+                        instance_name=expected_instance_name,
+                        command=["rm", "-f", "/usr/local/sbin/policy-rc.d"],
+                        project="test-project",
+                        remote="test-remote",
+                        runner=subprocess.run,
+                        check=True,
+                    ),
+                    call().lxc.exec(
+                        instance_name=expected_instance_name,
+                        command=[
+                            "sh",
+                            "-c",
+                            "getent passwd _lpci >/dev/null"
+                            + " || useradd -m -U _lpci",
+                        ],
+                        project="test-project",
+                        remote="test-remote",
+                        runner=subprocess.run,
+                        check=True,
+                    ),
+                    call().lxc.exec(
+                        instance_name=expected_instance_name,
+                        command=[
+                            "chown",
+                            "-R",
+                            "_lpci",
+                            "/build/lpci/project",
                         ],
                         project="test-project",
                         remote="test-remote",
diff --git a/lpci/tests/test_config.py b/lpci/tests/test_config.py
index 7bf45a5..5b06ea3 100644
--- a/lpci/tests/test_config.py
+++ b/lpci/tests/test_config.py
@@ -848,3 +848,46 @@ class TestConfig(TestCase):
             Config.load,
             path,
         )
+
+    def test_root_value(self):
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                jobs:
+                    test:
+                        root: True
+                        series: focal
+                        architectures: amd64
+                """
+            )
+        )
+        config = Config.load(path)
+        root_value = config.jobs["test"][0].root
+        self.assertEqual(True, root_value)
+
+    def test_bad_root_value(self):
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                jobs:
+                    test:
+                        root: bad_value
+                        series: focal
+                        architectures: amd64
+                """
+            )
+        )
+        self.assertRaisesRegex(
+            ValidationError,
+            "You configured `root` parameter, "
+            + "but you did not specify a valid value. "
+            + "Valid values would either be `True` or `False`.",
+            Config.load,
+            path,
+        )
diff --git a/lpci/tests/test_env.py b/lpci/tests/test_env.py
index 58b69f1..8bf4c62 100644
--- a/lpci/tests/test_env.py
+++ b/lpci/tests/test_env.py
@@ -9,6 +9,9 @@ from lpci import env
 
 
 class TestEnvironment(TestCase):
+    def test_get_default_user(self):
+        self.assertEqual("_lpci", env.get_default_user())
+
     def test_get_managed_environment_home_path(self):
         self.assertEqual(
             Path("/root"), env.get_managed_environment_home_path()