aspera-cli 4.22.0 → 4.24.0

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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +405 -364
  4. data/CONTRIBUTING.md +86 -29
  5. data/README.md +1856 -961
  6. data/bin/ascli +2 -1
  7. data/bin/asession +4 -4
  8. data/lib/aspera/agent/base.rb +4 -0
  9. data/lib/aspera/agent/connect.rb +20 -18
  10. data/lib/aspera/agent/desktop.rb +14 -11
  11. data/lib/aspera/agent/direct.rb +39 -31
  12. data/lib/aspera/agent/httpgw.rb +2 -2
  13. data/lib/aspera/agent/node.rb +9 -11
  14. data/lib/aspera/agent/transferd.rb +18 -11
  15. data/lib/aspera/api/aoc.rb +53 -43
  16. data/lib/aspera/api/cos_node.rb +7 -5
  17. data/lib/aspera/api/httpgw.rb +23 -22
  18. data/lib/aspera/api/node.rb +104 -22
  19. data/lib/aspera/ascmd.rb +35 -21
  20. data/lib/aspera/ascp/installation.rb +43 -43
  21. data/lib/aspera/ascp/management.rb +5 -4
  22. data/lib/aspera/assert.rb +55 -24
  23. data/lib/aspera/cli/basic_auth_plugin.rb +8 -7
  24. data/lib/aspera/cli/error.rb +1 -1
  25. data/lib/aspera/cli/extended_value.rb +28 -29
  26. data/lib/aspera/cli/formatter.rb +191 -168
  27. data/lib/aspera/cli/hints.rb +38 -4
  28. data/lib/aspera/cli/main.rb +139 -108
  29. data/lib/aspera/cli/manager.rb +51 -31
  30. data/lib/aspera/cli/plugin.rb +149 -78
  31. data/lib/aspera/cli/plugin_factory.rb +2 -2
  32. data/lib/aspera/cli/plugins/aoc.rb +217 -88
  33. data/lib/aspera/cli/plugins/ats.rb +15 -13
  34. data/lib/aspera/cli/plugins/config.rb +105 -227
  35. data/lib/aspera/cli/plugins/console.rb +49 -18
  36. data/lib/aspera/cli/plugins/cos.rb +4 -4
  37. data/lib/aspera/cli/plugins/faspex.rb +45 -51
  38. data/lib/aspera/cli/plugins/faspex5.rb +162 -163
  39. data/lib/aspera/cli/plugins/faspio.rb +6 -5
  40. data/lib/aspera/cli/plugins/httpgw.rb +2 -2
  41. data/lib/aspera/cli/plugins/node.rb +233 -247
  42. data/lib/aspera/cli/plugins/orchestrator.rb +10 -14
  43. data/lib/aspera/cli/plugins/preview.rb +26 -29
  44. data/lib/aspera/cli/plugins/server.rb +29 -28
  45. data/lib/aspera/cli/plugins/shares.rb +40 -28
  46. data/lib/aspera/cli/sync_actions.rb +101 -80
  47. data/lib/aspera/cli/transfer_agent.rb +55 -58
  48. data/lib/aspera/cli/transfer_progress.rb +29 -20
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +160 -0
  51. data/lib/aspera/colors.rb +13 -8
  52. data/lib/aspera/command_line_builder.rb +28 -22
  53. data/lib/aspera/command_line_converter.rb +31 -0
  54. data/lib/aspera/data_repository.rb +1 -0
  55. data/lib/aspera/environment.rb +144 -100
  56. data/lib/aspera/faspex_gw.rb +1 -1
  57. data/lib/aspera/faspex_postproc.rb +3 -2
  58. data/lib/aspera/hash_ext.rb +1 -1
  59. data/lib/aspera/id_generator.rb +10 -10
  60. data/lib/aspera/keychain/base.rb +18 -0
  61. data/lib/aspera/keychain/encrypted_hash.rb +6 -12
  62. data/lib/aspera/keychain/factory.rb +9 -3
  63. data/lib/aspera/keychain/hashicorp_vault.rb +9 -6
  64. data/lib/aspera/keychain/macos_security.rb +13 -13
  65. data/lib/aspera/log.rb +70 -20
  66. data/lib/aspera/nagios.rb +5 -6
  67. data/lib/aspera/node_simulator.rb +12 -7
  68. data/lib/aspera/oauth/base.rb +6 -2
  69. data/lib/aspera/oauth/factory.rb +25 -18
  70. data/lib/aspera/oauth/jwt.rb +13 -1
  71. data/lib/aspera/oauth/url_json.rb +3 -3
  72. data/lib/aspera/oauth/web.rb +5 -3
  73. data/lib/aspera/persistency_folder.rb +2 -2
  74. data/lib/aspera/preview/file_types.rb +43 -35
  75. data/lib/aspera/preview/generator.rb +26 -13
  76. data/lib/aspera/preview/terminal.rb +10 -7
  77. data/lib/aspera/preview/utils.rb +11 -9
  78. data/lib/aspera/products/connect.rb +2 -1
  79. data/lib/aspera/products/desktop.rb +1 -1
  80. data/lib/aspera/products/other.rb +2 -2
  81. data/lib/aspera/products/transferd.rb +8 -6
  82. data/lib/aspera/proxy_auto_config.rb +1 -1
  83. data/lib/aspera/rest.rb +46 -28
  84. data/lib/aspera/rest_call_error.rb +1 -1
  85. data/lib/aspera/rest_error_analyzer.rb +1 -0
  86. data/lib/aspera/resumer.rb +1 -1
  87. data/lib/aspera/secret_hider.rb +46 -40
  88. data/lib/aspera/ssh.rb +14 -4
  89. data/lib/aspera/sync/args.schema.yaml +102 -0
  90. data/lib/aspera/sync/conf.schema.yaml +701 -0
  91. data/lib/aspera/sync/database.rb +83 -0
  92. data/lib/aspera/{transfer/sync.rb → sync/operations.rb} +145 -68
  93. data/lib/aspera/temp_file_manager.rb +4 -2
  94. data/lib/aspera/timer_limiter.rb +7 -5
  95. data/lib/aspera/transfer/error.rb +1 -1
  96. data/lib/aspera/transfer/error_info.rb +1 -2
  97. data/lib/aspera/transfer/faux_file.rb +11 -10
  98. data/lib/aspera/transfer/parameters.rb +6 -5
  99. data/lib/aspera/transfer/spec.rb +15 -1
  100. data/lib/aspera/transfer/spec.schema.yaml +316 -293
  101. data/lib/aspera/transfer/spec_doc.rb +34 -16
  102. data/lib/aspera/transfer/uri.rb +5 -5
  103. data/lib/aspera/uri_reader.rb +14 -10
  104. data/lib/aspera/web_auth.rb +2 -2
  105. data/lib/aspera/web_server_simple.rb +2 -2
  106. data.tar.gz.sig +0 -0
  107. metadata +15 -15
  108. metadata.gz.sig +0 -0
  109. data/examples/dascli +0 -30
  110. data/examples/get_proto_file.rb +0 -8
  111. data/examples/proxy.pac +0 -60
  112. data/lib/aspera/transfer/convert.rb +0 -29
  113. data/lib/aspera/transfer/sync_instance.schema.yaml +0 -13
  114. data/lib/aspera/transfer/sync_session.schema.yaml +0 -79
