Merge branch '19954-permission-dedup-doc'
[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     # Customizing the passenger config template is the only documented
335     # way to override the default passenger_stat_throttle_rate (10 s).
336     # In the testing environment, we want restart.txt to take effect
337     # immediately.
338     resdir = subprocess.check_output(['bundle', 'exec', 'passenger-config', 'about', 'resourcesdir']).decode().rstrip()
339     with open(resdir + '/templates/standalone/config.erb') as f:
340         template = f.read()
341     newtemplate = re.sub('http {', 'http {\n        passenger_stat_throttle_rate 0;', template)
342     if newtemplate == template:
343         raise "template edit failed"
344     with open('tmp/passenger-nginx.conf.erb', 'w') as f:
345         f.write(newtemplate)
346
347     port = internal_port_from_config("RailsAPI")
348     env = os.environ.copy()
349     env['RAILS_ENV'] = 'test'
350     env['ARVADOS_RAILS_LOG_TO_STDOUT'] = '1'
351     env.pop('ARVADOS_WEBSOCKETS', None)
352     env.pop('ARVADOS_TEST_API_HOST', None)
353     env.pop('ARVADOS_API_HOST', None)
354     env.pop('ARVADOS_API_HOST_INSECURE', None)
355     env.pop('ARVADOS_API_TOKEN', None)
356     logf = open(_logfilename('railsapi'), WRITE_MODE)
357     railsapi = subprocess.Popen(
358         ['bundle', 'exec',
359          'passenger', 'start', '-p{}'.format(port),
360          '--nginx-config-template', 'tmp/passenger-nginx.conf.erb',
361          '--no-friendly-error-pages',
362          '--disable-anonymous-telemetry',
363          '--disable-security-update-check',
364          '--pid-file', pid_file,
365          '--log-file', '/dev/stdout',
366          '--ssl',
367          '--ssl-certificate', 'tmp/self-signed.pem',
368          '--ssl-certificate-key', 'tmp/self-signed.key'],
369         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
370
371     if not leave_running_atexit:
372         atexit.register(kill_server_pid, pid_file, passenger_root=api_src_dir)
373
374     my_api_host = "127.0.0.1:"+str(port)
375     os.environ['ARVADOS_API_HOST'] = my_api_host
376
377     # Make sure the server has written its pid file and started
378     # listening on its TCP port
379     _wait_until_port_listens(port)
380     find_server_pid(pid_file)
381
382     reset()
383     os.chdir(restore_cwd)
384
385 def reset():
386     """Reset the test server to fixture state.
387
388     This resets the ARVADOS_TEST_API_HOST provided by a parent process
389     if any, otherwise the server started by run().
390
391     It also resets ARVADOS_* environment vars to point to the test
392     server with admin credentials.
393     """
394     existing_api_host = os.environ.get('ARVADOS_TEST_API_HOST', my_api_host)
395     token = auth_token('admin')
396     httpclient = httplib2.Http(ca_certs=os.path.join(
397         SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem'))
398     httpclient.request(
399         'https://{}/database/reset'.format(existing_api_host),
400         'POST',
401         headers={'Authorization': 'OAuth2 {}'.format(token), 'Connection':'close'})
402
403     os.environ['ARVADOS_API_HOST_INSECURE'] = 'true'
404     os.environ['ARVADOS_API_TOKEN'] = token
405     os.environ['ARVADOS_API_HOST'] = existing_api_host
406
407 def stop(force=False):
408     """Stop the API server, if one is running.
409
410     If force==False, kill it only if we started it ourselves. (This
411     supports the use case where a Python test suite calls run(), but
412     run() just uses the ARVADOS_TEST_API_HOST provided by the parent
413     process, and the test suite cleans up after itself by calling
414     stop(). In this case the test server provided by the parent
415     process should be left alone.)
416
417     If force==True, kill it even if we didn't start it
418     ourselves. (This supports the use case in __main__, where "run"
419     and "stop" happen in different processes.)
420     """
421     global my_api_host
422     if force or my_api_host is not None:
423         kill_server_pid(_pidfile('api'))
424         my_api_host = None
425
426 def get_config():
427     with open(os.environ["ARVADOS_CONFIG"]) as f:
428         return yaml.safe_load(f)
429
430 def internal_port_from_config(service, idx=0):
431     return int(urlparse(
432         sorted(list(get_config()["Clusters"]["zzzzz"]["Services"][service]["InternalURLs"].keys()))[idx]).
433                netloc.split(":")[1])
434
435 def external_port_from_config(service):
436     return int(urlparse(get_config()["Clusters"]["zzzzz"]["Services"][service]["ExternalURL"]).netloc.split(":")[1])
437
438 def run_controller():
439     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
440         return
441     stop_controller()
442     logf = open(_logfilename('controller'), WRITE_MODE)
443     port = internal_port_from_config("Controller")
444     controller = subprocess.Popen(
445         ["arvados-server", "controller"],
446         stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
447     with open(_pidfile('controller'), 'w') as f:
448         f.write(str(controller.pid))
449     _wait_until_port_listens(port)
450     return port
451
452 def stop_controller():
453     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
454         return
455     kill_server_pid(_pidfile('controller'))
456
457 def run_ws():
458     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
459         return
460     stop_ws()
461     port = internal_port_from_config("Websocket")
462     logf = open(_logfilename('ws'), WRITE_MODE)
463     ws = subprocess.Popen(
464         ["arvados-server", "ws"],
465         stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
466     with open(_pidfile('ws'), 'w') as f:
467         f.write(str(ws.pid))
468     _wait_until_port_listens(port)
469     return port
470
471 def stop_ws():
472     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
473         return
474     kill_server_pid(_pidfile('ws'))
475
476 def _start_keep(n, blob_signing=False):
477     datadir = os.path.join(TEST_TMPDIR, "keep%d.data"%n)
478     if os.path.exists(datadir):
479         shutil.rmtree(datadir)
480     os.mkdir(datadir)
481     port = internal_port_from_config("Keepstore", idx=n)
482
483     # Currently, if there are multiple InternalURLs for a single host,
484     # the only way to tell a keepstore process which one it's supposed
485     # to listen on is to supply a redacted version of the config, with
486     # the other InternalURLs removed.
487     conf = os.path.join(TEST_TMPDIR, "keep%d.yaml"%n)
488     confdata = get_config()
489     confdata['Clusters']['zzzzz']['Services']['Keepstore']['InternalURLs'] = {"http://127.0.0.1:%d"%port: {}}
490     confdata['Clusters']['zzzzz']['Collections']['BlobSigning'] = blob_signing
491     with open(conf, 'w') as f:
492         yaml.safe_dump(confdata, f)
493     keep_cmd = ["arvados-server", "keepstore", "-config", conf]
494
495     with open(_logfilename('keep{}'.format(n)), WRITE_MODE) as logf:
496         with open('/dev/null') as _stdin:
497             child = subprocess.Popen(
498                 keep_cmd, stdin=_stdin, stdout=logf, stderr=logf, close_fds=True)
499
500     print('child.pid is %d'%child.pid, file=sys.stderr)
501     with open(_pidfile('keep{}'.format(n)), 'w') as f:
502         f.write(str(child.pid))
503
504     _wait_until_port_listens(port)
505
506     return port
507
508 def run_keep(num_servers=2, **kwargs):
509     stop_keep(num_servers)
510
511     api = arvados.api(
512         version='v1',
513         host=os.environ['ARVADOS_API_HOST'],
514         token=os.environ['ARVADOS_API_TOKEN'],
515         insecure=True)
516
517     for d in api.keep_services().list(filters=[['service_type','=','disk']]).execute()['items']:
518         api.keep_services().delete(uuid=d['uuid']).execute()
519     for d in api.keep_disks().list().execute()['items']:
520         api.keep_disks().delete(uuid=d['uuid']).execute()
521
522     for d in range(0, num_servers):
523         port = _start_keep(d, **kwargs)
524         svc = api.keep_services().create(body={'keep_service': {
525             'uuid': 'zzzzz-bi6l4-keepdisk{:07d}'.format(d),
526             'service_host': 'localhost',
527             'service_port': port,
528             'service_type': 'disk',
529             'service_ssl_flag': False,
530         }}).execute()
531         api.keep_disks().create(body={
532             'keep_disk': {'keep_service_uuid': svc['uuid'] }
533         }).execute()
534
535     # If keepproxy and/or keep-web is running, send SIGHUP to make
536     # them discover the new keepstore services.
537     for svc in ('keepproxy', 'keep-web'):
538         pidfile = _pidfile(svc)
539         if os.path.exists(pidfile):
540             try:
541                 with open(pidfile) as pid:
542                     os.kill(int(pid.read()), signal.SIGHUP)
543             except OSError:
544                 os.remove(pidfile)
545
546 def _stop_keep(n):
547     kill_server_pid(_pidfile('keep{}'.format(n)))
548
549 def stop_keep(num_servers=2):
550     for n in range(0, num_servers):
551         _stop_keep(n)
552
553 def run_keep_proxy():
554     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
555         os.environ["ARVADOS_KEEP_SERVICES"] = "http://localhost:{}".format(internal_port_from_config('Keepproxy'))
556         return
557     stop_keep_proxy()
558
559     port = internal_port_from_config("Keepproxy")
560     env = os.environ.copy()
561     env['ARVADOS_API_TOKEN'] = auth_token('anonymous')
562     logf = open(_logfilename('keepproxy'), WRITE_MODE)
563     kp = subprocess.Popen(
564         ['arvados-server', 'keepproxy'], env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf, close_fds=True)
565
566     with open(_pidfile('keepproxy'), 'w') as f:
567         f.write(str(kp.pid))
568     _wait_until_port_listens(port)
569
570     print("Using API %s token %s" % (os.environ['ARVADOS_API_HOST'], auth_token('admin')), file=sys.stdout)
571     api = arvados.api(
572         version='v1',
573         host=os.environ['ARVADOS_API_HOST'],
574         token=auth_token('admin'),
575         insecure=True)
576     for d in api.keep_services().list(
577             filters=[['service_type','=','proxy']]).execute()['items']:
578         api.keep_services().delete(uuid=d['uuid']).execute()
579     api.keep_services().create(body={'keep_service': {
580         'service_host': 'localhost',
581         'service_port': port,
582         'service_type': 'proxy',
583         'service_ssl_flag': False,
584     }}).execute()
585     os.environ["ARVADOS_KEEP_SERVICES"] = "http://localhost:{}".format(port)
586     _wait_until_port_listens(port)
587
588 def stop_keep_proxy():
589     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
590         return
591     kill_server_pid(_pidfile('keepproxy'))
592
593 def run_arv_git_httpd():
594     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
595         return
596     stop_arv_git_httpd()
597
598     gitport = internal_port_from_config("GitHTTP")
599     env = os.environ.copy()
600     env.pop('ARVADOS_API_TOKEN', None)
601     logf = open(_logfilename('githttpd'), WRITE_MODE)
602     agh = subprocess.Popen(['arvados-server', 'git-httpd'],
603         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
604     with open(_pidfile('githttpd'), 'w') as f:
605         f.write(str(agh.pid))
606     _wait_until_port_listens(gitport)
607
608 def stop_arv_git_httpd():
609     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
610         return
611     kill_server_pid(_pidfile('githttpd'))
612
613 def run_keep_web():
614     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
615         return
616     stop_keep_web()
617
618     keepwebport = internal_port_from_config("WebDAV")
619     env = os.environ.copy()
620     logf = open(_logfilename('keep-web'), WRITE_MODE)
621     keepweb = subprocess.Popen(
622         ['arvados-server', 'keep-web'],
623         env=env, stdin=open('/dev/null'), stdout=logf, stderr=logf)
624     with open(_pidfile('keep-web'), 'w') as f:
625         f.write(str(keepweb.pid))
626     _wait_until_port_listens(keepwebport)
627
628 def stop_keep_web():
629     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
630         return
631     kill_server_pid(_pidfile('keep-web'))
632
633 def run_nginx():
634     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
635         return
636     stop_nginx()
637     nginxconf = {}
638     nginxconf['UPSTREAMHOST'] = '127.0.0.1'
639     nginxconf['LISTENHOST'] = '127.0.0.1'
640     nginxconf['CONTROLLERPORT'] = internal_port_from_config("Controller")
641     nginxconf['ARVADOS_API_HOST'] = "0.0.0.0:" + str(external_port_from_config("Controller"))
642     nginxconf['CONTROLLERSSLPORT'] = external_port_from_config("Controller")
643     nginxconf['KEEPWEBPORT'] = internal_port_from_config("WebDAV")
644     nginxconf['KEEPWEBDLSSLPORT'] = external_port_from_config("WebDAVDownload")
645     nginxconf['KEEPWEBSSLPORT'] = external_port_from_config("WebDAV")
646     nginxconf['KEEPPROXYPORT'] = internal_port_from_config("Keepproxy")
647     nginxconf['KEEPPROXYSSLPORT'] = external_port_from_config("Keepproxy")
648     nginxconf['GITPORT'] = internal_port_from_config("GitHTTP")
649     nginxconf['GITSSLPORT'] = external_port_from_config("GitHTTP")
650     nginxconf['HEALTHPORT'] = internal_port_from_config("Health")
651     nginxconf['HEALTHSSLPORT'] = external_port_from_config("Health")
652     nginxconf['WSPORT'] = internal_port_from_config("Websocket")
653     nginxconf['WSSSLPORT'] = external_port_from_config("Websocket")
654     nginxconf['WORKBENCH1PORT'] = internal_port_from_config("Workbench1")
655     nginxconf['WORKBENCH1SSLPORT'] = external_port_from_config("Workbench1")
656     nginxconf['WORKBENCH2PORT'] = internal_port_from_config("Workbench2")
657     nginxconf['WORKBENCH2SSLPORT'] = external_port_from_config("Workbench2")
658     nginxconf['SSLCERT'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem')
659     nginxconf['SSLKEY'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.key')
660     nginxconf['ACCESSLOG'] = _logfilename('nginx_access')
661     nginxconf['ERRORLOG'] = _logfilename('nginx_error')
662     nginxconf['TMPDIR'] = TEST_TMPDIR + '/nginx'
663     nginxconf['INTERNALSUBNETS'] = '169.254.0.0/16 0;'
664
665     conftemplatefile = os.path.join(MY_DIRNAME, 'nginx.conf')
666     conffile = os.path.join(TEST_TMPDIR, 'nginx.conf')
667     with open(conffile, 'w') as f:
668         f.write(re.sub(
669             r'{{([A-Z]+[A-Z0-9]+)}}',
670             lambda match: str(nginxconf.get(match.group(1))),
671             open(conftemplatefile).read()))
672
673     env = os.environ.copy()
674     env['PATH'] = env['PATH']+':/sbin:/usr/sbin:/usr/local/sbin'
675
676     nginx = subprocess.Popen(
677         ['nginx',
678          '-g', 'error_log stderr info; pid '+_pidfile('nginx')+';',
679          '-c', conffile],
680         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
681     _wait_until_port_listens(nginxconf['CONTROLLERSSLPORT'])
682
683 def setup_config():
684     rails_api_port = find_available_port()
685     controller_port = find_available_port()
686     controller_external_port = find_available_port()
687     websocket_port = find_available_port()
688     websocket_external_port = find_available_port()
689     workbench1_port = find_available_port()
690     workbench1_external_port = find_available_port()
691     workbench2_port = find_available_port()
692     workbench2_external_port = find_available_port()
693     git_httpd_port = find_available_port()
694     git_httpd_external_port = find_available_port()
695     health_httpd_port = find_available_port()
696     health_httpd_external_port = find_available_port()
697     keepproxy_port = find_available_port()
698     keepproxy_external_port = find_available_port()
699     keepstore_ports = sorted([str(find_available_port()) for _ in range(0,4)])
700     keep_web_port = find_available_port()
701     keep_web_external_port = find_available_port()
702     keep_web_dl_external_port = find_available_port()
703
704     configsrc = os.environ.get("CONFIGSRC", None)
705     if configsrc:
706         clusterconf = os.path.join(configsrc, "config.yml")
707         print("Getting config from %s" % clusterconf, file=sys.stderr)
708         pgconnection = yaml.safe_load(open(clusterconf))["Clusters"]["zzzzz"]["PostgreSQL"]["Connection"]
709     else:
710         # assume "arvados-server install -type test" has set up the
711         # conventional db credentials
712         pgconnection = {
713             "client_encoding": "utf8",
714             "host": "localhost",
715             "dbname": "arvados_test",
716             "user": "arvados",
717             "password": "insecure_arvados_test",
718         }
719
720     localhost = "127.0.0.1"
721     services = {
722         "RailsAPI": {
723             "InternalURLs": {
724                 "https://%s:%s"%(localhost, rails_api_port): {},
725             },
726         },
727         "Controller": {
728             "ExternalURL": "https://%s:%s" % (localhost, controller_external_port),
729             "InternalURLs": {
730                 "http://%s:%s"%(localhost, controller_port): {},
731             },
732         },
733         "Websocket": {
734             "ExternalURL": "wss://%s:%s/websocket" % (localhost, websocket_external_port),
735             "InternalURLs": {
736                 "http://%s:%s"%(localhost, websocket_port): {},
737             },
738         },
739         "Workbench1": {
740             "ExternalURL": "https://%s:%s/" % (localhost, workbench1_external_port),
741             "InternalURLs": {
742                 "http://%s:%s"%(localhost, workbench1_port): {},
743             },
744         },
745         "Workbench2": {
746             "ExternalURL": "https://%s:%s/" % (localhost, workbench2_external_port),
747             "InternalURLs": {
748                 "http://%s:%s"%(localhost, workbench2_port): {},
749             },
750         },
751         "GitHTTP": {
752             "ExternalURL": "https://%s:%s" % (localhost, git_httpd_external_port),
753             "InternalURLs": {
754                 "http://%s:%s"%(localhost, git_httpd_port): {}
755             },
756         },
757         "Health": {
758             "ExternalURL": "https://%s:%s" % (localhost, health_httpd_external_port),
759             "InternalURLs": {
760                 "http://%s:%s"%(localhost, health_httpd_port): {}
761             },
762         },
763         "Keepstore": {
764             "InternalURLs": {
765                 "http://%s:%s"%(localhost, port): {} for port in keepstore_ports
766             },
767         },
768         "Keepproxy": {
769             "ExternalURL": "https://%s:%s" % (localhost, keepproxy_external_port),
770             "InternalURLs": {
771                 "http://%s:%s"%(localhost, keepproxy_port): {},
772             },
773         },
774         "WebDAV": {
775             "ExternalURL": "https://%s:%s" % (localhost, keep_web_external_port),
776             "InternalURLs": {
777                 "http://%s:%s"%(localhost, keep_web_port): {},
778             },
779         },
780         "WebDAVDownload": {
781             "ExternalURL": "https://%s:%s" % (localhost, keep_web_dl_external_port),
782             "InternalURLs": {
783                 "http://%s:%s"%(localhost, keep_web_port): {},
784             },
785         },
786     }
787
788     config = {
789         "Clusters": {
790             "zzzzz": {
791                 "ManagementToken": "e687950a23c3a9bceec28c6223a06c79",
792                 "SystemRootToken": auth_token('system_user'),
793                 "API": {
794                     "RequestTimeout": "30s",
795                     "LockBeforeUpdate": True,
796                 },
797                 "Login": {
798                     "Test": {
799                         "Enable": True,
800                         "Users": {
801                             "alice": {
802                                 "Email": "alice@example.com",
803                                 "Password": "xyzzy"
804                             }
805                         }
806                     },
807                 },
808                 "SystemLogs": {
809                     "LogLevel": ('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
810                 },
811                 "PostgreSQL": {
812                     "Connection": pgconnection,
813                 },
814                 "TLS": {
815                     "Insecure": True,
816                 },
817                 "Services": services,
818                 "Users": {
819                     "AnonymousUserToken": auth_token('anonymous'),
820                     "UserProfileNotificationAddress": "arvados@example.com",
821                 },
822                 "Collections": {
823                     "CollectionVersioning": True,
824                     "BlobSigningKey": "zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc",
825                     "TrustAllContent": False,
826                     "ForwardSlashNameSubstitution": "/",
827                     "TrashSweepInterval": "-1s",
828                 },
829                 "Git": {
830                     "Repositories": os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'git', 'test'),
831                 },
832                 "Containers": {
833                     "JobsAPI": {
834                         "GitInternalDir": os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'internal.git'),
835                     },
836                     "LocalKeepBlobBuffersPerVCPU": 0,
837                     "Logging": {
838                         "SweepInterval": 0, # disable, otherwise test cases can't acquire dblock
839                     },
840                     "SupportedDockerImageFormats": {"v1": {}},
841                     "ShellAccess": {
842                         "Admin": True,
843                         "User": True,
844                     },
845                 },
846                 "Volumes": {
847                     "zzzzz-nyw5e-%015d"%n: {
848                         "AccessViaHosts": {
849                             "http://%s:%s" % (localhost, keepstore_ports[n]): {},
850                         },
851                         "Driver": "Directory",
852                         "DriverParameters": {
853                             "Root": os.path.join(TEST_TMPDIR, "keep%d.data"%n),
854                         },
855                     } for n in range(len(keepstore_ports))
856                 },
857             },
858         },
859     }
860
861     conf = os.path.join(TEST_TMPDIR, 'arvados.yml')
862     with open(conf, 'w') as f:
863         yaml.safe_dump(config, f)
864
865     ex = "export ARVADOS_CONFIG="+conf
866     print(ex)
867
868
869 def stop_nginx():
870     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
871         return
872     kill_server_pid(_pidfile('nginx'))
873
874 def _pidfile(program):
875     return os.path.join(TEST_TMPDIR, program + '.pid')
876
877 def fixture(fix):
878     '''load a fixture yaml file'''
879     with open(os.path.join(SERVICES_SRC_DIR, 'api', "test", "fixtures",
880                            fix + ".yml")) as f:
881         yaml_file = f.read()
882         try:
883           trim_index = yaml_file.index("# Test Helper trims the rest of the file")
884           yaml_file = yaml_file[0:trim_index]
885         except ValueError:
886           pass
887         return yaml.safe_load(yaml_file)
888
889 def auth_token(token_name):
890     return fixture("api_client_authorizations")[token_name]["api_token"]
891
892 def authorize_with(token_name):
893     '''token_name is the symbolic name of the token from the api_client_authorizations fixture'''
894     arvados.config.settings()["ARVADOS_API_TOKEN"] = auth_token(token_name)
895     arvados.config.settings()["ARVADOS_API_HOST"] = os.environ.get("ARVADOS_API_HOST")
896     arvados.config.settings()["ARVADOS_API_HOST_INSECURE"] = "true"
897
898 class TestCaseWithServers(unittest.TestCase):
899     """TestCase to start and stop supporting Arvados servers.
900
901     Define any of MAIN_SERVER, KEEP_SERVER, and/or KEEP_PROXY_SERVER
902     class variables as a dictionary of keyword arguments.  If you do,
903     setUpClass will start the corresponding servers by passing these
904     keyword arguments to the run, run_keep, and/or run_keep_server
905     functions, respectively.  It will also set Arvados environment
906     variables to point to these servers appropriately.  If you don't
907     run a Keep or Keep proxy server, setUpClass will set up a
908     temporary directory for Keep local storage, and set it as
909     KEEP_LOCAL_STORE.
910
911     tearDownClass will stop any servers started, and restore the
912     original environment.
913     """
914     MAIN_SERVER = None
915     WS_SERVER = None
916     KEEP_SERVER = None
917     KEEP_PROXY_SERVER = None
918     KEEP_WEB_SERVER = None
919
920     @staticmethod
921     def _restore_dict(src, dest):
922         for key in list(dest.keys()):
923             if key not in src:
924                 del dest[key]
925         dest.update(src)
926
927     @classmethod
928     def setUpClass(cls):
929         cls._orig_environ = os.environ.copy()
930         cls._orig_config = arvados.config.settings().copy()
931         cls._cleanup_funcs = []
932         os.environ.pop('ARVADOS_KEEP_SERVICES', None)
933         for server_kwargs, start_func, stop_func in (
934                 (cls.MAIN_SERVER, run, reset),
935                 (cls.WS_SERVER, run_ws, stop_ws),
936                 (cls.KEEP_SERVER, run_keep, stop_keep),
937                 (cls.KEEP_PROXY_SERVER, run_keep_proxy, stop_keep_proxy),
938                 (cls.KEEP_WEB_SERVER, run_keep_web, stop_keep_web)):
939             if server_kwargs is not None:
940                 start_func(**server_kwargs)
941                 cls._cleanup_funcs.append(stop_func)
942         if (cls.KEEP_SERVER is None) and (cls.KEEP_PROXY_SERVER is None):
943             cls.local_store = tempfile.mkdtemp()
944             os.environ['KEEP_LOCAL_STORE'] = cls.local_store
945             cls._cleanup_funcs.append(
946                 lambda: shutil.rmtree(cls.local_store, ignore_errors=True))
947         else:
948             os.environ.pop('KEEP_LOCAL_STORE', None)
949         arvados.config.initialize()
950
951     @classmethod
952     def tearDownClass(cls):
953         for clean_func in cls._cleanup_funcs:
954             clean_func()
955         cls._restore_dict(cls._orig_environ, os.environ)
956         cls._restore_dict(cls._orig_config, arvados.config.settings())
957
958
959 if __name__ == "__main__":
960     actions = [
961         'start', 'stop',
962         'start_ws', 'stop_ws',
963         'start_controller', 'stop_controller',
964         'start_keep', 'stop_keep',
965         'start_keep_proxy', 'stop_keep_proxy',
966         'start_keep-web', 'stop_keep-web',
967         'start_githttpd', 'stop_githttpd',
968         'start_nginx', 'stop_nginx', 'setup_config',
969     ]
970     parser = argparse.ArgumentParser()
971     parser.add_argument('action', type=str, help="one of {}".format(actions))
972     parser.add_argument('--auth', type=str, metavar='FIXTURE_NAME', help='Print authorization info for given api_client_authorizations fixture')
973     parser.add_argument('--num-keep-servers', metavar='int', type=int, default=2, help="Number of keep servers desired")
974     parser.add_argument('--keep-blob-signing', action="store_true", help="Enable blob signing for keepstore servers")
975
976     args = parser.parse_args()
977
978     if args.action not in actions:
979         print("Unrecognized action '{}'. Actions are: {}.".
980               format(args.action, actions),
981               file=sys.stderr)
982         sys.exit(1)
983     # Create a new process group so our child processes don't exit on
984     # ^C in run-tests.sh interactive mode.
985     os.setpgid(0, 0)
986     if args.action == 'start':
987         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
988         run(leave_running_atexit=True)
989         host = os.environ['ARVADOS_API_HOST']
990         if args.auth is not None:
991             token = auth_token(args.auth)
992             print("export ARVADOS_API_TOKEN={}".format(pipes.quote(token)))
993             print("export ARVADOS_API_HOST={}".format(pipes.quote(host)))
994             print("export ARVADOS_API_HOST_INSECURE=true")
995         else:
996             print(host)
997     elif args.action == 'stop':
998         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
999     elif args.action == 'start_ws':
1000         run_ws()
1001     elif args.action == 'stop_ws':
1002         stop_ws()
1003     elif args.action == 'start_controller':
1004         run_controller()
1005     elif args.action == 'stop_controller':
1006         stop_controller()
1007     elif args.action == 'start_keep':
1008         run_keep(blob_signing=args.keep_blob_signing, num_servers=args.num_keep_servers)
1009     elif args.action == 'stop_keep':
1010         stop_keep(num_servers=args.num_keep_servers)
1011     elif args.action == 'start_keep_proxy':
1012         run_keep_proxy()
1013     elif args.action == 'stop_keep_proxy':
1014         stop_keep_proxy()
1015     elif args.action == 'start_githttpd':
1016         run_arv_git_httpd()
1017     elif args.action == 'stop_githttpd':
1018         stop_arv_git_httpd()
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!?")