aspera-cli 4.23.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 (109) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +32 -1
  4. data/CONTRIBUTING.md +86 -29
  5. data/README.md +1651 -856
  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 +44 -31
  16. data/lib/aspera/api/cos_node.rb +7 -5
  17. data/lib/aspera/api/httpgw.rb +15 -18
  18. data/lib/aspera/api/node.rb +104 -22
  19. data/lib/aspera/ascmd.rb +22 -16
  20. data/lib/aspera/ascp/installation.rb +37 -40
  21. data/lib/aspera/ascp/management.rb +5 -4
  22. data/lib/aspera/assert.rb +54 -23
  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 +29 -3
  28. data/lib/aspera/cli/main.rb +138 -107
  29. data/lib/aspera/cli/manager.rb +50 -30
  30. data/lib/aspera/cli/plugin.rb +148 -77
  31. data/lib/aspera/cli/plugin_factory.rb +2 -2
  32. data/lib/aspera/cli/plugins/aoc.rb +189 -70
  33. data/lib/aspera/cli/plugins/ats.rb +15 -13
  34. data/lib/aspera/cli/plugins/config.rb +86 -213
  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 +144 -162
  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 +28 -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 +51 -50
  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/environment.rb +144 -101
  55. data/lib/aspera/faspex_gw.rb +1 -1
  56. data/lib/aspera/faspex_postproc.rb +3 -2
  57. data/lib/aspera/hash_ext.rb +1 -1
  58. data/lib/aspera/id_generator.rb +10 -10
  59. data/lib/aspera/keychain/base.rb +18 -0
  60. data/lib/aspera/keychain/encrypted_hash.rb +6 -12
  61. data/lib/aspera/keychain/factory.rb +9 -3
  62. data/lib/aspera/keychain/hashicorp_vault.rb +9 -6
  63. data/lib/aspera/keychain/macos_security.rb +13 -13
  64. data/lib/aspera/log.rb +69 -20
  65. data/lib/aspera/nagios.rb +5 -6
  66. data/lib/aspera/node_simulator.rb +12 -7
  67. data/lib/aspera/oauth/base.rb +5 -3
  68. data/lib/aspera/oauth/factory.rb +24 -18
  69. data/lib/aspera/oauth/jwt.rb +13 -1
  70. data/lib/aspera/oauth/url_json.rb +3 -3
  71. data/lib/aspera/oauth/web.rb +5 -3
  72. data/lib/aspera/persistency_folder.rb +2 -2
  73. data/lib/aspera/preview/file_types.rb +4 -3
  74. data/lib/aspera/preview/generator.rb +25 -12
  75. data/lib/aspera/preview/terminal.rb +10 -7
  76. data/lib/aspera/preview/utils.rb +11 -9
  77. data/lib/aspera/products/connect.rb +1 -1
  78. data/lib/aspera/products/desktop.rb +1 -1
  79. data/lib/aspera/products/other.rb +2 -2
  80. data/lib/aspera/products/transferd.rb +8 -6
  81. data/lib/aspera/proxy_auto_config.rb +1 -1
  82. data/lib/aspera/rest.rb +29 -22
  83. data/lib/aspera/rest_call_error.rb +1 -1
  84. data/lib/aspera/resumer.rb +1 -1
  85. data/lib/aspera/secret_hider.rb +46 -40
  86. data/lib/aspera/ssh.rb +13 -3
  87. data/lib/aspera/sync/args.schema.yaml +102 -0
  88. data/lib/aspera/sync/conf.schema.yaml +701 -0
  89. data/lib/aspera/sync/database.rb +83 -0
  90. data/lib/aspera/{transfer/sync.rb → sync/operations.rb} +132 -65
  91. data/lib/aspera/temp_file_manager.rb +3 -2
  92. data/lib/aspera/transfer/error.rb +1 -1
  93. data/lib/aspera/transfer/error_info.rb +1 -2
  94. data/lib/aspera/transfer/faux_file.rb +11 -10
  95. data/lib/aspera/transfer/parameters.rb +6 -5
  96. data/lib/aspera/transfer/spec.rb +15 -1
  97. data/lib/aspera/transfer/spec.schema.yaml +316 -293
  98. data/lib/aspera/transfer/spec_doc.rb +34 -16
  99. data/lib/aspera/transfer/uri.rb +5 -5
  100. data/lib/aspera/uri_reader.rb +14 -10
  101. data/lib/aspera/web_auth.rb +2 -2
  102. data/lib/aspera/web_server_simple.rb +2 -2
  103. data.tar.gz.sig +0 -0
  104. metadata +15 -13
  105. metadata.gz.sig +2 -2
  106. data/lib/aspera/transfer/async_conf.schema.yaml +0 -716
  107. data/lib/aspera/transfer/convert.rb +0 -29
  108. data/lib/aspera/transfer/sync_instance.schema.yaml +0 -20
  109. data/lib/aspera/transfer/sync_session.schema.yaml +0 -86