@@ -21,6 +21,7 @@ module Aspera
21
21
  module Plugins
22
22
  class Node < Cli::BasicAuthPlugin
23
23
  include SyncActions
24
+
24
25
  class << self
25
26
  # directory: node, container: shares
26
27
  FOLDER_TYPES = %w[directory container].freeze
@@ -58,10 +59,10 @@ module Aspera
58
59
  Log.log.debug{"detect error: #{e}"}
59
60
  end
60
61
  raise error if error
61
- return nil
62
+ return
62
63
  end
63
64
 
64
- def wizard(object:, private_key_path: nil, pub_key_pem: nil)
65
+ def wizard(object:, _private_key_path: nil, _pub_key_pem: nil)
65
66
  options = object.options
66
67
  return {
67
68
  preset_value: {
@@ -76,20 +77,24 @@ module Aspera
76
77
  def declare_options(options)
77
78
  return if @options_declared
78
79
  @options_declared = true
80
+ @dynamic_key = nil
79
81
  options.declare(:validator, 'Identifier of validator (optional for central)')
80
82
  options.declare(:asperabrowserurl, 'URL for simple aspera web ui', default: 'https://asperabrowser.mybluemix.net')
81
- options.declare(:sync_name, 'Sync name')
82
83
  options.declare(
83
- :default_ports, 'Use standard FASP ports or get from node api (gen4)', values: :bool, default: :yes,
84
- handler: {o: Api::Node, m: :use_standard_ports})
84
+ :default_ports, 'Gen4: Use standard FASP ports (true) or get from node API (false)', values: :bool, default: :yes,
85
+ handler: {o: Api::Node, m: :use_standard_ports}
86
+ )
85
87
  options.declare(
86
- :node_cache, 'Set to no to force actual file system read (gen4)', values: :bool,
87
- handler: {o: Api::Node, m: :use_node_cache})
88
- options.declare(:root_id, 'File id of top folder when using access key (override AK root id)')
88
+ :node_cache, 'Gen4: Set to no to force actual file system read', values: :bool,
89
+ handler: {o: Api::Node, m: :use_node_cache}
90
+ )
91
+ options.declare(:root_id, 'Gen4: File id of top folder when using access key (override AK root id)')
92
+ options.declare(:dynamic_key, 'Private key PEM to use for dynamic key auth', handler: {o: Api::Node, m: :use_dynamic_key})
89
93
  SyncActions.declare_options(options)
90
94
  options.parse_options!
91
95
  end
92
96
 
97
+ # Using /files/browse: is it a folder (node and shares)
93
98
  def gen3_entry_folder?(entry)
94
99
  FOLDER_TYPES.include?(entry['type'])
95
100
  end
@@ -104,10 +109,10 @@ module Aspera
104
109
  '</soapenv:Envelope>'
105
110
  # spellchecker: enable
106
111
 
107
- # fields removed in result of search
112
+ # Fields removed in result of search
108
113
  SEARCH_REMOVE_FIELDS = %w[basename permissions].freeze
109
114
 
110
- # actions in execute_command_gen3
115
+ # Actions in execute_command_gen3
111
116
  COMMANDS_GEN3 = %i[search space mkdir mklink mkfile rename delete browse upload download cat sync transport]
112
117
 
113
118
  BASE_ACTIONS = %i[api_details].concat(COMMANDS_GEN3).freeze
@@ -120,7 +125,7 @@ module Aspera
120
125
  # actions used commonly when a node is involved
121
126
  COMMON_ACTIONS = %i[access_keys].concat(BASE_ACTIONS).concat(SPECIAL_ACTIONS).freeze
122
127
 
123
- private_constant(*%i[CENTRAL_SOAP_API_TEST SEARCH_REMOVE_FIELDS BASE_ACTIONS SPECIAL_ACTIONS V3_IN_V4_ACTIONS COMMON_ACTIONS])
128
+ private_constant :CENTRAL_SOAP_API_TEST, :SEARCH_REMOVE_FIELDS, :BASE_ACTIONS, :SPECIAL_ACTIONS, :V3_IN_V4_ACTIONS, :COMMON_ACTIONS
124
129
 
125
130
  # used in aoc
126
131
  NODE4_READ_ACTIONS = %i[bearer_token_node node_info browse find].freeze
@@ -137,16 +142,16 @@ module Aspera
137
142
 
138
143
  # @param api [Rest] an existing API object for the Node API
139
144
  # @param prefix_path [String,nil] for Faspex 4, allows browsing a package
140
- def initialize(api: nil, prefix_path: nil, **env)
145
+ def initialize(context:, api: nil, prefix_path: nil)
141
146
  @prefix_path = prefix_path
142
- super(**env, basic_options: api.nil?)
147
+ super(context: context, basic_options: api.nil?)
143
148
  Node.declare_options(options)
144
- return if env[:broker].only_manual?
149
+ return if context.only_manual?
145
150
  @api_node =
146
151
  if !api.nil?
147
152
  # this can be Api::Node or Rest (Shares)
148
153
  api
149
- elsif OAuth::Factory.bearer?(options.get_option(:password, mandatory: true))
154
+ elsif OAuth::Factory.bearer_auth?(options.get_option(:password, mandatory: true))
150
155
  # info is provided like node_info of aoc
