aspera-cli 4.25.2 → 4.25.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,16 +6,16 @@ require 'aspera/oauth'
6
6
  require 'aspera/log'
7
7
  require 'aspera/assert'
8
8
  require 'aspera/environment'
9
- require 'zlib'
10
9
  require 'base64'
11
10
  require 'openssl'
12
11
  require 'pathname'
12
+ require 'zlib'
13
13
  require 'net/ssh/buffer'
14
14
 
15
15
  module Aspera
16
16
  module Api
17
17
  # Provides additional functions using node API with gen4 extensions (access keys)
18
- class Node < Aspera::Rest
18
+ class Node < Rest
19
19
  # Format of node scope : node.<access key>:<scope>
20
20
  module Scope
21
21
  # Node sub-scopes
@@ -42,9 +42,10 @@ module Aspera
42
42
  ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
43
43
  # Special HTTP Headers
44
44
  HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
45
- HEADER_X_TOTAL_COUNT = 'X-Total-Count'
46
45
  HEADER_X_CACHE_CONTROL = 'X-Aspera-Cache-Control'
46
+ HEADER_X_TOTAL_COUNT = 'X-Total-Count'
47
47
  HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
48
+ HEADER_ACCEPT_VERSION = 'Accept-Version'
48
49
  # / in cloud
49
50
  PATH_SEPARATOR = '/'
50
51
 
@@ -52,22 +53,36 @@ module Aspera
52
53
  OAuth::Factory.instance.register_decoder(lambda{ |token| Node.decode_bearer_token(token)})
53
54
 
54
55
  # Class instance variable, access with accessors on class
55
- @use_standard_ports = true
56
- @use_node_cache = true
57
-
58
- class << self
56
+ @api_options = {
59
57
  # Set to false to read transfer parameters from download_setup
60
- attr_accessor :use_standard_ports
58
+ standard_ports: true,
61
59
  # Set to false to bypass cache in redis
62
- attr_accessor :use_node_cache
60
+ cache: true,
61
+ accept_v4: true
62
+ }
63
+ OPTIONS = @api_options.keys.freeze
64
+
65
+ class << self
66
+ attr_reader :api_options
63
67
  attr_reader :use_dynamic_key
64
68
 
65
- # Adds cache control header, as globally specified to read request
66
- # Use like this: read(...,**cache_control)
67
- def cache_control
68
- headers = {'Accept' => Rest::MIME_JSON}
69
- headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless use_node_cache
70
- {headers: headers}
69
+ def api_options=(h)
70
+ Aspera.assert_type(h, Hash)
71
+ h.each do |k, v|
72
+ Aspera.assert(@api_options.key?(k.to_sym)){"unknown api option: #{k} (#{OPTIONS.join(', ')})"}
73
+ Aspera.assert_type(v, TrueClass, FalseClass){"api options value for #{k} should be boolean"}
74
+ @api_options[k.to_sym] = v
75
+ end
76
+ end
77
+
78
+ # Adds cache control header for node API /files/:id
79
+ # as globally specified to read request
80
+ # Use like this: read(..., headers: add_cache_control)
81
+ # @param headers [Hash] optional initial headers to add to
82
+ # @return [Hash] headers with cache control header added if needed
83
+ def add_cache_control(headers = {})
84
+ headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless api_options[:cache]
85
+ headers
71
86
  end
72
87
 
73
88
  # Set private key to be used
@@ -180,8 +195,8 @@ module Aspera
180
195
  Aspera.assert(!access_key.nil?)
181
196
  end
182
197
  return {
183
- Node::HEADER_X_ASPERA_ACCESS_KEY => access_key,
184
- 'Authorization' => bearer_auth
198
+ HEADER_X_ASPERA_ACCESS_KEY => access_key,
199
+ 'Authorization' => bearer_auth
185
200
  }
186
201
  end
187
202
  end
@@ -248,6 +263,50 @@ module Aspera
248
263
  return false
249
264
  end
250
265
 
