3021: In start(), if a stale server is already running (but we can't reset() it)...
[arvados.git] / sdk / python / tests / run_test_server.py
index c52ba8ec714566ecab65148ddb4a1a2bd4ae7d94..ab1c7ffd1a270803e419e98557c0abfd8b40f2e9 100644 (file)
@@ -10,6 +10,7 @@ import re
 import shutil
 import signal
 import subprocess
+import string
 import sys
 import tempfile
 import time
@@ -61,17 +62,17 @@ def find_server_pid(PID_PATH, wait=10):
 
     return server_pid
 
-def kill_server_pid(pidfile, wait=10, passenger=False):
+def kill_server_pid(pidfile, wait=10, passenger_root=False):
     # Must re-import modules in order to work during atexit
     import os
     import signal
     import subprocess
     import time
     try:
-        if passenger:
+        if passenger_root:
             # First try to shut down nicely
             restore_cwd = os.getcwd()
-            os.chdir(os.path.join(SERVICES_SRC_DIR, 'api'))
+            os.chdir(passenger_root)
             subprocess.call([
                 'bundle', 'exec', 'passenger', 'stop', '--pid-file', pidfile])
             os.chdir(restore_cwd)
@@ -80,7 +81,7 @@ def kill_server_pid(pidfile, wait=10, passenger=False):
         with open(pidfile, 'r') as f:
             server_pid = int(f.read())
         while now <= timeout:
-            if not passenger or timeout - now < wait / 2:
+            if not passenger_root or timeout - now < wait / 2:
                 # Half timeout has elapsed. Start sending SIGTERM
                 os.kill(server_pid, signal.SIGTERM)
             # Raise OSError if process has disappeared
@@ -92,9 +93,45 @@ def kill_server_pid(pidfile, wait=10, passenger=False):
     except OSError:
         pass
 
+def find_available_port():
+    """Return a port number that is not in use right now.
+
+    Some opportunity for races here, but it's better than choosing
+    something at random and not checking at all. If all of our servers
+    (hey Passenger) knew that listening on port 0 was a thing, the OS
+    would take care of the races, and this wouldn't be needed at all.
+    """
+    port = None
+    while port is None:
+        port = random.randint(20000, 40000)
+        port_hex = ':%04x ' % port
+        try:
+            with open('/proc/net/tcp', 'r') as f:
+                for line in f:
+                    if 0 <= string.find(line, port_hex):
+                        port = None
+                        break
+        except OSError:
+            # This isn't going so well. Just use the random port.
+            pass
+        except IOError:
+            pass
+    return port
+
 def run(leave_running_atexit=False):
     """Ensure an API server is running, and ARVADOS_API_* env vars have
     admin credentials for it.
+
+    If ARVADOS_TEST_API_HOST is set, a parent process has started a
+    test server for us to use: we just need to reset() it using the
+    admin token fixture.
+
+    If a previous call to run() started a new server process, and it
+    is still running, we just need to reset() it to fixture state and
+    return.
+
+    If neither of those options work out, we'll really start a new
+    server.
     """
     global my_api_host
 
@@ -107,15 +144,26 @@ def run(leave_running_atexit=False):
     pid_file = os.path.join(SERVICES_SRC_DIR, 'api', SERVER_PID_PATH)
     pid_file_ok = find_server_pid(pid_file, 0)
 
-    if pid_file_ok:
+    existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
+    if existing_api_host and pid_file_ok:
         try:
+            os.environ['ARVADOS_API_HOST'] = existing_api_host
             reset()
             return
         except:
             pass
 
+    # Before trying to start up our own server, call stop() to avoid
+    # "Phusion Passenger Standalone is already running on PID 12345".
+    # We want to kill it if it's our own _or_ it's some stale
+    # left-over server. But if it's been deliberately provided to us
+    # by a parent process, we don't want to force-kill it. That'll
+    # just wreck things for the next test suite that tries to use it.
+    stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
+
     restore_cwd = os.getcwd()
-    os.chdir(os.path.join(SERVICES_SRC_DIR, 'api'))
+    api_src_dir = os.path.join(SERVICES_SRC_DIR, 'api')
+    os.chdir(api_src_dir)
 
     # Either we haven't started a server of our own yet, or it has
     # died, or we have lost our credentials, or something else is
@@ -135,7 +183,7 @@ def run(leave_running_atexit=False):
             '-days', '3650',
             '-subj', '/CN=0.0.0.0'])
 
-    port = random.randint(20000, 40000)
+    port = find_available_port()
     env = os.environ.copy()
     env['RAILS_ENV'] = 'test'
     env['ARVADOS_WEBSOCKETS'] = 'yes'
@@ -154,7 +202,7 @@ def run(leave_running_atexit=False):
         env=env)
 
     if not leave_running_atexit:
-        atexit.register(kill_server_pid, pid_file, passenger=True)
+        atexit.register(kill_server_pid, pid_file, passenger_root=api_src_dir)
 
     match = re.search(r'Accessible via: https://(.*?)/', start_msg)
     if not match:
@@ -170,17 +218,33 @@ def run(leave_running_atexit=False):
     os.chdir(restore_cwd)
 
 def reset():
+    """Reset the test server to fixture state.
+
+    This resets the ARVADOS_TEST_API_HOST provided by a parent process
+    if any, otherwise the server started by run().
+    """
+    existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
     token = auth_token('admin')
     httpclient = httplib2.Http(ca_certs=os.path.join(
         SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem'))
     httpclient.request(
-        'https://{}/database/reset'.format(os.environ['ARVADOS_API_HOST']),
+        'https://{}/database/reset'.format(existing_api_host),
         'POST',
         headers={'Authorization': 'OAuth2 {}'.format(token)})
 
 def stop(force=False):
-    """Stop the API server, if one is running. If force==True, kill it
-    even if we didn't start it ourselves.
+    """Stop the API server, if one is running.
+
+    If force==False, kill it only if we started it ourselves. (This
+    supports the use case where a Python test suite calls run(), but
+    run() just uses the ARVADOS_TEST_API_HOST provided by the parent
+    process, and the test suite cleans up after itself by calling
+    stop(). In this case the test server provided by the parent
+    process should be left alone.)
+
+    If force==True, kill it even if we didn't start it
+    ourselves. (This supports the use case in __main__, where "run"
+    and "stop" happen in different processes.)
     """
     global my_api_host
     if force or my_api_host is not None:
@@ -189,7 +253,7 @@ def stop(force=False):
 
 def _start_keep(n, keep_args):
     keep0 = tempfile.mkdtemp()
-    port = random.randint(20000, 40000)
+    port = find_available_port()
     keep_cmd = ["keepstore",
                 "-volumes={}".format(keep0),
                 "-listen=:{}".format(port),
@@ -258,7 +322,7 @@ def run_keep_proxy():
     stop_keep_proxy()
 
     admin_token = auth_token('admin')
-    port = random.randint(20000,40000)
+    port = find_available_port()
     env = os.environ.copy()
     env['ARVADOS_API_TOKEN'] = admin_token
     kp = subprocess.Popen(