@@ -1,108 +1,129 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aspera/transfer/sync'
3
+ require 'aspera/sync/operations'
4
4
  require 'aspera/assert'
5
+ require 'aspera/environment'
6
+ require 'pathname'
5
7
 
6
8
  module Aspera
7
9
  module Cli
8
- # Module for sync actions
10
+ # Manage command line arguments to provide to Sync::Run, Sync::Database and Sync::Operations
9
11
  module SyncActions
10
- # Optional simple command line arguments for sync
11
- # in Array to keep order as on command line
12
- # conf: key in option --conf
13
- # args: key for command line args
14
- # values: possible values for argument
15
- # type: type for validation
16
- ARGUMENTS_INFO = [
17
- {
18
- conf: 'direction',
19
- args: 'direction',
20
- values: Transfer::Sync::DIRECTIONS
21
- }, {
22
- conf: 'local.path',
23
- args: 'local_dir',
24
- type: String
25
- }, {
26
- conf: 'remote.path',
27
- args: 'remote_dir',
28
- type: String
29
- }
30
- ].freeze
31
- # name of minimal arguments required, also used to generate a session name
32
- ARGUMENTS_LIST = ARGUMENTS_INFO.map{ |i| i[:conf]}.freeze
33
- private_constant :ARGUMENTS_INFO
34
-
12
+ # translate state id (int) to string
13
+ STATE_STR = (['Nil'] +
14
+ (1..18).map{ |i| "P(#{i})"} +
15
+ %w[Syncd Error Confl Pconf] +
16
+ (23..24).map{ |i| "P(#{i})"}).freeze
35
17
  class << self
36
- def declare_options(options)
37
- options.declare(:sync_info, 'Information for sync instance and sessions', types: Hash)
18
+ def declare_options(_options)
38
19
  end
39
20
  end
40
21
 
41
- # Read command line arguments (3) and converts to sync_info format
42
- def sync_args_to_params(async_params)
43
- # sync session parameters can be provided on command line instead of sync_info
44
- arguments = {}
45
- ARGUMENTS_INFO.each do |info|
46
- value = options.get_next_argument(
47
- info[:conf],
48
- mandatory: false,
49
- validation: info[:type],
50
- accept_list: info[:values])
51
- break if value.nil?
52
- arguments[info[:conf]] = value.to_s
53
- end
54
- Log.log.debug{Log.dump('arguments', arguments)}
55
- case arguments.keys.length
56
- when 0 then nil
57
- when 3
58
- session_info = async_params
59
- param_path = :conf
60
- if async_params.key?('sessions') || async_params.key?('instance')
61
- async_params['sessions'] ||= [{}]
62
- Aspera.assert(async_params['sessions'].length == 1){'Only one session is supported with arguments'}
63
- session_info = async_params['sessions'][0]
64
- param_path = :args
22
+ # Read command line arguments (1 to 3) and converts to sync_info format
23
+ # @param sync [Bool] Set to `true` for non-admin
24
+ # @return [Hash] sync info
25
+ def async_info_from_args(direction: nil)
26
+ path = options.get_next_argument('path')
27
+ sync_info = options.get_next_argument('sync info', mandatory: false, validation: Hash, default: {})
28
+ path_is_remote = direction.eql?(:pull)
29
+ if sync_info.key?('sessions') || sync_info.key?('instance')
30
+ # "args"
31
+ sync_info['sessions'] ||= [{}]
32
+ Aspera.assert(sync_info['sessions'].length == 1){'Only one session is supported'}
33
+ session = sync_info['sessions'].first
34
+ dir_key = path_is_remote ? 'remote_dir' : 'local_dir'
35
+ raise "Parameter #{dir_key} shall not be in sync_info" if session.key?(dir_key)
36
+ session[dir_key] = path
37
+ if direction
38
+ dir_key = path_is_remote ? 'local_dir' : 'remote_dir'
39
+ raise "Parameter #{dir_key} shall not be in sync_info" if session.key?(dir_key)
40
+ session[dir_key] = transfer.destination_folder(path_is_remote ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND)
41
+ local_remote = %w[local remote].map{ |i| session["#{i}_dir"]}
65
42
  end
66
- ARGUMENTS_INFO.each do |info|
67
- key_path = info[param_path].split('.')
68
- hash_for_key = session_info
69
- if key_path.length > 1
70
- first = key_path.shift
71
- async_params[first] ||= {}
72
- hash_for_key = async_params[first]
73
- end
74
- raise "Parameter #{info[:conf]} is also set in sync_info, remove from sync_info" if hash_for_key.key?(key_path.last)
75
- hash_for_key[key_path.last] = arguments[info[:conf]]
43
+ else
44
+ # "conf"
45
+ session = sync_info
46
+ dir_key = path_is_remote ? 'remote' : 'local'
47
+ session[dir_key] ||= {}
48
+ raise "Parameter #{dir_key}.path shall not be in sync_info" if session[dir_key].key?('path')
49
+ session[dir_key]['path'] = path
50
+ if direction
51
+ dir_key = path_is_remote ? 'local' : 'remote'
52
+ session[dir_key] ||= {}
53
+ raise "Parameter #{dir_key}.path shall not be in sync_info" if session[dir_key].key?('path')
54
+ session[dir_key]['path'] = transfer.destination_folder(path_is_remote ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND)
55
+ local_remote = %w[local remote].map{ |i| session[i]['path']}
76
56
  end
