aspera-cli 4.0.0.pre2 → 4.2.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +761 -210
  3. data/bin/ascli +2 -0
  4. data/bin/dascli +13 -0
  5. data/docs/Makefile +2 -1
  6. data/docs/README.erb.md +628 -160
  7. data/docs/test_env.conf +22 -10
  8. data/docs/transfer_spec.html +1 -1
  9. data/lib/aspera/aoc.rb +87 -108
  10. data/lib/aspera/cli/formater.rb +2 -0
  11. data/lib/aspera/cli/main.rb +48 -45
  12. data/lib/aspera/cli/manager.rb +19 -6
  13. data/lib/aspera/cli/plugin.rb +9 -4
  14. data/lib/aspera/cli/plugins/alee.rb +1 -1
  15. data/lib/aspera/cli/plugins/aoc.rb +208 -183
  16. data/lib/aspera/cli/plugins/ats.rb +2 -2
  17. data/lib/aspera/cli/plugins/config.rb +205 -125
  18. data/lib/aspera/cli/plugins/console.rb +2 -2
  19. data/lib/aspera/cli/plugins/faspex.rb +15 -8
  20. data/lib/aspera/cli/plugins/faspex5.rb +76 -37
  21. data/lib/aspera/cli/plugins/node.rb +3 -3
  22. data/lib/aspera/cli/plugins/preview.rb +35 -25
  23. data/lib/aspera/cli/plugins/server.rb +23 -8
  24. data/lib/aspera/cli/transfer_agent.rb +7 -6
  25. data/lib/aspera/cli/version.rb +1 -1
  26. data/lib/aspera/colors.rb +5 -1
  27. data/lib/aspera/cos_node.rb +33 -28
  28. data/lib/aspera/environment.rb +15 -4
  29. data/lib/aspera/fasp/connect.rb +28 -21
  30. data/lib/aspera/fasp/http_gw.rb +140 -28
  31. data/lib/aspera/fasp/installation.rb +119 -57
  32. data/lib/aspera/fasp/local.rb +174 -178
  33. data/lib/aspera/fasp/manager.rb +12 -0
  34. data/lib/aspera/fasp/node.rb +4 -4
  35. data/lib/aspera/fasp/parameters.rb +6 -18
  36. data/lib/aspera/fasp/resume_policy.rb +13 -12
  37. data/lib/aspera/log.rb +10 -2
  38. data/lib/aspera/node.rb +61 -1
  39. data/lib/aspera/oauth.rb +36 -13
  40. data/lib/aspera/persistency_folder.rb +9 -4
  41. data/lib/aspera/preview/file_types.rb +53 -21
  42. data/lib/aspera/preview/generator.rb +3 -3
  43. data/lib/aspera/rest.rb +29 -18
  44. data/lib/aspera/secrets.rb +20 -0
  45. data/lib/aspera/temp_file_manager.rb +19 -0
  46. metadata +40 -22
@@ -5,6 +5,7 @@ require 'aspera/data_repository'
5
5
  require 'xmlsimple'
6
6
  require 'zlib'
7
7
  require 'base64'
8
+ require 'fileutils'
8
9
 
9
10
  module Aspera
10
11
  module Fasp
@@ -17,14 +18,28 @@ module Aspera
17
18
  # Installation.instance.ascp_path=""
18
19
  class Installation
19
20
  include Singleton
20
- # currently used ascp executable
21
- attr_accessor :ascp_path
22
- # location of SDK files
23
- attr_accessor :folder
24
21
  PRODUCT_CONNECT='Aspera Connect'
25
22
  PRODUCT_CLI_V1='Aspera CLI'
26
23
  PRODUCT_DRIVE='Aspera Drive'
27
24
  PRODUCT_ENTSRV='Enterprise Server'
25
+ MAX_REDIRECT_SDK=2
26
+ private_constant :MAX_REDIRECT_SDK
27
+ # set ascp executable path
28
+ def ascp_path=(v)
29
+ @path_to_ascp=v
30
+ end
31
+
32
+ # filename for ascp with optional extension (Windows)
33
+ def ascp_filename
34
+ return 'ascp'+Environment.exe_extension
35
+ end
36
+
37
+ # location of SDK files
38
+ def folder=(v)
39
+ @sdk_folder=v
40
+ folder_path
41
+ end
42
+
28
43
  # find ascp in named product (use value : FIRST_FOUND='FIRST' to just use first one)
29
44
  # or select one from installed_products()
30
45
  def use_ascp_from_product(product_name)
@@ -35,33 +50,37 @@ module Aspera
35
50
  pl=installed_products.select{|i|i[:name].eql?(product_name)}.first
36
51
  raise "no such product installed: #{product_name}" if pl.nil?
37
52
  end
38
- @ascp_path=pl[:ascp_path]
39
- Log.log.debug("ascp_path=#{@ascp_path}")
53
+ self.ascp_path=pl[:ascp_path]
54
+ Log.log.debug("ascp_path=#{@path_to_ascp}")
40
55
  end
41
56
 
42
57
  # @return the list of installed products in format of product_locations
43
58
  def installed_products
44
59
  if @found_products.nil?
