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