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