266
+ # Read folder content, with pagination management for gen4, not recursive
267
+ # if `Accept-Version: 4.0` is not specified:
268
+ # if `page` and `per_page` are not specified, then all entries are returned.
269
+ # if either `page` or `per_page` is specified, then both are required, else 400
270
+ # if `Accept-Version: 4.0` is specified:
271
+ # those queries are not available: page (not mentioned), sort, min_size, max_size, min_modified_time, max_modified_time, target_id, target_node_id, files_prefetch_count, page, name_iglob : either ignored or result in API error 400.
272
+ # query include is accepted, but seems to do nothing as access_levels and recursive_counts are already included in results.
273
+ # query `iteration_token` is accepted and allows to get paginated results, with `X-Aspera-Next-Iteration-Token` header in response to get next page token. `X-Aspera-Total-Count` header gives total count of entries.
274
+ def read_folder_content(file_id, query = nil, exception: true, path: nil)
275
+ folder_items = []
276
+ begin
277
+ query ||= {}
278
+ headers = self.class.add_cache_control
279
+ use_v4 = self.class.api_options[:accept_v4]
280
+ return read("files/#{file_id}/files", query, headers: headers) unless use_v4 || query.key?('page') || query.key?('per_page')
281
+ if use_v4
282
+ headers[HEADER_ACCEPT_VERSION] = '4.0'
283
+ query['per_page'] = 1000 unless query.key?('per_page')
284
+ elsif query.key?('per_page') && !query.key?('page')
285
+ query['page'] = 0
286
+ end
287
+ loop do
288
+ RestParameters.instance.spinner_cb.call(folder_items.count)
289
+ data, http = read("files/#{file_id}/files", query, headers: headers, ret: :both)
290
+ folder_items.concat(data)
291
+ if use_v4
292
+ iteration_token = http[HEADER_X_NEXT_ITER_TOKEN]
293
+ break if iteration_token.nil? || iteration_token.empty?
294
+ query['iteration_token'] = iteration_token
295
+ else
296
+ break if data['item_count'].eql?(0)
297
+ query['offset'] += data['item_count']
298
+ end
299
+ end
300
+ rescue StandardError => e
301
+ raise e if exception
302
+ Log.log.warn{"#{path}: #{e.class} #{e.message}"}
303
+ Log.log.debug{(['Backtrace:'] + e.backtrace).join("\n")}
304
+ ensure
305
+ RestParameters.instance.spinner_cb.call(folder_items.count, action: :success)
306
+ end
307
+ folder_items
308
+ end
309
+
251
310
  # Recursively browse in a folder (with non-recursive method)
252
311
  # Entries of folders are processed if the processing method returns true
253
312
  # Links are processed on the respective node
@@ -267,14 +326,7 @@ module Aspera
267
326
  current_item = folders_to_explore.shift
268
327
  Log.log.debug{"Exploring #{current_item[:path]}".bg_green}
269
328
  # Get folder content
270
- folder_contents =
271
- begin
272
- # TODO: use header
273
- read("files/#{current_item[:id]}/files", query, **self.class.cache_control)
274
- rescue StandardError => e
275
- Log.log.warn{"#{current_item[:path]}: #{e.class} #{e.message}"}
276
- []
277
- end
329
+ folder_contents = read_folder_content(current_item[:id], query, exception: false, path: current_item[:path])
278
330
  Log.dump(:folder_contents, folder_contents)
279
331
  folder_contents.each do |entry|
280
332
  if entry.key?('error')
@@ -308,7 +360,9 @@ module Aspera
308
360
  # @param top_file_id [String] id initial file id
309
361
  # @param path [String] file or folder path (end with "/" is like setting process_last_link)
310
362
  # @param process_last_link [Boolean] if true, follow the last link
311
- # @return [Hash] {.api,.file_id}
363
+ # @return [Hash] Result data
364
+ # @option return [Rest] :api REST client instance
365
+ # @option return [String] :file_id File identifier
312
366
  def resolve_api_fid(top_file_id, path, process_last_link = false)
313
367
  Aspera.assert_type(top_file_id, String)
314
368
  Aspera.assert_type(path, String)
@@ -443,7 +497,7 @@ module Aspera
443
497
  # Add application specific tags (AoC)
444
498
  @app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: @app_info) unless @app_info.nil?
445
499
  # Add remote host info
446
- if self.class.use_standard_ports
500
+ if self.class.api_options[:standard_ports]
447
501
  # Get default TCP/UDP ports and transfer user
448
502
  transfer_spec.merge!(Transfer::Spec::AK_TSPEC_BASE)
449
503
  # By default: same address as node API
data/lib/aspera/assert.rb CHANGED
@@ -93,7 +93,7 @@ module Aspera
93
93
  # The value is not one of the expected values
94
94
  # @param value [Object] The wrong value
95
95
  # @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
96
- # @param block [Proc] Additional description in front of message
96
+ # @param &block [Proc] Additional description in front of message
97
97
  def error_unexpected_value(value, type: InternalError)
