Merge branch 'master' into 4570-multi-auth-method
[arvados.git] / sdk / cli / bin / arv
index 355a947a55b5763239d95f1efae1ac606ea2b49b..533ea39eb7b27c2d96f391e1bc47c2cb8d599fc9 100755 (executable)
@@ -2,7 +2,7 @@
 
 # Arvados cli client
 #
 
 # Arvados cli client
 #
-# Ward Vandewege <ward@clinicalfuture.com>
+# Ward Vandewege <ward@curoverse.com>
 
 require 'fileutils'
 
 
 require 'fileutils'
 
@@ -23,6 +23,7 @@ begin
   require 'oj'
   require 'active_support/inflector'
   require 'yaml'
   require 'oj'
   require 'active_support/inflector'
   require 'yaml'
+  require 'tempfile'
 rescue LoadError
   abort <<-EOS
 
 rescue LoadError
   abort <<-EOS
 
@@ -53,17 +54,25 @@ end
 class Google::APIClient
  def discovery_document(api, version)
    api = api.to_s
 class Google::APIClient
  def discovery_document(api, version)
    api = api.to_s
-   return @discovery_documents["#{api}:#{version}"] ||=
+   discovery_uri = self.discovery_uri(api, version)
+   discovery_uri_hash = Digest::MD5.hexdigest(discovery_uri)
+   return @discovery_documents[discovery_uri_hash] ||=
      begin
        # fetch new API discovery doc if stale
      begin
        # fetch new API discovery doc if stale
-       cached_doc = File.expand_path '~/.cache/arvados/discovery_uri.json'
-       if not File.exist?(cached_doc) or (Time.now - File.mtime(cached_doc)) > 86400
+       cached_doc = File.expand_path "~/.cache/arvados/discovery-#{discovery_uri_hash}.json" rescue nil
+
+       if cached_doc.nil? or not File.exist?(cached_doc) or (Time.now - File.mtime(cached_doc)) > 86400
          response = self.execute!(:http_method => :get,
          response = self.execute!(:http_method => :get,
-                                  :uri => self.discovery_uri(api, version),
+                                  :uri => discovery_uri,
                                   :authenticated => false)
                                   :authenticated => false)
-         FileUtils.makedirs(File.dirname cached_doc)
-         File.open(cached_doc, 'w') do |f|
-           f.puts response.body
+
+         begin
+           FileUtils.makedirs(File.dirname cached_doc)
+           File.open(cached_doc, 'w') do |f|
+             f.puts response.body
+           end
+         rescue
+           return JSON.load response.body
          end
        end
 
          end
        end
 
@@ -85,8 +94,8 @@ end
 def init_config
   # read authentication data from arvados configuration file if present
   lineno = 0
 def init_config
   # read authentication data from arvados configuration file if present
   lineno = 0
-  config_file = File.expand_path('~/.config/arvados/settings.conf')
-  if File.exist? config_file then
+  config_file = File.expand_path('~/.config/arvados/settings.conf') rescue nil
+  if not config_file.nil? and File.exist? config_file then
     File.open(config_file, 'r').each do |line|
       lineno = lineno + 1
       # skip comments
     File.open(config_file, 'r').each do |line|
       lineno = lineno + 1
       # skip comments
@@ -104,10 +113,17 @@ def init_config
   end
 end
 
   end
 end
 
-subcommands = %w(keep pipeline tag ws edit)
+
+subcommands = %w(copy create edit keep pipeline run tag ws)
 
 def check_subcommands client, arvados, subcommand, global_opts, remaining_opts
   case subcommand
 
 def check_subcommands client, arvados, subcommand, global_opts, remaining_opts
   case subcommand
+  when 'create'
+    arv_create client, arvados, global_opts, remaining_opts
+  when 'edit'
+    arv_edit client, arvados, global_opts, remaining_opts
+  when 'copy', 'tag', 'ws', 'run'
+    exec `which arv-#{subcommand}`.strip, *remaining_opts
   when 'keep'
     @sub = remaining_opts.shift
     if ['get', 'put', 'ls', 'normalize'].index @sub then
   when 'keep'
     @sub = remaining_opts.shift
     if ['get', 'put', 'ls', 'normalize'].index @sub then
@@ -125,24 +141,92 @@ def check_subcommands client, arvados, subcommand, global_opts, remaining_opts
     end
     abort
   when 'pipeline'
     end
     abort
   when 'pipeline'
