aboutsummaryrefslogtreecommitdiff
path: root/rh/utils.py
blob: 14553a8379e240593399349254e2336a5d273c78 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# Copyright 2016 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Various utility functions."""

import errno
import functools
import os
import signal
import subprocess
import sys
import tempfile
import time

_path = os.path.realpath(__file__ + '/../..')
if sys.path[0] != _path:
    sys.path.insert(0, _path)
del _path

# pylint: disable=wrong-import-position
import rh.shell
import rh.signals


def timedelta_str(delta):
    """A less noisy timedelta.__str__.

    The default timedelta stringification contains a lot of leading zeros and
    uses microsecond resolution.  This makes for noisy output.
    """
    total = delta.total_seconds()
    hours, rem = divmod(total, 3600)
    mins, secs = divmod(rem, 60)
    ret = f'{int(secs)}.{delta.microseconds // 1000:03}s'
    if mins:
        ret = f'{int(mins)}m{ret}'
    if hours:
        ret = f'{int(hours)}h{ret}'
    return ret


class CompletedProcess(subprocess.CompletedProcess):
    """An object to store various attributes of a child process.

    This is akin to subprocess.CompletedProcess.
    """

    def __init__(self, args=None, returncode=None, stdout=None, stderr=None):
        super().__init__(
            args=args, returncode=returncode, stdout=stdout, stderr=stderr)

    @property
    def cmd(self):
        """Alias to self.args to better match other subprocess APIs."""
        return self.args

    @property
    def cmdstr(self):
        """Return self.cmd as a nicely formatted string (useful for logs)."""
        return rh.shell.cmd_to_str(self.cmd)


class CalledProcessError(subprocess.CalledProcessError):
    """Error caught in run() function.

    This is akin to subprocess.CalledProcessError.  We do not support |output|,
    only |stdout|.

    Attributes:
      returncode: The exit code of the process.
      cmd: The command that triggered this exception.
      msg: Short explanation of the error.
      exception: The underlying Exception if available.
    """

    def __init__(self, returncode, cmd, stdout=None, stderr=None, msg=None,
                 exception=None):
        if exception is not None and not isinstance(exception, Exception):
            raise TypeError(
                f'exception must be an exception instance; got {exception!r}')

        super().__init__(returncode, cmd, stdout)
        # The parent class will set |output|, so delete it.
        del self.output
        # TODO(vapier): When we're Python 3-only, delete this assignment as the
        # parent handles it for us.
        self.stdout = stdout
        # TODO(vapier): When we're Python 3-only, move stderr to the init above.
        self.stderr = stderr
        self.msg = msg
        self.exception = exception

    @property
    def cmdstr(self):
        """Return self.cmd as a well shell-quoted string for debugging."""
        return '' if self.cmd is None else rh.shell.cmd_to_str(self.cmd)

    def stringify(self, stdout=True, stderr=True):
        """Custom method for controlling what is included in stringifying this.

        Args:
          stdout: Whether to include captured stdout in the return value.
          stderr: Whether to include captured stderr in the return value.

        Returns:
          A summary string for this result.
        """
        items = [
            f'return code: {self.returncode}; command: {self.cmdstr}',
        ]
        if stderr and self.stderr:
            items.append(self.stderr)
        if stdout and self.stdout:
            items.append(self.stdout)
        if self.msg:
            items.append(self.msg)
        return '\n'.join(items)

    def __str__(self):
        return self.stringify()


class TerminateCalledProcessError(CalledProcessError):
    """We were signaled to shutdown while running a command.

    Client code shouldn't generally know, nor care about this class.  It's
    used internally to suppress retry attempts when we're signaled to die.
    """


def _kill_child_process(proc, int_timeout, kill_timeout, cmd, original_handler,
                        signum, frame):
    """Used as a signal handler by RunCommand.

    This is internal to Runcommand.  No other code should use this.
    """
    if signum:
        # If we've been invoked because of a signal, ignore delivery of that
        # signal from this point forward.  The invoking context of this func
        # restores signal delivery to what it was prior; we suppress future
        # delivery till then since this code handles SIGINT/SIGTERM fully
        # including delivering the signal to the original handler on the way
        # out.
        signal.signal(signum, signal.SIG_IGN)

    # Do not trust Popen's returncode alone; we can be invoked from contexts
    # where the Popen instance was created, but no process was generated.
    if proc.returncode is None and proc.pid is not None:
        try:
            while proc.poll_lock_breaker() is None and int_timeout >= 0:
                time.sleep(0.1)
                int_timeout -= 0.1

            proc.terminate()
            while proc.poll_lock_breaker() is None and kill_timeout >= 0:
                time.sleep(0.1)
                kill_timeout -= 0.1

            if proc.poll_lock_breaker() is None:
                # Still doesn't want to die.  Too bad, so sad, time to die.
                proc.kill()
        except EnvironmentError as e:
            print(f'Ignoring unhandled exception in _kill_child_process: {e}',
                  file=sys.stderr)

        # Ensure our child process has been reaped, but don't wait forever.
        proc.wait_lock_breaker(timeout=60)

    if not rh.signals.relay_signal(original_handler, signum, frame):
        # Mock up our own, matching exit code for signaling.
        raise TerminateCalledProcessError(
            signum << 8, cmd, msg=f'Received signal {signum}')


