Merge lp:~smoser/curtin/trunk.unshare-chroot into lp:~curtin-dev/curtin/trunk

Proposed by Scott Moser
Status: Merged
Merged at revision: 506
Proposed branch: lp:~smoser/curtin/trunk.unshare-chroot
Merge into: lp:~curtin-dev/curtin/trunk
Diff against target: 317 lines (+199/-24)
3 files modified
curtin/commands/apt_config.py (+0/-9)
curtin/util.py (+85/-13)
tests/unittests/test_util.py (+114/-2)
To merge this branch: bzr merge lp:~smoser/curtin/trunk.unshare-chroot
Reviewer Review Type Date Requested Status
Chad Smith Approve
Server Team CI bot continuous-integration Approve
curtin developers Pending
Review via email: mp+323596@code.launchpad.net

Commit message

use unshare to put chroot commands in own pid namespace.

Bug 1645680 showed problems when a process started detached and
was left running. This starts processes in a chroot in their own
pid namespace, so that the process is pid 1. Then, its children
are killed when it exits.

Also, backed out the work around changes made for 1645680.

To post a comment you must log in.
Revision history for this message
Scott Moser (smoser) wrote :
Revision history for this message
Scott Moser (smoser) wrote :

This does need unit test additions fixes, and probably even fails unit tests at this point.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Ryan Harper (raharper) :
Revision history for this message
Scott Moser (smoser) wrote :

Most of the complexity here is in trying to default to unshare_pid=None,
and having that do the right thing, which means
  a.) if i'm root
  b.) if target is not /
  c.) i have the unshare command with --fork and --pid, which does not exist on older versions o f ubuntu

Then there is some pain to avoid recursion in the subp call for 'unshare --help' to have it avoid recursing.

That complexity seems necessary, otherwise we have to change all the callers to call with unshare_pid=True only when they can.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

Much nicer now, but still needs some unit tests.
I've started vmtest run at
 https://jenkins.ubuntu.com/server/view/Curtin/job/curtin-vmtest-devel-debug/45/

I have high hopes at the moment.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Ryan Harper (raharper) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

replies inline.

Revision history for this message
Scott Moser (smoser) wrote :

I guess the issue with only calling it in specific cases is it is just not easy to do there.
as we'd have to either
a.) have the caller determine "can i use unshare?" and then pass it in if they want it.
b.) default to False and provide some way to indicate "use if you can".

I guess 'b' would equate to the current branch but with the default to 'false' and the 'apt' case explciitly passing in 'None'. or we could make some nicer name. util.UNSHARE_AUTO or something.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

This looks clearer with the separate methods for detecting and then unshare arg building.

w.r.t the policy; I think this is similar to the strict mode we use. Ideally we always use unshare pid for chroot, if available; we have a few cases where we *know* we require it and we have a workaround if it's not present.

I wonder if we could detect if it's available, and if not (and we're in strict=True) then apply the fallback?

OTOH, do we know if any supported release where we won't have unshare pid support available and that we also require the workarounds (apt is the only case I'm aware of)?

Revision history for this message
Scott Moser (smoser) wrote :

