Merge branch 'master' into 5720-ajax-loading-error
[arvados.git] / sdk / python / tests / run_test_server.py
1 #!/usr/bin/env python
2
3 from __future__ import print_function
4 import argparse
5 import atexit
6 import httplib2
7 import os
8 import pipes
9 import random
10 import re
11 import shutil
12 import signal
13 import socket
14 import subprocess
15 import string
16 import sys
17 import tempfile
18 import time
19 import unittest
20 import yaml
21
22 MY_DIRNAME = os.path.dirname(os.path.realpath(__file__))
23 if __name__ == '__main__' and os.path.exists(
24       os.path.join(MY_DIRNAME, '..', 'arvados', '__init__.py')):
25     # We're being launched to support another test suite.
26     # Add the Python SDK source to the library path.
27     sys.path.insert(1, os.path.dirname(MY_DIRNAME))
28
29 import arvados
30 import arvados.config
31
32 ARVADOS_DIR = os.path.realpath(os.path.join(MY_DIRNAME, '../../..'))
33 SERVICES_SRC_DIR = os.path.join(ARVADOS_DIR, 'services')
34 SERVER_PID_PATH = 'tmp/pids/test-server.pid'
35 if 'GOPATH' in os.environ:
36     gopaths = os.environ['GOPATH'].split(':')
37     gobins = [os.path.join(path, 'bin') for path in gopaths]
38     os.environ['PATH'] = ':'.join(gobins) + ':' + os.environ['PATH']
39
40 TEST_TMPDIR = os.path.join(ARVADOS_DIR, 'tmp')
41 if not os.path.exists(TEST_TMPDIR):
42     os.mkdir(TEST_TMPDIR)
43
44 my_api_host = None
45 _cached_config = {}
46
47 def find_server_pid(PID_PATH, wait=10):
48     now = time.time()
49     timeout = now + wait
50     good_pid = False
51     while (not good_pid) and (now <= timeout):
52         time.sleep(0.2)
53         try:
54             with open(PID_PATH, 'r') as f:
55                 server_pid = int(f.read())
56             good_pid = (os.kill(server_pid, 0) is None)
57         except IOError:
58             good_pid = False
59         except OSError:
60             good_pid = False
61         now = time.time()
62
63     if not good_pid:
64         return None
65
66     return server_pid
67
68 def kill_server_pid(pidfile, wait=10, passenger_root=False):
69     # Must re-import modules in order to work during atexit
70     import os
71     import signal
72     import subprocess
73     import time
74     try:
75         if passenger_root:
76             # First try to shut down nicely
77             restore_cwd = os.getcwd()
78             os.chdir(passenger_root)
79             subprocess.call([
80                 'bundle', 'exec', 'passenger', 'stop', '--pid-file', pidfile])
81             os.chdir(restore_cwd)
82         now = time.time()
83         timeout = now + wait
84         with open(pidfile, 'r') as f:
85             server_pid = int(f.read())
86         while now <= timeout:
87             if not passenger_root or timeout - now < wait / 2:
88                 # Half timeout has elapsed. Start sending SIGTERM
89                 os.kill(server_pid, signal.SIGTERM)
90             # Raise OSError if process has disappeared
91             os.getpgid(server_pid)
92             time.sleep(0.1)
93             now = time.time()
94     except IOError:
95         pass
96     except OSError:
97         pass
98
99 def find_available_port():
100     """Return an IPv4 port number that is not in use right now.
101
102     We assume whoever needs to use the returned port is able to reuse
103     a recently used port without waiting for TIME_WAIT (see
104     SO_REUSEADDR / SO_REUSEPORT).
105
106     Some opportunity for races here, but it's better than choosing
107     something at random and not checking at all. If all of our servers
108     (hey Passenger) knew that listening on port 0 was a thing, the OS
109     would take care of the races, and this wouldn't be needed at all.
110     """
111
112     sock = socket.socket()
113     sock.bind(('0.0.0.0', 0))
114     port = sock.getsockname()[1]
115     sock.close()
116     return port
117
118 def run(leave_running_atexit=False):
119     """Ensure an API server is running, and ARVADOS_API_* env vars have
120     admin credentials for it.
121
122     If ARVADOS_TEST_API_HOST is set, a parent process has started a
123     test server for us to use: we just need to reset() it using the
124     admin token fixture.
125
126     If a previous call to run() started a new server process, and it
127     is still running, we just need to reset() it to fixture state and
128     return.
129
130     If neither of those options work out, we'll really start a new
131     server.
132     """
133     global my_api_host
134
135     # Delete cached discovery document.
136     shutil.rmtree(arvados.http_cache('discovery'))
137
138     pid_file = os.path.join(SERVICES_SRC_DIR, 'api', SERVER_PID_PATH)
139     pid_file_ok = find_server_pid(pid_file, 0)
140
141     existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
142     if existing_api_host and pid_file_ok:
143         if existing_api_host == my_api_host:
144             try:
145                 return reset()
146             except:
147                 # Fall through to shutdown-and-start case.
148                 pass
149         else:
150             # Server was provided by parent. Can't recover if it's
151             # unresettable.
152             return reset()
153
154     # Before trying to start up our own server, call stop() to avoid
155     # "Phusion Passenger Standalone is already running on PID 12345".
156     # (If we've gotten this far, ARVADOS_TEST_API_HOST isn't set, so
157     # we know the server is ours to kill.)
158     stop(force=True)
159
160     restore_cwd = os.getcwd()
161     api_src_dir = os.path.join(SERVICES_SRC_DIR, 'api')
162     os.chdir(api_src_dir)
163
164     # Either we haven't started a server of our own yet, or it has
165     # died, or we have lost our credentials, or something else is
166     # preventing us from calling reset(). Start a new one.
167
168     if not os.path.exists('tmp/self-signed.pem'):
169         # We assume here that either passenger reports its listening
170         # address as https:/0.0.0.0:port/. If it reports "127.0.0.1"
171         # then the certificate won't match the host and reset() will
172         # fail certificate verification. If it reports "localhost",
173         # clients (notably Python SDK's websocket client) might
174         # resolve localhost as ::1 and then fail to connect.
175         subprocess.check_call([
176             'openssl', 'req', '-new', '-x509', '-nodes',
177             '-out', 'tmp/self-signed.pem',
178             '-keyout', 'tmp/self-signed.key',
179             '-days', '3650',
180             '-subj', '/CN=0.0.0.0'],
181         stdout=sys.stderr)
182
183     # Install the git repository fixtures.
184     gitdir = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git')
185     gittarball = os.path.join(SERVICES_SRC_DIR, 'api', 'test', 'test.git.tar')
186     if not os.path.isdir(gitdir):
187         os.makedirs(gitdir)
188     subprocess.check_output(['tar', '-xC', gitdir, '-f', gittarball])
189
190     port = find_available_port()
191     env = os.environ.copy()
192     env['RAILS_ENV'] = 'test'
193     env['ARVADOS_WEBSOCKETS'] = 'yes'
194     env.pop('ARVADOS_TEST_API_HOST', None)
195     env.pop('ARVADOS_API_HOST', None)
196     env.pop('ARVADOS_API_HOST_INSECURE', None)
197     env.pop('ARVADOS_API_TOKEN', None)
198     start_msg = subprocess.check_output(
199         ['bundle', 'exec',
200          'passenger', 'start', '-d', '-p{}'.format(port),
201          '--pid-file', os.path.join(os.getcwd(), pid_file),
202          '--log-file', os.path.join(os.getcwd(), 'log/test.log'),
203          '--ssl',
204          '--ssl-certificate', 'tmp/self-signed.pem',
205          '--ssl-certificate-key', 'tmp/self-signed.key'],
206         env=env)
207
208     if not leave_running_atexit:
209         atexit.register(kill_server_pid, pid_file, passenger_root=api_src_dir)
210
211     match = re.search(r'Accessible via: https://(.*?)/', start_msg)
212     if not match:
213         raise Exception(
214             "Passenger did not report endpoint: {}".format(start_msg))
215     my_api_host = match.group(1)
216     os.environ['ARVADOS_API_HOST'] = my_api_host
217
218     # Make sure the server has written its pid file before continuing
219     find_server_pid(pid_file)
220
221     reset()
222     os.chdir(restore_cwd)
223
224 def reset():
225     """Reset the test server to fixture state.
226
227     This resets the ARVADOS_TEST_API_HOST provided by a parent process
228     if any, otherwise the server started by run().
229
230     It also resets ARVADOS_* environment vars to point to the test
231     server with admin credentials.
232     """
233     existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
234     token = auth_token('admin')
235     httpclient = httplib2.Http(ca_certs=os.path.join(
236         SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem'))
237     httpclient.request(
238         'https://{}/database/reset'.format(existing_api_host),
239         'POST',
240         headers={'Authorization': 'OAuth2 {}'.format(token)})
241     os.environ['ARVADOS_API_HOST_INSECURE'] = 'true'
242     os.environ['ARVADOS_API_HOST'] = existing_api_host
243     os.environ['ARVADOS_API_TOKEN'] = token
244
245 def stop(force=False):
246     """Stop the API server, if one is running.
247
248     If force==False, kill it only if we started it ourselves. (This
249     supports the use case where a Python test suite calls run(), but
250     run() just uses the ARVADOS_TEST_API_HOST provided by the parent
251     process, and the test suite cleans up after itself by calling
252     stop(). In this case the test server provided by the parent
253     process should be left alone.)
254
255     If force==True, kill it even if we didn't start it
256     ourselves. (This supports the use case in __main__, where "run"
257     and "stop" happen in different processes.)
258     """
259     global my_api_host
260     if force or my_api_host is not None:
261         kill_server_pid(os.path.join(SERVICES_SRC_DIR, 'api', SERVER_PID_PATH))
262         my_api_host = None
263
264 def _start_keep(n, keep_args):
265     keep0 = tempfile.mkdtemp()
266     port = find_available_port()
267     keep_cmd = ["keepstore",
268                 "-volume={}".format(keep0),
269                 "-listen=:{}".format(port),
270                 "-pid="+_pidfile('keep{}'.format(n))]
271
272     for arg, val in keep_args.iteritems():
273         keep_cmd.append("{}={}".format(arg, val))
274
275     kp0 = subprocess.Popen(
276         keep_cmd, stdin=open('/dev/null'), stdout=sys.stderr)
277     with open(_pidfile('keep{}'.format(n)), 'w') as f:
278         f.write(str(kp0.pid))
279
280     with open("{}/keep{}.volume".format(TEST_TMPDIR, n), 'w') as f:
281         f.write(keep0)
282
283     return port
284
285 def run_keep(blob_signing_key=None, enforce_permissions=False):
286     stop_keep()
287
288     keep_args = {}
289     if blob_signing_key:
290         with open(os.path.join(TEST_TMPDIR, "keep.blob_signing_key"), "w") as f:
291             keep_args['--permission-key-file'] = f.name
292             f.write(blob_signing_key)
293     if enforce_permissions:
294         keep_args['--enforce-permissions'] = 'true'
295
296     api = arvados.api(
297         version='v1',
298         host=os.environ['ARVADOS_API_HOST'],
299         token=os.environ['ARVADOS_API_TOKEN'],
300         insecure=True)
301     for d in api.keep_services().list().execute()['items']:
302         api.keep_services().delete(uuid=d['uuid']).execute()
303     for d in api.keep_disks().list().execute()['items']:
304         api.keep_disks().delete(uuid=d['uuid']).execute()
305
306     for d in range(0, 2):
307         port = _start_keep(d, keep_args)
308         svc = api.keep_services().create(body={'keep_service': {
309             'uuid': 'zzzzz-bi6l4-keepdisk{:07d}'.format(d),
310             'service_host': 'localhost',
311             'service_port': port,
312             'service_type': 'disk',
313             'service_ssl_flag': False,
314         }}).execute()
315         api.keep_disks().create(body={
316             'keep_disk': {'keep_service_uuid': svc['uuid'] }
317         }).execute()
318
319 def _stop_keep(n):
320     kill_server_pid(_pidfile('keep{}'.format(n)), 0)
321     if os.path.exists("{}/keep{}.volume".format(TEST_TMPDIR, n)):
322         with open("{}/keep{}.volume".format(TEST_TMPDIR, n), 'r') as r:
323             shutil.rmtree(r.read(), True)
324         os.unlink("{}/keep{}.volume".format(TEST_TMPDIR, n))
325     if os.path.exists(os.path.join(TEST_TMPDIR, "keep.blob_signing_key")):
326         os.remove(os.path.join(TEST_TMPDIR, "keep.blob_signing_key"))
327
328 def stop_keep():
329     _stop_keep(0)
330     _stop_keep(1)
331
332 def run_keep_proxy():
333     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
334         return
335     stop_keep_proxy()
336
337     admin_token = auth_token('admin')
338     port = find_available_port()
339     env = os.environ.copy()
340     env['ARVADOS_API_TOKEN'] = admin_token
341     kp = subprocess.Popen(
342         ['keepproxy',
343          '-pid='+_pidfile('keepproxy'),
344          '-listen=:{}'.format(port)],
345         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
346
347     api = arvados.api(
348         version='v1',
349         host=os.environ['ARVADOS_API_HOST'],
350         token=admin_token,
351         insecure=True)
352     for d in api.keep_services().list(
353             filters=[['service_type','=','proxy']]).execute()['items']:
354         api.keep_services().delete(uuid=d['uuid']).execute()
355     api.keep_services().create(body={'keep_service': {
356         'service_host': 'localhost',
357         'service_port': port,
358         'service_type': 'proxy',
359         'service_ssl_flag': False,
360     }}).execute()
361     os.environ["ARVADOS_KEEP_PROXY"] = "http://localhost:{}".format(port)
362     _setport('keepproxy', port)
363
364 def stop_keep_proxy():
365     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
366         return
367     kill_server_pid(_pidfile('keepproxy'), wait=0)
368
369 def run_arv_git_httpd():
370     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
371         return
372     stop_arv_git_httpd()
373
374     gitdir = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git')
375     gitport = find_available_port()
376     env = os.environ.copy()
377     del env['ARVADOS_API_TOKEN']
378     agh = subprocess.Popen(
379         ['arv-git-httpd',
380          '-repo-root='+gitdir+'/test',
381          '-address=:'+str(gitport)],
382         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
383     with open(_pidfile('arv-git-httpd'), 'w') as f:
384         f.write(str(agh.pid))
385     _setport('arv-git-httpd', gitport)
386
387 def stop_arv_git_httpd():
388     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
389         return
390     kill_server_pid(_pidfile('arv-git-httpd'), wait=0)
391
392 def run_nginx():
393     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
394         return
395     nginxconf = {}
396     nginxconf['KEEPPROXYPORT'] = _getport('keepproxy')
397     nginxconf['KEEPPROXYSSLPORT'] = find_available_port()
398     nginxconf['GITPORT'] = _getport('arv-git-httpd')
399     nginxconf['GITSSLPORT'] = find_available_port()
400     nginxconf['SSLCERT'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem')
401     nginxconf['SSLKEY'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.key')
402
403     conftemplatefile = os.path.join(MY_DIRNAME, 'nginx.conf')
404     conffile = os.path.join(TEST_TMPDIR, 'nginx.conf')
405     with open(conffile, 'w') as f:
406         f.write(re.sub(
407             r'{{([A-Z]+)}}',
408             lambda match: str(nginxconf.get(match.group(1))),
409             open(conftemplatefile).read()))
410
411     env = os.environ.copy()
412     env['PATH'] = env['PATH']+':/sbin:/usr/sbin:/usr/local/sbin'
413     nginx = subprocess.Popen(
414         ['nginx',
415          '-g', 'error_log stderr info;',
416          '-g', 'pid '+_pidfile('nginx')+';',
417          '-c', conffile],
418         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
419     _setport('keepproxy-ssl', nginxconf['KEEPPROXYSSLPORT'])
420     _setport('arv-git-httpd-ssl', nginxconf['GITSSLPORT'])
421
422 def stop_nginx():
423     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
424         return
425     kill_server_pid(_pidfile('nginx'), wait=0)
426
427 def _pidfile(program):
428     return os.path.join(TEST_TMPDIR, program + '.pid')
429
430 def _portfile(program):
431     return os.path.join(TEST_TMPDIR, program + '.port')
432
433 def _setport(program, port):
434     with open(_portfile(program), 'w') as f:
435         f.write(str(port))
436
437 # Returns 9 if program is not up.
438 def _getport(program):
439     try:
440         return int(open(_portfile(program)).read())
441     except IOError:
442         return 9
443
444 def _apiconfig(key):
445     if _cached_config:
446         return _cached_config[key]
447     def _load(f):
448         return yaml.load(os.path.join(SERVICES_SRC_DIR, 'api', 'config', f))
449     cdefault = _load('application.default.yml')
450     csite = _load('application.yml')
451     _cached_config = {}
452     for section in [cdefault.get('common',{}), cdefault.get('test',{}),
453                     csite.get('common',{}), csite.get('test',{})]:
454         _cached_config.update(section)
455     return _cached_config[key]
456
457 def fixture(fix):
458     '''load a fixture yaml file'''
459     with open(os.path.join(SERVICES_SRC_DIR, 'api', "test", "fixtures",
460                            fix + ".yml")) as f:
461         yaml_file = f.read()
462         try:
463           trim_index = yaml_file.index("# Test Helper trims the rest of the file")
464           yaml_file = yaml_file[0:trim_index]
465         except ValueError:
466           pass
467         return yaml.load(yaml_file)
468
469 def auth_token(token_name):
470     return fixture("api_client_authorizations")[token_name]["api_token"]
471
472 def authorize_with(token_name):
473     '''token_name is the symbolic name of the token from the api_client_authorizations fixture'''
474     arvados.config.settings()["ARVADOS_API_TOKEN"] = auth_token(token_name)
475     arvados.config.settings()["ARVADOS_API_HOST"] = os.environ.get("ARVADOS_API_HOST")
476     arvados.config.settings()["ARVADOS_API_HOST_INSECURE"] = "true"
477
478 class TestCaseWithServers(unittest.TestCase):
479     """TestCase to start and stop supporting Arvados servers.
480
481     Define any of MAIN_SERVER, KEEP_SERVER, and/or KEEP_PROXY_SERVER
482     class variables as a dictionary of keyword arguments.  If you do,
483     setUpClass will start the corresponding servers by passing these
484     keyword arguments to the run, run_keep, and/or run_keep_server
485     functions, respectively.  It will also set Arvados environment
486     variables to point to these servers appropriately.  If you don't
487     run a Keep or Keep proxy server, setUpClass will set up a
488     temporary directory for Keep local storage, and set it as
489     KEEP_LOCAL_STORE.
490
491     tearDownClass will stop any servers started, and restore the
492     original environment.
493     """
494     MAIN_SERVER = None
495     KEEP_SERVER = None
496     KEEP_PROXY_SERVER = None
497
498     @staticmethod
499     def _restore_dict(src, dest):
500         for key in dest.keys():
501             if key not in src:
502                 del dest[key]
503         dest.update(src)
504
505     @classmethod
506     def setUpClass(cls):
507         cls._orig_environ = os.environ.copy()
508         cls._orig_config = arvados.config.settings().copy()
509         cls._cleanup_funcs = []
510         os.environ.pop('ARVADOS_KEEP_PROXY', None)
511         os.environ.pop('ARVADOS_EXTERNAL_CLIENT', None)
512         for server_kwargs, start_func, stop_func in (
513                 (cls.MAIN_SERVER, run, reset),
514                 (cls.KEEP_SERVER, run_keep, stop_keep),
515                 (cls.KEEP_PROXY_SERVER, run_keep_proxy, stop_keep_proxy)):
516             if server_kwargs is not None:
517                 start_func(**server_kwargs)
518                 cls._cleanup_funcs.append(stop_func)
519         if (cls.KEEP_SERVER is None) and (cls.KEEP_PROXY_SERVER is None):
520             cls.local_store = tempfile.mkdtemp()
521             os.environ['KEEP_LOCAL_STORE'] = cls.local_store
522             cls._cleanup_funcs.append(
523                 lambda: shutil.rmtree(cls.local_store, ignore_errors=True))
524         else:
525             os.environ.pop('KEEP_LOCAL_STORE', None)
526         arvados.config.initialize()
527
528     @classmethod
529     def tearDownClass(cls):
530         for clean_func in cls._cleanup_funcs:
531             clean_func()
532         cls._restore_dict(cls._orig_environ, os.environ)
533         cls._restore_dict(cls._orig_config, arvados.config.settings())
534
535
536 if __name__ == "__main__":
537     actions = [
538         'start', 'stop',
539         'start_keep', 'stop_keep',
540         'start_keep_proxy', 'stop_keep_proxy',
541         'start_arv-git-httpd', 'stop_arv-git-httpd',
542         'start_nginx', 'stop_nginx',
543     ]
544     parser = argparse.ArgumentParser()
545     parser.add_argument('action', type=str, help="one of {}".format(actions))
546     parser.add_argument('--auth', type=str, metavar='FIXTURE_NAME', help='Print authorization info for given api_client_authorizations fixture')
547     args = parser.parse_args()
548
549     if args.action not in actions:
550         print("Unrecognized action '{}'. Actions are: {}.".format(args.action, actions), file=sys.stderr)
551         sys.exit(1)
552     if args.action == 'start':
553         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
554         run(leave_running_atexit=True)
555         host = os.environ['ARVADOS_API_HOST']
556         if args.auth is not None:
557             token = auth_token(args.auth)
558             print("export ARVADOS_API_TOKEN={}".format(pipes.quote(token)))
559             print("export ARVADOS_API_HOST={}".format(pipes.quote(host)))
560             print("export ARVADOS_API_HOST_INSECURE=true")
561         else:
562             print(host)
563     elif args.action == 'stop':
564         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
565     elif args.action == 'start_keep':
566         run_keep()
567     elif args.action == 'stop_keep':
568         stop_keep()
569     elif args.action == 'start_keep_proxy':
570         run_keep_proxy()
571     elif args.action == 'stop_keep_proxy':
572         stop_keep_proxy()
573     elif args.action == 'start_arv-git-httpd':
574         run_arv_git_httpd()
575     elif args.action == 'stop_arv-git-httpd':
576         stop_arv_git_httpd()
577     elif args.action == 'start_nginx':
578         run_nginx()
579     elif args.action == 'stop_nginx':
580         stop_nginx()
581     else:
582         raise Exception("action recognized but not implemented!?")