diff --git a/Dockerfile b/Dockerfile index dc5e7dc..1de268e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ ARG CC # Persist ARGs into the image +RUN ln -s /usr/bin/python3 /usr/bin/python + ENV ARCH_SUFFIX="$ARCH_SUFFIX" \ ARCH_NATIVE="$ARCH_NATIVE" \ CC="$CC" diff --git a/ci/install_deps.sh b/ci/install_deps.sh index ef326d4..66c79bd 100755 --- a/ci/install_deps.sh +++ b/ci/install_deps.sh @@ -6,7 +6,7 @@ set -o xtrace DEPS=( build-essential git gdb valgrind cmake rpm file libcap-dev python3-dev python3-pip python3-setuptools - hardening-includes gnupg + hardening-includes gnupg curl ) case "${ARCH_SUFFIX-}" in @@ -26,5 +26,7 @@ apt-get update apt-get install --no-install-recommends --yes "${DEPS[@]}" rm -rf /var/lib/apt/lists/* -python3 -m pip install --upgrade pip +curl -fsSL -o- https://bootstrap.pypa.io/pip/3.5/get-pip.py | python3.5 +#python3 -m pip install --upgrade pip python3 -m pip install virtualenv +python3 -m pip install importlib-metadata==2.1.0 diff --git a/src/tini.c b/src/tini.c index 2c873f9..6beef12 100644 --- a/src/tini.c +++ b/src/tini.c @@ -44,6 +44,8 @@ #define STATUS_MAX 255 #define STATUS_MIN 0 +#define RESPAWN_CHILD -2 + typedef struct { sigset_t* const sigmask_ptr; struct sigaction* const sigttin_action_ptr; @@ -90,11 +92,11 @@ static int32_t expect_status[(STATUS_MAX - STATUS_MIN + 1) / 32]; #ifdef PR_SET_CHILD_SUBREAPER #define HAS_SUBREAPER 1 -#define OPT_STRING "p:hvwgle:s" +#define OPT_STRING "p:hvwgle:r:t:s" #define SUBREAPER_ENV_VAR "TINI_SUBREAPER" #else #define HAS_SUBREAPER 0 -#define OPT_STRING "p:hvwgle:" +#define OPT_STRING "p:hvwgle:r:t:" #endif #define VERBOSITY_ENV_VAR "TINI_VERBOSITY" @@ -128,6 +130,10 @@ To fix the problem, " #endif "run Tini as PID 1."; +static unsigned int restart_signal = 0; +static unsigned int child_term_signal = SIGTERM; +static bool is_restarting = false; + int restore_signals(const signal_configuration_t* const sigconf_ptr) { if (sigprocmask(SIG_SETMASK, sigconf_ptr->sigmask_ptr, NULL)) { PRINT_FATAL("Restoring child signal mask failed: '%s'", strerror(errno)); @@ -248,6 +254,9 @@ void print_usage(char* const name, FILE* const file) { fprintf(file, " -w: Print a warning when processes are getting reaped.\n"); fprintf(file, " -g: Send signals to the child's process group.\n"); fprintf(file, " -e EXIT_CODE: Remap EXIT_CODE (from 0 to 255) to 0 (can be repeated).\n"); + fprintf(file, " -r RESTART_SIGNAL: Restart(terminate and start) child process on RESTART_SIGNAL, e.g. \"-r SIGUSR1\".\n"); + fprintf(file, " -t CHILD_TERM_SIGNAL: Signal to terminate the child process for a restart, defaults to SIGTERM, e.g. \"-t SIGTERM\".\n"); + fprintf(file, " -e EXIT_CODE: Remap EXIT_CODE (from 0 to 255) to 0 (can be repeated).\n"); fprintf(file, " -l: Show license and exit.\n"); #endif @@ -273,13 +282,13 @@ void print_license(FILE* const file) { } } -int set_pdeathsig(char* const arg) { +int set_signal_number(char* const signal_name, unsigned int* signal_number) { size_t i; for (i = 0; i < ARRAY_LEN(signal_names); i++) { - if (strcmp(signal_names[i].name, arg) == 0) { + if (strcmp(signal_names[i].name, signal_name) == 0) { /* Signals start at value "1" */ - parent_death_signal = signal_names[i].number; + *signal_number = signal_names[i].number; return 0; } } @@ -329,13 +338,35 @@ int parse_args(const int argc, char* const argv[], char* (**child_args_ptr_ptr)[ break; #endif case 'p': - if (set_pdeathsig(optarg)) { + if (set_signal_number(optarg, &parent_death_signal)) { PRINT_FATAL("Not a valid option for -p: %s", optarg); *parse_fail_exitcode_ptr = 1; return 1; } break; + case 'r': + if (set_signal_number(optarg, &restart_signal)) { + PRINT_FATAL("Not a valid option for -r: %s", optarg); + *parse_fail_exitcode_ptr = 1; + return 1; + } + + if (restart_signal == SIGCHLD) { + PRINT_FATAL("SIGCHLD not a valid option for -r: %s", optarg); + *parse_fail_exitcode_ptr = 1; + return 1; + } + break; + + case 't': + if (set_signal_number(optarg, &child_term_signal)) { + PRINT_FATAL("Not a valid option for -t: %s", optarg); + *parse_fail_exitcode_ptr = 1; + return 1; + } + break; + case 'v': verbosity++; break; @@ -498,6 +529,10 @@ int configure_signals(sigset_t* const parent_sigset_ptr, const signal_configurat return 0; } +bool is_restart_enabled() { + return restart_signal != 0; +} + int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const child_pid) { siginfo_t sig; @@ -521,6 +556,17 @@ int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const PRINT_DEBUG("Received SIGCHLD"); break; default: + if (restart_signal == (unsigned)sig.si_signo && is_restart_enabled()) { + PRINT_DEBUG("Received process restart signal: %d", sig.si_signo); + // Shutdown the child process. + // Success full termination will be known when corresponding + // SIGCHLD is received. + PRINT_DEBUG("Terminating child process with pid %d", child_pid); + kill(child_pid, child_term_signal); + is_restarting = true; + break; + } + PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo)); /* Forward anything else */ if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) { @@ -542,6 +588,8 @@ int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) { pid_t current_pid; int current_status; + *child_exitcode_ptr = -1; + while (1) { current_pid = waitpid(-1, ¤t_status, WNOHANG); @@ -588,6 +636,14 @@ int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) { if (INT32_BITFIELD_TEST(expect_status, *child_exitcode_ptr)) { *child_exitcode_ptr = 0; } + + if (is_restarting && *child_exitcode_ptr == 0) { + /* The child process exited normally with a success exit + * code 0 and we are restarting. Indicate repsawn of the child. + */ + *child_exitcode_ptr = RESPAWN_CHILD; + } + is_restarting = false; } else if (warn_on_reap > 0) { PRINT_WARNING("Reaped zombie process with pid=%i", current_pid); } @@ -603,7 +659,6 @@ int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) { return 0; } - int main(int argc, char *argv[]) { pid_t child_pid; @@ -660,7 +715,10 @@ int main(int argc, char *argv[]) { if (spawn_ret) { return spawn_ret; } - free(child_args_ptr); + + if (!is_restart_enabled()) { + free(child_args_ptr); + } while (1) { /* Wait for one signal, and forward it */ @@ -673,8 +731,13 @@ int main(int argc, char *argv[]) { return 1; } - if (child_exitcode != -1) { - PRINT_TRACE("Exiting: child has exited"); + if (is_restart_enabled() && child_exitcode == RESPAWN_CHILD) { + spawn_ret = spawn(&child_sigconf, *child_args_ptr, &child_pid); + if (spawn_ret) { + return spawn_ret; + } + } else if (child_exitcode != -1) { + PRINT_TRACE("Exiting: child has exited %d", child_exitcode); return child_exitcode; } } diff --git a/test/restart/restart-test.py b/test/restart/restart-test.py new file mode 100755 index 0000000..eb6542d --- /dev/null +++ b/test/restart/restart-test.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +import signal +import os +import sys +import time + + +def sigterm_handler(sig, frame): + print("SIGTERM received - exiting gracefully") + sys.exit(0) + + +def sigusr1_handler(sig, frame): + print("SIGUSR1 received - exiting") + sys.exit(1) + + +def main(): + signal.signal(signal.SIGTERM, sigterm_handler) + signal.signal(signal.SIGUSR1, sigusr1_handler) + print("#") + print("Starting") + while True: + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/test/run_outer_tests.py b/test/run_outer_tests.py index b500ded..267f6b0 100755 --- a/test/run_outer_tests.py +++ b/test/run_outer_tests.py @@ -1,4 +1,4 @@ -#coding:utf-8 +# coding:utf-8 import os import sys import time @@ -12,6 +12,7 @@ class ReturnContainer(): def __init__(self): self.value = None + self.stdout = "" class Command(object): @@ -23,11 +24,13 @@ def __init__(self, cmd, fail_cmd, post_cmd=None, post_delay=0): self.proc = None def run(self, timeout=None, retcode=0): - print "Testing '{0}'...".format(" ".join(pipes.quote(s) for s in self.cmd)), + print("Testing '{0}'...".format( + " ".join(pipes.quote(s) for s in self.cmd)),) sys.stdout.flush() err = None - pipe_kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "stdin": subprocess.PIPE} + pipe_kwargs = {"stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, "stdin": subprocess.PIPE} def target(): self.proc = subprocess.Popen(self.cmd, **pipe_kwargs) @@ -42,7 +45,8 @@ def target(): time.sleep(self.post_delay) subprocess.check_call(self.post_cmd, **pipe_kwargs) - thread.join(timeout - self.post_delay if timeout is not None else timeout) + thread.join( + timeout - self.post_delay if timeout is not None else timeout) # Checks if thread.is_alive(): @@ -50,38 +54,42 @@ def target(): err = Exception("Test failed with timeout!") elif self.proc.returncode != retcode: - err = Exception("Test failed with unexpected returncode (expected {0}, got {1})".format(retcode, self.proc.returncode)) + err = Exception("Test failed with unexpected returncode (expected {0}, got {1})".format( + retcode, self.proc.returncode)) if err is not None: - print "FAIL" - print "--- STDOUT ---" - print getattr(self, "stdout", "no stdout") - print "--- STDERR ---" - print getattr(self, "stderr", "no stderr") - print "--- ... ---" + print("FAIL") + print("--- STDOUT ---") + print(getattr(self, "stdout", "no stdout")) + print("--- STDERR ---") + print(getattr(self, "stderr", "no stderr")) + print("--- ... ---") raise err else: - print "OK" + print("OK") def attach_and_type_exit_0(name): - print "Attaching to {0} to exit 0".format(name) + print("Attaching to {0} to exit 0".format(name)) p = pexpect.spawn("docker attach {0}".format(name)) p.sendline('') p.sendline('exit 0') + print("Sent exit 0 to {0}".format(name)) p.close() def attach_and_issue_ctrl_c(name): - print "Attaching to {0} to CTRL+C".format(name) + print("Attaching to {0} to CTRL+C".format(name)) p = pexpect.spawn("docker attach {0}".format(name)) p.expect_exact('#') p.sendintr() + print("Sent CTRL+C to {0}".format(name)) p.close() def test_tty_handling(img, name, base_cmd, fail_cmd, container_command, exit_function, expect_exit_code): - print "Testing TTY handling (using container command '{0}' and exit function '{1}')".format(container_command, exit_function.__name__) + print("Testing TTY handling (using container command '{0}' and exit function '{1}')".format( + container_command, exit_function.__name__)) rc = ReturnContainer() shell_ready_event = threading.Event() @@ -101,21 +109,80 @@ def spawn(): thread.start() - if not shell_ready_event.wait(2): + if not shell_ready_event.wait(20): raise Exception("Timeout waiting for shell to spawn") exit_function(name) - thread.join(timeout=2) + thread.join(timeout=20) if thread.is_alive(): subprocess.check_call(fail_cmd) raise Exception("Timeout waiting for container to exit!") if rc.value != expect_exit_code: - raise Exception("Return code is: {0} (expected {1})".format(rc.value, expect_exit_code)) + raise Exception("Return code is: {0} (expected {1})".format( + rc.value, expect_exit_code)) +def test_restart(img, name, base_cmd, entrypoint, container_command, fail_cmd, restart_cmd, num_restarts, expect_exit_code, expect_restart): + print("Testing restart (using container command '{0}' and expecting restart '{1}')".format( + container_command, expect_restart)) + rc = ReturnContainer() + + child_ready_event = threading.Event() + + def spawn(): + cmd = base_cmd + ["--tty", "--interactive", + "-e", "TINI_VERBOSITY=4", img, entrypoint] + cmd += container_command + p = pexpect.spawn(" ".join(cmd)) + p.expect("#") + child_ready_event.set() + p.expect(pexpect.EOF) + rc.value = p.wait() + rc.stdout = p.before.decode("utf-8") + + thread = threading.Thread(target=spawn) + thread.daemon = True + + thread.start() + + if not child_ready_event.wait(20): + raise Exception("Timeout waiting for command to spawn") + + for i in range(0, num_restarts): + p = pexpect.spawn(" ".join(restart_cmd)) + p.wait() + # Give tiny time to restart the child process + time.sleep(1) + + if expect_restart: + subprocess.check_call(fail_cmd) + + thread.join(timeout=20) + + expected_runs = 1 + expected_restarts = 0 + if expect_restart: + expected_runs = num_restarts + 1 + expected_restarts = num_restarts + 1 + + runs = rc.stdout.count("Starting") + if runs != expected_runs: + raise Exception( + "Number of starts is: {0} expected {1}".format(runs, expected_runs)) + + restarts = rc.stdout.count("SIGTERM received - exiting gracefully") + if restarts != expected_restarts: + raise Exception( + "Number of restarts is: {0} expected {1}".format(restarts, expected_restarts)) + + if rc.value != expect_exit_code: + raise Exception("Return code is: {0} (expected {1})".format( + rc.value, expect_exit_code)) + print("OK") + def main(): img = sys.argv[1] @@ -143,7 +210,8 @@ def main(): ] # Reaping test - Command(functional_base_cmd + ["/tini/test/reaping/stage_1.py"], fail_cmd).run(timeout=10) + Command(functional_base_cmd + + ["/tini/test/reaping/stage_1.py"], fail_cmd).run(timeout=10) # Signals test for sig, retcode in [("TERM", 143), ("USR1", 138), ("USR2", 140)]: @@ -155,18 +223,32 @@ def main(): ).run(timeout=10, retcode=retcode) # Exit code test - Command(functional_base_cmd + ["-z"], fail_cmd).run(retcode=127 if args_disabled else 1) - Command(functional_base_cmd + ["-h"], fail_cmd).run(retcode=127 if args_disabled else 0) + Command(functional_base_cmd + + ["-z"], fail_cmd).run(retcode=127 if args_disabled else 1) + Command(functional_base_cmd + + ["-h"], fail_cmd).run(retcode=127 if args_disabled else 0) Command(functional_base_cmd + ["zzzz"], fail_cmd).run(retcode=127) - Command(functional_base_cmd + ["-w"], fail_cmd).run(retcode=127 if args_disabled else 0) + Command(functional_base_cmd + + ["-w"], fail_cmd).run(retcode=127 if args_disabled else 1) + + # Restart test + restart_cmd = ["docker", "kill", "-s", "SIGUSR1", name] + restart_fail_cmd = ["docker", "kill", "-s", "SIGTERM", name] + test_restart(img, name, base_cmd, entrypoint, ["-r", "SIGUSR1", "-t", "SIGTERM", + "/tini/test/restart/restart-test.py"], restart_fail_cmd, restart_cmd, 5, 0, True) + test_restart(img, name, base_cmd, entrypoint, [ + "/tini/test/restart/restart-test.py"], restart_fail_cmd, restart_cmd, 5, 1, False) # Valgrind test (we only run this on the dynamic version, because otherwise Valgrind may bring up plenty of errors that are # actually from libc) - Command(base_cmd + [img, "valgrind", "--leak-check=full", "--error-exitcode=1", "/tini/dist/tini", "ls"], fail_cmd).run() + Command(base_cmd + [img, "valgrind", "--leak-check=full", + "--error-exitcode=1", "/tini/dist/tini", "ls"], fail_cmd).run() # Test tty handling - test_tty_handling(img, name, base_cmd, fail_cmd, "dash", attach_and_type_exit_0, 0) - test_tty_handling(img, name, base_cmd, fail_cmd, "dash -c 'while true; do echo \#; sleep 0.1; done'", attach_and_issue_ctrl_c, 128 + signal.SIGINT) + test_tty_handling(img, name, base_cmd, fail_cmd, + "dash", attach_and_type_exit_0, 0) + test_tty_handling(img, name, base_cmd, fail_cmd, "dash -c 'while true; do echo \#; sleep 0.1; done'", + attach_and_issue_ctrl_c, 128 + signal.SIGINT) # Installation tests (sh -c is used for globbing and &&) for image, pkg_manager, extension in [ @@ -175,7 +257,8 @@ def main(): ["centos:6", "rpm", "rpm"], ["centos:7", "rpm", "rpm"], ]: - Command(base_cmd + [image, "sh", "-c", "{0} -i /tini/dist/*.{1} && /usr/bin/tini true".format(pkg_manager, extension)], fail_cmd).run() + Command(base_cmd + [image, "sh", "-c", "{0} -i /tini/dist/*.{1} && /usr/bin/tini true".format( + pkg_manager, extension)], fail_cmd).run() if __name__ == "__main__":