151
156
  Api::Node.new(
152
157
  base_url: options.get_option(:url, mandatory: true),
@@ -160,7 +165,8 @@ module Aspera
160
165
  type: :basic,
161
166
  username: options.get_option(:username, mandatory: true),
162
167
  password: options.get_option(:password, mandatory: true)
163
- })
168
+ }
169
+ )
164
170
  end
165
171
  end
166
172
 
@@ -187,18 +193,17 @@ module Aspera
187
193
  result = success_msg
188
194
  if p.key?('error')
189
195
  Log.log.error{"#{p['error']['user_message']} : #{p['path']}"}
190
- result = "ERROR: #{p['error']['user_message']}"
196
+ result = p['error']['user_message']
191
197
  errors.push([p['path'], p['error']['user_message']])
192
198
  end
193
199
  final_result[:data].push({type => p['path'], 'result' => result})
194
200
  end
195
201
  # one error make all fail
196
- unless errors.empty?
197
- raise errors.map{ |i| "#{i.first}: #{i.last}"}.join(', ')
198
- end
202
+ raise errors.map{ |i| "#{i.first}: #{i.last}"}.join(', ') unless errors.empty?
199
203
  return c_result_remove_prefix_path(final_result, type)
200
204
  end
201
205
 
206
+ # Gen3 API
202
207
  def browse_gen3
203
208
  folders_to_process = [get_one_argument_with_prefix('path')]
204
209
  query = options.get_option(:query, default: {})
@@ -235,9 +240,7 @@ module Aspera
235
240
  formatter.display_item_count(response['item_count'], total_count)
236
241
  break
237
242
  end
238
- if recursive
239
- folders_to_process.concat(items.select{ |i| Node.gen3_entry_folder?(i)}.map{ |i| i['path']})
240
- end
243
+ folders_to_process.concat(items.select{ |i| Node.gen3_entry_folder?(i)}.map{ |i| i['path']}) if recursive
241
244
  if !max_items.nil? && (all_items.count >= max_items)
242
245
  all_items = all_items.slice(0, max_items) if all_items.count > max_items
243
246
  break
@@ -349,7 +352,7 @@ module Aspera
349
352
  transfer_spec.delete_if{ |_k, v| v.nil?}
350
353
  # delete this part, as the returned value contains only destination, and not sources
351
354
  # transfer_spec.delete('paths') if command.eql?(:upload)
352
- Log.log.debug{Log.dump(:ts, transfer_spec)}
355
+ Log.dump(:ts, transfer_spec)
353
356
  transfer_spec
354
357
  end
355
358
  when :upload, :download
@@ -363,10 +366,12 @@ module Aspera
363
366
  end
364
367
  # add fixed parameters if any (for COS)
365
368
  @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
369
+ Api::Node.add_public_key(request_transfer_spec)
366
370
  # prepare payload for single request
367
371
  setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
368
372
  # only one request, so only one answer
369
373
  transfer_spec = @api_node.create("files/#{command}_setup", setup_payload)['transfer_specs'].first['transfer_spec']
374
+ Api::Node.add_private_key(transfer_spec)
370
375
  # delete this part, as the returned value contains only destination, and not sources
371
376
  transfer_spec.delete('paths') if command.eql?(:upload)
372
377
  return Main.result_transfer(transfer.start(transfer_spec))
@@ -375,7 +380,8 @@ module Aspera
375
380
  File.basename(remote_path)
376
381
  result = @api_node.call(
377
382
  operation: 'GET',
378
- subpath: "files/#{URI.encode_www_form_component(remote_path)}/contents")
383
+ subpath: "files/#{URI.encode_www_form_component(remote_path)}/contents"
384
+ )
379
385
  return Main.result_text(result[:http].body)
380
386
  when :transport
381
387
  return Main.result_single_object(@api_node.transport_params)
@@ -392,8 +398,12 @@ module Aspera
392
398
  ak_command = options.get_next_command(%i[do set_bearer_key].concat(Plugin::ALL_OPS))
393
399
  case ak_command
394
400
  when *Plugin::ALL_OPS
395
- return entity_command(ak_command, @api_node, 'access_keys') do |field, value|
396
- raise Cli::BadIdentifier, 'only selector: %id:self' unless field.eql?('id') && value.eql?('self')
401
+ return entity_execute(
402
+ api: @api_node,
403
+ entity: 'access_keys',
404
+ command: ak_command
405
+ ) do |field, value|
406
+ raise BadArgument, 'only selector: %id:self' unless field.eql?('id') && value.eql?('self')
397
407
  @api_node.read('access_keys/self')['id']
398
408
  end
399
409
  when :do
@@ -455,9 +465,7 @@ module Aspera
455
465
  when :license
456
466
  # requires: asnodeadmin -mu <node user> --acl-add=internal --internal
457
467
  node_license = @api_node.read('license')
458
- if node_license['failure'].is_a?(String) && node_license['failure'].include?('ACL')
459
- Log.log.error('server must have: asnodeadmin -mu <node user> --acl-add=internal --internal')
460
- end
468
+ Log.log.error('server must have: asnodeadmin -mu <node user> --acl-add=internal --internal') if node_license['failure'].is_a?(String) && node_license['failure'].include?('ACL')
461
469
  return Main.result_single_object(node_license)
462
470
  when :api_details
463
471
  return Main.result_single_object({base_url: @api_node.base_url}.merge(@api_node.params))
@@ -468,7 +476,7 @@ module Aspera
468
476
  # @return [Hash] api and main file id for given path or id in next argument
469
477
  def apifid_from_next_arg(top_file_id)
470
478
  file_path = instance_identifier(description: 'path or %id:<id>') do |attribute, value|
471
- raise Cli::BadIdentifier, 'Only selection "id" is supported (file id)' unless attribute.eql?('id')
479
+ raise BadArgument, 'Only selection "id" is supported (file id)' unless attribute.eql?('id')
472
480
  # directly return result for method
473
481
  return {api: @api_node, file_id: value}
474
482
  end
@@ -486,7 +494,7 @@ module Aspera
486
494
  command_legacy = options.get_next_command(V3_IN_V4_ACTIONS)
487
495
  # TODO: shall we support all methods here ? what if there is a link ?
488
496
  apifid = @api_node.resolve_api_fid(top_file_id, '')
489
- return Node.new(**init_params, api: apifid[:api]).execute_action(command_legacy)
497
+ return Node.new(context: context, api: apifid[:api]).execute_action(command_legacy)
490
498
  when :node_info, :bearer_token_node