98
98
  report_error(type, "#{"#{yield}: " if block_given?}unexpected value: #{value.inspect}")
99
99
  end
@@ -128,20 +128,34 @@ module Aspera
128
128
  @spinner = nil
129
129
  end
130
130
 
131
- # call this after REST calls if several api calls are expected
132
- def long_operation_running(title = '')
131
+ def long_operation(title = nil, action: :spin)
133
132
  return unless Environment.terminal?
134
- if @spinner.nil?
133
+ return if %i[error data].include?(@options[:display])
134
+
135
+ # Handle the "delayed start" state
136
+ return @spinner = :starting if action == :spin && @spinner.nil?
137
+
138
+ # Cleanup if we try to stop a spinner that never actually started
139
+ @spinner = nil if action != :spin && @spinner == :starting
140
+ return if @spinner.nil?
141
+
142
+ # Initialize the real TTY object if it's currently just the :starting symbol
143
+ if @spinner == :starting
135
144
  @spinner = TTY::Spinner.new('[:spinner] :title', format: :classic)
145
+ @spinner.update(title: '')
136
146
  @spinner.start
137
147
  end
138
- @spinner.update(title: title)
139
- @spinner.spin
140
- end
141
148
 
142
- def long_operation_terminated
143
- @spinner&.stop
144
- @spinner = nil
149
+ @spinner.update(title: title) if title
150
+
151
+ case action
152
+ when :spin
153
+ @spinner.spin
154
+ when :success, :fail
155
+ action == :success ? @spinner.success : @spinner.error
156
+ @spinner.stop
157
+ @spinner = nil
158
+ end
145
159
  end
146
160
 
147
161
  def declare_options(options)
@@ -150,9 +164,9 @@ module Aspera
150
164
  else
151
165
  {}
152
166
  end
167
+ options.declare(:display, 'Output only some information', allowed: DISPLAY_LEVELS, handler: {o: self, m: :option_handler}, default: :data)
153
168
  options.declare(:format, 'Output format', allowed: DISPLAY_FORMATS, handler: {o: self, m: :option_handler}, default: :table)
154
169
  options.declare(:output, 'Destination for results', handler: {o: self, m: :option_handler})
155
- options.declare(:display, 'Output only some information', allowed: DISPLAY_LEVELS, handler: {o: self, m: :option_handler}, default: :info)
156
170
  options.declare(
157
171
  :fields, "Comma separated list of: fields, or #{SpecialValues::ALL}, or #{SpecialValues::DEF}", handler: {o: self, m: :option_handler},
158
172
  allowed: [String, Array, Regexp, Proc],
@@ -178,6 +192,8 @@ module Aspera
178
192
  @options[option_symbol] = value
179
193
  # special handling of some options
180
194
  case option_symbol
195
+ when :format
196
+ @options[:display] = value.eql?(:table) ? :info : :data
181
197
  when :output
182
198
  $stdout = if value.eql?('-')
183
199
  STDOUT # rubocop:disable Style/GlobalStdStream
@@ -197,7 +213,7 @@ module Aspera
197
213
  nil
198
214
  end
199
215
 
200
- # main output method
216
+ # Main output method
201
217
  # data: for requested data, not displayed if level==error
202
218
  # info: additional info, displayed if level==info
203
219
  # error: always displayed on stderr
@@ -115,6 +115,13 @@ module Aspera
115
115
  remediation: [
116
116
  'Check your public key in your AoC user profile.'
117
117
  ]
118
+ },
119
+ {
120
+ exception: Aspera::RestCallError,
121
+ match: /Please configure ACLs for this URI/,
122
+ remediation: [
123
+ 'server must have: asnodeadmin -mu <node user> --acl-add=internal --internal'
124
+ ]
118
125
  }
119
126
  ]
120
127
  private_constant :ERROR_HINTS
@@ -12,6 +12,37 @@ require 'optparse'
12
12
 
13
13
  module Aspera
14
14
  module Cli
