add --manual_run option for running interop tests
diff --git a/tools/run_tests/run_interop_tests.py b/tools/run_tests/run_interop_tests.py
index 0d5bec1..161fa81 100755
--- a/tools/run_tests/run_interop_tests.py
+++ b/tools/run_tests/run_interop_tests.py
@@ -501,6 +501,28 @@
   return docker_cmdline
 
 
+def manual_cmdline(docker_cmdline):
+  """Returns docker cmdline adjusted for manual invocation."""
+  print_cmdline = []
+  for item in docker_cmdline:
+    if item.startswith('--name='):
+      continue
+    # add quotes when necessary
+    if any(character.isspace() for character in item):
+      item = "\"%s\"" % item
+    print_cmdline.append(item)
+  return ' '.join(print_cmdline)
+
+
+def write_cmdlog_maybe(cmdlog, filename):
+  """Returns docker cmdline adjusted for manual invocation."""
+  if cmdlog:
+    with open(filename, 'w') as logfile:
+      logfile.write('#!/bin/bash\n')
+      logfile.writelines("%s\n" % line for line in cmdlog)
+    print('Command log written to file %s' % filename)
+
+
 def bash_cmdline(cmdline):
   """Creates bash -c cmdline from args list."""
   # Use login shell:
@@ -551,7 +573,8 @@
 
 
 def cloud_to_prod_jobspec(language, test_case, server_host_name,
-                          server_host_detail, docker_image=None, auth=False):
+                          server_host_detail, docker_image=None, auth=False,
+                          manual_cmd_log=None):
   """Creates jobspec for cloud-to-prod interop test"""
   container_name = None
   cmdargs = [
@@ -576,7 +599,9 @@
                                  cwd=cwd,
                                  environ=environ,
                                  docker_args=['--net=host',
-                                              '--name', container_name])
+                                              '--name=%s' % container_name])
+    if manual_cmd_log is not None:
+      manual_cmd_log.append(manual_cmdline(cmdline))
     cwd = None
     environ = None
 
@@ -597,7 +622,8 @@
 
 
 def cloud_to_cloud_jobspec(language, test_case, server_name, server_host,
-                           server_port, docker_image=None, insecure=False):
+                           server_port, docker_image=None, insecure=False,
+                           manual_cmd_log=None):
   """Creates jobspec for cloud-to-cloud interop test"""
   interop_only_options = [
       '--server_host_override=foo.test.google.fr',
@@ -628,7 +654,9 @@
                                  environ=environ,
                                  cwd=cwd,
                                  docker_args=['--net=host',
-                                              '--name', container_name])
+                                              '--name=%s' % container_name])
+    if manual_cmd_log is not None:
+      manual_cmd_log.append(manual_cmdline(cmdline))
     cwd = None
 
   test_job = jobset.JobSpec(
@@ -646,7 +674,7 @@
   return test_job
 
 
-def server_jobspec(language, docker_image, insecure=False):
+def server_jobspec(language, docker_image, insecure=False, manual_cmd_log=None):
   """Create jobspec for running a server"""
   container_name = dockerjob.random_name('interop_server_%s' % language.safename)
   cmdline = bash_cmdline(
@@ -676,7 +704,9 @@
                                       cwd=language.server_cwd,
                                       environ=environ,
                                       docker_args=port_args +
-                                        ['--name', container_name])
+                                        ['--name=%s' % container_name])
+  if manual_cmd_log is not None:
+      manual_cmd_log.append(manual_cmdline(docker_cmdline))
   server_job = jobset.JobSpec(
           cmdline=docker_cmdline,
           environ=environ,
@@ -806,6 +836,14 @@
                   action='store_const',
                   const=True,
                   help='Allow flaky tests to show as passing (re-runs failed tests up to five times)')
+argp.add_argument('--manual_run',
+                  default=False,
+                  action='store_const',
+                  const=True,
+                  help='Prepare things for running interop tests manually. ' +
+                  'Preserve docker images after building them and skip '
+                  'actually running the tests. Only print commands to run by ' +
+                  'hand.')
 argp.add_argument('--http2_interop',
                   default=False,
                   action='store_const',
@@ -837,6 +875,10 @@
     print('copied to the docker environment.')
     time.sleep(5)
 
+if args.manual_run and not args.use_docker:
+  print('--manual_run is only supported with --use_docker option enabled.')
+  sys.exit(1)
+
 if not args.use_docker and servers:
   print('Running interop servers is only supported with --use_docker option enabled.')
   sys.exit(1)
@@ -887,24 +929,36 @@
         dockerjob.remove_image(image, skip_nonexistent=True)
       sys.exit(1)
 
+server_manual_cmd_log = [] if args.manual_run else None
+client_manual_cmd_log = [] if args.manual_run else None
+
 # Start interop servers.
-server_jobs={}
-server_addresses={}
+server_jobs = {}
+server_addresses = {}
 try:
   for s in servers:
     lang = str(s)
     spec = server_jobspec(_LANGUAGES[lang], docker_images.get(lang),
-                          args.insecure)
-    job = dockerjob.DockerJob(spec)
-    server_jobs[lang] = job
-    server_addresses[lang] = ('localhost', job.mapped_port(_DEFAULT_SERVER_PORT))
+                          args.insecure, manual_cmd_log=server_manual_cmd_log)
+    if not args.manual_run:
+      job = dockerjob.DockerJob(spec)
+      server_jobs[lang] = job
+      server_addresses[lang] = ('localhost', job.mapped_port(_DEFAULT_SERVER_PORT))
+    else:
+      # don't run the server, set server port to a placeholder value
+      server_addresses[lang] = ('localhost', '${SERVER_PORT}')
 
   if args.http2_badserver_interop:
     # launch a HTTP2 server emulator that creates edge cases
     lang = str(http2InteropServer)