491
499
  apifid = apifid_from_next_arg(top_file_id)
492
500
  result = {
@@ -505,8 +513,8 @@ module Aspera
505
513
  end
506
514
  return Main.result_single_object(result) if command_repo.eql?(:node_info)
507
515
  # check format of bearer token
508
- OAuth::Factory.bearer_extract(result[:password])
509
- return Main.result_status(result[:password])
516
+ OAuth::Factory.bearer_token(result[:password])
517
+ return Main.result_text(result[:password])
510
518
  when :browse
511
519
  apifid = apifid_from_next_arg(top_file_id)
512
520
  file_info = apifid[:api].read_with_cache("files/#{apifid[:file_id]}")
@@ -520,9 +528,8 @@ module Aspera
520
528
  find_lambda = Api::Node.file_matcher_from_argument(options)
521
529
  return Main.result_object_list(@api_node.find_files(apifid[:file_id], find_lambda), fields: ['path'])
522
530
  when :mkdir, :mklink, :mkfile
523
- containing_folder_path = options.get_next_argument('path').split(Api::Node::PATH_SEPARATOR)
524
- new_item = containing_folder_path.pop
525
- apifid = @api_node.resolve_api_fid(top_file_id, containing_folder_path.join(Api::Node::PATH_SEPARATOR), true)
531
+ containing_folder_path, new_item = Api::Node.split_folder(options.get_next_argument('path'))
532
+ apifid = @api_node.resolve_api_fid(top_file_id, containing_folder_path, true)
526
533
  query = options.get_option(:query, mandatory: false)
527
534
  check_exists = true
528
535
  payload = {name: new_item}
@@ -576,61 +583,28 @@ module Aspera
576
583
  when :sync
577
584
  return execute_sync_action do |sync_direction, _local_path, remote_path|
578
585
  # Gen4 API
579
- # direction is push pull, bidi
580
586
  Aspera.assert_values(sync_direction, %i[push pull bidi])
581
587
  ts_direction = case sync_direction
582
588
  when :push, :bidi then Transfer::Spec::DIRECTION_SEND
583
589
  when :pull then Transfer::Spec::DIRECTION_RECEIVE
584
590
  else Aspera.error_unreachable_line
585
591
  end
586
- # remote is specified by option to_folder
592
+ # remote is specified by option: `to_folder`
587
593
  apifid = @api_node.resolve_api_fid(top_file_id, remote_path)
588
- transfer_spec = apifid[:api].transfer_spec_gen4(apifid[:file_id], ts_direction)
589
- Log.log.debug{Log.dump(:ts, transfer_spec)}
590
- transfer_spec
594
+ apifid[:api].transfer_spec_gen4(apifid[:file_id], ts_direction)
591
595
  end
592
596
  when :upload
593
597
  apifid = @api_node.resolve_api_fid(top_file_id, transfer.destination_folder(Transfer::Spec::DIRECTION_SEND), true)
594
598
  return Main.result_transfer(transfer.start(apifid[:api].transfer_spec_gen4(apifid[:file_id], Transfer::Spec::DIRECTION_SEND)))
595
599
  when :download
596
- source_paths = transfer.ts_source_paths
597
- # special case for AoC : all files must be in same folder
598
- source_folder = source_paths.shift['source']
599
- # if a single file: split into folder and path
600
- apifid = @api_node.resolve_api_fid(top_file_id, source_folder, true)
601
- if source_paths.empty?
602
- # get precise info in this element
603
- file_info = apifid[:api].read("files/#{apifid[:file_id]}")
604
- case file_info['type']
605
- when 'file'
606
- # if the single source is a file, we need to split into folder path and filename
607
- src_dir_elements = source_folder.split(Api::Node::PATH_SEPARATOR)
608
- # filename is the last one, source folder is what remains
609
- source_paths = [{'source' => src_dir_elements.pop}]
610
- apifid = @api_node.resolve_api_fid(top_file_id, src_dir_elements.join(Api::Node::PATH_SEPARATOR), true)
611
- when 'link', 'folder'
612
- # single source is 'folder' or 'link'
613
- # TODO: add this ? , 'destination'=>file_info['name']
614
- source_paths = [{'source' => '.'}]
615
- else
616
- raise BadArgument, "Unknown source type: #{file_info['type']}"
617
- end
618
- end
600
+ apifid, source_paths = @api_node.resolve_api_fid_paths(top_file_id, transfer.ts_source_paths)
619
601
  return Main.result_transfer(transfer.start(apifid[:api].transfer_spec_gen4(apifid[:file_id], Transfer::Spec::DIRECTION_RECEIVE, {'paths'=>source_paths})))
620
602
  when :cat
621
- source_paths = transfer.ts_source_paths
622
- source_folder = source_paths.shift['source']
623
- if source_paths.empty?
624
- source_folder = source_folder.split(Api::Node::PATH_SEPARATOR)
625
- source_paths = [{'source' => source_folder.pop}]
626
- source_folder = source_folder.join(Api::Node::PATH_SEPARATOR)
627
- end
628
- raise Cli::BadArgument, 'one file at a time only in HTTP mode' if source_paths.length > 1
629
- file_name = source_paths.first['source']
630
- apifid = @api_node.resolve_api_fid(top_file_id, File.join(source_folder, file_name))
603
+ apifid = apifid_from_next_arg(top_file_id)
631
604
  result = apifid[:api].call(
632
605
  operation: 'GET',
633
- subpath: "files/#{apifid[:file_id]}/content")
606
+ subpath: "files/#{apifid[:file_id]}/content"
607
+ )
634
608
  return Main.result_text(result[:http].body)
635
609
  when :show
636
610
  apifid = apifid_from_next_arg(top_file_id)
@@ -648,7 +622,7 @@ module Aspera
648
622
  subpath: "files/#{apifid[:file_id]}/preview",
649
623
  headers: {'Accept' => 'image/png'}
650
624
  )
651
- return Main.result_image(result[:http].body, formatter: formatter)
625
+ return Main.result_image(result[:http].body)
652
626
  when :permission
653
627
  apifid = apifid_from_next_arg(top_file_id)
654
628
  command_perm = options.get_next_command(%i[list show create delete])
@@ -665,7 +639,7 @@ module Aspera
665
639
  perm_id = instance_identifier
