Merge lp:~smoser/curtin/trunk.unshare-chroot into lp:~curtin-dev/curtin/trunk
- trunk.unshare-chroot
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Chad Smith | Approve | ||
Server Team CI bot | continuous-integration | Approve | |
curtin developers | Pending | ||
Review via email:
|
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.
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
This does need unit test additions fixes, and probably even fails unit tests at this point.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:499
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Ryan Harper (raharper) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:500
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
Much nicer now, but still needs some unit tests.
I've started vmtest run at
https:/
I have high hopes at the moment.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:502
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Ryan Harper (raharper) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
FAILED: Continuous integration, rev:503
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:504
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
replies inline.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:505
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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)?
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:503
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
https:/
vmtest run at ^.
I would like more review on this, i think its sane at this point.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chad Smith (chad.smith) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chad Smith (chad.smith) wrote : | # |
Some minor nits inline. Looks good learned a bit about unshare today.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
Chad,
I think I addressed all your feedback. I did leave the assignment to False.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:507
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chad Smith (chad.smith) wrote : | # |
+1 thanks for addressing the review comments.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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:/
If that passes, I'll push that merge branch to trunk.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chad Smith (chad.smith) wrote : | # |
Looks all green per the jenkins vmtest run.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Scott Moser (smoser) wrote : | # |
I merged and pushed.
revno 506.
Preview Diff
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 |
I started a jenkins vmtest run at https:/ /jenkins. ubuntu. com/server/ view/Curtin/ job/curtin- vmtest- devel-debug/ 39/console