-    spec = server_jobspec(http2InteropServer, docker_images.get(lang))
-    job = dockerjob.DockerJob(spec)
-    server_jobs[lang] = job
+    spec = server_jobspec(http2InteropServer, docker_images.get(lang),
+                          manual_cmd_log=server_manual_cmd_log)
+    if not args.manual_run:
+      job = dockerjob.DockerJob(spec)
+      server_jobs[lang] = job
+    else:
+      # don't run the server, set server port to a placeholder value
+      server_addresses[lang] = ('localhost', '${SERVER_PORT}')
 
   jobs = []
   if args.cloud_to_prod:
@@ -918,7 +972,8 @@
               test_job = cloud_to_prod_jobspec(
                   language, test_case, server_host_name,
                   prod_servers[server_host_name],
-                  docker_image=docker_images.get(str(language)))
+                  docker_image=docker_images.get(str(language)),
+                  manual_cmd_log=client_manual_cmd_log)
               jobs.append(test_job)
 
       if args.http2_interop:
@@ -926,7 +981,8 @@
           test_job = cloud_to_prod_jobspec(
               http2Interop, test_case, server_host_name,
               prod_servers[server_host_name],
-              docker_image=docker_images.get(str(http2Interop)))
+              docker_image=docker_images.get(str(http2Interop)),
+              manual_cmd_log=client_manual_cmd_log)
           jobs.append(test_job)
 
   if args.cloud_to_prod_auth:
@@ -939,7 +995,8 @@
             test_job = cloud_to_prod_jobspec(
                 language, test_case, server_host_name,
                 prod_servers[server_host_name],
-                docker_image=docker_images.get(str(language)), auth=True)
+                docker_image=docker_images.get(str(language)), auth=True,
+                manual_cmd_log=client_manual_cmd_log)
             jobs.append(test_job)
 
   for server in args.override_server:
@@ -963,7 +1020,8 @@
                                               server_host,
                                               server_port,
                                               docker_image=docker_images.get(str(language)),
-                                              insecure=args.insecure)
+                                              insecure=args.insecure,
+                                              manual_cmd_log=client_manual_cmd_log)
             jobs.append(test_job)
 
     if args.http2_interop:
@@ -977,7 +1035,8 @@
                                           server_host,
                                           server_port,
                                           docker_image=docker_images.get(str(http2Interop)),
-                                          insecure=args.insecure)
+                                          insecure=args.insecure,
+                                          manual_cmd_log=client_manual_cmd_log)
         jobs.append(test_job)
 
   if args.http2_badserver_interop:
@@ -988,7 +1047,8 @@
                                           str(http2InteropServer),
                                           'localhost',
                                           _DEFAULT_SERVER_PORT,
-                                          docker_image=docker_images.get(str(language)))
+                                          docker_image=docker_images.get(str(language)),
+                                          manual_cmd_log=client_manual_cmd_log)
         jobs.append(test_job)
 
   if not jobs:
@@ -997,13 +1057,20 @@
       dockerjob.remove_image(image, skip_nonexistent=True)
     sys.exit(1)
 
+  if args.manual_run:
+    print('All tests will skipped --manual_run option is active.')
+
   num_failures, resultset = jobset.run(jobs, newline_on_success=True,
-                                       maxjobs=args.jobs)
+                                       maxjobs=args.jobs,
+                                       skip_jobs=args.manual_run)
   if num_failures:
     jobset.message('FAILED', 'Some tests failed', do_newline=True)
   else:
     jobset.message('SUCCESS', 'All tests passed', do_newline=True)
 
+  write_cmdlog_maybe(server_manual_cmd_log, 'interop_server_cmds.sh')
+  write_cmdlog_maybe(client_manual_cmd_log, 'interop_client_cmds.sh')
+
   report_utils.render_junit_xml_report(resultset, 'report.xml')
 
   for name, job in resultset.items():
@@ -1029,5 +1096,8 @@
   dockerjob.finish_jobs([j for j in server_jobs.itervalues()])
 
   for image in docker_images.itervalues():
-    print('Removing docker image %s' % image)
-    dockerjob.remove_image(image)
+    if not args.manual_run:
+      print('Removing docker image %s' % image)
+      dockerjob.remove_image(image)
+    else:
+      print('Preserving docker image: %s' % image)