aspera-cli 4.17.0 → 4.18.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +2 -4
- data/CHANGELOG.md +23 -0
- data/CONTRIBUTING.md +15 -1
- data/README.md +620 -378
- data/bin/ascli +5 -0
- data/bin/asession +2 -2
- data/lib/aspera/agent/alpha.rb +6 -4
- data/lib/aspera/agent/base.rb +9 -6
- data/lib/aspera/agent/connect.rb +4 -4
- data/lib/aspera/agent/direct.rb +56 -37
- data/lib/aspera/agent/httpgw.rb +23 -324
- data/lib/aspera/agent/node.rb +19 -20
- data/lib/aspera/agent/trsdk.rb +19 -20
- data/lib/aspera/api/aoc.rb +17 -14
- data/lib/aspera/api/cos_node.rb +4 -4
- data/lib/aspera/api/httpgw.rb +339 -0
- data/lib/aspera/api/node.rb +34 -21
- data/lib/aspera/ascmd.rb +4 -3
- data/lib/aspera/ascp/installation.rb +15 -7
- data/lib/aspera/ascp/management.rb +2 -2
- data/lib/aspera/cli/basic_auth_plugin.rb +5 -9
- data/lib/aspera/cli/extended_value.rb +12 -6
- data/lib/aspera/cli/formatter.rb +155 -65
- data/lib/aspera/cli/hints.rb +18 -0
- data/lib/aspera/cli/main.rb +22 -29
- data/lib/aspera/cli/manager.rb +53 -36
- data/lib/aspera/cli/plugin.rb +26 -17
- data/lib/aspera/cli/plugin_factory.rb +31 -20
- data/lib/aspera/cli/plugins/alee.rb +14 -2
- data/lib/aspera/cli/plugins/aoc.rb +141 -131
- data/lib/aspera/cli/plugins/ats.rb +1 -1
- data/lib/aspera/cli/plugins/config.rb +52 -46
- data/lib/aspera/cli/plugins/console.rb +8 -5
- data/lib/aspera/cli/plugins/faspex.rb +27 -19
- data/lib/aspera/cli/plugins/faspex5.rb +222 -149
- data/lib/aspera/cli/plugins/faspio.rb +85 -0
- data/lib/aspera/cli/plugins/httpgw.rb +55 -0
- data/lib/aspera/cli/plugins/node.rb +86 -29
- data/lib/aspera/cli/plugins/orchestrator.rb +31 -29
- data/lib/aspera/cli/plugins/preview.rb +6 -2
- data/lib/aspera/cli/plugins/server.rb +5 -5
- data/lib/aspera/cli/plugins/shares.rb +16 -14
- data/lib/aspera/cli/sync_actions.rb +6 -6
- data/lib/aspera/cli/transfer_agent.rb +5 -4
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/environment.rb +7 -6
- data/lib/aspera/faspex_gw.rb +5 -4
- data/lib/aspera/faspex_postproc.rb +2 -2
- data/lib/aspera/log.rb +6 -3
- data/lib/aspera/node_simulator.rb +2 -2
- data/lib/aspera/oauth/base.rb +31 -19
- data/lib/aspera/oauth/factory.rb +12 -13
- data/lib/aspera/oauth/generic.rb +1 -0
- data/lib/aspera/oauth/jwt.rb +18 -15
- data/lib/aspera/oauth/url_json.rb +8 -6
- data/lib/aspera/open_application.rb +5 -7
- data/lib/aspera/persistency_folder.rb +2 -2
- data/lib/aspera/preview/generator.rb +3 -3
- data/lib/aspera/preview/options.rb +3 -3
- data/lib/aspera/preview/terminal.rb +4 -4
- data/lib/aspera/preview/utils.rb +3 -3
- data/lib/aspera/proxy_auto_config.rb +5 -1
- data/lib/aspera/rest.rb +60 -74
- data/lib/aspera/rest_call_error.rb +1 -1
- data/lib/aspera/rest_error_analyzer.rb +2 -2
- data/lib/aspera/rest_errors_aspera.rb +1 -1
- data/lib/aspera/resumer.rb +1 -1
- data/lib/aspera/secret_hider.rb +2 -4
- data/lib/aspera/ssh.rb +1 -1
- data/lib/aspera/transfer/parameters.rb +39 -36
- data/lib/aspera/transfer/spec.rb +2 -0
- data/lib/aspera/transfer/sync.rb +2 -1
- data/lib/aspera/transfer/uri.rb +1 -1
- data/lib/aspera/uri_reader.rb +5 -4
- data/lib/aspera/web_auth.rb +1 -1
- data/lib/aspera/web_server_simple.rb +4 -3
- data.tar.gz.sig +0 -0
- metadata +5 -3
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/plugins/bss.rb +0 -71
@@ -0,0 +1,339 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'aspera/log'
|
4
|
+
require 'aspera/rest'
|
5
|
+
require 'aspera/transfer/faux_file'
|
6
|
+
require 'aspera/assert'
|
7
|
+
require 'securerandom'
|
8
|
+
require 'websocket'
|
9
|
+
require 'base64'
|
10
|
+
require 'json'
|
11
|
+
|
12
|
+
module Aspera
|
13
|
+
module Api
|
14
|
+
# Start a transfer using Aspera HTTP Gateway, using web socket secure for uploads
|
15
|
+
# ref: https://api.ibm.com/explorer/catalog/aspera/product/ibm-aspera/api/http-gateway-api/doc/guides-toc
|
16
|
+
# https://developer.ibm.com/apis/catalog?search=%22aspera%20http%22
|
17
|
+
# HTTP GW Upload protocol:
|
18
|
+
# # type Contents Ack Counter
|
19
|
+
# v1
|
20
|
+
# 0 JSON.transfer_spec Transfer Spec "end upload" sent_general
|
21
|
+
# 1.. JSON.slice_upload File base64 chunks "end upload" sent_general
|
22
|
+
# v2
|
23
|
+
# 0 JSON.transfer_spec Transfer Spec "end upload" sent_general
|
24
|
+
# 1 JSON.slice_upload File start "end_slice_upload" sent_v2_delimiter
|
25
|
+
# 2.. Binary File binary chunks "end upload" sent_general
|
26
|
+
# last JSON.slice_upload File end "end_slice_upload" sent_v2_delimiter
|
27
|
+
class Httpgw < Aspera::Rest
|
28
|
+
DEFAULT_BASE_PATH = '/aspera/http-gwy'
|
29
|
+
INFO_ENDPOINT = 'info'
|
30
|
+
MSG_SEND_TRANSFER_SPEC = 'transfer_spec'
|
31
|
+
MSG_SEND_SLICE_UPLOAD = 'slice_upload'
|
32
|
+
MSG_RECV_DATA_RECEIVED_SIGNAL = 'end upload'
|
33
|
+
MSG_RECV_SLICE_UPLOAD_SIGNAL = 'end_slice_upload'
|
34
|
+
# upload API versions
|
35
|
+
API_V1 = 'v1'
|
36
|
+
API_V2 = 'v2'
|
37
|
+
THR_RECV = 'recv'
|
38
|
+
LOG_WS_SEND = 'ws: send: '.red
|
39
|
+
LOG_WS_RECV = "ws: #{THR_RECV}: ".green
|
40
|
+
private_constant :MSG_RECV_DATA_RECEIVED_SIGNAL, :MSG_RECV_SLICE_UPLOAD_SIGNAL
|
41
|
+
# send message on http gw web socket
|
42
|
+
def ws_snd_json(msg_type, payload)
|
43
|
+
if msg_type.eql?(MSG_SEND_SLICE_UPLOAD) && @upload_version.eql?(API_V2)
|
44
|
+
@shared_info[:count][:sent_v2_delimiter] += 1
|
45
|
+
else
|
46
|
+
@shared_info[:count][:sent_general] += 1
|
47
|
+
end
|
48
|
+
Log.log.debug do
|
49
|
+
log_data = payload.dup
|
50
|
+
log_data[:data] = "[data #{log_data[:data].length} bytes]" if log_data.key?(:data)
|
51
|
+
"#{LOG_WS_SEND}json: #{msg_type}: #{JSON.generate(log_data)}"
|
52
|
+
end
|
53
|
+
ws_send(ws_type: :text, data: JSON.generate({msg_type => payload}))
|
54
|
+
end
|
55
|
+
|
56
|
+
# send data on http gw web socket
|
57
|
+
def ws_send(ws_type:, data:)
|
58
|
+
Log.log.debug{"#{LOG_WS_SEND}sending: #{ws_type} (#{data&.length || 0} bytes)"}
|
59
|
+
@shared_info[:count][:sent_general] += 1 if ws_type.eql?(:binary)
|
60
|
+
frame_generator = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: ws_type, version: @ws_handshake.version)
|
61
|
+
@ws_io.write(frame_generator.to_s)
|
62
|
+
if @synchronous
|
63
|
+
@shared_info[:mutex].synchronize do
|
64
|
+
# if read thread exited, there will be no more updates
|
65
|
+
# we allow for 1 of difference else it stays blocked
|
66
|
+
while @ws_read_thread.alive? &&
|
67
|
+
@shared_info[:read_exception].nil? &&
|
68
|
+
(((@shared_info[:count][:sent_general] - @shared_info[:count][:received_general]) > 1) ||
|
69
|
+
((@shared_info[:count][:received_v2_delimiter] - @shared_info[:count][:sent_v2_delimiter]) > 1))
|
70
|
+
if !@shared_info[:cond_var].wait(@shared_info[:mutex], 2.0)
|
71
|
+
Log.log.debug{"#{LOG_WS_SEND}#{'timeout'.blue}: #{@shared_info[:count]}"}
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
raise @shared_info[:read_exception] unless @shared_info[:read_exception].nil?
|
77
|
+
Log.log.debug{"#{LOG_WS_SEND}counts: #{@shared_info[:count]}"}
|
78
|
+
end
|
79
|
+
|
80
|
+
# message processing for read thread
|
81
|
+
def process_received_message(message)
|
82
|
+
Log.log.debug{"#{LOG_WS_RECV}message: [#{message}] (#{message.class})"}
|
83
|
+
if message.eql?(MSG_RECV_DATA_RECEIVED_SIGNAL)
|
84
|
+
@shared_info[:mutex].synchronize do
|
85
|
+
@shared_info[:count][:received_general] += 1
|
86
|
+
@shared_info[:cond_var].signal
|
87
|
+
end
|
88
|
+
elsif message.eql?(MSG_RECV_SLICE_UPLOAD_SIGNAL)
|
89
|
+
@shared_info[:mutex].synchronize do
|
90
|
+
@shared_info[:count][:received_v2_delimiter] += 1
|
91
|
+
@shared_info[:cond_var].signal
|
92
|
+
end
|
93
|
+
else
|
94
|
+
message.chomp!
|
95
|
+
error_message =
|
96
|
+
if message.start_with?('"') && message.end_with?('"')
|
97
|
+
# remove double quotes : 1..-2
|
98
|
+
JSON.parse(Base64.strict_decode64(message.chomp[1..-2]))['message']
|
99
|
+
elsif message.start_with?('{') && message.end_with?('}')
|
100
|
+
JSON.parse(message)['message']
|
101
|
+
else
|
102
|
+
"unknown message from gateway: [#{message}]"
|
103
|
+
end
|
104
|
+
raise error_message
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# main function of read thread
|
109
|
+
def process_read_thread
|
110
|
+
Log.log.debug{"#{LOG_WS_RECV}read thread started"}
|
111
|
+
frame_parser = ::WebSocket::Frame::Incoming::Client.new(version: @ws_handshake.version)
|
112
|
+
until @ws_io.eof?
|
113
|
+
begin # rubocop:disable Style/RedundantBegin
|
114
|
+
# ready byte by byte until frame is ready
|
115
|
+
# blocking read
|
116
|
+
byte = @ws_io.read(1)
|
117
|
+
Log.log.trace1{"#{LOG_WS_RECV}read: #{byte} (#{byte.class}) eof=#{@ws_io.eof?}"}
|
118
|
+
frame_parser << byte
|
119
|
+
frame_ok = frame_parser.next
|
120
|
+
next if frame_ok.nil?
|
121
|
+
process_received_message(frame_ok.data.to_s)
|
122
|
+
Log.log.debug{"#{LOG_WS_RECV}counts: #{@shared_info[:count]}"}
|
123
|
+
rescue => e
|
124
|
+
Log.log.debug{"#{LOG_WS_RECV}Exception: #{e}"}
|
125
|
+
@shared_info[:mutex].synchronize do
|
126
|
+
@shared_info[:read_exception] = e
|
127
|
+
@shared_info[:cond_var].signal
|
128
|
+
end
|
129
|
+
break
|
130
|
+
end
|
131
|
+
end
|
132
|
+
Log.log.debug do
|
133
|
+
"#{LOG_WS_RECV}exception: #{@shared_info[:read_exception]},cls=#{@shared_info[:read_exception].class})"
|
134
|
+
end unless @shared_info[:read_exception].nil?
|
135
|
+
Log.log.debug{"#{LOG_WS_RECV}read thread stopped (ws eof=#{@ws_io.eof?})"}
|
136
|
+
end
|
137
|
+
|
138
|
+
def upload(transfer_spec)
|
139
|
+
# identify this session uniquely
|
140
|
+
session_id = SecureRandom.uuid
|
141
|
+
@notify_cb&.call(session_id: nil, type: :pre_start, info: 'starting')
|
142
|
+
# total size of all files
|
143
|
+
total_bytes_to_transfer = 0
|
144
|
+
# we need to keep track of actual file path because transfer spec is modified to be sent in web socket
|
145
|
+
files_to_read = []
|
146
|
+
# get source root or nil
|
147
|
+
source_root = transfer_spec.key?('source_root') && !transfer_spec['source_root'].empty? ? transfer_spec['source_root'] : nil
|
148
|
+
# source root is ignored by GW, used only here
|
149
|
+
transfer_spec.delete('source_root')
|
150
|
+
# compute total size of files to upload (for progress)
|
151
|
+
# modify transfer spec to be suitable for GW
|
152
|
+
transfer_spec['paths'].each do |item|
|
153
|
+
# save actual file location to be able read contents later
|
154
|
+
file_to_add = Transfer::FauxFile.open(item['source'])
|
155
|
+
if file_to_add
|
156
|
+
item['source'] = file_to_add.path
|
157
|
+
item['file_size'] = file_to_add.size
|
158
|
+
else
|
159
|
+
file_to_add = item['source']
|
160
|
+
# add source root if needed
|
161
|
+
file_to_add = File.join(source_root, file_to_add) unless source_root.nil?
|
162
|
+
# GW expects a simple file name in 'source' but if user wants to change the name, we take it
|
163
|
+
item['source'] = File.basename(item['destination'].nil? ? item['source'] : item['destination'])
|
164
|
+
item['file_size'] = File.size(file_to_add)
|
165
|
+
end
|
166
|
+
# save so that we can actually read the file later
|
167
|
+
files_to_read.push(file_to_add)
|
168
|
+
total_bytes_to_transfer += item['file_size']
|
169
|
+
end
|
170
|
+
# TODO: check that this is available in endpoints: @api_info['endpoints']
|
171
|
+
upload_url = File.join(@gw_root_url, @upload_version, 'upload')
|
172
|
+
@notify_cb&.call(session_id: nil, type: :pre_start, info: 'connecting wss')
|
173
|
+
# open web socket to end point (equivalent to Net::HTTP.start)
|
174
|
+
http_session = Rest.start_http_session(upload_url)
|
175
|
+
# get the underlying socket i/o
|
176
|
+
@ws_io = Rest.io_http_session(http_session)
|
177
|
+
@ws_handshake = ::WebSocket::Handshake::Client.new(url: upload_url, headers: {})
|
178
|
+
@ws_io.write(@ws_handshake.to_s)
|
179
|
+
sleep(0.1)
|
180
|
+
@ws_handshake << @ws_io.readuntil("\r\n\r\n")
|
181
|
+
Aspera.assert(@ws_handshake.finished?){'Error in websocket handshake'}
|
182
|
+
Log.log.debug{"#{LOG_WS_SEND}handshake success"}
|
183
|
+
# start read thread after handshake
|
184
|
+
@ws_read_thread = Thread.new {process_read_thread}
|
185
|
+
@notify_cb&.call(session_id: session_id, type: :session_start)
|
186
|
+
@notify_cb&.call(session_id: session_id, type: :session_size, info: total_bytes_to_transfer)
|
187
|
+
sleep(1)
|
188
|
+
# data shared between main thread and read thread
|
189
|
+
@shared_info = {
|
190
|
+
read_exception: nil, # error message if any in callback
|
191
|
+
count: {
|
192
|
+
sent_general: 0,
|
193
|
+
received_general: 0,
|
194
|
+
sent_v2_delimiter: 0,
|
195
|
+
received_v2_delimiter: 0
|
196
|
+
},
|
197
|
+
mutex: Mutex.new,
|
198
|
+
cond_var: ConditionVariable.new
|
199
|
+
}
|
200
|
+
# notify progress bar
|
201
|
+
@notify_cb&.call(type: :session_size, session_id: session_id, info: total_bytes_to_transfer)
|
202
|
+
# first step send transfer spec
|
203
|
+
Log.log.debug{Log.dump(:ws_spec, transfer_spec)}
|
204
|
+
ws_snd_json(MSG_SEND_TRANSFER_SPEC, transfer_spec)
|
205
|
+
# current file index
|
206
|
+
file_index = 0
|
207
|
+
# aggregate size sent
|
208
|
+
session_sent_bytes = 0
|
209
|
+
# process each file
|
210
|
+
transfer_spec['paths'].each do |item|
|
211
|
+
slice_info = {
|
212
|
+
name: nil,
|
213
|
+
# TODO: get mime type?
|
214
|
+
type: 'application/octet-stream',
|
215
|
+
size: item['file_size'],
|
216
|
+
slice: 0, # current slice index
|
217
|
+
# index of last slice (i.e number of slices - 1)
|
218
|
+
last_slice: (item['file_size'] - 1) / @upload_chunk_size,
|
219
|
+
fileIndex: file_index
|
220
|
+
}
|
221
|
+
file = files_to_read[file_index]
|
222
|
+
if file.is_a?(Transfer::FauxFile)
|
223
|
+
slice_info[:name] = file.path
|
224
|
+
else
|
225
|
+
file = File.open(file)
|
226
|
+
slice_info[:name] = File.basename(item[item['destination'].nil? ? 'source' : 'destination'])
|
227
|
+
end
|
228
|
+
begin
|
229
|
+
until file.eof?
|
230
|
+
slice_bin_data = file.read(@upload_chunk_size)
|
231
|
+
# interrupt main thread if read thread failed
|
232
|
+
raise @shared_info[:read_exception] unless @shared_info[:read_exception].nil?
|
233
|
+
begin
|
234
|
+
if @upload_version.eql?(API_V1)
|
235
|
+
slice_info[:data] = Base64.strict_encode64(slice_bin_data)
|
236
|
+
ws_snd_json(MSG_SEND_SLICE_UPLOAD, slice_info)
|
237
|
+
else
|
238
|
+
# send once, before data, at beginning
|
239
|
+
ws_snd_json(MSG_SEND_SLICE_UPLOAD, slice_info) if slice_info[:slice].eql?(0)
|
240
|
+
ws_send(ws_type: :binary, data: slice_bin_data)
|
241
|
+
Log.log.debug{"#{LOG_WS_SEND}buffer: file: #{file_index}, slice: #{slice_info[:slice]}/#{slice_info[:last_slice]}"}
|
242
|
+
# send once, after data, at end
|
243
|
+
ws_snd_json(MSG_SEND_SLICE_UPLOAD, slice_info) if slice_info[:slice].eql?(slice_info[:last_slice])
|
244
|
+
end
|
245
|
+
rescue Errno::EPIPE => e
|
246
|
+
raise @shared_info[:read_exception] unless @shared_info[:read_exception].nil?
|
247
|
+
raise e
|
248
|
+
rescue Net::ReadTimeout => e
|
249
|
+
Log.log.warn{'A timeout condition using HTTPGW may signal a permission problem on destination. Check ascp logs on httpgw.'}
|
250
|
+
raise e
|
251
|
+
end
|
252
|
+
session_sent_bytes += slice_bin_data.length
|
253
|
+
@notify_cb&.call(type: :transfer, session_id: session_id, info: session_sent_bytes)
|
254
|
+
slice_info[:slice] += 1
|
255
|
+
end
|
256
|
+
ensure
|
257
|
+
file.close
|
258
|
+
end
|
259
|
+
file_index += 1
|
260
|
+
end
|
261
|
+
# throttling may have skipped last one
|
262
|
+
@notify_cb&.call(type: :transfer, session_id: session_id, info: session_sent_bytes)
|
263
|
+
@notify_cb&.call(type: :end, session_id: session_id)
|
264
|
+
ws_send(ws_type: :close, data: nil)
|
265
|
+
Log.log.debug("Finished upload, waiting for end of #{THR_RECV} thread.")
|
266
|
+
@ws_read_thread.join
|
267
|
+
Log.log.debug{'Read thread joined'}
|
268
|
+
# session no more used
|
269
|
+
@ws_io = nil
|
270
|
+
http_session&.finish
|
271
|
+
end
|
272
|
+
|
273
|
+
def download(transfer_spec)
|
274
|
+
transfer_spec['zip_required'] ||= false
|
275
|
+
transfer_spec['source_root'] ||= '/'
|
276
|
+
# is normally provided by application, like package name
|
277
|
+
if !transfer_spec.key?('download_name')
|
278
|
+
# by default it is the name of first file
|
279
|
+
download_name = File.basename(transfer_spec['paths'].first['source'], '.*')
|
280
|
+
# ands add indication of number of files if there is more than one
|
281
|
+
if transfer_spec['paths'].length > 1
|
282
|
+
download_name += " #{transfer_spec['paths'].length} Files"
|
283
|
+
end
|
284
|
+
transfer_spec['download_name'] = download_name
|
285
|
+
end
|
286
|
+
creation = create('download', {'transfer_spec' => transfer_spec})[:data]
|
287
|
+
transfer_uuid = creation['url'].split('/').last
|
288
|
+
file_name =
|
289
|
+
if transfer_spec['zip_required'] || transfer_spec['paths'].length > 1
|
290
|
+
# it is a zip file if zip is required or there is more than 1 file
|
291
|
+
transfer_spec['download_name'] + '.zip'
|
292
|
+
else
|
293
|
+
# it is a plain file if we don't require zip and there is only one file
|
294
|
+
File.basename(transfer_spec['paths'].first['source'])
|
295
|
+
end
|
296
|
+
file_path = File.join(transfer_spec['destination_root'], file_name)
|
297
|
+
call(operation: 'GET', subpath: "download/#{transfer_uuid}", save_to_file: file_path)
|
298
|
+
end
|
299
|
+
|
300
|
+
def info
|
301
|
+
return @api_info
|
302
|
+
end
|
303
|
+
|
304
|
+
# @param url [String] URL of the HTTP Gateway, without version
|
305
|
+
def initialize(
|
306
|
+
url:,
|
307
|
+
api_version: API_V2,
|
308
|
+
upload_chunk_size: 64_000,
|
309
|
+
synchronous: false,
|
310
|
+
notify_cb: nil,
|
311
|
+
**opts
|
312
|
+
)
|
313
|
+
# add scheme if missing
|
314
|
+
url = "https://#{url}" unless url.match?(%r{^[a-z]{1,6}://})
|
315
|
+
raise 'GW URL shall be with scheme https' unless url.start_with?('https://')
|
316
|
+
# remove trailing slash and version if any
|
317
|
+
url = url.gsub(%r{/+$}, '').gsub(%r{/#{API_V1}$}o, '')
|
318
|
+
url = File.join(url, DEFAULT_BASE_PATH) unless url.end_with?(DEFAULT_BASE_PATH)
|
319
|
+
@gw_root_url = url
|
320
|
+
super(base_url: "#{@gw_root_url}/#{API_V1}", **opts)
|
321
|
+
@upload_version = api_version
|
322
|
+
@upload_chunk_size = upload_chunk_size
|
323
|
+
@synchronous = synchronous
|
324
|
+
@notify_cb = notify_cb
|
325
|
+
# get API info
|
326
|
+
@api_info = read('info')[:data].freeze
|
327
|
+
Log.log.debug{Log.dump(:api_info, @api_info)}
|
328
|
+
# web socket endpoint: by default use v2 (newer gateways), without base64 encoding
|
329
|
+
# is the latest supported? else revert to old api
|
330
|
+
if !@upload_version.eql?(API_V1)
|
331
|
+
if !@api_info['endpoints'].any?{|i|i.include?(@upload_version)}
|
332
|
+
Log.log.warn{"API version #{@upload_version} not supported, reverting to #{API_V1}"}
|
333
|
+
@upload_version = API_V1
|
334
|
+
end
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
data/lib/aspera/api/node.rb
CHANGED
@@ -14,21 +14,23 @@ module Aspera
|
|
14
14
|
module Api
|
15
15
|
# Provides additional functions using node API with gen4 extensions (access keys)
|
16
16
|
class Node < Aspera::Rest
|
17
|
-
# permissions
|
17
|
+
# node api permissions
|
18
18
|
ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
|
19
|
+
HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
|
20
|
+
SCOPE_SEPARATOR = ':'
|
21
|
+
SCOPE_USER = 'user:all'
|
22
|
+
SCOPE_ADMIN = 'admin:all'
|
23
|
+
SCOPE_NODE_PREFIX = 'node.'
|
19
24
|
# prefix for ruby code for filter (deprecated)
|
20
25
|
MATCH_EXEC_PREFIX = 'exec:'
|
21
26
|
MATCH_TYPES = [String, Proc, Regexp, NilClass].freeze
|
22
|
-
HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
|
23
27
|
PATH_SEPARATOR = '/'
|
24
|
-
TS_FIELDS_TO_COPY = %w[remote_host remote_user ssh_port fasp_port wss_enabled wss_port].freeze
|
25
|
-
SCOPE_USER = 'user:all'
|
26
|
-
SCOPE_ADMIN = 'admin:all'
|
27
|
-
SCOPE_PREFIX = 'node.'
|
28
|
-
SCOPE_SEPARATOR = ':'
|
29
28
|
SIGNATURE_DELIMITER = '==SIGNATURE=='
|
30
29
|
BEARER_TOKEN_VALIDITY_DEFAULT = 86400
|
31
30
|
BEARER_TOKEN_SCOPE_DEFAULT = SCOPE_USER
|
31
|
+
private_constant :MATCH_EXEC_PREFIX, :MATCH_TYPES,
|
32
|
+
:SIGNATURE_DELIMITER, :BEARER_TOKEN_VALIDITY_DEFAULT, :BEARER_TOKEN_SCOPE_DEFAULT,
|
33
|
+
:SCOPE_SEPARATOR, :SCOPE_NODE_PREFIX
|
32
34
|
|
33
35
|
# register node special token decoder
|
34
36
|
OAuth::Factory.instance.register_decoder(lambda{|token|Node.decode_bearer_token(token)})
|
@@ -62,14 +64,14 @@ module Aspera
|
|
62
64
|
|
63
65
|
# node API scopes
|
64
66
|
def token_scope(access_key, scope)
|
65
|
-
return [
|
67
|
+
return [SCOPE_NODE_PREFIX, access_key, SCOPE_SEPARATOR, scope].join('')
|
66
68
|
end
|
67
69
|
|
68
70
|
def decode_scope(scope)
|
69
71
|
items = scope.split(SCOPE_SEPARATOR, 2)
|
70
72
|
Aspera.assert(items.length.eql?(2)){"invalid scope: #{scope}"}
|
71
|
-
Aspera.assert(items[0].start_with?(
|
72
|
-
return {access_key: items[0][
|
73
|
+
Aspera.assert(items[0].start_with?(SCOPE_NODE_PREFIX)){"invalid scope: #{scope}"}
|
74
|
+
return {access_key: items[0][SCOPE_NODE_PREFIX.length..-1], scope: items[1]}
|
73
75
|
end
|
74
76
|
|
75
77
|
# Create an Aspera Node bearer token
|
@@ -134,6 +136,7 @@ module Aspera
|
|
134
136
|
@app_info = app_info
|
135
137
|
# this is added to transfer spec, for instance to add tags (COS)
|
136
138
|
@add_tspec = add_tspec
|
139
|
+
@std_t_spec_cache = nil
|
137
140
|
if !@app_info.nil?
|
138
141
|
REQUIRED_APP_INFO_FIELDS.each do |field|
|
139
142
|
Aspera.assert(@app_info.key?(field)){"app_info lacks field #{field}"}
|
@@ -205,7 +208,7 @@ module Aspera
|
|
205
208
|
end
|
206
209
|
end
|
207
210
|
end
|
208
|
-
end
|
211
|
+
end
|
209
212
|
|
210
213
|
# Navigate the path from given file id
|
211
214
|
# @param top_file_id [String] id initial file id
|
@@ -271,7 +274,23 @@ module Aspera
|
|
271
274
|
end
|
272
275
|
|
273
276
|
def refreshed_transfer_token
|
274
|
-
return
|
277
|
+
return oauth.token(refresh: true)
|
278
|
+
end
|
279
|
+
|
280
|
+
# @return part of transfer spec with transport parameters only
|
281
|
+
def transport_params
|
282
|
+
if @std_t_spec_cache.nil?
|
283
|
+
# retrieve values from API (and keep a copy/cache)
|
284
|
+
full_spec = create(
|
285
|
+
'files/download_setup',
|
286
|
+
{transfer_requests: [{transfer_request: {paths: [{source: '/'}]}}]}
|
287
|
+
)[:data]['transfer_specs'].first['transfer_spec']
|
288
|
+
# set available fields
|
289
|
+
@std_t_spec_cache = Transfer::Spec::TRANSPORT_FIELDS.each_with_object({}) do |i, h|
|
290
|
+
h[i] = full_spec[i] if full_spec.key?(i)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
return @std_t_spec_cache
|
275
294
|
end
|
276
295
|
|
277
296
|
# Create transfer spec for gen4
|
@@ -285,9 +304,9 @@ module Aspera
|
|
285
304
|
ak_token = Rest.basic_token(auth_params[:username], auth_params[:password])
|
286
305
|
when :oauth2
|
287
306
|
ak_name = params[:headers][HEADER_X_ASPERA_ACCESS_KEY]
|
288
|
-
# TODO: token_generation_lambda = lambda{|do_refresh|
|
307
|
+
# TODO: token_generation_lambda = lambda{|do_refresh|oauth.token(refresh: do_refresh)}
|
289
308
|
# get bearer token, possibly use cache
|
290
|
-
ak_token =
|
309
|
+
ak_token = oauth.token(refresh: false)
|
291
310
|
else Aspera.error_unexpected_value(auth_params[:type])
|
292
311
|
end
|
293
312
|
transfer_spec = {
|
@@ -327,13 +346,7 @@ module Aspera
|
|
327
346
|
transfer_spec[i] = settings[i] if settings.key?(i)
|
328
347
|
end if settings.is_a?(Hash)
|
329
348
|
else
|
330
|
-
|
331
|
-
@std_t_spec_cache ||= create(
|
332
|
-
'files/download_setup',
|
333
|
-
{transfer_requests: [{ transfer_request: {paths: [{'source' => '/'}] } }] }
|
334
|
-
)[:data]['transfer_specs'].first['transfer_spec']
|
335
|
-
# copy some parts
|
336
|
-
TS_FIELDS_TO_COPY.each {|i| transfer_spec[i] = @std_t_spec_cache[i] if @std_t_spec_cache.key?(i)}
|
349
|
+
transfer_spec.merge!(transport_params)
|
337
350
|
end
|
338
351
|
Log.log.warn{"Expected transfer user: #{Transfer::Spec::ACCESS_KEY_TRANSFER_USER}, but have #{transfer_spec['remote_user']}"} \
|
339
352
|
unless transfer_spec['remote_user'].eql?(Transfer::Spec::ACCESS_KEY_TRANSFER_USER)
|
data/lib/aspera/ascmd.rb
CHANGED
@@ -22,6 +22,7 @@ module Aspera
|
|
22
22
|
mv: 2,
|
23
23
|
rm: 1
|
24
24
|
}.freeze
|
25
|
+
private_constant :OPS_ARGS
|
25
26
|
# list of supported actions
|
26
27
|
OPERATIONS = OPS_ARGS.keys.freeze
|
27
28
|
|
@@ -94,7 +95,7 @@ module Aspera
|
|
94
95
|
raise Error.new(result[:errno], result[:errstr], action_sym, arguments) if
|
95
96
|
result.is_a?(Hash) && (result.keys.sort == TYPES_DESCR[:error][:fields].map{|i|i[:name]}.sort)
|
96
97
|
return result
|
97
|
-
end
|
98
|
+
end
|
98
99
|
|
99
100
|
# This exception is raised when +ascmd+ returns an error.
|
100
101
|
class Error < StandardError
|
@@ -103,7 +104,7 @@ module Aspera
|
|
103
104
|
|
104
105
|
def message; "ascmd: #{@errstr} (#{@errno})"; end
|
105
106
|
def extended_message; "ascmd: errno=#{@errno} errstr=\"#{@errstr}\" command=#{@command} arguments=#{@arguments&.join(',')}"; end
|
106
|
-
end
|
107
|
+
end
|
107
108
|
|
108
109
|
# description of result structures (see ascmdtypes.h). Base types are big endian
|
109
110
|
# key = name of type
|
@@ -211,7 +212,7 @@ module Aspera
|
|
211
212
|
end
|
212
213
|
end
|
213
214
|
else Aspera.error_unexpected_value(type_descr[:decode])
|
214
|
-
end
|
215
|
+
end
|
215
216
|
return result
|
216
217
|
end
|
217
218
|
end
|
@@ -173,8 +173,7 @@ module Aspera
|
|
173
173
|
return exe_version
|
174
174
|
end
|
175
175
|
|
176
|
-
def
|
177
|
-
data = file_paths
|
176
|
+
def ascp_add_pvcl(data)
|
178
177
|
# read PATHs from ascp directly, and pvcl modules as well
|
179
178
|
Open3.popen3(data['ascp'], '-DDL-') do |_stdin, _stdout, stderr, thread|
|
180
179
|
last_line = ''
|
@@ -202,15 +201,24 @@ module Aspera
|
|
202
201
|
raise last_line
|
203
202
|
end
|
204
203
|
end
|
205
|
-
|
204
|
+
end
|
205
|
+
|
206
|
+
# extract some stings from ascp binary
|
207
|
+
def ascp_add_openssl(data)
|
206
208
|
ascp_file = data['ascp']
|
207
|
-
File.binread(ascp_file).scan(/[\x20-\x7E]{
|
208
|
-
if (m =
|
209
|
+
File.binread(ascp_file).scan(/[\x20-\x7E]{10,}/) do |bin_string|
|
210
|
+
if (m = bin_string.match(/OPENSSLDIR.*"(.*)"/))
|
209
211
|
data['openssldir'] = m[1]
|
212
|
+
elsif (m = bin_string.match(/OpenSSL (\d[^ -]+)/))
|
213
|
+
data['openssl_version'] = m[1]
|
210
214
|
end
|
211
215
|
end if File.file?(ascp_file)
|
212
|
-
|
213
|
-
|
216
|
+
end
|
217
|
+
|
218
|
+
def ascp_info
|
219
|
+
data = file_paths
|
220
|
+
ascp_add_pvcl(data)
|
221
|
+
ascp_add_openssl(data)
|
214
222
|
return data
|
215
223
|
end
|
216
224
|
|
@@ -209,7 +209,7 @@ module Aspera
|
|
209
209
|
h[new_name] = value
|
210
210
|
end
|
211
211
|
end
|
212
|
-
end
|
212
|
+
end
|
213
213
|
|
214
214
|
def initialize
|
215
215
|
# current event being parsed line by line
|
@@ -236,7 +236,7 @@ module Aspera
|
|
236
236
|
return @last_event
|
237
237
|
else
|
238
238
|
raise "mgt port: unexpected line: [#{line}]"
|
239
|
-
end
|
239
|
+
end
|
240
240
|
return nil
|
241
241
|
end
|
242
242
|
end
|
@@ -8,10 +8,7 @@ module Aspera
|
|
8
8
|
# base class for applications supporting basic authentication
|
9
9
|
class BasicAuthPlugin < Cli::Plugin
|
10
10
|
class << self
|
11
|
-
|
12
|
-
def declare_options(options) # , force: false
|
13
|
-
#return if @@basic_options_declared && !force
|
14
|
-
#@@basic_options_declared = true # rubocop:disable Style/ClassVars
|
11
|
+
def declare_options(options)
|
15
12
|
options.declare(:url, 'URL of application, e.g. https://faspex.example.com/aspera/faspex')
|
16
13
|
options.declare(:username, "User's name to log in")
|
17
14
|
options.declare(:password, "User's password")
|
@@ -21,14 +18,13 @@ module Aspera
|
|
21
18
|
|
22
19
|
def initialize(basic_options: true, **env)
|
23
20
|
super(**env)
|
24
|
-
# , force: env[:all_manuals]
|
25
21
|
BasicAuthPlugin.declare_options(options) if basic_options
|
26
22
|
end
|
27
23
|
|
28
24
|
# returns a Rest object with basic auth
|
29
25
|
def basic_auth_params(subpath=nil)
|
30
26
|
api_url = options.get_option(:url, mandatory: true)
|
31
|
-
api_url = api_url
|
27
|
+
api_url = "#{api_url}/#{subpath}" unless subpath.nil?
|
32
28
|
return {
|
33
29
|
base_url: api_url,
|
34
30
|
auth: {
|
@@ -41,6 +37,6 @@ module Aspera
|
|
41
37
|
def basic_auth_api(subpath=nil)
|
42
38
|
return Rest.new(**basic_auth_params(subpath))
|
43
39
|
end
|
44
|
-
end
|
45
|
-
end
|
46
|
-
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -22,6 +22,10 @@ module Aspera
|
|
22
22
|
ALL = 'ALL'
|
23
23
|
DEF = 'DEF'
|
24
24
|
|
25
|
+
MARKER_START = '@'
|
26
|
+
MARKER_END = ':'
|
27
|
+
MARKER_IN_END = '@'
|
28
|
+
|
25
29
|
class << self
|
26
30
|
# decode comma separated table text
|
27
31
|
def decode_csvt(value)
|
@@ -61,10 +65,11 @@ module Aspera
|
|
61
65
|
list: lambda{|v|v[1..-1].split(v[0])},
|
62
66
|
none: lambda{|v|ExtendedValue.assert_no_value(v, :none); nil}, # rubocop:disable Style/Semicolon
|
63
67
|
path: lambda{|v|File.expand_path(v)},
|
64
|
-
re: lambda{|v|Regexp.new(v)},
|
68
|
+
re: lambda{|v|Regexp.new(v, Regexp::MULTILINE)},
|
65
69
|
ruby: lambda{|v|Environment.secure_eval(v, __FILE__, __LINE__)},
|
66
70
|
secret: lambda{|v|prompt = v.empty? ? 'secret' : v; $stdin.getpass("#{prompt}> ")}, # rubocop:disable Style/Semicolon
|
67
71
|
stdin: lambda{|v|ExtendedValue.assert_no_value(v, :stdin); $stdin.read}, # rubocop:disable Style/Semicolon
|
72
|
+
stdbin: lambda{|v|ExtendedValue.assert_no_value(v, :stdin); $stdin.binmode.read}, # rubocop:disable Style/Semicolon
|
68
73
|
yaml: lambda{|v|YAML.load(v)},
|
69
74
|
zlib: lambda{|v|Zlib::Inflate.inflate(v)},
|
70
75
|
extend: lambda{|v|ExtendedValue.instance.evaluate_all(v)}
|
@@ -84,14 +89,14 @@ module Aspera
|
|
84
89
|
|
85
90
|
# Regex to match an extended value
|
86
91
|
def ext_re
|
87
|
-
"
|
92
|
+
"#{MARKER_START}(#{modifiers.join('|')})#{MARKER_END}"
|
88
93
|
end
|
89
94
|
|
90
95
|
# parse an option value if it is a String using supported extended value modifiers
|
91
96
|
# other value types are returned as is
|
92
97
|
def evaluate(value)
|
93
98
|
return value unless value.is_a?(String)
|
94
|
-
regex = Regexp.new("^#{ext_re}(.*)$")
|
99
|
+
regex = Regexp.new("^#{ext_re}(.*)$", Regexp::MULTILINE)
|
95
100
|
# first determine decoders, in reversed order
|
96
101
|
handlers_reversed = []
|
97
102
|
while (m = value.match(regex))
|
@@ -101,18 +106,19 @@ module Aspera
|
|
101
106
|
# stop processing if handler is extend (it will be processed later)
|
102
107
|
break if handler.eql?(:extend)
|
103
108
|
end
|
109
|
+
Log.log.trace1{"evaluating: #{handlers_reversed}, value: #{value}"}
|
104
110
|
handlers_reversed.each do |handler|
|
105
111
|
value = @handlers[handler].call(value)
|
106
112
|
end
|
107
113
|
return value
|
108
|
-
end
|
114
|
+
end
|
109
115
|
|
110
116
|
# find inner extended values
|
111
117
|
def evaluate_all(value)
|
112
|
-
regex = Regexp.new("^(.*)#{ext_re}([
|
118
|
+
regex = Regexp.new("^(.*)#{ext_re}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)$", Regexp::MULTILINE)
|
113
119
|
while (m = value.match(regex))
|
114
120
|
sub_value = "@#{m[2]}:#{m[3]}"
|
115
|
-
Log.log.debug
|
121
|
+
Log.log.debug{"evaluating #{sub_value}"}
|
116
122
|
value = m[1] + evaluate(sub_value) + m[4]
|
117
123
|
end
|
118
124
|
return value
|