class _Popen(subprocess.Popen):
    """subprocess.Popen derivative customized for our usage.

    Specifically, we fix terminate/send_signal/kill to work if the child process
    was a setuid binary; on vanilla kernels, the parent can wax the child
    regardless, on goobuntu this apparently isn't allowed, thus we fall back
    to the sudo machinery we have.

    While we're overriding send_signal, we also suppress ESRCH being raised
    if the process has exited, and suppress signaling all together if the
    process has knowingly been waitpid'd already.
    """

    # pylint: disable=arguments-differ,arguments-renamed
    def send_signal(self, signum):
        if self.returncode is not None:
            # The original implementation in Popen allows signaling whatever
            # process now occupies this pid, even if the Popen object had
            # waitpid'd.  Since we can escalate to sudo kill, we do not want
            # to allow that.  Fixing this addresses that angle, and makes the
            # API less sucky in the process.
            return

        try:
            os.kill(self.pid, signum)
        except EnvironmentError as e:
            if e.errno == errno.ESRCH:
                # Since we know the process is dead, reap it now.
                # Normally Popen would throw this error- we suppress it since
                # frankly that's a misfeature and we're already overriding
                # this method.
                self.poll()
            else:
                raise

    def _lock_breaker(self, func, *args, **kwargs):
        """Helper to manage the waitpid lock.

        Workaround https://bugs.python.org/issue25960.
        """
        # If the lock doesn't exist, or is not locked, call the func directly.
        lock = getattr(self, '_waitpid_lock', None)
        if lock is not None and lock.locked():
            try:
                lock.release()
                return func(*args, **kwargs)
            finally:
                if not lock.locked():
                    lock.acquire()
        else:
            return func(*args, **kwargs)

    def poll_lock_breaker(self, *args, **kwargs):
        """Wrapper around poll() to break locks if needed."""
        return self._lock_breaker(self.poll, *args, **kwargs)

    def wait_lock_breaker(self, *args, **kwargs):
        """Wrapper around wait() to break locks if needed."""
        return self._lock_breaker(self.wait, *args, **kwargs)