77
- if !session_info.key?('name')
78
- # if no name is specified, generate one from simple arguments
79
- session_info['name'] = ARGUMENTS_LIST.filter_map do |arg_name|
80
- arguments[arg_name]&.gsub(/[^a-zA-Z0-9]+/, '_')
81
- end.reject(&:empty?).join('_').gsub(/__+/, '_')
57
+ # "conf" is quiet by default
58
+ session['quiet'] = false if !session.key?('quiet') && Environment.terminal?
59
+ end
60
+ if direction
61
+ raise BadArgument, 'direction shall not be in sync_info' if session.key?('direction')
62
+ session['direction'] = direction.to_s
63
+ # generate name if not provided by user
64
+ if !session.key?('name')
65
+ session['name'] = Environment.instance.sanitized_filename(
66
+ ([direction.to_s] + local_remote).map do |value|
67
+ Pathname(value).each_filename.to_a.last(2).join(Environment.instance.safe_filename_character)
68
+ end.join(Environment.instance.safe_filename_character)
69
+ )
82
70
  end
83
- else raise Cli::BadArgument, "Provide 0 or 3 arguments, not #{arguments.keys.length} for: #{ARGUMENTS_LIST.join(', ')}"
84
71
  end
72
+ sync_info
85
73
  end
86
74
 
75
+ # provide database object from command line arguments for admin ops
76
+ def db_from_args
77
+ sync_info = async_info_from_args
78
+ session = sync_info.key?('sessions') ? sync_info['sessions'].first : sync_info
79
+ # if name not provided, check in db folder if there is only one name
80
+ if !session.key?('name')
81
+ local_db_dir = Sync::Operations.local_db_folder(sync_info)
82
+ dbs = Sync::Operations.list_db_files(local_db_dir)
83
+ raise "#{dbs.length} session found in #{local_db_dir}, please provide a name" unless dbs.length == 1
84
+ session['name'] = dbs.keys.first
85
+ end
86
+ Sync::Database.new(Sync::Operations.session_db_file(sync_info))
87
+ end
88
+
89
+ # Execute sync action
90
+ # @param &block [nil, Proc] block to generate transfer spec, takes: direction (one of DIRECTIONS), local_dir, remote_dir
87
91
  def execute_sync_action(&block)
88
- Aspera.assert(block){'No block given'}
89
- command = options.get_next_command(%i[start admin])
92
+ command = options.get_next_command(%i[admin] + Sync::Operations::DIRECTIONS)
90
93
  # try to get 3 arguments as simple arguments
91
94
  case command
92
- when :start
93
- # possibilities are:
94
- async_params = options.get_option(:sync_info, default: {})
95
- sync_args_to_params(async_params)
96
- Transfer::Sync.start(async_params, &block)
95
+ when *Sync::Operations::DIRECTIONS
96
+ Sync::Operations.start(async_info_from_args(direction: command), transfer.option_transfer_spec, &block)
97
97
  return Main.result_success
98
98
  when :admin
99
- command2 = options.get_next_command([:status])
99
+ command2 = options.get_next_command(%i[status find meta counters file_info overview])
100
+ require 'aspera/sync/database' unless command2.eql?(:status)
100
101
  case command2
101
102
  when :status
102
- sync_session_name = options.get_next_argument('name of sync session', mandatory: false, validation: String)
103
- async_params = options.get_option(:sync_info, mandatory: true)
104
- return Main.result_single_object(Transfer::Sync.admin_status(async_params, sync_session_name))
103
+ return Main.result_single_object(Sync::Operations.admin_status(async_info_from_args))
104
+ when :find
105
+ folder = options.get_next_argument('path')
106
+ dbs = Sync::Operations.list_db_files(folder)
107
+ return Main.result_object_list(dbs.keys.map{ |n| {name: n, path: dbs[n]}})
108
+ when :meta, :counters
109
+ return Main.result_single_object(db_from_args.send(command2))
110
+ when :file_info
111
+ result = db_from_args.send(command2)
112
+ result.each do |r|
113
+ r['sstate'] = SyncActions::STATE_STR[r['state']] if r['state']
114
+ end
115
+ return Main.result_object_list(
116
+ result,
117
+ fields: %w[sstate record_id f_meta_path message]
118
+ )
119
+ when :overview
120
+ return Main.result_object_list(
121
+ db_from_args.overview,
122
+ fields: %w[table name type]
123
+ )
124
+ else Aspera.error_unexpected_value(command2)
105
125
  end