666
640
  return Main.result_single_object(apifid[:api].read("permissions/#{perm_id}"))
667
641
  when :delete
668
- return do_bulk_operation(command: command_perm, descr: 'identifier', values: :identifier) do |one_id|
642
+ return do_bulk_operation(command: command_perm, values: :identifier) do |one_id|
669
643
  apifid[:api].delete("permissions/#{one_id}")
670
644
  # notify application of deletion
671
645
  the_app = apifid[:api].app_info
@@ -692,25 +666,30 @@ module Aspera
692
666
  Aspera.error_unreachable_line
693
667
  end
694
668
 
695
- # This is older API
669
+ # Search /async by name
670
+ # @param field [String] name of the field to search
671
+ # @param value [String] value of the field to search
672
+ # @return [Integer] id of the sync
673
+ # @raise [Cli::BadArgument] if no such sync, or not by name
674
+ def async_lookup(field, value)
675
+ raise Cli::BadArgument, "Only search by name is supported (#{field})" unless field.eql?('name')
676
+ async_ids = @api_node.read('async/list')['sync_ids']
677
+ summaries = @api_node.create('async/summary', {'syncs' => async_ids})['sync_summaries']
678
+ selected = summaries.find{ |s| s['name'].eql?(value)}
679
+ raise Cli::BadIdentifier.new('sync', "#{field}=#{value}") if selected.nil?
680
+ return selected['snid']
681
+ end
682
+
683
+ # Node API: /async (stats only)
696
684
  def execute_async
697
685
  command = options.get_next_command(%i[list delete files show counters bandwidth])
698
686
  unless command.eql?(:list)
699
- async_name = options.get_option(:sync_name)
700
- if async_name.nil?
701
- async_id = instance_identifier
702
- if async_id.eql?(SpecialValues::ALL) && %i[show delete].include?(command)
703
- async_ids = @api_node.read('async/list')['sync_ids']
704
- else
705
- Integer(async_id) # must be integer
706
- async_ids = [async_id]
707
- end
708
- else
687
+ async_id = instance_identifier{ |field, value| async_lookup(field, value)}
688
+ if async_id.eql?(SpecialValues::ALL)
689
+ raise Cli::BadArgument, 'ALL only for show and delete' unless %i[show delete].include?(command)
709
690
  async_ids = @api_node.read('async/list')['sync_ids']
710
- summaries = @api_node.create('async/summary', {'syncs' => async_ids})['sync_summaries']
711
- selected = summaries.find{ |s| s['name'].eql?(async_name)}
712
- raise Cli::BadIdentifier, "no such sync: #{async_name}" if selected.nil?
713
- async_id = selected['snid']
691
+ else
692
+ Integer(async_id) # must be integer
714
693
  async_ids = [async_id]
715
694
  end
716
695
  post_data = {'syncs' => async_ids}
@@ -718,7 +697,7 @@ module Aspera
718
697
  case command
719
698
  when :list
720
699
  resp = @api_node.read('async/list')['sync_ids']
721
- return Main.result_value_list(resp, name: 'id')
700
+ return Main.result_value_list(resp)
722
701
  when :show
723
702
  resp = @api_node.create('async/summary', post_data)['sync_summaries']
724
703
  return Main.result_empty if resp.empty?
@@ -728,7 +707,7 @@ module Aspera
728
707
  resp = @api_node.create('async/delete', post_data)
729
708
  return Main.result_single_object(resp)
730
709
  when :bandwidth
731
- post_data['seconds'] = 100 # TODO: as parameter with --value
710
+ post_data['seconds'] = 100 # TODO: as parameter with --query
732
711
  resp = @api_node.create('async/bandwidth', post_data)
733
712
  data = resp['bandwidth_data']
734
713
  return Main.result_empty if data.empty?
@@ -754,10 +733,10 @@ module Aspera
754
733
  'sync_files',
755
734
  options.get_option(:url, mandatory: true),
756
735
  options.get_option(:username, mandatory: true),
757
- async_id]))
758
- unless iteration_data.first.nil?
759
- data.select!{ |l| l['fnid'].to_i > iteration_data.first}
760
- end
736
+ async_id
737
+ ])
738
+ )
739
+ data.select!{ |l| l['fnid'].to_i > iteration_data.first} unless iteration_data.first.nil?
761
740
  iteration_data[0] = data.last['fnid'].to_i unless data.empty?
762
741
  end
763
742
  return Main.result_empty if data.empty?
@@ -770,10 +749,11 @@ module Aspera
770
749
  end
771
750
  end
772
751
 
752
+ # Search /asyncs by name
753
+ # @param field [String] name of the field to search
754
+ # @param value [String] value of the field to search
773
755
  # @return [Integer] id of the sync
774
756
  # @raise [Cli::BadArgument] if no such sync, or not by name
775
- # @param [String] field name of the field to search
776
- # @param [String] value value of the field to search
777
757
  def ssync_lookup(field, value)
778
758
  raise Cli::BadArgument, "Only search by name is supported (#{field})" unless field.eql?('name')
779
759
  @api_node.read('asyncs')['ids'].each do |id|
@@ -781,7 +761,41 @@ module Aspera
781
761
  # name is unique, so we can return
782
762
  return id if sync_info[field].eql?(value)
783
763
  end
784
- raise Cli::BadIdentifier, "no such sync: #{field}=#{value}"
764
+ raise Cli::BadIdentifier.new('ssync', "#{field}=#{value}")
765
+ end
766
+
767
+ WATCH_FOLDER_MUL = %i[create list].freeze
768
+ WATCH_FOLDER_SING = %i[show modify delete state].freeze
769
+ private_constant :WATCH_FOLDER_MUL, :WATCH_FOLDER_SING
770
+
771
+ def watch_folder_action
772
+ res_class_path = 'v3/watchfolders'
773
+ command = options.get_next_command(WATCH_FOLDER_MUL + WATCH_FOLDER_SING)
774
+ if WATCH_FOLDER_SING.include?(command)
775
+ one_res_id = instance_identifier
776
+ one_res_path = "#{res_class_path}/#{one_res_id}"
777
+ end
778
+ # hum, to avoid: Unable to convert 2016_09_14 configuration
779
+ @api_node.params[:headers] ||= {}
780
+ @api_node.params[:headers]['X-aspera-WF-version'] = '2017_10_23'
781
+ case command
782
+ when :create
783
+ resp = @api_node.create(res_class_path, value_create_modify(command: command))
784
+ return Main.result_status("#{resp['id']} created")
785
+ when :list
786
+ resp = @api_node.read(res_class_path, query_read_delete)
787
+ return Main.result_value_list(resp['ids'])
788
+ when :show
789
+ return Main.result_single_object(@api_node.read(one_res_path))
790
+ when :modify
791
+ @api_node.update(one_res_path, options.get_option(:query, mandatory: true))
792
+ return Main.result_status("#{one_res_id} updated")
793
+ when :delete
794
+ @api_node.delete(one_res_path)
795
+ return Main.result_status("#{one_res_id} deleted")
796
+ when :state
797
+ return Main.result_single_object(@api_node.read("#{one_res_path}/state"))
798
+ end
785
799
  end