# We use the keyword arg |input| which trips up pylint checks.
# pylint: disable=redefined-builtin
def run(cmd, redirect_stdout=False, redirect_stderr=False, cwd=None, input=None,
        shell=False, env=None, extra_env=None, combine_stdout_stderr=False,
        check=True, int_timeout=1, kill_timeout=1, capture_output=False,
        close_fds=True):
    """Runs a command.

    Args:
      cmd: cmd to run.  Should be input to subprocess.Popen.  If a string, shell
          must be true.  Otherwise the command must be an array of arguments,
          and shell must be false.
      redirect_stdout: Returns the stdout.
      redirect_stderr: Holds stderr output until input is communicated.
      cwd: The working directory to run this cmd.
      input: The data to pipe into this command through stdin.  If a file object
          or file descriptor, stdin will be connected directly to that.
      shell: Controls whether we add a shell as a command interpreter.  See cmd
          since it has to agree as to the type.
      env: If non-None, this is the environment for the new process.
      extra_env: If set, this is added to the environment for the new process.
          This dictionary is not used to clear any entries though.
      combine_stdout_stderr: Combines stdout and stderr streams into stdout.
      check: Whether to raise an exception when command returns a non-zero exit
          code, or return the CompletedProcess object containing the exit code.
          Note: will still raise an exception if the cmd file does not exist.
      int_timeout: If we're interrupted, how long (in seconds) should we give
          the invoked process to clean up before we send a SIGTERM.
      kill_timeout: If we're interrupted, how long (in seconds) should we give
          the invoked process to shutdown from a SIGTERM before we SIGKILL it.
      capture_output: Set |redirect_stdout| and |redirect_stderr| to True.
      close_fds: Whether to close all fds before running |cmd|.

    Returns:
      A CompletedProcess object.

    Raises:
      CalledProcessError: Raises exception on error.
    """
    if capture_output:
        redirect_stdout, redirect_stderr = True, True

    # Set default for variables.
    popen_stdout = None
    popen_stderr = None
    stdin = None
    result = CompletedProcess()

    # Force the timeout to float; in the process, if it's not convertible,
    # a self-explanatory exception will be thrown.
    kill_timeout = float(kill_timeout)

    def _get_tempfile():
        try:
            return tempfile.TemporaryFile(buffering=0)
        except EnvironmentError as e:
            if e.errno != errno.ENOENT:
                raise
            # This can occur if we were pointed at a specific location for our
            # TMP, but that location has since been deleted.  Suppress that
            # issue in this particular case since our usage gurantees deletion,
            # and since this is primarily triggered during hard cgroups
            # shutdown.
            return tempfile.TemporaryFile(dir='/tmp', buffering=0)

    # Modify defaults based on parameters.
    # Note that tempfiles must be unbuffered else attempts to read
    # what a separate process did to that file can result in a bad
    # view of the file.
    # The Popen API accepts either an int or a file handle for stdout/stderr.
    # pylint: disable=redefined-variable-type
    if redirect_stdout:
        popen_stdout = _get_tempfile()

    if combine_stdout_stderr:
        popen_stderr = subprocess.STDOUT
    elif redirect_stderr:
        popen_stderr = _get_tempfile()
    # pylint: enable=redefined-variable-type

    # If subprocesses have direct access to stdout or stderr, they can bypass
    # our buffers, so we need to flush to ensure that output is not interleaved.
    if popen_stdout is None or popen_stderr is None:
        sys.stdout.flush()
        sys.stderr.flush()

    # If input is a string, we'll create a pipe and send it through that.
    # Otherwise we assume it's a file object that can be read from directly.
    if isinstance(input, str):
        stdin = subprocess.PIPE
        input = input.encode('utf-8')
    elif input is not None:
        stdin = input
        input = None

    if isinstance(cmd, str):
        if not shell:
            raise Exception('Cannot run a string command without a shell')
        cmd = ['/bin/bash', '-c', cmd]
        shell = False
    elif shell:
        raise Exception('Cannot run an array command with a shell')

    # If we are using enter_chroot we need to use enterchroot pass env through
    # to the final command.
    env = env.copy() if env is not None else os.environ.copy()
    env.update(extra_env if extra_env else {})

    def ensure_text(s):
        """Make sure |s| is a string if it's bytes."""
        if isinstance(s, bytes):
            s = s.decode('utf-8', 'replace')
        return s

    result.args = cmd

    proc = None
    try:
        proc = _Popen(cmd, cwd=cwd, stdin=stdin, stdout=popen_stdout,
                      stderr=popen_stderr, shell=False, env=env,
                      close_fds=close_fds)

        old_sigint = signal.getsignal(signal.SIGINT)
        handler = functools.partial(_kill_child_process, proc, int_timeout,
                                    kill_timeout, cmd, old_sigint)
        signal.signal(signal.SIGINT, handler)

        old_sigterm = signal.getsignal(signal.SIGTERM)
        handler = functools.partial(_kill_child_process, proc, int_timeout,
                                    kill_timeout, cmd, old_sigterm)
        signal.signal(signal.SIGTERM, handler)

        try:
            (result.stdout, result.stderr) = proc.communicate(input)
        finally:
            signal.signal(signal.SIGINT, old_sigint)
            signal.signal(signal.SIGTERM, old_sigterm)

            if popen_stdout:
                # The linter is confused by how stdout is a file & an int.
                # pylint: disable=maybe-no-member,no-member
                popen_stdout.seek(0)
                result.stdout = popen_stdout.read()
                popen_stdout.close()

            if popen_stderr and popen_stderr != subprocess.STDOUT:
                # The linter is confused by how stderr is a file & an int.
                # pylint: disable=maybe-no-member,no-member
                popen_stderr.seek(0)
                result.stderr = popen_stderr.read()
                popen_stderr.close()

        result.returncode = proc.returncode

        if check and proc.returncode:
            msg = f'cwd={cwd}'
            if extra_env:
                msg += f', extra env={extra_env}'
            raise CalledProcessError(
                result.returncode, result.cmd, msg=msg,
                stdout=ensure_text(result.stdout),
                stderr=ensure_text(result.stderr))
    except OSError as e:
        # Avoid leaking tempfiles.
        if popen_stdout is not None and not isinstance(popen_stdout, int):
            popen_stdout.close()
        if popen_stderr is not None and not isinstance(popen_stderr, int):
            popen_stderr.close()

        estr = str(e)
        if e.errno == errno.EACCES:
            estr += '; does the program need `chmod a+x`?'
        if not check:
            result = CompletedProcess(args=cmd, stderr=estr, returncode=255)
        else:
            raise CalledProcessError(
                result.returncode, result.cmd, msg=estr, exception=e,
                stdout=ensure_text(result.stdout),
                stderr=ensure_text(result.stderr)) from e
    finally:
        if proc is not None:
            # Ensure the process is dead.
            # Some pylint3 versions are confused here.
            # pylint: disable=too-many-function-args
            _kill_child_process(proc, int_timeout, kill_timeout, cmd, None,
                                None, None)

    # Make sure output is returned as a string rather than bytes.
    result.stdout = ensure_text(result.stdout)
    result.stderr = ensure_text(result.stderr)

    return result
# pylint: enable=redefined-builtin