Merge branch 'master' into 3198-writable-fuse
[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'):
169         os.makedirs('tmp')
170
171     if not os.path.exists('tmp/api'):
172         os.makedirs('tmp/api')
173
174     if not os.path.exists('tmp/logs'):
175         os.makedirs('tmp/logs')
176
177     if not os.path.exists('tmp/self-signed.pem'):
178         # We assume here that either passenger reports its listening
179         # address as https:/0.0.0.0:port/. If it reports "127.0.0.1"
180         # then the certificate won't match the host and reset() will
181         # fail certificate verification. If it reports "localhost",
182         # clients (notably Python SDK's websocket client) might
183         # resolve localhost as ::1 and then fail to connect.
184         subprocess.check_call([
185             'openssl', 'req', '-new', '-x509', '-nodes',
186             '-out', 'tmp/self-signed.pem',
187             '-keyout', 'tmp/self-signed.key',
188             '-days', '3650',
189             '-subj', '/CN=0.0.0.0'],
190         stdout=sys.stderr)
191
192     # Install the git repository fixtures.
193     gitdir = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git')
194     gittarball = os.path.join(SERVICES_SRC_DIR, 'api', 'test', 'test.git.tar')
195     if not os.path.isdir(gitdir):
196         os.makedirs(gitdir)
197     subprocess.check_output(['tar', '-xC', gitdir, '-f', gittarball])
198
199     port = find_available_port()
200     env = os.environ.copy()
201     env['RAILS_ENV'] = 'test'
202     env['ARVADOS_WEBSOCKETS'] = 'yes'
203     env.pop('ARVADOS_TEST_API_HOST', None)
204     env.pop('ARVADOS_API_HOST', None)
205     env.pop('ARVADOS_API_HOST_INSECURE', None)
206     env.pop('ARVADOS_API_TOKEN', None)
207     start_msg = subprocess.check_output(
208         ['bundle', 'exec',
209          'passenger', 'start', '-d', '-p{}'.format(port),
210          '--pid-file', os.path.join(os.getcwd(), pid_file),
211          '--log-file', os.path.join(os.getcwd(), 'log/test.log'),
212          '--ssl',
213          '--ssl-certificate', 'tmp/self-signed.pem',
214          '--ssl-certificate-key', 'tmp/self-signed.key'],
215         env=env)
216
217     if not leave_running_atexit:
218         atexit.register(kill_server_pid, pid_file, passenger_root=api_src_dir)
219
220     match = re.search(r'Accessible via: https://(.*?)/', start_msg)
221     if not match:
222         raise Exception(
223             "Passenger did not report endpoint: {}".format(start_msg))
224     my_api_host = match.group(1)
225     os.environ['ARVADOS_API_HOST'] = my_api_host
226
227     # Make sure the server has written its pid file before continuing
228     find_server_pid(pid_file)
229
230     reset()
231     os.chdir(restore_cwd)
232
233 def reset():
234     """Reset the test server to fixture state.
235
236     This resets the ARVADOS_TEST_API_HOST provided by a parent process
237     if any, otherwise the server started by run().
238
239     It also resets ARVADOS_* environment vars to point to the test
240     server with admin credentials.
241     """
242     existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
243     token = auth_token('admin')
244     httpclient = httplib2.Http(ca_certs=os.path.join(
245         SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem'))
246     httpclient.request(
247         'https://{}/database/reset'.format(existing_api_host),
248         'POST',
249         headers={'Authorization': 'OAuth2 {}'.format(token)})
250     os.environ['ARVADOS_API_HOST_INSECURE'] = 'true'
251     os.environ['ARVADOS_API_HOST'] = existing_api_host
252     os.environ['ARVADOS_API_TOKEN'] = token
253
254 def stop(force=False):
255     """Stop the API server, if one is running.
256
257     If force==False, kill it only if we started it ourselves. (This
258     supports the use case where a Python test suite calls run(), but
259     run() just uses the ARVADOS_TEST_API_HOST provided by the parent
260     process, and the test suite cleans up after itself by calling
261     stop(). In this case the test server provided by the parent
262     process should be left alone.)
263
264     If force==True, kill it even if we didn't start it
265     ourselves. (This supports the use case in __main__, where "run"
266     and "stop" happen in different processes.)
267     """
268     global my_api_host
269     if force or my_api_host is not None:
270         kill_server_pid(os.path.join(SERVICES_SRC_DIR, 'api', SERVER_PID_PATH))
271         my_api_host = None
272
273 def _start_keep(n, keep_args):
274     keep0 = tempfile.mkdtemp()
275     port = find_available_port()
276     keep_cmd = ["keepstore",
277                 "-volume={}".format(keep0),
278                 "-listen=:{}".format(port),
279                 "-pid="+_pidfile('keep{}'.format(n))]
280
281     for arg, val in keep_args.iteritems():
282         keep_cmd.append("{}={}".format(arg, val))
283
284     kp0 = subprocess.Popen(
285         keep_cmd, stdin=open('/dev/null'), stdout=sys.stderr)
286     with open(_pidfile('keep{}'.format(n)), 'w') as f:
287         f.write(str(kp0.pid))
288
289     with open("{}/keep{}.volume".format(TEST_TMPDIR, n), 'w') as f:
290         f.write(keep0)
291
292     return port
293
294 def run_keep(blob_signing_key=None, enforce_permissions=False):
295     stop_keep()
296
297     keep_args = {}
298     if blob_signing_key:
299         with open(os.path.join(TEST_TMPDIR, "keep.blob_signing_key"), "w") as f:
300             keep_args['--permission-key-file'] = f.name
301             f.write(blob_signing_key)
302     if enforce_permissions:
303         keep_args['--enforce-permissions'] = 'true'
304
305     api = arvados.api(
306         version='v1',
307         host=os.environ['ARVADOS_API_HOST'],
308         token=os.environ['ARVADOS_API_TOKEN'],
309         insecure=True)
310     for d in api.keep_services().list().execute()['items']:
311         api.keep_services().delete(uuid=d['uuid']).execute()
312     for d in api.keep_disks().list().execute()['items']:
313         api.keep_disks().delete(uuid=d['uuid']).execute()
314
315     for d in range(0, 2):
316         port = _start_keep(d, keep_args)
317         svc = api.keep_services().create(body={'keep_service': {
318             'uuid': 'zzzzz-bi6l4-keepdisk{:07d}'.format(d),
319             'service_host': 'localhost',
320             'service_port': port,
321             'service_type': 'disk',
322             'service_ssl_flag': False,
323         }}).execute()
324         api.keep_disks().create(body={
325             'keep_disk': {'keep_service_uuid': svc['uuid'] }
326         }).execute()
327
328 def _stop_keep(n):
329     kill_server_pid(_pidfile('keep{}'.format(n)), 0)
330     if os.path.exists("{}/keep{}.volume".format(TEST_TMPDIR, n)):
331         with open("{}/keep{}.volume".format(TEST_TMPDIR, n), 'r') as r:
332             shutil.rmtree(r.read(), True)
333         os.unlink("{}/keep{}.volume".format(TEST_TMPDIR, n))
334     if os.path.exists(os.path.join(TEST_TMPDIR, "keep.blob_signing_key")):
335         os.remove(os.path.join(TEST_TMPDIR, "keep.blob_signing_key"))
336
337 def stop_keep():
338     _stop_keep(0)
339     _stop_keep(1)
340
341 def run_keep_proxy():
342     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
343         return
344     stop_keep_proxy()
345
346     admin_token = auth_token('admin')
347     port = find_available_port()
348     env = os.environ.copy()
349     env['ARVADOS_API_TOKEN'] = admin_token
350     kp = subprocess.Popen(
351         ['keepproxy',
352          '-pid='+_pidfile('keepproxy'),
353          '-listen=:{}'.format(port)],
354         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
355
356     api = arvados.api(
357         version='v1',
358         host=os.environ['ARVADOS_API_HOST'],
359         token=admin_token,
360         insecure=True)
361     for d in api.keep_services().list(
362             filters=[['service_type','=','proxy']]).execute()['items']:
363         api.keep_services().delete(uuid=d['uuid']).execute()
364     api.keep_services().create(body={'keep_service': {
365         'service_host': 'localhost',
366         'service_port': port,
367         'service_type': 'proxy',
368         'service_ssl_flag': False,
369     }}).execute()
370     os.environ["ARVADOS_KEEP_PROXY"] = "http://localhost:{}".format(port)
371     _setport('keepproxy', port)
372
373 def stop_keep_proxy():
374     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
375         return
376     kill_server_pid(_pidfile('keepproxy'), wait=0)
377
378 def run_arv_git_httpd():
379     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
380         return
381     stop_arv_git_httpd()
382
383     gitdir = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git')
384     gitport = find_available_port()
385     env = os.environ.copy()
386     env.pop('ARVADOS_API_TOKEN', None)
387     agh = subprocess.Popen(
388         ['arv-git-httpd',
389          '-repo-root='+gitdir+'/test',
390          '-address=:'+str(gitport)],
391         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
392     with open(_pidfile('arv-git-httpd'), 'w') as f:
393         f.write(str(agh.pid))
394     _setport('arv-git-httpd', gitport)
395
396 def stop_arv_git_httpd():
397     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
398         return
399     kill_server_pid(_pidfile('arv-git-httpd'), wait=0)
400
401 def run_nginx():
402     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
403         return
404     nginxconf = {}
405     nginxconf['KEEPPROXYPORT'] = _getport('keepproxy')
406     nginxconf['KEEPPROXYSSLPORT'] = find_available_port()
407     nginxconf['GITPORT'] = _getport('arv-git-httpd')
408     nginxconf['GITSSLPORT'] = find_available_port()
409     nginxconf['SSLCERT'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem')
410     nginxconf['SSLKEY'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.key')
411
412     conftemplatefile = os.path.join(MY_DIRNAME, 'nginx.conf')
413     conffile = os.path.join(TEST_TMPDIR, 'nginx.conf')
414     with open(conffile, 'w') as f:
415         f.write(re.sub(
416             r'{{([A-Z]+)}}',
417             lambda match: str(nginxconf.get(match.group(1))),
418             open(conftemplatefile).read()))
419
420     env = os.environ.copy()
421     env['PATH'] = env['PATH']+':/sbin:/usr/sbin:/usr/local/sbin'
422     nginx = subprocess.Popen(
423         ['nginx',
424          '-g', 'error_log stderr info;',
425          '-g', 'pid '+_pidfile('nginx')+';',
426          '-c', conffile],
427         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
428     _setport('keepproxy-ssl', nginxconf['KEEPPROXYSSLPORT'])
429     _setport('arv-git-httpd-ssl', nginxconf['GITSSLPORT'])
430
431 def stop_nginx():
432     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
433         return
434     kill_server_pid(_pidfile('nginx'), wait=0)
435
436 def _pidfile(program):
437     return os.path.join(TEST_TMPDIR, program + '.pid')
438
439 def _portfile(program):
440     return os.path.join(TEST_TMPDIR, program + '.port')
441
442 def _setport(program, port):
443     with open(_portfile(program), 'w') as f:
444         f.write(str(port))
445
446 # Returns 9 if program is not up.
447 def _getport(program):
448     try:
449         return int(open(_portfile(program)).read())
450     except IOError:
451         return 9
452
453 def _apiconfig(key):
454     if _cached_config:
455         return _cached_config[key]
456     def _load(f):
457         return yaml.load(os.path.join(SERVICES_SRC_DIR, 'api', 'config', f))
458     cdefault = _load('application.default.yml')
459     csite = _load('application.yml')
460     _cached_config = {}
461     for section in [cdefault.get('common',{}), cdefault.get('test',{}),
462                     csite.get('common',{}), csite.get('test',{})]:
463         _cached_config.update(section)
464     return _cached_config[key]
465
466 def fixture(fix):
467     '''load a fixture yaml file'''
468     with open(os.path.join(SERVICES_SRC_DIR, 'api', "test", "fixtures",
469                            fix + ".yml")) as f:
470         yaml_file = f.read()
471         try:
472           trim_index = yaml_file.index("# Test Helper trims the rest of the file")
473           yaml_file = yaml_file[0:trim_index]
474         except ValueError:
475           pass
476         return yaml.load(yaml_file)
477
478 def auth_token(token_name):
479     return fixture("api_client_authorizations")[token_name]["api_token"]
480
481 def authorize_with(token_name):
482     '''token_name is the symbolic name of the token from the api_client_authorizations fixture'''
483     arvados.config.settings()["ARVADOS_API_TOKEN"] = auth_token(token_name)
484     arvados.config.settings()["ARVADOS_API_HOST"] = os.environ.get("ARVADOS_API_HOST")
485     arvados.config.settings()["ARVADOS_API_HOST_INSECURE"] = "true"
486
487 class TestCaseWithServers(unittest.TestCase):
488     """TestCase to start and stop supporting Arvados servers.
489
490     Define any of MAIN_SERVER, KEEP_SERVER, and/or KEEP_PROXY_SERVER
491     class variables as a dictionary of keyword arguments.  If you do,
492     setUpClass will start the corresponding servers by passing these
493     keyword arguments to the run, run_keep, and/or run_keep_server
494     functions, respectively.  It will also set Arvados environment
495     variables to point to these servers appropriately.  If you don't
496     run a Keep or Keep proxy server, setUpClass will set up a
497     temporary directory for Keep local storage, and set it as
498     KEEP_LOCAL_STORE.
499
500     tearDownClass will stop any servers started, and restore the
501     original environment.
502     """
503     MAIN_SERVER = None
504     KEEP_SERVER = None
505     KEEP_PROXY_SERVER = None
506
507     @staticmethod
508     def _restore_dict(src, dest):
509         for key in dest.keys():
510             if key not in src:
511                 del dest[key]
512         dest.update(src)
513
514     @classmethod
515     def setUpClass(cls):
516         cls._orig_environ = os.environ.copy()
517         cls._orig_config = arvados.config.settings().copy()
518         cls._cleanup_funcs = []
519         os.environ.pop('ARVADOS_KEEP_PROXY', None)
520         os.environ.pop('ARVADOS_EXTERNAL_CLIENT', None)
521         for server_kwargs, start_func, stop_func in (
522                 (cls.MAIN_SERVER, run, reset),
523                 (cls.KEEP_SERVER, run_keep, stop_keep),
524                 (cls.KEEP_PROXY_SERVER, run_keep_proxy, stop_keep_proxy)):
525             if server_kwargs is not None:
526                 start_func(**server_kwargs)
527                 cls._cleanup_funcs.append(stop_func)
528         if (cls.KEEP_SERVER is None) and (cls.KEEP_PROXY_SERVER is None):
529             cls.local_store = tempfile.mkdtemp()
530             os.environ['KEEP_LOCAL_STORE'] = cls.local_store
531             cls._cleanup_funcs.append(
532                 lambda: shutil.rmtree(cls.local_store, ignore_errors=True))
533         else:
534             os.environ.pop('KEEP_LOCAL_STORE', None)
535         arvados.config.initialize()
536
537     @classmethod
538     def tearDownClass(cls):
539         for clean_func in cls._cleanup_funcs:
540             clean_func()
541         cls._restore_dict(cls._orig_environ, os.environ)
542         cls._restore_dict(cls._orig_config, arvados.config.settings())
543
544
545 if __name__ == "__main__":
546     actions = [
547         'start', 'stop',
548         'start_keep', 'stop_keep',
549         'start_keep_proxy', 'stop_keep_proxy',
550         'start_arv-git-httpd', 'stop_arv-git-httpd',
551         'start_nginx', 'stop_nginx',
552     ]
553     parser = argparse.ArgumentParser()
554     parser.add_argument('action', type=str, help="one of {}".format(actions))
555     parser.add_argument('--auth', type=str, metavar='FIXTURE_NAME', help='Print authorization info for given api_client_authorizations fixture')
556     args = parser.parse_args()
557
558     if args.action not in actions:
559         print("Unrecognized action '{}'. Actions are: {}.".format(args.action, actions), file=sys.stderr)
560         sys.exit(1)
561     if args.action == 'start':
562         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
563         run(leave_running_atexit=True)
564         host = os.environ['ARVADOS_API_HOST']
565         if args.auth is not None:
566             token = auth_token(args.auth)
567             print("export ARVADOS_API_TOKEN={}".format(pipes.quote(token)))
568             print("export ARVADOS_API_HOST={}".format(pipes.quote(host)))
569             print("export ARVADOS_API_HOST_INSECURE=true")
570         else:
571             print(host)
572     elif args.action == 'stop':
573         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
574     elif args.action == 'start_keep':
575         run_keep()
576     elif args.action == 'stop_keep':
577         stop_keep()
578     elif args.action == 'start_keep_proxy':
579         run_keep_proxy()
580     elif args.action == 'stop_keep_proxy':
581         stop_keep_proxy()
582     elif args.action == 'start_arv-git-httpd':
583         run_arv_git_httpd()
584     elif args.action == 'stop_arv-git-httpd':
585         stop_arv_git_httpd()
586     elif args.action == 'start_nginx':
587         run_nginx()
588     elif args.action == 'stop_nginx':
589         stop_nginx()
590     else:
591         raise Exception("action recognized but not implemented!?")