Commit c1dd99d4 authored by Michael Hanselmann's avatar Michael Hanselmann
Browse files

Add utility function to start daemon



The currently available functions in this direction (utils.RunCmd and
utils.Daemonize) both can not be used to start an external daemon
process. This function takes many steps to ensure the daemon is started
properly and does careful error checking. Unittests are included.
Signed-off-by: default avatarMichael Hanselmann <hansmi@google.com>
Reviewed-by: default avatarGuido Trotter <ultrotter@google.com>
parent bdd5e420
......@@ -28,6 +28,7 @@ the command line scripts.
import os
import sys
import time
import subprocess
import re
......@@ -119,16 +120,27 @@ class RunResult(object):
output = property(_GetOutput, None, None, "Return full output")
def RunCmd(cmd, env=None, output=None, cwd='/'):
def _BuildCmdEnvironment(env):
"""Builds the environment for an external program.
"""
cmd_env = os.environ.copy()
cmd_env["LC_ALL"] = "C"
if env is not None:
cmd_env.update(env)
return cmd_env
def RunCmd(cmd, env=None, output=None, cwd="/"):
"""Execute a (shell) command.
The command should not read from its standard input, as it will be
closed.
@type cmd: string or list
@type cmd: string or list
@param cmd: Command to run
@type env: dict
@param env: Additional environment
@param env: Additional environment variables
@type output: str
@param output: if desired, the output of the command can be
saved in a file instead of the RunResult instance; this
......@@ -144,19 +156,20 @@ def RunCmd(cmd, env=None, output=None, cwd='/'):
if no_fork:
raise errors.ProgrammerError("utils.RunCmd() called with fork() disabled")
if isinstance(cmd, list):
if isinstance(cmd, basestring):
strcmd = cmd
shell = True
else:
cmd = [str(val) for val in cmd]
strcmd = " ".join(cmd)
strcmd = ShellQuoteArgs(cmd)
shell = False
if output:
logging.debug("RunCmd %s, output file '%s'", strcmd, output)
else:
strcmd = cmd
shell = True
logging.debug("RunCmd '%s'", strcmd)
logging.debug("RunCmd %s", strcmd)
cmd_env = os.environ.copy()
cmd_env["LC_ALL"] = "C"
if env is not None:
cmd_env.update(env)
cmd_env = _BuildCmdEnvironment(env)
try:
if output is None:
......@@ -181,6 +194,201 @@ def RunCmd(cmd, env=None, output=None, cwd='/'):
return RunResult(exitcode, signal_, out, err, strcmd)
def StartDaemon(cmd, env=None, cwd="/", output=None, output_fd=None,
pidfile=None):
"""Start a daemon process after forking twice.
@type cmd: string or list
@param cmd: Command to run
@type env: dict
@param env: Additional environment variables
@type cwd: string
@param cwd: Working directory for the program
@type output: string
@param output: Path to file in which to save the output
@type output_fd: int
@param output_fd: File descriptor for output
@type pidfile: string
@param pidfile: Process ID file
@rtype: int
@return: Daemon process ID
@raise errors.ProgrammerError: if we call this when forks are disabled
"""
if no_fork:
raise errors.ProgrammerError("utils.StartDaemon() called with fork()"
" disabled")
if output and not (bool(output) ^ (output_fd is not None)):
raise errors.ProgrammerError("Only one of 'output' and 'output_fd' can be"
" specified")
if isinstance(cmd, basestring):
cmd = ["/bin/sh", "-c", cmd]
strcmd = ShellQuoteArgs(cmd)
if output:
logging.debug("StartDaemon %s, output file '%s'", strcmd, output)
else:
logging.debug("StartDaemon %s", strcmd)
cmd_env = _BuildCmdEnvironment(env)
# Create pipe for sending PID back
(pidpipe_read, pidpipe_write) = os.pipe()
try:
try:
# Create pipe for sending error messages
(errpipe_read, errpipe_write) = os.pipe()
try:
try:
# First fork
pid = os.fork()
if pid == 0:
try:
# Child process, won't return
_RunCmdDaemonChild(errpipe_read, errpipe_write,
pidpipe_read, pidpipe_write,
cmd, cmd_env, cwd,
output, output_fd, pidfile)
finally:
# Well, maybe child process failed
os._exit(1)
finally:
_CloseFDNoErr(errpipe_write)
# Wait for daemon to be started (or an error message to arrive) and read
# up to 100 KB as an error message
errormsg = RetryOnSignal(os.read, errpipe_read, 100 * 1024)
finally:
_CloseFDNoErr(errpipe_read)
finally:
_CloseFDNoErr(pidpipe_write)
# Read up to 128 bytes for PID
pidtext = RetryOnSignal(os.read, pidpipe_read, 128)
finally:
_CloseFDNoErr(pidpipe_read)
# Try to avoid zombies by waiting for child process
try:
os.waitpid(pid, 0)
except OSError:
pass
if errormsg:
raise errors.OpExecError("Error when starting daemon process: %r" %
errormsg)
try:
return int(pidtext)
except (ValueError, TypeError), err:
raise errors.OpExecError("Error while trying to parse PID %r: %s" %
(pidtext, err))
def _RunCmdDaemonChild(errpipe_read, errpipe_write,
pidpipe_read, pidpipe_write,
args, env, cwd,
output, fd_output, pidfile):
"""Child process for starting daemon.
"""
try:
# Close parent's side
_CloseFDNoErr(errpipe_read)
_CloseFDNoErr(pidpipe_read)
# First child process
os.chdir("/")
os.umask(077)
os.setsid()
# And fork for the second time
pid = os.fork()
if pid != 0:
# Exit first child process
os._exit(0) # pylint: disable-msg=W0212
# Make sure pipe is closed on execv* (and thereby notifies original process)
SetCloseOnExecFlag(errpipe_write, True)
# List of file descriptors to be left open
noclose_fds = [errpipe_write]
# Open PID file
if pidfile:
try:
# TODO: Atomic replace with another locked file instead of writing into
# it after creating
fd_pidfile = os.open(pidfile, os.O_WRONLY | os.O_CREAT, 0600)
# Lock the PID file (and fail if not possible to do so). Any code
# wanting to send a signal to the daemon should try to lock the PID
# file before reading it. If acquiring the lock succeeds, the daemon is
# no longer running and the signal should not be sent.
LockFile(fd_pidfile)
os.write(fd_pidfile, "%d\n" % os.getpid())
except Exception, err:
raise Exception("Creating and locking PID file failed: %s" % err)
# Keeping the file open to hold the lock
noclose_fds.append(fd_pidfile)
SetCloseOnExecFlag(fd_pidfile, False)
else:
fd_pidfile = None
# Open /dev/null
fd_devnull = os.open(os.devnull, os.O_RDWR)
assert not output or (bool(output) ^ (fd_output is not None))
if fd_output is not None:
pass
elif output:
# Open output file
try:
# TODO: Implement flag to set append=yes/no
fd_output = os.open(output, os.O_WRONLY | os.O_CREAT, 0600)
except EnvironmentError, err:
raise Exception("Opening output file failed: %s" % err)
else:
fd_output = fd_devnull
# Redirect standard I/O
os.dup2(fd_devnull, 0)
os.dup2(fd_output, 1)
os.dup2(fd_output, 2)
# Send daemon PID to parent
RetryOnSignal(os.write, pidpipe_write, str(os.getpid()))
# Close all file descriptors except stdio and error message pipe
CloseFDs(noclose_fds=noclose_fds)
# Change working directory
os.chdir(cwd)
if env is None:
os.execvp(args[0], args)
else:
os.execvpe(args[0], args, env)
except: # pylint: disable-msg=W0702
try:
# Report errors to original process
buf = str(sys.exc_info()[1])
RetryOnSignal(os.write, errpipe_write, buf)
except: # pylint: disable-msg=W0702
# Ignore errors in error handling
pass
os._exit(1) # pylint: disable-msg=W0212
def _RunCmdPipe(cmd, env, via_shell, cwd):
"""Run a command and return its output.
......
......@@ -234,6 +234,123 @@ class TestRunCmd(testutils.GanetiTestCase):
self.failUnlessEqual(RunCmd(["pwd"], cwd=cwd).stdout.strip(), cwd)
class TestStartDaemon(testutils.GanetiTestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp(prefix="ganeti-test")
self.tmpfile = os.path.join(self.tmpdir, "test")
def tearDown(self):
shutil.rmtree(self.tmpdir)
def testShell(self):
utils.StartDaemon("echo Hello World > %s" % self.tmpfile)
self._wait(self.tmpfile, 60.0, "Hello World")
def testShellOutput(self):
utils.StartDaemon("echo Hello World", output=self.tmpfile)
self._wait(self.tmpfile, 60.0, "Hello World")
def testNoShellNoOutput(self):
utils.StartDaemon(["pwd"])
def testNoShellNoOutputTouch(self):
testfile = os.path.join(self.tmpdir, "check")
self.failIf(os.path.exists(testfile))
utils.StartDaemon(["touch", testfile])
self._wait(testfile, 60.0, "")
def testNoShellOutput(self):
utils.StartDaemon(["pwd"], output=self.tmpfile)
self._wait(self.tmpfile, 60.0, "/")
def testNoShellOutputCwd(self):
utils.StartDaemon(["pwd"], output=self.tmpfile, cwd=os.getcwd())
self._wait(self.tmpfile, 60.0, os.getcwd())
def testShellEnv(self):
utils.StartDaemon("echo \"$GNT_TEST_VAR\"", output=self.tmpfile,
env={ "GNT_TEST_VAR": "Hello World", })
self._wait(self.tmpfile, 60.0, "Hello World")
def testNoShellEnv(self):
utils.StartDaemon(["printenv", "GNT_TEST_VAR"], output=self.tmpfile,
env={ "GNT_TEST_VAR": "Hello World", })
self._wait(self.tmpfile, 60.0, "Hello World")
def testOutputFd(self):
fd = os.open(self.tmpfile, os.O_WRONLY | os.O_CREAT)
try:
utils.StartDaemon(["pwd"], output_fd=fd, cwd=os.getcwd())
finally:
os.close(fd)
self._wait(self.tmpfile, 60.0, os.getcwd())
def testPid(self):
pid = utils.StartDaemon("echo $$ > %s" % self.tmpfile)
self._wait(self.tmpfile, 60.0, str(pid))
def testPidFile(self):
pidfile = os.path.join(self.tmpdir, "pid")
checkfile = os.path.join(self.tmpdir, "abort")
pid = utils.StartDaemon("while sleep 5; do :; done", pidfile=pidfile,
output=self.tmpfile)
try:
fd = os.open(pidfile, os.O_RDONLY)
try:
# Check file is locked
self.assertRaises(errors.LockError, utils.LockFile, fd)
pidtext = os.read(fd, 100)
finally:
os.close(fd)
self.assertEqual(int(pidtext.strip()), pid)
self.assert_(utils.IsProcessAlive(pid))
finally:
# No matter what happens, kill daemon
utils.KillProcess(pid, timeout=5.0, waitpid=False)
self.failIf(utils.IsProcessAlive(pid))
self.assertEqual(utils.ReadFile(self.tmpfile), "")
def _wait(self, path, timeout, expected):
# Due to the asynchronous nature of daemon processes, polling is necessary.
# A timeout makes sure the test doesn't hang forever.
def _CheckFile():
if not (os.path.isfile(path) and
utils.ReadFile(path).strip() == expected):
raise utils.RetryAgain()
try:
utils.Retry(_CheckFile, (0.01, 1.5, 1.0), timeout)
except utils.RetryTimeout:
self.fail("Apparently the daemon didn't run in %s seconds and/or"
" didn't write the correct output" % timeout)
def testError(self):
self.assertRaises(errors.OpExecError, utils.StartDaemon,
["./does-NOT-EXIST/here/0123456789"])
self.assertRaises(errors.OpExecError, utils.StartDaemon,
["./does-NOT-EXIST/here/0123456789"],
output=os.path.join(self.tmpdir, "DIR/NOT/EXIST"))
self.assertRaises(errors.OpExecError, utils.StartDaemon,
["./does-NOT-EXIST/here/0123456789"],
cwd=os.path.join(self.tmpdir, "DIR/NOT/EXIST"))
self.assertRaises(errors.OpExecError, utils.StartDaemon,
["./does-NOT-EXIST/here/0123456789"],
output=os.path.join(self.tmpdir, "DIR/NOT/EXIST"))
fd = os.open(self.tmpfile, os.O_WRONLY | os.O_CREAT)
try:
self.assertRaises(errors.ProgrammerError, utils.StartDaemon,
["./does-NOT-EXIST/here/0123456789"],
output=self.tmpfile, output_fd=fd)
finally:
os.close(fd)
class TestSetCloseOnExecFlag(unittest.TestCase):
"""Tests for SetCloseOnExecFlag"""
......@@ -1031,7 +1148,7 @@ class TestForceDictType(unittest.TestCase):
class TestIsAbsNormPath(unittest.TestCase):
"""Testing case for IsProcessAlive"""
"""Testing case for IsNormAbsPath"""
def _pathTestHelper(self, path, result):
if result:
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment