devtools: download shell binaries and set origin when run w/ --mojo-version.

Before, a user of devtools that wanted to use prebuilt mojo at particular
version, had to:

 - set up DEPS hooks and use GN build rules that together downloaded and copied
   the shell and the network service at the correct version to the output
   directory
 - set the --origin to point to deployed mojo services at particular version

This patch adds a --mojo-version flag that does all of the above, downloading
binaries as needed and setting the origin accordingly.

Once consumers switch over to --mojo-version, we can retire the GN rules for
downloading prebuilt shells.

Fixes https://github.com/domokit/devtools/issues/62.
Fixes https://github.com/domokit/devtools/issues/63.

R=qsr@chromium.org

Review URL: https://codereview.chromium.org/1844943004 .

Cr-Mirrored-From: https://github.com/domokit/mojo
Cr-Mirrored-Commit: da012592f169e8c531e4a92cdf00d3c39413dd34
diff --git a/.gitignore b/.gitignore
index 0d20b64..930730d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
 *.pyc
+_prebuilt
diff --git a/devtoolslib/download.py b/devtoolslib/download.py
new file mode 100644
index 0000000..db6518f
--- /dev/null
+++ b/devtoolslib/download.py
@@ -0,0 +1,99 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import os
+import subprocess
+import sys
+import tempfile
+import urllib2
+import zipfile
+
+from devtoolslib import paths
+
+
+_SHELL_FILE_NAME = {
+    'linux-x64': 'mojo_shell',
+    'android-arm': 'MojoShell.apk'
+}
+
+
+def _get_artifacts_to_download(mojo_version, platform, verbose):
+  """Returns a list of tuples of (gs_path, file_name) to be downloaded."""
+  artifacts = []
+  shell_gs_path = ('gs://mojo/shell/%s/%s.zip' % (mojo_version, platform))
+  artifacts.append(
+      (shell_gs_path, _SHELL_FILE_NAME[platform])
+  )
+  if platform == 'linux-x64':
+    network_service_version_url = (
+        'https://raw.githubusercontent.com/domokit/mojo/' +
+        mojo_version + '/mojo/public/tools/NETWORK_SERVICE_VERSION')
+    network_service_version = (
+        urllib2.urlopen(network_service_version_url).read().strip())
+    if verbose:
+      print('Looked up the network service version for mojo at %s as: %s ' % (
+          mojo_version, network_service_version))
+
+    network_service_gs_path = (
+        'gs://mojo/network_service/%s/%s/network_service.mojo.zip' %
+        (network_service_version, platform))
+    artifacts.append(
+        (network_service_gs_path, 'network_service.mojo')
+    )
+  return artifacts
+
+
+def _download_from_gs(gs_path, output_path, depot_tools_path, verbose):
+  """Downloads the file at the given gs_path using depot_tools."""
+  # We're downloading from a public bucket which does not need authentication,
+  # but the user might have busted credential files somewhere such as ~/.boto
+  # that the gsutil script will try (and fail) to use. Setting these
+  # environment variables convinces gsutil not to attempt to use these.
+  env = os.environ.copy()
+  env['AWS_CREDENTIAL_FILE'] = ""
+  env['BOTO_CONFIG'] = ""
+
+  gsutil_exe = os.path.join(depot_tools_path, 'third_party', 'gsutil', 'gsutil')
+  if verbose:
+    print('Fetching ' + gs_path)
+
+  try:
+    subprocess.check_output(
+        [gsutil_exe,
+         '--bypass_prodaccess',
+         'cp',
+         gs_path,
+         output_path],
+        stderr=subprocess.STDOUT,
+        env=env)
+  except subprocess.CalledProcessError as e:
+    print e.output
+    sys.exit(1)
+
+
+def _extract_file(archive_path, file_name, output_dir):
+  with zipfile.ZipFile(archive_path) as z:
+    zi = z.getinfo(file_name)
+    mode = zi.external_attr >> 16
+    z.extract(zi, output_dir)
+    os.chmod(os.path.join(output_dir, file_name), mode)
+
+
+def download_shell(mojo_version, platform, root_output_dir, verbose):
+  """Downloads the shell (along with corresponding artifacts if needed) at the
+  given version.
+  """
+  depot_tools_path = paths.find_depot_tools()
+  artifacts = _get_artifacts_to_download(mojo_version, platform, verbose)
+  output_dir = os.path.join(root_output_dir, mojo_version, platform)
+
+  for (gs_path, file_name) in artifacts:
+    if os.path.isfile(os.path.join(output_dir, file_name)):
+      continue
+
+    with tempfile.NamedTemporaryFile() as temp_zip_file:
+      _download_from_gs(gs_path, temp_zip_file.name, depot_tools_path, verbose)
+      _extract_file(temp_zip_file.name, file_name, output_dir)
+
+  return os.path.join(output_dir, _SHELL_FILE_NAME[platform])
diff --git a/devtoolslib/paths.py b/devtoolslib/paths.py
index 870dd0a..a1b1b41 100644
--- a/devtoolslib/paths.py
+++ b/devtoolslib/paths.py
@@ -12,6 +12,8 @@
 import os.path
 import sys
 
