Merge branch '6279-web-shell-client' closes #6279
[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, required=True):
457         fullpath = os.path.join(SERVICES_SRC_DIR, 'api', 'config', f)
458         if not required and not os.path.exists(fullpath):
459             return {}
460         return yaml.load(fullpath)
461     cdefault = _load('application.default.yml')
462     csite = _load('application.yml', required=False)
463     _cached_config = {}
464     for section in [cdefault.get('common',{}), cdefault.get('test',{}),
465                     csite.get('common',{}), csite.get('test',{})]:
466         _cached_config.update(section)
467     return _cached_config[key]
468
469 def fixture(fix):
470     '''load a fixture yaml file'''
471     with open(os.path.join(SERVICES_SRC_DIR, 'api', "test", "fixtures",
472                            fix + ".yml")) as f:
473         yaml_file = f.read()
474         try:
475           trim_index = yaml_file.index("# Test Helper trims the rest of the file")
476           yaml_file = yaml_file[0:trim_index]
477         except ValueError:
478           pass
479         return yaml.load(yaml_file)
480
481 def auth_token(token_name):
482     return fixture("api_client_authorizations")[token_name]["api_token"]
483
484 def authorize_with(token_name):
485     '''token_name is the symbolic name of the token from the api_client_authorizations fixture'''
486     arvados.config.settings()["ARVADOS_API_TOKEN"] = auth_token(token_name)
487     arvados.config.settings()["ARVADOS_API_HOST"] = os.environ.get("ARVADOS_API_HOST")
488     arvados.config.settings()["ARVADOS_API_HOST_INSECURE"] = "true"
489
490 class TestCaseWithServers(unittest.TestCase):
491     """TestCase to start and stop supporting Arvados servers.
492
493     Define any of MAIN_SERVER, KEEP_SERVER, and/or KEEP_PROXY_SERVER
494     class variables as a dictionary of keyword arguments.  If you do,
495     setUpClass will start the corresponding servers by passing these
496     keyword arguments to the run, run_keep, and/or run_keep_server
497     functions, respectively.  It will also set Arvados environment
498     variables to point to these servers appropriately.  If you don't
499     run a Keep or Keep proxy server, setUpClass will set up a
500     temporary directory for Keep local storage, and set it as
501     KEEP_LOCAL_STORE.
502
503     tearDownClass will stop any servers started, and restore the
504     original environment.
505     """
506     MAIN_SERVER = None
507     KEEP_SERVER = None
508     KEEP_PROXY_SERVER = None
509
510     @staticmethod
511     def _restore_dict(src, dest):
512         for key in dest.keys():
513             if key not in src:
514                 del dest[key]
515         dest.update(src)
516
517     @classmethod
518     def setUpClass(cls):
519         cls._orig_environ = os.environ.copy()
520         cls._orig_config = arvados.config.settings().copy()
521         cls._cleanup_funcs = []
522         os.environ.pop('ARVADOS_KEEP_PROXY', None)
523         os.environ.pop('ARVADOS_EXTERNAL_CLIENT', None)
524         for server_kwargs, start_func, stop_func in (
525                 (cls.MAIN_SERVER, run, reset),
526                 (cls.KEEP_SERVER, run_keep, stop_keep),
527                 (cls.KEEP_PROXY_SERVER, run_keep_proxy, stop_keep_proxy)):
528             if server_kwargs is not None:
529                 start_func(**server_kwargs)
530                 cls._cleanup_funcs.append(stop_func)
531         if (cls.KEEP_SERVER is None) and (cls.KEEP_PROXY_SERVER is None):
532             cls.local_store = tempfile.mkdtemp()
533             os.environ['KEEP_LOCAL_STORE'] = cls.local_store
534             cls._cleanup_funcs.append(
535                 lambda: shutil.rmtree(cls.local_store, ignore_errors=True))
536         else:
537             os.environ.pop('KEEP_LOCAL_STORE', None)
538         arvados.config.initialize()
539
540     @classmethod
541     def tearDownClass(cls):
542         for clean_func in cls._cleanup_funcs:
543             clean_func()
544         cls._restore_dict(cls._orig_environ, os.environ)
545         cls._restore_dict(cls._orig_config, arvados.config.settings())
546
547
548 if __name__ == "__main__":
549     actions = [
550         'start', 'stop',
551         'start_keep', 'stop_keep',
552         'start_keep_proxy', 'stop_keep_proxy',
553         'start_arv-git-httpd', 'stop_arv-git-httpd',
554         'start_nginx', 'stop_nginx',
555     ]
556     parser = argparse.ArgumentParser()
557     parser.add_argument('action', type=str, help="one of {}".format(actions))
558     parser.add_argument('--auth', type=str, metavar='FIXTURE_NAME', help='Print authorization info for given api_client_authorizations fixture')
559     args = parser.parse_args()
560
561     if args.action not in actions:
562         print("Unrecognized action '{}'. Actions are: {}.".format(args.action, actions), file=sys.stderr)
563         sys.exit(1)
564     if args.action == 'start':
565         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
566         run(leave_running_atexit=True)
567         host = os.environ['ARVADOS_API_HOST']
568         if args.auth is not None:
569             token = auth_token(args.auth)
570             print("export ARVADOS_API_TOKEN={}".format(pipes.quote(token)))
571             print("export ARVADOS_API_HOST={}".format(pipes.quote(host)))
572             print("export ARVADOS_API_HOST_INSECURE=true")
573         else:
574             print(host)
575     elif args.action == 'stop':
576         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
577     elif args.action == 'start_keep':
578         run_keep()
579     elif args.action == 'stop_keep':
580         stop_keep()
581     elif args.action == 'start_keep_proxy':
582         run_keep_proxy()
583     elif args.action == 'stop_keep_proxy':
584         stop_keep_proxy()
585     elif args.action == 'start_arv-git-httpd':
586         run_arv_git_httpd()
587     elif args.action == 'stop_arv-git-httpd':
588         stop_arv_git_httpd()
589     elif args.action == 'start_nginx':
590         run_nginx()
591     elif args.action == 'stop_nginx':
592         stop_nginx()
593     else:
594         raise Exception("action recognized but not implemented!?")