> OTOH, do we know if any supported release where we won't have unshare pid
> support available and that we also require the workarounds (apt is the only
> case I'm aware of)?

'unshare --fork --pid' should work in xenial and beyond.
yakkety+ is where we explicitly need the apt fix.

So we end up using unshare on xenial and newer whenever we have target != /.
And we do *not* use it wherever target == / or precise or trusty.

500. By Scott Moser

merge with trunk

501. By Scott Moser

merge with unshare-chroot branch

502. By Scott Moser

add unit tests

503. By Scott Moser

pep8

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

https://jenkins.ubuntu.com/server/view/Curtin/job/curtin-vmtest-devel-debug/78/

vmtest run at ^.

I would like more review on this, i think its sane at this point.

Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Chad Smith (chad.smith) wrote :

Some minor nits inline. Looks good learned a bit about unshare today.

Revision history for this message
Scott Moser (smoser) :
504. By Scott Moser

address feedback

- push subp through taking a string as input.
- push subp through taking a tuple as input.
- add a docstring

505. By Scott Moser

address feedback wrt self.expected_on and self.expected_off

506. By Scott Moser

address feedback drop some code in test_unshare_pid_true_and_not_root_raises

507. By Scott Moser

test_euid0_target_not_slash: use only by kwargs

Revision history for this message
Scott Moser (smoser) wrote :

Chad,
I think I addressed all your feedback. I did leave the assignment to False.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

+1 thanks for addressing the review comments.

review: Approve
Revision history for this message
Ryan Harper (raharper) wrote :

I've started another vmtest run, we don't have a successful run of this branch due to the port issue which is now fixed in trunk,

https://jenkins.ubuntu.com/server/view/Curtin/job/curtin-vmtest-devel-debug/80/

If that passes, I'll push that merge branch to trunk.

Revision history for this message
Chad Smith (chad.smith) wrote :

Looks all green per the jenkins vmtest run.

Revision history for this message
Scott Moser (smoser) wrote :

I merged and pushed.
revno 506.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'curtin/commands/apt_config.py'
2--- curtin/commands/apt_config.py 2017-02-09 17:55:33 +0000
3+++ curtin/commands/apt_config.py 2017-06-09 20:54:37 +0000
4@@ -24,7 +24,6 @@
5 import os
6 import re
7 import sys
8-import time
9 import yaml
10
11 from curtin.log import LOG
12@@ -406,20 +405,12 @@
13 if aa_repo_match(source):
14 with util.ChrootableTarget(
15 target, sys_resolvconf=True) as in_chroot:
16- time_entered = time.time()
17 try:
18 in_chroot.subp(["add-apt-repository", source],
19 retries=(1, 2, 5, 10))
20 except util.ProcessExecutionError:
21 LOG.exception("add-apt-repository failed.")
22 raise
23- finally:
24- # workaround to gnupg >=2.x spawning daemons (LP: #1645680)
25- seconds_since = time.time() - time_entered + 1
26- in_chroot.subp(['killall', '--wait', '--quiet',
27- '--younger-than', '%ds' % seconds_since,
28- '--regexp', '(dirmngr|gpg-agent)'],
29- rcs=[0, 1])
30 continue
31
32 sourcefn = util.target_path(target, ent['filename'])
33
34=== modified file 'curtin/util.py'
35--- curtin/util.py 2017-05-26 18:27:22 +0000
36+++ curtin/util.py 2017-06-09 20:54:37 +0000
37@@ -57,6 +57,7 @@
38 _INSTALLED_MAIN = '/usr/bin/curtin'
39
40 _LSB_RELEASE = {}
41+_HAS_UNSHARE_PID = None
42
43 _DNS_REDIRECT_IP = None
44
45@@ -66,21 +67,31 @@
46
47 def _subp(args, data=None, rcs=None, env=None, capture=False,
48 shell=False, logstring=False, decode="replace",
49- target=None, cwd=None, log_captured=False):
50+ target=None, cwd=None, log_captured=False, unshare_pid=None):
51 if rcs is None:
52 rcs = [0]
53-
54 devnull_fp = None
55- try:
56- if target_path(target) != "/":
57- args = ['chroot', target] + list(args)
58-
59- if not logstring:
60- LOG.debug(("Running command %s with allowed return codes %s"
61- " (shell=%s, capture=%s)"), args, rcs, shell, capture)
62- else:
63- LOG.debug(("Running hidden command to protect sensitive "
64- "input/output logstring: %s"), logstring)
65+
66+ tpath = target_path(target)
67+ chroot_args = [] if tpath == "/" else ['chroot', target]
68+ sh_args = ['sh', '-c'] if shell else []
69+ if isinstance(args, string_types):
70+ args = [args]
71+
72+ try:
73+ unshare_args = _get_unshare_pid_args(unshare_pid, tpath)
74+ except RuntimeError as e:
75+ raise RuntimeError("Unable to unshare pid (cmd=%s): %s" % (args, e))
76+
77+ args = unshare_args + chroot_args + sh_args + list(args)
78+
79+ if not logstring:
80+ LOG.debug(("Running command %s with allowed return codes %s"
81+ " (capture=%s)"), args, rcs, capture)
82+ else:
83+ LOG.debug(("Running hidden command to protect sensitive "
84+ "input/output logstring: %s"), logstring)
85+ try:
86 stdin = None
87 stdout = None
88 stderr = None
89@@ -94,7 +105,7 @@
90 stdin = subprocess.PIPE
91 sp = subprocess.Popen(args, stdout=stdout,
92 stderr=stderr, stdin=stdin,
93- env=env, shell=shell, cwd=cwd)
94+ env=env, shell=False, cwd=cwd)
95 # communicate in python2 returns str, python3 returns bytes
96 (out, err) = sp.communicate(data)
97
98@@ -128,6 +139,63 @@
99 return (out, err)
100
101
102+def _has_unshare_pid():
103+ global _HAS_UNSHARE_PID
104+ if _HAS_UNSHARE_PID is not None:
105+ return _HAS_UNSHARE_PID
106+
107+ if not which('unshare'):
108+ _HAS_UNSHARE_PID = False
109+ return False
110+ out, err = subp(["unshare", "--help"], capture=True, decode=False,
111+ unshare_pid=False)
112+ joined = b'\n'.join([out, err])
113+ _HAS_UNSHARE_PID = b'--fork' in joined and b'--pid' in joined
114+ return _HAS_UNSHARE_PID
115+
116+
117+def _get_unshare_pid_args(unshare_pid=None, target=None, euid=None):
118+ """Get args for calling unshare for a pid.
119+
120+ If unshare_pid is False, return empty list.
121+ If unshare_pid is True, check if it is usable. If not, raise exception.
122+ if unshare_pid is None, then unshare if
123+ * euid is 0
124+ * 'unshare' with '--fork' and '--pid' is available.
125+ * target != /
126+ """
127+ if unshare_pid is not None and not unshare_pid:
128+ # given a false-ish other than None means no.
129+ return []
130+
131+ if euid is None:
132+ euid = os.geteuid()
133+
134+ tpath = target_path(target)
135+
136+ unshare_pid_in = unshare_pid
137+ if unshare_pid is None:
138+ unshare_pid = False
139+ if tpath != "/" and euid == 0:
140+ if _has_unshare_pid():
141+ unshare_pid = True
142+
143+ if not unshare_pid:
144+ return []
145+
146+ # either unshare was passed in as True, or None and turned to True.
147+ if euid != 0:
148+ raise RuntimeError(
149+ "given unshare_pid=%s but euid (%s) != 0." %
150+ (unshare_pid_in, euid))
151+
152+ if not _has_unshare_pid():
153+ raise RuntimeError(
154+ "given unshare_pid=%s but no unshare command." % unshare_pid_in)
155+
156+ return ['unshare', '--fork', '--pid', '--']
157+
158+
159 def subp(*args, **kwargs):
160 """Run a subprocess.
161
162@@ -160,6 +228,10 @@
163 means to run, sleep 1, run, sleep 3, run and then return exit code.
164 :param target:
165 run the command as 'chroot target <args>'
166+ :param unshare_pid:
167+ unshare the pid namespace.
168+ default value (None) is to unshare pid namespace if possible
169+ and target != /
170
171 :return
172 if not capturing, return is (None, None)
173
174=== modified file 'tests/unittests/test_util.py'
175--- tests/unittests/test_util.py 2017-05-12 14:35:53 +0000
176+++ tests/unittests/test_util.py 2017-06-09 20:54:37 +0000
177@@ -160,6 +160,13 @@
178 decode_type = str
179 nodecode_type = bytes
180
181+ def setUp(self):
182+ super(TestSubp, self).setUp()
183+ mock_getunsh = mock.patch(
184+ "curtin.util._get_unshare_pid_args", return_value=[])
185+ self.mock_get_unshare_pid_args = mock_getunsh.start()
186+ self.addCleanup(mock_getunsh.stop)
187+
188 def printf_cmd(self, *args):
189 # bash's printf supports \xaa. So does /usr/bin/printf
190 # but by using bash, we remove dependency on another program.
191@@ -296,12 +303,29 @@
192 calls = m_popen.call_args_list
193 popen_args, popen_kwargs = calls[-1]
194 target = util.target_path(kwargs.get('target', None))
195+ unshcmd = self.mock_get_unshare_pid_args.return_value
196 if target == "/":
197- self.assertEqual(cmd, popen_args[0])
198+ self.assertEqual(unshcmd + list(cmd), popen_args[0])
199 else:
200- self.assertEqual(['chroot', target] + list(cmd), popen_args[0])
201+ self.assertEqual(unshcmd + ['chroot', target] + list(cmd),
202+ popen_args[0])
203 return calls
204
205+ def test_args_can_be_a_tuple(self):
206+ """subp can take a tuple for cmd rather than a list."""
207+ my_cmd = tuple(['echo', 'hi', 'mom'])
208+ calls = self._subp_wrap_popen(my_cmd, {})
209+ args, kwargs = calls[0]
210+ # subp was called with cmd as a tuple. That may get converted to
211+ # a list before subprocess.popen. So only compare as lists.
212+ self.assertEqual(1, len(calls))
213+ self.assertEqual(list(my_cmd), list(args[0]))
214+
215+ def test_args_can_be_a_string(self):
216+ """subp("cat") is acceptable, as suprocess.call("cat") works fine."""
217+ out, err = util.subp("cat", data=b'hi mom', capture=True, decode=False)
218+ self.assertEqual(b'hi mom', out)
219+
220 def test_with_target_gets_chroot(self):
221 args, kwargs = self._subp_wrap_popen(["my-command"],
222 {'target': "/mytarget"})[0]
223@@ -342,6 +366,94 @@
224 # since we fail a few times, it needs to have been called again.
225 self.assertEqual(len(r), len(rcs))
226
227+ def test_unshare_pid_return_is_used(self):
228+ """The return of _get_unshare_pid_return needs to be in command."""
229+ my_unshare_cmd = ['do-unshare-command', 'arg0', 'arg1', '--']
230+ self.mock_get_unshare_pid_args.return_value = my_unshare_cmd
231+ my_kwargs = {'target': '/target', 'unshare_pid': True}
232+ r = self._subp_wrap_popen(['apt-get', 'install'], my_kwargs)
233+ self.assertEqual(1, len(r))
234+ args, kwargs = r[0]
235+ self.assertEqual(
236+ [mock.call(my_kwargs['unshare_pid'], my_kwargs['target'])],
237+ self.mock_get_unshare_pid_args.call_args_list)
238+ expected = (my_unshare_cmd + ['chroot', '/target'] +
239+ ['apt-get', 'install'])
240+ self.assertEqual(expected, args[0])
241+
242+
243+class TestGetUnsharePidArgs(TestCase):
244+ """Test the internal implementation for when to unshare."""
245+
246+ def setUp(self):
247+ super(TestGetUnsharePidArgs, self).setUp()
248+ mock_hasunsh = mock.patch(
249+ "curtin.util._has_unshare_pid", return_value=True)
250+ self.mock_has_unshare_pid = mock_hasunsh.start()
251+ self.addCleanup(mock_hasunsh.stop)
252+ mock_geteuid = mock.patch(
253+ "curtin.util.os.geteuid", return_value=0)
254+ self.mock_geteuid = mock_geteuid.start()
255+ self.addCleanup(mock_geteuid.stop)
256+
257+ def assertOff(self, result):
258+ self.assertEqual([], result)
259+
260+ def assertOn(self, result):
261+ self.assertEqual(['unshare', '--fork', '--pid', '--'], result)
262+
263+ def test_unshare_pid_none_and_not_root_means_off(self):
264+ """If not root, then expect off."""
265+ self.assertOff(util._get_unshare_pid_args(None, "/foo", 500))
266+ self.assertOff(util._get_unshare_pid_args(None, "/", 500))
267+
268+ self.mock_geteuid.return_value = 500
269+ self.assertOff(util._get_unshare_pid_args(None, "/"))
270+ self.assertOff(
271+ util._get_unshare_pid_args(unshare_pid=None, target="/foo"))
272+
273+ def test_unshare_pid_none_and_no_unshare_pid_means_off(self):
274+ """No unshare support and unshare_pid is None means off."""
275+ self.mock_has_unshare_pid.return_value = False
276+ self.assertOff(util._get_unshare_pid_args(None, "/target", 0))
277+
278+ def test_unshare_pid_true_and_no_unshare_pid_raises(self):
279+ """Passing unshare_pid in as True and no command should raise."""
280+ self.mock_has_unshare_pid.return_value = False
281+ expected_msg = 'no unshare command'
282+ with self.assertRaisesRegexp(RuntimeError, expected_msg):
283+ util._get_unshare_pid_args(True)
284+
285+ with self.assertRaisesRegexp(RuntimeError, expected_msg):
286+ util._get_unshare_pid_args(True, "/foo", 0)
287+
288+ def test_unshare_pid_true_and_not_root_raises(self):
289+ """When unshare_pid is True for non-root an error is raised."""
290+ expected_msg = 'euid.* != 0'
291+ with self.assertRaisesRegexp(RuntimeError, expected_msg):
292+ util._get_unshare_pid_args(True, "/foo", 500)
293+
294+ self.mock_geteuid.return_value = 500
295+ with self.assertRaisesRegexp(RuntimeError, expected_msg):
296+ util._get_unshare_pid_args(True)
297+
298+ def test_euid0_target_not_slash(self):
299+ """If root and target is not /, then expect on."""
300+ self.assertOn(util._get_unshare_pid_args(None, target="/foo", euid=0))
301+
302+ def test_euid0_target_slash(self):
303+ """If root and target is /, then expect off."""
304+ self.assertOff(util._get_unshare_pid_args(None, "/", 0))
305+ self.assertOff(util._get_unshare_pid_args(None, target=None, euid=0))
306+
307+ def test_unshare_pid_of_false_means_off(self):
308+ """Any unshare_pid value false-ish other than None means no unshare."""
309+ self.assertOff(
310+ util._get_unshare_pid_args(unshare_pid=False, target=None))
311+ self.assertOff(util._get_unshare_pid_args(False, "/target", 1))
312+ self.assertOff(util._get_unshare_pid_args(False, "/", 0))
313+ self.assertOff(util._get_unshare_pid_args("", "/target", 0))
314+
315
316 class TestHuman2Bytes(TestCase):
317 GB = 1024 * 1024 * 1024

Subscribers

People subscribed via source and target branches