+DEVTOOLS_ROOT = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..')
+
 
 def find_ancestor_with(relpath, start_path=None):
   """Returns the lowest ancestor of this file that contains |relpath|."""
diff --git a/devtoolslib/shell_arguments.py b/devtoolslib/shell_arguments.py
index 616615b..fd012f2 100644
--- a/devtoolslib/shell_arguments.py
+++ b/devtoolslib/shell_arguments.py
@@ -11,6 +11,8 @@
 import os.path
 import urlparse
 
+from devtoolslib import download
+from devtoolslib import paths
 from devtoolslib.android_shell import AndroidShell
 from devtoolslib.linux_shell import LinuxShell
 from devtoolslib.shell_config import ShellConfigurationException
@@ -193,6 +195,20 @@
   Throws:
     ShellConfigurationException if shell abstraction could not be configured.
   """
+  platform = 'android-arm' if shell_config.android else 'linux-x64'
+
+  shell_path = shell_config.shell_path
+  if not shell_path and shell_config.mojo_version:
+    download_dir = os.path.join(paths.DEVTOOLS_ROOT, '_prebuilt')
+    shell_path = download.download_shell(shell_config.mojo_version, platform,
+                                         download_dir, shell_config.verbose)
+  if shell_config.verbose:
+    if shell_path:
+      print('Using shell binary at ' + shell_path)
+    else:
+      print('No shell path given, only running on Android with pre-installed '
+            'shell will be possible.')
+
   if shell_config.android:
     shell = AndroidShell(shell_config.adb_path, shell_config.target_device,
                          logcat_tags=shell_config.logcat_tags,
@@ -201,13 +217,14 @@
     device_status, error = shell.check_device()
     if not device_status:
       raise ShellConfigurationException('Device check failed: ' + error)
-    if shell_config.shell_path:
-      shell.install_apk(shell_config.shell_path)
+    if shell_path:
+      shell.install_apk(shell_path)
   else:
-    if not shell_config.shell_path:
+    if not shell_path:
       raise ShellConfigurationException('Can not run without a shell binary. '
-                                        'Please pass --shell-path.')
-    shell = LinuxShell(shell_config.shell_path)
+                                        'Please pass --mojo-version or '
+                                        '--shell-path.')
+    shell = LinuxShell(shell_path)
     if shell_config.use_osmesa:
       shell_args.append('--args-for=mojo:native_viewport_service --use-osmesa')
 
@@ -215,7 +232,9 @@
                                shell_config.map_origin_list,
                                shell_config.reuse_servers)
 
+  # Configure origin for mojo: urls.
   if shell_config.origin:
+    # If origin was set on the command line, this takes precedence.
     if _is_web_url(shell_config.origin):
       shell_args.append('--origin=' + shell_config.origin)
     else:
@@ -223,6 +242,13 @@
       shell_args.extend(configure_local_origin(shell, shell_config.origin,
                                                local_origin_port,
                                                shell_config.reuse_servers))
+  elif shell_config.mojo_version:
+    # Otherwise we infer the origin from the mojo_version.
+    web_origin = "https://storage.googleapis.com/mojo/services/%s/%s" % (
+        platform, shell_config.mojo_version)
+    if shell_config.verbose:
+      print('Inferring origin from `MOJO_VERSION` as: ' + web_origin)
+    shell_args.append('--origin=' + web_origin)
 
   if shell_config.content_handlers:
     for (mime_type,
diff --git a/devtoolslib/shell_config.py b/devtoolslib/shell_config.py
index e1c04fe..298a54f 100644
--- a/devtoolslib/shell_config.py
+++ b/devtoolslib/shell_config.py
@@ -24,6 +24,7 @@
 
   def __init__(self):
     self.android = None
+    self.mojo_version = None
     self.shell_path = None
     self.origin = None
     self.map_url_list = []
@@ -59,6 +60,9 @@
   # Arguments configuring the shell run.
   parser.add_argument('--android', help='Run on Android',
                       action='store_true')
+  parser.add_argument('--mojo-version', help='Version of the Mojo to use. '
+                      'This will set the origin and download the shell binary '
+                      'at the given version')
   parser.add_argument('--shell-path', help='Path of the Mojo shell binary.')
   parser.add_argument('--origin', help='Origin for mojo: URLs. This can be a '
                       'web url or a local directory path. If MOJO_VERSION file '
@@ -144,33 +148,33 @@
   Returns:
     An instance of ShellConfig.
   """
