Merge branch '21121-cluster-activity' refs #21121
[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 _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=open('/dev/null'),
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=open('/dev/null'),
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=open('/dev/null'),
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         with open('/dev/null') as _stdin:
515             child = subprocess.Popen(
516                 keep_cmd,
517                 env=_service_environ(),
518                 stdin=_stdin,
519                 stdout=logf,
520                 stderr=logf,
521                 close_fds=True)
522             _detachedSubprocesses.append(child)
523
524     print('child.pid is %d'%child.pid, file=sys.stderr)
525     with open(_pidfile('keep{}'.format(n)), 'w') as f:
526         f.write(str(child.pid))
527
528     _wait_until_port_listens(port)
529
530     return port
531
532 def run_keep(num_servers=2, **kwargs):
533     stop_keep(num_servers)
534
535     api = arvados.api(
536         version='v1',
537         host=os.environ['ARVADOS_API_HOST'],
538         token=os.environ['ARVADOS_API_TOKEN'],
539         insecure=True)
540
541     for d in api.keep_services().list(filters=[['service_type','=','disk']]).execute()['items']:
542         api.keep_services().delete(uuid=d['uuid']).execute()
543
544     for d in range(0, num_servers):
545         port = _start_keep(d, **kwargs)
546         svc = api.keep_services().create(body={'keep_service': {
547             'uuid': 'zzzzz-bi6l4-keepdisk{:07d}'.format(d),
548             'service_host': 'localhost',
549             'service_port': port,
550             'service_type': 'disk',
551             'service_ssl_flag': False,
552         }}).execute()
553
554     # If keepproxy and/or keep-web is running, send SIGHUP to make
555     # them discover the new keepstore services.
556     for svc in ('keepproxy', 'keep-web'):
557         pidfile = _pidfile(svc)
558         if os.path.exists(pidfile):
559             try:
560                 with open(pidfile) as pid:
561                     os.kill(int(pid.read()), signal.SIGHUP)
562             except OSError:
563                 os.remove(pidfile)
564
565 def _stop_keep(n):
566     kill_server_pid(_pidfile('keep{}'.format(n)))
567
568 def stop_keep(num_servers=2):
569     for n in range(0, num_servers):
570         _stop_keep(n)
571
572 def run_keep_proxy():
573     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
574         os.environ["ARVADOS_KEEP_SERVICES"] = "http://localhost:{}".format(internal_port_from_config('Keepproxy'))
575         return
576     stop_keep_proxy()
577
578     port = internal_port_from_config("Keepproxy")
579     env = _service_environ()
580     env['ARVADOS_API_TOKEN'] = auth_token('anonymous')
581     logf = open(_logfilename('keepproxy'), WRITE_MODE)
582     kp = subprocess.Popen(
583         ['arvados-server', 'keepproxy'],
584         env=env,
585         stdin=open('/dev/null'),
586         stdout=logf,
587         stderr=logf,
588         close_fds=True)
589     _detachedSubprocesses.append(kp)
590
591     with open(_pidfile('keepproxy'), 'w') as f:
592         f.write(str(kp.pid))
593     _wait_until_port_listens(port)
594
595     print("Using API %s token %s" % (os.environ['ARVADOS_API_HOST'], auth_token('admin')), file=sys.stdout)
596     api = arvados.api(
597         version='v1',
598         host=os.environ['ARVADOS_API_HOST'],
599         token=auth_token('admin'),
600         insecure=True)
601     for d in api.keep_services().list(
602             filters=[['service_type','=','proxy']]).execute()['items']:
603         api.keep_services().delete(uuid=d['uuid']).execute()
604     api.keep_services().create(body={'keep_service': {
605         'service_host': 'localhost',
606         'service_port': port,
607         'service_type': 'proxy',
608         'service_ssl_flag': False,
609     }}).execute()
610     os.environ["ARVADOS_KEEP_SERVICES"] = "http://localhost:{}".format(port)
611     _wait_until_port_listens(port)
612
613 def stop_keep_proxy():
614     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
615         return
616     kill_server_pid(_pidfile('keepproxy'))
617
618 def run_keep_web():
619     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
620         return
621     stop_keep_web()
622
623     keepwebport = internal_port_from_config("WebDAV")
624     logf = open(_logfilename('keep-web'), WRITE_MODE)
625     keepweb = subprocess.Popen(
626         ['arvados-server', 'keep-web'],
627         env=_service_environ(),
628         stdin=open('/dev/null'),
629         stdout=logf,
630         stderr=logf)
631     _detachedSubprocesses.append(keepweb)
632     with open(_pidfile('keep-web'), 'w') as f:
633         f.write(str(keepweb.pid))
634     _wait_until_port_listens(keepwebport)
635
636 def stop_keep_web():
637     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
638         return
639     kill_server_pid(_pidfile('keep-web'))
640
641 def run_nginx():
642     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
643         return
644     stop_nginx()
645     nginxconf = {}
646     nginxconf['UPSTREAMHOST'] = '127.0.0.1'
647     nginxconf['LISTENHOST'] = '127.0.0.1'
648     nginxconf['CONTROLLERPORT'] = internal_port_from_config("Controller")
649     nginxconf['ARVADOS_API_HOST'] = "0.0.0.0:" + str(external_port_from_config("Controller"))
650     nginxconf['CONTROLLERSSLPORT'] = external_port_from_config("Controller")
651     nginxconf['KEEPWEBPORT'] = internal_port_from_config("WebDAV")
652     nginxconf['KEEPWEBDLSSLPORT'] = external_port_from_config("WebDAVDownload")
653     nginxconf['KEEPWEBSSLPORT'] = external_port_from_config("WebDAV")
654     nginxconf['KEEPPROXYPORT'] = internal_port_from_config("Keepproxy")
655     nginxconf['KEEPPROXYSSLPORT'] = external_port_from_config("Keepproxy")
656     nginxconf['HEALTHPORT'] = internal_port_from_config("Health")
657     nginxconf['HEALTHSSLPORT'] = external_port_from_config("Health")
658     nginxconf['WSPORT'] = internal_port_from_config("Websocket")
659     nginxconf['WSSSLPORT'] = external_port_from_config("Websocket")
660     nginxconf['WORKBENCH1SSLPORT'] = external_port_from_config("Workbench1")
661     nginxconf['WORKBENCH2PORT'] = internal_port_from_config("Workbench2")
662     nginxconf['WORKBENCH2SSLPORT'] = external_port_from_config("Workbench2")
663     nginxconf['SSLCERT'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.pem')
664     nginxconf['SSLKEY'] = os.path.join(SERVICES_SRC_DIR, 'api', 'tmp', 'self-signed.key')
665     nginxconf['ACCESSLOG'] = _logfilename('nginx_access')
666     nginxconf['ERRORLOG'] = _logfilename('nginx_error')
667     nginxconf['TMPDIR'] = TEST_TMPDIR + '/nginx'
668     nginxconf['INTERNALSUBNETS'] = '169.254.0.0/16 0;'
669
670     conftemplatefile = os.path.join(MY_DIRNAME, 'nginx.conf')
671     conffile = os.path.join(TEST_TMPDIR, 'nginx.conf')
672     with open(conffile, 'w') as f:
673         f.write(re.sub(
674             r'{{([A-Z]+[A-Z0-9]+)}}',
675             lambda match: str(nginxconf.get(match.group(1))),
676             open(conftemplatefile).read()))
677
678     env = os.environ.copy()
679     env['PATH'] = env['PATH']+':/sbin:/usr/sbin:/usr/local/sbin'
680
681     nginx = subprocess.Popen(
682         ['nginx',
683          '-g', 'error_log stderr notice; pid '+_pidfile('nginx')+';',
684          '-c', conffile],
685         env=env, stdin=open('/dev/null'), stdout=sys.stderr)
686     _detachedSubprocesses.append(nginx)
687     _wait_until_port_listens(nginxconf['CONTROLLERSSLPORT'])
688
689 def setup_config():
690     rails_api_port = find_available_port()
691     controller_port = find_available_port()
692     controller_external_port = find_available_port()
693     websocket_port = find_available_port()
694     websocket_external_port = find_available_port()
695     workbench1_external_port = find_available_port()
696     workbench2_port = find_available_port()
697     workbench2_external_port = find_available_port()
698     health_httpd_port = find_available_port()
699     health_httpd_external_port = find_available_port()
700     keepproxy_port = find_available_port()
701     keepproxy_external_port = find_available_port()
702     keepstore_ports = sorted([str(find_available_port()) for _ in range(0,4)])
703     keep_web_port = find_available_port()
704     keep_web_external_port = find_available_port()
705     keep_web_dl_external_port = find_available_port()
706
707     configsrc = os.environ.get("CONFIGSRC", None)
708     if configsrc:
709         clusterconf = os.path.join(configsrc, "config.yml")
710         print("Getting config from %s" % clusterconf, file=sys.stderr)
711         pgconnection = yaml.safe_load(open(clusterconf))["Clusters"]["zzzzz"]["PostgreSQL"]["Connection"]
712     else:
713         # assume "arvados-server install -type test" has set up the
714         # conventional db credentials
715         pgconnection = {
716             "client_encoding": "utf8",
717             "host": "localhost",
718             "dbname": "arvados_test",
719             "user": "arvados",
720             "password": "insecure_arvados_test",
721         }
722
723     localhost = "127.0.0.1"
724     services = {
725         "RailsAPI": {
726             "InternalURLs": {
727                 "https://%s:%s"%(localhost, rails_api_port): {},
728             },
729         },
730         "Controller": {
731             "ExternalURL": "https://%s:%s" % (localhost, controller_external_port),
732             "InternalURLs": {
733                 "http://%s:%s"%(localhost, controller_port): {},
734             },
735         },
736         "Websocket": {
737             "ExternalURL": "wss://%s:%s/websocket" % (localhost, websocket_external_port),
738             "InternalURLs": {
739                 "http://%s:%s"%(localhost, websocket_port): {},
740             },
741         },
742         "Workbench1": {
743             "ExternalURL": "https://%s:%s/" % (localhost, workbench1_external_port),
744         },
745         "Workbench2": {
746             "ExternalURL": "https://%s:%s/" % (localhost, workbench2_external_port),
747             "InternalURLs": {
748                 "http://%s:%s"%(localhost, workbench2_port): {},
749             },
750         },
751         "Health": {
752             "ExternalURL": "https://%s:%s" % (localhost, health_httpd_external_port),
753             "InternalURLs": {
754                 "http://%s:%s"%(localhost, health_httpd_port): {}
755             },
756         },
757         "Keepstore": {
758             "InternalURLs": {
759                 "http://%s:%s"%(localhost, port): {} for port in keepstore_ports
760             },
761         },
762         "Keepproxy": {
763             "ExternalURL": "https://%s:%s" % (localhost, keepproxy_external_port),
764             "InternalURLs": {
765                 "http://%s:%s"%(localhost, keepproxy_port): {},
766             },
767         },
768         "WebDAV": {
769             "ExternalURL": "https://%s:%s" % (localhost, keep_web_external_port),
770             "InternalURLs": {
771                 "http://%s:%s"%(localhost, keep_web_port): {},
772             },
773         },
774         "WebDAVDownload": {
775             "ExternalURL": "https://%s:%s" % (localhost, keep_web_dl_external_port),
776             "InternalURLs": {
777                 "http://%s:%s"%(localhost, keep_web_port): {},
778             },
779         },
780     }
781
782     config = {
783         "Clusters": {
784             "zzzzz": {
785                 "ManagementToken": "e687950a23c3a9bceec28c6223a06c79",
786                 "SystemRootToken": auth_token('system_user'),
787                 "API": {
788                     "RequestTimeout": "30s",
789                     "LockBeforeUpdate": True,
790                 },
791                 "Login": {
792                     "Test": {
793                         "Enable": True,
794                         "Users": {
795                             "alice": {
796                                 "Email": "alice@example.com",
797                                 "Password": "xyzzy"
798                             }
799                         }
800                     },
801                 },
802                 "SystemLogs": {
803                     "LogLevel": ('info' if os.environ.get('ARVADOS_DEBUG', '') in ['','0'] else 'debug'),
804                 },
805                 "PostgreSQL": {
806                     "Connection": pgconnection,
807                 },
808                 "TLS": {
809                     "Insecure": True,
810                 },
811                 "Services": services,
812                 "Users": {
813                     "AnonymousUserToken": auth_token('anonymous'),
814                     "UserProfileNotificationAddress": "arvados@example.com",
815                 },
816                 "Collections": {
817                     "CollectionVersioning": True,
818                     "BlobSigningKey": "zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc",
819                     "TrustAllContent": False,
820                     "ForwardSlashNameSubstitution": "/",
821                     "TrashSweepInterval": "-1s", # disable, otherwise test cases can't acquire dblock
822                 },
823                 "Containers": {
824                     "LocalKeepBlobBuffersPerVCPU": 0,
825                     "SupportedDockerImageFormats": {"v1": {}},
826                     "ShellAccess": {
827                         "Admin": True,
828                         "User": True,
829                     },
830                 },
831                 "Volumes": {
832                     "zzzzz-nyw5e-%015d"%n: {
833                         "AccessViaHosts": {
834                             "http://%s:%s" % (localhost, keepstore_ports[n]): {},
835                         },
836                         "Driver": "Directory",
837                         "DriverParameters": {
838                             "Root": os.path.join(TEST_TMPDIR, "keep%d.data"%n),
839                         },
840                     } for n in range(len(keepstore_ports))
841                 },
842             },
843         },
844     }
845
846     conf = os.path.join(TEST_TMPDIR, 'arvados.yml')
847     with open(conf, 'w') as f:
848         yaml.safe_dump(config, f)
849
850     ex = "export ARVADOS_CONFIG="+conf
851     print(ex)
852
853
854 def stop_nginx():
855     if 'ARVADOS_TEST_PROXY_SERVICES' in os.environ:
856         return
857     kill_server_pid(_pidfile('nginx'))
858
859 def _pidfile(program):
860     return os.path.join(TEST_TMPDIR, program + '.pid')
861
862 def fixture(fix):
863     '''load a fixture yaml file'''
864     with open(os.path.join(SERVICES_SRC_DIR, 'api', "test", "fixtures",
865                            fix + ".yml")) as f:
866         yaml_file = f.read()
867         try:
868           trim_index = yaml_file.index("# Test Helper trims the rest of the file")
869           yaml_file = yaml_file[0:trim_index]
870         except ValueError:
871           pass
872         return yaml.safe_load(yaml_file)
873
874 def auth_token(token_name):
875     return fixture("api_client_authorizations")[token_name]["api_token"]
876
877 def authorize_with(token_name):
878     '''token_name is the symbolic name of the token from the api_client_authorizations fixture'''
879     arvados.config.settings()["ARVADOS_API_TOKEN"] = auth_token(token_name)
880     arvados.config.settings()["ARVADOS_API_HOST"] = os.environ.get("ARVADOS_API_HOST")
881     arvados.config.settings()["ARVADOS_API_HOST_INSECURE"] = "true"
882
883 class TestCaseWithServers(unittest.TestCase):
884     """TestCase to start and stop supporting Arvados servers.
885
886     Define any of MAIN_SERVER, KEEP_SERVER, and/or KEEP_PROXY_SERVER
887     class variables as a dictionary of keyword arguments.  If you do,
888     setUpClass will start the corresponding servers by passing these
889     keyword arguments to the run, run_keep, and/or run_keep_server
890     functions, respectively.  It will also set Arvados environment
891     variables to point to these servers appropriately.  If you don't
892     run a Keep or Keep proxy server, setUpClass will set up a
893     temporary directory for Keep local storage, and set it as
894     KEEP_LOCAL_STORE.
895
896     tearDownClass will stop any servers started, and restore the
897     original environment.
898     """
899     MAIN_SERVER = None
900     WS_SERVER = None
901     KEEP_SERVER = None
902     KEEP_PROXY_SERVER = None
903     KEEP_WEB_SERVER = None
904
905     @staticmethod
906     def _restore_dict(src, dest):
907         for key in list(dest.keys()):
908             if key not in src:
909                 del dest[key]
910         dest.update(src)
911
912     @classmethod
913     def setUpClass(cls):
914         cls._orig_environ = os.environ.copy()
915         cls._orig_config = arvados.config.settings().copy()
916         cls._cleanup_funcs = []
917         os.environ.pop('ARVADOS_KEEP_SERVICES', None)
918         for server_kwargs, start_func, stop_func in (
919                 (cls.MAIN_SERVER, run, reset),
920                 (cls.WS_SERVER, run_ws, stop_ws),
921                 (cls.KEEP_SERVER, run_keep, stop_keep),
922                 (cls.KEEP_PROXY_SERVER, run_keep_proxy, stop_keep_proxy),
923                 (cls.KEEP_WEB_SERVER, run_keep_web, stop_keep_web)):
924             if server_kwargs is not None:
925                 start_func(**server_kwargs)
926                 cls._cleanup_funcs.append(stop_func)
927         if (cls.KEEP_SERVER is None) and (cls.KEEP_PROXY_SERVER is None):
928             cls.local_store = tempfile.mkdtemp()
929             os.environ['KEEP_LOCAL_STORE'] = cls.local_store
930             cls._cleanup_funcs.append(
931                 lambda: shutil.rmtree(cls.local_store, ignore_errors=True))
932         else:
933             os.environ.pop('KEEP_LOCAL_STORE', None)
934         arvados.config.initialize()
935
936     @classmethod
937     def tearDownClass(cls):
938         for clean_func in cls._cleanup_funcs:
939             clean_func()
940         cls._restore_dict(cls._orig_environ, os.environ)
941         cls._restore_dict(cls._orig_config, arvados.config.settings())
942
943
944 if __name__ == "__main__":
945     actions = [
946         'start', 'stop',
947         'start_ws', 'stop_ws',
948         'start_controller', 'stop_controller',
949         'start_keep', 'stop_keep',
950         'start_keep_proxy', 'stop_keep_proxy',
951         'start_keep-web', 'stop_keep-web',
952         'start_nginx', 'stop_nginx', 'setup_config',
953     ]
954     parser = argparse.ArgumentParser()
955     parser.add_argument('action', type=str, help="one of {}".format(actions))
956     parser.add_argument('--auth', type=str, metavar='FIXTURE_NAME', help='Print authorization info for given api_client_authorizations fixture')
957     parser.add_argument('--num-keep-servers', metavar='int', type=int, default=2, help="Number of keep servers desired")
958     parser.add_argument('--keep-blob-signing', action="store_true", help="Enable blob signing for keepstore servers")
959
960     args = parser.parse_args()
961
962     if args.action not in actions:
963         print("Unrecognized action '{}'. Actions are: {}.".
964               format(args.action, actions),
965               file=sys.stderr)
966         sys.exit(1)
967     # Create a new process group so our child processes don't exit on
968     # ^C in run-tests.sh interactive mode.
969     os.setpgid(0, 0)
970     if args.action == 'start':
971         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
972         run(leave_running_atexit=True)
973         host = os.environ['ARVADOS_API_HOST']
974         if args.auth is not None:
975             token = auth_token(args.auth)
976             print("export ARVADOS_API_TOKEN={}".format(shlex.quote(token)))
977             print("export ARVADOS_API_HOST={}".format(shlex.quote(host)))
978             print("export ARVADOS_API_HOST_INSECURE=true")
979             print("export ARVADOS_USE_KEEP_ACCESSIBLE_API=true")
980         else:
981             print(host)
982     elif args.action == 'stop':
983         stop(force=('ARVADOS_TEST_API_HOST' not in os.environ))
984     elif args.action == 'start_ws':
985         run_ws()
986     elif args.action == 'stop_ws':
987         stop_ws()
988     elif args.action == 'start_controller':
989         run_controller()
990     elif args.action == 'stop_controller':
991         stop_controller()
992     elif args.action == 'start_keep':
993         run_keep(blob_signing=args.keep_blob_signing, num_servers=args.num_keep_servers)
994     elif args.action == 'stop_keep':
995         stop_keep(num_servers=args.num_keep_servers)
996     elif args.action == 'start_keep_proxy':
997         run_keep_proxy()
998     elif args.action == 'stop_keep_proxy':
999         stop_keep_proxy()
1000     elif args.action == 'start_keep-web':
1001         run_keep_web()
1002     elif args.action == 'stop_keep-web':
1003         stop_keep_web()
1004     elif args.action == 'start_nginx':
1005         run_nginx()
1006         print("export ARVADOS_API_HOST=0.0.0.0:{}".format(external_port_from_config('Controller')))
1007     elif args.action == 'stop_nginx':
1008         stop_nginx()
1009     elif args.action == 'setup_config':
1010         setup_config()
1011     else:
1012         raise Exception("action recognized but not implemented!?")