# -*- coding:utf-8 -*- # 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.""" from __future__ import print_function 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 class CommandResult(object): """An object to store various attributes of a child process.""" def __init__(self, cmd=None, error=None, output=None, returncode=None): self.cmd = cmd self.error = error self.output = output self.returncode = returncode @property def cmdstr(self): """Return self.cmd as a nicely formatted string (useful for logs).""" return rh.shell.cmd_to_str(self.cmd) class RunCommandError(Exception): """Error caught in RunCommand() method.""" def __init__(self, msg, result, exception=None): self.msg, self.result, self.exception = msg, result, exception if exception is not None and not isinstance(exception, Exception): raise ValueError('exception must be an exception instance; got %r' % (exception,)) Exception.__init__(self, msg) self.args = (msg, result, exception) def stringify(self, error=True, output=True): """Custom method for controlling what is included in stringifying this. Each individual argument is the literal name of an attribute on the result object; if False, that value is ignored for adding to this string content. If true, it'll be incorporated. Args: error: See comment about individual arguments above. output: See comment about individual arguments above. """ items = [ 'return code: %s; command: %s' % ( self.result.returncode, self.result.cmdstr), ] if error and self.result.error: items.append(self.result.error) if output and self.result.output: items.append(self.result.output) if self.msg: items.append(self.msg) return '\n'.join(items) def __str__(self): # __str__ needs to return ascii, thus force a conversion to be safe. return self.stringify().decode('utf-8', 'replace').encode( 'ascii', 'xmlcharrefreplace') def __eq__(self, other): return (type(self) == type(other) and self.args == other.args) def __ne__(self, other): return not self.__eq__(other) class TerminateRunCommandError(RunCommandError): """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 sudo_run_command(cmd, user='root', **kwargs): """Run a command via sudo. Client code must use this rather than coming up with their own RunCommand invocation that jams sudo in- this function is used to enforce certain rules in our code about sudo usage, and as a potential auditing point. Args: cmd: The command to run. See RunCommand for rules of this argument- SudoRunCommand purely prefixes it with sudo. user: The user to run the command as. kwargs: See RunCommand options, it's a direct pass thru to it. Note that this supports a 'strict' keyword that defaults to True. If set to False, it'll suppress strict sudo behavior. Returns: See RunCommand documentation. Raises: This function may immediately raise RunCommandError if we're operating in a strict sudo context and the API is being misused. Barring that, see RunCommand's documentation- it can raise the same things RunCommand does. """ sudo_cmd = ['sudo'] if user == 'root' and os.geteuid() == 0: return run_command(cmd, **kwargs) if user != 'root': sudo_cmd += ['-u', user] # Pass these values down into the sudo environment, since sudo will # just strip them normally. extra_env = kwargs.pop('extra_env', None) extra_env = {} if extra_env is None else extra_env.copy() sudo_cmd.extend('%s=%s' % (k, v) for k, v in extra_env.iteritems()) # Finally, block people from passing options to sudo. sudo_cmd.append('--') if isinstance(cmd, basestring): # We need to handle shell ourselves so the order is correct: # $ sudo [sudo args] -- bash -c '[shell command]' # If we let RunCommand take care of it, we'd end up with: # $ bash -c 'sudo [sudo args] -- [shell command]' shell = kwargs.pop('shell', False) if not shell: raise Exception('Cannot run a string command without a shell') sudo_cmd.extend(['/bin/bash', '-c', cmd]) else: sudo_cmd.extend(cmd) return run_command(sudo_cmd, **kwargs) 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() is None and int_timeout >= 0: time.sleep(0.1) int_timeout -= 0.1 proc.terminate() while proc.poll() is None and kill_timeout >= 0: time.sleep(0.1) kill_timeout -= 0.1 if proc.poll() is None: # Still doesn't want to die. Too bad, so sad, time to die. proc.kill() except EnvironmentError as e: print('Ignoring unhandled exception in _kill_child_process: %s' % e, file=sys.stderr) # Ensure our child process has been reaped. proc.wait() if not rh.signals.relay_signal(original_handler, signum, frame): # Mock up our own, matching exit code for signaling. cmd_result = CommandResult(cmd=cmd, returncode=signum << 8) raise TerminateRunCommandError('Received signal %i' % signum, cmd_result) 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. """ 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.EPERM: # Kill returns either 0 (signal delivered), or 1 (signal wasn't # delivered). This isn't particularly informative, but we still # need that info to decide what to do, thus error_code_ok=True. ret = sudo_run_command(['kill', '-%i' % signum, str(self.pid)], redirect_stdout=True, redirect_stderr=True, error_code_ok=True) if ret.returncode == 1: # The kill binary doesn't distinguish between permission # denied and the pid is missing. Denied can only occur # under weird grsec/selinux policies. We ignore that # potential and just assume the pid was already dead and # try to reap it. self.poll() elif 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 # pylint: disable=redefined-builtin def run_command(cmd, error_message=None, redirect_stdout=False, redirect_stderr=False, cwd=None, input=None, shell=False, env=None, extra_env=None, ignore_sigint=False, combine_stdout_stderr=False, log_stdout_to_file=None, error_code_ok=False, int_timeout=1, kill_timeout=1, stdout_to_pipe=False, capture_output=False, quiet=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. error_message: Prints out this message when an error occurs. 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. ignore_sigint: If True, we'll ignore signal.SIGINT before calling the child. This is the desired behavior if we know our child will handle Ctrl-C. If we don't do this, I think we and the child will both get Ctrl-C at the same time, which means we'll forcefully kill the child. combine_stdout_stderr: Combines stdout and stderr streams into stdout. log_stdout_to_file: If set, redirects stdout to file specified by this path. If |combine_stdout_stderr| is set to True, then stderr will also be logged to the specified file. error_code_ok: Does not raise an exception when command returns a non-zero exit code. Instead, returns the CommandResult object containing the exit code. 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. stdout_to_pipe: Redirect stdout to pipe. capture_output: Set |redirect_stdout| and |redirect_stderr| to True. quiet: Set |stdout_to_pipe| and |combine_stdout_stderr| to True. close_fds: Whether to close all fds before running |cmd|. Returns: A CommandResult object. Raises: RunCommandError: Raises exception on error with optional error_message. """ if capture_output: redirect_stdout, redirect_stderr = True, True if quiet: stdout_to_pipe, combine_stdout_stderr = True, True # Set default for variables. stdout = None stderr = None stdin = None cmd_result = CommandResult() # 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(bufsize=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(bufsize=0, dir='/tmp') # 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 log_stdout_to_file: stdout = open(log_stdout_to_file, 'w+') elif stdout_to_pipe: stdout = subprocess.PIPE elif redirect_stdout: stdout = _get_tempfile() if combine_stdout_stderr: stderr = subprocess.STDOUT elif redirect_stderr: 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 stdout is None or 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, basestring): stdin = subprocess.PIPE elif input is not None: stdin = input input = None if isinstance(cmd, basestring): 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 {}) cmd_result.cmd = cmd proc = None # Verify that the signals modules is actually usable, and won't segfault # upon invocation of getsignal. See signals.SignalModuleUsable for the # details and upstream python bug. use_signals = rh.signals.signal_module_usable() try: proc = _Popen(cmd, cwd=cwd, stdin=stdin, stdout=stdout, stderr=stderr, shell=False, env=env, close_fds=close_fds) if use_signals: old_sigint = signal.getsignal(signal.SIGINT) if ignore_sigint: handler = signal.SIG_IGN else: 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: (cmd_result.output, cmd_result.error) = proc.communicate(input) finally: if use_signals: signal.signal(signal.SIGINT, old_sigint) signal.signal(signal.SIGTERM, old_sigterm) if stdout and not log_stdout_to_file and not stdout_to_pipe: # The linter is confused by how stdout is a file & an int. # pylint: disable=maybe-no-member,no-member stdout.seek(0) cmd_result.output = stdout.read() stdout.close() if stderr and stderr != subprocess.STDOUT: # The linter is confused by how stderr is a file & an int. # pylint: disable=maybe-no-member,no-member stderr.seek(0) cmd_result.error = stderr.read() stderr.close() cmd_result.returncode = proc.returncode if not error_code_ok and proc.returncode: msg = 'cwd=%s' % cwd if extra_env: msg += ', extra env=%s' % extra_env if error_message: msg += '\n%s' % error_message raise RunCommandError(msg, cmd_result) except OSError as e: estr = str(e) if e.errno == errno.EACCES: estr += '; does the program need `chmod a+x`?' if error_code_ok: cmd_result = CommandResult(cmd=cmd, error=estr, returncode=255) else: raise RunCommandError(estr, CommandResult(cmd=cmd), exception=e) finally: if proc is not None: # Ensure the process is dead. _kill_child_process(proc, int_timeout, kill_timeout, cmd, None, None, None) return cmd_result # pylint: enable=redefined-builtin def collection(classname, **kwargs): """Create a new class with mutable named members. This is like collections.namedtuple, but mutable. Also similar to the python 3.3 types.SimpleNamespace. Example: # Declare default values for this new class. Foo = collection('Foo', a=0, b=10) # Create a new class but set b to 4. foo = Foo(b=4) # Print out a (will be the default 0) and b (will be 4). print('a = %i, b = %i' % (foo.a, foo.b)) """ def sn_init(self, **kwargs): """The new class's __init__ function.""" # First verify the kwargs don't have excess settings. valid_keys = set(self.__slots__[1:]) these_keys = set(kwargs.keys()) invalid_keys = these_keys - valid_keys if invalid_keys: raise TypeError('invalid keyword arguments for this object: %r' % invalid_keys) # Now initialize this object. for k in valid_keys: setattr(self, k, kwargs.get(k, self.__defaults__[k])) def sn_repr(self): """The new class's __repr__ function.""" return '%s(%s)' % (classname, ', '.join( '%s=%r' % (k, getattr(self, k)) for k in self.__slots__[1:])) # Give the new class a unique name and then generate the code for it. classname = 'Collection_%s' % classname expr = '\n'.join(( 'class %(classname)s(object):', ' __slots__ = ["__defaults__", "%(slots)s"]', ' __defaults__ = {}', )) % { 'classname': classname, 'slots': '", "'.join(sorted(str(k) for k in kwargs)), } # Create the class in a local namespace as exec requires. namespace = {} exec expr in namespace # pylint: disable=exec-used new_class = namespace[classname] # Bind the helpers. new_class.__defaults__ = kwargs.copy() new_class.__init__ = sn_init new_class.__repr__ = sn_repr return new_class