15
+ module BoolValue
16
+ # boolean options are set to true/false from the following values
17
+ YES_SYM = :yes
18
+ NO_SYM = :no
19
+ FALSE_VALUES = [NO_SYM, false].freeze
20
+ TRUE_VALUES = [YES_SYM, true].freeze
21
+ private_constant :YES_SYM, :NO_SYM, :FALSE_VALUES, :TRUE_VALUES
22
+ # Boolean values
23
+ # @return [Array<true, false, :yes, :no>]
24
+ ALL = (TRUE_VALUES + FALSE_VALUES).freeze
25
+ TYPES = [FalseClass, TrueClass].freeze
26
+ SYMBOLS = [NO_SYM, YES_SYM].freeze
27
+ # @return `true` if value is a value for `true` in ALL
28
+ def true?(enum)
29
+ Aspera.assert_values(enum, ALL){'boolean'}
30
+ return TRUE_VALUES.include?(enum)
31
+ end
32
+
33
+ # @return [:yes, :no]
34
+ def to_sym(enum)
35
+ Aspera.assert_values(enum, ALL){'boolean'}
36
+ return TRUE_VALUES.include?(enum) ? YES_SYM : NO_SYM
37
+ end
38
+
39
+ # @return `true` if value is a value for `true` or `false` in ALL
40
+ def symbol?(sym)
41
+ return ALL.include?(sym)
42
+ end
43
+ module_function :true?, :to_sym, :symbol?
44
+ end
45
+
15
46
  # Constants to be used as parameter `allowed:` for `OptionValue`
16
47
  module Allowed
17
48
  # This option can be set to a single string or array, multiple times, and gives Array of String
@@ -20,7 +51,7 @@ module Aspera
20
51
  TYPES_SYMBOL_ARRAY = [Array, Symbol].freeze
21
52
  # Value will be coerced to int
22
53
  TYPES_INTEGER = [Integer].freeze
23
- TYPES_BOOLEAN = [FalseClass, TrueClass].freeze
54
+ TYPES_BOOLEAN = BoolValue::TYPES
24
55
  # no value at all, it's a switch
25
56
  TYPES_NONE = [].freeze
26
57
  TYPES_ENUM = [Symbol].freeze
@@ -74,7 +105,7 @@ module Aspera
74
105
  @values = allowed[Allowed::TYPES_SYMBOL_ARRAY.length..-1]
75
106
  elsif allowed.all?(Class)
76
107
  @types = allowed
77
- @values = Manager::BOOLEAN_VALUES if allowed.eql?(Allowed::TYPES_BOOLEAN)
108
+ @values = BoolValue::ALL if allowed.eql?(Allowed::TYPES_BOOLEAN)
78
109
  # Default value for array
79
110
  @object ||= [] if @types.first.eql?(Array) && !@types.include?(NilClass)
80
111
  @object ||= {} if @types.first.eql?(Hash) && !@types.include?(NilClass)
@@ -110,7 +141,7 @@ module Aspera
110
141
  Aspera.assert(!@deprecation, type: warn){"Option #{@option} is deprecated: #{@deprecation}"}
111
142
  new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
112
143
  Log.log.trace1{"#{where}: #{@option} <- (#{new_value.class})#{new_value}"}
113
- new_value = Manager.enum_to_bool(new_value) if @types.eql?(Allowed::TYPES_BOOLEAN)
144
+ new_value = BoolValue.true?(new_value) if @types.eql?(Allowed::TYPES_BOOLEAN)
114
145
  new_value = Integer(new_value) if @types.eql?(Allowed::TYPES_INTEGER)
115
146
  new_value = [new_value] if @types.eql?(Allowed::TYPES_STRING_ARRAY) && new_value.is_a?(String)
116
147
  # Setting a Hash to null set an empty hash
@@ -142,20 +173,7 @@ module Aspera
142
173
  # arguments options start with '-', others are commands
143
174
  # resolves on extended value syntax
144
175
  class Manager
145
- BOOLEAN_SIMPLE = %i[no yes].freeze
146
176
  class << self
147
- # @return `true` if value is a value for `true` in BOOLEAN_VALUES
148
- def enum_to_bool(enum)
149
- Aspera.assert_values(enum, BOOLEAN_VALUES){'boolean'}
150
- return TRUE_VALUES.include?(enum)
151
- end
152
-
153
- # @return :yes ot :no
154
- def enum_to_yes_no(enum)
155
- Aspera.assert_values(enum, BOOLEAN_VALUES){'boolean'}
156
- return TRUE_VALUES.include?(enum) ? BOOL_YES : BOOL_NO
157
- end
158
-
159
177
  # Find shortened string value in allowed symbol list
160
178
  def get_from_list(short_value, descr, allowed_values)
161
179
  Aspera.assert_type(short_value, String)
@@ -165,7 +183,7 @@ module Aspera
165
183
  matching = allowed_values.select{ |i| i.to_s.start_with?(short_value)}