126
+ else Aspera.error_unexpected_value(command)
106
127
  end
107
128
  end
108
129
  end
@@ -12,9 +12,9 @@ module Aspera
12
12
  # one of the supported transfer agents
13
13
  # provides CLI options to select one of the transfer agents (FASP/ascp client)
14
14
  class TransferAgent
15
- # special value for --sources : read file list from arguments
15
+ # @args special value for --sources : read file list from arguments
16
16
  FILE_LIST_FROM_ARGS = '@args'
17
- # special value for --sources : read file list from transfer spec (--ts)
17
+ # @ts special value for --sources : read file list from transfer spec (--ts)
18
18
  FILE_LIST_FROM_TRANSFER_SPEC = '@ts'
19
19
  FILE_LIST_OPTIONS = [FILE_LIST_FROM_ARGS, FILE_LIST_FROM_TRANSFER_SPEC, 'Array'].freeze
20
20
  DEFAULT_TRANSFER_NOTIFY_TEMPLATE = <<~END_OF_TEMPLATE
@@ -49,7 +49,10 @@ module Aspera
49
49
  @opt_mgr = opt_mgr
50
50
  @config = config_plugin
51
51
  # command line can override transfer spec
52
- @transfer_spec_command_line = {'create_dir' => true}
52
+ @transfer_spec_command_line = {
53
+ 'create_dir' => true,
54
+ 'resume_policy' => 'sparse_csum'
55
+ }
53
56
  # options for transfer agent
54
57
  @transfer_info = {}
55
58
  # the currently selected transfer agent
@@ -60,7 +63,7 @@ module Aspera
60
63
  @httpgw_url_lambda = nil
61
64
  @opt_mgr.declare(:ts, 'Override transfer spec values', types: Hash, handler: {o: self, m: :option_transfer_spec})
62
65
  @opt_mgr.declare(:to_folder, 'Destination folder for transferred files')