786
800
 
787
801
  ACTIONS = %i[
@@ -799,16 +813,22 @@ module Aspera
799
813
  telemetry
800
814
  ].concat(COMMON_ACTIONS).freeze
801
815
 
802
- def execute_action(command=nil)
816
+ def execute_action(command = nil)
803
817
  command ||= options.get_next_command(ACTIONS)
804
818
  case command
805
819
  when *COMMON_ACTIONS then return execute_simple_common(command)
806
820
  when :async then return execute_async # former API
807
821
  when :ssync
808
- # newer API
809
- sync_command = options.get_next_command(%i[start stop bandwidth counters files state summary].concat(Plugin::ALL_OPS) - %i[modify])
822
+ # Node API: /asyncs (newer)
823
+ sync_command = options.get_next_command(%i[start stop bandwidth counters files state summary] + Plugin::ALL_OPS - %i[modify])
810
824
  case sync_command
811
- when *Plugin::ALL_OPS then return entity_command(sync_command, @api_node, 'asyncs', item_list_key: 'ids'){ |field, value| ssync_lookup(field, value)}
825
+ when *Plugin::ALL_OPS
826
+ return entity_execute(
827
+ api: @api_node,
828
+ entity: :asyncs,
829
+ command: sync_command,
830
+ items_key: 'ids'
831
+ ){ |field, value| ssync_lookup(field, value)}
812
832
  else
813
833
  asyncs_id = instance_identifier{ |field, value| ssync_lookup(field, value)}
814
834
  if %i[start stop].include?(sync_command)
@@ -849,7 +869,6 @@ module Aspera
849
869
  case command
850
870
  when :list
851
871
  transfer_filter = query_read_delete(default: {})
852
- last_iteration_token = nil
853
872
  iteration_persistency = nil
854
873
  if options.get_option(:once_only, mandatory: true)
855
874
  iteration_persistency = PersistencyActionOnce.new(
@@ -859,47 +878,19 @@ module Aspera
859
878
  'node_transfers',
860
879
  options.get_option(:url, mandatory: true),
861
880
  options.get_option(:username, mandatory: true)
862
- ]))
881
+ ])
882
+ )
863
883
  if transfer_filter.delete('reset')
864
884
  iteration_persistency.data.clear
865
885
  iteration_persistency.save
866
886
  return Main.result_status('Persistency reset')
867
887
  end
868
- last_iteration_token = iteration_persistency.data.first
869
888
  end
870
889
  raise Cli::BadArgument, 'reset only with once_only' if transfer_filter.key?('reset') && iteration_persistency.nil?
871
890
  max_items = transfer_filter.delete(MAX_ITEMS)
872
- transfers_data = []
873
- loop do
874
- transfer_filter['iteration_token'] = last_iteration_token unless last_iteration_token.nil?
875
- result = @api_node.call(operation: 'GET', subpath: 'ops/transfers', query: transfer_filter)
876
- # no data
877
- break if result[:data].empty?
878
- # get next iteration token from link
879
- next_iteration_token = nil
880
- link_info = result[:http]['Link']
881
- unless link_info.nil?
882
- m = link_info.match(/<([^>]+)>/)
883
- raise "Cannot parse iteration in Link: #{link_info}" if m.nil?
884
- next_iteration_token = CGI.parse(URI.parse(m[1]).query)['iteration_token']&.first
885
- end
886
- # same as last iteration: stop
887
- break if next_iteration_token&.eql?(last_iteration_token)
888
- last_iteration_token = next_iteration_token
889
- transfers_data.concat(result[:data])
890
- if max_items&.<=(transfers_data.length)
891
- transfers_data = transfers_data.slice(0, max_items)
892
- break
893
- end
894
- break if last_iteration_token.nil?
895
- end
896
- iteration_persistency&.data&.[]=(0, last_iteration_token)
891
+ transfers_data = call_with_iteration(api: @api_node, operation: 'GET', subpath: 'ops/transfers', max: max_items, query: transfer_filter, iteration: iteration_persistency&.data)
897
892
  iteration_persistency&.save
898
- return {
899
- type: :object_list,
900
- data: transfers_data,
901
- fields: %w[id status start_spec.direction start_spec.remote_user start_spec.remote_host start_spec.destination_path]
902
- }
893
+ return Main.result_object_list(transfers_data, fields: %w[id status start_spec.direction start_spec.remote_user start_spec.remote_host start_spec.destination_path])
903
894
  when :sessions
904
895
  transfers_data = @api_node.read('ops/transfers', query_read_delete)
905
896
  sessions = transfers_data.map{ |t| t['sessions']}.flatten
@@ -907,11 +898,7 @@ module Aspera
907
898
  session['start_time'] = Time.at(session['start_time_usec'] / 1_000_000.0).utc.iso8601(0)
908
899
  session['end_time'] = Time.at(session['end_time_usec'] / 1_000_000.0).utc.iso8601(0)
909
900
  end
910
- return {
911
- type: :object_list,
912
- data: sessions,
913
- fields: %w[id status start_time end_time target_rate_kbps]
914
- }
901
+ return Main.result_object_list(sessions, fields: %w[id status start_time end_time target_rate_kbps])
915
902
  when :cancel
916
903
  resp = @api_node.cancel("ops/transfers/#{instance_identifier}")
917
904
  return Main.result_single_object(resp)
@@ -970,9 +957,7 @@ module Aspera
970
957
  end
971
958
  when :service
972
959
  command = options.get_next_command(%i[list create delete])
973
- if [:delete].include?(command)
974
- service_id = instance_identifier
975
- end
960
+ service_id = instance_identifier if [:delete].include?(command)
976
961
  case command
