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