diff --git a/.gitignore b/.gitignore index 3e7e969..fcad8de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ **/*.pyc .pydevproject - +/vendor/ diff --git a/README.md b/README.md index 4891736..d08d5a2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ -## wait-for-it +# wait-for-it -`wait-for-it.sh` is a pure bash script that will wait on the availability of a host and TCP port. It is useful for synchronizing the spin-up of interdependent services, such as linked docker containers. Since it is a pure bash script, it does not have any external dependencies. +`wait-for-it.sh` is a pure bash script that will wait on the availability of a +host and TCP port. It is useful for synchronizing the spin-up of +interdependent services, such as linked docker containers. Since it is a pure +bash script, it does not have any external dependencies. ## Usage -``` +```text wait-for-it.sh host:port [-s] [-t timeout] [-- command args] -h HOST | --host=HOST Host or IP under test -p PORT | --port=PORT TCP port under test @@ -18,36 +21,43 @@ wait-for-it.sh host:port [-s] [-t timeout] [-- command args] ## Examples -For example, let's test to see if we can access port 80 on www.google.com, and if it is available, echo the message `google is up`. +For example, let's test to see if we can access port 80 on `www.google.com`, +and if it is available, echo the message `google is up`. -``` +```text $ ./wait-for-it.sh www.google.com:80 -- echo "google is up" wait-for-it.sh: waiting 15 seconds for www.google.com:80 wait-for-it.sh: www.google.com:80 is available after 0 seconds google is up ``` -You can set your own timeout with the `-t` or `--timeout=` option. Setting the timeout value to 0 will disable the timeout: +You can set your own timeout with the `-t` or `--timeout=` option. Setting +the timeout value to 0 will disable the timeout: -``` +```text $ ./wait-for-it.sh -t 0 www.google.com:80 -- echo "google is up" wait-for-it.sh: waiting for www.google.com:80 without a timeout wait-for-it.sh: www.google.com:80 is available after 0 seconds google is up ``` -The subcommand will be executed regardless if the service is up or not. If you wish to execute the subcommand only if the service is up, add the `--strict` argument. In this example, we will test port 81 on www.google.com which will fail: +The subcommand will be executed regardless if the service is up or not. If you +wish to execute the subcommand only if the service is up, add the `--strict` +argument. In this example, we will test port 81 on `www.google.com` which will +fail: -``` +```text $ ./wait-for-it.sh www.google.com:81 --timeout=1 --strict -- echo "google is up" wait-for-it.sh: waiting 1 seconds for www.google.com:81 wait-for-it.sh: timeout occurred after waiting 1 seconds for www.google.com:81 wait-for-it.sh: strict mode, refusing to execute subprocess ``` -If you don't want to execute a subcommand, leave off the `--` argument. This way, you can test the exit condition of `wait-for-it.sh` in your own scripts, and determine how to proceed: +If you don't want to execute a subcommand, leave off the `--` argument. This +way, you can test the exit condition of `wait-for-it.sh` in your own scripts, +and determine how to proceed: -``` +```text $ ./wait-for-it.sh www.google.com:80 wait-for-it.sh: waiting 15 seconds for www.google.com:80 wait-for-it.sh: www.google.com:80 is available after 0 seconds diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ea892d7 --- /dev/null +++ b/composer.json @@ -0,0 +1,7 @@ +{ + "name": "vishnubob/wait-for-it", + "description": "Pure bash script to test and wait on the availability of a TCP host and port", + "type": "library", + "license": "MIT", + "bin": ["wait-for-it.sh"] +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..59cd8db --- /dev/null +++ b/test/README.md @@ -0,0 +1,18 @@ +# Tests for wait-for-it + +* wait-for-it.py - pytests for wait-for-it.sh +* container-runners.py - Runs wait-for-it.py tests in multiple containers +* requirements.txt - pip requirements for container-runners.py + +To run the basic tests: + +``` +python wait-for-it.py +``` + +Many of the issues encountered have been related to differences between operating system versions. The container-runners.py script provides an easy way to run the python wait-for-it.py tests against multiple system configurations: + +``` +pip install -r requirements.txt +python container-runners.py +``` diff --git a/test/container-runners.py b/test/container-runners.py new file mode 100755 index 0000000..3f8f358 --- /dev/null +++ b/test/container-runners.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +# Unit tests to run wait-for-it.py unit tests in several different docker images + +import unittest +import os +import docker +from parameterized import parameterized + +client = docker.from_env() +app_path = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..')) +volumes = {app_path: {'bind': '/app', 'mode': 'ro'}} + +class TestContainers(unittest.TestCase): + """ + Test multiple container types with the test cases in wait-for-it.py + """ + + @parameterized.expand([ + "python:3.5-buster", + "python:3.5-stretch", + "dougg/alpine-busybox:alpine-3.11.3_busybox-1.30.1", + "dougg/alpine-busybox:alpine-3.11.3_busybox-1.31.1" + ]) + def test_image(self, image): + print(image) + command="/app/test/wait-for-it.py" + container = client.containers.run(image, command=command, volumes=volumes, detach=True) + result = container.wait() + logs = container.logs() + container.remove() + self.assertEqual(result["StatusCode"], 0) + +if __name__ == '__main__': + unittest.main() diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..9ba1e52 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,2 @@ +docker>=4.0.0 +parameterized>=0.7.0 diff --git a/test/wait-for-it.py b/test/wait-for-it.py old mode 100644 new mode 100755 index e06fb8c..de7530e --- a/test/wait-for-it.py +++ b/test/wait-for-it.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + import unittest import shlex from subprocess import Popen, PIPE @@ -24,17 +26,17 @@ def execute(self, cmd): proc = Popen(args, stdout=PIPE, stderr=PIPE) out, err = proc.communicate() exitcode = proc.returncode - return exitcode, out, err + return exitcode, out.decode('utf-8'), err.decode('utf-8') - def open_local_port(self, host="localhost", port=8929, timeout=5): + def open_local_port(self, timeout=5): s = socket.socket() - s.bind((host, port)) + s.bind(('', 0)) s.listen(timeout) - return s + return s, s.getsockname()[1] - def check_args(self, args, stdout_regex, stderr_regex, exitcode): + def check_args(self, args, stdout_regex, stderr_regex, should_succeed): command = self.wait_script + " " + args - actual_exitcode, out, err = self.execute(command) + exitcode, out, err = self.execute(command) # Check stderr msg = ("Failed check that STDERR:\n" + @@ -51,7 +53,7 @@ def check_args(self, args, stdout_regex, stderr_regex, exitcode): self.assertIsNotNone(re.match(stdout_regex, out, re.DOTALL), msg) # Check exit code - self.assertEqual(actual_exitcode, exitcode) + self.assertEqual(should_succeed, exitcode == 0) def setUp(self): script_path = os.path.dirname(sys.argv[0]) @@ -67,7 +69,7 @@ def test_no_args(self): "", "^$", MISSING_ARGS_TEXT, - 1 + False ) # Return code should be 1 when called with no args exitcode, out, err = self.execute(self.wait_script) @@ -79,7 +81,7 @@ def test_help(self): "--help", "", HELP_TEXT, - 1 + False ) def test_no_port(self): @@ -88,7 +90,7 @@ def test_no_port(self): "--host=localhost", "", MISSING_ARGS_TEXT, - 1 + False ) def test_no_host(self): @@ -97,17 +99,17 @@ def test_no_host(self): "--port=80", "", MISSING_ARGS_TEXT, - 1 + False ) def test_host_port(self): """ Check that --host and --port args work correctly """ - soc = self.open_local_port(port=8929) + soc, port = self.open_local_port() self.check_args( - "--host=localhost --port=8929 --timeout=1", + "--host=localhost --port={0} --timeout=1".format(port), "", - "wait-for-it.sh: waiting 1 seconds for localhost:8929", - 0 + "wait-for-it.sh: waiting 1 seconds for localhost:{0}".format(port), + True ) soc.close() @@ -116,15 +118,16 @@ def test_combined_host_port(self): Tests that wait-for-it.sh returns correctly after establishing a connectionm using combined host and ports """ - soc = self.open_local_port(port=8929) + soc, port = self.open_local_port() self.check_args( - "localhost:8929 --timeout=1", + "localhost:{0} --timeout=1".format(port), "", - "wait-for-it.sh: waiting 1 seconds for localhost:8929", - 0 + "wait-for-it.sh: waiting 1 seconds for localhost:{0}".format(port), + True ) soc.close() + def test_port_failure_with_timeout(self): """ Note exit status of 124 is exected, passed from the timeout command @@ -133,19 +136,19 @@ def test_port_failure_with_timeout(self): "localhost:8929 --timeout=1", "", ".*timeout occurred after waiting 1 seconds for localhost:8929", - 124 + False ) def test_command_execution(self): """ Checks that a command executes correctly after a port test passes """ - soc = self.open_local_port(port=8929) + soc, port = self.open_local_port() self.check_args( - "localhost:8929 -- echo \"CMD OUTPUT\"", + "localhost:{0} -- echo \"CMD OUTPUT\"".format(port), "CMD OUTPUT", - ".*wait-for-it.sh: localhost:8929 is available after 0 seconds", - 0 + ".*wait-for-it.sh: localhost:{0} is available after 0 seconds".format(port), + True ) soc.close() @@ -154,12 +157,12 @@ def test_failed_command_execution(self): Check command failure. The command in question outputs STDERR and an exit code of 2 """ - soc = self.open_local_port(port=8929) + soc, port = self.open_local_port() self.check_args( - "localhost:8929 -- ls not_real_file", + "localhost:{0} -- ls not_real_file".format(port), "", ".*No such file or directory\n", - 2 + False ) soc.close() @@ -172,7 +175,7 @@ def test_command_after_connection_failure(self): "localhost:8929 --timeout=1 -- echo \"CMD OUTPUT\"", "CMD OUTPUT", ".*timeout occurred after waiting 1 seconds for localhost:8929", - 0 + True ) if __name__ == '__main__': diff --git a/wait-for-it.sh b/wait-for-it.sh index 071c2be..c0d6a9b 100755 --- a/wait-for-it.sh +++ b/wait-for-it.sh @@ -140,17 +140,23 @@ WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} - -# check to see if timeout is from busybox? +WAITFORIT_ISBUSY=0 +WAITFORIT_BUSYTIMEFLAG="" WAITFORIT_TIMEOUT_PATH=$(type -p timeout) WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +# check to see if we're using busybox? if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then - WAITFORIT_ISBUSY=1 - WAITFORIT_BUSYTIMEFLAG="-t" + WAITFORIT_ISBUSY=1 +fi -else - WAITFORIT_ISBUSY=0 - WAITFORIT_BUSYTIMEFLAG="" +# see if timeout.c args have been updated in busybox v1.30.0 or newer +# note: this requires the use of bash on Alpine +if [[ $WAITFORIT_ISBUSY -eq 1 && $(busybox | head -1) =~ ^.*v([[:digit:]]+)\.([[:digit:]]+)\..+$ ]]; then + if [[ ${BASH_REMATCH[1]} -le 1 && ${BASH_REMATCH[2]} -lt 30 ]]; then + # using pre 1.30.0 version with `-t SEC` arg + WAITFORIT_BUSYTIMEFLAG="-t" + fi fi if [[ $WAITFORIT_CHILD -gt 0 ]]; then