63
- @opt_mgr.declare(:sources, "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})")
66
+ @opt_mgr.declare(:sources, "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS)
64
67
  @opt_mgr.declare(:src_type, 'Type of file list', values: %i[list pair], default: :list)
65
68
  @opt_mgr.declare(:transfer, 'Type of transfer agent', values: TRANSFER_AGENTS, default: :direct)
66
69
  @opt_mgr.declare(:transfer_info, 'Parameters for transfer agent', types: Hash, handler: {o: self, m: :transfer_info})
@@ -86,7 +89,7 @@ module Aspera
86
89
  end
87
90
 
88
91
  # add other transfer spec parameters
89
- def option_transfer_spec_deep_merge(ts); @transfer_spec_command_line.deep_merge!(ts); end
92
+ def option_transfer_spec_deep_merge(value); @transfer_spec_command_line.deep_merge!(value); end
90
93
 
91
94
  attr_reader :transfer_info
92
95
 
@@ -163,54 +166,56 @@ module Aspera
163
166
  @httpgw_url_lambda = httpgw_url_proc
164
167
  end
165
168
 
169
+ # transform the list of paths to a list of hash with source/dest
170
+ def list_to_paths(file_list)
171
+ source_type = @opt_mgr.get_option(:src_type, mandatory: true)
172
+ case source_type
173
+ when :list
174
+ # when providing a list, just specify source
175
+ @transfer_paths = file_list.map{ |i| {'source' => i}}
176
+ when :pair
177
+ Aspera.assert(file_list.length.even?, type: Cli::BadArgument){"When using pair, provide an even number of paths: #{file_list.length}"}
178
+ @transfer_paths = file_list.each_slice(2).map{ |s, d| {'source' => s, 'destination' => d}}
179
+ else Aspera.error_unexpected_value(source_type)
180
+ end
181
+ end
182
+
166
183
  # This is how the list of files to be transferred is specified
167
184
  # get paths suitable for transfer spec from command line
168
- # @param default [String] if set, used as default file for --sources=@args
169
- # @return [Hash] {source: (mandatory), destination: (optional)}
170
185
  # computation is done only once, cache is kept in @transfer_paths
186
+ # @param default [Array] of [String] if set, used as default file for --sources=@args
187
+ # @return [Array, nil] of Hash {source: (mandatory), destination: (optional)}
171
188
  def ts_source_paths(default: nil)
172
189
  # return cache if set
173
190
  return @transfer_paths unless @transfer_paths.nil?
174
191
  # start with lower priority : get paths from transfer spec on command line
175
192
  @transfer_paths = @transfer_spec_command_line['paths'] if @transfer_spec_command_line.key?('paths')
176
193
  # is there a source list option ?
177
- file_list = @opt_mgr.get_option(:sources)
178
- case file_list
179
- when nil, FILE_LIST_FROM_ARGS
180
- Log.log.debug('getting file list as parameters')
181
- Aspera.assert_type(default, Array) unless default.nil?
182
- # get remaining arguments
183
- file_list = @opt_mgr.get_next_argument('source file list', multiple: true, default: default)
184
- raise Cli::BadArgument, 'specify at least one file on command line or use ' \
185
- "--sources=#{FILE_LIST_FROM_TRANSFER_SPEC} to use transfer spec" if !file_list.is_a?(Array) || file_list.empty?
186
- when FILE_LIST_FROM_TRANSFER_SPEC
187
- Log.log.debug('assume list provided in transfer spec')
188
- special_case_direct_with_list =
189
- @opt_mgr.get_option(:transfer, mandatory: true).eql?(:direct) &&
190
- Transfer::Parameters.ascp_args_file_list?(@opt_mgr.get_option(:transfer_info)['ascp_args'])
191
- raise Cli::BadArgument, 'transfer spec on command line must have sources' if @transfer_paths.nil? && !special_case_direct_with_list
192
- # here we assume check of sources is made in transfer agent
193
- return @transfer_paths
194
- when Array
195
- Log.log.debug('getting file list as extended value')
196
- raise Cli::BadArgument, 'sources must be a Array of String' if !file_list.reject{ |f| f.is_a?(String)}.empty?
197
- else
198
- raise Cli::BadArgument, "sources must be a Array, not #{file_list.class}"
199
- end
200
- # here, file_list is an Array or String
201
- if !@transfer_paths.nil?
202
- Log.log.warn('--sources overrides paths from --ts')
203
- end
204
- source_type = @opt_mgr.get_option(:src_type, mandatory: true)
205
- case source_type
206
- when :list
207
- # when providing a list, just specify source
208
- @transfer_paths = file_list.map{ |i| {'source' => i}}
209
- when :pair
210
- Aspera.assert(file_list.length.even?, exception_class: Cli::BadArgument){"When using pair, provide an even number of paths: #{file_list.length}"}
211
- @transfer_paths = file_list.each_slice(2).to_a.map{ |s, d| {'source' => s, 'destination' => d}}
212
- else Aspera.error_unexpected_value(source_type)
213
- end
194
+ sources = @opt_mgr.get_option(:sources)
195
+ @transfer_paths =
196
+ case sources
197
+ when FILE_LIST_FROM_ARGS
198
+ Log.log.debug('getting file list as parameters')
199
+ Aspera.assert_type(default, Array, NilClass)
200
+ # get remaining arguments
201
+ list = @opt_mgr.get_next_argument('source file list', multiple: true, default: default)
202
+ raise Cli::BadArgument, 'specify at least one file on command line or use ' \
203
+ "--sources=#{FILE_LIST_FROM_TRANSFER_SPEC} to use transfer spec" if !list.is_a?(Array) || list.empty?
204
+ list_to_paths(list)
205
+ when FILE_LIST_FROM_TRANSFER_SPEC
206
+ Log.log.debug('assume list provided in transfer spec')
207
+ special_case_direct_with_list =
208
+ @opt_mgr.get_option(:transfer, mandatory: true).eql?(:direct) &&
209
+ Transfer::Parameters.ascp_args_file_list?(@opt_mgr.get_option(:transfer_info)['ascp_args'])
210
+ raise Cli::BadArgument, 'transfer spec on command line must have sources' if @transfer_paths.nil? && !special_case_direct_with_list
211
+ # can be nil
212
+ @transfer_paths
213
+ when Array
214
+ Log.log.debug('getting file list as extended value')
215
+ Aspera.assert(sources.all?(String), type: Cli::BadArgument){'sources must be a Array of String'}
216
+ list_to_paths(sources)
217
+ else Aspera.error_unexpected_value(sources){'sources'}
218
+ end
214
219
  Log.log.debug{"paths=#{@transfer_paths}"}
215
220
  return @transfer_paths
216
221
  end
@@ -221,9 +226,7 @@ module Aspera
221
226
  def start(transfer_spec, rest_token: nil)
222
227
  # check parameters
223
228
  Aspera.assert_type(transfer_spec, Hash){'transfer_spec'}
224
- if transfer_spec['remote_host'].eql?(CP4I_REMOTE_HOST_LB)
225
- raise "Wrong remote host: #{CP4I_REMOTE_HOST_LB}"
226
- end
229
+ raise "Wrong remote host: #{CP4I_REMOTE_HOST_LB}" if transfer_spec['remote_host'].eql?(CP4I_REMOTE_HOST_LB)
227
230
  # process :src option
228
231
  case transfer_spec['direction']
229
232
  when Transfer::Spec::DIRECTION_RECEIVE
@@ -250,9 +253,7 @@ module Aspera
250
253
  # recursively remove values that are nil (user wants to delete)
251
254
  transfer_spec.deep_do{ |hash, key, value, _unused| hash.delete(key) if value.nil?}
252
255
  # if TS from app has content_protection (e.g. F5), that means content is protected: ask password if not provided
253
- if transfer_spec['content_protection'].eql?('decrypt') && !transfer_spec.key?('content_protection_password')
254
- transfer_spec['content_protection_password'] = @opt_mgr.prompt_user_input('content protection password', sensitive: true)
255
- end
256
+ transfer_spec['content_protection_password'] = @opt_mgr.prompt_user_input('content protection password', sensitive: true) if transfer_spec['content_protection'].eql?('decrypt') && !transfer_spec.key?('content_protection_password')
256
257
  # create transfer agent
257
258
  agent_instance.start_transfer(transfer_spec, token_regenerator: rest_token)
258
259
  # list of: :success or "error message string"
@@ -8,13 +8,10 @@ module Aspera
8
8
  module Cli
9
9
  # Progress bar for transfers.
10
10
  # Supports multi-session.
11
+ # Note that we can have this case:
12
+ # 2 sessions (-C x:2), but one session fails and restarts...
11
13
  class TransferProgress
12
14
  def initialize
13
- reset
14
- end
15
-
16
- # Reset progress bar, to re-use it.
17
- def reset
18
15
  @progress_bar = nil
19
16
  # key is session id
20
17
  @sessions = {}
@@ -22,11 +19,16 @@ module Aspera
22
19
  @title = nil
23
20
  end
24
21
 
22
+ # Reset progress bar, to re-use it.
23
+ def reset
24
+ send(:initialize)
25
+ end
26
+
25
27
  # Called by user of progress bar with a status on a transfer session
26
28
  # @param session_id the unique identifier of a transfer session
27
- # @param type one of: pre_start, session_start, session_size, transfer, end
29
+ # @param type [Symbol] one of: sessions_init, session_start, session_size, transfer, session_end and end
28
30
  # @param info optional specific additional info for the given event type
29
- def event(type, session_id:, info: nil)
31
+ def event(type, session_id: nil, info: nil)
30
32
  Log.log.trace1{"progress: #{type} #{session_id} #{info}"}
31
33
  return if @completed
32
34
  if @progress_bar.nil?
@@ -34,11 +36,12 @@ module Aspera
34
36
  format: '%t %a %B %p%% %r Mbps %E',
35
37
  rate_scale: lambda{ |rate| rate / Environment::BYTES_PER_MEBIBIT},
36
38
  title: '',
37
- total: nil)
39
+ total: nil
40
+ )
38
41
  end
