diff --git a/Makefile.am b/Makefile.am index f080b44b3bfc947e7ee256bf629efbf51c013c7a..f1b169ff64aa50f62cda70e7f41e06904eabde0e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -241,8 +241,12 @@ dist_tools_SCRIPTS = \ tools/lvmstrap \ tools/sanitize-config +pkglib_python_scripts = \ + daemons/import-export + pkglib_SCRIPTS = \ - daemons/daemon-util + daemons/daemon-util \ + $(pkglib_python_scripts) EXTRA_DIST = \ NEWS \ @@ -256,6 +260,7 @@ EXTRA_DIST = \ $(RUN_IN_TEMPDIR) \ daemons/daemon-util.in \ daemons/ganeti-cleaner.in \ + $(pkglib_python_scripts) \ devel/upload.in \ $(docdot) \ $(docpng) \ @@ -322,7 +327,8 @@ TEST_FILES = \ test/data/cert1.pem \ test/data/proc_drbd8.txt \ test/data/proc_drbd80-emptyline.txt \ - test/data/proc_drbd83.txt + test/data/proc_drbd83.txt \ + test/import-export_unittest-helper python_tests = \ test/ganeti.backend_unittest.py \ @@ -351,6 +357,7 @@ python_tests = \ dist_TESTS = \ test/daemon-util_unittest.bash \ + test/import-export_unittest.bash \ $(python_tests) nodist_TESTS = @@ -368,6 +375,7 @@ TESTS_ENVIRONMENT = \ all_python_code = \ $(dist_sbin_SCRIPTS) \ $(dist_tools_SCRIPTS) \ + $(pkglib_python_scripts) \ $(python_tests) \ $(pkgpython_PYTHON) \ $(hypervisor_PYTHON) \ @@ -379,6 +387,7 @@ all_python_code = \ srclink_files = \ man/footer.sgml \ test/daemon-util_unittest.bash \ + test/import-export_unittest.bash \ $(all_python_code) check_python_code = \ @@ -389,6 +398,7 @@ lint_python_code = \ ganeti \ $(dist_sbin_SCRIPTS) \ $(dist_tools_SCRIPTS) \ + $(pkglib_python_scripts) \ $(BUILD_BASH_COMPLETION) test/daemon-util_unittest.bash: daemons/daemon-util diff --git a/daemons/import-export b/daemons/import-export new file mode 100755 index 0000000000000000000000000000000000000000..af16f31f3e3e75812b1e3bb9a443fd3d50f6b7d7 --- /dev/null +++ b/daemons/import-export @@ -0,0 +1,647 @@ +#!/usr/bin/python +# + +# Copyright (C) 2010 Google Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + + +"""Import/export daemon. + +""" + +# pylint: disable-msg=C0103 +# C0103: Invalid name import-export + +import errno +import logging +import optparse +import os +import re +import select +import signal +import socket +import subprocess +import sys +import time +from cStringIO import StringIO + +from ganeti import constants +from ganeti import cli +from ganeti import utils +from ganeti import serializer +from ganeti import objects +from ganeti import locking + + +#: Used to recognize point at which socat(1) starts to listen on its socket. +#: The local address is required for the remote peer to connect (in particular +#: the port number). +LISTENING_RE = re.compile(r"^listening on\s+" + r"AF=(?P<family>\d+)\s+" + r"(?P<address>.+):(?P<port>\d+)$", re.I) + +#: Used to recognize point at which socat(1) is sending data over the wire +TRANSFER_LOOP_RE = re.compile(r"^starting data transfer loop with FDs\s+.*$", + re.I) + +SOCAT_LOG_DEBUG = "D" +SOCAT_LOG_INFO = "I" +SOCAT_LOG_NOTICE = "N" +SOCAT_LOG_WARNING = "W" +SOCAT_LOG_ERROR = "E" +SOCAT_LOG_FATAL = "F" + +SOCAT_LOG_IGNORE = frozenset([ + SOCAT_LOG_DEBUG, + SOCAT_LOG_INFO, + SOCAT_LOG_NOTICE, + ]) + +#: Socat buffer size: at most this many bytes are transferred per step +SOCAT_BUFSIZE = 1024 * 1024 + +#: How many lines to keep in the status file +MAX_RECENT_OUTPUT_LINES = 20 + +#: Don't update status file more than once every 5 seconds (unless forced) +MIN_UPDATE_INTERVAL = 5.0 + +#: Give child process up to 5 seconds to exit after sending a signal +CHILD_LINGER_TIMEOUT = 5.0 + +# Common options for socat +SOCAT_TCP_OPTS = ["keepalive", "keepidle=60", "keepintvl=10", "keepcnt=5"] +SOCAT_OPENSSL_OPTS = ["verify=1", "cipher=HIGH", "method=TLSv1"] +SOCAT_CONNECT_TIMEOUT = 60 + + +# Global variable for options +options = None + + +class Error(Exception): + """Generic exception""" + + +def SetupLogging(): + """Configures the logging module. + + """ + formatter = logging.Formatter("%(asctime)s: %(message)s") + + stderr_handler = logging.StreamHandler() + stderr_handler.setFormatter(formatter) + stderr_handler.setLevel(logging.NOTSET) + + root_logger = logging.getLogger("") + root_logger.addHandler(stderr_handler) + + if options.debug: + root_logger.setLevel(logging.NOTSET) + elif options.verbose: + root_logger.setLevel(logging.INFO) + else: + root_logger.setLevel(logging.ERROR) + + # Create special logger for child process output + child_logger = logging.Logger("child output") + child_logger.addHandler(stderr_handler) + child_logger.setLevel(logging.NOTSET) + + return child_logger + + +def _VerifyListening(family, address, port): + """Verify address given as listening address by socat. + + """ + # TODO: Implement IPv6 support + if family != socket.AF_INET: + raise Error("Address family %r not supported" % family) + + try: + packed_address = socket.inet_pton(family, address) + except socket.error: + raise Error("Invalid address %r for family %s" % (address, family)) + + return (socket.inet_ntop(family, packed_address), port) + + +class StatusFile: + """Status file manager. + + """ + def __init__(self, path): + """Initializes class. + + """ + self._path = path + self._data = objects.ImportExportStatus(ctime=time.time(), + mtime=None, + recent_output=[]) + + def AddRecentOutput(self, line): + """Adds a new line of recent output. + + """ + self._data.recent_output.append(line) + + # Remove old lines + del self._data.recent_output[:-MAX_RECENT_OUTPUT_LINES] + + def SetListenPort(self, port): + """Sets the port the daemon is listening on. + + @type port: int + @param port: TCP/UDP port + + """ + assert isinstance(port, (int, long)) and 0 < port < 2**16 + self._data.listen_port = port + + def GetListenPort(self): + """Returns the port the daemon is listening on. + + """ + return self._data.listen_port + + def SetConnected(self): + """Sets the connected flag. + + """ + self._data.connected = True + + def SetExitStatus(self, exit_status, error_message): + """Sets the exit status and an error message. + + """ + # Require error message when status isn't 0 + assert exit_status == 0 or error_message + + self._data.exit_status = exit_status + self._data.error_message = error_message + + def ExitStatusIsSuccess(self): + """Returns whether the exit status means "success". + + """ + return not bool(self._data.error_message) + + def Update(self, force): + """Updates the status file. + + @type force: bool + @param force: Write status file in any case, not only when minimum interval + is expired + + """ + if not (force or + self._data.mtime is None or + time.time() > (self._data.mtime + MIN_UPDATE_INTERVAL)): + return + + logging.debug("Updating status file %s", self._path) + + self._data.mtime = time.time() + utils.WriteFile(self._path, + data=serializer.DumpJson(self._data.ToDict(), indent=True), + mode=0400) + + +def _ProcessSocatOutput(status_file, level, msg): + """Interprets socat log output. + + """ + if level == SOCAT_LOG_NOTICE: + if status_file.GetListenPort() is None: + # TODO: Maybe implement timeout to not listen forever + m = LISTENING_RE.match(msg) + if m: + (_, port) = _VerifyListening(int(m.group("family")), m.group("address"), + int(m.group("port"))) + + status_file.SetListenPort(port) + return True + + m = TRANSFER_LOOP_RE.match(msg) + if m: + status_file.SetConnected() + return True + + return False + + +def ProcessOutput(line, status_file, logger, socat): + """Takes care of child process output. + + @param status_file: Status file manager + @param logger: Child output logger + @type socat: bool + @param socat: Whether it's a socat output line + @type line: string + @param line: Child output line + + """ + force_update = False + forward_line = line + + if socat: + level = None + parts = line.split(None, 4) + + if len(parts) == 5: + (_, _, _, level, msg) = parts + + force_update = _ProcessSocatOutput(status_file, level, msg) + + if options.debug or (level and level not in SOCAT_LOG_IGNORE): + forward_line = "socat: %s %s" % (level, msg) + else: + forward_line = None + else: + forward_line = "socat: %s" % line + + if forward_line: + logger.info(forward_line) + status_file.AddRecentOutput(forward_line) + + status_file.Update(force_update) + + +def GetBashCommand(cmd): + """Prepares a command to be run in Bash. + + """ + return ["bash", "-o", "errexit", "-o", "pipefail", "-c", cmd] + + +def GetSocatCommand(mode): + """Returns the socat command. + + """ + common_addr_opts = SOCAT_TCP_OPTS + SOCAT_OPENSSL_OPTS + [ + "key=%s" % options.key, + "cert=%s" % options.cert, + "cafile=%s" % options.ca, + ] + + if options.bind is not None: + common_addr_opts.append("bind=%s" % options.bind) + + if mode == constants.IEM_IMPORT: + if options.port is None: + port = 0 + else: + port = options.port + + addr1 = [ + "OPENSSL-LISTEN:%s" % port, + "reuseaddr", + ] + common_addr_opts + addr2 = ["stdout"] + + elif mode == constants.IEM_EXPORT: + addr1 = ["stdin"] + addr2 = [ + "OPENSSL:%s:%s" % (options.host, options.port), + "connect-timeout=%s" % SOCAT_CONNECT_TIMEOUT, + ] + common_addr_opts + + else: + raise Error("Invalid mode") + + for i in [addr1, addr2]: + for value in i: + if "," in value: + raise Error("Comma not allowed in socat option value: %r" % value) + + return [ + constants.SOCAT_PATH, + + # Log to stderr + "-ls", + + # Log level + "-d", "-d", + + # Buffer size + "-b%s" % SOCAT_BUFSIZE, + + # Unidirectional mode, the first address is only used for reading, and the + # second address is only used for writing + "-u", + + ",".join(addr1), ",".join(addr2) + ] + + +def GetTransportCommand(mode, socat_stderr_fd): + """Returns the command for the transport part of the daemon. + + @param mode: Daemon mode (import or export) + @type socat_stderr_fd: int + @param socat_stderr_fd: File descriptor socat should write its stderr to + + """ + socat_cmd = ("%s 2>&%d" % + (utils.ShellQuoteArgs(GetSocatCommand(mode)), + socat_stderr_fd)) + + # TODO: Make compression configurable + + if mode == constants.IEM_IMPORT: + transport_cmd = "%s | gunzip -c" % socat_cmd + elif mode == constants.IEM_EXPORT: + transport_cmd = "gzip -c | %s" % socat_cmd + else: + raise Error("Invalid mode") + + # TODO: Use "dd" to measure processed data (allows to give an ETA) + # TODO: If a connection to socat is dropped (e.g. due to a wrong + # certificate), socat should be restarted + + # TODO: Run transport as separate user + # The transport uses its own shell to simplify running it as a separate user + # in the future. + return GetBashCommand(transport_cmd) + + +def GetCommand(mode, socat_stderr_fd): + """Returns the complete child process command. + + """ + buf = StringIO() + + if options.cmd_prefix: + buf.write(options.cmd_prefix) + buf.write(" ") + + buf.write(utils.ShellQuoteArgs(GetTransportCommand(mode, socat_stderr_fd))) + + if options.cmd_suffix: + buf.write(" ") + buf.write(options.cmd_suffix) + + return GetBashCommand(buf.getvalue()) + + +def ProcessChildIO(child, socat_stderr_read, status_file, child_logger, + signal_notify, signal_handler): + """Handles the child processes' output. + + """ + poller = select.poll() + + script_stderr_lines = utils.LineSplitter(ProcessOutput, status_file, + child_logger, False) + try: + socat_stderr_lines = utils.LineSplitter(ProcessOutput, status_file, + child_logger, True) + try: + fdmap = { + child.stderr.fileno(): (child.stderr, script_stderr_lines), + socat_stderr_read.fileno(): (socat_stderr_read, socat_stderr_lines), + signal_notify.fileno(): (signal_notify, None), + } + + for fd in fdmap: + utils.SetNonblockFlag(fd, True) + poller.register(fd, select.POLLIN) + + timeout_calculator = None + while True: + # Break out of loop if only signal notify FD is left + if len(fdmap) == 1 and signal_notify.fileno() in fdmap: + break + + if timeout_calculator: + timeout = timeout_calculator.Remaining() * 1000 + if timeout < 0: + logging.info("Child process didn't exit in time") + break + else: + timeout = None + + for fd, event in utils.RetryOnSignal(poller.poll, timeout): + if event & (select.POLLIN | event & select.POLLPRI): + (from_, to) = fdmap[fd] + + # Read up to 1 KB of data + data = from_.read(1024) + if data: + if to: + to.write(data) + elif fd == signal_notify.fileno(): + # Signal handling + if signal_handler.called: + signal_handler.Clear() + if timeout_calculator: + logging.info("Child process still has about %0.2f seconds" + " to exit", timeout_calculator.Remaining()) + else: + logging.info("Giving child process %0.2f seconds to exit", + CHILD_LINGER_TIMEOUT) + timeout_calculator = \ + locking.RunningTimeout(CHILD_LINGER_TIMEOUT, True) + else: + poller.unregister(fd) + del fdmap[fd] + + elif event & (select.POLLNVAL | select.POLLHUP | + select.POLLERR): + poller.unregister(fd) + del fdmap[fd] + + script_stderr_lines.flush() + socat_stderr_lines.flush() + + # If there was a timeout calculator, we were waiting for the child to + # finish, e.g. due to a signal + return not bool(timeout_calculator) + finally: + socat_stderr_lines.close() + finally: + script_stderr_lines.close() + + +def ParseOptions(): + """Parses the options passed to the program. + + @return: Arguments to program + + """ + global options # pylint: disable-msg=W0603 + + parser = optparse.OptionParser(usage=("%%prog <status-file> {%s|%s}" % + (constants.IEM_IMPORT, + constants.IEM_EXPORT))) + parser.add_option(cli.DEBUG_OPT) + parser.add_option(cli.VERBOSE_OPT) + parser.add_option("--key", dest="key", action="store", type="string", + help="RSA key file") + parser.add_option("--cert", dest="cert", action="store", type="string", + help="X509 certificate file") + parser.add_option("--ca", dest="ca", action="store", type="string", + help="X509 CA file") + parser.add_option("--bind", dest="bind", action="store", type="string", + help="Bind address") + parser.add_option("--host", dest="host", action="store", type="string", + help="Remote hostname") + parser.add_option("--port", dest="port", action="store", type="int", + help="Remote port") + parser.add_option("--cmd-prefix", dest="cmd_prefix", action="store", + type="string", help="Command prefix") + parser.add_option("--cmd-suffix", dest="cmd_suffix", action="store", + type="string", help="Command suffix") + + (options, args) = parser.parse_args() + + if len(args) != 2: + # Won't return + parser.error("Expected exactly two arguments") + + (status_file_path, mode) = args + + if mode not in (constants.IEM_IMPORT, + constants.IEM_EXPORT): + # Won't return + parser.error("Invalid mode: %s" % mode) + + return (status_file_path, mode) + + +def main(): + """Main function. + + """ + # Option parsing + (status_file_path, mode) = ParseOptions() + + # Configure logging + child_logger = SetupLogging() + + status_file = StatusFile(status_file_path) + try: + try: + # Pipe to receive socat's stderr output + (socat_stderr_read_fd, socat_stderr_write_fd) = os.pipe() + + # Pipe to notify on signals + (signal_notify_read_fd, signal_notify_write_fd) = os.pipe() + + # Configure signal module's notifier + try: + # This is only supported in Python 2.5 and above (some distributions + # backported it to Python 2.4) + set_wakeup_fd_fn = signal.set_wakeup_fd + except AttributeError: + pass + else: + set_wakeup_fd_fn(signal_notify_write_fd) + + # Buffer size 0 is important, otherwise .read() with a specified length + # might buffer data while poll(2) won't mark its file descriptor as + # readable again. + socat_stderr_read = os.fdopen(socat_stderr_read_fd, "r", 0) + signal_notify_read = os.fdopen(signal_notify_read_fd, "r", 0) + + # Get child process command + cmd = GetCommand(mode, socat_stderr_write_fd) + + logging.debug("Starting command %r", cmd) + + def _ChildPreexec(): + # Move child to separate process group. By sending a signal to its + # process group we can kill the child process and all its own + # child-processes. + os.setpgid(0, 0) + + # Close almost all file descriptors + utils.CloseFDs(noclose_fds=[socat_stderr_write_fd]) + + # Not using close_fds because doing so would also close the socat stderr + # pipe, which we still need. + child = subprocess.Popen(cmd, shell=False, close_fds=False, + stderr=subprocess.PIPE, stdout=None, stdin=None, + preexec_fn=_ChildPreexec) + try: + # Avoid race condition by setting child's process group (as good as + # possible in Python) before sending signals to child. For an + # explanation, see preexec function for child. + try: + os.setpgid(child.pid, child.pid) + except EnvironmentError, err: + # If the child process was faster we receive EPERM or EACCES + if err.errno not in (errno.EPERM, errno.EACCES): + raise + + # Forward signals to child process + def _ForwardSignal(signum, _): + # Wake up poll(2) + os.write(signal_notify_write_fd, "\0") + + # Send signal to child + os.killpg(child.pid, signum) + + # TODO: There is a race condition between starting the child and + # handling the signals here. While there might be a way to work around + # it by registering the handlers before starting the child and + # deferring sent signals until the child is available, doing so can be + # complicated. + signal_handler = utils.SignalHandler([signal.SIGTERM, signal.SIGINT], + handler_fn=_ForwardSignal) + try: + # Close child's side + utils.RetryOnSignal(os.close, socat_stderr_write_fd) + + if ProcessChildIO(child, socat_stderr_read, status_file, child_logger, + signal_notify_read, signal_handler): + # The child closed all its file descriptors and there was no signal + # TODO: Implement timeout instead of waiting indefinitely + utils.RetryOnSignal(child.wait) + finally: + signal_handler.Reset() + finally: + # Final check if child process is still alive + if utils.RetryOnSignal(child.poll) is None: + logging.error("Child process still alive, sending SIGKILL") + os.killpg(child.pid, signal.SIGKILL) + utils.RetryOnSignal(child.wait) + + if child.returncode == 0: + errmsg = None + elif child.returncode < 0: + errmsg = "Exited due to signal %s" % (-child.returncode, ) + else: + errmsg = "Exited with status %s" % (child.returncode, ) + + status_file.SetExitStatus(child.returncode, errmsg) + except Exception, err: # pylint: disable-msg=W0703 + logging.exception("Unhandled error occurred") + status_file.SetExitStatus(constants.EXIT_FAILURE, + "Unhandled error occurred: %s" % (err, )) + + if status_file.ExitStatusIsSuccess(): + sys.exit(constants.EXIT_SUCCESS) + + sys.exit(constants.EXIT_FAILURE) + finally: + status_file.Update(True) + + +if __name__ == "__main__": + main() diff --git a/epydoc.conf b/epydoc.conf index 56d471a73f0617e920e5af256a493e46992c951b..4f3cbb79fd0fc172eb0df5c9e307697c87ebccc2 100644 --- a/epydoc.conf +++ b/epydoc.conf @@ -8,7 +8,7 @@ output: html # note: the wildcards means the directories should be cleaned up after each # run, otherwise there will be stale '*c' (compiled) files that will not be # parsable and will break the epydoc run -modules: ganeti, scripts/gnt-*, daemons/ganeti-confd, daemons/ganeti-masterd, daemons/ganeti-noded, daemons/ganeti-rapi, daemons/ganeti-watcher, tools/* +modules: ganeti, scripts/gnt-*, daemons/ganeti-confd, daemons/ganeti-masterd, daemons/ganeti-noded, daemons/ganeti-rapi, daemons/ganeti-watcher, daemons/import-export, tools/* graph: all diff --git a/lib/constants.py b/lib/constants.py index 4d0179b2441a0a95fbda8e693a33eb9f15db52e2..b38a5bddafa902332a3a8b9237f4d09cf881fcee 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -193,6 +193,12 @@ X509_CERT_SIGN_DIGEST = "SHA1" X509_CERT_SIGNATURE_HEADER = "X-Ganeti-Signature" +IMPORT_EXPORT_DAEMON = _autoconf.PKGLIBDIR + "/import-export" + +# Import/export daemon mode +IEM_IMPORT = "import" +IEM_EXPORT = "export" + VALUE_DEFAULT = "default" VALUE_AUTO = "auto" VALUE_GENERATE = "generate" diff --git a/lib/objects.py b/lib/objects.py index f03cb9fb67c66c7734e1aac71aa2edfcf77d965e..20ea67478c95454f4ec76093c4ae95032196dfdd 100644 --- a/lib/objects.py +++ b/lib/objects.py @@ -1007,6 +1007,17 @@ class BlockDevStatus(ConfigObject): ] +class ImportExportStatus(ConfigObject): + """Config object representing the status of an import or export.""" + __slots__ = [ + "recent_output", + "listen_port", + "connected", + "exit_status", + "error_message", + ] + _TIMESTAMPS + + class ConfdRequest(ConfigObject): """Object holding a confd request. diff --git a/test/import-export_unittest-helper b/test/import-export_unittest-helper new file mode 100755 index 0000000000000000000000000000000000000000..3968e1ff20c3f110cc24429a063107d1cc20c153 --- /dev/null +++ b/test/import-export_unittest-helper @@ -0,0 +1,77 @@ +#!/usr/bin/python +# + +# Copyright (C) 2010 Google Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + + +"""Helpers for testing import-export daemon""" + +import os +import sys +import errno + +from ganeti import constants +from ganeti import utils +from ganeti import objects +from ganeti import serializer + + +RETRY_INTERVAL = 0.1 +TIMEOUT = int(os.getenv("TIMEOUT", 10)) + + +def _GetImportExportData(filename): + try: + data = utils.ReadFile(filename) + except EnvironmentError, err: + if err.errno != errno.ENOENT: + raise + raise utils.RetryAgain() + + return objects.ImportExportStatus.FromDict(serializer.LoadJson(data)) + + +def _CheckConnected(filename): + if not _GetImportExportData(filename).connected: + raise utils.RetryAgain() + + +def WaitForListenPort(filename): + return utils.Retry(lambda: _GetImportExportData(filename).listen_port, + RETRY_INTERVAL, TIMEOUT) + + +def WaitForConnected(filename): + utils.Retry(_CheckConnected, RETRY_INTERVAL, TIMEOUT, args=(filename, )) + + +def main(): + (filename, what) = sys.argv[1:] + + if what == "listen-port": + print WaitForListenPort(filename) + elif what == "connected": + WaitForConnected(filename) + elif what == "gencert": + utils.GenerateSelfSignedSslCert(filename, validity=1) + else: + raise Exception("Unknown command '%s'" % what) + + +if __name__ == "__main__": + main() diff --git a/test/import-export_unittest.bash b/test/import-export_unittest.bash new file mode 100755 index 0000000000000000000000000000000000000000..3b992edc0734f474ac241483173c49f2b79f6bfc --- /dev/null +++ b/test/import-export_unittest.bash @@ -0,0 +1,196 @@ +#!/bin/bash +# + +# Copyright (C) 2010 Google Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + +set -e +set -o pipefail + +export PYTHON=${PYTHON:=python} + +impexpd="$PYTHON daemons/import-export" + +# Add "-d" for debugging +#impexpd+=' -d' + +err() { + echo "$@" + echo 'Aborting' + exit 1 +} + +checkpids() { + local result=0 + + # Unlike combining the "wait" commands using || or &&, this ensures we + # actually wait for all PIDs. + for pid in "$@"; do + if ! wait $pid; then + result=1 + fi + done + + return $result +} + +get_testpath() { + echo "${TOP_SRCDIR:-.}/test" +} + +get_testfile() { + echo "$(get_testpath)/data/$1" +} + +statusdir=$(mktemp -d) +trap "rm -rf $statusdir" EXIT + +src_statusfile=$statusdir/src.status +src_x509=$statusdir/src.pem + +dst_statusfile=$statusdir/dst.status +dst_x509=$statusdir/dst.pem +dst_portfile=$statusdir/dst.port + +other_x509=$statusdir/other.pem + +testdata=$statusdir/data1 + +cmd_prefix= +cmd_suffix= + +$impexpd >/dev/null 2>&1 && + err "daemon-util succeeded without parameters" + +$impexpd foo bar baz moo boo >/dev/null 2>&1 && + err "daemon-util succeeded with wrong parameters" + +$impexpd $src_statusfile >/dev/null 2>&1 && + err "daemon-util succeeded with insufficient parameters" + +$impexpd $src_statusfile invalidmode >/dev/null 2>&1 && + err "daemon-util succeeded with invalid mode" + +cat $(get_testfile proc_drbd8.txt) $(get_testfile cert1.pem) > $testdata + +impexpd_helper() { + $PYTHON $(get_testpath)/import-export_unittest-helper "$@" +} + +reset_status() { + rm -f $src_statusfile $dst_statusfile $dst_portfile +} + +write_data() { + # Wait for connection to be established + impexpd_helper $dst_statusfile connected + + cat $testdata +} + +do_export() { + # Wait for listening port + impexpd_helper $dst_statusfile listen-port > $dst_portfile + + local port=$(< $dst_portfile) + + test -n "$port" || err 'Empty port file' + + do_export_to_port $port +} + +do_export_to_port() { + local port=$1 + + $impexpd $src_statusfile export --bind=127.0.0.1 \ + --host=127.0.0.1 --port=$port \ + --key=$src_x509 --cert=$src_x509 --ca=$dst_x509 \ + --cmd-prefix="$cmd_prefix" --cmd-suffix="$cmd_suffix" +} + +do_import() { + $impexpd $dst_statusfile import --bind=127.0.0.1 \ + --host=127.0.0.1 \ + --key=$dst_x509 --cert=$dst_x509 --ca=$src_x509 \ + --cmd-prefix="$cmd_prefix" --cmd-suffix="$cmd_suffix" +} + +# Generate X509 certificates and keys +impexpd_helper $src_x509 gencert +impexpd_helper $dst_x509 gencert +impexpd_helper $other_x509 gencert + +# Normal case +reset_status +do_import > $statusdir/recv1 & imppid=$! +write_data | do_export & exppid=$! +checkpids $exppid $imppid || err 'An error occurred' +cmp $testdata $statusdir/recv1 || err 'Received data does not match input' + +# Export using wrong CA +reset_status +do_import > /dev/null 2>&1 & imppid=$! +: | dst_x509=$other_x509 do_export 2>/dev/null & exppid=$! +checkpids $exppid $imppid && err 'Export did not fail when using wrong CA' + +# Import using wrong CA +reset_status +src_x509=$other_x509 do_import > /dev/null 2>&1 & imppid=$! +: | do_export 2> /dev/null & exppid=$! +checkpids $exppid $imppid && err 'Import did not fail when using wrong CA' + +# Suffix command on import +reset_status +cmd_suffix="| cksum > $statusdir/recv2" do_import & imppid=$! +write_data | do_export & exppid=$! +checkpids $exppid $imppid || err 'Testing additional commands failed' +cmp $statusdir/recv2 <(cksum < $testdata) || \ + err 'Checksum of received data does not match' + +# Prefix command on export +reset_status +do_import > $statusdir/recv3 & imppid=$! +write_data | cmd_prefix="cksum |" do_export & exppid=$! +checkpids $exppid $imppid || err 'Testing additional commands failed' +cmp $statusdir/recv3 <(cksum < $testdata) || \ + err 'Received checksum does not match' + +# Failing prefix command on export +reset_status +: | cmd_prefix='exit 1;' do_export_to_port 0 & exppid=$! +checkpids $exppid && err 'Prefix command on export did not fail when it should' + +# Failing suffix command on export +reset_status +do_import > /dev/null & imppid=$! +: | cmd_suffix='| exit 1' do_export & exppid=$! +checkpids $imppid $exppid && \ + err 'Suffix command on export did not fail when it should' + +# Failing prefix command on import +reset_status +cmd_prefix='exit 1;' do_import > /dev/null & imppid=$! +checkpids $imppid && err 'Prefix command on import did not fail when it should' + +# Failing suffix command on import +reset_status +cmd_suffix='| exit 1' do_import > /dev/null & imppid=$! +: | do_export & exppid=$! +checkpids $imppid $exppid && \ + err 'Suffix command on import did not fail when it should' + +exit 0