aspera-cli 4.27.3 → 4.27.5

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 (79) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +118 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +1 -0
  6. data/docs/README.md +224 -42
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +0 -4
  9. data/lib/aspera/agent/desktop.rb +0 -4
  10. data/lib/aspera/agent/direct.rb +49 -21
  11. data/lib/aspera/agent/node.rb +6 -5
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/faspex.rb +2 -0
  14. data/lib/aspera/api/httpgw.rb +1 -2
  15. data/lib/aspera/api/node.rb +3 -3
  16. data/lib/aspera/ascp/installation.rb +1 -1
  17. data/lib/aspera/cli/async_transfer_store.rb +10 -9
  18. data/lib/aspera/cli/bootstrapper.rb +3 -1
  19. data/lib/aspera/cli/command_registry.rb +69 -7
  20. data/lib/aspera/cli/command_spec.rb +1 -1
  21. data/lib/aspera/cli/extended_value.rb +4 -3
  22. data/lib/aspera/cli/formatter.rb +10 -8
  23. data/lib/aspera/cli/http.rb +8 -20
  24. data/lib/aspera/cli/option_types.rb +3 -1
  25. data/lib/aspera/cli/option_value.rb +16 -19
  26. data/lib/aspera/cli/options.schema.yaml +86 -10
  27. data/lib/aspera/cli/parser.rb +30 -18
  28. data/lib/aspera/cli/plugins/aoc.rb +133 -156
  29. data/lib/aspera/cli/plugins/ats.rb +1 -7
  30. data/lib/aspera/cli/plugins/base.rb +40 -34
  31. data/lib/aspera/cli/plugins/config.rb +24 -11
  32. data/lib/aspera/cli/plugins/console.rb +1 -1
  33. data/lib/aspera/cli/plugins/faspex5.rb +30 -11
  34. data/lib/aspera/cli/plugins/faspio.rb +1 -1
  35. data/lib/aspera/cli/plugins/node.rb +26 -19
  36. data/lib/aspera/cli/plugins/orchestrator.rb +73 -44
  37. data/lib/aspera/cli/plugins/preview.rb +21 -19
  38. data/lib/aspera/cli/plugins/server.rb +3 -4
  39. data/lib/aspera/cli/plugins/shares.rb +21 -24
  40. data/lib/aspera/cli/preset_actions.rb +28 -18
  41. data/lib/aspera/cli/preset_manager.rb +34 -19
  42. data/lib/aspera/cli/prompt.rb +2 -1
  43. data/lib/aspera/cli/result.rb +33 -22
  44. data/lib/aspera/cli/runner.rb +1 -5
  45. data/lib/aspera/cli/sync_actions.rb +17 -16
  46. data/lib/aspera/cli/transfer_actions.rb +14 -3
  47. data/lib/aspera/cli/transfer_agent.rb +6 -4
  48. data/lib/aspera/cli/transfer_progress.rb +290 -55
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +1 -1
  51. data/lib/aspera/coverage.rb +0 -1
  52. data/lib/aspera/environment.rb +29 -5
  53. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  54. data/lib/aspera/keychain/factory.rb +2 -1
  55. data/lib/aspera/log.rb +25 -2
  56. data/lib/aspera/node_emulator.rb +759 -0
  57. data/lib/aspera/oauth/base.rb +2 -1
  58. data/lib/aspera/oauth/factory.rb +6 -3
  59. data/lib/aspera/oauth/json_credentials.rb +34 -0
  60. data/lib/aspera/oauth/jwt.rb +3 -4
  61. data/lib/aspera/oauth.rb +1 -0
  62. data/lib/aspera/persistency_folder.rb +1 -3
  63. data/lib/aspera/preview/generator.rb +4 -1
  64. data/lib/aspera/preview/options.schema.yaml +119 -0
  65. data/lib/aspera/rest/aspera_errors.rb +12 -0
  66. data/lib/aspera/rest/client.rb +28 -19
  67. data/lib/aspera/rest/list.rb +14 -8
  68. data/lib/aspera/rest/parameters.rb +2 -2
  69. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  70. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  71. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  72. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  73. data/lib/aspera/schema/reader.rb +12 -18
  74. data/lib/aspera/schema/registry.rb +6 -1
  75. data.tar.gz.sig +0 -0
  76. metadata +5 -3
  77. metadata.gz.sig +0 -0
  78. data/lib/aspera/node_simulator.rb +0 -345
  79. data/lib/aspera/preview/options.rb +0 -45