-    exec `which arv-run-pipeline-instance`.strip, *remaining_opts
-  when 'tag'
-    exec `which arv-tag`.strip, *remaining_opts
-  when 'ws'
-    exec `which arv-ws`.strip, *remaining_opts
-  when 'edit'
-    arv_edit client, arvados, global_opts, remaining_opts
+    sub = remaining_opts.shift
+    if sub == 'run'
+      exec `which arv-run-pipeline-instance`.strip, *remaining_opts
+    else
+      puts "Usage: arv pipeline [method] [--parameters]\n"
+      puts "Use 'arv pipeline [method] --help' to get more information about specific methods.\n\n"
+      puts "Available methods: run"
+    end
+    abort
   end
 end
 
   end
 end
 
+def arv_edit_save_tmp tmp
+  FileUtils::cp tmp.path, tmp.path + ".saved"
+  puts "Saved contents to " + tmp.path + ".saved"
+end
+
+def command_exists?(command)
+  ENV['PATH'].split(':').each {|folder| File.executable?(File.join(folder, command))}
+end
+
+def run_editor tmp_file, global_opts
+  need_edit = true
+  while need_edit
+    pid = Process::fork
+    if pid.nil?
+      editor = nil
+      [ENV["VISUAL"], ENV["EDITOR"], "nano", "vi"].each do |e|
+        editor ||= e if e and command_exists? e
+      end
+      if editor.nil?
+        puts "Could not find any editor to use, please set $VISUAL or $EDITOR to your desired editor."
+        exit 1
+      end
+      exec editor, tmp_file.path
+    else
+      Process.wait pid
+    end
+
+    if $?.exitstatus == 0
+      tmp_file.open
+      newcontent = tmp_file.read()
+
+      newobj = {}
+      begin
+        case global_opts[:format]
+        when 'json'
+          newobj = Oj.load(newcontent)
+        when 'yaml'
+          newobj = YAML.load(newcontent)
+        end
+        need_edit = false
+      rescue Exception => e
+        n = 1
+        newcontent.each_line do |line|
+          puts "#{n.to_s.rjust 4}  #{line}"
+          n += 1
+        end
+        puts "Parse error! " + e.to_s
+        puts "\nTry again (y/n)? "
+        yn = "X"
+        while not ["y", "Y", "n", "N"].include?(yn)
+          yn = $stdin.read 1
+        end
+        if yn == 'n' or yn == 'N'
+          arv_edit_save_tmp tmp_file
+          abort
+        end
+      end
+    else
+      puts "Editor exited with status #{$?.exitstatus}"
+      exit $?.exitstatus
+    end
+  end
+
+  newobj
+end
+
 def arv_edit client, arvados, global_opts, remaining_opts
 def arv_edit client, arvados, global_opts, remaining_opts
-  n = remaining_opts.shift
-  if n.nil? or n == "-h" or n == "--help"
+  uuid = remaining_opts.shift
+  if uuid.nil? or uuid == "-h" or uuid == "--help"
     puts head_banner
     puts "Usage: arv edit [uuid] [fields...]\n\n"
     puts head_banner
     puts "Usage: arv edit [uuid] [fields...]\n\n"
-    puts "Fetchs the specified Arvados object, select the specified fields, and\n"
+    puts "Fetch the specified Arvados object, select the specified fields, \n"
     puts "open an interactive text editor on a text representation (json or\n"
     puts "open an interactive text editor on a text representation (json or\n"
-    puts "yaml, use --format) and then updates the object.  Will use 'nano'\n"
+    puts "yaml, use --format) and then update the object.  Will use 'nano'\n"
     puts "by default, customize with the EDITOR or VISUAL environment variable.\n"
     exit 255
   end
     puts "by default, customize with the EDITOR or VISUAL environment variable.\n"
     exit 255
   end
@@ -154,9 +238,13 @@ def arv_edit client, arvados, global_opts, remaining_opts
 
   # determine controller
 
 
   # determine controller
 
-  m = /([a-z0-9]{5})-([a-z0-9]{5})-([a-z0-9]{15})/.match n
+  m = /([a-z0-9]{5})-([a-z0-9]{5})-([a-z0-9]{15})/.match uuid
   if !m
   if !m
-    abort puts "#{n} does not appear to be an arvados uuid"
+    if /^[a-f0-9]{32}/.match uuid
+      abort "Arvados collections are not editable."
+    else
+      abort "#{n} does not appear to be an Arvados uuid"
+    end
   end
 
   rsc = nil
   end
 
   rsc = nil