39
42
  progress_provided = false
40
43
  case type
41
- when :pre_start
44
+ when :sessions_init
42
45
  # give opportunity to show progress of initialization with multiple status
43
46
  Aspera.assert(session_id.nil?)
44
47
  Aspera.assert_type(info, String)
@@ -50,35 +53,41 @@ module Aspera
50
53
  raise "Session #{session_id} already started" if @sessions[session_id]
51
54
  @sessions[session_id] = {
52
55
  job_size: 0, # total size of transfer (pre-calc)
53
- current: 0
56
+ current: 0,
57
+ running: true
54
58
  }
55
59
  # remove last pre-start message if any
56
60
  @title = nil
57
61
  when :session_size
58
62
  Aspera.assert_type(session_id, String)
59
63
  Aspera.assert(!info.nil?)
64
+ Aspera.assert_type(@sessions[session_id], Hash)
60
65
  @sessions[session_id][:job_size] = info.to_i
61
- current_total = total(:job_size)
62
- @progress_bar.total = current_total unless current_total.eql?(@progress_bar.total) || current_total < @progress_bar.progress
66
+ sessions_total = total(:job_size)
67
+ @progress_bar.total = sessions_total unless sessions_total.eql?(@progress_bar.total) || sessions_total < @progress_bar.progress
63
68
  when :transfer
64
69
  Aspera.assert_type(session_id, String)
70
+ Aspera.assert_type(@sessions[session_id], Hash)
65
71
  if !@progress_bar.total.nil? && !info.nil?
66
72
  progress_provided = true
67
73
  @sessions[session_id][:current] = info.to_i
68
- current_total = total(:current)
69
- @progress_bar.progress = current_total unless @progress_bar.progress.eql?(current_total)
74
+ sessions_current = total(:current)
75
+ @progress_bar.progress = sessions_current unless @progress_bar.progress.eql?(sessions_current) || sessions_current > total(:job_size)
70
76
  end
77
+ when :session_end
78
+ Aspera.assert_type(session_id, String)
79
+ Aspera.assert(info.nil?)
80
+ # a session may be too short and finish before it has been started
81
+ @sessions[session_id][:running] = false if @sessions[session_id].is_a?(Hash)
71
82
  when :end
72
- Aspera.assert(session_id, String)
83
+ Aspera.assert(session_id.nil?)
73
84
  Aspera.assert(info.nil?)
74
- @title = nil
75
- @completed = true
76
85
  @progress_bar.finish
77
86
  else Aspera.error_unexpected_value(type){'event type'}
78
87
  end