@@ -0,0 +1,759 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore precalc noxfer euid gettime
4
+ require 'aspera/assert'
5
+ require 'aspera/mime'
6
+ require 'aspera/ascp/installation'
7
+ require 'aspera/agent/transferd'
8
+ require 'aspera/transfer/spec'
9
+ require 'aspera/transfer/error'
10
+ require 'aspera/secret_hider'
11
+ require 'aspera/string_ext'
12
+ require 'aspera/log'
13
+ require 'webrick'
14
+ require 'openssl'
15
+ require 'etc'
16
+ require 'securerandom'
17
+ require 'json'
18
+ require 'time'
19
+
20
+ module Aspera
21
+ # Node API transfers executed by transferd.
22
+ # Only transfers started through the emulator are known (kept in memory).
23
+ class NodeEmulator
24
+ # transferd status → Node API transfer status
25
+ STATUS = {
26
+ UNKNOWN_STATUS: 'waiting',
27
+ QUEUED: 'waiting',
28
+ RUNNING: 'running',
29
+ COMPLETED: 'completed',
30
+ FAILED: 'failed',
31
+ ORPHANED: 'failed',
32
+ CANCELED: 'canceled',
33
+ PAUSED: 'paused'
34
+ }.freeze
35
+ # transferd statuses after which no event is received
36
+ TERMINAL = %i[COMPLETED FAILED CANCELED ORPHANED].freeze
37
+ # Node API statuses of active transfers and sessions
38
+ ACTIVE = %w[waiting running].freeze
39
+ # transferd session status (lowercase) → Node API, others are only lowercased
40
+ SESSION_STATUS = {'draft' => 'waiting'}.freeze
41
+ # transferd file status (lowercase) → Node API, others are only lowercased
42
+ FILE_STATUS = {'transferring' => 'running', 'finished' => 'completed'}.freeze
43
+ # transferd field → Node API field, when not the snake case of it (`nil`: ignored)
44
+ TRANSFER_OVERRIDES = {'averageRateKbps' => 'avg_rate_kbps', 'errorDescription' => 'error_desc'}.freeze
45
+ SESSION_OVERRIDES = {'id' => nil, 'sessionId' => 'id'}.freeze
46
+ FILE_OVERRIDES = {'fileId' => 'id', 'errorDescription' => 'error_desc', 'fileType' => 'type', 'fileChecksumType' => 'checksum_type'}.freeze
47
+ # Node API fields taken as is from transferd (after renaming)
48
+ TRANSFER_FIELDS = %w[bytes_transferred bytes_written bytes_lost avg_rate_kbps files_completed directories_completed elapsed_usec].freeze
49
+ SESSION_FIELDS = %w[
50
+ id client_node_id server_node_id client_ip_address server_ip_address start_time_usec end_time_usec elapsed_usec
51
+ bytes_transferred bytes_written bytes_lost files_completed directories_completed
52
+ target_rate_kbps min_rate_kbps calc_rate_kbps network_delay_usec error_code error_desc
53
+ ].freeze
54
+ FILE_FIELDS = %w[id path start_time_usec elapsed_usec error_code error_desc size type checksum_type checksum start_byte bytes_written session_id].freeze
55
+ # Node API session `source_statistics` field → transferd session field
56
+ SOURCE_STATISTICS = {
57
+ 'args_scan_attempted' => 'arg_scans_attempted',
58
+ 'args_scan_completed' => 'arg_scans_completed',
59
+ 'paths_scan_attempted' => 'source_paths_scan_attempted',
60
+ 'paths_scan_failed' => 'source_paths_scan_failed',
61
+ 'paths_scan_excluded' => 'source_paths_scan_excluded',
62
+ 'files_scan_completed' => 'source_paths_scan_completed',
63
+ 'dirs_scan_completed' => 'dir_scans_completed',
64
+ 'dirs_xfer_attempted' => 'dir_creates_attempted',
65
+ 'dirs_xfer_fail' => 'dir_creates_failed',
66
+ 'files_xfer_attempted' => 'transfers_attempted',
67
+ 'files_xfer_fail' => 'transfers_failed',
68
+ 'files_xfer_noxfer' => 'transfers_skipped'
69
+ }.freeze
70
+ # Node API `precalc` field → transferd session field
71
+ PRECALC = {
72
+ 'bytes_expected' => 'pre_transfer_bytes',
73
+ 'files_expected' => 'pre_transfer_files',
74
+ 'directories_expected' => 'pre_transfer_dirs',
75
+ 'files_special' => 'pre_transfer_special'
76
+ }.freeze
77
+ # transferd fills fields `*TimeUsec` (except `elapsedUsec`) with milliseconds: values below are not microseconds
78
+ MAX_MSEC = 10**14
79
+ # Max wait for the first event of a new transfer
80
+ START_TIMEOUT_SEC = 10
81
+ # `File.ftype` → Node API item type, others are unchanged
82
+ FILE_TYPES = {'link' => 'symbolic_link'}.freeze
83
+ # Node API node information not provided by transferd
84
+ # The emulator has no access key, AEJ, watch folder, sync, file id API (gen4), `files/page` or file operations
85
+ INFO_STATIC = {
86
+ 'aej_status' => 'disconnected',
87
+ 'async_reporting' => 'no',
88
+ 'transfer_activity_reporting' => 'no',
89
+ 'acls' => [],
90
+ 'access_key_configuration_capabilities' => {'transfer' => [], 'server' => []},
91
+ 'capabilities' => [
92
+ {'name' => 'sync', 'value' => false},
93
+ {'name' => 'watchfolder', 'value' => false},
94
+ {'name' => 'symbolic_links', 'value' => false},
95
+ {'name' => 'move_file', 'value' => false},
96
+ {'name' => 'move_directory', 'value' => false},
97
+ {'name' => 'filelock', 'value' => false},
98
+ {'name' => 'ssh_fingerprint', 'value' => false},
99
+ {'name' => 'page', 'value' => false},
100
+ {'name' => 'auto_delete', 'value' => false}
101
+ ],
102
+ 'settings' => [
103
+ {'name' => 'content_protection_required', 'value' => false},
104
+ {'name' => 'content_protection_strong_pass_required', 'value' => false},
105
+ {'name' => 'filelock_restriction', 'value' => 'none'},
106
+ {'name' => 'ssh_fingerprint', 'value' => nil},
107
+ {'name' => 'wss_enabled', 'value' => false},
108
+ {'name' => 'wss_port', 'value' => 443}
109
+ ]
110
+ }.freeze
111
+ private_constant :STATUS, :TERMINAL, :ACTIVE, :SESSION_STATUS, :FILE_STATUS,
112
+ :TRANSFER_OVERRIDES, :SESSION_OVERRIDES, :FILE_OVERRIDES, :TRANSFER_FIELDS, :SESSION_FIELDS, :FILE_FIELDS,
113
+ :SOURCE_STATISTICS, :PRECALC, :MAX_MSEC, :START_TIMEOUT_SEC, :FILE_TYPES, :INFO_STATIC
114
+
115
+ class << self
116
+ # Convert a transferd transfer status to a Node API status
117
+ # @param status [Symbol] transferd `TransferStatus`
118
+ # @return [String] Node API transfer status
119
+ def node_status(status)
120
+ STATUS.fetch(status, 'waiting')
121
+ end
122
+
123
+ # Convert a transferd message to a hash with Node API field names
124
+ # @param message [Google::Protobuf::AbstractMessage] transferd message
125
+ # @param overrides [Hash] field name → Node API name (`nil`: ignored)
126
+ # @return [Hash] all fields (including default values), keys in snake case, time stamps in microseconds
127
+ def message_to_hash(message, overrides)
128
+ message.class.descriptor.each_with_object({}) do |field, hash|
129
+ key = overrides.fetch(field.name) { field.name.capital_to_snake }
130
+ next if key.nil?
131
+ value = message[field.name]
132
+ value *= 1000 if key.end_with?('_time_usec') && value.positive? && value < MAX_MSEC
133
+ hash[key] = value
134
+ end
135
+ end
136
+
137
+ # Update a store entry with a transferd event
138
+ # @param entry [Hash] store entry
139
+ # @param response [Transferd::Api::TransferResponse] transferd event
140
+ def update_entry(entry, response)
141
+ entry[:status] = response.status
142
+ entry[:info] = response.transferInfo unless response.transferInfo.nil?
143
+ session = response.sessionInfo
144
+ entry[:sessions][session.sessionId] = session unless session.nil? || session.sessionId.empty?
145
+ # event ARG_STOP has a file without id
146
+ file = response.fileInfo
147
+ entry[:files][file.fileId] = file unless file.nil? || file.fileId.empty?
148
+ entry[:error] = response.error.description unless response.error.nil? || response.error.description.empty?
149
+ # session information keeps the initial rates, the ascp management message has the current ones
150
+ entry[:rates][session.sessionId] = management_rates(response.message) if response.transferEvent.eql?(:RATE_MODIFICATION) && entry[:sessions].key?(session&.sessionId)
151
+ end
152
+
153
+ # Build a Node API transfer from a store entry
154
+ # @param id [String] transfer id
155
+ # @param entry [Hash] store entry
156
+ # @param node_id [String] node id of the emulator
157
+ # @return [Hash] Node API transfer (`transferResponseSessionSpec`)
158
+ def transfer_to_node(id, entry, node_id: '')
159
+ info = message_to_hash(entry[:info] || ::Transferd::Api::TransferInfo.new, TRANSFER_OVERRIDES)
160
+ terminal = TERMINAL.include?(entry[:status])
161
+ retry_timeout = xfer_retry(entry[:start_spec])
162
+ sessions = entry[:sessions].map { |session_id, session| session_to_node(session, retry_timeout: retry_timeout, node_id: node_id).merge(entry[:rates].fetch(session_id, {})) }
163
+ error_desc = info['error_desc'].strip
164
+ error_desc = entry[:error].to_s if error_desc.empty?
165
+ precalc = PRECALC.keys.to_h { |field| [field, sessions.sum { |session| session['precalc'][field] }] }
166
+ info.slice(*TRANSFER_FIELDS).merge(
167
+ 'id' => id,
168
+ 'status' => node_status(entry[:status]),
169
+ 'start_spec' => entry[:start_spec],
170
+ 'sessions' => sessions,
171
+ # transferd does not provide the start time of the transfer
172
+ 'start_time_usec' => sessions.map { |session| session['start_time_usec'] }.select(&:positive?).min || 0,
173
+ 'end_time_usec' => terminal ? info['end_time_usec'] : 0,
174
+ 'error_code' => info['error_code'].to_i,
175
+ 'error_desc' => error_desc,
176
+ 'precalc' => precalc.merge(
177
+ 'enabled' => sessions.any? { |session| session['precalc']['enabled'] },
178
+ 'status' => precalc_status(precalc['bytes_expected'], terminal)
179
+ ),
180
+ 'files' => entry[:files].values.map { |file| file_to_node(file) }
181
+ )
182
+ end
183
+
184
+ # Convert a transferd session to a Node API session
185
+ # @param session [Transferd::Api::SessionTransferInformation]
186
+ # @param retry_timeout [Integer] from the transfer spec
187
+ # @param node_id [String] node id of the emulator
188
+ # @return [Hash] Node API session (`transferResponseSessions`)
189
+ def session_to_node(session, retry_timeout: 0, node_id: '')
190
+ source = message_to_hash(session, SESSION_OVERRIDES)
191
+ status = session.status.downcase
192
+ status = SESSION_STATUS.fetch(status, status)
193
+ result = source.slice(*SESSION_FIELDS)
194
+ # transferd provides the address of the remote side only: the server
195
+ result['server_ip_address'] = source['remote_address'] if result['server_ip_address'].empty?
196
+ # transferd provides the node id of the remote side only: the local side is the emulator
197
+ result['client_node_id'] = node_id if result['client_node_id'].empty?
198
+ precalc = PRECALC.transform_values { |field| source[field] }
199
+ result.merge(
200
+ 'status' => status,
201
+ 'retry_count' => 0,
202
+ 'retry_timeout' => retry_timeout,
203
+ 'stalled' => false,
204
+ 'avg_rate_kbps' => source['elapsed_usec'].positive? ? (source['bytes_transferred'] * 8000.0 / source['elapsed_usec']).round(2) : 0,
205
+ 'source_statistics' => SOURCE_STATISTICS.transform_values { |field| source[field] },
206
+ 'precalc' => precalc.merge(
207
+ 'enabled' => session.precalc.casecmp?('yes'),
208
+ 'status' => precalc_status(precalc['bytes_expected'], !ACTIVE.include?(status))
209
+ )
210
+ )
211
+ end
212
+
213
+ # Convert a transferd file to a Node API file
214
+ # @param file [Transferd::Api::FileTransferInformation]
215
+ # @return [Hash] Node API file (`fileMetadata`)
216
+ def file_to_node(file)
217
+ result = message_to_hash(file, FILE_OVERRIDES).slice(*FILE_FIELDS)
218
+ status = file.status.downcase
219
+ result['status'] = FILE_STATUS.fetch(status, status)
220
+ terminal = !result['status'].eql?('running') && result['start_time_usec'].positive?
221
+ result['end_time_usec'] = terminal ? result['start_time_usec'] + result['elapsed_usec'] : 0
222
+ result
223
+ end
224
+
225
+ # Build Node API node information from transferd instance information
226
+ # @param info [Transferd::Api::InstanceInfo]
227
+ # @param node_id [String] node id of the emulator
228
+ # @param cluster_id [String] cluster id of the emulator
229
+ # @param docroot [String] real path of the docroot
230
+ # @param transfer_user [String] system user executing transfers
231
+ # @return [Hash] Node API node information (`info-get-200`)
232
+ def info_to_node(info, node_id:, cluster_id:, docroot:, transfer_user:)
233
+ ascp = info.asperaInfo.find { |binary| binary.asperaBinary.eql?('ascp') } || ::Transferd::Api::AsperaInfo.new
234
+ license = info.licenseInfo || ::Transferd::Api::LicenseInfo.new
235
+ {
236
+ 'application' => 'node',
237
+ # without build id
238
+ 'version' => ascp.asperaVersion.sub(/ .*$/, ''),
239
+ 'current_time' => Time.now.utc.iso8601(0),
240
+ 'license_expiration_date' => license.license[%r{<expiration_date>([^<]*)</expiration_date>}, 1].to_s,
241
+ 'license_max_rate' => license.maxRate,
242
+ 'os' => ascp.operatingSystem,
243
+ 'node_id' => node_id,
244
+ 'cluster_id' => cluster_id,
245
+ 'transfer_user' => transfer_user,
246
+ 'docroot' => "file:///#{docroot}"
247
+ }.merge(INFO_STATIC)
248
+ end
249
+
250
+ private
251
+
252
+ # Extract the current rates from an ascp management message
253
+ # @param message [String] ascp management message of event `RATE_MODIFICATION` (JSON)
254
+ # @return [Hash] Node API session rates found in the message
255
+ def management_rates(message)
256
+ management = JSON.parse(message)
257
+ {'target_rate_kbps' => management['Rate'], 'min_rate_kbps' => management['MinRate']}.compact.transform_values(&:to_i)
258
+ rescue JSON::ParserError
259
+ {}
260
+ end
261
+
262
+ # Compute the status of the pre-calculation of the transfer size
263
+ # @param bytes_expected [Integer] size of the transfer, 0 if not known yet
264
+ # @param terminal [Boolean] `true` if the transfer or session has ended
265
+ # @return [String] `ready` when the size is known, else `pending`
266
+ def precalc_status(bytes_expected, terminal)
267
+ bytes_expected.positive? || terminal ? 'ready' : 'pending'
268
+ end
269
+
270
+ # Get the retry timeout from the tags of the transfer spec
271
+ # @param start_spec [Hash] transfer spec
272
+ # @return [Integer] retry timeout set by `Agent::Node`, or 0
273
+ def xfer_retry(start_spec)
274
+ reserved = start_spec['tags'][Transfer::Spec::TAG_RESERVED] if start_spec['tags'].is_a?(Hash)
275
+ reserved.is_a?(Hash) ? reserved['xfer_retry'].to_i : 0
276
+ end
277
+ end
278
+
279
+ # Create an emulator, start a transferd daemon if no client is given
280
+ # @param docroot [String, nil] folder of the node files, default: current folder
281
+ # @param transfer_client [Transferd::Api::TransferService::Stub, nil] gRPC client, default: start a transferd daemon
282
+ # @param retention_sec [Integer] time a transfer is kept after it ended (CLI default in schema)
283
+ def initialize(retention_sec:, docroot: nil, transfer_client: nil)
284
+ Aspera.assert(retention_sec.is_a?(Integer) && retention_sec.positive?) { "retention_sec must be a positive integer: #{retention_sec.inspect}" }
285
+ # paths of browse and local paths of transfers are relative to it
286
+ @docroot = File.realpath(docroot || Dir.pwd)
287
+ # the daemon is stopped at exit by the agent
288
+ @transfer_client = transfer_client || Agent::Transferd.new.transfer_client
289
+ @retention_sec = retention_sec
290
+ # transfer id → store entry: `start_spec` (secrets hidden), last `status`, last `info`, `sessions` by id, `files` by id, current `rates` by session id, `error`,
291
+ # `ended` (monotonic time of the end), accessed with `store`
292
+ @transfers = {}
293
+ # WEBrick serves each request in a thread, monitoring runs in threads
294
+ @mutex = Mutex.new
295
+ # identifiers of the simulated node, in node information and in sessions
296
+ @node_id = SecureRandom.uuid
297
+ @cluster_id = SecureRandom.uuid
298
+ # transferd runs as the user of the emulator
299
+ @transfer_user = Etc.getpwuid(Process.euid)&.name || Etc.getlogin.to_s
300
+ end
301
+
302
+ # Get the node information
303
+ # @return [Hash] Node API node information, from transferd
304
+ def info
305
+ response = @transfer_client.get_info(::Transferd::Api::InstanceInfoRequest.new)
306
+ Log.dump(:get_info_response, response.to_h, level: :trace2)
307
+ Aspera.assert(response.error.nil? || response.error.description.empty?, type: RuntimeError) { response.error.description }
308
+ self.class.info_to_node(response.info || ::Transferd::Api::InstanceInfo.new, node_id: @node_id, cluster_id: @cluster_id, docroot: @docroot, transfer_user: @transfer_user)
309
+ end
310
+
311
+ # List a folder of the docroot
312
+ # @param path [String] folder to list, relative to the docroot
313
+ # @param skip [Integer] number of items to skip (paging)
314
+ # @param count [Integer] max number of items returned (paging)
315
+ # @return [Hash] Node API folder content, paths relative to the docroot
316
+ def browse(path, skip: 0, count: nil)
317
+ folder_path = real_path(path)
318
+ # symbolic links shall not lead out of the docroot
319
+ Aspera.assert(in_docroot?(File.realpath(folder_path))) { "Path outside docroot: #{path}" }
320
+ Aspera.assert(File.directory?(folder_path)) { "Not a folder: #{path}" }
321
+ folder_virtual = virtual_path(folder_path)
322
+
323
+ # Build self structure
324
+ folder_stat = File.stat(folder_path)
325
+ structure = {
326
+ 'self' => {
327
+ 'path' => folder_virtual,
328
+ 'basename' => File.basename(folder_path),
329
+ 'type' => 'directory',
330
+ # Node API reports 0 for folders
331
+ 'size' => 0,
332
+ 'mtime' => folder_stat.mtime.utc.iso8601,
333
+ 'permissions' => permissions(folder_path)
334
+ },
335
+ 'items' => []
336
+ }
337
+
338
+ # Iterate over folder contents
339
+ Dir.foreach(folder_path) do |entry|
340
+ next if entry == '.' || entry == '..' # Skip current and parent directory
341
+
342
+ item_path = File.join(folder_path, entry)
343
+ item_type = File.ftype(item_path) rescue 'unknown' # Get the type of file
344
+ item_type = FILE_TYPES.fetch(item_type, item_type)
345
+ item_stat = File.lstat(item_path) # Use lstat to handle symbolic links correctly
346
+
347
+ item = {
348
+ 'path' => File.join(folder_virtual, entry),
349
+ 'basename' => entry,
350
+ 'type' => item_type,
351
+ 'size' => item_type.eql?('directory') ? 0 : item_stat.size,
352
+ 'mtime' => item_stat.mtime.utc.iso8601,
353
+ 'permissions' => permissions(item_path)
354
+ }
355
+
356
+ # Add additional details for specific types
357
+ case item_type
358
+ when 'file'
359
+ item['partial_file'] = false
360
+ when 'symbolic_link'
361
+ item['target'] = link_target(item_path)
362
+ when 'unknown'
363
+ item['note'] = 'File type could not be determined'
364
+ end
365
+
366
+ structure['items'] << item
367
+ end
368
+
369
+ # stable order for paging
370
+ all_items = structure['items'].sort_by { |item| item['basename'] }
371
+ structure['items'] = all_items.drop(skip).first(count || all_items.length)
372
+ structure['item_count'] = structure['items'].length
373
+ structure['total_count'] = all_items.length
374
+ structure
375
+ end
376
+
377
+ # Start a transfer, and monitor it in a thread until terminated
378
+ # @param transfer_spec [Hash] transfer spec, local paths relative to the docroot (modified)
379
+ # @return [String] transfer id
380
+ def start(transfer_spec)
381
+ Transfer::Spec.fix_transferd_resume_policy(transfer_spec)
382
+ # deep copy, returned in transfer `start_spec`
383
+ start_spec = SecretHider.instance.deep_remove_secret(JSON.parse(transfer_spec.to_json))
384
+ Log.dump(:start_spec, start_spec)
385
+ request = ::Transferd::Api::TransferRequest.new(
386
+ transferType: ::Transferd::Api::TransferType::FILE_REGULAR,
387
+ config: ::Transferd::Api::TransferConfig.new,
388
+ transferSpec: apply_docroot(transfer_spec).to_json
389
+ )
390
+ first_event = Thread::Queue.new
391
+ Thread.new { monitor(request, start_spec, first_event) }
392
+ result = first_event.pop(timeout: START_TIMEOUT_SEC)
393
+ raise Transfer::Error, "No event from transferd after #{START_TIMEOUT_SEC}s" if result.nil?
394
+ raise result if result.is_a?(Exception)
395
+ return result
396
+ end
397
+
398
+ # Get a transfer started by the emulator
399
+ # @param id [String] transfer id
400
+ # @return [Hash, nil] Node API transfer, `nil` if unknown
401
+ def transfer(id)
402
+ entry = store do |transfers|
403
+ found = transfers[id]
404
+ found&.merge(sessions: found[:sessions].dup, files: found[:files].dup, rates: found[:rates].dup)
405
+ end
406
+ return if entry.nil?
407
+ result = self.class.transfer_to_node(id, entry, node_id: @node_id)
408
+ # transferd gives real paths of local files
409
+ result['files'].each { |file| file['path'] = virtual_path(file['path']) if in_docroot?(file['path']) }
410
+ result
411
+ end
412
+
413
+ # List the transfers started by the emulator
414
+ # @param active_only [Boolean, nil] `true`: only waiting or running, `false`: only terminated, `nil`: all
415
+ # @param direction [String, nil] `send` or `receive`
416
+ # @param count [Integer, nil] max number of transfers, oldest first
417
+ # @return [Array<Hash>] Node API transfers
418
+ def transfers(active_only: nil, direction: nil, count: nil)
419
+ # a transfer may expire between the two calls
420
+ result = store(&:keys).filter_map { |id| transfer(id) }
421
+ result.select! { |transfer| ACTIVE.include?(transfer['status']).eql?(active_only) } unless active_only.nil?
422
+ result.select! { |transfer| transfer['start_spec']['direction'].eql?(direction) } unless direction.nil?
423
+ count.nil? ? result : result.first(count)
424
+ end
425
+
426
+ # Stop a transfer: its status becomes `canceled` when transferd notifies it
427
+ # @param id [String] transfer id
428
+ # @return [Boolean] `false` if unknown
429
+ def cancel(id)
430
+ return false unless known?(id)
431
+ response = @transfer_client.stop_transfer(::Transferd::Api::StopTransferRequest.new(transferId: [id]))
432
+ Log.dump(:stop_response, response.to_h, level: :trace2)
433
+ result = response.stopResult.find { |info| info.transferId.eql?(id) }
434
+ Aspera.assert(result&.stopped, type: Transfer::Error) { result&.error&.description || 'Transfer not stopped by transferd' }
435
+ true
436
+ end
437
+
438
+ # Modify the rates or the policy of a transfer
439
+ # @param id [String] transfer id
440
+ # @param changes [Hash] new values of transfer spec fields: `target_rate_kbps`, `min_rate_kbps`, `rate_policy`
441
+ # @return [Boolean] `false` if unknown
442
+ def modify(id, changes)
443
+ return false unless known?(id)
444
+ response = @transfer_client.modify_transfer(::Transferd::Api::TransferModificationRequest.new(transferId: id, transferSpec: changes.to_json))
445
+ Log.dump(:modify_response, response.to_h, level: :trace2)
446
+ Aspera.assert(response.error.nil? || response.error.description.empty?, type: Transfer::Error) { response.error.description }
447
+ true
448
+ end
449
+
450
+ private
451
+
452
+ # Check if a transfer is known
453
+ # @param id [String] transfer id
454
+ # @return [Boolean] `true` if the transfer was started by the emulator
455
+ def known?(id)
456
+ store { |transfers| transfers.key?(id) }
457
+ end
458
+
459
+ # Lock the store, and remove the transfers ended for longer than the retention time
460
+ # @yieldparam transfers [Hash] transfer id → store entry
461
+ # @return [Object] result of the block
462
+ def store
463
+ @mutex.synchronize do
464
+ now = monotonic_time
465
+ @transfers.delete_if { |_, entry| entry.key?(:ended) && now - entry[:ended] >= @retention_sec }
466
+ yield(@transfers)
467
+ end
468
+ end
469
+
470
+ # @return [Float] current time in seconds, for durations
471
+ def monotonic_time
472
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
473
+ end
474
+
475
+ # Convert a Node API path to a real path
476
+ # @param path [String] path relative to the docroot, leading `/` optional
477
+ # @return [String] real path, within the docroot
478
+ def real_path(path)
479
+ # `./` prevents expansion of `~`
480
+ real = File.expand_path("./#{path.to_s.sub(%r{\A/+}, '')}", @docroot)
481
+ Aspera.assert(in_docroot?(real)) { "Path outside docroot: #{path}" }
482
+ real
483
+ end
484
+
485
+ # Convert a real path to a Node API path
486
+ # @param real [String] real path within the docroot
487
+ # @return [String] path relative to the docroot, with leading `/`
488
+ def virtual_path(real)
489
+ "/#{real.delete_prefix(@docroot).delete_prefix('/')}"
490
+ end
491
+
492
+ # Check if a real path is within the docroot
493
+ # @param real [String] real path
494
+ # @return [Boolean] `true` if the path is the docroot or inside it
495
+ def in_docroot?(real)
496
+ real.eql?(@docroot) || real.start_with?(@docroot.end_with?('/') ? @docroot : "#{@docroot}/")
497
+ end
498
+
499
+ # Get the Node API permissions of an item
500
+ # @param real [String] real path within the docroot
501
+ # @return [Array<Hash>] Node API permissions, from the access of the emulator on the item
502
+ def permissions(real)
503
+ names = []
504
+ names.push('view') if File.readable?(real)
505
+ names.push('edit') if File.writable?(real)
506
+ # the docroot itself cannot be deleted
507
+ names.push('delete') if !real.eql?(@docroot) && File.writable?(File.dirname(real))
508
+ names.map { |name| {'name' => name} }
509
+ end
510
+
511
+ # Get the Node API target of a symbolic link
512
+ # @param link [String] real path of a symbolic link within the docroot
513
+ # @return [Hash] Node API target of the link: only the content of the link if it is dangling or leads out of the docroot
514
+ def link_target(link)
515
+ target = File.realpath(link) rescue nil
516
+ unless target && in_docroot?(target)
517
+ content = File.readlink(link)
518
+ return {'path' => content, 'basename' => File.basename(content)}
519
+ end
520
+ stat = File.stat(target)
521
+ {
522
+ 'path' => virtual_path(target),
523
+ 'basename' => File.basename(target),
524
+ 'type' => File.ftype(target),
525
+ 'size' => stat.directory? ? 0 : stat.size
526
+ }
527
+ end
528
+
529
+ # Local paths of the transfer spec (sources for `send`, destination for `receive`) are relative to the docroot
530
+ # @param transfer_spec [Hash] modified: local paths are replaced with real paths
531
+ # @return [Hash] the transfer spec
532
+ def apply_docroot(transfer_spec)
533
+ receive = transfer_spec['direction'].eql?(Transfer::Spec::DIRECTION_RECEIVE)
534
+ root_field, path_field = receive ? %w[destination_root destination] : %w[source_root source]
535
+ # a download without destination goes to the docroot
536
+ root = transfer_spec[root_field] || ('/' if receive)
537
+ if root.nil?
538
+ transfer_spec['paths']&.each { |item| item[path_field] = real_path(item[path_field]) if item.key?(path_field) }
539
+ else
540
+ transfer_spec[root_field] = real_path(root)
541
+ # paths are relative to the root: they shall not lead out of the docroot
542
+ transfer_spec['paths']&.each { |item| real_path(File.join(root, item[path_field])) if item.key?(path_field) }
543
+ end
544
+ transfer_spec
545
+ end
546
+
547
+ # Start the transfer and update the store with its events, until terminated.
548
+ # Runs in a thread.
549
+ # @param request [Transferd::Api::TransferRequest]
550
+ # @param start_spec [Hash] transfer spec without secrets
551
+ # @param first_event [Thread::Queue] receives the transfer id, or the exception if the transfer could not start
552
+ def monitor(request, start_spec, first_event)
553
+ id = nil
554
+ @transfer_client.start_transfer_with_monitor(request) do |response|
555
+ Log.dump(:transferd_event, response.to_h, level: :trace2)
556
+ new_transfer = id.nil?
557
+ if new_transfer
558
+ Aspera.assert(!response.transferId.empty?, type: Transfer::Error) { response.error&.description || 'No transfer id from transferd' }
559
+ id = response.transferId
560
+ end
561
+ store do |transfers|
562
+ entry = (transfers[id] ||= {start_spec: start_spec, sessions: {}, files: {}, rates: {}})
563
+ self.class.update_entry(entry, response)
564
+ # no more event, and so no more retry: retention starts
565
+ entry[:ended] = monotonic_time if TERMINAL.include?(entry[:status])
566
+ end
567
+ first_event.push(id) if new_transfer
568
+ break if TERMINAL.include?(response.status)
569
+ end
570
+ first_event.push(Transfer::Error.new('transferd closed the stream without event')) if id.nil?
571
+ rescue StandardError => e
572
+ if id.nil?
573
+ first_event.push(e)
574
+ else
575
+ Log.log.error { "Transfer #{id}: #{e.message}" }
576
+ store { |transfers| transfers[id].merge!(status: :FAILED, error: e.message, ended: monotonic_time) }
577
+ end
578
+ end
579
+ end
580
+
581
+ # Answers a subset of the Node API, transfers are delegated to a NodeEmulator
582
+ # a new instance is created for each request
583
+ class NodeEmulatorServlet < WEBrick::HTTPServlet::AbstractServlet
584
+ PATH_TRANSFERS = '/ops/transfers'
585
+ PATH_ONE_TRANSFER = %r{/ops/transfers/(.+)$}
586
+ PATH_BROWSE = '/files/browse'
587
+ REALM = 'Aspera Node Emulator'
588
+ # `PUT` values of `status` that cancel the transfer (pause and resume are not supported)
589
+ CANCEL_STATUSES = %w[canceled cancelled stopped].freeze
590
+ # `PUT` fields modified by transferd
591
+ MODIFIABLE = %w[target_rate_kbps min_rate_kbps rate_policy].freeze
592
+ # Create the servlet, with optional Basic authentication
593
+ # @param server [WEBrick::HTTPServer]
594
+ # @param config [Hash] `username` and `password` (Basic authentication expected from clients, optional)
595
+ # @param simulator [NodeEmulator]
596
+ def initialize(server, config, simulator)
597
+ super(server)
598
+ @simulator = simulator
599
+ @expected_auth = "#{config[:username]}:#{config[:password]}" unless config[:username].nil?
600
+ end
601
+
602
+ # Check authentication, dispatch to `do_<verb>`, and send errors in Node API format
603
+ # @param request [WEBrick::HTTPRequest]
604
+ # @param response [WEBrick::HTTPResponse]
605
+ def service(request, response)
606
+ unless authorized?(request)
607
+ response['WWW-Authenticate'] = %Q(Basic realm="#{REALM}")
608
+ return set_error(request, response, 401, 'Invalid or missing credentials')
609
+ end
610
+ super
611
+ rescue WEBrick::HTTPStatus::Error => e
612
+ set_error(request, response, e.code, e.message)
613
+ rescue JSON::ParserError, AssertError, Transfer::Error => e
614
+ set_error(request, response, 400, e.message)
615
+ rescue Errno::ENOENT => e
616
+ set_error(request, response, 404, e.message)
617
+ rescue StandardError => e
618
+ Log.log.error { "#{request.request_method} #{request.path}: #{e.class}: #{e.message}" }
619
+ set_error(request, response, 500, e.message)
620
+ end
621
+
622
+ # Start a transfer, or list a folder
623
+ # @param request [WEBrick::HTTPRequest]
624
+ # @param response [WEBrick::HTTPResponse]
625
+ def do_POST(request, response)
626
+ case request.path
627
+ when PATH_TRANSFERS
628
+ set_json_response(request, response, @simulator.transfer(@simulator.start(JSON.parse(request.body))))
629
+ when PATH_BROWSE
630
+ req = JSON.parse(request.body)
631
+ set_json_response(request, response, @simulator.browse(req['path'] || '/', skip: req['skip'].to_i, count: req['count']&.to_i))
632
+ else
633
+ set_error(request, response, 404, "Unknown path: #{request.path}")
634
+ end
635
+ end
636
+
637
+ # Get the node information, the list of transfers, or one transfer
638
+ # @param request [WEBrick::HTTPRequest]
639
+ # @param response [WEBrick::HTTPResponse]
640
+ def do_GET(request, response)
641
+ case request.path
642
+ when '/info'
643
+ set_json_response(request, response, @simulator.info)
644
+ when PATH_TRANSFERS
645
+ set_json_response(request, response, @simulator.transfers(
646
+ active_only: query_boolean(request, 'active_only'),
647
+ direction: request.query['direction']&.to_s,
648
+ count: query_positive(request, 'count')
649
+ ))
650
+ when PATH_ONE_TRANSFER
651
+ transfer = @simulator.transfer(request.path.match(PATH_ONE_TRANSFER)[1])
652
+ if transfer.nil?
653
+ set_error(request, response, 404, 'Unknown transfer')
654
+ else
655
+ set_json_response(request, response, transfer)
656
+ end
657
+ else
658
+ set_error(request, response, 404, "Unknown path: #{request.path}")
659
+ end
660
+ end
661
+
662
+ # Modify a transfer, or cancel it with `status`
663
+ # @param request [WEBrick::HTTPRequest]
664
+ # @param response [WEBrick::HTTPResponse]
665
+ def do_PUT(request, response)
666
+ id = transfer_id(request)
667
+ changes = JSON.parse(request.body.to_s)
668
+ raise WEBrick::HTTPStatus::BadRequest, 'Body must be a JSON object' unless changes.is_a?(Hash)
669
+ status = changes.delete('status')
670
+ if status.nil?
671
+ unsupported = changes.keys - MODIFIABLE
672
+ raise WEBrick::HTTPStatus::BadRequest, "Cannot modify: #{unsupported.join(', ')}" unless unsupported.empty?
673
+ raise WEBrick::HTTPStatus::BadRequest, 'Nothing to modify' if changes.empty?
674
+ found = @simulator.modify(id, changes)
675
+ else
676
+ raise WEBrick::HTTPStatus::BadRequest, "Unsupported status: #{status}" unless CANCEL_STATUSES.include?(status)
677
+ found = @simulator.cancel(id)
678
+ end
679
+ return set_error(request, response, 404, 'Unknown transfer') unless found
680
+ set_json_response(request, response, @simulator.transfer(id))
681
+ end
682
+
683
+ # Node API cancels a transfer with HTTP verb `CANCEL`
684
+ # @param request [WEBrick::HTTPRequest]
685
+ # @param response [WEBrick::HTTPResponse]
686
+ def do_CANCEL(request, response)
687
+ return set_error(request, response, 404, 'Unknown transfer') unless @simulator.cancel(transfer_id(request))
688
+ response.status = 204
689
+ end
690
+
691
+ private
692
+
693
+ # Extract the transfer id from the path of the request
694
+ # @param request [WEBrick::HTTPRequest]
695
+ # @return [String] transfer id
696
+ # @raise [WEBrick::HTTPStatus::NotFound] if the path is not the one of a transfer
697
+ def transfer_id(request)
698
+ match = request.path.match(PATH_ONE_TRANSFER)
699
+ raise WEBrick::HTTPStatus::NotFound, "Unknown path: #{request.path}" if match.nil?
700
+ match[1]
701
+ end
702
+
703
+ # Read a boolean query parameter
704
+ # @param request [WEBrick::HTTPRequest]
705
+ # @param name [String] name of the query parameter
706
+ # @return [Boolean, nil] value, `nil` if absent
707
+ # @raise [WEBrick::HTTPStatus::BadRequest] if the value is not `true` or `false`
708
+ def query_boolean(request, name)
709
+ value = request.query[name]&.to_s
710
+ return if value.nil?
711
+ raise WEBrick::HTTPStatus::BadRequest, "Query #{name} must be true or false: #{value}" unless %w[true false].include?(value)
712
+ value.eql?('true')
713
+ end
714
+
715
+ # Read a positive integer query parameter
716
+ # @param request [WEBrick::HTTPRequest]
717
+ # @param name [String] name of the query parameter
718
+ # @return [Integer, nil] value, `nil` if absent
719
+ # @raise [WEBrick::HTTPStatus::BadRequest] if the value is not a positive integer
720
+ def query_positive(request, name)
721
+ value = request.query[name]&.to_s
722
+ return if value.nil?
723
+ number = Integer(value, exception: false)
724
+ raise WEBrick::HTTPStatus::BadRequest, "Query #{name} must be a positive integer: #{value}" unless number&.positive?
725
+ number
726
+ end
727
+
728
+ # Set error body in Node API format
729
+ # @param request [WEBrick::HTTPRequest]
730
+ # @param response [WEBrick::HTTPResponse]
731
+ # @param code [Integer] HTTP status code
732
+ # @param message [String] message for the user
733
+ def set_error(request, response, code, message)
734
+ set_json_response(request, response, {error: {code: code, reason: WEBrick::HTTPStatus.reason_phrase(code), user_message: message}}, code: code)
735
+ end
736
+
737
+ # Check the Basic credentials of the request
738
+ # @param request [WEBrick::HTTPRequest]
739
+ # @return [Boolean] `true` if the request has the expected Basic credentials, or if none are expected
740
+ def authorized?(request)
741
+ return true if @expected_auth.nil?
742
+ scheme, value = request['Authorization'].to_s.split(' ', 2)
743
+ # constant time comparison
744
+ scheme.to_s.casecmp?('Basic') && OpenSSL.secure_compare(value.to_s.unpack1('m'), @expected_auth)
745
+ end
746
+
747
+ # Set the status and the JSON body of the response
748
+ # @param request [WEBrick::HTTPRequest] for the log
749
+ # @param response [WEBrick::HTTPResponse]
750
+ # @param json [Hash, Array] body of the response
751
+ # @param code [Integer] HTTP status code
752
+ def set_json_response(request, response, json, code: 200)
753
+ response.status = code
754
+ response['Content-Type'] = Mime::JSON
755
+ response.body = json.to_json
756
+ Log.log.trace1 { Log.obj_dump("response for #{request.request_method} #{request.path}", json) }
757
+ end
758
+ end
759
+ end