2 Wrappers around subprocess functionality that simulate an actual shell.
6 from wizard.shell import *
16 from wizard import util
18 wizard_bin = sys.argv[0]
20 This is the path to the wizard executable as specified
21 by the caller; it lets us recursively invoke wizard.
25 """Detects whether or not an argument list invokes a Python program."""
26 return args[0] == "python" or args[0] == "wizard"
28 def drop_priviledges(dir, log_file):
30 Checks if we are running as root. If we are, attempt to drop
31 priviledges to the user who owns ``dir``, by re-calling
32 itself using sudo with exec, such that the new process subsumes our
33 current one. If ``log_file`` is passed, the file is chown'ed
34 to the user we are dropping priviledges to, so the subprocess
39 uid = util.get_dir_uid(dir)
43 for k,v in os.environ.items():
44 if k.startswith('WIZARD_') or k == "SSH_GSSAPI_NAME":
45 args.append("%s=%s" % (k,v))
47 logging.debug("Dropping priviledges")
48 if log_file: os.chown(log_file, uid, -1)
49 os.execlp('sudo', 'sudo', '-u', '#' + str(uid), *args)
53 An advanced shell that performs logging. If ``dry`` is ``True``,
54 no commands are actually run.
56 def __init__(self, dry = False):
59 def call(self, *args, **kwargs):
61 Performs a system call. The actual executable and options should
62 be passed as arguments to this function. It will magically
63 ensure that 'wizard' as a command works. Several keyword arguments
66 :param python: explicitly marks the subprocess as Python or not Python
67 for improved error reporting. By default, we use
68 :func:`is_python` to autodetect this.
69 :param input: input to feed the subprocess on standard input.
70 :param interactive: whether or not directly hook up all pipes
71 to the controlling terminal, to allow interaction with subprocess.
72 :param strip: if ``True``, instead of returning a tuple,
73 return the string stdout output of the command with trailing newlines
74 removed. This emulates the behavior of backticks and ``$()`` in Bash.
75 Prefer to use :meth:`eval` instead (you should only need to explicitly
76 specify this if you are using another wrapper around this function).
77 :param log: if True, we log the call as INFO, if False, we log the call
78 as DEBUG, otherwise, we detect based on ``strip``.
79 :param addenv: mapping of environment variables *to add*
82 :param stdin: a file-type object that will be written to or read from as a pipe.
83 :returns: a tuple of strings ``(stdout, stderr)``, or a string ``stdout``
84 if ``strip`` is specified.
87 >>> sh.call("echo", "Foobar")
89 >>> sh.call("cat", input='Foobar')
93 kwargs.setdefault("interactive", False)
94 kwargs.setdefault("strip", False)
95 kwargs.setdefault("python", None)
96 kwargs.setdefault("log", None)
97 kwargs.setdefault("stdout", subprocess.PIPE)
98 kwargs.setdefault("stdin", subprocess.PIPE)
99 kwargs.setdefault("stderr", subprocess.PIPE)
100 kwargs.setdefault("addenv", None)
101 kwargs.setdefault("close_fds", True)
102 msg = "Running `" + ' '.join(args) + "`"
103 if kwargs["strip"] and not kwargs["log"] is True or kwargs["log"] is False:
111 if kwargs["python"] is None and is_python(args):
112 kwargs["python"] = True
113 if args[0] == "wizard":
116 kwargs.setdefault("input", None)
117 if kwargs["interactive"]:
122 stdout=kwargs["stdout"]
123 stdin=kwargs["stdin"]
124 stderr=kwargs["stderr"]
127 env = dict(os.environ.items() + kwargs["addenv"].items())
128 close_fds = kwargs["close_fds"]
129 # XXX: There is a possible problem here where we can fill up
130 # the kernel buffer if we have 64KB of data. This shouldn't
131 # normally be a problem, and the fix for such case would be to write to
132 # temporary files instead of a pipe.
134 # However, it *is* a problem when you do something silly, like
135 # pass --debug to mass-upgrade.
137 # Another possible way of fixing this is converting from a
138 # waitpid() pump to a select() pump, creating a pipe to
139 # ourself, and then setting up a SIGCHILD handler to write a single
140 # byte to the pipe to get us out of select() when a subprocess exits.
141 proc = subprocess.Popen(args, stdout=stdout, stderr=stderr, stdin=stdin, cwd=self.cwd, env=env, close_fds=close_fds)
142 if self._async(proc, args, **kwargs):
144 stdout, stderr = proc.communicate(kwargs["input"])
145 # can occur if we were doing interactive communication; i.e.
146 # we didn't pass in PIPE.
151 if not kwargs["interactive"]:
153 self._log(None, stderr)
155 self._log(stdout, stderr)
157 if kwargs["python"]: eclass = PythonCallError
158 else: eclass = CallError
159 raise eclass(proc.returncode, args, stdout, stderr)
161 return str(stdout).rstrip("\n")
162 return (stdout, stderr)
163 def _log(self, stdout, stderr):
164 """Logs the standard output and standard input from a command."""
166 logging.debug("STDOUT:\n" + stdout)
168 logging.debug("STDERR:\n" + stderr)
171 def _async(self, *args, **kwargs):
173 def callAsUser(self, *args, **kwargs):
175 Performs a system call as a different user. This is only possible
176 if you are running as root. Keyword arguments
177 are the same as :meth:`call` with the following additions:
179 :param user: name of the user to run command as.
180 :param uid: uid of the user to run command as.
184 The resulting system call internally uses :command:`sudo`,
185 and as such environment variables will get scrubbed. We
186 manually preserve :envvar:`SSH_GSSAPI_NAME`.
188 user = kwargs.pop("user", None)
189 uid = kwargs.pop("uid", None)
190 kwargs.setdefault("python", is_python(args))
191 if not user and not uid: return self.call(*args, **kwargs)
192 if os.getenv("SSH_GSSAPI_NAME"):
193 # This might be generalized as "preserve some environment"
195 args.insert(0, "SSH_GSSAPI_NAME=" + os.getenv("SSH_GSSAPI_NAME"))
196 if uid: return self.call("sudo", "-u", "#" + str(uid), *args, **kwargs)
197 if user: return self.call("sudo", "-u", user, *args, **kwargs)
198 def safeCall(self, *args, **kwargs):
200 Checks if the owner of the current working directory is the same
201 as the current user, and if it isn't, attempts to sudo to be
202 that user. The intended use case is for calling Git commands
203 when running as root, but this method should be used when
204 interfacing with any moderately complex program that depends
205 on working directory context. Keyword arguments are the
206 same as :meth:`call`.
209 return self.call(*args, **kwargs)
210 uid = os.stat(os.getcwd()).st_uid
211 # consider also checking ruid?
212 if uid != os.geteuid():
214 return self.callAsUser(*args, **kwargs)
216 return self.call(*args, **kwargs)
217 def eval(self, *args, **kwargs):
219 Evaluates a command and returns its output, with trailing newlines
220 stripped (like backticks in Bash). This is a convenience method for
221 calling :meth:`call` with ``strip``.
224 >>> sh.eval("echo", "Foobar")
227 kwargs["strip"] = True
228 return self.call(*args, **kwargs)
229 def setcwd(self, cwd):
231 Sets the directory processes are executed in. This sets a value
232 to be passed as the ``cwd`` argument to ``subprocess.Popen``.
236 user_shell = os.getenv("SHELL")
237 if not user_shell: user_shell = "/bin/bash"
238 # XXX: scripts specific hack, since mbash doesn't respect the current working directory
239 # When the revolution comes (i.e. $ATHENA_HOMEDIR/Scripts is your Scripts home
240 # directory) this isn't strictly necessary, but we'll probably need to support
241 # web_scripts directories ad infinitum.
242 if user_shell == "/usr/local/bin/mbash": user_shell = "/bin/bash"
245 self.call(user_shell, "-i", interactive=True)
246 except shell.CallError as e:
247 logging.warning("Shell returned non-zero exit code %d" % e.code)
249 class ParallelShell(Shell):
251 Modifies the semantics of :class:`Shell` so that
252 commands are queued here, and executed in parallel using waitpid
253 with ``max`` subprocesses, and result in callback execution
256 .. method:: call(*args, **kwargs)
258 Enqueues a system call for parallel processing. If there are
259 no openings in the queue, this will block. Keyword arguments
260 are the same as :meth:`Shell.call` with the following additions:
262 :param on_success: Callback function for success (zero exit status).
263 The callback function should accept two arguments,
264 ``stdout`` and ``stderr``.
265 :param on_error: Callback function for failure (nonzero exit status).
266 The callback function should accept one argument, the
267 exception that would have been thrown by the synchronous
269 :return: The :class:`subprocess.Proc` object that was opened.
271 .. method:: callAsUser(*args, **kwargs)
273 Enqueues a system call under a different user for parallel
274 processing. Keyword arguments are the same as
275 :meth:`Shell.callAsUser` with the additions of keyword
276 arguments from :meth:`call`.
278 .. method:: safeCall(*args, **kwargs)
280 Enqueues a "safe" call for parallel processing. Keyword
281 arguments are the same as :meth:`Shell.safeCall` with the
282 additions of keyword arguments from :meth:`call`.
284 .. method:: eval(*args, **kwargs)
286 No difference from :meth:`call`. Consider having a
287 non-parallel shell if the program you are shelling out
291 def __init__(self, dry = False, max = 10):
292 super(ParallelShell, self).__init__(dry=dry)
294 self.max = max # maximum of commands to run in parallel
296 def make(no_parallelize, max):
297 """Convenience method oriented towards command modules."""
299 return DummyParallelShell()
301 return ParallelShell(max=max)
302 def _async(self, proc, args, python, on_success, on_error, **kwargs):
304 Gets handed a :class:`subprocess.Proc` object from our deferred
305 execution. See :meth:`Shell.call` source code for details.
307 self.running[proc.pid] = (proc, args, python, on_success, on_error)
308 return True # so that the parent function returns
311 Blocking call that waits for an open subprocess slot. This is
312 automatically called by :meth:`Shell.call`.
314 # XXX: This API sucks; the actual call/callAsUser call should
315 # probably block automatically (unless I have a good reason not to)
316 # bail out immediately on initial ramp up
317 if len(self.running) < self.max: return
318 # now, wait for open pids.
320 self.reap(*os.waitpid(-1, 0))
322 if e.errno == errno.ECHILD: return
325 """Waits for all of our subprocesses to terminate."""
328 self.reap(*os.waitpid(-1, 0))
330 if e.errno == errno.ECHILD: return
332 def reap(self, pid, status):
333 """Reaps a process."""
334 # ooh, zombie process. reap it
335 proc, args, python, on_success, on_error = self.running.pop(pid)
336 # XXX: this is slightly dangerous; should actually use
338 stdout = proc.stdout.read()
339 stderr = proc.stderr.read()
340 self._log(stdout, stderr)
342 if python: eclass = PythonCallError
343 else: eclass = CallError
344 on_error(eclass(proc.returncode, args, stdout, stderr))
346 on_success(stdout, stderr)
348 raise Error("Cannot use interactive() on parallel shell")
350 # Setup a convenience global instance
353 callAsUser = shell.callAsUser
354 safeCall = shell.safeCall
356 interactive = shell.interactive
358 class DummyParallelShell(ParallelShell):
359 """Same API as :class:`ParallelShell`, but doesn't actually
360 parallelize (i.e. all calls to :meth:`wait` block.)"""
361 def __init__(self, dry = False):
362 super(DummyParallelShell, self).__init__(dry=dry, max=1)
364 class Error(wizard.Error):
365 """Base exception for this module"""
368 class CallError(Error):
369 """Indicates that a subprocess call returned a nonzero exit status."""
370 #: The exit code of the failed subprocess.
372 #: List of the program and arguments that failed.
374 #: The stdout of the program.
376 #: The stderr of the program.
378 def __init__(self, code, args, stdout, stderr):
384 compact = self.stderr.rstrip().split("\n")[-1]
385 return "%s (exited with %d)\n%s" % (compact, self.code, self.stderr)
387 class PythonCallError(CallError):
389 Indicates that a Python subprocess call had an uncaught exception.
390 This exception also contains the attributes of :class:`CallError`.
392 #: Name of the uncaught exception.
394 def __init__(self, code, args, stdout, stderr):
395 if stderr: self.name = util.get_exception_name(stderr)
396 CallError.__init__(self, code, args, stdout, stderr)
399 return "PythonCallError [%s]\n%s" % (self.name, self.stderr)
401 return "PythonCallError\n%s" % self.stderr