79
- new_title = @sessions.length < 2 ? @title.to_s : "[#{@sessions.length}] #{@title}"
80
- @progress_bar.title = new_title unless @progress_bar.title.eql?(new_title)
81
- @progress_bar.increment if !progress_provided && !@completed
88
+ new_title = @sessions.length < 2 ? @title.to_s : "[#{@sessions.count{ |_i, d| d[:running]}}] #{@title}"
89
+ @progress_bar&.title = new_title unless @progress_bar&.title.eql?(new_title)
90
+ @progress_bar&.increment if !progress_provided && @progress_bar.progress.nil?
82
91
  rescue ProgressBar::InvalidProgressError => e
83
92
  Log.log.error{"Progress error: #{e}"}
84
93
  end
@@ -4,6 +4,6 @@ module Aspera
4
4
  module Cli
5
5
  # for beta add extension : .beta1
6
6
  # for dev version add extension : .pre
7
- VERSION = '4.23.0'
7
+ VERSION = '4.24.0'
8
8
  end
9
9
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/oauth/jwt'
4
+
5
+ module Aspera
6
+ module Cli
7
+ class Wizard
8
+ WIZARD_RESULT_KEYS = %i[preset_value test_args].freeze
9
+ DEFAULT_PRIV_KEY_FILENAME = 'my_private_key.pem' # pragma: allowlist secret
10
+ private_constant :WIZARD_RESULT_KEYS,
11
+ :DEFAULT_PRIV_KEY_FILENAME
12
+
13
+ def initialize(parent, main_folder)
14
+ @parent = parent
15
+ @main_folder = main_folder
16
+ # wizard options
17
+ options.declare(:override, 'Wizard: override existing value', values: :bool, default: :no)
18
+ options.declare(:default, 'Wizard: set as default configuration for specified plugin (also: update)', values: :bool, default: true)
19
+ options.declare(:test_mode, 'Wizard: skip private key check step', values: :bool, default: false)
20
+ options.declare(:key_path, 'Wizard: path to private key for JWT')
21
+ end
22
+
23
+ def options
24
+ @parent.options
25
+ end
26
+
27
+ def formatter
28
+ @parent.formatter
29
+ end
30
+
31
+ # Find a plugin, and issue the "require"
32
+ # @return [Hash] plugin info: { product:, name:, url:, version: }
33
+ def identify_plugins_for_url
34
+ app_url = options.get_next_argument('url', mandatory: true)
35
+ check_only = options.get_next_argument('plugin name', mandatory: false)
36
+ check_only = check_only.to_sym unless check_only.nil?
37
+ found_apps = []
38
+ my_self_plugin_sym = self.class.name.split('::').last.downcase.to_sym
39
+ PluginFactory.instance.plugin_list.each do |plugin_name_sym|
40
+ # no detection for internal plugin
41
+ next if plugin_name_sym.eql?(my_self_plugin_sym)
42
+ next if check_only && !check_only.eql?(plugin_name_sym)
43
+ # load plugin class
44
+ detect_plugin_class = PluginFactory.instance.plugin_class(plugin_name_sym)
45
+ # requires detection method
46
+ next unless detect_plugin_class.respond_to?(:detect)
47
+ detection_info = nil
48
+ begin
49
+ Log.log.debug{"detecting #{plugin_name_sym} at #{app_url}"}
50
+ formatter.long_operation_running("#{plugin_name_sym}\r")
51
+ detection_info = detect_plugin_class.detect(app_url)
52
+ rescue OpenSSL::SSL::SSLError => e
53
+ Log.log.warn(e.message)
54
+ Log.log.warn('Use option --insecure=yes to allow unchecked certificate') if e.message.include?('cert')
55
+ rescue StandardError => e
56
+ Log.log.debug{"detect error: [#{e.class}] #{e}"}
57
+ next
58
+ end
59
+ next if detection_info.nil?
60
+ Aspera.assert_type(detection_info, Hash)
61
+ Aspera.assert_type(detection_info[:url], String) if detection_info.key?(:url)
62
+ app_name = detect_plugin_class.respond_to?(:application_name) ? detect_plugin_class.application_name : detect_plugin_class.name.split('::').last
63
+ # if there is a redirect, then the detector can override the url.
64
+ found_apps.push({product: plugin_name_sym, name: app_name, url: app_url, version: 'unknown'}.merge(detection_info))
65
+ end
66
+ raise "No known application found at #{app_url}" if found_apps.empty?
67
+ Aspera.assert(found_apps.all?{ |a| a.keys.all?(Symbol)})
68
+ return found_apps
69
+ end
70
+
71
+ def find(apps)
72
+ identification = if apps.length.eql?(1)
73
+ Log.log.debug{"Detected: #{identification}"}
74
+ apps.first
75
+ else
76
+ formatter.display_status('Multiple applications detected, please select from:')
77
+ formatter.display_results(type: :object_list, data: apps, fields: %w[product url version])
78
+ answer = options.prompt_user_input_in_list('product', apps.map{ |a| a[:product]})
79
+ apps.find{ |a| a[:product].eql?(answer)}
80
+ end
81
+ wiz_preset_name = options.get_next_argument('preset name', default: '')
82
+ Log.dump(:identification, identification)
83
+ wiz_url = identification[:url]
84
+ formatter.display_status("Using: #{identification[:name]} at #{wiz_url}".bold)
85
+ # set url for instantiation of plugin
86
+ options.add_option_preset({url: wiz_url}, 'wizard')
87
+ # instantiate plugin: command line options will be known and wizard can be called
88
+ wiz_plugin_class = PluginFactory.instance.plugin_class(identification[:product])
89
+ Aspera.assert(wiz_plugin_class.respond_to?(:wizard), type: Cli::BadArgument) do
90
+ "Detected: #{identification[:product]}, but this application has no wizard"
91
+ end
92
+ # instantiate plugin: command line options will be known, e.g. private_key
93
+ plugin_instance = wiz_plugin_class.new(context: @parent.context)
94
+ wiz_params = {
95
+ object: plugin_instance
96
+ }
97
+ # is private key needed ?
98
+ if options.known_options.key?(:private_key) &&
99
+ (!wiz_plugin_class.respond_to?(:private_key_required?) || wiz_plugin_class.private_key_required?(wiz_url))
100
+ # lets see if path to priv key is provided
101
+ private_key_path = options.get_option(:key_path)
102
+ # give a chance to provide
103
+ if private_key_path.nil?
104
+ formatter.display_status('Please provide the path to your private RSA key, or nothing to generate one:')
105
+ private_key_path = options.get_option(:key_path, mandatory: true).to_s
106
+ end
107
+ # else generate path
108
+ private_key_path = File.join(@main_folder, DEFAULT_PRIV_KEY_FILENAME) if private_key_path.empty?
109
+ if File.exist?(private_key_path)
110
+ formatter.display_status('Using existing key:')
111
+ else
112
+ formatter.display_status("Generating #{OAuth::Jwt::DEFAULT_PRIV_KEY_LENGTH} bit RSA key...")
113
+ OAuth::Jwt.generate_rsa_private_key(path: private_key_path)
114
+ formatter.display_status('Created key:')
115
+ end
116
+ formatter.display_status(private_key_path)
117
+ private_key_pem = File.read(private_key_path)
118
+ options.set_option(:private_key, private_key_pem)
119
+ wiz_params[:private_key_path] = private_key_path
120
+ wiz_params[:pub_key_pem] = OpenSSL::PKey::RSA.new(private_key_pem).public_key.to_s
121
+ end
122
+ Log.dump(:wiz_params, wiz_params)
123
+ # finally, call the wizard
124
+ wizard_result = wiz_plugin_class.wizard(**wiz_params)
125
+ Log.log.debug{"wizard result: #{wizard_result}"}
126
+ Aspera.assert(WIZARD_RESULT_KEYS.eql?(wizard_result.keys.sort)){"missing or extra keys in wizard result: #{wizard_result.keys}"}
127
+ # get preset name from user or default
128
+ if wiz_preset_name.empty?
129
+ elements = [
130
+ identification[:product],
131
+ URI.parse(wiz_url).host
132
+ ]
133
+ elements.push(options.get_option(:username, mandatory: true)) unless wizard_result[:preset_value].key?(:link) rescue nil
134
+ wiz_preset_name = elements.join('_').strip.downcase.gsub(/[^a-z0-9]/, '_').squeeze('_')
135
+ end
136
+ # test mode does not change conf file
137
+ return Main.result_single_object(wizard_result) if options.get_option(:test_mode)
138
+ # Write configuration file
139
+ formatter.display_status("Preparing preset: #{wiz_preset_name}")
140
+ # init defaults if necessary
141
+ @config_presets[CONF_PRESET_DEFAULTS] ||= {}
142
+ option_override = options.get_option(:override, mandatory: true)
143
+ raise Cli::Error, "A default configuration already exists for plugin '#{identification[:product]}' (use --override=yes or --default=no)" \
144
+ if !option_override && options.get_option(:default, mandatory: true) && @config_presets[CONF_PRESET_DEFAULTS].key?(identification[:product])
145
+ raise Cli::Error, "Preset already exists: #{wiz_preset_name} (use --override=yes or --id=<name>)" \
146
+ if !option_override && @config_presets.key?(wiz_preset_name)
147
+ @config_presets[wiz_preset_name] = wizard_result[:preset_value].stringify_keys
148
+ test_args = wizard_result[:test_args]
149
+ if options.get_option(:default, mandatory: true)
150
+ formatter.display_status("Setting config preset as default for #{identification[:product]}")
151
+ @config_presets[CONF_PRESET_DEFAULTS][identification[:product].to_s] = wiz_preset_name
152
+ else
153
+ test_args = "-P#{wiz_preset_name} #{test_args}"
154
+ end
155
+ # TODO: actually test the command
156
+ return Main.result_status("You can test with:\n#{Info::CMD_NAME} #{identification[:product]} #{test_args}")
157
+ end
158
+ end
159
+ end
160
+ end