977
962
  when :list
978
963
  resp = @api_node.read('rund/services')
@@ -987,33 +972,7 @@ module Aspera
987
972
  return Main.result_status("#{service_id} deleted")
988
973
  end
989
974
  when :watch_folder
990
- res_class_path = 'v3/watchfolders'
991
- command = options.get_next_command(%i[create list show modify delete state])
992
- if %i[show modify delete state].include?(command)
993
- one_res_id = instance_identifier
994
- one_res_path = "#{res_class_path}/#{one_res_id}"
995
- end
996
- # hum, to avoid: Unable to convert 2016_09_14 configuration
997
- @api_node.params[:headers] ||= {}
998
- @api_node.params[:headers]['X-aspera-WF-version'] = '2017_10_23'
999
- case command
1000
- when :create
1001
- resp = @api_node.create(res_class_path, value_create_modify(command: command))
1002
- return Main.result_status("#{resp['id']} created")
1003
- when :list
1004
- resp = @api_node.read(res_class_path, query_read_delete)
1005
- return Main.result_value_list(resp['ids'], name: 'id')
1006
- when :show
1007
- return Main.result_single_object(@api_node.read(one_res_path))
1008
- when :modify
1009
- @api_node.update(one_res_path, options.get_option(:query, mandatory: true))
1010
- return Main.result_status("#{one_res_id} updated")
1011
- when :delete
1012
- @api_node.delete(one_res_path)
1013
- return Main.result_status("#{one_res_id} deleted")
1014
- when :state
1015
- return Main.result_single_object(@api_node.read("#{one_res_path}/state"))
1016
- end
975
+ return watch_folder_action
1017
976
  when :central
1018
977
  command = options.get_next_command(%i[session file])
1019
978
  validator_id = options.get_option(:validator)
@@ -1027,11 +986,7 @@ module Aspera
1027
986
  request_data = options.get_next_argument('request data', mandatory: false, validation: Hash, default: {})
1028
987
  request_data.deep_merge!({'validation' => validation}) unless validation.nil?
1029
988
  resp = @api_node.create('services/rest/transfers/v1/sessions', request_data)
1030
- return {
1031
- type: :object_list,
1032
- data: resp['session_info_result']['session_info'],
1033
- fields: %w[session_uuid status transport direction bytes_transferred]
1034
- }
989
+ return Main.result_object_list(resp['session_info_result']['session_info'], fields: %w[session_uuid status transport direction bytes_transferred])
1035
990
  end
1036
991
  when :file
1037
992
  command = options.get_next_command(%i[list modify])
@@ -1041,7 +996,7 @@ module Aspera
1041
996
  request_data.deep_merge!({'validation' => validation}) unless validation.nil?
1042
997
  resp = @api_node.create('services/rest/transfers/v1/files', request_data)
1043
998
  resp = JSON.parse(resp) if resp.is_a?(String)
1044
- Log.log.debug{Log.dump(:resp, resp)}
999
+ Log.dump(:resp, resp)
1045
1000
  return Main.result_object_list(resp['file_transfer_info_result']['file_transfer_info'], fields: %w[session_uuid file_id status path])
1046
1001
  when :modify
1047
1002
  request_data = options.get_next_argument('request data', mandatory: false, validation: Hash, default: {})
@@ -1061,12 +1016,12 @@ module Aspera
1061
1016
  Environment.instance.open_uri("#{options.get_option(:asperabrowserurl)}?goto=#{encoded_params}")
1062
1017
  return Main.result_status('done')
1063
1018
  when :basic_token
1064
- return Main.result_status(Rest.basic_authorization(options.get_option(:username, mandatory: true), options.get_option(:password, mandatory: true)))
1019
+ return Main.result_text(Rest.basic_authorization(options.get_option(:username, mandatory: true), options.get_option(:password, mandatory: true)))
1065
1020
  when :bearer_token
1066
1021
  private_key = OpenSSL::PKey::RSA.new(options.get_next_argument('private RSA key PEM value', validation: String))
1067
1022
  token_info = options.get_next_argument('user and group identification', validation: Hash)
1068
1023
  access_key = options.get_option(:username, mandatory: true)
1069
- return Main.result_status(Api::Node.bearer_token(payload: token_info, access_key: access_key, private_key: private_key))
1024
+ return Main.result_text(Api::Node.bearer_token(payload: token_info, access_key: access_key, private_key: private_key))
1070
1025
  when :simulator
1071
1026
  require 'aspera/node_simulator'
1072
1027
  parameters = value_create_modify(command: command, default: {}).symbolize_keys
@@ -1077,7 +1032,7 @@ module Aspera
1077
1032
  return Main.result_status('Simulator terminated')
1078
1033
  when :telemetry
1079
1034
  parameters = value_create_modify(command: command, default: {}).symbolize_keys
1080
- %i[url apikey].each do |psym|
1035
+ %i[url key].each do |psym|
1081
1036
  raise Cli::BadArgument, "Missing parameter: #{psym}" unless parameters.key?(psym)
1082
1037
  end
1083
1038
  require 'socket'
@@ -1085,79 +1040,69 @@ module Aspera
1085
1040
  parameters[:hostname] = Socket.gethostname unless parameters.key?(:hostname)
1086
1041
  interval = parameters[:interval].to_f
1087
1042
  raise Cli::BadArgument, 'Interval must be a positive number in seconds' if interval <= 0