166
184
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
167
185
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
168
- return enum_to_bool(matching.first) if allowed_values.eql?(BOOLEAN_VALUES)
186
+ return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
169
187
  return matching.first
170
188
  end
171
189
 
@@ -279,11 +297,11 @@ module Aspera
279
297
  case option_attrs.types
280
298
  when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN
281
299
  # This option value must be a symbol (or array of symbols)
282
- set_option(option_symbol, Manager.enum_to_bool(default), where: 'default') if option_attrs.values.eql?(BOOLEAN_VALUES) && !default.nil?
300
+ set_option(option_symbol, BoolValue.true?(default), where: 'default') if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
283
301
  value = get_option(option_symbol)
284
302
  help_values =
285
303
  if option_attrs.types.eql?(Allowed::TYPES_BOOLEAN)
286
- highlight_current_in_list(BOOLEAN_SIMPLE, self.class.enum_to_yes_no(value))
304
+ highlight_current_in_list(BoolValue::SYMBOLS, BoolValue.to_sym(value))
287
305
  else
288
306
  highlight_current_in_list(option_attrs.values, value)
289
307
  end
@@ -312,7 +330,7 @@ module Aspera
312
330
 
313
331
  # @param descr [String] description for help
314
332
  # @param mandatory [Boolean] if true, raise error if option not set
315
- # @param multiple [Boolean] if true, return remaining arguments (Array)
333
+ # @param multiple [Boolean] if true, return remaining arguments (Array) unil END
316
334
  # @param accept_list [Array, NilClass] list of allowed values (Symbol)
317
335
  # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
318
336
  # @param aliases [Hash] map of aliases: key = alias, value = real value
@@ -327,10 +345,19 @@ module Aspera
327
345
  descr = "#{descr} (#{validation.join(', ')})" unless validation.nil? || validation.eql?(Allowed::TYPES_STRING)
328
346
  result =
329
347
  if !@unprocessed_cmd_line_arguments.empty?
330
- how_many = multiple ? @unprocessed_cmd_line_arguments.length : 1
331
- values = @unprocessed_cmd_line_arguments.shift(how_many)
348
+ if multiple
349
+ index = @unprocessed_cmd_line_arguments.index(SpecialValues::EOA)
350
+ if index.nil?
351
+ values = @unprocessed_cmd_line_arguments.shift(@unprocessed_cmd_line_arguments.length)
352
+ else
353
+ values = @unprocessed_cmd_line_arguments.shift(index)
354
+ @unprocessed_cmd_line_arguments.shift # remove EOA
355
+ end
356
+ else
357
+ values = [@unprocessed_cmd_line_arguments.shift]
358
+ end
332
359
  values = values.map{ |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation)}
333
- # if expecting list and only one arg of type array : it is the list
360
+ # If expecting list and only one arg of type array : it is the list
334
361
  values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
335
362
  if accept_list
336
363
  allowed_values = [].concat(accept_list)
@@ -636,12 +663,6 @@ module Aspera
636
663
  unprocessed_options.delete(k)
637
664
  end
638
665
  end
639
- # boolean options are set to true/false from the following values
640
- BOOL_YES = BOOLEAN_SIMPLE.last
641
- BOOL_NO = BOOLEAN_SIMPLE.first
642
- FALSE_VALUES = [BOOL_NO, false].freeze
643
- TRUE_VALUES = [BOOL_YES, true].freeze
644
- BOOLEAN_VALUES = (TRUE_VALUES + FALSE_VALUES).freeze
645
666
 
646
667
  # Option name separator on command line, e.g. in --option-blah, third "-"
647
668
  OPTION_SEP_LINE = '-'
@@ -655,7 +676,7 @@ module Aspera
655
676
  OPTIONS_STOP = '--'
656
677
  SOURCE_USER = 'cmdline' # cspell:disable-line
657
678
 
658
- private_constant :BOOL_YES, :BOOL_NO, :FALSE_VALUES, :TRUE_VALUES, :OPTION_SEP_LINE, :OPTION_SEP_SYMBOL, :OPTION_VALUE_SEPARATOR, :OPTION_PREFIX, :OPTIONS_STOP, :SOURCE_USER
679
+ private_constant :OPTION_SEP_LINE, :OPTION_SEP_SYMBOL, :OPTION_VALUE_SEPARATOR, :OPTION_PREFIX, :OPTIONS_STOP, :SOURCE_USER
659
680
  end
660
681
  end
661
682
  end