-  # Infer paths based on the Chromium configuration options
-  # (--debug/--release, etc.), if running within a Chromium-like checkout.
-  inferred_params = paths.infer_params(script_args.android, script_args.debug,
-                                       script_args.target_cpu)
-  inferred_origin = None
-  if inferred_params['mojo_version']:
-    inferred_origin = "https://storage.googleapis.com/mojo/services"
-    if script_args.android:
-      inferred_origin += "/android-arm"
-    else:
-      inferred_origin += "/linux-x64"
-    # Get the versions that were built against Modular's version of the SDK.
-    inferred_origin += "/%s" % inferred_params['mojo_version']
-    if script_args.verbose:
-      print('Inferring origin from `MOJO_VERSION` as: ' +
-            inferred_origin)
 
   shell_config = ShellConfig()
   shell_config.android = script_args.android
-  shell_config.shell_path = (script_args.shell_path or
-                             inferred_params['shell_path'])
-  shell_config.origin = script_args.origin or inferred_origin
+  shell_config.mojo_version = script_args.mojo_version
+  shell_config.shell_path = script_args.shell_path
+  shell_config.origin = script_args.origin
   shell_config.map_url_list = script_args.map_url
   shell_config.map_origin_list = script_args.map_origin
   shell_config.reuse_servers = script_args.reuse_servers
   shell_config.verbose = script_args.verbose
 
+  # Infer paths based on the Chromium configuration options
+  # (--debug/--release, etc.), if running within a Chromium-like checkout.
+  inferred_params = paths.infer_params(script_args.android, script_args.debug,
+                                       script_args.target_cpu)
+  # Infer |mojo_version| if the client sets it in a MOJO_VERSION file.
+  shell_config.mojo_version = (shell_config.mojo_version or
+                               inferred_params['mojo_version'])
+  # Use the shell binary and mojo: apps from the build directory, unless
+  # |mojo_version| is set.
+  if not shell_config.mojo_version:
+    shell_config.shell_path = (shell_config.shell_path or
+                               inferred_params['shell_path'])
+    if shell_config.android:
+      shell_config.origin = (shell_config.origin or
+                             inferred_params['build_dir_path'])
+
   # Android-only.
   shell_config.adb_path = (script_args.adb_path or inferred_params['adb_path']
                            or 'adb')
@@ -180,10 +184,6 @@
   # Desktop-only.
   shell_config.use_osmesa = script_args.use_osmesa
 
-  if (shell_config.android and not shell_config.origin and
-      inferred_params['build_dir_path']):
-    shell_config.origin = inferred_params['build_dir_path']
-
   # Read the mojoconfig file.
   config_file = script_args.config_file
   if not script_args.no_config_file:
diff --git a/docs/mojo_run.md b/docs/mojo_run.md
index aa60937..9279cd6 100644
--- a/docs/mojo_run.md
+++ b/docs/mojo_run.md
@@ -4,18 +4,34 @@
 Android device.
 
 ```sh
-mojo_run APP_URL  # Run on the host.
+mojo_run APP_URL  # Run on Linux host.
 mojo_run APP_URL --android  # Run on Android device.
 mojo_run "APP_URL APP_ARGUMENTS"  # Run an app with startup arguments
 ```
 
-Unless running within a Mojo checkout, we need to indicate the path to the shell
-binary:
+## mojo version
+
+`mojo_run` will download mojo shell and configure it to use `mojo:` apps built
+at the corresponding version, if you pass the git commit sha of the
+https://github.com/domokit/mojo repository as `--mojo-version`:
 
 ```sh
-mojo_run --shell-path path/to/shell/binary APP_URL
+mojo_run APP_URL --mojo-version SOME_HASH
 ```
 
+If your project uses a pinned version of mojo, you can put the pinned hash in
+a `MOJO_VERSION` file in any ancestor directory of `mojo_run`. This will make
+`mojo_run` infer the parameter automatically.
+
+If you don't want to use prebuilt binaries at the given version, you can
+configure the shell binary and the origin to use manually:
+
+```sh
+mojo_run APP_URL --shell-path path/to/shell/binary --origin ORIGIN_URL
+```
+
+## Running applications in a view
+
 Some applications implement ViewProvider and are run embedded in a view. To run
 these, you can pass the app url using the `--embed` flag:
 
@@ -65,12 +81,3 @@
 mkdir ~/another_home
 HOME=~/another_home mojo_run APP_URL --reuse-servers
 ```
-
-## Setting default mojo origin
-
-When run outside of the `domokit/mojo` repository, `mojo_run` needs `--origin`
-parameter to indicate where binaries of the core mojo services come from. If a
-`MOJO_VERSION` file is present among ancestors of `mojo_run` and `--origin`
-parameter is not set, origin will point to Google Storage location storing
-binaries of core mojo services built at the git revision indicated in
-`MOJO_VERSION`.