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