|
1
|
+#
|
|
2
|
+# Copyright (C) 2017 Codethink Limited
|
|
3
|
+#
|
|
4
|
+# This program is free software; you can redistribute it and/or
|
|
5
|
+# modify it under the terms of the GNU Lesser General Public
|
|
6
|
+# License as published by the Free Software Foundation; either
|
|
7
|
+# version 2 of the License, or (at your option) any later version.
|
|
8
|
+#
|
|
9
|
+# This library is distributed in the hope that it will be useful,
|
|
10
|
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
12
|
+# Lesser General Public License for more details.
|
|
13
|
+#
|
|
14
|
+# You should have received a copy of the GNU Lesser General Public
|
|
15
|
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
|
|
16
|
+#
|
|
17
|
+# Authors:
|
|
18
|
+# Tristan Maat <tristan maat codethink co uk>
|
|
19
|
+# Tristan Van Berkom <tristan vanberkom codethink co uk>
|
|
20
|
+
|
|
21
|
+import os
|
|
22
|
+import sys
|
|
23
|
+import stat
|
|
24
|
+import signal
|
|
25
|
+import subprocess
|
|
26
|
+from contextlib import contextmanager, ExitStack
|
|
27
|
+import psutil
|
|
28
|
+
|
|
29
|
+from .._exceptions import SandboxError
|
|
30
|
+from .. import utils
|
|
31
|
+from .. import _signals
|
|
32
|
+from ._mounter import Mounter
|
|
33
|
+from ._mount import MountMap
|
|
34
|
+from . import Sandbox, SandboxFlags
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+class SandboxChroot(Sandbox):
|
|
38
|
+ def __init__(self, *args, **kwargs):
|
|
39
|
+ super().__init__(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+ uid = self._get_config().build_uid
|
|
42
|
+ gid = self._get_config().build_gid
|
|
43
|
+ if uid != 0 or gid != 0:
|
|
44
|
+ raise SandboxError("Chroot sandboxes cannot specify a non-root uid/gid "
|
|
45
|
+ "({},{} were supplied via config)".format(uid, gid))
|
|
46
|
+
|
|
47
|
+ self.mount_map = None
|
|
48
|
+
|
|
49
|
+ def run(self, command, flags, *, cwd=None, env=None):
|
|
50
|
+
|
|
51
|
+ # Default settings
|
|
52
|
+ if cwd is None:
|
|
53
|
+ cwd = self._get_work_directory()
|
|
54
|
+
|
|
55
|
+ if cwd is None:
|
|
56
|
+ cwd = '/'
|
|
57
|
+
|
|
58
|
+ if env is None:
|
|
59
|
+ env = self._get_environment()
|
|
60
|
+
|
|
61
|
+ # Naive getcwd implementations can break when bind-mounts to different
|
|
62
|
+ # paths on the same filesystem are present. Letting the command know
|
|
63
|
+ # what directory it is in makes it unnecessary to call the faulty
|
|
64
|
+ # getcwd.
|
|
65
|
+ env['PWD'] = cwd
|
|
66
|
+
|
|
67
|
+ if not self._has_command(command[0], env):
|
|
68
|
+ raise SandboxError("Staged artifacts do not provide command "
|
|
69
|
+ "'{}'".format(command[0]),
|
|
70
|
+ reason='missing-command')
|
|
71
|
+
|
|
72
|
+ # Command must be a list
|
|
73
|
+ if isinstance(command, str):
|
|
74
|
+ command = [command]
|
|
75
|
+
|
|
76
|
+ stdout, stderr = self._get_output()
|
|
77
|
+
|
|
78
|
+ # Create the mount map, this will tell us where
|
|
79
|
+ # each mount point needs to be mounted from and to
|
|
80
|
+ self.mount_map = MountMap(self, flags & SandboxFlags.ROOT_READ_ONLY)
|
|
81
|
+ root_mount_source = self.mount_map.get_mount_source('/')
|
|
82
|
+
|
|
83
|
+ # Create a sysroot and run the command inside it
|
|
84
|
+ with ExitStack() as stack:
|
|
85
|
+ os.makedirs('/var/run/buildstream', exist_ok=True)
|
|
86
|
+
|
|
87
|
+ # FIXME: While we do not currently do anything to prevent
|
|
88
|
+ # network access, we also don't copy /etc/resolv.conf to
|
|
89
|
+ # the new rootfs.
|
|
90
|
+ #
|
|
91
|
+ # This effectively disables network access, since DNs will
|
|
92
|
+ # never resolve, so anything a normal process wants to do
|
|
93
|
+ # will fail. Malicious processes could gain rights to
|
|
94
|
+ # anything anyway.
|
|
95
|
+ #
|
|
96
|
+ # Nonetheless a better solution could perhaps be found.
|
|
97
|
+
|
|
98
|
+ rootfs = stack.enter_context(utils._tempdir(dir='/var/run/buildstream'))
|
|
99
|
+ stack.enter_context(self.create_devices(self._root, flags))
|
|
100
|
+ stack.enter_context(self.mount_dirs(rootfs, flags, stdout, stderr))
|
|
101
|
+
|
|
102
|
+ if flags & SandboxFlags.INTERACTIVE:
|
|
103
|
+ stdin = sys.stdin
|
|
104
|
+ else:
|
|
105
|
+ stdin = stack.enter_context(open(os.devnull, 'r'))
|
|
106
|
+
|
|
107
|
+ # Ensure the cwd exists
|
|
108
|
+ if cwd is not None:
|
|
109
|
+ workdir = os.path.join(root_mount_source, cwd.lstrip(os.sep))
|
|
110
|
+ os.makedirs(workdir, exist_ok=True)
|
|
111
|
+
|
|
112
|
+ status = self.chroot(rootfs, command, stdin, stdout,
|
|
113
|
+ stderr, cwd, env, flags)
|
|
114
|
+
|
|
115
|
+ self._vdir._mark_changed()
|
|
116
|
+ return status
|
|
117
|
+
|
|
118
|
+ # chroot()
|
|
119
|
+ #
|
|
120
|
+ # A helper function to chroot into the rootfs.
|
|
121
|
+ #
|
|
122
|
+ # Args:
|
|
123
|
+ # rootfs (str): The path of the sysroot to chroot into
|
|
124
|
+ # command (list): The command to execute in the chroot env
|
|
125
|
+ # stdin (file): The stdin
|
|
126
|
+ # stdout (file): The stdout
|
|
127
|
+ # stderr (file): The stderr
|
|
128
|
+ # cwd (str): The current working directory
|
|
129
|
+ # env (dict): The environment variables to use while executing the command
|
|
130
|
+ # flags (:class:`SandboxFlags`): The flags to enable on the sandbox
|
|
131
|
+ #
|
|
132
|
+ # Returns:
|
|
133
|
+ # (int): The exit code of the executed command
|
|
134
|
+ #
|
|
135
|
+ def chroot(self, rootfs, command, stdin, stdout, stderr, cwd, env, flags):
|
|
136
|
+ raise SandboxError("This platform does not support local builds")
|
|
137
|
+ def kill_proc():
|
|
138
|
+ if process:
|
|
139
|
+ # First attempt to gracefully terminate
|
|
140
|
+ proc = psutil.Process(process.pid)
|
|
141
|
+ proc.terminate()
|
|
142
|
+
|
|
143
|
+ try:
|
|
144
|
+ proc.wait(20)
|
|
145
|
+ except psutil.TimeoutExpired:
|
|
146
|
+ utils._kill_process_tree(process.pid)
|
|
147
|
+
|
|
148
|
+ def suspend_proc():
|
|
149
|
+ group_id = os.getpgid(process.pid)
|
|
150
|
+ os.killpg(group_id, signal.SIGSTOP)
|
|
151
|
+
|
|
152
|
+ def resume_proc():
|
|
153
|
+ group_id = os.getpgid(process.pid)
|
|
154
|
+ os.killpg(group_id, signal.SIGCONT)
|
|
155
|
+
|
|
156
|
+ try:
|
|
157
|
+ with _signals.suspendable(suspend_proc, resume_proc), _signals.terminator(kill_proc):
|
|
158
|
+ process = subprocess.Popen(
|
|
159
|
+ command,
|
|
160
|
+ close_fds=True,
|
|
161
|
+ cwd=os.path.join(rootfs, cwd.lstrip(os.sep)),
|
|
162
|
+ env=env,
|
|
163
|
+ stdin=stdin,
|
|
164
|
+ stdout=stdout,
|
|
165
|
+ stderr=stderr,
|
|
166
|
+ # If you try to put gtk dialogs here Tristan (either)
|
|
167
|
+ # will personally scald you
|
|
168
|
+ preexec_fn=lambda: (os.chroot(rootfs), os.chdir(cwd)),
|
|
169
|
+ start_new_session=flags & SandboxFlags.INTERACTIVE
|
|
170
|
+ )
|
|
171
|
+
|
|
172
|
+ # Wait for the child process to finish, ensuring that
|
|
173
|
+ # a SIGINT has exactly the effect the user probably
|
|
174
|
+ # expects (i.e. let the child process handle it).
|
|
175
|
+ try:
|
|
176
|
+ while True:
|
|
177
|
+ try:
|
|
178
|
+ _, status = os.waitpid(process.pid, 0)
|
|
179
|
+ # If the process exits due to a signal, we
|
|
180
|
+ # brutally murder it to avoid zombies
|
|
181
|
+ if not os.WIFEXITED(status):
|
|
182
|
+ utils._kill_process_tree(process.pid)
|
|
183
|
+
|
|
184
|
+ # Unlike in the bwrap case, here only the main
|
|
185
|
+ # process seems to receive the SIGINT. We pass
|
|
186
|
+ # on the signal to the child and then continue
|
|
187
|
+ # to wait.
|
|
188
|
+ except KeyboardInterrupt:
|
|
189
|
+ process.send_signal(signal.SIGINT)
|
|
190
|
+ continue
|
|
191
|
+
|
|
192
|
+ break
|
|
193
|
+ # If we can't find the process, it has already died of
|
|
194
|
+ # its own accord, and therefore we don't need to check
|
|
195
|
+ # or kill anything.
|
|
196
|
+ except psutil.NoSuchProcess:
|
|
197
|
+ pass
|
|
198
|
+
|
|
199
|
+ # Return the exit code - see the documentation for
|
|
200
|
+ # os.WEXITSTATUS to see why this is required.
|
|
201
|
+ if os.WIFEXITED(status):
|
|
202
|
+ code = os.WEXITSTATUS(status)
|
|
203
|
+ else:
|
|
204
|
+ code = -1
|
|
205
|
+
|
|
206
|
+ except subprocess.SubprocessError as e:
|
|
207
|
+ # Exceptions in preexec_fn are simply reported as
|
|
208
|
+ # 'Exception occurred in preexec_fn', turn these into
|
|
209
|
+ # a more readable message.
|
|
210
|
+ if '{}'.format(e) == 'Exception occurred in preexec_fn.':
|
|
211
|
+ raise SandboxError('Could not chroot into {} or chdir into {}. '
|
|
212
|
+ 'Ensure you are root and that the relevant directory exists.'
|
|
213
|
+ .format(rootfs, cwd)) from e
|
|
214
|
+ else:
|
|
215
|
+ raise SandboxError('Could not run command {}: {}'.format(command, e)) from e
|
|
216
|
+
|
|
217
|
+ return code
|
|
218
|
+
|
|
219
|
+ # create_devices()
|
|
220
|
+ #
|
|
221
|
+ # Create the nodes in /dev/ usually required for builds (null,
|
|
222
|
+ # none, etc.)
|
|
223
|
+ #
|
|
224
|
+ # Args:
|
|
225
|
+ # rootfs (str): The path of the sysroot to prepare
|
|
226
|
+ # flags (:class:`.SandboxFlags`): The sandbox flags
|
|
227
|
+ #
|
|
228
|
+ @contextmanager
|
|
229
|
+ def create_devices(self, rootfs, flags):
|
|
230
|
+
|
|
231
|
+ devices = []
|
|
232
|
+ # When we are interactive, we'd rather mount /dev due to the
|
|
233
|
+ # sheer number of devices
|
|
234
|
+ if not flags & SandboxFlags.INTERACTIVE:
|
|
235
|
+
|
|
236
|
+ for device in Sandbox.DEVICES:
|
|
237
|
+ location = os.path.join(rootfs, device.lstrip(os.sep))
|
|
238
|
+ os.makedirs(os.path.dirname(location), exist_ok=True)
|
|
239
|
+ try:
|
|
240
|
+ if os.path.exists(location):
|
|
241
|
+ os.remove(location)
|
|
242
|
+
|
|
243
|
+ devices.append(self.mknod(device, location))
|
|
244
|
+ except OSError as err:
|
|
245
|
+ if err.errno == 1:
|
|
246
|
+ raise SandboxError("Permission denied while creating device node: {}.".format(err) +
|
|
247
|
+ "BuildStream reqiures root permissions for these setttings.")
|
|
248
|
+ else:
|
|
249
|
+ raise
|
|
250
|
+
|
|
251
|
+ yield
|
|
252
|
+
|
|
253
|
+ for device in devices:
|
|
254
|
+ os.remove(device)
|
|
255
|
+
|
|
256
|
+ # mount_dirs()
|
|
257
|
+ #
|
|
258
|
+ # Mount paths required for the command.
|
|
259
|
+ #
|
|
260
|
+ # Args:
|
|
261
|
+ # rootfs (str): The path of the sysroot to prepare
|
|
262
|
+ # flags (:class:`.SandboxFlags`): The sandbox flags
|
|
263
|
+ # stdout (file): The stdout
|
|
264
|
+ # stderr (file): The stderr
|
|
265
|
+ #
|
|
266
|
+ @contextmanager
|
|
267
|
+ def mount_dirs(self, rootfs, flags, stdout, stderr):
|
|
268
|
+
|
|
269
|
+ # FIXME: This should probably keep track of potentially
|
|
270
|
+ # already existing files a la _sandboxwrap.py:239
|
|
271
|
+
|
|
272
|
+ @contextmanager
|
|
273
|
+ def mount_point(point, **kwargs):
|
|
274
|
+ mount_source_overrides = self._get_mount_sources()
|
|
275
|
+ if point in mount_source_overrides:
|
|
276
|
+ mount_source = mount_source_overrides[point]
|
|
277
|
+ else:
|
|
278
|
+ mount_source = self.mount_map.get_mount_source(point)
|
|
279
|
+ mount_point = os.path.join(rootfs, point.lstrip(os.sep))
|
|
280
|
+
|
|
281
|
+ with Mounter.bind_mount(mount_point, src=mount_source, stdout=stdout, stderr=stderr, **kwargs):
|
|
282
|
+ yield
|
|
283
|
+
|
|
284
|
+ @contextmanager
|
|
285
|
+ def mount_src(src, **kwargs):
|
|
286
|
+ mount_point = os.path.join(rootfs, src.lstrip(os.sep))
|
|
287
|
+ os.makedirs(mount_point, exist_ok=True)
|
|
288
|
+
|
|
289
|
+ with Mounter.bind_mount(mount_point, src=src, stdout=stdout, stderr=stderr, **kwargs):
|
|
290
|
+ yield
|
|
291
|
+
|
|
292
|
+ with ExitStack() as stack:
|
|
293
|
+ stack.enter_context(self.mount_map.mounted(self))
|
|
294
|
+
|
|
295
|
+ stack.enter_context(mount_point('/'))
|
|
296
|
+
|
|
297
|
+ if flags & SandboxFlags.INTERACTIVE:
|
|
298
|
+ stack.enter_context(mount_src('/dev'))
|
|
299
|
+
|
|
300
|
+ stack.enter_context(mount_src('/tmp'))
|
|
301
|
+ stack.enter_context(mount_src('/proc'))
|
|
302
|
+
|
|
303
|
+ for mark in self._get_marked_directories():
|
|
304
|
+ stack.enter_context(mount_point(mark['directory']))
|
|
305
|
+
|
|
306
|
+ # Remount root RO if necessary
|
|
307
|
+ if flags & flags & SandboxFlags.ROOT_READ_ONLY:
|
|
308
|
+ root_mount = Mounter.mount(rootfs, stdout=stdout, stderr=stderr, remount=True, ro=True, bind=True)
|
|
309
|
+ # Since the exit stack has already registered a mount
|
|
310
|
+ # for this path, we do not need to register another
|
|
311
|
+ # umount call.
|
|
312
|
+ root_mount.__enter__()
|
|
313
|
+
|
|
314
|
+ yield
|
|
315
|
+
|
|
316
|
+ # mknod()
|
|
317
|
+ #
|
|
318
|
+ # Create a device node equivalent to the given source node
|
|
319
|
+ #
|
|
320
|
+ # Args:
|
|
321
|
+ # source (str): Path of the device to mimic (e.g. '/dev/null')
|
|
322
|
+ # target (str): Location to create the new device in
|
|
323
|
+ #
|
|
324
|
+ # Returns:
|
|
325
|
+ # target (str): The location of the created node
|
|
326
|
+ #
|
|
327
|
+ def mknod(self, source, target):
|
|
328
|
+ try:
|
|
329
|
+ dev = os.stat(source)
|
|
330
|
+ major = os.major(dev.st_rdev)
|
|
331
|
+ minor = os.minor(dev.st_rdev)
|
|
332
|
+
|
|
333
|
+ target_dev = os.makedev(major, minor)
|
|
334
|
+
|
|
335
|
+ os.mknod(target, mode=stat.S_IFCHR | dev.st_mode, device=target_dev)
|
|
336
|
+
|
|
337
|
+ except PermissionError as e:
|
|
338
|
+ raise SandboxError('Could not create device {}, ensure that you have root permissions: {}')
|
|
339
|
+
|
|
340
|
+ except OSError as e:
|
|
341
|
+ raise SandboxError('Could not create device {}: {}'
|
|
342
|
+ .format(target, e)) from e
|
|
343
|
+
|
|
344
|
+ return target
|