2986: Implemented "arv edit" command. Refactored 'arv' to be easier to follow.
[arvados.git] / sdk / cli / bin / arv
1 #!/usr/bin/env ruby
2
3 # Arvados cli client
4 #
5 # Ward Vandewege <ward@clinicalfuture.com>
6
7 require 'fileutils'
8
9 if RUBY_VERSION < '1.9.3' then
10   abort <<-EOS
11 #{$0.gsub(/^\.\//,'')} requires Ruby version 1.9.3 or higher.
12   EOS
13 end
14
15 begin
16   require 'curb'
17   require 'rubygems'
18   require 'google/api_client'
19   require 'json'
20   require 'pp'
21   require 'trollop'
22   require 'andand'
23   require 'oj'
24   require 'active_support/inflector'
25   require 'yaml'
26 rescue LoadError
27   abort <<-EOS
28
29 Please install all required gems:
30
31   gem install activesupport andand curb google-api-client json oj trollop yaml
32
33   EOS
34 end
35
36 # Search for 'ENTRY POINT' to see where things get going
37
38 ActiveSupport::Inflector.inflections do |inflect|
39   inflect.irregular 'specimen', 'specimens'
40   inflect.irregular 'human', 'humans'
41 end
42
43 module Kernel
44   def suppress_warnings
45     original_verbosity = $VERBOSE
46     $VERBOSE = nil
47     result = yield
48     $VERBOSE = original_verbosity
49     return result
50   end
51 end
52
53 class Google::APIClient
54  def discovery_document(api, version)
55    api = api.to_s
56    return @discovery_documents["#{api}:#{version}"] ||=
57      begin
58        # fetch new API discovery doc if stale
59        cached_doc = File.expand_path '~/.cache/arvados/discovery_uri.json'
60        if not File.exist?(cached_doc) or (Time.now - File.mtime(cached_doc)) > 86400
61          response = self.execute!(:http_method => :get,
62                                   :uri => self.discovery_uri(api, version),
63                                   :authenticated => false)
64          FileUtils.makedirs(File.dirname cached_doc)
65          File.open(cached_doc, 'w') do |f|
66            f.puts response.body
67          end
68        end
69
70        File.open(cached_doc) { |f| JSON.load f }
71      end
72  end
73 end
74
75 class ArvadosClient < Google::APIClient
76   def execute(*args)
77     if args.last.is_a? Hash
78       args.last[:headers] ||= {}
79       args.last[:headers]['Accept'] ||= 'application/json'
80     end
81     super(*args)
82   end
83 end
84
85 def init_config
86   # read authentication data from arvados configuration file if present
87   lineno = 0
88   config_file = File.expand_path('~/.config/arvados/settings.conf')
89   if File.exist? config_file then
90     File.open(config_file, 'r').each do |line|
91       lineno = lineno + 1
92       # skip comments
93       if line.match('^\s*#') then
94         next
95       end
96       var, val = line.chomp.split('=', 2)
97       # allow environment settings to override config files.
98       if var and val
99         ENV[var] ||= val
100       else
101         warn "#{config_file}: #{lineno}: could not parse `#{line}'"
102       end
103     end
104   end
105 end
106
107 subcommands = %w(keep pipeline tag ws edit)
108
109 def check_subcommands client, arvados, subcommand, global_opts, remaining_opts
110   case subcommand
111   when 'keep'
112     @sub = remaining_opts.shift
113     if ['get', 'put', 'ls', 'normalize'].index @sub then
114       # Native Arvados
115       exec `which arv-#{@sub}`.strip, *remaining_opts
116     elsif ['less', 'check'].index @sub then
117       # wh* shims
118       exec `which wh#{@sub}`.strip, *remaining_opts
119     elsif @sub == 'docker'
120       exec `which arv-keepdocker`.strip, *remaining_opts
121     else
122       puts "Usage: arv keep [method] [--parameters]\n"
123       puts "Use 'arv keep [method] --help' to get more information about specific methods.\n\n"
124       puts "Available methods: ls, get, put, less, check, docker"
125     end
126     abort
127   when 'pipeline'
128     exec `which arv-run-pipeline-instance`.strip, *remaining_opts
129   when 'tag'
130     exec `which arv-tag`.strip, *remaining_opts
131   when 'ws'
132     exec `which arv-ws`.strip, *remaining_opts
133   when 'edit'
134     arv_edit client, arvados, global_opts, remaining_opts
135   end
136 end
137
138 def arv_edit client, arvados, global_opts, remaining_opts
139   n = remaining_opts.shift
140   if n.nil? or n == "-h" or n == "--help"
141     puts head_banner
142     puts "Usage: arv edit [uuid]\n\n"
143     puts "Fetchs the specified Arvados object, opens an interactive text\n"
144     puts "editor on a text representation (json or yaml, use --format)\n"
145     puts "and then updates the object.  Will use 'nano' by default, customize\n"
146     puts "with the EDITOR or VISUAL environment variable."
147     exit 255
148   end
149
150   if not $stdout.tty?
151     puts "Not connected to a TTY, cannot run interactive editor."
152     exit 1
153   end
154
155   # determine controller
156
157   m = /([a-z0-9]{5})-([a-z0-9]{5})-([a-z0-9]{15})/.match n
158   if !m
159     abort puts "#{n} does not appear to be an arvados uuid"
160   end
161
162   rsc = nil
163   arvados.discovery_document["resources"].each do |k,v|
164     klass = k.singularize.camelize
165     dig = Digest::MD5.hexdigest(klass).to_i(16).to_s(36)[-5..-1]
166     if dig == m[2]
167       rsc = k
168     end
169   end
170
171   if rsc.nil?
172     abort "Could not determine resource type #{m[2]}"
173   end
174
175   api_method = 'arvados.' + rsc + '.get'
176
177   result = client.execute(:api_method => eval(api_method),
178                           :parameters => {"uuid" => n},
179                           :authenticated => false,
180                           :headers => {
181                             authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
182                           })
183   begin
184     results = JSON.parse result.body
185   rescue JSON::ParserError => e
186     abort "Failed to parse server response:\n" + e.to_s
187   end
188
189   content = ""
190
191   case global_opts[:format]
192   when 'json'
193     content = Oj.dump(results, :indent => 1)
194   when 'yaml'
195     content = results.to_yaml
196   end
197
198   require 'tempfile'
199
200   tmp = Tempfile.new(n)
201   tmp.write(content)
202   tmp.close
203
204   pid = Process::fork
205   if pid.nil?
206     editor ||= ENV["VISUAL"]
207     editor ||= ENV["EDITOR"]
208     editor ||= "nano"
209     exec editor, tmp.path
210   else
211     Process.wait pid
212   end
213
214   if $?.exitstatus == 0
215     tmp.open
216     newcontent = tmp.read()
217
218     newobj = {}
219     case global_opts[:format]
220     when 'json'
221       newobj = Oj.load(newcontent)
222     when 'yaml'
223       newobj = YAML.load(newcontent)
224     end
225     tmp.close
226     tmp.unlink
227
228     if newobj != results
229       api_method = 'arvados.' + rsc + '.update'
230       result = client.execute(:api_method => eval(api_method),
231                               :parameters => {"uuid" => n, rsc.singularize => Oj.dump(newobj)},
232                               :authenticated => false,
233                               :headers => {
234                                 authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
235                               })
236
237       begin
238         results = JSON.parse result.body
239       rescue JSON::ParserError => e
240         abort "Failed to parse server response:\n" + e.to_s
241       end
242
243       if result.response.status != 200
244         puts "Update failed.  Server responded #{result.response.status}: #{results['errors']} "
245       end
246     else
247       puts "Object is unchanged, did not update."
248     end
249   end
250
251   exit 0
252 end
253
254 def to_boolean(s)
255   !!(s =~ /^(true|t|yes|y|1)$/i)
256 end
257
258 def head_banner
259   "Arvados command line client\n"
260 end
261
262 def help_methods(discovery_document, resource, method=nil)
263   banner = head_banner
264   banner += "Usage: arv #{resource} [method] [--parameters]\n"
265   banner += "Use 'arv #{resource} [method] --help' to get more information about specific methods.\n\n"
266   banner += "The #{resource} resource supports the following methods:"
267   banner += "\n\n"
268   discovery_document["resources"][resource.pluralize]["methods"].
269     each do |k,v|
270     description = ''
271     if v.include? "description"
272       # add only the first line of the discovery doc description
273       description = '  ' + v["description"].split("\n").first.chomp
274     end
275     banner += "   #{sprintf("%20s",k)}#{description}\n"
276   end
277   banner += "\n"
278   STDERR.puts banner
279
280   if not method.nil? and method != '--help' and method != '-h' then
281     abort "Unknown method #{method.inspect} " +
282                   "for resource #{resource.inspect}"
283   end
284   exit 255
285 end
286
287 def help_resources(option_parser, discovery_document, resource)
288   option_parser.educate
289
290   if not resource.nil? and resource != '--help' then
291     Trollop::die "Unknown resource type #{resource.inspect}"
292   end
293   exit 255
294 end
295
296 def parse_arguments(discovery_document, subcommands)
297   resource_types = Array.new()
298   discovery_document["resources"].each do |k,v|
299     resource_types << k.singularize
300   end
301
302   resource_types += subcommands
303
304   option_parser = Trollop::Parser.new do
305     version __FILE__
306     banner head_banner
307     banner "Usage: arv [--flags] subcommand|resource [method] [--parameters]"
308     banner ""
309     banner "Available flags:"
310
311     opt :dry_run, "Don't actually do anything", :short => "-n"
312     opt :verbose, "Print some things on stderr"
313     opt :format,
314         "Set the output format. Must be one of json (default), yaml or uuid.",
315         :type => :string,
316         :default => 'json'
317     opt :short, "Return only UUIDs (equivalent to --format=uuid)"
318
319     banner ""
320     banner "Use 'arv subcommand|resource --help' to get more information about a particular command or resource."
321     banner ""
322     banner "Available subcommands: #{subcommands.join(', ')}"
323     banner ""
324
325     banner "Available resources: #{discovery_document['resources'].keys.map { |k| k.singularize }.join(', ')}"
326
327     banner ""
328     banner "Additional options:"
329
330     conflicts :short, :format
331     stop_on resource_types
332   end
333
334   global_opts = Trollop::with_standard_exception_handling option_parser do
335     o = option_parser.parse ARGV
336   end
337
338   unless %w(json yaml uuid).include?(global_opts[:format])
339     $stderr.puts "#{$0}: --format must be one of json, yaml or uuid."
340     $stderr.puts "Use #{$0} --help for more information."
341     abort
342   end
343
344   if global_opts[:short]
345     global_opts[:format] = 'uuid'
346   end
347
348   resource = ARGV.shift
349
350   if not subcommands.include? resource
351     if global_opts[:resources] or not resource_types.include?(resource)
352       help_resources(option_parser, discovery_document, resource)
353     end
354
355     method = ARGV.shift
356     if not (discovery_document["resources"][resource.pluralize]["methods"].
357             include?(method))
358       help_methods(discovery_document, resource, method)
359     end
360
361     discovered_params = discovery_document\
362     ["resources"][resource.pluralize]\
363     ["methods"][method]["parameters"]
364     method_opts = Trollop::options do
365       banner head_banner
366       banner "Usage: arv #{resource} #{method} [--parameters]"
367       banner ""
368       banner "This method supports the following parameters:"
369       banner ""
370       discovered_params.each do |k,v|
371         opts = Hash.new()
372         opts[:type] = v["type"].to_sym if v.include?("type")
373         if [:datetime, :text, :object, :array].index opts[:type]
374           opts[:type] = :string                       # else trollop bork
375         end
376         opts[:default] = v["default"] if v.include?("default")
377         opts[:default] = v["default"].to_i if opts[:type] == :integer
378         opts[:default] = to_boolean(v["default"]) if opts[:type] == :boolean
379         opts[:required] = true if v.include?("required") and v["required"]
380         description = ''
381         description = '  ' + v["description"] if v.include?("description")
382         opt k.to_sym, description, opts
383       end
384
385       body_object = discovery_document["resources"][resource.pluralize]["methods"][method]["request"]
386       if body_object and discovered_params[resource].nil?
387         is_required = true
388         if body_object["required"] == false
389           is_required = false
390         end
391         opt resource.to_sym, "#{resource} (request body)", {
392           required: is_required,
393           type: :string
394         }
395       end
396     end
397
398     discovered_params.each do |k,v|
399       k = k.to_sym
400       if ['object', 'array'].index(v["type"]) and method_opts.has_key? k
401         if method_opts[k].andand.match /^\//
402           method_opts[k] = File.open method_opts[k], 'rb' do |f| f.read end
403         end
404       end
405     end
406   end
407
408   return resource, method, method_opts, global_opts, ARGV
409 end
410
411 #
412 # ENTRY POINT
413 #
414
415 init_config
416
417 ENV['ARVADOS_API_VERSION'] ||= 'v1'
418
419 if not ENV.include?('ARVADOS_API_HOST') or not ENV.include?('ARVADOS_API_TOKEN') then
420   abort <<-EOS
421 ARVADOS_API_HOST and ARVADOS_API_TOKEN need to be defined as environment variables.
422   EOS
423 end
424
425 # do this if you're testing with a dev server and you don't care about SSL certificate checks:
426 if ENV['ARVADOS_API_HOST_INSECURE']
427   suppress_warnings { OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE }
428 end
429
430 begin
431   client = ArvadosClient.new(:host => ENV['ARVADOS_API_HOST'], :application_name => 'arvados-cli', :application_version => '1.0')
432   arvados = client.discovered_api('arvados', ENV['ARVADOS_API_VERSION'])
433 rescue Exception => e
434   puts "Failed to connect to Arvados API server: #{e}"
435   exit 1
436 end
437
438 # Parse arguments here
439 resource_schema, method, method_opts, global_opts, remaining_opts = parse_arguments(arvados.discovery_document, subcommands)
440
441 check_subcommands client, arvados, resource_schema, global_opts, remaining_opts
442
443 controller = resource_schema.pluralize
444
445 api_method = 'arvados.' + controller + '.' + method
446
447 if global_opts[:dry_run]
448   if global_opts[:verbose]
449     $stderr.puts "#{api_method} #{method_opts.inspect}"
450   end
451   exit
452 end
453
454 request_parameters = {_profile:true}.merge(method_opts)
455 resource_body = request_parameters.delete(resource_schema.to_sym)
456 if resource_body
457   request_body = {
458     resource_schema => resource_body
459   }
460 else
461   request_body = nil
462 end
463
464 case api_method
465 when
466   'arvados.jobs.log_tail_follow'
467
468   # Special case for methods that respond with data streams rather
469   # than JSON (TODO: use the discovery document instead of a static
470   # list of methods)
471   uri_s = eval(api_method).generate_uri(request_parameters)
472   Curl::Easy.perform(uri_s) do |curl|
473     curl.headers['Accept'] = 'text/plain'
474     curl.headers['Authorization'] = "OAuth2 #{ENV['ARVADOS_API_TOKEN']}"
475     if ENV['ARVADOS_API_HOST_INSECURE']
476       curl.ssl_verify_peer = false
477       curl.ssl_verify_host = false
478     end
479     if global_opts[:verbose]
480       curl.on_header { |data| $stderr.write data }
481     end
482     curl.on_body { |data| $stdout.write data }
483   end
484   exit 0
485 else
486   result = client.execute(:api_method => eval(api_method),
487                           :parameters => request_parameters,
488                           :body => request_body,
489                           :authenticated => false,
490                           :headers => {
491                             authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
492                           })
493 end
494
495 begin
496   results = JSON.parse result.body
497 rescue JSON::ParserError => e
498   abort "Failed to parse server response:\n" + e.to_s
499 end
500
501 if results["errors"] then
502   abort "Error: #{results["errors"][0]}"
503 end
504
505 case global_opts[:format]
506 when 'json'
507   puts Oj.dump(results, :indent => 1)
508 when 'yaml'
509   puts results.to_yaml
510 else
511   if results["items"] and results["kind"].match /list$/i
512     results['items'].each do |i| puts i['uuid'] end
513   elsif results['uuid'].nil?
514     abort("Response did not include a uuid:\n" +
515           Oj.dump(results, :indent => 1) +
516           "\n")
517   else
518     puts results['uuid']
519   end
520 end