12020: Fix unreliable test.
[arvados.git] / sdk / python / tests / run_test_server.py
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: Apache-2.0
4
5 from __future__ import print_function
6 from __future__ import division
7 from builtins import str
8 from builtins import range
9 import argparse
10 import atexit
11 import errno
12 import glob
13 import httplib2
14 import os
15 import pipes
16 import random
17 import re
18 import shutil
19 import signal
20 import socket
21 import string
22 import subprocess
23 import sys
24 import tempfile
25 import time
26 import unittest
27 import yaml
28
29 MY_DIRNAME = os.path.dirname(os.path.realpath(__file__))
30 if __name__ == '__main__' and os.path.exists(
31       os.path.join(MY_DIRNAME, '..', 'arvados', '__init__.py')):
32     # We're being launched to support another test suite.
33     # Add the Python SDK source to the library path.
34     sys.path.insert(1, os.path.dirname(MY_DIRNAME))
35
36 import arvados
37 import arvados.config
38
39 ARVADOS_DIR = os.path.realpath(os.path.join(MY_DIRNAME, '../../..'))
40 SERVICES_SRC_DIR = os.path.join(ARVADOS_DIR, 'services')
41 if 'GOPATH' in os.environ:
42     # Add all GOPATH bin dirs to PATH -- but insert them after the
43     # ruby gems bin dir, to ensure "bundle" runs the Ruby bundler
44     # command, not the golang.org/x/tools/cmd/bundle command.
45     gopaths = os.environ['GOPATH'].split(':')
46     addbins = [os.path.join(path, 'bin') for path in gopaths]
47     newbins = []
48     for path in os.environ['PATH'].split(':'):
49         newbins.append(path)
50         if os.path.exists(os.path.join(path, 'bundle')):
51             newbins += addbins
52             addbins = []
53     newbins += addbins
54     os.environ['PATH'] = ':'.join(newbins)
55
56 TEST_TMPDIR = os.path.join(ARVADOS_DIR, 'tmp')
57 if not os.path.exists(TEST_TMPDIR):
58     os.mkdir(TEST_TMPDIR)
59
60 my_api_host = None
61 _cached_config = {}
62 _cached_db_config = {}
63
64 def find_server_pid(PID_PATH, wait=10):
65     now = time.time()
66     timeout = now + wait
67     good_pid = False
68     while (not good_pid) and (now <= timeout):
69         time.sleep(0.2)
70         try:
71             with open(PID_PATH, 'r') as f:
72                 server_pid = int(f.read())
73             good_pid = (os.kill(server_pid, 0) is None)
74         except EnvironmentError:
75             good_pid = False
76         now = time.time()
77
78     if not good_pid:
79         return None
80
81     return server_pid
82
83 def kill_server_pid(pidfile, wait=10, passenger_root=False):
84     # Must re-import modules in order to work during atexit
85     import os
86     import signal
87     import subprocess
88     import time
89
90     now = time.time()
91     startTERM = now
92     deadline = now + wait
93
94     if passenger_root:
95         # First try to shut down nicely
96         restore_cwd = os.getcwd()
97         os.chdir(passenger_root)
98         subprocess.call([
99             'bundle', 'exec', 'passenger', 'stop', '--pid-file', pidfile])
100         os.chdir(restore_cwd)
101         # Use up to half of the +wait+ period waiting for "passenger
102         # stop" to work. If the process hasn't exited by then, start
103         # sending TERM signals.
104         startTERM += wait//2
105
106     server_pid = None
107     while now <= deadline and server_pid is None:
108         try:
109             with open(pidfile, 'r') as f:
110                 server_pid = int(f.read())
111         except IOError:
112             # No pidfile = nothing to kill.
113             return
114         except ValueError as error:
115             # Pidfile exists, but we can't parse it. Perhaps the
116             # server has created the file but hasn't written its PID
117             # yet?
118             print("Parse error reading pidfile {}: {}".format(pidfile, error),
119                   file=sys.stderr)
120             time.sleep(0.1)
121             now = time.time()
122
123     while now <= deadline:
124         try:
125             exited, _ = os.waitpid(server_pid, os.WNOHANG)
126             if exited > 0:
127                 _remove_pidfile(pidfile)
128                 return
129         except OSError:
130             # already exited, or isn't our child process
131             pass
132         try:
133             if now >= startTERM:
134                 os.kill(server_pid, signal.SIGTERM)
135                 print("Sent SIGTERM to {} ({})".format(server_pid, pidfile),
136                       file=sys.stderr)
137         except OSError as error:
138             if error.errno == errno.ESRCH:
139                 # Thrown by os.getpgid() or os.kill() if the process
140                 # does not exist, i.e., our work here is done.
141                 _remove_pidfile(pidfile)
142                 return
143             raise
144         time.sleep(0.1)
145         now = time.time()
146
147     print("Server PID {} ({}) did not exit, giving up after {}s".
148           format(server_pid, pidfile, wait),
149           file=sys.stderr)
150
151 def _remove_pidfile(pidfile):
152     try:
153         os.unlink(pidfile)
154     except:
155         if os.path.lexists(pidfile):
156             raise
157
158 def find_available_port():
159     """Return an IPv4 port number that is not in use right now.
160
161     We assume whoever needs to use the returned port is able to reuse
162     a recently used port without waiting for TIME_WAIT (see
163     SO_REUSEADDR / SO_REUSEPORT).
164
165     Some opportunity for races here, but it's better than choosing
166     something at random and not checking at all. If all of our servers
167     (hey Passenger) knew that listening on port 0 was a thing, the OS
168     would take care of the races, and this wouldn't be needed at all.
169     """
170
171     sock = socket.socket()
172     sock.bind(('0.0.0.0', 0))
173     port = sock.getsockname()[1]
174     sock.close()
175     return port
176
177 def _wait_until_port_listens(port, timeout=10, warn=True):
178     """Wait for a process to start listening on the given port.
179
180     If nothing listens on the port within the specified timeout (given
181     in seconds), print a warning on stderr before returning.
182     """
183     try:
184         subprocess.check_output(['which', 'netstat'])
185     except subprocess.CalledProcessError:
186         print("WARNING: No `netstat` -- cannot wait for port to listen. "+
187               "Sleeping 0.5 and hoping for the best.",
188               file=sys.stderr)
189         time.sleep(0.5)
190         return
191     deadline = time.time() + timeout
192     while time.time() < deadline:
193         if re.search(r'\ntcp.*:'+str(port)+' .* LISTEN *\n', subprocess.check_output(['netstat', '-Wln']).decode()):
194             return True
195         time.sleep(0.1)
196     if warn:
197         print(
198             "WARNING: Nothing is listening on port {} (waited {} seconds).".
199             format(port, timeout),
200             file=sys.stderr)
201     return False
202
203 def _logfilename(label):
204     """Set up a labelled log file, and return a path to write logs to.
205
206     Normally, the returned path is {tmpdir}/{label}.log.
207
208     In debug mode, logs are also written to stderr, with [label]
209     prepended to each line. The returned path is a FIFO.
210
211     +label+ should contain only alphanumerics: it is also used as part
212     of the FIFO filename.
213
214     """
215     logfilename = os.path.join(TEST_TMPDIR, label+'.log')
216     if not os.environ.get('ARVADOS_DEBUG', ''):
217         return logfilename
218     fifo = os.path.join(TEST_TMPDIR, label+'.fifo')
219     try:
220         os.remove(fifo)
221     except OSError as error:
222         if error.errno != errno.ENOENT:
223             raise
224     os.mkfifo(fifo, 0o700)
225     stdbuf = ['stdbuf', '-i0', '-oL', '-eL']
226     # open(fifo, 'r') would block waiting for someone to open the fifo
227     # for writing, so we need a separate cat process to open it for
228     # us.
229     cat = subprocess.Popen(
230         stdbuf+['cat', fifo],
231         stdin=open('/dev/null'),
232         stdout=subprocess.PIPE)
233     tee = subprocess.Popen(
234         stdbuf+['tee', '-a', logfilename],
235         stdin=cat.stdout,
236         stdout=subprocess.PIPE)
237     subprocess.Popen(
238         stdbuf+['sed', '-e', 's/^/['+label+'] /'],
239         stdin=tee.stdout,
240         stdout=sys.stderr)
241     return fifo
242
243 def run(leave_running_atexit=False):
244     """Ensure an API server is running, and ARVADOS_API_* env vars have
245     admin credentials for it.
246
247     If ARVADOS_TEST_API_HOST is set, a parent process has started a
248     test server for us to use: we just need to reset() it using the
249     admin token fixture.
250
251     If a previous call to run() started a new server process, and it
252     is still running, we just need to reset() it to fixture state and
253     return.
254
255     If neither of those options work out, we'll really start a new
256     server.
257     """
258     global my_api_host
259
260     # Delete cached discovery documents.
261     #
262     # This will clear cached docs that belong to other processes (like
263     # concurrent test suites) even if they're still running. They should
264     # be able to tolerate that.
265     for fn in glob.glob(os.path.join(
266             str(arvados.http_cache('discovery')),
267             '*,arvados,v1,rest,*')):
268         os.unlink(fn)
269
270     pid_file = _pidfile('api')
271     pid_file_ok = find_server_pid(pid_file, 0)
272
273     existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
274     if existing_api_host and pid_file_ok:
275         if existing_api_host == my_api_host:
276             try:
277                 return reset()
278             except:
279                 # Fall through to shutdown-and-start case.
280                 pass
281         else:
282             # Server was provided by parent. Can't recover if it's
283             # unresettable.
284             return reset()
285
286     # Before trying to start up our own server, call stop() to avoid
287     # "Phusion Passenger Standalone is already running on PID 12345".
288     # (If we've gotten this far, ARVADOS_TEST_API_HOST isn't set, so
289     # we know the server is ours to kill.)
290     stop(force=True)
291
292     restore_cwd = os.getcwd()
293     api_src_dir = os.path.join(SERVICES_SRC_DIR, 'api')
294     os.chdir(api_src_dir)
295
296     # Either we haven't started a server of our own yet, or it has
297     # died, or we have lost our credentials, or something else is
298     # preventing us from calling reset(). Start a new one.
299
300     if not os.path.exists('tmp'):
301         os.makedirs('tmp')
302
303     if not os.path.exists('tmp/api'):
304         os.makedirs('tmp/api')
305
306     if not os.path.exists('tmp/logs'):
307         os.makedirs('tmp/logs')
308
309     # Install the git repository fixtures.
310     gitdir = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git')
311     gittarball = os.path.join(SERVICES_SRC_DIR, 'api', 'test', 'test.git.tar')
312     if not os.path.isdir(gitdir):
313         os.makedirs(gitdir)
314     subprocess.check_output(['tar', '-xC', gitdir, '-f', gittarball])
315
316     # The nginx proxy isn't listening here yet, but we need to choose
317     # the wss:// port now so we can write the API server config file.
318     wss_port = find_available_port()
319     _setport('wss', wss_port)
320
321     port = find_available_port()
322     env = os.environ.copy()
323     env['RAILS_ENV'] = 'test'
324     env['ARVADOS_TEST_WSS_PORT'] = str(wss_port)
325     env.pop('ARVADOS_WEBSOCKETS', None)
326     env.pop('ARVADOS_TEST_API_HOST', None)
327     env.pop('ARVADOS_API_HOST', None)
328     env.pop('ARVADOS_API_HOST_INSECURE', None)
329     env.pop('ARVADOS_API_TOKEN', None)
330     start_msg = subprocess.check_output(
331         ['bundle', 'exec',
332          'passenger', 'start', '-d', '-p{}'.format(port),
333          '--pid-file', pid_file,
334          '--log-file', os.path.join(os.getcwd(), 'log/test.log'),
335          '--ssl',
336          '--ssl-certificate', 'tmp/self-signed.pem',
337          '--ssl-certificate-key', 'tmp/self-signed.key'],
338         env=env)
339
340     if not leave_running_atexit:
341         atexit.register(kill_server_pid, pid_file, passenger_root=api_src_dir)
342
343     match = re.search(r'Accessible via: https://(.*?)/', start_msg)
344     if not match:
345         raise Exception(
346             "Passenger did not report endpoint: {}".format(start_msg))
347     my_api_host = match.group(1)
348     os.environ['ARVADOS_API_HOST'] = my_api_host
349
350     # Make sure the server has written its pid file and started
351     # listening on its TCP port
352     find_server_pid(pid_file)
353     _wait_until_port_listens(port)
354
355     reset()
356     os.chdir(restore_cwd)
357
358 def reset():
359     """Reset the test server to fixture state.
360
361     This resets the ARVADOS_TEST_API_HOST provided by a parent process
362     if any, otherwise the server started by run().
363
364     It also resets ARVADOS_* environment vars to point to the test
365     server with admin credentials.
366     """
367     existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
368     token = auth_token('admin')
369     httpclient = httplib2.Http(ca_certs=os.path.join(
370         SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem'))
371     httpclient.request(
372         'https://{}/database/reset'.format(existing_api_host),
373         'POST',
374         headers={'Authorization': 'OAuth2 {}'.format(token), 'Connection':'close'})
375
376     os.environ['ARVADOS_API_HOST_INSECURE'] = 'true'
377     os.environ['ARVADOS_API_TOKEN'] = token
378     if _wait_until_port_listens(_getport('controller-ssl'), timeout=0.5, warn=False):
379         os.environ['ARVADOS_API_HOST'] = '0.0.0.0:'+str(_getport('controller-ssl'))
380     else:
381         os.environ['ARVADOS_API_HOST'] = existing_api_host
382
383 def stop(force=False):
384     """Stop the API server, if one is running.
385
386     If force==False, kill it only if we started it ourselves. (This
387     supports the use case where a Python test suite calls run(), but
388     run() just uses the ARVADOS_TEST_API_HOST provided by the parent
389     process, and the test suite cleans up after itself by calling
390     stop(). In this case the test server provided by the parent
391     process should be left alone.)
392
393     If force==True, kill it even if we didn't start it
394     ourselves. (This supports the use case in __main__, where "run"
395     and "stop" happen in different processes.)
396     """
397     global my_api_host
398     if force or my_api_host is not None:
399         kill_server_pid(_pidfile('api'))
400         my_api_host = None
401
402 def run_controller():
403     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
404         return
405     stop_controller()
406     rails_api_port = int(string.split(os.environ.get('ARVADOS_TEST_API_HOST', my_api_host), ':')[-1])
407     port = find_available_port()
408     conf = os.path.join(TEST_TMPDIR, 'arvados.yml')
409     with open(conf, 'w') as f:
410         f.write("""
411 Clusters:
412   zzzzz:
413     EnableBetaController14287: {beta14287}
414     ManagementToken: e687950a23c3a9bceec28c6223a06c79
415     API:
416       RequestTimeout: 30s
417     Logging:
418         Level: "{loglevel}"
419     HTTPRequestTimeout: 30s
420     PostgreSQL:
421       ConnectionPool: 32
422       Connection:
423         host: {dbhost}
424         dbname: {dbname}
425         user: {dbuser}
426         password: {dbpass}
427     TLS:
428       Insecure: true
429     Services:
430       Controller:
431         InternalURLs:
432           "http://localhost:{controllerport}": {{}}
433       RailsAPI:
434         InternalURLs:
435           "https://localhost:{railsport}": {{}}
436         """.format(
437             beta14287=('true' if '14287' in os.environ.get('ARVADOS_EXPERIMENTAL', '') else 'false'),
438             loglevel=('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
439             dbhost=_dbconfig('host'),
440             dbname=_dbconfig('dbname'),
441             dbuser=_dbconfig('user'),
442             dbpass=_dbconfig('password'),
443             controllerport=port,
444             railsport=rails_api_port,
445         ))
446     logf = open(_logfilename('controller'), 'a')
447     controller = subprocess.Popen(
448         ["arvados-server", "controller", "-config", conf],
449         stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
450     with open(_pidfile('controller'), 'w') as f:
451         f.write(str(controller.pid))
452     _wait_until_port_listens(port)
453     _setport('controller', port)
454     return port
455
456 def stop_controller():
457     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
458         return
459     kill_server_pid(_pidfile('controller'))
460
461 def run_ws():
462     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
463         return
464     stop_ws()
465     port = find_available_port()
466     conf = os.path.join(TEST_TMPDIR, 'ws.yml')
467     with open(conf, 'w') as f:
468         f.write("""
469 Client:
470   APIHost: {}
471   Insecure: true
472 Listen: :{}
473 LogLevel: {}
474 Postgres:
475   host: {}
476   dbname: {}
477   user: {}
478   password: {}
479   sslmode: require
480         """.format(os.environ['ARVADOS_API_HOST'],
481                    port,
482                    ('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
483                    _dbconfig('host'),
484                    _dbconfig('dbname'),
485                    _dbconfig('user'),
486                    _dbconfig('password')))
487     logf = open(_logfilename('ws'), 'a')
488     ws = subprocess.Popen(
489         ["ws", "-config", conf],
490         stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
491     with open(_pidfile('ws'), 'w') as f:
492         f.write(str(ws.pid))
493     _wait_until_port_listens(port)
494     _setport('ws', port)
495     return port
496
497 def stop_ws():
498     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
499         return
500     kill_server_pid(_pidfile('ws'))
501
502 def _start_keep(n, keep_args):
503     keep0 = tempfile.mkdtemp()
504     port = find_available_port()
505     keep_cmd = ["keepstore",
506                 "-volume={}".format(keep0),
507                 "-listen=:{}".format(port),
508                 "-pid="+_pidfile('keep{}'.format(n))]
509
510     for arg, val in keep_args.items():
511         keep_cmd.append("{}={}".format(arg, val))
512
513     with open(_logfilename('keep{}'.format(n)), 'a') as logf:
514         with open('/dev/null') as _stdin:
515             kp0 = subprocess.Popen(
516                 keep_cmd, stdin=_stdin, stdout=logf, stderr=logf, close_fds=True)
517
518     with open(_pidfile('keep{}'.format(n)), 'w') as f:
519         f.write(str(kp0.pid))
520
521     with open("{}/keep{}.volume".format(TEST_TMPDIR, n), 'w') as f:
522         f.write(keep0)
523
524     _wait_until_port_listens(port)
525
526     return port
527
528 def run_keep(blob_signing_key=None, enforce_permissions=False, num_servers=2):
529     stop_keep(num_servers)
530
531     keep_args = {}
532     if not blob_signing_key:
533         blob_signing_key = 'zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc'
534     with open(os.path.join(TEST_TMPDIR, "keep.blob_signing_key"), "w") as f:
535         keep_args['-blob-signing-key-file'] = f.name
536         f.write(blob_signing_key)
537     keep_args['-enforce-permissions'] = str(enforce_permissions).lower()
538     with open(os.path.join(TEST_TMPDIR, "keep.data-manager-token-file"), "w") as f:
539         keep_args['-data-manager-token-file'] = f.name
540         f.write(auth_token('data_manager'))
541     keep_args['-never-delete'] = 'false'
542
543     api = arvados.api(
544         version='v1',
545         host=os.environ['ARVADOS_API_HOST'],
546         token=os.environ['ARVADOS_API_TOKEN'],
547         insecure=True)
548
549     for d in api.keep_services().list(filters=[['service_type','=','disk']]).execute()['items']:
550         api.keep_services().delete(uuid=d['uuid']).execute()
551     for d in api.keep_disks().list().execute()['items']:
552         api.keep_disks().delete(uuid=d['uuid']).execute()
553
554     for d in range(0, num_servers):
555         port = _start_keep(d, keep_args)
556         svc = api.keep_services().create(body={'keep_service': {
557             'uuid': 'zzzzz-bi6l4-keepdisk{:07d}'.format(d),
558             'service_host': 'localhost',
559             'service_port': port,
560             'service_type': 'disk',
561             'service_ssl_flag': False,
562         }}).execute()
563         api.keep_disks().create(body={
564             'keep_disk': {'keep_service_uuid': svc['uuid'] }
565         }).execute()
566
567     # If keepproxy and/or keep-web is running, send SIGHUP to make
568     # them discover the new keepstore services.
569     for svc in ('keepproxy', 'keep-web'):
570         pidfile = _pidfile('keepproxy')
571         if os.path.exists(pidfile):
572             try:
573                 with open(pidfile) as pid:
574                     os.kill(int(pid.read()), signal.SIGHUP)
575             except OSError:
576                 os.remove(pidfile)
577
578 def _stop_keep(n):
579     kill_server_pid(_pidfile('keep{}'.format(n)))
580     if os.path.exists("{}/keep{}.volume".format(TEST_TMPDIR, n)):
581         with open("{}/keep{}.volume".format(TEST_TMPDIR, n), 'r') as r:
582             shutil.rmtree(r.read(), True)
583         os.unlink("{}/keep{}.volume".format(TEST_TMPDIR, n))
584     if os.path.exists(os.path.join(TEST_TMPDIR, "keep.blob_signing_key")):
585         os.remove(os.path.join(TEST_TMPDIR, "keep.blob_signing_key"))
586
587 def stop_keep(num_servers=2):
588     for n in range(0, num_servers):
589         _stop_keep(n)
590
591 def run_keep_proxy():
592     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
593         os.environ["ARVADOS_KEEP_SERVICES"] = "http://localhost:{}".format(_getport('keepproxy'))
594         return
595     stop_keep_proxy()
596
597     port = find_available_port()
598     env = os.environ.copy()
599     env['ARVADOS_API_TOKEN'] = auth_token('anonymous')
600     logf = open(_logfilename('keepproxy'), 'a')
601     kp = subprocess.Popen(
602         ['keepproxy',
603          '-pid='+_pidfile('keepproxy'),
604          '-listen=:{}'.format(port)],
605         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
606
607     api = arvados.api(
608         version='v1',
609         host=os.environ['ARVADOS_API_HOST'],
610         token=auth_token('admin'),
611         insecure=True)
612     for d in api.keep_services().list(
613             filters=[['service_type','=','proxy']]).execute()['items']:
614         api.keep_services().delete(uuid=d['uuid']).execute()
615     api.keep_services().create(body={'keep_service': {
616         'service_host': 'localhost',
617         'service_port': port,
618         'service_type': 'proxy',
619         'service_ssl_flag': False,
620     }}).execute()
621     os.environ["ARVADOS_KEEP_SERVICES"] = "http://localhost:{}".format(port)
622     _setport('keepproxy', port)
623     _wait_until_port_listens(port)
624
625 def stop_keep_proxy():
626     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
627         return
628     kill_server_pid(_pidfile('keepproxy'))
629
630 def run_arv_git_httpd():
631     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
632         return
633     stop_arv_git_httpd()
634
635     gitdir = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git')
636     gitport = find_available_port()
637     env = os.environ.copy()
638     env.pop('ARVADOS_API_TOKEN', None)
639     logf = open(_logfilename('arv-git-httpd'), 'a')
640     agh = subprocess.Popen(
641         ['arv-git-httpd',
642          '-repo-root='+gitdir+'/test',
643          '-management-token=e687950a23c3a9bceec28c6223a06c79',
644          '-address=:'+str(gitport)],
645         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
646     with open(_pidfile('arv-git-httpd'), 'w') as f:
647         f.write(str(agh.pid))
648     _setport('arv-git-httpd', gitport)
649     _wait_until_port_listens(gitport)
650
651 def stop_arv_git_httpd():
652     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
653         return
654     kill_server_pid(_pidfile('arv-git-httpd'))
655
656 def run_keep_web():
657     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
658         return
659     stop_keep_web()
660
661     keepwebport = find_available_port()
662     env = os.environ.copy()
663     env['ARVADOS_API_TOKEN'] = auth_token('anonymous')
664     logf = open(_logfilename('keep-web'), 'a')
665     keepweb = subprocess.Popen(
666         ['keep-web',
667          '-allow-anonymous',
668          '-attachment-only-host=download',
669          '-management-token=e687950a23c3a9bceec28c6223a06c79',
670          '-listen=:'+str(keepwebport)],
671         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
672     with open(_pidfile('keep-web'), 'w') as f:
673         f.write(str(keepweb.pid))
674     _setport('keep-web', keepwebport)
675     _wait_until_port_listens(keepwebport)
676
677 def stop_keep_web():
678     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
679         return
680     kill_server_pid(_pidfile('keep-web'))
681
682 def run_nginx():
683     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
684         return
685     stop_nginx()
686     nginxconf = {}
687     nginxconf['CONTROLLERPORT'] = _getport('controller')
688     nginxconf['CONTROLLERSSLPORT'] = find_available_port()
689     nginxconf['KEEPWEBPORT'] = _getport('keep-web')
690     nginxconf['KEEPWEBDLSSLPORT'] = find_available_port()
691     nginxconf['KEEPWEBSSLPORT'] = find_available_port()
692     nginxconf['KEEPPROXYPORT'] = _getport('keepproxy')
693     nginxconf['KEEPPROXYSSLPORT'] = find_available_port()
694     nginxconf['GITPORT'] = _getport('arv-git-httpd')
695     nginxconf['GITSSLPORT'] = find_available_port()
696     nginxconf['WSPORT'] = _getport('ws')
697     nginxconf['WSSPORT'] = _getport('wss')
698     nginxconf['SSLCERT'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem')
699     nginxconf['SSLKEY'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.key')
700     nginxconf['ACCESSLOG'] = _logfilename('nginx_access')
701     nginxconf['ERRORLOG'] = _logfilename('nginx_error')
702     nginxconf['TMPDIR'] = TEST_TMPDIR
703
704     conftemplatefile = os.path.join(MY_DIRNAME, 'nginx.conf')
705     conffile = os.path.join(TEST_TMPDIR, 'nginx.conf')
706     with open(conffile, 'w') as f:
707         f.write(re.sub(
708             r'{{([A-Z]+)}}',
709             lambda match: str(nginxconf.get(match.group(1))),
710             open(conftemplatefile).read()))
711
712     env = os.environ.copy()
713     env['PATH'] = env['PATH']+':/sbin:/usr/sbin:/usr/local/sbin'
714
715     nginx = subprocess.Popen(
716         ['nginx',
717          '-g', 'error_log stderr info;',
718          '-g', 'pid '+_pidfile('nginx')+';',
719          '-c', conffile],
720         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
721     _setport('controller-ssl', nginxconf['CONTROLLERSSLPORT'])
722     _setport('keep-web-dl-ssl', nginxconf['KEEPWEBDLSSLPORT'])
723     _setport('keep-web-ssl', nginxconf['KEEPWEBSSLPORT'])
724     _setport('keepproxy-ssl', nginxconf['KEEPPROXYSSLPORT'])
725     _setport('arv-git-httpd-ssl', nginxconf['GITSSLPORT'])
726
727 def stop_nginx():
728     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
729         return
730     kill_server_pid(_pidfile('nginx'))
731
732 def _pidfile(program):
733     return os.path.join(TEST_TMPDIR, program + '.pid')
734
735 def _portfile(program):
736     return os.path.join(TEST_TMPDIR, program + '.port')
737
738 def _setport(program, port):
739     with open(_portfile(program), 'w') as f:
740         f.write(str(port))
741
742 # Returns 9 if program is not up.
743 def _getport(program):
744     try:
745         with open(_portfile(program)) as prog:
746             return int(prog.read())
747     except IOError:
748         return 9
749
750 def _dbconfig(key):
751     global _cached_db_config
752     if not _cached_db_config:
753         if "ARVADOS_CONFIG" in os.environ:
754             _cached_db_config = list(yaml.safe_load(open(os.environ["ARVADOS_CONFIG"]))["Clusters"].values())[0]["PostgreSQL"]["Connection"]
755         else:
756             _cached_db_config = yaml.safe_load(open(os.path.join(
757                 SERVICES_SRC_DIR, 'api', 'config', 'database.yml')))["test"]
758             _cached_db_config["dbname"] = _cached_db_config["database"]
759             _cached_db_config["user"] = _cached_db_config["username"]
760     return _cached_db_config[key]
761
762 def _apiconfig(key):
763     global _cached_config
764     if _cached_config:
765         return _cached_config[key]
766     def _load(f, required=True):
767         fullpath = os.path.join(SERVICES_SRC_DIR, 'api', 'config', f)
768         if not required and not os.path.exists(fullpath):
769             return {}
770         return yaml.safe_load(fullpath)
771     cdefault = _load('application.default.yml')
772     csite = _load('application.yml', required=False)
773     _cached_config = {}
774     for section in [cdefault.get('common',{}), cdefault.get('test',{}),
775                     csite.get('common',{}), csite.get('test',{})]:
776         _cached_config.update(section)
777     return _cached_config[key]
778
779 def fixture(fix):
780     '''load a fixture yaml file'''
781     with open(os.path.join(SERVICES_SRC_DIR, 'api', "test", "fixtures",
782                            fix + ".yml")) as f:
783         yaml_file = f.read()
784         try:
785           trim_index = yaml_file.index("# Test Helper trims the rest of the file")
786           yaml_file = yaml_file[0:trim_index]
787         except ValueError:
788           pass
789         return yaml.safe_load(yaml_file)
790
791 def auth_token(token_name):
792     return fixture("api_client_authorizations")[token_name]["api_token"]
793
794 def authorize_with(token_name):
795     '''token_name is the symbolic name of the token from the api_client_authorizations fixture'''
796     arvados.config.settings()["ARVADOS_API_TOKEN"] = auth_token(token_name)
797     arvados.config.settings()["ARVADOS_API_HOST"] = os.environ.get("ARVADOS_API_HOST")
798     arvados.config.settings()["ARVADOS_API_HOST_INSECURE"] = "true"
799
800 class TestCaseWithServers(unittest.TestCase):
801     """TestCase to start and stop supporting Arvados servers.
802
803     Define any of MAIN_SERVER, KEEP_SERVER, and/or KEEP_PROXY_SERVER
804     class variables as a dictionary of keyword arguments.  If you do,
805     setUpClass will start the corresponding servers by passing these
806     keyword arguments to the run, run_keep, and/or run_keep_server
807     functions, respectively.  It will also set Arvados environment
808     variables to point to these servers appropriately.  If you don't
809     run a Keep or Keep proxy server, setUpClass will set up a
810     temporary directory for Keep local storage, and set it as
811     KEEP_LOCAL_STORE.
812
813     tearDownClass will stop any servers started, and restore the
814     original environment.
815     """
816     MAIN_SERVER = None
817     WS_SERVER = None
818     KEEP_SERVER = None
819     KEEP_PROXY_SERVER = None
820     KEEP_WEB_SERVER = None
821
822     @staticmethod
823     def _restore_dict(src, dest):
824         for key in list(dest.keys()):
825             if key not in src:
826                 del dest[key]
827         dest.update(src)
828
829     @classmethod
830     def setUpClass(cls):
831         cls._orig_environ = os.environ.copy()
832         cls._orig_config = arvados.config.settings().copy()
833         cls._cleanup_funcs = []
834         os.environ.pop('ARVADOS_KEEP_SERVICES', None)
835         os.environ.pop('ARVADOS_EXTERNAL_CLIENT', None)
836         for server_kwargs, start_func, stop_func in (
837                 (cls.MAIN_SERVER, run, reset),
838                 (cls.WS_SERVER, run_ws, stop_ws),
839                 (cls.KEEP_SERVER, run_keep, stop_keep),
840                 (cls.KEEP_PROXY_SERVER, run_keep_proxy, stop_keep_proxy),
841                 (cls.KEEP_WEB_SERVER, run_keep_web, stop_keep_web)):
842             if server_kwargs is not None:
843                 start_func(**server_kwargs)
844                 cls._cleanup_funcs.append(stop_func)
845         if (cls.KEEP_SERVER is None) and (cls.KEEP_PROXY_SERVER is None):
846             cls.local_store = tempfile.mkdtemp()
847             os.environ['KEEP_LOCAL_STORE'] = cls.local_store
848             cls._cleanup_funcs.append(
849                 lambda: shutil.rmtree(cls.local_store, ignore_errors=True))
850         else:
851             os.environ.pop('KEEP_LOCAL_STORE', None)
852         arvados.config.initialize()
853
854     @classmethod
855     def tearDownClass(cls):
856         for clean_func in cls._cleanup_funcs:
857             clean_func()
858         cls._restore_dict(cls._orig_environ, os.environ)
859         cls._restore_dict(cls._orig_config, arvados.config.settings())
860
861
862 if __name__ == "__main__":
863     actions = [
864         'start', 'stop',
865         'start_ws', 'stop_ws',
866         'start_controller', 'stop_controller',
867         'start_keep', 'stop_keep',
868         'start_keep_proxy', 'stop_keep_proxy',
869         'start_keep-web', 'stop_keep-web',
870         'start_arv-git-httpd', 'stop_arv-git-httpd',
871         'start_nginx', 'stop_nginx',
872     ]
873     parser = argparse.ArgumentParser()
874     parser.add_argument('action', type=str, help="one of {}".format(actions))
875     parser.add_argument('--auth', type=str, metavar='FIXTURE_NAME', help='Print authorization info for given api_client_authorizations fixture')
876     parser.add_argument('--num-keep-servers', metavar='int', type=int, default=2, help="Number of keep servers desired")
877     parser.add_argument('--keep-enforce-permissions', action="store_true", help="Enforce keep permissions")
878
879     args = parser.parse_args()
880
881     if args.action not in actions:
882         print("Unrecognized action '{}'. Actions are: {}.".
883               format(args.action, actions),
884               file=sys.stderr)
885         sys.exit(1)
886     if args.action == 'start':
887         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
888         run(leave_running_atexit=True)
889         host = os.environ['ARVADOS_API_HOST']
890         if args.auth is not None:
891             token = auth_token(args.auth)
892             print("export ARVADOS_API_TOKEN={}".format(pipes.quote(token)))
893             print("export ARVADOS_API_HOST={}".format(pipes.quote(host)))
894             print("export ARVADOS_API_HOST_INSECURE=true")
895         else:
896             print(host)
897     elif args.action == 'stop':
898         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
899     elif args.action == 'start_ws':
900         run_ws()
901     elif args.action == 'stop_ws':
902         stop_ws()
903     elif args.action == 'start_controller':
904         run_controller()
905     elif args.action == 'stop_controller':
906         stop_controller()
907     elif args.action == 'start_keep':
908         run_keep(enforce_permissions=args.keep_enforce_permissions, num_servers=args.num_keep_servers)
909     elif args.action == 'stop_keep':
910         stop_keep(num_servers=args.num_keep_servers)
911     elif args.action == 'start_keep_proxy':
912         run_keep_proxy()
913     elif args.action == 'stop_keep_proxy':
914         stop_keep_proxy()
915     elif args.action == 'start_arv-git-httpd':
916         run_arv_git_httpd()
917     elif args.action == 'stop_arv-git-httpd':
918         stop_arv_git_httpd()
919     elif args.action == 'start_keep-web':
920         run_keep_web()
921     elif args.action == 'stop_keep-web':
922         stop_keep_web()
923     elif args.action == 'start_nginx':
924         run_nginx()
925         print("export ARVADOS_API_HOST=0.0.0.0:{}".format(_getport('controller-ssl')))
926     elif args.action == 'stop_nginx':
927         stop_nginx()
928     else:
929         raise Exception("action recognized but not implemented!?")