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
1diff --git a/NEWS.rst b/NEWS.rst
2index 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
14diff --git a/docs/configuration.rst b/docs/configuration.rst
15index 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.
31diff --git a/lpci/commands/run.py b/lpci/commands/run.py
32index 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:
76diff --git a/lpci/commands/tests/test_run.py b/lpci/commands/tests/test_run.py
77index 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):
136diff --git a/lpci/config.py b/lpci/config.py
137index 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 = []
165diff --git a/lpci/env.py b/lpci/env.py
166index 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")
180diff --git a/lpci/providers/_base.py b/lpci/providers/_base.py
181index 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
192diff --git a/lpci/providers/_lxd.py b/lpci/providers/_lxd.py
193index 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:
258diff --git a/lpci/providers/tests/test_lxd.py b/lpci/providers/tests/test_lxd.py
259index 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",
417diff --git a/lpci/tests/test_config.py b/lpci/tests/test_config.py
418index 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+ )
468diff --git a/lpci/tests/test_env.py b/lpci/tests/test_env.py
469index 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()

Subscribers

People subscribed via source and target branches