45
- @found_products=product_locations
46
- # add sdk as first search path
47
- @found_products.unshift({# SDK
60
+ scan_locations=product_locations.clone
61
+ # add SDK as first search path
62
+ scan_locations.unshift({
48
63
  :expected =>'SDK',
49
- :app_root =>@folder,
64
+ :app_root =>folder_path,
50
65
  :sub_bin =>''
51
66
  })
52
- @found_products.select! do |pl|
53
- next false unless Dir.exist?(pl[:app_root])
54
- Log.log.debug("found #{pl[:app_root]}")
55
- sub_bin = pl[:sub_bin] || BIN_SUBFOLDER
56
- pl[:ascp_path]=File.join(pl[:app_root],sub_bin,'ascp'+Environment.exe_extension)
57
- next false unless File.exist?(pl[:ascp_path])
58
- product_info_file="#{pl[:app_root]}/#{PRODUCT_INFO}"
67
+ # search installed products: with ascp
68
+ @found_products=scan_locations.select! do |item|
69
+ # skip if not main folder
70
+ next false unless Dir.exist?(item[:app_root])
71
+ Log.log.debug("Found #{item[:app_root]}")
72
+ sub_bin = item[:sub_bin] || BIN_SUBFOLDER
73
+ item[:ascp_path]=File.join(item[:app_root],sub_bin,ascp_filename)
74
+ # skip if no ascp
75
+ next false unless File.exist?(item[:ascp_path])
76
+ # read info from product info file if present
77
+ product_info_file="#{item[:app_root]}/#{PRODUCT_INFO}"
59
78
  if File.exist?(product_info_file)
60
- res_s=XmlSimple.xml_in(File.read(product_info_file),{"ForceArray"=>false})
61
- pl[:name]=res_s['name']
62
- pl[:version]=res_s['version']
79
+ res_s=XmlSimple.xml_in(File.read(product_info_file),{'ForceArray'=>false})
80
+ item[:name]=res_s['name']
81
+ item[:version]=res_s['version']
63
82
  else
64
- pl[:name]=pl[:expected]
83
+ item[:name]=item[:expected]
65
84
  end
66
85
  true # select this version
67
86
  end
@@ -69,6 +88,7 @@ module Aspera
69
88
  return @found_products
70
89
  end
71
90
 
91
+ # all ascp files (in SDK)
72
92
  FILES=[:ascp,:ascp4,:ssh_bypass_key_dsa,:ssh_bypass_key_rsa,:aspera_license,:aspera_conf,:fallback_cert,:fallback_key]
73
93
 
74
94
  # get path of one resource file of currently activated product
@@ -76,44 +96,38 @@ module Aspera
76
96
  def path(k)
77
97
  case k
78
98
  when :ascp,:ascp4
79
- use_ascp_from_product(FIRST_FOUND) if @ascp_path.nil?
80
- file=@ascp_path
99
+ use_ascp_from_product(FIRST_FOUND) if @path_to_ascp.nil?
100
+ file=@path_to_ascp
81
101
  # note that there might be a .exe at the end
82
102
  file=file.gsub('ascp','ascp4') if k.eql?(:ascp4)
83
103
  when :ssh_bypass_key_dsa
84
- file=File.join(@folder,'aspera_bypass_dsa.pem')
104
+ file=File.join(folder_path,'aspera_bypass_dsa.pem')
85
105
  File.write(file,get_key('dsa',1)) unless File.exist?(file)
86
106
  File.chmod(0400,file)
87
107
  when :ssh_bypass_key_rsa
88
- file=File.join(@folder,'aspera_bypass_rsa.pem')
108
+ file=File.join(folder_path,'aspera_bypass_rsa.pem')
89
109
  File.write(file,get_key('rsa',2)) unless File.exist?(file)
90
110
  File.chmod(0400,file)
91
111
  when :aspera_license
92
- file=File.join(@folder,'aspera-license')
112
+ file=File.join(folder_path,'aspera-license')
93
113
  File.write(file,Base64.strict_encode64("#{Zlib::Inflate.inflate(DataRepository.instance.get_bin(6))}==SIGNATURE==\n#{Base64.strict_encode64(DataRepository.instance.get_bin(7))}")) unless File.exist?(file)
94
114
  File.chmod(0400,file)
95
115
  when :aspera_conf
96
- file=File.join(@folder,'aspera.conf')
116
+ file=File.join(folder_path,'aspera.conf')
97
117
  File.write(file,%Q{<?xml version='1.0' encoding='UTF-8'?>
98
118
  <CONF version="2">
99
119
  <default>
100
120
  <file_system>
101
- <storage_rc>
102
- <adaptive>
103
- true
104
- </adaptive>
105
- </storage_rc>
106
121
  <resume_suffix>.aspera-ckpt</resume_suffix>
107
122
  <partial_file_suffix>.partial</partial_file_suffix>
108
- <replace_illegal_chars>_</replace_illegal_chars>
109
123
  </file_system>
110
124
  </default>
111
125
  </CONF>
112
126
  }) unless File.exist?(file)
113
127
  File.chmod(0400,file)
114
128
  when :fallback_cert,:fallback_key
115
- file_key=File.join(@folder,'aspera_fallback_key.pem')
116
- file_cert=File.join(@folder,'aspera_fallback_cert.pem')
129
+ file_key=File.join(folder_path,'aspera_fallback_key.pem')
130
+ file_cert=File.join(folder_path,'aspera_fallback_cert.pem')
117
131
  if !File.exist?(file_key) or !File.exist?(file_cert)
118
132
  require 'openssl'
119
133
  # create new self signed certificate for http fallback
@@ -139,7 +153,7 @@ module Aspera
139
153
  return file
140
154
  end
141
155
 
142
- # @returns the file path of local connect where API's URI can be read
156
+ # @return the file path of local connect where API's URI can be read
143
157
  def connect_uri
144
158
  connect=get_product_folders(PRODUCT_CONNECT)
145
159
  folder=File.join(connect[:run_root],VARRUN_SUBFOLDER)
@@ -168,23 +182,67 @@ module Aspera
168
182
  return [:ssh_bypass_key_dsa,:ssh_bypass_key_rsa].map{|i|Installation.instance.path(i)}
169
183
  end
170
184
 
171
- def install_sdk
185
+ # Check that specified path is ascp and get version
186
+ def get_ascp_version(ascp_path)
187
+ raise "File basename of #{ascp_path} must be #{ascp_filename}" unless File.basename(ascp_path).eql?(ascp_filename)
188
+ ascp_version='n/a'
189
+ raise "error in sdk: no ascp included" if ascp_path.nil?
190
+ cmd_out=%x{"#{ascp_path}" -A}
191
+ raise "An error occured when testing #{ascp_filename}: #{cmd_out}" unless $? == 0
192
+ # get version from ascp, only after full extract, as windows requires DLLs (SSL/TLS/etc...)
193
+ m=cmd_out.match(/ascp version (.*)/)
194
+ ascp_version=m[1] unless m.nil?
195
+ end
196
+
197
+ # download aspera SDK or use local file
198
+ # extracts ascp binary for current system architecture
199
+ # @return ascp version (from execution)
200
+ def install_sdk(sdk_url)
172
201
  require 'zip'
173
202
  sdk_zip_path=File.join(Dir.tmpdir,'sdk.zip')
174
- Aspera::Rest.new(base_url: SDK_URL).call(operation: 'GET',save_to_file: sdk_zip_path)
203
+ if sdk_url.start_with?('file:')
204
+ # require specific file scheme: the path part is "relative", or absolute if there are 4 slash
205
+ raise 'use format: file:///<path>' unless sdk_url.start_with?('file:///')
206
+ sdk_zip_path=sdk_url.gsub(%r{^file:///},'')
207
+ else
208
+ redirect_remain=MAX_REDIRECT_SDK
209
+ begin
210
+ Aspera::Rest.new(base_url: sdk_url).call(operation: 'GET',save_to_file: sdk_zip_path)
211
+ rescue Aspera::RestCallError => e
212
+ if e.response.is_a?(Net::HTTPRedirection)
213
+ if redirect_remain > 0
214
+ redirect_remain-=1
215
+ sdk_url=e.response['location']
216
+ retry
217
+ else
218
+ raise "Too many redirect"
219
+ end
220
+ else
221
+ raise e
222
+ end
223
+ end
224
+ end
225
+ # SDK is organized by architecture
175
226
  filter="/#{Environment.architecture}/"
176
227
  ascp_path=nil
228
+ sdk_path=folder_path
229
+ # rename old install
230
+ if File.exist?(File.join(sdk_path,ascp_filename))
231
+ Log.log.warn("Previous install exists, renaming.")
232
+ File.rename(sdk_path,"#{sdk_path}.#{Time.now.strftime("%Y%m%d%H%M%S")}")
233
+ end
177
234
  # first ensure license file is here so that ascp invokation for version works
178
235
  self.path(:aspera_license)
179
236
  self.path(:aspera_conf)
180
237
  Zip::File.open(sdk_zip_path) do |zip_file|
181
238
  zip_file.each do |entry|
239
+ # get only specified arch, but not folder, only files
182
240
  if entry.name.include?(filter) and !entry.name.end_with?('/')
183
- archive_file=File.join(@folder,File.basename(entry.name))
241
+ archive_file=File.join(sdk_path,File.basename(entry.name))
184
242
  File.open(archive_file, 'wb') do |output_stream|
185
243
  IO.copy_stream(entry.get_input_stream, output_stream)
186
244
  end
187
- if entry.name.include?('ascp')
245
+ if File.basename(entry.name).eql?(ascp_filename)
188
246
  FileUtils.chmod(0755,archive_file)
189
247
  ascp_path=archive_file
190
248
  end
@@ -192,13 +250,8 @@ module Aspera
192
250
  end
193
251
  end
194
252
  File.unlink(sdk_zip_path) rescue nil # Windows may give error
195
- ascp_version='n/a'
196
- if !ascp_path.nil?
197
- # get version from ascp, only after full extract, as windows requires SSL/TLS DLLs
198
- m=%x{#{ascp_path} -A}.match(/ascp version (.*)/)
199
- ascp_version=m[1] unless m.nil?
200
- File.write(File.join(@folder,PRODUCT_INFO),"<product><name>IBM Aspera SDK</name><version>#{ascp_version}</version></product>")
201
- end
253
+ ascp_version=get_ascp_version(ascp_path)
254
+ File.write(File.join(folder_path,PRODUCT_INFO),"<product><name>IBM Aspera SDK</name><version>#{ascp_version}</version></product>")
202
255
  return ascp_version
203
256
  end
204
257
 
@@ -211,25 +264,31 @@ module Aspera
211
264
  PRODUCT_INFO='product-info.mf'
212
265
  # policy for product selection
213
266
  FIRST_FOUND='FIRST'
214
- SDK_URL='https://eudemo.asperademo.com/aspera/faspex/sdk.zip'
215
267
 
216
- private_constant :BIN_SUBFOLDER,:ETC_SUBFOLDER,:VARRUN_SUBFOLDER,:PRODUCT_INFO,:SDK_URL
268
+ private_constant :BIN_SUBFOLDER,:ETC_SUBFOLDER,:VARRUN_SUBFOLDER,:PRODUCT_INFO
217
269
 
218
- # get some specific folder from specific applications: Connect or CLI
270
+ def initialize
271
+ @path_to_ascp=nil
272
+ @sdk_folder=nil
273
+ @found_products=nil
274
+ end
275
+
276
+ # @return folder paths for specified applications
277
+ # @param name Connect or CLI
219
278
  def get_product_folders(name)
220
279
  found=installed_products.select{|i|i[:expected].eql?(name) or i[:name].eql?(name)}
221
280
  raise "Product: #{name} not found, please install." if found.empty?
222
281
  return found.first
223
282
  end
224
283
 
225
- def initialize
226
- @ascp_path=nil
227
- @folder='.'
228
- @found_products=nil
284
+ # @return the path to folder where SDK is installed
285
+ def folder_path
286
+ raise "Undefined path to SDK" if @sdk_folder.nil?
287
+ FileUtils.mkdir_p(@sdk_folder) unless Dir.exist?(@sdk_folder)
288
+ @sdk_folder
229
289
  end
230
290
 
231
- # returns product folders depending on OS
232
- # fields
291
+ # @return product folders depending on OS fields
233
292
  # :expected M app name is taken from the manifest if present, else defaults to this value
234
293
  # :app_root M main folder for the application
235
294
  # :log_root O location of log files (Linux uses syslog)
@@ -271,7 +330,7 @@ module Aspera
271
330
  :log_root =>File.join(Dir.home,'Library','Logs','Aspera_Drive'),
272
331
  :sub_bin =>File.join('Contents','Resources'),
273
332
  }]
274
- else; return [{ # other: Linux and unix family
333
+ else; return [{ # other: Linux and Unix family
275
334
  :expected =>PRODUCT_CONNECT,
276
335
  :app_root =>File.join(Dir.home,'.aspera','connect'),
277
336
  :run_root =>File.join(Dir.home,'.aspera','connect')
@@ -285,6 +344,9 @@ module Aspera
285
344
  end
286
345
  end
287
346
 
347
+ # @return a standard bypass key
348
+ # @param type rsa or dsa
349
+ # @param id in repository 1 for dsa, 2 for rsa
288
350
  def get_key(type,id)
289
351
  hf=['begin','end'].map{|t|"-----#{t} #{type} private key-----".upcase}
290
352
  bin=Base64.strict_encode64(DataRepository.instance.get_bin(id))
@@ -17,17 +17,33 @@ require 'securerandom'
17
17
 
18
18
  module Aspera
19
19
  module Fasp
20
- # default transfer username for access key based transfers
20
+ # (public) default transfer username for access key based transfers
21
21
  ACCESS_KEY_TRANSFER_USER='xfer'
22
22
  # executes a local "ascp", connects mgt port, equivalent of "Fasp Manager"
23
23
  class Local < Manager
24
- # set to false to keep ascp progress bar display (basically: removes ascp's option -q)
24
+ # options for initialize
25
+ DEFAULT_OPTIONS = {
26
+ :spawn_timeout_sec => 3,
27
+ :spawn_delay_sec => 2,
28
+ :wss => false,
29
+ :resume => {}
30
+ }
31
+ DEFAULT_UDP_PORT=33001
32
+ private_constant :DEFAULT_OPTIONS
33
+ # set to false to keep ascp progress bar display ("true" adds ascp's option -q)
25
34
  attr_accessor :quiet
26
- # start FASP transfer based on transfer spec (hash table)
27
- # note that it is asynchronous
35
+
36
+ # start ascp transfer (non blocking), single or multi-session
37
+ # job information added to @jobs
38
+ # @param transfer_spec [Hash] aspera transfer specification
39
+ # @param options [Hash] :resumer, :regenerate_token
28
40
  def start_transfer(transfer_spec,options={})
29
- raise "option: must be hash (or nil)" unless options.is_a?(Hash)
30
- job_id=options[:job_id] || SecureRandom.uuid
41
+ raise 'option: must be hash (or nil)' unless options.is_a?(Hash)
42
+ job_options = options.clone
43
+ job_options[:resumer] ||= @resume_policy
44
+ job_options[:job_id] ||= SecureRandom.uuid
45
+ # clone transfer spec because we modify it (first level keys)
46
+ transfer_spec=transfer_spec.clone
31
47
  # if there is aspera tags
32
48
  if transfer_spec['tags'].is_a?(Hash) and transfer_spec['tags']['aspera'].is_a?(Hash)
33
49
  # TODO: what is this for ? only on local ascp ?
@@ -39,6 +55,7 @@ module Aspera
39
55
  transfer_spec['tags']['aspera']['xfer_retry']||=3600
40
56
  end
41
57
  Log.dump('ts',transfer_spec)
58
+
42
59
  # add bypass keys when authentication is token and no auth is provided
43
60
  if transfer_spec.has_key?('token') and
44
61
  !transfer_spec.has_key?('remote_password') and
@@ -47,8 +64,28 @@ module Aspera
47
64
  transfer_spec['EX_ssh_key_paths'] = Installation.instance.bypass_keys
48
65
  end
49
66
 
67
+ # TODO: check if changing fasp(UDP) port is really necessary, not clear from doc
68
+ # compute this before using transfer spec, even if the var is not used in single session
69
+ multi_session_udp_port_base=DEFAULT_UDP_PORT
70
+ multi_session_number=0
71
+ if transfer_spec.has_key?('multi_session')
72
+ multi_session_number=transfer_spec['multi_session'].to_i
73
+ if multi_session_number < 0
74
+ Log.log.error("multi_session(#{transfer_spec['multi_session']}) shall be integer >= 0")
75
+ multi_session_number = 0
76
+ end
77
+ if multi_session_number > 0
78
+ # managed here, so delete from transfer spec
79
+ transfer_spec.delete('multi_session')
80
+ if transfer_spec.has_key?('fasp_port')
81
+ multi_session_udp_port_base=transfer_spec['fasp_port']
82
+ transfer_spec.delete('fasp_port')
83
+ end
84
+ end
85
+ end
86
+
50
87
  # compute known args
51
- env_args=Parameters.ts_to_env_args(transfer_spec,wss: @enable_wss)
88
+ env_args=Parameters.ts_to_env_args(transfer_spec,wss: @options[:wss])
52
89
 
53
90
  # add fallback cert and key as arguments if needed
54
91
  if ['1','force'].include?(transfer_spec['http_fallback'])
@@ -60,118 +97,98 @@ module Aspera
60
97
 
61
98
  # transfer job can be multi session
62
99
  xfer_job={
63
- :id => job_id,
64
- :sessions => []
100
+ :id => job_options[:job_id],
101
+ :sessions => [] # all sessions as below
65
102
  }
66
103
 
67
104
  # generic session information
68
105
  session={
69
- :state => :initial, # :initial, :started, :success, :failed
70
- :env_args => env_args,
71
- :resumer => options['resume_policy'] || @resume_policy,
72
- :options => options
106
+ :thread => nil, # Thread object monitoring management port, not nil when pushed to :sessions
107
+ :error => nil, # exception if failed
108
+ :io => nil, # management port server socket
109
+ :id => nil, # SessionId from INIT message in mgt port
110
+ :env_args => env_args, # env vars and args to ascp (from transfer spec)
111
+ :options => job_options # [Hash]
73
112
  }
74
113
 
75
- Log.log.debug("starting session thread(s)")
76
- if !transfer_spec.has_key?('multi_session')
114
+ if multi_session_number <= 1
115
+ Log.log.debug('Starting single session thread')
77
116
  # single session for transfer : simple
78
117
  session[:thread] = Thread.new(session) {|s|transfer_thread_entry(s)}
79
118
  xfer_job[:sessions].push(session)
80
119
  else
81
- # default value overriden by fasp_port
82
- multi_session_udp_port_base=33001
83
- multi_session=transfer_spec['multi_session'].to_i
84
- raise "multi_session(#{transfer_spec['multi_session']}) shall be integer > 1" unless multi_session >= 1
85
- # managed here, so delete from transfer spec
86
- transfer_spec.delete('multi_session')
87
- # TODO: check if changing fasp(UDP) port is really necessary, not clear from doc
88
- if transfer_spec.has_key?('fasp_port')
89
- multi_session_udp_port_base=transfer_spec['fasp_port']
90
- transfer_spec.delete('fasp_port')
91
- end
92
- 1.upto(multi_session) do |i|
120
+ Log.log.debug('Starting multi session threads')
121
+ 1.upto(multi_session_number) do |i|
122
+ sleep(@options[:spawn_delay_sec]) unless i.eql?(1)
93
123
  # do deep copy (each thread has its own copy because it is modified here below and in thread)
94
124
  this_session=session.clone()
95
125
  this_session[:env_args]=this_session[:env_args].clone()
96
126
  this_session[:env_args][:args]=this_session[:env_args][:args].clone()
97
- this_session[:env_args][:args].unshift("-C#{i}:#{multi_session}")
127
+ this_session[:env_args][:args].unshift("-C#{i}:#{multi_session_number}")
98
128
  # necessary only if server is not linux, i.e. server does not support port re-use
99
- this_session[:env_args][:args].unshift("-O","#{multi_session_udp_port_base+i-1}")
129
+ this_session[:env_args][:args].unshift('-O',"#{multi_session_udp_port_base+i-1}")
100
130
  this_session[:thread] = Thread.new(this_session) {|s|transfer_thread_entry(s)}
101
131
  xfer_job[:sessions].push(this_session)
102
132
  end
103
133
  end
104
- Log.log.debug("started session thread(s)")
134
+ Log.log.debug('started session thread(s)')
105
135
 
106
136
  # add job to list of jobs
107
- @jobs[job_id]=xfer_job
108
-
137
+ @jobs[job_options[:job_id]]=xfer_job
109
138
  Log.log.debug("jobs: #{@jobs.keys.count}")
110
- return job_id
139
+
140
+ return job_options[:job_id]
111
141
  end # start_transfer
112
142
 
113
143
  # wait for completion of all jobs started
114
144
  # @return list of :success or error message
115
145
  def wait_for_transfers_completion
116
- Log.log.debug("wait_for_sessions: #{@jobs.values.inject(0){|m,j|m+j[:sessions].count}}")
117
- @mutex.synchronize do
118
- loop do
119
- running=0
120
- result=[]
121
- @jobs.each do |id,job|
122
- job[:sessions].each do |session|
123
- case session[:state]
124
- when :failed; result.push(session[:error])
125
- when :success; result.push(:success)
126
- else running+=1
127
- end
128
- end
129
- end
130
- if running.eql?(0)
131
- # since all are finished and we return the result, clear statuses
132
- @jobs.clear
133
- return result
134
- end
135
- Log.log.debug("wait for completed: running: #{running}")
136
- # wait for session termination
137
- @cond_var.wait(@mutex)
138
- end # loop
139
- end # mutex
140
- # never reach here
141
- raise "internal error"
146
+ Log.log.debug('wait_for_transfers_completion')
147
+ # set to non-nil to exit loop
148
+ result=[]
149
+ @jobs.each do |id,job|
150
+ job[:sessions].each do |session|
151
+ Log.log.debug("join #{session[:thread]}")
152
+ session[:thread].join
153
+ result.push(session[:error] ? session[:error] : :success)
154
+ end
155
+ end
156
+ Log.log.debug('all transfers joined')
157
+ # since all are finished and we return the result, clear statuses
158
+ @jobs.clear
159
+ return result
142
160
  end
143
161
 
144
- # terminates monitor thread
162
+ # used by asession (to be removed ?)
145
163
  def shutdown
146
- Log.log.debug("fasp local shutdown")
147
- Log.log.debug("send signal to monitor")
148
- # tell monitor to stop
149
- @mutex.synchronize do
150
- @monitor_stop=true
151
- @cond_var.broadcast
152
- end
153
- # wait for thread termination
154
- @monitor_thread.join
155
- @monitor_thread=nil
156
- Log.log.debug("joined monitor")
164
+ Log.log.debug('fasp local shutdown')
157
165
  end
158
166
 
159
- # This is the low level method to start FASP
167
+ # This is the low level method to start the "ascp" process
160
168
  # currently, relies on command line arguments
161
169
  # start ascp with management port.
162
170
  # raises FaspError on error
163
171
  # if there is a thread info: set and broadcast session id
164
172
  # @param env_args a hash containing :args :env :ascp_version
165
- # cloud be private method
166
- def start_transfer_with_args_env(env_args,session=nil)
173
+ # @param session this session information
174
+ # could be private method
175
+ def start_transfer_with_args_env(env_args,session)
176
+ raise 'env_args must be Hash' unless env_args.is_a?(Hash)
177
+ raise 'session must be Hash' unless session.is_a?(Hash)
178
+ # by default we assume an exception will be raised (for ensure block)
179
+ exception_raised=true
167
180
  begin
168
181
  Log.log.debug("env_args=#{env_args.inspect}")
169
- ascp_path=Fasp::Installation.instance.path(env_args[:ascp_version])
182
+ # get location of ascp executable
183
+ ascp_path=@mutex.synchronize do
184
+ Fasp::Installation.instance.path(env_args[:ascp_version])
185
+ end
186
+ # (optional) check it exists
170
187
  raise Fasp::Error.new("no such file: #{ascp_path}") unless File.exist?(ascp_path)
171
- ascp_pid=nil
188
+ # open random local TCP port for listening for ascp management
189
+ mgt_sock = TCPServer.new('127.0.0.1',0)
190
+ # clone arguments as we eed to modify with mgt port
172
191
  ascp_arguments=env_args[:args].clone
173
- # open random local TCP port listening
174
- mgt_sock = TCPServer.new('127.0.0.1',0 )
175
192
  # add management port
176
193
  ascp_arguments.unshift('-M', mgt_sock.addr[1].to_s)
177
194
  # start ascp in sub process
@@ -180,8 +197,9 @@ module Aspera
180
197
  ascp_pid = Process.spawn(env_args[:env],[ascp_path,ascp_path],*ascp_arguments)
181
198
  # in parent, wait for connection to socket max 3 seconds
182
199
  Log.log.debug("before accept for pid (#{ascp_pid})")
200
+ # init management socket
183
201
  ascp_mgt_io=nil
184
- Timeout.timeout( 3 ) do
202
+ Timeout.timeout(@options[:spawn_timeout_sec]) do
185
203
  ascp_mgt_io = mgt_sock.accept
186
204
  # management messages include file names which may be utf8
187
205
  # by default socket is US-ASCII
@@ -189,21 +207,13 @@ module Aspera
189
207
  ascp_mgt_io.set_encoding(Encoding::UTF_8)
190
208
  end
191
209
  Log.log.debug("after accept (#{ascp_mgt_io})")
192
-
193
- unless session.nil?
194
- @mutex.synchronize do
195
- session[:io]=ascp_mgt_io
196
- @cond_var.broadcast
197
- end
198
- end
210
+ session[:io]=ascp_mgt_io
199
211
  # exact text for event, with \n
200
212
  current_event_text=''
201
213
  # parsed event (hash)
202
214
  current_event_data=nil
203
-
204
215
  # this is the last full status
205
216
  last_status_event=nil
206
-
207
217
  # read management port
208
218
  loop do
209
219
  # TODO: timeout here ?
@@ -222,45 +232,44 @@ module Aspera
222
232
  # event field
223
233
  current_event_data[$1] = $2
224
234
  when ''
225
- # end event
226
- raise "unexpected empty line" if current_event_data.nil?
235
+ # empty line is separator to end event information
236
+ raise 'unexpected empty line' if current_event_data.nil?
227
237
  current_event_data[Manager::LISTENER_SESSION_ID_B]=ascp_pid
228
238
  notify_listeners(current_event_text,current_event_data)
229
- # TODO: check if this is always the last event
230
239
  case current_event_data['Type']
240
+ when 'INIT'
241
+ session[:id]=current_event_data['SessionId']
242
+ Log.log.debug("session id: #{session[:id]}")
231
243
  when 'DONE','ERROR'
244
+ # TODO: check if this is always the last event
232
245
  last_status_event = current_event_data
233
- when 'INIT'
234
- unless session.nil?
235
- @mutex.synchronize do
236
- session[:state]=:started
237
- session[:id]=current_event_data['SessionId']
238
- Log.log.debug("session id: #{session[:id]}")
239
- @cond_var.broadcast
240
- end
241
- end
242
246
  end # event type
243
247
  else
244
248
  raise "unexpected line:[#{line}]"
245
249
  end # case
246
- end # loop
250
+ end # loop (process mgt port lines)
247
251
  # check that last status was received before process exit
248
- raise "INTERNAL: nil last status" if last_status_event.nil?
249
- case last_status_event['Type']
250
- when 'DONE'
251
- return
252
- when 'ERROR'
253
- Log.log.error("code: #{last_status_event['Code']}")
254
- if last_status_event['Description'] =~ /bearer token/i
255
- Log.log.error("need to regenerate token".red)
256
- if !session.nil? and session[:options].is_a?(Hash) and session[:options].has_key?(:regenerate_token)
257
- # regenerate token here, expired, or error on it
258
- env_args[:env]['ASPERA_SCP_TOKEN']=session[:options][:regenerate_token].call(true)
252
+ if last_status_event.is_a?(Hash)
253
+ case last_status_event['Type']
254
+ when 'DONE'
255
+ # all went well
256
+ exception_raised=false
257
+ when 'ERROR'
258
+ Log.log.error("code: #{last_status_event['Code']}")
259
+ if last_status_event['Description'] =~ /bearer token/i
260
+ Log.log.error('need to regenerate token'.red)
261
+ if session[:options].is_a?(Hash) and session[:options].has_key?(:regenerate_token)
262
+ # regenerate token here, expired, or error on it
263
+ env_args[:env]['ASPERA_SCP_TOKEN']=session[:options][:regenerate_token].call(true)
264
+ end
259
265
  end
266
+ raise Fasp::Error.new(last_status_event['Description'],last_status_event['Code'].to_i)
267
+ else # case
268
+ raise "unexpected last event type: #{last_status_event['Type']}"
260
269
  end
261
- raise Fasp::Error.new(last_status_event['Description'],last_status_event['Code'].to_i)
262
270
  else
263
- raise "INTERNAL ERROR: unexpected last event"
271
+ exception_raised=false
272
+ Log.log.debug('no status read from ascp mgt port')
264
273
  end
265
274
  rescue SystemCallError => e
266
275
  # Process.spawn
@@ -270,107 +279,94 @@ module Aspera
270
279
  rescue Interrupt => e
271
280
  raise Fasp::Error.new('transfer interrupted by user')
272
281
  ensure
273
- # ensure there is no ascp left running
282
+ # if ascp was successfully started
274
283
  unless ascp_pid.nil?
275
- begin
276
- Process.kill('INT',ascp_pid)
277
- rescue
278
- end
279
- # avoid zombie
284
+ # "wait" for process to avoid zombie
280
285
  Process.wait(ascp_pid)
286
+ status=$?
281
287
  ascp_pid=nil
282
288
  session.delete(:io)
289
+ if !status.success?
290
+ message="ascp failed with code #{status.exitstatus}"
291
+ if exception_raised
292
+ # just debug, as main exception is already here
293
+ Log.log.debug(message)
294
+ else
295
+ raise Fasp::Error.new(message)
296
+ end
297
+ end
283
298
  end
284
299
  end # begin-ensure
285
300
  end # start_transfer_with_args_env
286
301
 
287
- # send command on mgt port, examples:
302
+ # send command of management port to ascp session
303
+ # @param job_id identified transfer process
304
+ # @param session_index index of session (for multi session)
305
+ # @param data command on mgt port, examples:
288
306
  # {'type'=>'START','source'=>_path_,'destination'=>_path_}
289
307
  # {'type'=>'DONE'}
290
308
  def send_command(job_id,session_index,data)
291
- @mutex.synchronize do
292
- job=@jobs[job_id]
293
- raise "no such job" if job.nil?
294
- session=job[:sessions][session_index]
295
- raise "no such session" if session.nil?
296
- Log.log.debug("command: #{data}")
297
- command=data.
298
- keys.
299
- map{|k|"#{k.capitalize}: #{data[k]}"}.
300
- unshift('FASPMGR 2').
301
- push('','').
302
- join("\n")
303
- session[:io].puts(command)
304
- end
309
+ job=@jobs[job_id]
310
+ raise 'no such job' if job.nil?
311
+ session=job[:sessions][session_index]
312
+ raise 'no such session' if session.nil?
313
+ Log.log.debug("command: #{data}")
314
+ # build command
315
+ command=data.
316
+ keys.
317
+ map{|k|"#{k.capitalize}: #{data[k]}"}.
318
+ unshift('FASPMGR 2').
319
+ push('','').
320
+ join("\n")
321
+ session[:io].puts(command)
305
322
  end
306
323
 
307
324
  private
308
325
 
309
- def initialize(agent_options=nil)
310
- agent_options||={}
326
+ # @param options : keys(symbol): wss, resume
327
+ def initialize(options=nil)
311
328
  super()
312
329
  # by default no interactive progress bar
313
330
  @quiet=true
314
- # shared data between transfer threads and others: protected by mutex, CV on change
331
+ # all transfer jobs, key = SecureRandom.uuid, protected by mutex, condvar on change
315
332
  @jobs={}
316
- # mutex protects jobs data
333
+ # mutex protects global data accessed by threads
317
334
  @mutex=Mutex.new
318
- # cond var is waited or broadcast on jobs data change
319
- @cond_var=ConditionVariable.new
320
- # must be set before starting monitor, set to false to stop thread. also shared and protected by mutex
321
- @monitor_stop=false
322
- @monitor_thread=Thread.new{monitor_thread_entry}
323
- @resume_policy=ResumePolicy.new(agent_options)
324
- @enable_wss = agent_options[:wss] || false
335
+ # manage options
336
+ @options=DEFAULT_OPTIONS.clone
337
+ if !options.nil?
338
+ raise "expecting Hash (or nil), but have #{options.class}" unless options.is_a?(Hash)
339
+ options.each do |k,v|
340
+ if DEFAULT_OPTIONS.has_key?(k)
341
+ @options[k]=v
342
+ else
343
+ raise "unknown local agent parameter: #{k}, expect one of #{DEFAULT_OPTIONS.keys.map{|i|i.to_s}.join(",")}"
344
+ end
345
+ end
346
+ end
347
+ Log.log.debug("local options= #{options}")
348
+ @resume_policy=ResumePolicy.new(@options[:resume].symbolize_keys)
325
349
  end
326
350
 
327
351
  # transfer thread entry
328
- # implements resumable transfer
329
- # TODO: extract resume algorithm in a specific object
352
+ # @param session information
330
353
  def transfer_thread_entry(session)
331
354
  begin
332
355
  # set name for logging
333
- Thread.current[:name]="transfer"
334
- # update state once in thread
335
- session[:state]=:started
356
+ Thread.current[:name]='transfer'
336
357
  Log.log.debug("ENTER (#{Thread.current[:name]})")
337
358
  # start transfer with selected resumer policy
338
- session[:resumer].process do
359
+ session[:options][:resumer].process do
339
360
  start_transfer_with_args_env(session[:env_args],session)
340
361
  end
341
362
  Log.log.debug('transfer ok'.bg_green)
342
- session[:state]=:success
343
363
  rescue => e
344
- session[:state]=:failed
345
364
  session[:error]=e
346
- Log.log.error("#{e.class}:\n#{e.message}:\n#{e.backtrace.join("\n")}".red) if Log.instance.level.eql?(:debug)
347
- ensure
348
- @mutex.synchronize do
349
- # ensure id is set to unblock start procedure
350
- session[:id]||=nil
351
- @cond_var.broadcast
352
- end
365
+ Log.log.error("Transfer thread error: #{e.class}:\n#{e.message}:\n#{e.backtrace.join("\n")}".red) if Log.instance.level.eql?(:debug)
353
366
  end
354
367
  Log.log.debug("EXIT (#{Thread.current[:name]})")
355
368
  end
356
369
 
357
- # main thread method for monitor
358
- # currently: just joins started threads
359
- def monitor_thread_entry
360
- Thread.current[:name]="monitor"
361
- @mutex.synchronize do
362
- until @monitor_stop do
363
- # wait for session termination
364
- @cond_var.wait(@mutex)
365
- @jobs.values do |job|
366
- job[:sessions].each do |session|
367
- session[:thread].join if [:success,:failed].include?(session[:state])
368
- end # sessions
369
- end # jobs
370
- end # monitor run
371
- end # sync
372
- Log.log.debug("EXIT (#{Thread.current[:name]})")
373
- end # monitor_thread_entry
374
370
  end # Local
375
371
  end
376
372
  end