@@ -175,7 +263,7 @@ def arv_edit client, arvados, global_opts, remaining_opts
   api_method = 'arvados.' + rsc + '.get'
 
   result = client.execute(:api_method => eval(api_method),
   api_method = 'arvados.' + rsc + '.get'
 
   result = client.execute(:api_method => eval(api_method),
-                          :parameters => {"uuid" => n},
+                          :parameters => {"uuid" => uuid},
                           :authenticated => false,
                           :headers => {
                             authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
                           :authenticated => false,
                           :headers => {
                             authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
@@ -199,57 +287,152 @@ def arv_edit client, arvados, global_opts, remaining_opts
     content = results.to_yaml
   end
 
     content = results.to_yaml
   end
 
-  require 'tempfile'
-
-  tmp = Tempfile.new([n, "." + global_opts[:format]])
-  tmp.write(content)
-  tmp.close
-
-  pid = Process::fork
-  if pid.nil?
-    editor ||= ENV["VISUAL"]
-    editor ||= ENV["EDITOR"]
-    editor ||= "nano"
-    exec editor, tmp.path
-  else
-    Process.wait pid
-  end
-
-  if $?.exitstatus == 0
-    tmp.open
-    newcontent = tmp.read()
+  tmp_file = Tempfile.new([uuid, "." + global_opts[:format]])
+  tmp_file.write(content)
+  tmp_file.close
 
 
-    newobj = {}
-    case global_opts[:format]
-    when 'json'
-      newobj = Oj.load(newcontent)
-    when 'yaml'
-      newobj = YAML.load(newcontent)
-    end
-    tmp.close
-    tmp.unlink
+  newobj = run_editor tmp_file, global_opts
 
 
+  begin
     if newobj != results
       api_method = 'arvados.' + rsc + '.update'
     if newobj != results
       api_method = 'arvados.' + rsc + '.update'
-      result = client.execute(:api_method => eval(api_method),
-                              :parameters => {"uuid" => n, rsc.singularize => Oj.dump(newobj)},
-                              :authenticated => false,
-                              :headers => {
-                                authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
-                              })
+      dumped = Oj.dump(newobj)
+
+      begin
+        result = client.execute(:api_method => eval(api_method),
+                                :parameters => {"uuid" => uuid},
+                                :body_object => { rsc.singularize => dumped },
+                                :authenticated => false,
+                                :headers => {
+                                  authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                                })
+      rescue Exception => e
+        puts "Error communicating with server, error was #{e}"
+        puts "Update body was:"
+        puts dumped
+        arv_edit_save_tmp tmp_file
+        abort
+      end
 
       begin
         results = JSON.parse result.body
       rescue JSON::ParserError => e
 
       begin
         results = JSON.parse result.body
       rescue JSON::ParserError => e
+        arv_edit_save_tmp tmp_file
         abort "Failed to parse server response:\n" + e.to_s
       end
 
       if result.response.status != 200
         puts "Update failed.  Server responded #{result.response.status}: #{results['errors']} "
         abort "Failed to parse server response:\n" + e.to_s
       end
 
       if result.response.status != 200
         puts "Update failed.  Server responded #{result.response.status}: #{results['errors']} "
+        puts "Update body was:"
+        puts dumped
+        arv_edit_save_tmp tmp_file
+        abort
       end
     else
       puts "Object is unchanged, did not update."
     end
       end
     else
       puts "Object is unchanged, did not update."
     end
+  ensure
+    tmp_file.close(true)
+  end
+
+  exit 0
+end
+
+def arv_create client, arvados, global_opts, remaining_opts
+  types = resource_types(arvados.discovery_document)
+  create_opts = Trollop::options do
+    opt :project_uuid, "Project uuid in which to create the object", :type => :string
+    stop_on resource_types(arvados.discovery_document)
+  end
+
+  object_type = remaining_opts.shift
+  if object_type.nil?
+    abort "Missing resource type, must be one of #{types.join ', '}"
+  end
+
+  rsc = arvados.discovery_document["resources"].keys.select { |k| object_type == k.singularize }
+  if rsc.empty?
+    abort "Could not determine resource type #{object_type}"
+  end
+  rsc = rsc.first
+
+  discovered_params = arvados.discovery_document["resources"][rsc]["methods"]["create"]["parameters"]
+  method_opts = Trollop::options do
+    banner head_banner
+    banner "Usage: arv create [--project-uuid] #{object_type} [create parameters]"
+    banner ""
+    banner "This method supports the following parameters:"
+    banner ""
+    discovered_params.each do |k,v|
+      opts = Hash.new()
+      opts[:type] = v["type"].to_sym if v.include?("type")
+      if [:datetime, :text, :object, :array].index opts[:type]
+        opts[:type] = :string                       # else trollop bork
+      end
+      opts[:default] = v["default"] if v.include?("default")
+      opts[:default] = v["default"].to_i if opts[:type] == :integer
+      opts[:default] = to_boolean(v["default"]) if opts[:type] == :boolean
+      opts[:required] = true if v.include?("required") and v["required"]
+      description = ''
+      description = '  ' + v["description"] if v.include?("description")
+      opt k.to_sym, description, opts
+    end
+  end
+
+
+  newobj = {}
+  if create_opts[:project_uuid]
+    newobj["owner_uuid"] = create_opts[:project_uuid]
+  end
+
+  case global_opts[:format]
+  when 'json'
+    content = Oj.dump(newobj, :indent => 1)
+  when 'yaml'
+    content = newobj.to_yaml
+  end
+
+  tmp_file = Tempfile.new(["", ".#{global_opts[:format]}"])
+  tmp_file.write(content)
+  tmp_file.close
+
+  newobj = run_editor tmp_file, global_opts
+
+  begin
+    api_method = 'arvados.' + rsc + '.create'
+    dumped = Oj.dump(newobj)
+
+    result = client.execute(:api_method => eval(api_method),
+                            :parameters => method_opts,
+                            :body_object => {object_type => newobj},
+                            :authenticated => false,
+                            :headers => {
+                              authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                            })
+
+    begin
+      results = JSON.parse result.body
+    rescue JSON::ParserError => e
+      arv_edit_save_tmp tmp_file
+      abort "Failed to parse server response:\n" + e.to_s
+    end
+
+    if result.response.status != 200
+      puts "Create failed.  Server responded #{result.response.status}: #{results['errors']} "
+      puts "Create body was:"
+      puts dumped
+      arv_edit_save_tmp tmp_file
+      abort
+    end
+
+    begin
+      puts "Created object #{results['uuid']}"
+    rescue
+      arv_edit_save_tmp tmp_file
+      abort "Unexpected response:\n#{results}"
+    end
+  ensure
+    tmp_file.close(true)
   end
 
   exit 0
   end
 
   exit 0
@@ -290,20 +473,19 @@ end
 
 def help_resources(option_parser, discovery_document, resource)
   option_parser.educate
 
 def help_resources(option_parser, discovery_document, resource)
   option_parser.educate
-
-  if not resource.nil? and resource != '--help' then
-    Trollop::die "Unknown resource type #{resource.inspect}"
-  end
   exit 255
 end
 
   exit 255
 end
 
-def parse_arguments(discovery_document, subcommands)
+def resource_types discovery_document
   resource_types = Array.new()
   discovery_document["resources"].each do |k,v|
     resource_types << k.singularize
   end
   resource_types = Array.new()
   discovery_document["resources"].each do |k,v|
     resource_types << k.singularize
   end
+  resource_types
+end
 
 
-  resource_types += subcommands
+def parse_arguments(discovery_document, subcommands)
+  resources_and_subcommands = resource_types(discovery_document) + subcommands
 
   option_parser = Trollop::Parser.new do
     version __FILE__
 
   option_parser = Trollop::Parser.new do
     version __FILE__
@@ -332,7 +514,7 @@ def parse_arguments(discovery_document, subcommands)
     banner "Additional options:"
 
     conflicts :short, :format
     banner "Additional options:"
 
     conflicts :short, :format
-    stop_on resource_types
+    stop_on resources_and_subcommands
   end
 
   global_opts = Trollop::with_standard_exception_handling option_parser do
   end
 
   global_opts = Trollop::with_standard_exception_handling option_parser do
@@ -352,7 +534,8 @@ def parse_arguments(discovery_document, subcommands)
   resource = ARGV.shift
 
   if not subcommands.include? resource
   resource = ARGV.shift
 
   if not subcommands.include? resource
-    if global_opts[:resources] or not resource_types.include?(resource)
+    if not resources_and_subcommands.include?(resource)
+      puts "Resource or subcommand '#{resource}' is not recognized.\n\n" if !resource.nil?
       help_resources(option_parser, discovery_document, resource)
     end
 
       help_resources(option_parser, discovery_document, resource)
     end
 
@@ -489,7 +672,7 @@ when
 else
   result = client.execute(:api_method => eval(api_method),
                           :parameters => request_parameters,
 else
   result = client.execute(:api_method => eval(api_method),
                           :parameters => request_parameters,
-                          :body => request_body,
+                          :body_object => request_body,
                           :authenticated => false,
                           :headers => {
                             authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
                           :authenticated => false,
                           :headers => {
                             authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']