1088
- backend_api = Rest.new(
1043
+ otel_api = Rest.new(
1089
1044
  base_url: "#{parameters[:url]}/v1",
1090
1045
  headers: {
1091
- # 'Authorization' => "apiToken #{parameters[:apikey]}",
1092
- 'x-instana-key' => parameters[:apikey],
1046
+ # 'Authorization' => "apiToken #{parameters[:key]}",
1047
+ 'x-instana-key' => parameters[:key],
1093
1048
  'x-instana-host' => parameters[:hostname]
1094
1049
  }
1095
1050
  )
1096
-
1097
- loop do
1098
- start_time = Time.now
1099
- transfer_filter = {active_only: true}
1100
- transfers_data = []
1101
- loop do
1102
- result = @api_node.call(operation: 'GET', subpath: 'ops/transfers', query: transfer_filter)
1103
- # no data
1104
- break if result[:data].empty?
1105
- # get next iteration token from link
1106
- next_iteration_token = nil
1107
- link_info = result[:http]['Link']
1108
- unless link_info.nil?
1109
- m = link_info.match(/<([^>]+)>/)
1110
- Aspera.assert(m){"Cannot parse iteration in Link: #{link_info}"}
1111
- next_iteration_token = CGI.parse(URI.parse(m[1]).query)['iteration_token']&.first
1112
- end
1113
- # same as last iteration: stop
1114
- break if next_iteration_token&.eql?(transfer_filter[:iteration_token])
1115
- transfer_filter[:iteration_token] = next_iteration_token
1116
- transfers_data.concat(result[:data])
1117
- break if next_iteration_token.nil?
1118
- end
1119
- puts("#{transfers_data.length} active transfers")
1120
- epoch_nsec = start_time.to_i * 1_000_000_000 + start_time.nsec
1121
- # https://www.ibm.com/docs/en/instana-observability/current?topic=instana-backend
1122
- backend_api.create('metrics', {
1123
- resourceMetrics: [
1124
- {
1125
- resource: {
1126
- attributes: [
1051
+ datapoint = {
1052
+ attributes: [
1053
+ {
1054
+ key: 'server.name',
1055
+ value: {
1056
+ stringValue: 'HSTS1'
1057
+ }
1058
+ }
1059
+ ],
1060
+ asInt: nil,
1061
+ timeUnixNano: nil
1062
+ }
1063
+ # https://opentelemetry.io/docs/specs/otel/metrics/data-model/#gauge
1064
+ metrics = {
1065
+ resourceMetrics: [
1066
+ {
1067
+ resource: {
1068
+ attributes: [
1069
+ {
1070
+ key: 'service.name',
1071
+ value: {
1072
+ stringValue: 'IBMAspera'
1073
+ }
1074
+ }
1075
+ ]
1076
+ },
1077
+ scopeMetrics: [
1078
+ {
1079
+ metrics: [
1127
1080
  {
1128
- key: 'service.name',
1129
- value: {
1130
- stringValue: 'mycurl5'
1081
+ name: 'active.transfers',
1082
+ description: 'Number of active transfers',
1083
+ unit: '1',
1084
+ gauge: {
1085
+ dataPoints: [
1086
+ datapoint
1087
+ ]
1131
1088
  }
1132
1089
  }
1133
1090
  ]
1134
- },
1135
- scopeMetrics: [
1136
- {
1137
- metrics: [
1138
- {
1139
- name: 'tutur2',
1140
- unit: '1',
1141
- description: '',
1142
- sum: {
1143
- aggregationTemporality: 1,
1144
- isMonotonic: true,
1145
- dataPoints: [
1146
- {
1147
- asDouble: 4,
1148
- startTimeUnixNano: epoch_nsec,
1149
- timeUnixNano: epoch_nsec
1150
- }
1151
- ]
1152
- }
1153
- }
1154
- ]
1155
- }
1156
- ]
1157
- }
1158
- ]
1159
- })
1160
- sleep([0, interval - (Time.now - start_time)].max)
1091
+ }
1092
+ ]
1093
+ }
1094
+ ]
1095
+ }
1096
+ loop do
1097
+ timestamp = Time.now
1098
+ transfers_data = call_with_iteration(api: @api_node, operation: 'GET', subpath: 'ops/transfers', query: {active_only: true})
1099
+ datapoint[:asInt] = transfers_data.length
1100
+ datapoint[:timeUnixNano] = timestamp.to_i * 1_000_000_000 + timestamp.nsec
1101
+ Log.log.info("#{datapoint[:asInt]} active transfers")
1102
+ # https://www.ibm.com/docs/en/instana-observability/current?topic=instana-backend
1103
+ otel_api.create('metrics', metrics)
1104
+ break if interval.eql?(0.0)
1105
+ sleep([0.0, interval - (Time.now - timestamp)].max)
1161
1106
  end
1162
1107
  end
1163
1108
  Aspera.error_unreachable_line
@@ -1178,6 +1123,47 @@ module Aspera
1178
1123
  return path_arg if @prefix_path.nil?
1179
1124
  return File.join(@prefix_path, path_arg)
1180
1125
  end
1126
+
1127
+ # Executes the provided API call in loop
1128
+ # @param api [Rest] the API to call
1129
+ # @param iteration [Array] a single element array with the iteration token or nil
1130
+ # @param max [Integer] maximum number of items to return, or nil for no limit
1131
+ # @param query [Hash] query parameters to use for the API call
1132
+ # @param call_args [Hash] additional arguments to pass to the API call
1133
+ # @return [Array] list of items returned by the API call
1134
+ def call_with_iteration(api:, iteration: nil, max: nil, query: nil, **call_args)
1135
+ Aspera.assert_type(iteration, Array, NilClass){'iteration'}
1136
+ Aspera.assert_type(query, Hash, NilClass){'query'}
1137
+ query_token = query&.dup || {}
1138
+ item_list = []
1139
+ query_token[:iteration_token] = iteration[0] unless iteration.nil?
1140
+ loop do
1141
+ result = api.call(**call_args, query: query_token)
1142
+ Aspera.assert_type(result[:data], Array){"Expected data to be an Array, got: #{result[:data].class}"}
1143
+ # no data
1144
+ break if result[:data].empty?
1145
+ # get next iteration token from link
1146
+ next_iteration_token = nil
1147
+ link_info = result[:http]['Link']
1148
+ unless link_info.nil?
1149
+ m = link_info.match(/<([^>]+)>/)
1150
+ Aspera.assert(m){"Cannot parse iteration in Link: #{link_info}"}
1151
+ next_iteration_token = CGI.parse(URI.parse(m[1]).query)['iteration_token']&.first
1152
+ end
1153
+ # same as last iteration: stop
1154
+ break if next_iteration_token&.eql?(query_token[:iteration_token])
1155
+ query_token[:iteration_token] = next_iteration_token
1156
+ item_list.concat(result[:data])
1157
+ if max&.<=(item_list.length)
1158
+ item_list = item_list.slice(0, max)
1159
+ break
1160
+ end
1161
+ break if next_iteration_token.nil?
1162
+ end
1163
+ # save iteration token if needed
1164
+ iteration[0] = query_token[:iteration_token] unless iteration.nil?
1165
+ item_list
1166
+ end
1181
1167
  end
1182
1168
  end
1183
1169
  end